linkforge-core 1.4.0__tar.gz → 1.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/.gitignore +3 -0
  2. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/PKG-INFO +14 -34
  3. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/README.md +13 -33
  4. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/pyproject.toml +1 -1
  5. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/__init__.py +3 -3
  6. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/xml_utils.py +6 -25
  7. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/link_builder.py +1 -1
  8. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/semantic_builder.py +26 -20
  9. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/constants.py +2 -21
  10. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/exceptions.py +0 -6
  11. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/generators/urdf_generator.py +0 -39
  12. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/link.py +16 -15
  13. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/robot.py +6 -4
  14. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/sensor.py +8 -1
  15. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/parsers/urdf_parser.py +10 -10
  16. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/parsers/xacro_parser.py +116 -9
  17. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/parsers/xml_base.py +2 -2
  18. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/validation/security.py +5 -1
  19. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/LICENSE +0 -0
  20. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/__init__.py +0 -0
  21. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/dict_utils.py +0 -0
  22. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/math_utils.py +0 -0
  23. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/path_utils.py +0 -0
  24. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/_utils/string_utils.py +0 -0
  25. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/base.py +0 -0
  26. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/__init__.py +0 -0
  27. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/helpers.py +0 -0
  28. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/interfaces.py +0 -0
  29. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/composer/robot_builder.py +3 -3
  30. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/generators/__init__.py +0 -0
  31. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/generators/srdf_generator.py +0 -0
  32. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/generators/xacro_generator.py +0 -0
  33. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/generators/xml_base.py +0 -0
  34. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/io.py +0 -0
  35. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/logging_config.py +0 -0
  36. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/__init__.py +0 -0
  37. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/gazebo.py +0 -0
  38. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/geometry.py +0 -0
  39. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/graph.py +0 -0
  40. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/joint.py +0 -0
  41. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/material.py +0 -0
  42. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/ros2_control.py +0 -0
  43. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/srdf.py +0 -0
  44. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/models/transmission.py +0 -0
  45. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/parsers/__init__.py +0 -0
  46. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/parsers/srdf_parser.py +0 -0
  47. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/physics/__init__.py +0 -0
  48. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/physics/inertia.py +0 -0
  49. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/physics/mesh_validation.py +0 -0
  50. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/py.typed +0 -0
  51. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/validation/__init__.py +0 -0
  52. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/validation/checks.py +0 -0
  53. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/validation/result.py +0 -0
  54. {linkforge_core-1.4.0 → linkforge_core-1.4.2}/src/linkforge/core/validation/validator.py +0 -0
@@ -105,3 +105,6 @@ site/
105
105
 
106
106
  # Linting Tools
107
107
  actionlint
108
+
109
+ # Blender Development Symlinks
110
+ platforms/blender/src/linkforge/blender/core
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linkforge-core
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: LinkForge Core: Programmable IR and Physical Validation Engine for Robotics
5
5
  Project-URL: Homepage, https://github.com/arounamounchili/linkforge
6
6
  Project-URL: Documentation, https://linkforge.readthedocs.io/
@@ -37,17 +37,13 @@ Description-Content-Type: text/markdown
37
37
  <a href="https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License"></a>
38
38
  </p>
39
39
 
40
- ---
41
-
42
- ## 🔭 What Is LinkForge Core?
40
+ ## What Is LinkForge Core?
43
41
 
44
42
  Writing and maintaining URDF or SRDF by hand is fragile: inertia values are guessed, collision geometries drift, and physics bugs surface only after a simulator crash (or worse, on hardware). LinkForge Core solves this by treating your robot as **source code with physical constraints**, not a static XML document.
45
43
 
46
44
  It provides a mathematically rigorous, zero-dependency Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
47
45
 
48
- ---
49
-
50
- ## 📦 Installation
46
+ ## Installation
51
47
 
52
48
  ```bash
53
49
  pip install linkforge-core
@@ -55,18 +51,14 @@ pip install linkforge-core
55
51
 
56
52
  Zero external dependencies. No Blender, no ROS installation, no C++ compilation required.
57
53
 
58
- ---
59
-
60
- ## ⚡ Why LinkForge Core?
54
+ ## Why LinkForge Core?
61
55
 
62
- - **⚖️ Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
63
- - **🔌 Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
64
- - **🛡️ Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
65
- - **📦 Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
56
+ - **Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
57
+ - **Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
58
+ - **Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
59
+ - **Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
66
60
 
67
- ---
68
-
69
- ## 🚀 Quickstart
61
+ ## Quickstart
70
62
 
71
63
  LinkForge Core exposes a flat, curated public API. No nested import paths required.
72
64
 
@@ -100,9 +92,7 @@ builder.link("upper_arm", parent="base_link") \
100
92
  urdf_xml = builder.export_urdf()
101
93
  ```
102
94
 
103
- ---
104
-
105
- ## 💎 Key Capabilities
95
+ ## Key Capabilities
106
96
 
107
97
  ### Parse, Validate & Compile (Full Round-Trip)
108
98
 
@@ -126,8 +116,6 @@ else:
126
116
  print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
127
117
  ```
128
118
 
129
- ---
130
-
131
119
  ### MoveIt 2 & SRDF Semantic Composition
132
120
 
133
121
  Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
@@ -162,8 +150,6 @@ semantic = read_srdf("my_robot.srdf")
162
150
  write_srdf(semantic, "my_robot_updated.srdf")
163
151
  ```
164
152
 
165
- ---
166
-
167
153
  ### Exact Solid-Body Inertia Solver
168
154
 
169
155
  Compute principal moments of inertia and Center of Mass offsets for primitives or complex triangle meshes, hardened with local origin conditioning for floating-point accuracy:
@@ -176,8 +162,6 @@ geometry = Box(size=Vector3(1.0, 0.5, 0.3))
176
162
  inertia = calculate_inertia(geometry, mass=10.0)
177
163
  ```
178
164
 
179
- ---
180
-
181
165
  ### Sensor Suite
182
166
 
183
167
  Define cameras, LiDAR, IMU, GPS, and force/torque sensors with configurable noise models directly in the IR:
@@ -199,8 +183,6 @@ builder.link("camera_link", parent="base_link") \
199
183
  .commit()
200
184
  ```
201
185
 
202
- ---
203
-
204
186
  ### Headless Use in CI / ML Pipelines
205
187
 
206
188
  `linkforge-core` has zero GUI dependencies, making it ideal for headless environments:
@@ -227,10 +209,8 @@ pip install linkforge-core
227
209
  python ci_validate.py
