
Urdf
- 5k installs
- 11k repo stars
- Updated July 11, 2026
- earthtojake/text-to-cad
URDF robot description generation from Python sources with frame-correct semantics, unit consistency, geometry/mesh/inertial validation, and generation-time error detection.
About
URDF skill generates and validates robot description files from Python sources, treating URDF work as constrained kinematic modeling rather than simple XML writing. Developers use it when creating or modifying robot links, joints, geometry, inertials, mesh references, and frame conventions - establishing frame ledgers, joint-axis semantics, unit consistency, and spatial transforms before writing or regenerating `.urdf` artifacts. Core workflow: identify target consumers (RViz, robot_state_publisher, Gazebo, MoveIt, simulators), create or read design ledger, edit Python generator sources, regenerate explicit targets with `scripts/urdf`, validate on generation, and hand off results to cad-viewer. Generation-time validation fails fast on XML, graph, joint, geometry, mesh, and inertial problems. Integrates with CAD, SRDF, and cad-viewer skills for complete robot-description pipelines. Python gen_urdf() generator as source of truth; generated .urdf files treated as artifacts, not edited directly
- Python gen_urdf() generator as source of truth; generated .urdf files treated as artifacts, not edited directly
- Built-in generation-time validation catches XML, graph, joint, geometry, mesh-reference, and inertial errors without sep
- Frame semantics enforced: joint origins, link frames, joint axes, visual/collision/inertial origins use different refere
- Design ledger and assumption tracking before spatial transforms, mesh units, axes, or joint signs to prevent silent drif
- Multi-target regeneration with SOURCE.py=OUTPUT.urdf pairs; mandatory CAD Viewer handoff for created or updated .urdf fi
Urdf by the numbers
- 4,999 all-time installs (skills.sh)
- +626 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #69 of 2,742 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
urdf capabilities & compatibility
- Capabilities
- python gen_urdf() execution with multi target ou · generation time urdf validation (xml, graph, joi · design ledger creation and frame semantic enforc · custom output path mapping (source.py=output.urd · mesh reference validation and unit consistency c
- Use cases
- debugging
- Platforms
- macOS · Windows · Linux
- Runs
- Runs locally
- Pricing
- Free
What urdf says it does
Joint origins, link frames, joint axes, and visual/collision/inertial origins use different reference frames.
npx skills add https://github.com/earthtojake/text-to-cad --skill urdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5k |
|---|---|
| repo stars | ★ 11k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 11, 2026 |
| Repository | earthtojake/text-to-cad ↗ |
What it does
Generate, edit, validate, and debug URDF robot descriptions with frame-correct kinematic modeling and generation-time validation.
Who is it for?
Roboticists and robotics software engineers building or modifying robot kinematic models in URDF format; teams integrating multiple simulation or planning tools (RViz, Gazebo, MoveIt, real robot drivers).
Skip if: Static XML editing without validation; visual CAD design (use CAD skill); MoveIt semantic planning groups or IK semantics (use SRDF skill); local 3D visualization controls (use cad-viewer skill).
When should I use this skill?
Creating, editing, regenerating, or debugging .urdf files; fixing joint-axis, frame-origin, mesh-reference, or inertial errors; establishing robot kinematic structure before simulation or planning.
What you get
Generated .urdf files with correct frame conventions, validated geometry and mesh references, consistent units, auditable inertials, and downstream consumer smoke-test readiness.
- Generated .urdf files with validation passing
- Design ledger documenting frames, joints, units, assumptions
- Smoke-test results (RViz display, robot_state_publisher tree, Gazebo load, or MoveIt model load)
By the numbers
- Generation-time validation covers XML, graph, joint, geometry, mesh, and inertial correctness in single run
- Multi-target regeneration supported: SOURCE.py=OUTPUT.urdf pair syntax for batch processing
- Frame semantics enforce 4 distinct reference frames: joint origin, link frame, joint axis, visual/collision/inertial ori
Files
URDF
Provenance: maintained in earthtojake/text-to-cad. Use the installed local skill files as the runtime source of truth; the repository link is only for provenance and release review.
Use this skill for URDF robot-description outputs. Treat URDF work as constrained kinematic modeling, not just XML writing. The main correctness risks are frame placement, joint-axis semantics, unit consistency, mesh scale, inertial data, and generated-artifact drift.
Core Rules
1. Treat the Python source that defines gen_urdf() as the source of truth. Treat configured .urdf files as generated artifacts. 2. Generate only explicit URDF targets. Do not regenerate unrelated CAD, mesh, render, SRDF, SDF, or simulator artifacts from this skill. 3. The scripts/urdf generator validates generated URDFs by default. Do not use or document a separate validate command. 4. Before writing or changing URDF XML, establish the robot's frame, joint, geometry, unit, and assumption ledger. See references/design-ledger.md. 5. Use URDF frame semantics exactly. Joint origins, link frames, joint axes, and visual/collision/inertial origins use different reference frames. See references/frame-semantics.md. 6. Do not infer spatial transforms, mesh units, handedness, axes, or joint signs from vague prose. Use CAD transforms, dimensioned drawings, measured values, existing source data, or explicit documented assumptions. 7. Prefer simple, auditable generator code over clever XML construction. Keep constants named by physical meaning, not by arbitrary numbers. 8. For physical links, model inertial, visual, and collision separately when the target consumer needs them. Frame-only links may intentionally omit mass and geometry.
CAD Viewer Handoff
After completing URDF work that creates or modifies a .urdf, you must ALWAYS hand the explicit file path to $cad-viewer when that skill is installed. $cad-viewer must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if $cad-viewer is unavailable or startup fails, report that instead of silently omitting the handoff.
Workflow
1. Identify the gen_urdf() Python source and target .urdf output. 2. Identify target consumers: RViz, robot_state_publisher, Gazebo/Ignition, MoveIt, a real robot driver, or another simulator. 3. Read or create the design ledger before editing frames, origins, axes, mesh scale, limits, or inertials. 4. Edit the generator source, not generated URDF XML. 5. Regenerate only explicit targets with scripts/urdf. 6. Let generation-time validation fail fast on XML, graph, joint, geometry, mesh-reference, and inertial problems. 7. When geometry or mesh references depend on changed CAD or exported mesh outputs, regenerate those explicit artifacts with the owning CAD or mesh workflow, then regenerate the affected URDF target. 8. When available, run a consumer smoke test appropriate to the target: RViz display, robot_state_publisher tree, Gazebo/Ignition loading, or MoveIt model loading. 9. Report remaining assumptions, unchecked spatial data, and validation/smoke-test gaps.
Commands
Run with the Python environment for the project or workspace. Treat python in examples as an interpreter placeholder; if bare python is unavailable, substitute python3, a project virtualenv interpreter, or the configured interpreter path. The URDF generator and lightweight validator use only the Python standard library; downstream consumers such as RViz, Gazebo, or MoveIt may need their own runtime packages.
From this skill directory, the launcher shape is:
python scripts/urdf path/to/source.py
python scripts/urdf path/to/source.py -o path/to/robot.urdf
python scripts/urdf path/to/a.py=out/a.urdf path/to/b.py=out/b.urdfPlain Python targets write a sibling .urdf beside the source. -o/--output is valid only with one plain target. Use SOURCE.py=OUTPUT.urdf pairs for custom multi-target destinations.
Relative source targets and CLI output overrides are resolved from the current working directory. When running from outside this skill directory, prefix the launcher path so target files still resolve from the intended workspace.
The launcher executes only gen_urdf() and validates the generated URDF output. It does not provide a separate validation-only command.
References
- Design ledger:
references/design-ledger.md - Frame semantics:
references/frame-semantics.md - URDF generator contract:
references/generator-contract.md - URDF generation command:
references/gen-urdf.md - URDF edit workflow:
references/urdf-workflow.md - Generation-time validation expectations:
references/validation.md
interface:
display_name: "URDF"
short_description: "Generate and validate URDF robot descriptions."
default_prompt: "Use $urdf to generate, update, and validate URDF robot descriptions from Python sources, handing generated URDFs to $cad-viewer when available."
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.
URDF Design Ledger
Use this reference before creating or editing a URDF generator. The ledger is the written spatial model that prevents silent frame, axis, unit, and mesh-scale mistakes.
The ledger may live in the generator source comments, adjacent project documentation, a README, or a task note. It must be specific enough that another engineer can audit the generated URDF without reverse-engineering the XML.
Required Sections
Robot Metadata
Record:
- robot name
- target consumers: RViz, robot_state_publisher, Gazebo/Ignition, MoveIt, real robot driver, or other
- unit convention: meters, kilograms, seconds, radians unless the project explicitly states otherwise
- frame convention: REP-103-style body convention when applicable, or a documented exception
- mesh unit convention: meters, millimeters, inches, or other
- source of dimensions: CAD, drawing, measured data, vendor documentation, existing URDF, or assumption
Link Ledger
For every link, record:
| Field | Meaning |
|---|---|
| link name | Exact URDF <link name="..."> value. |
| role | Physical link, frame-only link, sensor frame, tool frame, base frame, or other. |
| frame definition | Where the link frame is located and how its axes point. |
| parent joint | Joint that creates this child link frame, or none for root. |
| visual geometry | Primitive or mesh source, with origin relative to the link frame. |
| collision geometry | Primitive or mesh source, with origin relative to the link frame. |
| inertial source | CAD mass properties, vendor data, approximation, or intentionally omitted. |
Frame-only links such as base_footprint, optical frames, and tool0 may omit inertial, visual, and collision blocks. Mark them explicitly as frame-only rather than leaving intent ambiguous.
Joint Ledger
For every joint, record:
| Field | Meaning |
|---|---|
| joint name | Exact URDF <joint name="..."> value. |
| type | fixed, revolute, continuous, or prismatic for the current scripts/urdf validator; record floating or planar only if the project has a different supported validation path. |
| parent link | Link whose frame expresses the joint origin. |
| child link | Link whose frame is created at the joint frame. |
| origin xyz/rpy | Parent-link-frame transform from parent link to joint frame. |
| axis | Axis vector expressed in the joint frame, for movable joints. |
| limits | Radians for revolute, meters for prismatic, no finite lower/upper limits for continuous. |
| positive motion | What positive joint motion physically does. |
| source | CAD, drawing, measured data, existing model, or documented assumption. |
Do not write a movable joint without an explicit positive-motion convention. The sign of the axis is part of the model, not a cosmetic detail.
Geometry Ledger
For every visual or collision item, record:
| Field | Meaning |
|---|---|
| link | Owning link. |
| kind | visual or collision. |
| geometry type | mesh, box, cylinder, or sphere. |
| source | CAD export, primitive approximation, vendor mesh, generated mesh, or temporary placeholder. |
| origin xyz/rpy | Transform from link frame to geometry frame. |
| scale | Mesh scale if applicable. |
| units | Mesh source units and URDF scale needed to express meters. |
Visual geometry is for display. Collision geometry is for contact, planning, and physics. It may intentionally be simpler than the visual geometry.
Inertial Ledger
For every physical link, record:
| Field | Meaning |
|---|---|
| mass | Kilograms. |
| center of mass | Inertial origin xyz in the link frame. |
| inertia tensor | Tensor values and frame. |
| source | CAD mass properties, vendor data, calculation, approximation, or intentionally omitted. |
| confidence | Exact, estimated, placeholder, or unknown. |
Do not silently copy visual origins into inertial origins. The visual frame, collision frame, link frame, and center of mass can all differ.
Assumption Ledger
Record every inferred or guessed value, including:
- unknown dimensions
- mesh units
- sign conventions
- joint axes
- parent/child direction
- visual or collision offsets
- mass, COM, and inertia approximations
- frame-only link intent
- unverified package URI resolution
Use named constants in generator code for assumed values. Prefer names like ASSUMED_BASE_TO_SHOULDER_Z_M over unlabelled numeric literals.
When Information Is Missing
If spatial information is missing, do not invent a precise-looking transform. Choose one of these outcomes:
1. preserve existing source data unchanged; 2. create a frame-only or placeholder structure with explicit assumption comments; 3. use a clearly named approximate constant; 4. ask for dimensions or CAD data when the workflow allows interaction; 5. report that the generated model is structurally valid but spatially provisional.
A provisional URDF is acceptable when clearly labelled. A plausible but undocumented URDF is not.
URDF Frame Semantics
Use this reference whenever editing origins, axes, visual placement, collision placement, or inertials. Most URDF generation errors are frame errors.
Core Semantics
URDF represents a robot as a tree of links connected by joints.
For a joint:
<parent link="...">names the parent link.<child link="...">names the child link.<origin xyz="..." rpy="...">is the transform from the parent link frame to the joint frame.- The child link frame is coincident with the joint frame.
<axis xyz="...">for a movable joint is expressed in the joint frame, not automatically in the world frame and not in the visual mesh frame.
For link subelements:
<visual><origin ...>is expressed in the link frame.<collision><origin ...>is expressed in the link frame.<inertial><origin ...>is the center-of-mass/inertial frame expressed in the link frame.
These origins are independent. A mesh can be offset from its link frame, and the center of mass can be offset differently.
Units and Angles
Use:
- meters for length;
- kilograms for mass;
- radians for angles;
- seconds for time;
- right-handed coordinate frames unless the project documents an exception.
Do not store revolute limits in degrees in generated URDF. Convert degrees to radians before emission.
Do not use finite lower/upper limits for a continuous joint unless the project is intentionally not using URDF continuous-joint semantics.
Joint Axis Checklist
For every non-fixed joint, confirm:
1. the axis is present; 2. the axis vector has three finite numbers; 3. the vector is nonzero; 4. the vector is normalized or intentionally normalized by helper code; 5. the vector is expressed in the joint frame; 6. positive motion is documented.
Examples:
<joint name="shoulder_pan_joint" type="revolute">
<parent link="base_link" />
<child link="shoulder_link" />
<origin xyz="0 0 0.24" rpy="0 0 0" />
<axis xyz="0 0 1" />
<limit lower="-3.14159" upper="3.14159" effort="40" velocity="2" />
</joint>This means the child link frame is at z = 0.24 in base_link, and positive joint motion rotates about +Z of the joint/child frame.
Visual and Collision Placement Checklist
For every visual or collision block, confirm:
1. the origin is relative to the owning link frame; 2. mesh scale converts the mesh source units into meters; 3. visual and collision geometry are intentionally the same or intentionally different; 4. collision geometry is simple enough for the intended physics/planning consumer; 5. mesh paths are stable from the generated URDF's location or use an intended package URI.
Example:
<link name="forearm_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://robot_description/meshes/forearm.stl" scale="0.001 0.001 0.001" />
</geometry>
</visual>
<collision>
<origin xyz="0.12 0 0" rpy="0 1.57079632679 0" />
<geometry>
<cylinder radius="0.035" length="0.24" />
</geometry>
</collision>
</link>This places the visual mesh at the link frame and uses a simplified collision cylinder offset in the same link frame.
Inertial Placement Checklist
For every physical link with inertial data, confirm:
1. mass is positive and finite; 2. inertial origin is the center of mass in the link frame; 3. inertia tensor values are in SI units; 4. tensor values correspond to the inertial frame being declared; 5. approximations are documented.
Do not infer inertial origin from visual or collision origin unless the source data proves they coincide.
URDF Generation
Regenerate explicit URDF outputs from Python sources with top-level gen_urdf() functions.
python scripts/urdf path/to/assembly.py
python scripts/urdf path/to/assembly.py -o path/to/robot.urdf
python scripts/urdf path/to/a.py=out/a.urdf path/to/b.py=out/b.urdfPlain Python targets write a sibling .urdf beside the source. -o/--output is valid only with one plain target. Use SOURCE.py=OUTPUT.urdf pairs for custom multi-target destinations.
gen_urdf() must be a top-level zero-argument function returning the root xml.etree.ElementTree.Element, a complete URDF XML string, or an accepted envelope whose only field is xml. See references/generator-contract.md.
Relative source targets and CLI output paths resolve from the current working directory.
Default Validation Behavior
The generation command validates the generated URDF before accepting the output. There is no separate validation-only command in this skill.
A generation failure should be treated as a model or source problem, not as a reason to hand-edit the generated .urdf. Fix the generator source, then regenerate the explicit target.
The generation-time validation is intended to catch compact robot-description issues such as:
- malformed XML or wrong root element;
- missing or duplicate link and joint names;
- invalid parent/child references;
- multiple roots, disconnected graphs, or cycles;
- suspicious joint limits, axes, origins, geometry, mesh references, or inertials when supported by the runtime.
Boundaries
This tool runs only gen_urdf(). It does not regenerate CAD, mesh/export, GLB/topology, render, SDF, SRDF, MoveIt2, or simulator outputs.
If URDF visual/collision mesh references depend on updated CAD or mesh outputs, regenerate those explicit targets separately with the owning CAD or mesh workflow, then regenerate the affected URDF.
After creating or modifying generated .urdf files, hand explicit paths to $cad-viewer for live viewer links when available.
Failure Handling
When generation-time validation fails:
1. inspect the reported links, joints, mesh references, or inertial fields; 2. update the source generator, design ledger, or referenced assets; 3. regenerate the same explicit URDF target; 4. do not patch the generated .urdf directly unless the task is explicitly forensic and the patch will not be treated as source of truth.
URDF Generator Contract
Use this reference when creating or editing Python sources that generate URDF files.
Source of Truth
The Python source that defines gen_urdf() is source of truth. The configured .urdf file is generated and should not be hand-edited.
Keep the design ledger close to the source. At minimum, generator source should make units, frame conventions, parent/child choices, joint axes, mesh scale, and assumptions easy to audit.
Contract
gen_urdf() must be a top-level zero-argument function that returns one of:
- the root
xml.etree.ElementTree.Elementfor a complete URDF document; - a complete URDF XML string;
- an envelope dict with exactly one field,
xml, whose value is anElementTree.Elementor XML string.
Default generator shape:
import xml.etree.ElementTree as ET
def gen_urdf():
robot = ET.Element("robot", {"name": "sample"})
ET.SubElement(robot, "link", {"name": "base_link"})
return robotWhen an envelope is needed, set xml to the same root element or XML string:
def gen_urdf():
robot = ET.Element("robot", {"name": "sample"})
ET.SubElement(robot, "link", {"name": "base_link"})
return {"xml": robot}Do not include output-path, validation, Explorer, pose-preset, or consumer metadata in the envelope. The current runtime rejects fields such as urdf_output, validate, and explorer_metadata; output paths are selected only by CLI arguments. Named robot poses belong in SRDF <group_state> elements, not URDF-side explorer.json or Explorer metadata artifacts.
The CLI serializes the returned payload, writes the configured .urdf output path, and validates that generated file before returning success.
The generated .urdf output path is selected by the CLI. A plain source target writes a sibling .urdf; -o/--output overrides one target; SOURCE.py=OUTPUT.urdf pairs override individual targets.
The host project may impose its own layout policy, but the URDF skill runtime does not hardcode a project directory.
Authoring Expectations
Use explicit constants for physical dimensions, joint locations, joint limits, mesh scale, and inertial values. Avoid anonymous literals in joint origins and axes.
Prefer:
BASE_TO_SHOULDER_Z_M = 0.240
SHOULDER_PAN_AXIS = (0.0, 0.0, 1.0)
FOREARM_MESH_SCALE_FROM_MM = (0.001, 0.001, 0.001)Over:
origin="0 0 0.24"
axis="0 0 1"
scale="0.001 0.001 0.001"Document assumptions directly in comments near the constants or in references/design-ledger.md style project documentation.
Frame and Unit Rules
Generators must emit URDF values in the project's URDF unit convention, normally meters, kilograms, seconds, and radians.
Before emitting a joint, confirm:
- the parent and child link names are correct;
- the joint origin is expressed in the parent link frame;
- the child link frame is intended to coincide with the joint frame;
- the joint axis is expressed in the joint frame;
- revolute limits are radians;
- prismatic limits are meters;
- continuous joints do not get fake finite lower/upper limits.
Before emitting visual, collision, or inertial data, confirm:
- its origin is expressed in the owning link frame;
- mesh scale converts mesh source units into meters;
- collision geometry is intentionally simplified or intentionally identical to visual geometry;
- inertial origin represents the center of mass, not merely the visual origin.
The current scripts/urdf validation path accepts any non-empty mesh filename or URI, checks local file existence when the URI resolves locally, and leaves mesh-format loadability to the target URDF consumer. Visual and collision geometry may use <mesh>, <box>, <cylinder>, or <sphere>.
Runtime Behavior
scripts/urdf runs only gen_urdf(). It does not regenerate external CAD, mesh/export, GLB/topology, render, SDF, or SRDF/MoveIt2 artifacts.
If URDF visual/collision mesh references depend on updated CAD or mesh outputs, regenerate those explicit targets separately with the owning CAD or mesh workflow.
Importing a generator module executes its top-level Python code. Keep top-level generator modules deterministic and side-effect-light. Place expensive or mutating work behind gen_urdf() only when the task explicitly requires it.
Paths
Relative source targets and CLI output paths resolve from the current working directory.
Inside generator code, prefer paths derived from the generator file or package conventions over implicit shell working-directory assumptions. Mesh filenames emitted into URDF should be stable from the generated .urdf file's perspective or use a package URI understood by the consumer.
URDF Workflow
Use this reference when editing robot-description structure, frame placement, mesh references, inertial data, or generated URDF output.
Edit Loop
1. Find the Python source that defines gen_urdf(). 2. Treat that Python source as source of truth and the .urdf file as generated. 3. Identify target consumers and strictness requirements: visualization, TF tree, simulation, planning, or real robot integration. 4. Create or update the design ledger before changing frames, origins, axes, limits, mesh scales, or inertials. 5. Apply URDF frame semantics: joint origin in parent frame, child link frame at joint frame, joint axis in joint frame, visual/collision/inertial origins in link frame. 6. Edit links, joints, limits, axes, origins, inertials, materials, visual/collision geometry, and mesh filenames deliberately in the generator source. 7. Regenerate only the explicit URDF target with scripts/urdf <source-file>, scripts/urdf <source-file> -o <output.urdf>, or scripts/urdf <source-file>=<output.urdf>. 8. Let generation-time validation catch structural and semantic problems. Fix the generator source instead of hand-editing generated XML. 9. If mesh outputs changed, regenerate only the affected explicit outputs with the owning CAD or mesh workflow. 10. Hand generated or modified .urdf files to $cad-viewer for live viewer links when available. 11. When available, run a consumer smoke test and report what was not checked.
Spatial-Reasoning Guardrails
LLMs are prone to plausible-looking spatial mistakes. Use these guardrails:
- Do not infer dimensions, handedness, axes, mesh units, or joint signs from vague descriptions.
- Do not silently mirror left/right parts unless the mirror transform and sign changes are explicit.
- Do not assume visual mesh origin equals link frame, collision frame, or center of mass.
- Do not assume CAD mesh units are meters. STL files often carry no reliable unit metadata.
- Do not encode a kinematic correction by offsetting only the visual mesh; correct the link and joint frames unless the visual mesh is genuinely offset.
- Preserve existing proven transforms unless the task explicitly requires changing them.
- Use named constants and comments for assumptions.
Standard Link Tags
Use these tags for each link that represents physical robot geometry:
inertial: mass, center of mass, and inertia tensor used by simulators.visual: display geometry and optional material.collision: contact geometry used by physics and planning.
Frame-only links, such as base_footprint, optical frames, or tool-center marker frames, may intentionally omit these tags when they represent no physical mass or geometry.
For movable physical links, avoid zero or missing mass unless the target simulator explicitly supports that modeling choice. If exact mass properties are unavailable, use a documented approximation and make the approximation easy to replace later.
Joint Authoring
For every joint, confirm:
- parent and child direction are correct;
- joint origin is expressed in the parent link frame;
- child link frame is intended to coincide with the joint frame;
- non-fixed joint axis is expressed in the joint frame;
- positive motion is documented;
- revolute limits are radians;
- prismatic limits are meters;
- continuous joints are not given artificial finite lower/upper limits;
- fixed joints are used for frame relationships and rigid assemblies.
Supported joint types may vary by project runtime. If the validator/runtime supports only fixed, continuous, revolute, and prismatic, do not author floating or planar joints unless the consumer and validation path support them.
Mesh References
URDF mesh filenames should be stable from the generated URDF file's perspective or use a package URI convention understood by the consumer.
The current scripts/urdf validation path accepts any non-empty mesh filename or URI. Local relative mesh paths are checked relative to the generated URDF file; package://... and remote references are left unresolved with warnings unless a package map is supplied through read_urdf_source().
When using package URIs, confirm the consuming environment resolves the package root the same way as the generated URDF expects.
Do not use generated URDF XML as the source of truth for mesh placement. Prefer deriving visual mesh references from the same source data that owns the mesh instance placement.
When mesh references point to generated assets, keep the ownership clear:
- CAD or mesh workflows own mesh generation;
- URDF generation owns references, scales, and placements;
- SRDF/MoveIt workflows own semantic groups, named joint poses via
<group_state>, and planning metadata.
Collision Geometry
Add collision geometry under each <link> that should participate in physics, contact, or collision-aware planning. Do not encode collision behavior on joints.
Use one or more <collision> blocks per link. The <origin> is expressed in the link frame, just like <visual>, and mesh scales must match the units of the exported mesh:
The current scripts/urdf validator allows visual and collision geometry to use <mesh>, <box>, <cylinder>, or <sphere>.
<link name="forearm_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="package://robot_description/meshes/forearm.stl" scale="0.001 0.001 0.001" />
</geometry>
</visual>
<collision>
<origin xyz="0.12 0 0" rpy="0 1.57079632679 0" />
<geometry>
<cylinder radius="0.035" length="0.24" />
</geometry>
</collision>
</link>Prefer simplified collision geometry over detailed visual meshes. Good options, from simplest to most specific:
- primitive
<box>,<cylinder>, or<sphere>geometry when it approximates the part well; - a coarse, closed collision mesh exported from CAD;
- the visual mesh as a temporary fallback for loading and smoke tests.
In generator sources, model collisions explicitly rather than hand-editing generated URDF. A common pattern is to add a collisions collection beside visuals in each link spec and emit it with the same origin and scale helper code used for visual meshes.
Inertials
For each physical link, use an explicit inertial block when the target simulator or dynamics consumer needs mass properties.
The inertial origin is the center of mass in the link frame. It is not automatically the visual mesh origin, collision origin, or link origin.
When exact mass properties are unavailable, use a documented approximation and make it easy to replace. Mark approximate mass, COM, and inertia constants clearly.
Smoke Tests
After generation-time validation, use the most relevant available smoke test:
- load in RViz or equivalent visualization to inspect visible placement;
- run robot_state_publisher or equivalent to check the TF tree;
- load in Gazebo/Ignition or another simulator for physics consumers;
- load in MoveIt only after URDF structure is stable, then handle semantic data through the SRDF workflow.
Report the smoke tests run and any skipped checks that would materially affect confidence.
URDF Generation-Time Validation
Use this reference to understand what generated URDFs should satisfy. The scripts/urdf generation path validates by default; there is no separate validation-only command in this skill.
Validation is a guardrail, not a substitute for a design ledger or visual/consumer smoke test. A URDF can pass structural checks while still having incorrect spatial assumptions.
Generated or modified .urdf files should be handed to $cad-viewer for live viewer links when available.
Structural Checks
Validate that:
- the root element is
<robot>; - the robot has a non-empty name;
- every link has a unique non-empty name;
- every joint has a unique non-empty name;
- every joint has valid parent and child fields;
- parent and child links exist;
- each child link has at most one parent;
- the graph has exactly one root link;
- the graph is connected and acyclic;
- the tree has exactly
links - 1joints unless the design intentionally uses a different structure and the runtime supports it.
Origin Checks
For each joint, visual, collision, and inertial origin, validate or review that:
xyzhas three finite numeric values when present;rpyhas three finite numeric values when present;- omitted values follow the URDF default only when that is intentional;
- units are meters for
xyzand radians forrpy; - the origin is expressed in the correct frame.
Frame correctness must be reviewed from the design ledger. XML validation alone cannot prove that an origin is spatially correct.
Joint Checks
The current scripts/urdf source reader supports these joint types:
fixedcontinuousrevoluteprismatic
Some URDF consumers also support floating and planar; use them only when the project runtime and validation path support them. The current scripts/urdf reader rejects them.
For non-fixed joints, validate or review that:
<axis>exists where required;- axis has exactly three finite numeric values;
- axis is nonzero;
- axis is normalized or intentionally normalized by generator code;
- axis is expressed in the joint frame;
- positive motion is documented in the design ledger.
For limits:
revolutelower/upper limits are radians;prismaticlower/upper limits are meters;- lower is not greater than upper;
- effort and velocity are finite when present;
continuousjoints do not use artificial finite lower/upper limits;fixedjoints do not need motion limits.
Geometry Checks
General URDF visual and collision geometry may use:
<mesh><box><cylinder><sphere>
The current scripts/urdf validation path allows these geometry types for both visual and collision geometry.
For geometry, validate or review that:
- each visual or collision block has a
<geometry>element; - each geometry block has exactly one supported geometry child;
- primitive dimensions are positive and finite;
- mesh filenames are non-empty;
- mesh scale, when present, has three positive finite values;
- visual and collision origins are relative to the owning link frame;
- collision geometry is appropriate for the target consumer.
Mesh Reference Checks
For mesh references, distinguish syntax, local existence, and runtime package resolution.
Validate or review that:
- local relative and absolute paths are intentional;
- local files exist when filesystem validation is available;
package://...URIs have valid syntax;- package URIs are not incorrectly assumed to resolve from the current working directory;
- the consuming ROS or simulator environment can resolve package URIs;
- mesh source units and emitted scale convert to meters.
The current source reader accepts any non-empty mesh filename or URI. It resolves local mesh paths from the generated URDF file's directory and verifies those files exist. It accepts unresolved package://... and remote references with warnings, because final mesh-format support is consumer-specific.
If mesh references changed, confirm the corresponding mesh outputs were regenerated separately by the owning CAD or mesh workflow.
Inertial Checks
For each physical link with mass or geometry, prefer an explicit inertial block:
originis the center of mass in the link frame;massis positive and finite;inertiadefinesixx,ixy,ixz,iyy,iyz, andizz;- all inertia values are finite;
- diagonal inertia values are positive;
- the inertia matrix is physically plausible for the intended approximation;
- approximations are documented.
Frame-only links may intentionally omit inertial. Physical links with omitted inertials should be called out when the target consumer uses simulation or dynamics.
URDF Validity vs Project Policy
Separate general URDF validity from project-specific policy.
Examples of general URDF validity or sanity checks:
- unique links and joints;
- valid tree structure;
- valid joint parent/child references;
- finite origins;
- nonzero movable-joint axes;
- positive primitive dimensions;
- positive mass for physical inertial blocks.
Examples of project policy checks:
- visual mesh references must use a specific extension;
- visual geometry must be mesh-only;
- collision geometry must be primitive-only;
- mesh files must live under a specific directory;
- every physical link must have collision geometry;
- every physical link must have inertial data;
- package URIs must use a specific package prefix.
When a generated URDF fails because of project policy, report it as policy failure, not as universal URDF invalidity.
Failure Handling
When generation-time validation fails:
1. fix the generator source, design ledger, or referenced assets; 2. regenerate the explicit URDF target; 3. do not hand-edit the generated .urdf as a permanent fix; 4. record any remaining assumptions or unchecked spatial claims.
Tooling
Use scripts/urdf/source.py through the urdf.source.read_urdf_source() helper for compact robot/link/joint checks when writing tests or focused checks.
The URDF source reader is a lightweight standard-library validator. It checks the generated subset used by this skill; consumer-specific parser and mesh-loading compatibility should still be smoke-tested in the target runtime when confidence matters.
--editable ./scripts/packages/cadpy_metadata
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cadpy-metadata"
version = "0.3.6"
description = "Dependency-free metadata helpers for Python-generated CAD outputs."
requires-python = ">=3.11"
dependencies = []
[tool.setuptools.packages.find]
where = ["src"]
include = ["cadpy_metadata*"]
[tool.setuptools.package-data]
cadpy_metadata = ["py.typed"]
"""Dependency-free metadata helpers for generated CAD-adjacent outputs."""
from cadpy_metadata.generator import (
GenerationOutput,
PythonSourceIdentity,
python_source_identity,
track_generation_run,
xml_with_text_to_cad_metadata,
)
__all__ = [
"GenerationOutput",
"PythonSourceIdentity",
"python_source_identity",
"track_generation_run",
"xml_with_text_to_cad_metadata",
]
"""Metadata helpers for Python-generated CAD outputs."""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import re
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator, Sequence
TEXT_TO_CAD_PREFIX = "cadpy:"
GENERATION_STATUS_SCHEMA_VERSION = 1
GENERATION_LOCK_SUFFIX = ".generation.lock.json"
GENERATION_STATUS_HEARTBEAT_INTERVAL_SEC = 1.0
_ACTIVE_RUN = threading.local()
@dataclass(frozen=True)
class PythonSourceIdentity:
source_path: str
source_hash: str
@dataclass(frozen=True)
class GenerationOutput:
path: Path
kind: str
def generation_lock_path(output_path: Path | str, run_id: str) -> Path:
resolved_output = Path(output_path).expanduser().resolve()
return resolved_output.parent / f".{resolved_output.name}.{_safe_lock_token(run_id)}{GENERATION_LOCK_SUFFIX}"
def python_source_identity(script_path: Path) -> PythonSourceIdentity:
resolved_script = script_path.expanduser().resolve()
repo_root = Path.cwd().resolve()
return PythonSourceIdentity(
source_path=_manifest_path(resolved_script, repo_root),
source_hash=_sha256_file(resolved_script),
)
def track_generation_run(
*,
source_path: Path | None,
generator: str,
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
repo_root: Path | None = None,
) -> contextlib.AbstractContextManager[None]:
tracker = _GenerationStatusTracker(
repo_root=repo_root or Path.cwd(),
source_path=source_path,
generator=generator,
outputs=outputs,
)
return tracker.run()
class _GenerationStatusTracker:
def __init__(
self,
*,
repo_root: Path,
source_path: Path | None,
generator: str,
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
) -> None:
self.repo_root = repo_root.expanduser().resolve()
self.source_path = source_path.expanduser().resolve() if source_path is not None else None
self.generator = str(generator or "").strip()
self.outputs = tuple(_normalize_generation_output(output) for output in outputs)
self.run_id = f"{os.getpid()}-{uuid.uuid4().hex}"
self.status_paths = tuple(
dict.fromkeys(
generation_lock_path(output.path, self.run_id)
for output in self.outputs
if output.path is not None
)
)
self.status_path = self.status_paths[0] if self.status_paths else None
self.started_at = _now_iso()
self.stop = threading.Event()
self.thread: threading.Thread | None = None
@contextlib.contextmanager
def run(self) -> Iterator[None]:
active_depth = int(getattr(_ACTIVE_RUN, "depth", 0) or 0)
if active_depth > 0:
yield
return
_ACTIVE_RUN.depth = active_depth + 1
self._start()
try:
yield
finally:
try:
self._finish()
finally:
_ACTIVE_RUN.depth = active_depth
def _start(self) -> None:
if not self.status_paths:
return
self._write_status()
self.thread = threading.Thread(target=self._heartbeat, daemon=True)
self.thread.start()
def _finish(self) -> None:
self.stop.set()
if self.thread is not None:
self.thread.join(timeout=0.25)
for status_path in self.status_paths:
try:
status_path.unlink()
except FileNotFoundError:
pass
except OSError:
self._write_status(status="finished", status_paths=(status_path,))
def _heartbeat(self) -> None:
while not self.stop.wait(GENERATION_STATUS_HEARTBEAT_INTERVAL_SEC):
self._write_status()
def _write_status(
self,
*,
status: str = "running",
status_paths: Sequence[Path] | None = None,
) -> None:
for status_path in tuple(status_paths or self.status_paths):
try:
status_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = status_path.with_name(f"{status_path.name}.{os.getpid()}.tmp")
payload = self._status_payload(status=status, status_path=status_path)
tmp_path.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
tmp_path.replace(status_path)
except OSError:
pass
def _status_payload(self, *, status: str, status_path: Path) -> dict[str, object]:
base_dir = status_path.parent
return {
"schemaVersion": GENERATION_STATUS_SCHEMA_VERSION,
"id": self.run_id,
"status": status,
"pid": os.getpid(),
"startedAt": self.started_at,
"updatedAt": _now_iso(),
"sourcePath": _display_generation_path(self.source_path, base_dir),
"generator": self.generator,
"outputs": [
{
"path": _display_generation_path(output.path, base_dir),
"kind": output.kind,
}
for output in self.outputs
if output.path is not None
],
}
def xml_with_text_to_cad_metadata(
xml_text: str,
identity: PythonSourceIdentity,
*,
output_path: Path | None = None,
source_path: Path | None = None,
) -> str:
text = _strip_text_to_cad_metadata_comments(str(xml_text or ""))
metadata_source_path = identity.source_path
if output_path is not None and source_path is not None:
metadata_source_path = _display_generation_path(source_path, Path(output_path).expanduser().resolve().parent)
comment_block = "".join(
f"<!-- {TEXT_TO_CAD_PREFIX}{key}={value} -->\n"
for key, value in (
("sourcePath", metadata_source_path),
("sourceHash", identity.source_hash),
)
if value
)
declaration = re.match(r"\s*<\?xml[^>]*\?>\s*", text)
if declaration:
insert_at = declaration.end()
return text[:insert_at] + comment_block + text[insert_at:]
return comment_block + text
def _normalize_generation_output(output: GenerationOutput | tuple[Path, str] | Path | str) -> GenerationOutput:
if isinstance(output, GenerationOutput):
return output
if isinstance(output, tuple):
output_path, kind = output
return GenerationOutput(path=Path(output_path), kind=str(kind or "").strip())
path = Path(output)
return GenerationOutput(path=path, kind=path.suffix.lower().replace(".", "") or "output")
def _safe_lock_token(value: object) -> str:
token = "".join(
character if character.isalnum() or character in {"-", "_"} else "_"
for character in str(value or "").strip()
).strip("._")
return token or uuid.uuid4().hex
def _display_generation_path(path: Path | None, repo_root: Path) -> str:
if path is None:
return ""
resolved = path.expanduser().resolve()
return os.path.relpath(resolved, start=repo_root.expanduser().resolve()).replace(os.sep, "/")
def _now_iso() -> str:
return datetime.fromtimestamp(time.time(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _strip_text_to_cad_metadata_comments(xml_text: str) -> str:
return re.sub(
r"\s*<!--\s*cadpy:source[A-Za-z]*=[\s\S]*?-->\s*",
"\n",
xml_text,
).lstrip("\n")
def _manifest_path(path: Path, repo_root: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(repo_root).as_posix()
except ValueError:
return resolved.as_posix()
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
"""URDF generation and validation helpers."""
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 urdf.cli import main
else:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
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 urdf.source import read_urdf_source
@dataclass(frozen=True)
class _TargetSpec:
source_path: Path
output_path: Path
def generate_urdf_targets(targets: Sequence[str], *, output: str | Path | None = None) -> int:
target_specs = _resolve_target_specs(targets, output=output)
_validate_unique_outputs(target_specs)
for target_spec in target_specs:
_generate_target(target_spec.source_path, output_path=target_spec.output_path)
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="urdf",
description="Generate explicit URDF targets from Python sources.",
)
parser.add_argument(
"targets",
nargs="+",
help="Explicit Python source file or SOURCE.py=OUTPUT.urdf pair defining gen_urdf() to generate.",
)
parser.add_argument(
"-o",
"--output",
metavar="PATH",
help="Write the generated URDF file to this path. Valid only with one plain Python target.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
if args.output is not None:
if _targets_include_output_pairs(args.targets):
parser.error("--output cannot be combined with SOURCE=OUTPUT targets")
if len(args.targets) != 1:
parser.error("--output can only be used with exactly one target")
return generate_urdf_targets(args.targets, output=args.output)
def _resolve_target_specs(targets: Sequence[str], *, output: str | Path | None = None) -> list[_TargetSpec]:
if output is not None and _targets_include_output_pairs(targets):
raise ValueError("urdf --output cannot be combined with SOURCE=OUTPUT targets")
if output is not None and len(targets) != 1:
raise ValueError("urdf --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(".urdf")
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("urdf 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("urdf output must be a non-empty path")
if "\\" in value:
raise ValueError("urdf 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() != ".urdf":
raise ValueError("urdf output must end in .urdf")
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"urdf output path is used more than once: {_display_path(target_spec.output_path)}")
seen[output_path] = target_spec.output_path
def _generate_target(script_path: Path, *, output_path: Path) -> Path:
script_path = script_path.resolve()
if script_path.suffix.lower() != ".py":
raise ValueError(f"{_display_path(script_path)} must be a Python source file")
if not script_path.is_file():
raise FileNotFoundError(f"Python source not found: {_display_path(script_path)}")
with track_generation_run(
source_path=script_path,
generator="gen_urdf",
outputs=[GenerationOutput(output_path, "urdf")],
):
return _generate_target_inner(script_path, output_path=output_path)
def _generate_target_inner(script_path: Path, *, output_path: Path) -> Path:
module = _load_generator_module(script_path)
generator = getattr(module, "gen_urdf", None)
if not callable(generator):
raise RuntimeError(f"{_display_path(script_path)} does not define callable gen_urdf()")
if inspect.signature(generator).parameters:
raise ValueError(f"{_display_path(script_path)} gen_urdf() must not accept arguments")
payload = _normalize_urdf_payload(generator(), script_path=script_path)
_write_urdf_payload(payload, output_path=output_path, script_path=script_path)
if not output_path.exists():
raise RuntimeError(f"{_display_path(script_path)} did not write {_display_path(output_path)}")
read_urdf_source(output_path)
return output_path
def _load_generator_module(script_path: Path) -> object:
module_name = (
"_urdf_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_urdf_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_urdf() must return a URDF XML root element, XML string, "
"or generator envelope dict"
)
allowed_fields = {"xml"}
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_urdf() envelope has unsupported field(s): {joined}")
if "xml" not in raw_payload:
raise TypeError(f"{_display_path(script_path)} gen_urdf() envelope must define 'xml'")
payload = dict(raw_payload)
payload["xml"] = _normalize_xml_value(payload["xml"], script_path=script_path, label="gen_urdf() envelope field 'xml'")
return payload
def _write_urdf_payload(payload: dict[str, object], *, output_path: Path, script_path: Path) -> None:
xml = payload.get("xml")
if not isinstance(xml, str):
raise TypeError(
f"{_display_path(script_path)} gen_urdf() envelope field 'xml' must be a string, "
f"got {type(xml).__name__}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
identity = python_source_identity(script_path)
xml = xml_with_text_to_cad_metadata(xml, identity, 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 URDF: {output_path}")
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 _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())
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from math import degrees, isfinite
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlparse
import warnings
import xml.etree.ElementTree as ET
URDF_SUFFIX = ".urdf"
SUPPORTED_JOINT_TYPES = {"fixed", "continuous", "revolute", "prismatic"}
SUPPORTED_GEOMETRY_TAGS = {"box", "cylinder", "mesh", "sphere"}
class UrdfSourceError(ValueError):
pass
class UrdfSourceWarning(UserWarning):
pass
class MeshUriKind(Enum):
LOCAL_RELATIVE = "local_relative"
LOCAL_ABSOLUTE = "local_absolute"
PACKAGE = "package"
REMOTE = "remote"
@dataclass(frozen=True)
class MeshReference:
uri: str
kind: MeshUriKind
path: Path | None = None
package_name: str | None = None
package_path: PurePosixPath | None = None
@dataclass(frozen=True)
class UrdfJoint:
name: str
joint_type: str
parent_link: str
child_link: str
min_value_deg: float | None
max_value_deg: float | None
@dataclass(frozen=True)
class UrdfSource:
file_ref: str
source_path: Path
robot_name: str
root_link: str
links: tuple[str, ...]
joints: tuple[UrdfJoint, ...]
mesh_paths: tuple[Path, ...]
visual_mesh_paths: tuple[Path, ...] = ()
collision_mesh_paths: tuple[Path, ...] = ()
def file_ref_from_urdf_path(urdf_path: Path) -> str:
resolved = urdf_path.resolve()
if resolved.suffix.lower() != URDF_SUFFIX:
raise UrdfSourceError(f"{resolved} is not a URDF source file")
return _relative_to_repo(resolved)
def read_urdf_source(urdf_path: Path, *, package_map: dict[str, Path] | None = None) -> UrdfSource:
resolved_path = urdf_path.resolve()
if resolved_path.suffix.lower() != URDF_SUFFIX:
raise UrdfSourceError(f"{resolved_path} is not a URDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as URDF XML") from exc
if root.tag != "robot":
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} robot name is required")
link_names = []
for link_element in root.findall("link"):
name = str(link_element.attrib.get("name") or "").strip()
if not name:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} link name is required")
link_names.append(name)
if not link_names:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} must define at least one link")
_raise_on_duplicates(link_names, source_path=resolved_path, label="link")
link_name_set = set(link_names)
_validate_link_inertials(root, source_path=resolved_path)
visual_mesh_paths: list[Path] = []
collision_mesh_paths: list[Path] = []
for link_element in root.findall("link"):
visual_mesh_paths.extend(
_geometry_mesh_paths(
link_element,
element_name="visual",
source_path=resolved_path,
package_map=package_map,
)
)
collision_mesh_paths.extend(
_geometry_mesh_paths(
link_element,
element_name="collision",
source_path=resolved_path,
package_map=package_map,
)
)
joints = []
joint_names = []
parent_by_child: dict[str, str] = {}
children = set()
joints_by_parent: dict[str, list[str]] = {}
for joint_element in root.findall("joint"):
name = str(joint_element.attrib.get("name") or "").strip()
if not name:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} joint name is required")
joint_names.append(name)
joint_type = str(joint_element.attrib.get("type") or "").strip().lower()
if joint_type not in SUPPORTED_JOINT_TYPES:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} joint {name!r} uses unsupported type {joint_type!r}"
)
_validate_origin(
joint_element.find("origin"),
source_path=resolved_path,
label=f"joint {name!r} origin",
)
_validate_joint_axis(joint_element, joint_type=joint_type, source_path=resolved_path)
parent_element = joint_element.find("parent")
child_element = joint_element.find("child")
parent_link = str(parent_element.attrib.get("link") if parent_element is not None else "").strip()
child_link = str(child_element.attrib.get("link") if child_element is not None else "").strip()
if not parent_link or not child_link:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} joint {name!r} must define parent and child links"
)
if parent_link not in link_name_set:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} joint {name!r} references missing parent link {parent_link!r}"
)
if child_link not in link_name_set:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} joint {name!r} references missing child link {child_link!r}"
)
if child_link in parent_by_child:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} link {child_link!r} has multiple parents"
)
parent_by_child[child_link] = parent_link
children.add(child_link)
joints_by_parent.setdefault(parent_link, []).append(child_link)
min_value_deg, max_value_deg = _joint_limits_deg(joint_element, joint_type=joint_type, source_path=resolved_path)
joints.append(
UrdfJoint(
name=name,
joint_type=joint_type,
parent_link=parent_link,
child_link=child_link,
min_value_deg=min_value_deg,
max_value_deg=max_value_deg,
)
)
_raise_on_duplicates(joint_names, source_path=resolved_path, label="joint")
root_candidates = [link_name for link_name in link_names if link_name not in children]
if len(root_candidates) != 1:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} must form a single rooted tree; found roots {root_candidates!r}"
)
root_link = root_candidates[0]
visited: set[str] = set()
visiting: set[str] = set()
def visit(link_name: str) -> None:
if link_name in visited:
return
if link_name in visiting:
raise UrdfSourceError(f"{_relative_to_repo(resolved_path)} joint graph contains a cycle")
visiting.add(link_name)
for child_link in joints_by_parent.get(link_name, ()):
visit(child_link)
visiting.remove(link_name)
visited.add(link_name)
visit(root_link)
if visited != link_name_set:
missing_links = sorted(link_name_set - visited)
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} leaves links disconnected from the root: {missing_links!r}"
)
if len(joints) != len(link_names) - 1:
raise UrdfSourceError(
f"{_relative_to_repo(resolved_path)} must form a tree with exactly links-1 joints"
)
return UrdfSource(
file_ref=file_ref_from_urdf_path(resolved_path),
source_path=resolved_path,
robot_name=robot_name,
root_link=root_link,
links=tuple(link_names),
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 _validate_link_inertials(root: ET.Element, *, source_path: Path) -> None:
for link_element in root.findall("link"):
link_name = str(link_element.attrib.get("name") or "").strip()
inertial_element = link_element.find("inertial")
if inertial_element is None:
continue
_validate_origin(
inertial_element.find("origin"),
source_path=source_path,
label=f"link {link_name!r} inertial origin",
)
mass_element = inertial_element.find("mass")
if mass_element is None:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} inertial requires <mass>"
)
mass = _required_float_attr(
mass_element,
"value",
source_path=source_path,
label=f"link {link_name!r} inertial mass",
)
if mass <= 0.0:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} inertial mass must be positive"
)
inertia_element = inertial_element.find("inertia")
if inertia_element is None:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} inertial requires <inertia>"
)
ixx = _required_float_attr(
inertia_element,
"ixx",
source_path=source_path,
label=f"link {link_name!r} inertia ixx",
)
ixy = _required_float_attr(
inertia_element,
"ixy",
source_path=source_path,
label=f"link {link_name!r} inertia ixy",
)
ixz = _required_float_attr(
inertia_element,
"ixz",
source_path=source_path,
label=f"link {link_name!r} inertia ixz",
)
iyy = _required_float_attr(
inertia_element,
"iyy",
source_path=source_path,
label=f"link {link_name!r} inertia iyy",
)
iyz = _required_float_attr(
inertia_element,
"iyz",
source_path=source_path,
label=f"link {link_name!r} inertia iyz",
)
izz = _required_float_attr(
inertia_element,
"izz",
source_path=source_path,
label=f"link {link_name!r} inertia izz",
)
_validate_inertia_values(
link_name,
ixx=ixx,
ixy=ixy,
ixz=ixz,
iyy=iyy,
iyz=iyz,
izz=izz,
source_path=source_path,
)
def _required_float_attr(
element: ET.Element,
attr_name: str,
*,
source_path: Path,
label: str,
) -> float:
try:
value = element.attrib[attr_name]
except KeyError as exc:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {label} requires {attr_name!r}"
) from exc
try:
parsed = float(value)
except ValueError as exc:
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} is invalid") from exc
if not isfinite(parsed):
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} must be finite")
return parsed
def _validate_inertia_values(
link_name: str,
*,
ixx: float,
ixy: float,
ixz: float,
iyy: float,
iyz: float,
izz: float,
source_path: Path,
) -> None:
del ixy, ixz, iyz
if ixx <= 0.0 or iyy <= 0.0 or izz <= 0.0:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} inertia diagonal values must be positive"
)
tolerance = 1e-12
if ixx + iyy + tolerance < izz or ixx + izz + tolerance < iyy or iyy + izz + tolerance < ixx:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} inertia violates triangle inequalities"
)
def _geometry_mesh_paths(
link_element: ET.Element,
*,
element_name: str,
source_path: Path,
package_map: dict[str, Path] | None,
) -> list[Path]:
mesh_paths: list[Path] = []
link_name = str(link_element.attrib.get("name") or "").strip()
for geometry_owner in link_element.findall(element_name):
_validate_origin(
geometry_owner.find("origin"),
source_path=source_path,
label=f"link {link_name!r} {element_name} origin",
)
geometry_element = geometry_owner.find("geometry")
if geometry_element is None:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} link {link_name!r} {element_name} requires <geometry>"
)
geometry_child = _validate_geometry_element(
geometry_element,
source_path=source_path,
label=f"link {link_name!r} {element_name} geometry",
)
if geometry_child.tag != "mesh":
continue
mesh_path = _validated_mesh_path(geometry_child, source_path=source_path, package_map=package_map)
if mesh_path is not None:
mesh_paths.append(mesh_path)
return mesh_paths
def _validated_mesh_path(
mesh_element: ET.Element,
*,
source_path: Path,
package_map: dict[str, Path] | None,
) -> Path | None:
filename = str(mesh_element.attrib.get("filename") or "").strip()
if not filename:
raise UrdfSourceError(f"{_relative_to_repo(source_path)} mesh filename is required")
mesh_ref = classify_mesh_uri(filename)
_validate_mesh_scale(mesh_element, source_path=source_path, filename=filename)
mesh_path = resolve_mesh_uri(filename, package_map=package_map)
if mesh_path is None:
_warn_unresolved_mesh_uri(filename, mesh_ref=mesh_ref, source_path=source_path)
return None
if mesh_ref.kind == MeshUriKind.LOCAL_RELATIVE:
mesh_path = (source_path.parent / mesh_path).resolve()
if not mesh_path.is_file():
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} references missing mesh file: {filename!r}"
)
return mesh_path
def _joint_limits_deg(
joint_element: ET.Element,
*,
joint_type: str,
source_path: Path,
) -> tuple[float | None, float | None]:
if joint_type == "fixed":
return 0.0, 0.0
if joint_type == "continuous":
return -180.0, 180.0
limit_element = joint_element.find("limit")
if limit_element is None:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {joint_type} joint {joint_element.attrib.get('name', '')!r} requires <limit>"
)
try:
lower = float(limit_element.attrib["lower"])
upper = float(limit_element.attrib["upper"])
except KeyError as exc:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {joint_type} joint {joint_element.attrib.get('name', '')!r} requires lower and upper limits"
) from exc
except ValueError as exc:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {joint_type} joint {joint_element.attrib.get('name', '')!r} has invalid limits"
) from exc
if not isfinite(lower) or not isfinite(upper):
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {joint_type} joint {joint_element.attrib.get('name', '')!r} limits must be finite"
)
if lower > upper:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {joint_type} joint {joint_element.attrib.get('name', '')!r} lower limit exceeds upper limit"
)
_validate_optional_float_attr(
limit_element,
"effort",
source_path=source_path,
label=f"{joint_type} joint {joint_element.attrib.get('name', '')!r} effort limit",
)
_validate_optional_float_attr(
limit_element,
"velocity",
source_path=source_path,
label=f"{joint_type} joint {joint_element.attrib.get('name', '')!r} velocity limit",
)
if joint_type == "prismatic":
return lower, upper
return degrees(lower), degrees(upper)
def _validate_optional_float_attr(
element: ET.Element,
attr_name: str,
*,
source_path: Path,
label: str,
) -> float | None:
if attr_name not in element.attrib:
return None
return _required_float_attr(element, attr_name, source_path=source_path, label=label)
def _validate_origin(origin_element: ET.Element | None, *, source_path: Path, label: str) -> None:
if origin_element is None:
return
if "xyz" in origin_element.attrib:
_required_float_vector_attr(
origin_element,
"xyz",
expected_count=3,
source_path=source_path,
label=f"{label} xyz",
)
if "rpy" in origin_element.attrib:
_required_float_vector_attr(
origin_element,
"rpy",
expected_count=3,
source_path=source_path,
label=f"{label} rpy",
)
def _validate_joint_axis(joint_element: ET.Element, *, joint_type: str, source_path: Path) -> None:
axis_element = joint_element.find("axis")
if axis_element is None:
return
axis = _required_float_vector_attr(
axis_element,
"xyz",
expected_count=3,
source_path=source_path,
label=f"joint {joint_element.attrib.get('name', '')!r} axis",
)
if joint_type != "fixed" and all(component == 0.0 for component in axis):
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} joint {joint_element.attrib.get('name', '')!r} axis must be nonzero"
)
def _validate_geometry_element(geometry_element: ET.Element, *, source_path: Path, label: str) -> ET.Element:
geometry_children = list(geometry_element)
if len(geometry_children) != 1:
supported = ", ".join(sorted(SUPPORTED_GEOMETRY_TAGS))
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {label} must define exactly one geometry element: {supported}"
)
geometry_child = geometry_children[0]
if geometry_child.tag not in SUPPORTED_GEOMETRY_TAGS:
supported = ", ".join(sorted(SUPPORTED_GEOMETRY_TAGS))
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {label} must use one of: {supported}"
)
_validate_geometry_child(geometry_child, source_path=source_path, label=label)
return geometry_child
def _validate_geometry_child(geometry_child: ET.Element, *, source_path: Path, label: str) -> None:
if geometry_child.tag == "mesh":
return
if geometry_child.tag == "box":
_required_positive_float_vector_attr(
geometry_child,
"size",
expected_count=3,
source_path=source_path,
label=f"{label} box size",
)
return
if geometry_child.tag == "cylinder":
_required_positive_float_attr(
geometry_child,
"radius",
source_path=source_path,
label=f"{label} cylinder radius",
)
_required_positive_float_attr(
geometry_child,
"length",
source_path=source_path,
label=f"{label} cylinder length",
)
return
if geometry_child.tag == "sphere":
_required_positive_float_attr(
geometry_child,
"radius",
source_path=source_path,
label=f"{label} sphere radius",
)
def _validate_mesh_scale(mesh_element: ET.Element, *, source_path: Path, filename: str) -> None:
if "scale" not in mesh_element.attrib:
return
_required_positive_float_vector_attr(
mesh_element,
"scale",
expected_count=3,
source_path=source_path,
label=f"mesh {filename!r} scale",
)
def _required_positive_float_attr(
element: ET.Element,
attr_name: str,
*,
source_path: Path,
label: str,
) -> float:
value = _required_float_attr(element, attr_name, source_path=source_path, label=label)
if value <= 0.0:
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} must be positive")
return value
def _required_float_vector_attr(
element: ET.Element,
attr_name: str,
*,
expected_count: int,
source_path: Path,
label: str,
) -> tuple[float, ...]:
try:
raw_value = element.attrib[attr_name]
except KeyError as exc:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {label} requires {attr_name!r}"
) from exc
raw_parts = raw_value.split()
if len(raw_parts) != expected_count:
raise UrdfSourceError(
f"{_relative_to_repo(source_path)} {label} must have {expected_count} values"
)
try:
values = tuple(float(part) for part in raw_parts)
except ValueError as exc:
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} is invalid") from exc
if not all(isfinite(value) for value in values):
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} values must be finite")
return values
def _required_positive_float_vector_attr(
element: ET.Element,
attr_name: str,
*,
expected_count: int,
source_path: Path,
label: str,
) -> tuple[float, ...]:
values = _required_float_vector_attr(
element,
attr_name,
expected_count=expected_count,
source_path=source_path,
label=label,
)
if any(value <= 0.0 for value in values):
raise UrdfSourceError(f"{_relative_to_repo(source_path)} {label} values must be positive")
return values
def classify_mesh_uri(uri: str) -> MeshReference:
value = str(uri or "").strip()
parsed = urlparse(value)
if parsed.scheme == "package":
package_name = unquote(parsed.netloc).strip()
package_path = PurePosixPath(unquote(parsed.path).lstrip("/"))
if not package_name or package_path.as_posix() == "." or ".." in package_path.parts:
raise UrdfSourceError(f"package mesh URI is invalid: {uri!r}")
return MeshReference(
uri=value,
kind=MeshUriKind.PACKAGE,
package_name=package_name,
package_path=package_path,
)
if parsed.scheme == "file":
file_path = Path(unquote(parsed.path))
if file_path.is_absolute():
return MeshReference(uri=value, kind=MeshUriKind.LOCAL_ABSOLUTE, path=file_path.resolve())
return MeshReference(uri=value, kind=MeshUriKind.LOCAL_RELATIVE, path=file_path)
if parsed.scheme:
return MeshReference(uri=value, kind=MeshUriKind.REMOTE)
local_path = Path(value)
if local_path.is_absolute():
return MeshReference(uri=value, kind=MeshUriKind.LOCAL_ABSOLUTE, path=local_path.resolve())
return MeshReference(uri=value, kind=MeshUriKind.LOCAL_RELATIVE, path=local_path)
def resolve_mesh_uri(uri: str, package_map: dict[str, Path] | None = None) -> Path | None:
mesh_ref = classify_mesh_uri(uri)
if mesh_ref.kind in {MeshUriKind.LOCAL_RELATIVE, MeshUriKind.LOCAL_ABSOLUTE}:
return mesh_ref.path
if mesh_ref.kind == MeshUriKind.PACKAGE:
package_root = (package_map or {}).get(str(mesh_ref.package_name))
if package_root is None or mesh_ref.package_path is None:
return None
return (Path(package_root).expanduser() / Path(*mesh_ref.package_path.parts)).resolve()
return None
def _warn_unresolved_mesh_uri(uri: str, *, mesh_ref: MeshReference, source_path: Path) -> None:
if mesh_ref.kind == MeshUriKind.PACKAGE:
message = f"WARN: {uri} syntax is valid but was not resolved."
else:
message = f"WARN: {uri} is not a local mesh URI and was not resolved."
warnings.warn(f"{_relative_to_repo(source_path)} {message}", UrdfSourceWarning, stacklevel=3)
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 UrdfSourceError(
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()
Related skills
How it compares
Choose urdf for ROS and Gazebo URDF pipelines; use the sibling sdf skill when the simulator expects SDFormat instead.
FAQ
Do I edit .urdf XML files directly or the Python generator?
Edit the Python gen_urdf() source; treat generated .urdf files as artifacts. The generator is source of truth; manual XML changes will be overwritten on regeneration.
What validation does URDF skill perform?
Generation-time validation runs automatically via scripts/urdf launcher. It checks XML syntax, kinematic graph closure, joint axis semantics, geometry mesh references, inertial data correctness, and frame convention consistency. No separate validate command needed.
How do I handle mesh units and spatial transforms?
Establish a design ledger before editing frames, origins, or mesh scale. Do not infer transforms from vague prose; use CAD exports, dimensioned drawings, measured values, or explicit documented assumptions. Frame semantics differ for joint origins, link frames, joint axes, and vi
Is Urdf safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.