228
210
  ```
229
211
 
230
- ---
231
-
232
- ## 📚 Resources & Documentation
212
+ ## Resources & Documentation
233
213
 
234
- - **📚 Extensive Documentation**: Read the tutorials and how-to guides at [linkforge.readthedocs.io](https://linkforge.readthedocs.io/).
235
- - **🐙 Open Source Repository**: View source, open issues, and join discussions on [GitHub](https://github.com/arounamounchili/linkforge).
236
- - **📄 License**: Standard open-source **[Apache-2.0 License](https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE)**.
214
+ - **Extensive Documentation**: Read the tutorials and how-to guides at [linkforge.readthedocs.io](https://linkforge.readthedocs.io/).
215
+ - **Open Source Repository**: View source, open issues, and join discussions on [GitHub](https://github.com/arounamounchili/linkforge).
216
+ - **License**: Standard open-source **[Apache-2.0 License](https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE)**.
@@ -8,17 +8,13 @@
8
8
  <a href="https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License"></a>
9
9
  </p>
10
10
 
11
- ---
12
-
13
- ## 🔭 What Is LinkForge Core?
11
+ ## What Is LinkForge Core?
14
12
 
15
13
  Writing and maintaining URDF or SRDF by hand is fragile: inertia values are guessed, collision geometries drift, and physics bugs surface only after a simulator crash (or worse, on hardware). LinkForge Core solves this by treating your robot as **source code with physical constraints**, not a static XML document.
16
14
 
17
15
  It provides a mathematically rigorous, zero-dependency Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
18
16
 
19
- ---
20
-
21
- ## 📦 Installation
17
+ ## Installation
22
18
 
23
19
  ```bash
24
20
  pip install linkforge-core
@@ -26,18 +22,14 @@ pip install linkforge-core
26
22
 
27
23
  Zero external dependencies. No Blender, no ROS installation, no C++ compilation required.
28
24
 
29
- ---
30
-
31
- ## ⚡ Why LinkForge Core?
25
+ ## Why LinkForge Core?
32
26
 
33
- - **⚖️ Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
34
- - **🔌 Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
35
- - **🛡️ Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
36
- - **📦 Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
27
+ - **Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
28
+ - **Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
29
+ - **Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
30
+ - **Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
37
31
 
38
- ---
39
-
40
- ## 🚀 Quickstart
32
+ ## Quickstart
41
33
 
42
34
  LinkForge Core exposes a flat, curated public API. No nested import paths required.
43
35
 
@@ -71,9 +63,7 @@ builder.link("upper_arm", parent="base_link") \
71
63
  urdf_xml = builder.export_urdf()
72
64
  ```
73
65
 
74
- ---
75
-
76
- ## 💎 Key Capabilities
66
+ ## Key Capabilities
77
67
 
78
68
  ### Parse, Validate & Compile (Full Round-Trip)
79
69
 
@@ -97,8 +87,6 @@ else:
97
87
  print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
98
88
  ```
99
89
 
100
- ---
101
-
102
90
  ### MoveIt 2 & SRDF Semantic Composition
103
91
 
104
92
  Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
@@ -133,8 +121,6 @@ semantic = read_srdf("my_robot.srdf")
133
121
  write_srdf(semantic, "my_robot_updated.srdf")
134
122
  ```
135
123
 
136
- ---
137
-
138
124
  ### Exact Solid-Body Inertia Solver
139
125
 
140
126
  Compute principal moments of inertia and Center of Mass offsets for primitives or complex triangle meshes, hardened with local origin conditioning for floating-point accuracy:
@@ -147,8 +133,6 @@ geometry = Box(size=Vector3(1.0, 0.5, 0.3))
147
133
  inertia = calculate_inertia(geometry, mass=10.0)
148
134
  ```
149
135
 
150
- ---
151
-
152
136
  ### Sensor Suite
153
137
 
154
138
  Define cameras, LiDAR, IMU, GPS, and force/torque sensors with configurable noise models directly in the IR:
@@ -170,8 +154,6 @@ builder.link("camera_link", parent="base_link") \
170
154
  .commit()
171
155
  ```
172
156
 
173
- ---
174
-
175
157
  ### Headless Use in CI / ML Pipelines
176
158
 
177
159
  `linkforge-core` has zero GUI dependencies, making it ideal for headless environments:
@@ -198,10 +180,8 @@ pip install linkforge-core
198
180
  python ci_validate.py
199
181
  ```
200
182
 
201
- ---
202
-
203
- ## 📚 Resources & Documentation
183
+ ## Resources & Documentation
204
184
 
205
- - **📚 Extensive Documentation**: Read the tutorials and how-to guides at [linkforge.readthedocs.io](https://linkforge.readthedocs.io/).
206
- - **🐙 Open Source Repository**: View source, open issues, and join discussions on [GitHub](https://github.com/arounamounchili/linkforge).
207
- - **📄 License**: Standard open-source **[Apache-2.0 License](https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE)**.
185
+ - **Extensive Documentation**: Read the tutorials and how-to guides at [linkforge.readthedocs.io](https://linkforge.readthedocs.io/).
186
+ - **Open Source Repository**: View source, open issues, and join discussions on [GitHub](https://github.com/arounamounchili/linkforge).
187
+ - **License**: Standard open-source **[Apache-2.0 License](https://github.com/arounamounchili/linkforge/blob/main/core/LICENSE)**.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "linkforge-core"
3
- version = "1.4.0" # x-release-please-version
3
+ version = "1.4.2" # x-release-please-version
4
4
  description = "LinkForge Core: Programmable IR and Physical Validation Engine for Robotics"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -11,7 +11,7 @@ for parsing, generating, and validating robot descriptions across formats.
11
11
  from __future__ import annotations
12
12
 
13
13
  # Versioning
14
- __version__ = "1.4.0" # x-release-please-version
14
+ __version__ = "1.4.2" # x-release-please-version
15
15
 
16
16
  # Sub-Package Exposure
17
17
  # (Ensures lf.models, lf.parsers, etc. are accessible via dot-notation)
@@ -47,9 +47,9 @@ from .composer import (
47
47
 
48
48
  # Core Infrastructure: Constants and Exceptions
49
49
  from .constants import (
50
- DEFAULT_GRAVITY,
51
50
  DEFAULT_LINK_MASS,
52
51
  EPSILON,
52
+ GRAVITY_ENABLED,
53
53
  PI,
54
54
  )
55
55
  from .exceptions import (
@@ -293,7 +293,7 @@ __all__ = [
293
293
  # Constants
294
294
  "PI",
295
295
  "EPSILON",
296
- "DEFAULT_GRAVITY",
296
+ "GRAVITY_ENABLED",
297
297
  "DEFAULT_LINK_MASS",
298
298
  # Logging
299
299
  "get_logger",
@@ -136,11 +136,14 @@ def serialize_xml(
136
136
 
137
137
  # Ensure namespaces are explicitly present on root if ElementTree dropped them
138
138
  if namespaces:
139
+ import re
140
+
139
141
  for prefix, uri in namespaces.items():
140
142
  ns_attr = f'xmlns:{prefix}="{uri}"'
141
- # Using a more robust check for root tag insertion
142
- if ns_attr not in xml_str and "<robot" in xml_str:
143
- xml_str = xml_str.replace("<robot", f"<robot {ns_attr}", 1)
143
+ if ns_attr not in xml_str:
144
+ xml_str = re.sub(
145
+ rf"<{element.tag}\b", f"<{element.tag} {ns_attr}", xml_str, count=1
146
+ )
144
147
 
145
148
  return get_xml_header(element, version) + xml_str
146
149
 
@@ -350,28 +353,6 @@ def xml_add_text(parent: ET.Element, tag: str, value: Any) -> ET.Element:
350
353
  return elem
351
354
 
352
355
 
353
- def xml_add_vector(
354
- parent: ET.Element,
355
- tag: str,
356
- vector: Vector3,
357
- formatter: Callable[[float], str],
358
- ) -> ET.Element:
359
- """Create a sub-element for a vector relying on a formatter for the values.
360
-
361
- Args:
362
- parent: The parent XML element.
363
- tag: The tag name for the new element.
364
- vector: The Vector3 object containing the values.
365
- formatter: A callable that takes a float and returns a string (e.g., format_float).
366
-
367
- Returns:
368
- The newly created XML element with the formatted text string.
369
- """
370
- # Create text from formatted components
371
- text_val = f"{formatter(vector.x)} {formatter(vector.y)} {formatter(vector.z)}"
372
- return xml_add_text(parent, tag, text_val)
373
-
374
-
375
356
  def create_xml_element(
376
357
  parent: ET.Element,
377
358
  tag: str,
@@ -983,7 +983,7 @@ class LinkBuilder:
983
983
  if l_state.inertial_origin is None:
984
984
  l_state.inertial_origin = source_origin
985
985
  else:
986
- l_state.inertia = InertiaTensor.zero()
986
+ l_state.inertia = InertiaTensor.stability_floor()
987
987
 
988
988
  return Inertial(
989
989
  mass=l_state.mass,
@@ -36,6 +36,10 @@ class SemanticBuilder:
36
36
  """Initialize semantic builder."""
37
37
  self._builder = builder
38
38
 
39
+ def done(self) -> IComposer:
40
+ """Return to the parent RobotBuilder."""
41
+ return self._builder
42
+
39
43
  def group(
40
44
  self,
41
45
  name: str,
@@ -45,7 +49,7 @@ class SemanticBuilder:
45
49
  subgroups: list[str] | None = None,
46
50
  base_link: str | None = None,
47
51
  tip_link: str | None = None,
48
- ) -> IComposer:
52
+ ) -> SemanticBuilder:
49
53
  """Define a planning group for MoveIt.
50
54
 
51
55
  Args:
@@ -75,11 +79,11 @@ class SemanticBuilder:
75
79
 
76
80
  semantic = self._builder.robot.semantic
77
81
  self._builder.robot.semantic = replace(semantic, groups=tuple(semantic.groups) + (group,))
78
- return self._builder
82
+ return self
79
83
 
80
84
  def group_state(
81
85
  self, name: str, group: str, values: dict[str, float | tuple[float, ...]]
82
- ) -> IComposer:
86
+ ) -> SemanticBuilder:
83
87
  """Define a named state (e.g. 'home') for a planning group.
84
88
 
85
89
  Args:
@@ -98,11 +102,11 @@ class SemanticBuilder:
98
102
  self._builder.robot.semantic = replace(
99
103
  semantic, group_states=tuple(semantic.group_states) + (state,)
100
104
  )
101
- return self._builder
105
+ return self
102
106
 
103
107
  def end_effector(
104
108
  self, name: str, group: str, parent_link: str, parent_group: str | None = None
105
- ) -> IComposer:
109
+ ) -> SemanticBuilder:
106
110
  """Define an end effector for MoveIt.
107
111
 
108
112
  Args:
@@ -119,9 +123,9 @@ class SemanticBuilder:
119
123
  self._builder.robot.semantic = replace(
120
124
  semantic, end_effectors=tuple(semantic.end_effectors) + (ee,)
121
125
  )
122
- return self._builder
126
+ return self
123
127
 
124
- def passive_joint(self, name: str) -> IComposer:
128
+ def passive_joint(self, name: str) -> SemanticBuilder:
125
129
  """Mark a joint as passive (not actuated) for MoveIt.
126
130
 
127
131
  Args:
@@ -135,7 +139,7 @@ class SemanticBuilder:
135
139
  self._builder.robot.semantic = replace(
136
140
  semantic, passive_joints=tuple(semantic.passive_joints) + (pj,)
137
141
  )
138
- return self._builder
142
+ return self
139
143
 
140
144
  def virtual_joint(
141
145
  self,
@@ -143,7 +147,7 @@ class SemanticBuilder:
143
147
  child_link: str,
144
148
  parent_frame: str = "world",
145
149
  joint_type: str = SRDF_VJOIN_FIXED,
146
- ) -> IComposer:
150
+ ) -> SemanticBuilder:
147
151
  """Define a virtual joint connecting the robot to the world frame.
148
152
 
149
153
  Args:
@@ -162,11 +166,11 @@ class SemanticBuilder:
162
166
  self._builder.robot.semantic = replace(
163
167
  semantic, virtual_joints=tuple(semantic.virtual_joints) + (vj,)
164
168
  )
165
- return self._builder
169
+ return self
166
170
 
167
171
  def disable_collisions(
168
172
  self, link1: str, link2: str, reason: str = SRDF_REASON_ADJACENT
169
- ) -> IComposer:
173
+ ) -> SemanticBuilder:
170
174
  """Instruct MoveIt to ignore collisions between two specific links.
171
175
 
172
176
  Args:
@@ -181,9 +185,11 @@ class SemanticBuilder:
181
185
  self._builder.robot.semantic = replace(
182
186
  semantic, disabled_collisions=tuple(semantic.disabled_collisions) + (dc,)
183
187
  )
184
- return self._builder
188
+ return self
185
189
 
186
- def enable_collisions(self, link1: str, link2: str, reason: str | None = None) -> IComposer:
190
+ def enable_collisions(
191
+ self, link1: str, link2: str, reason: str | None = None
192
+ ) -> SemanticBuilder:
187
193
  """Explicitly re-enable collision checking between two specific links.
188
194
 
189
195
  Args:
@@ -198,9 +204,9 @@ class SemanticBuilder:
198
204
  self._builder.robot.semantic = replace(
199
205
  semantic, enabled_collisions=tuple(semantic.enabled_collisions) + (ec,)
200
206
  )
201
- return self._builder
207
+ return self
202
208
 
203
- def disable_default_collisions(self, link: str) -> IComposer:
209
+ def disable_default_collisions(self, link: str) -> SemanticBuilder:
204
210
  """Disable all default collisions for a specific link.
205
211
 
206
212
  Args:
@@ -214,9 +220,9 @@ class SemanticBuilder:
214
220
  semantic,
215
221
  no_default_collision_links=tuple(semantic.no_default_collision_links) + (link,),
216
222
  )
217
- return self._builder
223
+ return self
218
224
 
219
- def joint_property(self, joint_name: str, property_name: str, value: str) -> IComposer:
225
+ def joint_property(self, joint_name: str, property_name: str, value: str) -> SemanticBuilder:
220
226
  """Add a custom property/metadata to a joint.
221
227
 
222
228
  Args:
@@ -232,9 +238,9 @@ class SemanticBuilder:
232
238
  self._builder.robot.semantic = replace(
233
239
  semantic, joint_properties=tuple(semantic.joint_properties) + (jp,)
234
240
  )
235
- return self._builder
241
+ return self
236
242
 
237
- def approximate_link_collision(self, link: str, spheres: list[SrdfSphere]) -> IComposer:
243
+ def approximate_link_collision(self, link: str, spheres: list[SrdfSphere]) -> SemanticBuilder:
238
244
  """Add sphere-based collision approximation for a link.
239
245
 
240
246
  Args:
@@ -250,4 +256,4 @@ class SemanticBuilder:
250
256
  semantic,
251
257
  link_sphere_approximations=tuple(semantic.link_sphere_approximations) + (lsa,),
252
258
  )
253
- return self._builder
259
+ return self
@@ -75,10 +75,6 @@ MAX_XML_DEPTH: Final[int] = 2000
75
75
 
76
76
  # Geometric and Mesh thresholds
77
77
  DEGENERATE_VOL_THRESHOLD: Final[float] = 1e-12 # m³
78
- NEGATIVE_INERTIA_THRESHOLD: Final[float] = -1e-06
79
- MESH_PROXIMITY_THRESHOLD: Final[int] = 6
80
- MESH_SLIVER_THRESHOLD: Final[float] = 1000.0
81
- MIN_MESH_AREA: Final[float] = 1e-15 # m²
82
78
 
83
79
 
84
80
  # 4. Global Physics Defaults
@@ -94,7 +90,7 @@ DEFAULT_CONTACT_KP: Final[float] = 1e12 # N/m
94
90
  DEFAULT_CONTACT_KD: Final[float] = 1.0 # N·s/m
95
91
 
96
92
  # Simulation toggles
97
- DEFAULT_GRAVITY: Final[bool] = True
93
+ GRAVITY_ENABLED: Final[bool] = True
98
94
  DEFAULT_SELF_COLLIDE: Final[bool] = False
99
95
 
100
96
 
@@ -105,13 +101,11 @@ DEFAULT_SELF_COLLIDE: Final[bool] = False
105
101
  DEFAULT_LINK_MASS: Final[float] = 1.0 # kg
106
102
  DEFAULT_MATERIAL_RGBA: Final[tuple[float, float, float, float]] = (0.7, 0.7, 0.7, 1.0)
107
103
  DEFAULT_MATERIAL_RGBA_STR: Final[str] = "0.7 0.7 0.7 1.0"
108
- DEFAULT_MESH_SCALE_STR: Final[str] = "1 1 1"
109
104
  DEFAULT_GEOMETRY_RADIUS: Final[float] = 0.1 # m
110
105
  DEFAULT_GEOMETRY_LENGTH: Final[float] = 0.5 # m
111
106
 
112
107
  # Joint Defaults
113
108
  DEFAULT_AXIS_XYZ: Final[tuple[float, float, float]] = (0.0, 0.0, 1.0)
114
- DEFAULT_AXIS_XYZ_STR: Final[str] = "0 0 1"
115
109
  DEFAULT_URDF_AXIS_XYZ: Final[tuple[float, float, float]] = (1.0, 0.0, 0.0) # URDF spec default
116
110
  DEFAULT_URDF_AXIS_XYZ_STR: Final[str] = "1 0 0"
117
111
 
@@ -146,7 +140,6 @@ CAM_FORMAT_BAYER_BGGR8: Final[str] = "BAYER_BGGR8"
146
140
 
147
141
  # LIDAR Horizontal Parameters
148
142
  DEFAULT_LIDAR_SAMPLES: Final[int] = 640
149
- DEFAULT_LIDAR_HORIZONTAL_RESOLUTION: Final[float] = 1.0
150
143
  DEFAULT_LIDAR_RANGE_MIN: Final[float] = 0.1 # m
151
144
  DEFAULT_LIDAR_RANGE_MAX: Final[float] = 10.0 # m
152
145
  DEFAULT_LIDAR_RANGE_RESOLUTION: Final[float] = 0.01 # m
@@ -185,6 +178,7 @@ DEFAULT_JOINT_TYPE: Final[str] = JOINT_REVOLUTE
185
178
  SENSOR_CAMERA: Final[str] = "camera"
186
179
  SENSOR_DEPTH_CAMERA: Final[str] = "depth_camera"
187
180
  SENSOR_LIDAR: Final[str] = "lidar"
181
+ SENSOR_RAY: Final[str] = "ray"
188
182
  SENSOR_GPU_LIDAR: Final[str] = "gpu_lidar"
189
183
  SENSOR_IMU: Final[str] = "imu"
190
184
  SENSOR_GPS: Final[str] = "gps"
@@ -213,20 +207,14 @@ SRDF_VJOIN_PLANAR: Final[str] = "planar"
213
207
  SRDF_VJOIN_FLOATING: Final[str] = "floating"
214
208
 
215
209
  SRDF_REASON_ADJACENT: Final[str] = "Adjacent"
216
- SRDF_REASON_NEVER: Final[str] = "Never"
217
- SRDF_REASON_USER: Final[str] = "User"
218
- SRDF_REASON_DEFAULT: Final[str] = "Default"
219
210
 
220
211
  # Standard fallback names
221
212
  UNNAMED_LINK: Final[str] = "unnamed_link"
222
213
  UNNAMED_JOINT: Final[str] = "unnamed_joint"
223
- UNNAMED_SENSOR: Final[str] = "unnamed_sensor"
224
214
 
225
215
  # Sensor Update Rates (Industry standard defaults)
226
216
  DEFAULT_UPDATE_RATE_IMU: Final[float] = 100.0 # Hz
227
217
  DEFAULT_UPDATE_RATE_GPS: Final[float] = 5.0 # Hz
228
- DEFAULT_UPDATE_RATE_LIDAR: Final[float] = 30.0 # Hz
229
- DEFAULT_UPDATE_RATE_CAMERA: Final[float] = 30.0 # Hz
230
218
  DEFAULT_UPDATE_RATE_CONTACT: Final[float] = 50.0 # Hz
231
219
  DEFAULT_UPDATE_RATE_FORCE_TORQUE: Final[float] = 100.0 # Hz
232
220
 
@@ -261,13 +249,6 @@ GZ_SENSOR_IMU: Final[str] = "imu"
261
249
  GZ_SENSOR_CONTACT: Final[str] = "contact"
262
250
  GZ_SENSOR_FORCE_TORQUE: Final[str] = "force_torque"
263
251
 
264
- # Gazebo / GZ XML Elements and Attributes
265
- GZ_ELEM_GAZEBO: Final[str] = "gazebo"
266
- GZ_ELEM_SENSOR: Final[str] = "sensor"
267
- GZ_ELEM_PLUGIN: Final[str] = "plugin"
268
- GZ_ATTR_REFERENCE: Final[str] = "reference"
269
- COLLISION_ADJACENT: Final[str] = "Adjacent"
270
-
271
252
  # XACRO Parameters and Attributes
272
253
  XACRO_PARAM_NAME: Final[str] = "name"
273
254
  XACRO_PARAM_PARENT: Final[str] = "parent"
@@ -29,14 +29,12 @@ class ValidationErrorCode(StrEnum):
29
29
  HAS_CYCLE = "has_cycle"
30
30
  NO_ROOT = "no_root"
31
31
  MULTIPLE_ROOTS = "multiple_roots"
32
- DISCONNECTED = "disconnected"
33
32
 
34
33
  # Physics and Values
35
34
  OUT_OF_RANGE = "out_of_range"
36
35
  VALUE_EMPTY = "value_empty"
37
36
  INVALID_VALUE = "invalid_value"
38
37
  PHYSICS_VIOLATION = "physics_violation"
39
- MATH_ERROR = "math_error"
40
38
  INERTIA_TRIANGLE_INEQUALITY = "inertia_triangle_inequality"
41
39
 
42
40
  # Mesh Topology
@@ -49,7 +47,6 @@ class ValidationErrorCode(StrEnum):
49
47
  MESH_SLIVER = "mesh_sliver"
50
48
 
51
49
  # Configuration and Misc
52
- MISMATCH = "mismatch"
53
50
  GENERIC_FAILURE = "generic_failure"
54
51
 
55
52
 
@@ -114,7 +111,6 @@ class RobotPhysicsError(RobotModelError):
114
111
  self.code = code
115
112
  self.target = target
116
113
  self.value = value
117
- self.raw_message = message
118
114
 
119
115
  full_msg = f"[PHYSICS_{code.name}] {message}"
120
116
  if target:
@@ -141,7 +137,6 @@ class RobotValidationError(RobotModelError):
141
137
  self.code = code
142
138
  self.target = target
143
139
  self.value = value
144
- self.raw_message = message
145
140
 
146
141
  full_msg = f"[{code.name}] {message}"
147
142
  if target:
@@ -172,7 +167,6 @@ class RobotMathError(RobotModelError):
172
167
  self.code = code
173
168
  self.target = target
174
169
  self.value = value
175
- self.raw_message = message
176
170
 
177
171
  full_msg = f"[MATH_{code.name}] {message}"
178
172
  if target:
@@ -782,45 +782,6 @@ class URDFGenerator(RobotXMLGenerator):
782
782
  bias_stddev_elem = ET.SubElement(noise_elem, "bias_stddev")
783
783
  bias_stddev_elem.text = format_float(noise.bias_stddev)
784
784
 
785
- def _add_gazebo_element(self, parent: ET.Element, gazebo_elem: GazeboElement) -> None:
786
- """Add Gazebo extension element to parent.
787
-
788
- Args:
789
- parent: Parent XML element
790
- gazebo_elem: GazeboElement model
791
-
792
- """
793
- # Create gazebo element with optional reference attribute
794
- attrib: dict[str, str] = {}
795
- if gazebo_elem.reference is not None:
796
- attrib["reference"] = gazebo_elem.reference
797
-
798
- gz_elem = create_xml_element(parent, "gazebo", formatter=self._format_value, **attrib)
799
-
800
- # Add material if specified
801
- if gazebo_elem.material is not None:
802
- xml_add_text(gz_elem, "material", gazebo_elem.material)
803
-
804
- # Add boolean properties
805
- self._add_optional_bool_element(gz_elem, "static", gazebo_elem.static)
806
- self._add_optional_bool_element(gz_elem, "provideFeedback", gazebo_elem.provide_feedback)
807
- self._add_optional_bool_element(
808
- gz_elem, "implicitSpringDamper", gazebo_elem.implicit_spring_damper
809
- )
810
-
811
- # Add numeric properties
812
- self._add_optional_numeric_element(gz_elem, "stopCfm", gazebo_elem.stop_cfm)
813
- self._add_optional_numeric_element(gz_elem, "stopErp", gazebo_elem.stop_erp)
814
-
815
- # Add custom properties (sort by key for deterministic output)
816
- for key in sorted(gazebo_elem.properties.keys()):
817
- prop_elem = ET.SubElement(gz_elem, key)
818
- prop_elem.text = gazebo_elem.properties[key]
819
-
820
- # Add plugins (sort by name for deterministic output)
821
- for plugin in sorted(gazebo_elem.plugins, key=lambda p: p.name):
822
- self._add_gazebo_plugin_element(gz_elem, plugin)
823
-
824
785
  def _add_gazebo_plugin_element(self, parent: ET.Element, plugin: GazeboPlugin) -> None:
825
786
  """Add Gazebo plugin element to parent.
826
787
 
@@ -21,9 +21,9 @@ from ..constants import (
21
21
  DEFAULT_CONTACT_KP,
22
22
  DEFAULT_FRICTION_MU,
23
23
  DEFAULT_FRICTION_MU2,
24
- DEFAULT_GRAVITY,
25
24
  DEFAULT_SELF_COLLIDE,
26
25
  EPSILON,
26
+ GRAVITY_ENABLED,
27
27
  MIN_REASONABLE_INERTIA,
28
28
  )
29
29
  from ..exceptions import RobotPhysicsError, RobotValidationError, ValidationErrorCode
@@ -46,11 +46,11 @@ class InertiaTensor:
46
46
  """
47
47
 
48
48
  ixx: float
49
- ixy: float
50
- ixz: float
51
49
  iyy: float
52
- iyz: float
53
50
  izz: float
51
+ ixy: float = 0.0
52
+ ixz: float = 0.0
53
+ iyz: float = 0.0
54
54
 
55
55
  def __post_init__(self) -> None:
56
56
  """Validate inertia tensor values."""
@@ -78,15 +78,16 @@ class InertiaTensor:
78
78
  )
79
79
 
80
80
  @classmethod
81
- def zero(cls) -> InertiaTensor:
82
- """Create a minimal valid inertia tensor (for massless links)."""
81
+ def stability_floor(cls) -> InertiaTensor:
82
+ """Create a minimal valid inertia tensor for stability (e.g. massless links).
83
+
84
+ Returns a tensor with MIN_REASONABLE_INERTIA on the diagonals to prevent
85
+ physics solver instability.
86
+ """
83
87
  return cls(
84
- MIN_REASONABLE_INERTIA,
85
- 0.0,
86
- 0.0,
87
- MIN_REASONABLE_INERTIA,
88
- 0.0,
89
- MIN_REASONABLE_INERTIA,
88
+ ixx=MIN_REASONABLE_INERTIA,
89
+ iyy=MIN_REASONABLE_INERTIA,
90
+ izz=MIN_REASONABLE_INERTIA,
90
91
  )
91
92
 
92
93
 
@@ -96,7 +97,7 @@ class Inertial:
96
97
 
97
98
  mass: float
98
99
  origin: Transform = Transform.identity()
99
- inertia: InertiaTensor = field(default_factory=InertiaTensor.zero)
100
+ inertia: InertiaTensor = field(default_factory=InertiaTensor.stability_floor)
100
101
 
101
102
  def __post_init__(self) -> None:
102
103
  """Validate inertial properties.
@@ -122,7 +123,7 @@ class LinkPhysics:
122
123
  """
123
124
 
124
125
  self_collide: bool = DEFAULT_SELF_COLLIDE
125
- gravity: bool = DEFAULT_GRAVITY
126
+ gravity: bool = GRAVITY_ENABLED
126
127
  mu: float = DEFAULT_FRICTION_MU
127
128
  mu2: float = DEFAULT_FRICTION_MU2
128
129
  kp: float = DEFAULT_CONTACT_KP
@@ -211,7 +212,7 @@ class Link:
211
212
  @property
212
213
  def inertia(self) -> InertiaTensor:
213
214
  """Get link inertia tensor (zero tensor if not defined)."""
214
- return self.inertial.inertia if self.inertial else InertiaTensor.zero()
215
+ return self.inertial.inertia if self.inertial else InertiaTensor.stability_floor()
215
216
 
216
217
  @property
217
218
  def inertial_origin(self) -> Transform:
@@ -24,8 +24,8 @@ from typing import Any
24
24
  from .._utils.string_utils import is_valid_name
25
25
  from ..base import FileSystemResolver, IResourceResolver
26
26
  from ..constants import (
27
- COLLISION_ADJACENT,
28
27
  IR_VERSION,
28
+ SRDF_REASON_ADJACENT,
29
29
  )
30
30
  from ..exceptions import RobotValidationError, ValidationErrorCode
31
31
  from .gazebo import GazeboElement
@@ -856,13 +856,15 @@ class Robot:
856
856
  self.semantic = replace(self.semantic, groups=tuple(self.semantic.groups) + (group,))
857
857
  return self
858
858
 
859
- def disable_collisions(self, link1: str, link2: str, reason: str = COLLISION_ADJACENT) -> Robot:
859
+ def disable_collisions(
860
+ self, link1: str, link2: str, reason: str = SRDF_REASON_ADJACENT
861
+ ) -> Robot:
860
862
  """Disable collision checking between two links.
861
863
 
862
864
  Args:
863
865
  link1: First link name.
864
866
  link2: Second link name.
865
- reason: Reason for disabling (default: COLLISION_ADJACENT).
867
+ reason: Reason for disabling (default: SRDF_REASON_ADJACENT).
866
868
 
867
869
  Returns:
868
870
  The robot instance for chaining.
@@ -881,7 +883,7 @@ class Robot:
881
883
  )
882
884
  return self
883
885
 
884
- def disable_all_collisions(self, links: list[str], reason: str = COLLISION_ADJACENT) -> Robot:
886
+ def disable_all_collisions(self, links: list[str], reason: str = SRDF_REASON_ADJACENT) -> Robot:
885
887
  """Disable collision checking between all pairs in the provided list.
886
888
 
887
889
  Args:
@@ -50,6 +50,7 @@ from ..constants import (
50
50
  SENSOR_GPU_LIDAR,
51
51
  SENSOR_IMU,
52
52
  SENSOR_LIDAR,
53
+ SENSOR_RAY,
53
54
  )
54
55
  from ..exceptions import RobotValidationError, ValidationErrorCode
55
56
  from .gazebo import GazeboPlugin
@@ -57,11 +58,17 @@ from .geometry import Transform
57
58
 
58
59
 
59
60
  class SensorType(StrEnum):
60
- """Enumeration of supported sensor types in the LinkForge IR."""
61
+ """Enumeration of supported sensor types in the LinkForge IR.
62
+
63
+ Note on LIDAR vs RAY:
64
+ - LIDAR is the modern Gazebo Harmonic/GZ standard ("lidar")
65
+ - RAY is provided for Gazebo Classic / ROS1 compatibility ("ray")
66
+ """
61
67
 
62
68
  CAMERA = SENSOR_CAMERA
63
69
  DEPTH_CAMERA = SENSOR_DEPTH_CAMERA
64
70
  LIDAR = SENSOR_LIDAR
71
+ RAY = SENSOR_RAY
65
72
  GPU_LIDAR = SENSOR_GPU_LIDAR
66
73
  IMU = SENSOR_IMU
67
74
  GPS = SENSOR_GPS
@@ -1031,13 +1031,13 @@ class URDFParser(RobotXMLParser[Robot]):
1031
1031
  try:
1032
1032
  link = self._parse_link(elem, materials, source_directory)
1033
1033
  robot.add_link(link)
1034
- except (RobotModelError, ValueError, Exception) as e:
1034
+ except (RobotModelError, ValueError) as e:
1035
1035
  logger.warning(f"Skipping invalid link '{elem.get('name')}': {e}")
1036
1036
 
1037
1037
  elif tag == "joint":
1038
1038
  try:
1039
1039
  delayed_joints.append(self._parse_joint(elem))
1040
- except (RobotModelError, ValueError, Exception) as e:
1040
+ except (RobotModelError, ValueError) as e:
1041
1041
  logger.warning(f"Skipping invalid joint '{elem.get('name')}': {e}")
1042
1042
 
1043
1043
  elif tag == "transmission":
@@ -1045,7 +1045,7 @@ class URDFParser(RobotXMLParser[Robot]):
1045
1045
  trans = self._parse_transmission(elem)
1046
1046
  if trans:
1047
1047
  delayed_transmissions.append(trans)
1048
- except (RobotModelError, ValueError, Exception) as e:
1048
+ except (RobotModelError, ValueError) as e:
1049
1049
  logger.warning(
1050
1050
  f"Skipping invalid transmission '{elem.get('name')}': {e}"
1051
1051
  )
@@ -1055,7 +1055,7 @@ class URDFParser(RobotXMLParser[Robot]):
1055
1055
  ros2_ctrl = self._parse_ros2_control(elem)
1056
1056
  if ros2_ctrl:
1057
1057
  delayed_ros2_controls.append(ros2_ctrl)
1058
- except (RobotModelError, ValueError, Exception) as e:
1058
+ except (RobotModelError, ValueError) as e:
1059
1059
  logger.warning(
1060
1060
  f"Skipping invalid ros2_control '{elem.get('name')}': {e}"
1061
1061
  )
@@ -1082,7 +1082,7 @@ class URDFParser(RobotXMLParser[Robot]):
1082
1082
  # 3. Extract other Gazebo metadata (plugins, material, properties)
1083
1083
  gazebo_elem = self._parse_gazebo_element(elem)
1084
1084
  delayed_gazebo_elements.append((gazebo_elem, physics_data))
1085
- except (RobotModelError, ValueError, Exception) as e:
1085
+ except (RobotModelError, ValueError) as e:
1086
1086
  logger.warning(
1087
1087
  f"Skipping invalid gazebo element '{elem.get('name') or elem.get('reference')}': {e}"
1088
1088
  )
@@ -1095,25 +1095,25 @@ class URDFParser(RobotXMLParser[Robot]):
1095
1095
  for joint in delayed_joints:
1096
1096
  try:
1097
1097
  robot.add_joint(joint)
1098
- except Exception as e:
1098
+ except (RobotModelError, ValueError) as e:
1099
1099
  logger.warning(f"Skipping invalid joint '{joint.name}': {e}")
1100
1100
 
1101
1101
  for trans in delayed_transmissions:
1102
1102
  try:
1103
1103
  robot.add_transmission(trans)
1104
- except Exception as e:
1104
+ except (RobotModelError, ValueError) as e:
1105
1105
  logger.warning(f"Skipping invalid transmission '{trans.name}': {e}")
1106
1106
 
1107
1107
  for ros2_ctrl in delayed_ros2_controls:
1108
1108
  try:
1109
1109
  robot.add_ros2_control(ros2_ctrl)
1110
- except Exception as e:
1110
+ except (RobotModelError, ValueError) as e:
1111
1111
  logger.warning(f"Skipping invalid ros2_control '{ros2_ctrl.name}': {e}")
1112
1112
 
1113
1113
  for sensor in delayed_sensors:
1114
1114
  try:
1115
1115
  robot.add_sensor(sensor)
1116
- except Exception as e:
1116
+ except (RobotModelError, ValueError) as e:
1117
1117
  logger.warning(f"Skipping invalid sensor '{sensor.name}': {e}")
1118
1118
 
1119
1119
  for gazebo_elem, physics_data in delayed_gazebo_elements:
@@ -1139,7 +1139,7 @@ class URDFParser(RobotXMLParser[Robot]):
1139
1139
  or gazebo_elem.implicit_spring_damper is not None
1140
1140
  ):
1141
1141
  robot.add_gazebo_element(gazebo_elem)
1142
- except Exception as e:
1142
+ except (RobotModelError, ValueError) as e:
1143
1143
  logger.warning(f"Skipping invalid gazebo element '{gazebo_elem.reference}': {e}")
1144
1144
 
1145
1145
  return robot
@@ -6,9 +6,11 @@ properties, and includes.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import ast
9
10
  import copy
10
11
  import json
11
12
  import math
13
+ import operator
12
14
  import os
13
15
  import re
14
16
  import sys
@@ -48,8 +50,6 @@ logger = get_logger(__name__)
48
50
  DEFAULT_MAX_DEPTH = 2000 # Increased for extremely complex industrial robots
49
51
  RECURSION_LIMIT_BOOST = 5000 # Safer limit that prevents C-stack segmentation faults
50
52
 
51
- _DUNDER_PATTERN: re.Pattern[str] = re.compile(r"__\w+__")
52
-
53
53
  # Safe math context for evaluations
54
54
  MATH_CONTEXT: dict[str, Any] = {
55
55
  name: getattr(math, name) for name in dir(math) if not name.startswith("__")
@@ -72,6 +72,118 @@ MATH_CONTEXT["__builtins__"] = {
72
72
  MATH_CONTEXT["true"] = True
73
73
  MATH_CONTEXT["false"] = False
74
74
 
75
+ _SAFE_OPERATORS: dict[type[ast.AST], Any] = {
76
+ ast.Add: operator.add,
77
+ ast.Sub: operator.sub,
78
+ ast.Mult: operator.mul,
79
+ ast.Div: operator.truediv,
80
+ ast.FloorDiv: operator.floordiv,
81
+ ast.Mod: operator.mod,
82
+ ast.Pow: operator.pow,
83
+ ast.BitXor: operator.xor,
84
+ ast.BitOr: operator.or_,
85
+ ast.BitAnd: operator.and_,
86
+ ast.USub: operator.neg,
87
+ ast.UAdd: operator.pos,
88
+ ast.Not: operator.not_,
89
+ ast.Eq: operator.eq,
90
+ ast.NotEq: operator.ne,
91
+ ast.Lt: operator.lt,
92
+ ast.LtE: operator.le,
93
+ ast.Gt: operator.gt,
94
+ ast.GtE: operator.ge,
95
+ ast.And: lambda a, b: a and b,
96
+ ast.Or: lambda a, b: a or b,
97
+ }
98
+
99
+
100
+ def _safe_eval(expr: str, context: dict[str, Any]) -> Any:
101
+ """Safe evaluation of an AST expression using a restricted context."""
102
+
103
+ def _eval_node(node: ast.AST) -> Any:
104
+ if isinstance(node, ast.Expression):
105
+ return _eval_node(node.body)
106
+ elif isinstance(node, ast.Constant):
107
+ return node.value
108
+ elif isinstance(node, ast.Name):
109
+ if node.id.startswith("__") and node.id.endswith("__"):
110
+ raise RobotXacroExpressionError(expr, f"Forbidden dunder attributes: '{node.id}'")
111
+ if node.id in context:
112
+ return context[node.id]
113
+ if "__builtins__" in context and node.id in context["__builtins__"]:
114
+ return context["__builtins__"][node.id]
115
+ raise NameError(f"Unknown variable '{node.id}'") # noqa: TRY003
116
+ elif isinstance(node, ast.BinOp):
117
+ op = _SAFE_OPERATORS.get(type(node.op))
118
+ if not op:
119
+ raise TypeError(f"Unsupported operator {type(node.op)}") # noqa: TRY003
120
+ return op(_eval_node(node.left), _eval_node(node.right))
121
+ elif isinstance(node, ast.UnaryOp):
122
+ op = _SAFE_OPERATORS.get(type(node.op))
123
+ if not op:
124
+ raise TypeError(f"Unsupported unary operator {type(node.op)}") # noqa: TRY003
125
+ return op(_eval_node(node.operand))
126
+ elif isinstance(node, ast.BoolOp):
127
+ if isinstance(node.op, ast.And):
128
+ res = _eval_node(node.values[0])
129
+ for val in node.values[1:]:
130
+ res = res and _eval_node(val)
131
+ return res
132
+ elif isinstance(node.op, ast.Or):
133
+ res = _eval_node(node.values[0])
134
+ for val in node.values[1:]:
135
+ res = res or _eval_node(val)
136
+ return res
137
+ raise TypeError(f"Unsupported boolean operator {type(node.op)}") # noqa: TRY003
138
+ elif isinstance(node, ast.Compare):
139
+ left = _eval_node(node.left)
140
+ for cmp_op, right_node in zip(node.ops, node.comparators, strict=False):
141
+ op_func = _SAFE_OPERATORS.get(type(cmp_op))
142
+ if not op_func:
143
+ raise TypeError(f"Unsupported comparison operator {type(cmp_op)}") # noqa: TRY003
144
+ right = _eval_node(right_node)
145
+ if not op_func(left, right):
146
+ return False
147
+ left = right
148
+ return True
149
+ elif isinstance(node, ast.Call):
150
+ func = _eval_node(node.func)
151
+ if not callable(func):
152
+ raise TypeError(f"'{type(func)}' object is not callable") # noqa: TRY003
153
+ args = [_eval_node(arg) for arg in node.args]
154
+ kwargs = {kw.arg: _eval_node(kw.value) for kw in node.keywords if kw.arg is not None}
155
+ return func(*args, **kwargs)
156
+ elif isinstance(node, ast.Attribute):
157
+ if node.attr.startswith("__") and node.attr.endswith("__"):
158
+ raise RobotXacroExpressionError(expr, f"Forbidden dunder attributes: '{node.attr}'")
159
+ val = _eval_node(node.value)
160
+ if hasattr(val, node.attr):
161
+ return getattr(val, node.attr)
162
+ elif isinstance(val, dict) and node.attr in val:
163
+ return val[node.attr]
164
+ raise AttributeError(f"Object has no attribute '{node.attr}'") # noqa: TRY003
165
+ elif isinstance(node, ast.Subscript):
166
+ val = _eval_node(node.value)
167
+ slice_val = _eval_node(node.slice)
168
+ return val[slice_val]
169
+ elif isinstance(node, ast.List):
170
+ return [_eval_node(elt) for elt in node.elts]
171
+ elif isinstance(node, ast.Dict):
172
+ return {
173
+ _eval_node(k) if k else None: _eval_node(v)
174
+ for k, v in zip(node.keys, node.values, strict=False)
175
+ }
176
+ elif isinstance(node, ast.Tuple):
177
+ return tuple(_eval_node(elt) for elt in node.elts)
178
+ else:
179
+ raise TypeError(f"Unsupported AST node {type(node)}") # noqa: TRY003
180
+
181
+ # We no longer catch SyntaxError and wrap it, allowing it to bubble up
182
+ # just like eval() did, so the existing try/except blocks work as before.
183
+ tree = ast.parse(expr, mode="eval")
184
+ return _eval_node(tree)
185
+
186
+
75
187
  # Internal XML tags used for structural processing
76
188
  _TAG_CONTAINER = "container"
77
189
  _TAG_SKIP = "skip"
@@ -733,10 +845,8 @@ class XacroResolver:
733
845
  else:
734
846
  try:
735
847
  # Standard expression evaluation
736
- if _DUNDER_PATTERN.search(condition_str):
737
- raise RobotXacroExpressionError(condition_str, "Forbidden dunder attributes")
738
848
  ctx = {**self.eval_context, **self.properties, **self.args}
739
- return bool(eval(condition_str, ctx, {}))
849
+ return bool(_safe_eval(condition_str, ctx))
740
850
  except Exception as e:
741
851
  if isinstance(e, RobotParserError):
742
852
  raise
@@ -894,9 +1004,6 @@ class XacroResolver:
894
1004
  Raises:
895
1005
  RobotXacroExpressionError: If the expression is invalid or contains forbidden dunder attributes.
896
1006
  """
897
- if _DUNDER_PATTERN.search(expr):
898
- raise RobotXacroExpressionError(expr, "Forbidden dunder attributes")
899
-
900
1007
  try:
901
1008
  # Build nested context for hierarchical namespaces (e.g. arm.mass)
902
1009
  ctx = self.eval_context.copy()
@@ -925,7 +1032,7 @@ class XacroResolver:
925
1032
  if "." not in short_name:
926
1033
  ctx[short_name] = val
927
1034
 
928
- return eval(expr, ctx, {})
1035
+ return _safe_eval(expr, ctx)
929
1036
  except Exception as e:
930
1037
  # CRITICAL: Do not silent-fail! If math fails (e.g. missing variable),
931
1038
  # we must tell the user immediately rather than producing a corrupt output.
@@ -317,8 +317,8 @@ class RobotXMLParser(RobotParser[T], Generic[T]):
317
317
  inertia = InertiaTensor(ixx=ixx, iyy=iyy, izz=izz, ixy=ixy, ixz=ixz, iyz=iyz)
318
318
  except RobotModelError:
319
319
  # If triangle inequality is still violated, fall back to minimal valid
320
- inertia = InertiaTensor.zero()
320
+ inertia = InertiaTensor.stability_floor()
321
321
  else:
322
- inertia = InertiaTensor.zero()
322
+ inertia = InertiaTensor.stability_floor()
323
323
 
324
324
  return Inertial(mass=mass, origin=origin, inertia=inertia)
@@ -43,9 +43,11 @@ def validate_mesh_path(
43
43
  RobotSecurityError: If the mesh path attempts to escape the source directory
44
44
  RobotSecurityError: If absolute paths are not allowed but one is provided
45
45
  """
46
- # Decode URL encoding to catch encoded path traversal attempts (e.g., %2e%2e%2f -> ../)
46
+ # Decode URL encoding to catch encoded path traversal attempts (e.g., %252e%252e%252f -> ../)
47
47
  mesh_str = str(mesh_filepath)
48
48
  decoded_str = unquote(mesh_str)
49
+ while unquote(decoded_str) != decoded_str:
50
+ decoded_str = unquote(decoded_str)
49
51
 
50
52
  # Recreate Path from decoded string for further validation
51
53
  if decoded_str != mesh_str:
@@ -122,6 +124,8 @@ def validate_package_uri(uri: str) -> str:
122
124
 
123
125
  # Decode URL encoding to catch encoded path traversal attempts
124
126
  decoded_uri = unquote(uri)
127
+ while unquote(decoded_uri) != decoded_uri:
128
+ decoded_uri = unquote(decoded_uri)
125
129
 
126
130
  # Check both original and decoded for path traversal
127
131
  if ".." in uri or ".." in decoded_uri:
File without changes
@@ -230,9 +230,6 @@ class RobotBuilder:
230
230
  self._active_link_builders.clear()
231
231
 
232
232
  if validate:
233
- # Trigger root search to verify connectivity (raises error if no root)
234
- _ = self.robot.root_link
235
-
236
233
  if self.robot.has_cycle:
237
234
  raise RobotValidationError(
238
235
  ValidationErrorCode.HAS_CYCLE,
@@ -240,6 +237,9 @@ class RobotBuilder:
240
237
  target="KinematicTree",
241
238
  )
242
239
 
240
+ # Trigger root search to verify connectivity (raises error if no root)
241
+ _ = self.robot.root_link
242
+
243
243
  return self.robot
244
244
 
245
245
  def export_urdf(self, validate: bool = True, pretty_print: bool = True) -> str: