linkforge-core 1.5.2__tar.gz → 1.5.4__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.
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/PKG-INFO +36 -5
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/README.md +35 -4
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/pyproject.toml +1 -1
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/__init__.py +21 -1
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/math_utils.py +12 -1
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/path_utils.py +10 -26
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/xml_utils.py +20 -30
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/base.py +4 -11
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/__init__.py +2 -1
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/interfaces.py +14 -3
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/link_builder.py +5 -8
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/robot_builder.py +18 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/constants.py +48 -33
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/exceptions.py +26 -44
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/srdf_generator.py +5 -4
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/urdf_generator.py +13 -12
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/xacro_generator.py +19 -18
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/logging_config.py +3 -1
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/link.py +2 -2
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/urdf_parser.py +60 -29
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/xacro_parser.py +9 -9
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/inertia.py +5 -11
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/checks.py +100 -97
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/.gitignore +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/LICENSE +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/dict_utils.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/string_utils.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/helpers.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/semantic_builder.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/xml_base.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/io.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/gazebo.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/geometry.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/graph.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/joint.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/material.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/robot.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/ros2_control.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/sensor.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/srdf.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/transmission.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/srdf_parser.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/xml_base.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/mesh_validation.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/py.typed +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/__init__.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/result.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/security.py +0 -0
- {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/validator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: linkforge-core
|
|
3
|
-
Version: 1.5.
|
|
3
|
+
Version: 1.5.4
|
|
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/
|
|
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
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.
|
|
43
43
|
|
|
44
|
-
It provides a mathematically rigorous,
|
|
44
|
+
It provides a mathematically rigorous, lightweight Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
|
|
45
45
|
|
|
46
46
|
## Installation
|
|
47
47
|
|
|
@@ -49,14 +49,14 @@ It provides a mathematically rigorous, zero-dependency Intermediate Representati
|
|
|
49
49
|
pip install linkforge-core
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Minimal pure-Python dependencies (only PyYAML). No Blender, no ROS installation, no C++ compilation required.
|
|
53
53
|
|
|
54
54
|
## Why LinkForge Core?
|
|
55
55
|
|
|
56
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
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
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**:
|
|
59
|
+
- **Light & Portable**: Minimal pure-Python footprint with no C++ extensions. Highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
|
|
60
60
|
|
|
61
61
|
## Quickstart
|
|
62
62
|
|
|
@@ -116,6 +116,36 @@ else:
|
|
|
116
116
|
print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
### Modular Assembly & Sub-Assemblies
|
|
120
|
+
|
|
121
|
+
Compose validated sub-assemblies from reusable robot components with automated naming prefixing and transform offsets:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from linkforge.core import RobotBuilder, read_urdf
|
|
125
|
+
|
|
126
|
+
base_builder = RobotBuilder(robot=read_urdf("mobile_base.urdf"))
|
|
127
|
+
arm_builder = RobotBuilder(robot=read_urdf("manipulator.urdf"))
|
|
128
|
+
|
|
129
|
+
# Attach arm onto the base, auto-resolving naming conflicts
|
|
130
|
+
base_builder.attach(
|
|
131
|
+
arm_builder,
|
|
132
|
+
at_link="top_plate",
|
|
133
|
+
prefix="left_arm_",
|
|
134
|
+
xyz=(0, 0, 0.1),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Add a MoveIt 2 planning group for the combined assembly
|
|
138
|
+
base_builder.semantic.group(
|
|
139
|
+
"arm_chain",
|
|
140
|
+
base_link="top_plate",
|
|
141
|
+
tip_link="left_arm_end_effector",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
robot = base_builder.build(validate=True)
|
|
145
|
+
urdf_xml = base_builder.export_urdf()
|
|
146
|
+
srdf_xml = base_builder.export_srdf()
|
|
147
|
+
```
|
|
148
|
+
|
|
119
149
|
### MoveIt 2 & SRDF Semantic Composition
|
|
120
150
|
|
|
121
151
|
Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
|
|
@@ -133,7 +163,8 @@ builder.semantic.group(
|
|
|
133
163
|
tip_link="wrist_link",
|
|
134
164
|
)
|
|
135
165
|
|
|
136
|
-
# Define an end-effector
|
|
166
|
+
# Define an end-effector group and register the end-effector
|
|
167
|
+
builder.semantic.group("gripper_group", links=["gripper_link"])
|
|
137
168
|
builder.semantic.end_effector(
|
|
138
169
|
name="gripper",
|
|
139
170
|
group="gripper_group",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
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.
|
|
14
14
|
|
|
15
|
-
It provides a mathematically rigorous,
|
|
15
|
+
It provides a mathematically rigorous, lightweight Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
@@ -20,14 +20,14 @@ It provides a mathematically rigorous, zero-dependency Intermediate Representati
|
|
|
20
20
|
pip install linkforge-core
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Minimal pure-Python dependencies (only PyYAML). No Blender, no ROS installation, no C++ compilation required.
|
|
24
24
|
|
|
25
25
|
## Why LinkForge Core?
|
|
26
26
|
|
|
27
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
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
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**:
|
|
30
|
+
- **Light & Portable**: Minimal pure-Python footprint with no C++ extensions. Highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
|
|
31
31
|
|
|
32
32
|
## Quickstart
|
|
33
33
|
|
|
@@ -87,6 +87,36 @@ else:
|
|
|
87
87
|
print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
### Modular Assembly & Sub-Assemblies
|
|
91
|
+
|
|
92
|
+
Compose validated sub-assemblies from reusable robot components with automated naming prefixing and transform offsets:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from linkforge.core import RobotBuilder, read_urdf
|
|
96
|
+
|
|
97
|
+
base_builder = RobotBuilder(robot=read_urdf("mobile_base.urdf"))
|
|
98
|
+
arm_builder = RobotBuilder(robot=read_urdf("manipulator.urdf"))
|
|
99
|
+
|
|
100
|
+
# Attach arm onto the base, auto-resolving naming conflicts
|
|
101
|
+
base_builder.attach(
|
|
102
|
+
arm_builder,
|
|
103
|
+
at_link="top_plate",
|
|
104
|
+
prefix="left_arm_",
|
|
105
|
+
xyz=(0, 0, 0.1),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Add a MoveIt 2 planning group for the combined assembly
|
|
109
|
+
base_builder.semantic.group(
|
|
110
|
+
"arm_chain",
|
|
111
|
+
base_link="top_plate",
|
|
112
|
+
tip_link="left_arm_end_effector",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
robot = base_builder.build(validate=True)
|
|
116
|
+
urdf_xml = base_builder.export_urdf()
|
|
117
|
+
srdf_xml = base_builder.export_srdf()
|
|
118
|
+
```
|
|
119
|
+
|
|
90
120
|
### MoveIt 2 & SRDF Semantic Composition
|
|
91
121
|
|
|
92
122
|
Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
|
|
@@ -104,7 +134,8 @@ builder.semantic.group(
|
|
|
104
134
|
tip_link="wrist_link",
|
|
105
135
|
)
|
|
106
136
|
|
|
107
|
-
# Define an end-effector
|
|
137
|
+
# Define an end-effector group and register the end-effector
|
|
138
|
+
builder.semantic.group("gripper_group", links=["gripper_link"])
|
|
108
139
|
builder.semantic.end_effector(
|
|
109
140
|
name="gripper",
|
|
110
141
|
group="gripper_group",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "linkforge-core"
|
|
3
|
-
version = "1.5.
|
|
3
|
+
version = "1.5.4" # 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.5.
|
|
14
|
+
__version__ = "1.5.4" # x-release-please-version
|
|
15
15
|
|
|
16
16
|
# Sub-Package Exposure
|
|
17
17
|
# (Ensures lf.models, lf.parsers, etc. are accessible via dot-notation)
|
|
@@ -26,6 +26,15 @@ from . import (
|
|
|
26
26
|
validation,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
+
# Domain Utilities
|
|
30
|
+
from ._utils.math_utils import clean_float, format_float
|
|
31
|
+
from ._utils.string_utils import (
|
|
32
|
+
format_scientific,
|
|
33
|
+
is_valid_name,
|
|
34
|
+
parse_scientific,
|
|
35
|
+
sanitize_name,
|
|
36
|
+
)
|
|
37
|
+
|
|
29
38
|
# Base Architecture: Interfaces and Resolvers
|
|
30
39
|
from .base import (
|
|
31
40
|
FileSystemResolver,
|
|
@@ -36,6 +45,7 @@ from .base import (
|
|
|
36
45
|
|
|
37
46
|
# Composer API: The LinkForge way to build robots
|
|
38
47
|
from .composer import (
|
|
48
|
+
IComposer,
|
|
39
49
|
LinkBuilder,
|
|
40
50
|
RobotBuilder,
|
|
41
51
|
box,
|
|
@@ -54,6 +64,7 @@ from .constants import (
|
|
|
54
64
|
from .exceptions import (
|
|
55
65
|
LinkForgeError,
|
|
56
66
|
RobotGeneratorError,
|
|
67
|
+
RobotGeneratorUnsupportedTypeError,
|
|
57
68
|
RobotMathError,
|
|
58
69
|
RobotModelError,
|
|
59
70
|
RobotParserError,
|
|
@@ -259,12 +270,20 @@ __all__ = [
|
|
|
259
270
|
"IResourceResolver",
|
|
260
271
|
"FileSystemResolver",
|
|
261
272
|
# Composer API
|
|
273
|
+
"IComposer",
|
|
262
274
|
"RobotBuilder",
|
|
263
275
|
"LinkBuilder",
|
|
264
276
|
"box",
|
|
265
277
|
"cylinder",
|
|
266
278
|
"sphere",
|
|
267
279
|
"mesh",
|
|
280
|
+
# Domain Utilities
|
|
281
|
+
"clean_float",
|
|
282
|
+
"format_float",
|
|
283
|
+
"sanitize_name",
|
|
284
|
+
"is_valid_name",
|
|
285
|
+
"format_scientific",
|
|
286
|
+
"parse_scientific",
|
|
268
287
|
# Validation & Physics
|
|
269
288
|
"RobotValidator",
|
|
270
289
|
"ValidationResult",
|
|
@@ -280,6 +299,7 @@ __all__ = [
|
|
|
280
299
|
"RobotParserError",
|
|
281
300
|
"RobotParserIOError",
|
|
282
301
|
"RobotGeneratorError",
|
|
302
|
+
"RobotGeneratorUnsupportedTypeError",
|
|
283
303
|
"RobotValidationError",
|
|
284
304
|
"RobotPhysicsError",
|
|
285
305
|
"RobotMathError",
|
|
@@ -6,11 +6,13 @@ import math
|
|
|
6
6
|
|
|
7
7
|
from ..constants import (
|
|
8
8
|
EPSILON,
|
|
9
|
+
FLOAT_CLEAN_EPSILON,
|
|
10
|
+
SCIENTIFIC_NOTATION_THRESHOLD,
|
|
9
11
|
SYLVESTER_TOLERANCE_EPSILON,
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
def clean_float(value: float, epsilon: float =
|
|
15
|
+
def clean_float(value: float, epsilon: float = FLOAT_CLEAN_EPSILON) -> float:
|
|
14
16
|
"""Clean up floating point values to avoid -0.0 and very small numbers.
|
|
15
17
|
|
|
16
18
|
Args:
|
|
@@ -37,6 +39,15 @@ def format_float(value: float, precision: int = 6) -> str:
|
|
|
37
39
|
"""
|
|
38
40
|
# Clean up small values and -0.0 first
|
|
39
41
|
cleaned = clean_float(value)
|
|
42
|
+
if cleaned == 0.0:
|
|
43
|
+
return "0"
|
|
44
|
+
|
|
45
|
+
# Use scientific notation for very small numbers to avoid zeroing them out
|
|
46
|
+
if abs(cleaned) < SCIENTIFIC_NOTATION_THRESHOLD:
|
|
47
|
+
formatted = f"{cleaned:.{precision}e}"
|
|
48
|
+
mantissa, exp = formatted.split("e")
|
|
49
|
+
mantissa = mantissa.rstrip("0").rstrip(".")
|
|
50
|
+
return f"{mantissa}e{exp}"
|
|
40
51
|
|
|
41
52
|
# Format with specified precision
|
|
42
53
|
formatted = f"{cleaned:.{precision}f}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Path and resource resolution utilities for LinkForge."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
from pathlib import Path
|
|
@@ -91,13 +92,11 @@ def resolve_package_path(
|
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
def _extract_package_name(xml_path: Path) -> str | None:
|
|
94
|
-
"""Extract <name> from package.xml
|
|
95
|
+
"""Extract <name> from package.xml."""
|
|
95
96
|
try:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
match = re.search(r"<name>(.*?)</name>", content)
|
|
100
|
-
return match.group(1).strip() if match else None
|
|
97
|
+
content = xml_path.read_text(encoding="utf-8")
|
|
98
|
+
match = re.search(r"<name>(.*?)</name>", content)
|
|
99
|
+
return match.group(1).strip() if match else None
|
|
101
100
|
except Exception:
|
|
102
101
|
return None
|
|
103
102
|
|
|
@@ -142,28 +141,13 @@ def get_export_path(resource: str, relative_to: Path | None = None) -> str:
|
|
|
142
141
|
The string to be used in the 'filename' attribute.
|
|
143
142
|
"""
|
|
144
143
|
# Preserve package:// URIs (never make relative)
|
|
145
|
-
if resource.startswith("package://"
|
|
144
|
+
if resource.startswith(("package://", "package:/")):
|
|
146
145
|
return resource
|
|
147
146
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
path = normalize_uri_to_path(resource)
|
|
151
|
-
if relative_to and path.is_absolute():
|
|
152
|
-
try:
|
|
153
|
-
# Use .absolute() on relative_to just in case it's not
|
|
154
|
-
rel = path.relative_to(relative_to.absolute())
|
|
155
|
-
return str(rel)
|
|
156
|
-
except ValueError:
|
|
157
|
-
pass
|
|
158
|
-
return resource
|
|
159
|
-
|
|
160
|
-
# Handle standard paths
|
|
161
|
-
path = Path(resource)
|
|
147
|
+
# Convert file:// URI or filesystem path to Path and relativize if possible
|
|
148
|
+
path = normalize_uri_to_path(resource)
|
|
162
149
|
if relative_to and path.is_absolute():
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return str(rel)
|
|
166
|
-
except ValueError:
|
|
167
|
-
pass
|
|
150
|
+
with contextlib.suppress(ValueError):
|
|
151
|
+
return str(path.relative_to(relative_to.absolute()))
|
|
168
152
|
|
|
169
153
|
return resource
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import math
|
|
4
4
|
import xml.etree.ElementTree as ET
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
-
from datetime import datetime
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
9
8
|
from ..constants import (
|
|
@@ -77,22 +76,22 @@ def validate_xml_depth(element: ET.Element, depth: int = 0) -> None:
|
|
|
77
76
|
validate_xml_depth(child, depth + 1)
|
|
78
77
|
|
|
79
78
|
|
|
80
|
-
def get_xml_header(element: ET.Element, version: str) -> str:
|
|
79
|
+
def get_xml_header(element: ET.Element, version: str | None = None) -> str:
|
|
81
80
|
"""Standardized header comment for XML files.
|
|
82
81
|
|
|
83
82
|
Args:
|
|
84
83
|
element: Root XML element (to extract robot name)
|
|
85
|
-
version: LinkForge version
|
|
84
|
+
version: Optional LinkForge version
|
|
86
85
|
|
|
87
86
|
Returns:
|
|
88
87
|
Header string containing XML declaration and meta comment
|
|
89
88
|
"""
|
|
89
|
+
version_str = f" v{version}" if version else ""
|
|
90
90
|
return f"""<?xml version="1.0"?>
|
|
91
91
|
<!--
|
|
92
92
|
Robot: {element.get("name", "robot")}
|
|
93
|
-
Generated by: LinkForge
|
|
93
|
+
Generated by: LinkForge{version_str}
|
|
94
94
|
https://github.com/arounamounchili/linkforge
|
|
95
|
-
Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
96
95
|
-->
|
|
97
96
|
"""
|
|
98
97
|
|
|
@@ -111,7 +110,7 @@ def indent_xml(element: ET.Element, space: str = " ", level: int = 0) -> None:
|
|
|
111
110
|
def serialize_xml(
|
|
112
111
|
element: ET.Element,
|
|
113
112
|
pretty_print: bool = True,
|
|
114
|
-
version: str =
|
|
113
|
+
version: str | None = None,
|
|
115
114
|
namespaces: dict[str, str] | None = None,
|
|
116
115
|
) -> str:
|
|
117
116
|
"""Convert XML element to standardized LinkForge string.
|
|
@@ -119,7 +118,7 @@ def serialize_xml(
|
|
|
119
118
|
Args:
|
|
120
119
|
element: XML element to serialize
|
|
121
120
|
pretty_print: Whether to indent
|
|
122
|
-
version: LinkForge version for header
|
|
121
|
+
version: Optional LinkForge version for header
|
|
123
122
|
namespaces: Optional dict of {prefix: uri} to register
|
|
124
123
|
|
|
125
124
|
Returns:
|
|
@@ -152,7 +151,6 @@ def parse_float(
|
|
|
152
151
|
text: str | None,
|
|
153
152
|
attribute_name: str = "value",
|
|
154
153
|
default: float | None = None,
|
|
155
|
-
check_name: str | None = None,
|
|
156
154
|
) -> float:
|
|
157
155
|
"""Parse float value from XML with comprehensive validation.
|
|
158
156
|
|
|
@@ -160,7 +158,6 @@ def parse_float(
|
|
|
160
158
|
text: String to parse
|
|
161
159
|
attribute_name: Context for error messages
|
|
162
160
|
default: Default value if text is None
|
|
163
|
-
check_name: Alias for attribute_name used in some parsers
|
|
164
161
|
|
|
165
162
|
Returns:
|
|
166
163
|
Parsed float
|
|
@@ -169,9 +166,6 @@ def parse_float(
|
|
|
169
166
|
RobotMathError: If input is invalid
|
|
170
167
|
RobotValidationError: If attribute is missing
|
|
171
168
|
"""
|
|
172
|
-
# Alias check_name if provided
|
|
173
|
-
report_name = check_name or attribute_name
|
|
174
|
-
|
|
175
169
|
if text is not None and not text.strip():
|
|
176
170
|
text = None
|
|
177
171
|
|
|
@@ -180,8 +174,8 @@ def parse_float(
|
|
|
180
174
|
return default
|
|
181
175
|
raise RobotValidationError(
|
|
182
176
|
ValidationErrorCode.VALUE_EMPTY,
|
|
183
|
-
f"Missing required attribute: {
|
|
184
|
-
target=
|
|
177
|
+
f"Missing required attribute: {attribute_name}",
|
|
178
|
+
target=attribute_name,
|
|
185
179
|
)
|
|
186
180
|
|
|
187
181
|
try:
|
|
@@ -189,8 +183,8 @@ def parse_float(
|
|
|
189
183
|
if math.isnan(value) or math.isinf(value):
|
|
190
184
|
raise RobotMathError(
|
|
191
185
|
ValidationErrorCode.INVALID_VALUE,
|
|
192
|
-
f"Non-finite float value '{value}' in {
|
|
193
|
-
target=
|
|
186
|
+
f"Non-finite float value '{value}' in {attribute_name}",
|
|
187
|
+
target=attribute_name,
|
|
194
188
|
value=value,
|
|
195
189
|
)
|
|
196
190
|
|
|
@@ -199,8 +193,8 @@ def parse_float(
|
|
|
199
193
|
if not (-MAX_REASONABLE_FLOAT < value < MAX_REASONABLE_FLOAT):
|
|
200
194
|
raise RobotMathError(
|
|
201
195
|
ValidationErrorCode.OUT_OF_RANGE,
|
|
202
|
-
f"Float value '{value}' in {
|
|
203
|
-
target=
|
|
196
|
+
f"Float value '{value}' in {attribute_name} is outside reasonable range",
|
|
197
|
+
target=attribute_name,
|
|
204
198
|
value=value,
|
|
205
199
|
)
|
|
206
200
|
|
|
@@ -208,8 +202,8 @@ def parse_float(
|
|
|
208
202
|
except ValueError:
|
|
209
203
|
raise RobotMathError(
|
|
210
204
|
ValidationErrorCode.INVALID_VALUE,
|
|
211
|
-
f"Invalid float format '{text}' in {
|
|
212
|
-
target=
|
|
205
|
+
f"Invalid float format '{text}' in {attribute_name}",
|
|
206
|
+
target=attribute_name,
|
|
213
207
|
value=text,
|
|
214
208
|
) from None
|
|
215
209
|
|
|
@@ -218,7 +212,6 @@ def parse_int(
|
|
|
218
212
|
text: str | None,
|
|
219
213
|
attribute_name: str = "value",
|
|
220
214
|
default: int | None = None,
|
|
221
|
-
check_name: str | None = None,
|
|
222
215
|
) -> int:
|
|
223
216
|
"""Parse integer value from XML with comprehensive validation.
|
|
224
217
|
|
|
@@ -226,7 +219,6 @@ def parse_int(
|
|
|
226
219
|
text: String to parse
|
|
227
220
|
attribute_name: Context for error messages
|
|
228
221
|
default: Default value if text is None
|
|
229
|
-
check_name: Alias for attribute_name for consistent reporting
|
|
230
222
|
|
|
231
223
|
Returns:
|
|
232
224
|
Parsed integer
|
|
@@ -235,8 +227,6 @@ def parse_int(
|
|
|
235
227
|
RobotMathError: If input format is invalid or value is out of range
|
|
236
228
|
RobotValidationError: If attribute is missing and no default is provided
|
|
237
229
|
"""
|
|
238
|
-
report_name = check_name or attribute_name
|
|
239
|
-
|
|
240
230
|
if text is not None and not text.strip():
|
|
241
231
|
text = None
|
|
242
232
|
|
|
@@ -245,8 +235,8 @@ def parse_int(
|
|
|
245
235
|
return default
|
|
246
236
|
raise RobotValidationError(
|
|
247
237
|
ValidationErrorCode.VALUE_EMPTY,
|
|
248
|
-
f"Missing required attribute: {
|
|
249
|
-
target=
|
|
238
|
+
f"Missing required attribute: {attribute_name}",
|
|
239
|
+
target=attribute_name,
|
|
250
240
|
)
|
|
251
241
|
|
|
252
242
|
try:
|
|
@@ -255,16 +245,16 @@ def parse_int(
|
|
|
255
245
|
if not (-MAX_REASONABLE_INT < value < MAX_REASONABLE_INT):
|
|
256
246
|
raise RobotMathError(
|
|
257
247
|
ValidationErrorCode.OUT_OF_RANGE,
|
|
258
|
-
f"Integer value '{value}' in {
|
|
259
|
-
target=
|
|
248
|
+
f"Integer value '{value}' in {attribute_name} is outside reasonable range",
|
|
249
|
+
target=attribute_name,
|
|
260
250
|
value=value,
|
|
261
251
|
)
|
|
262
252
|
return value
|
|
263
253
|
except ValueError:
|
|
264
254
|
raise RobotMathError(
|
|
265
255
|
ValidationErrorCode.INVALID_VALUE,
|
|
266
|
-
f"Invalid integer format '{text}' in {
|
|
267
|
-
target=
|
|
256
|
+
f"Invalid integer format '{text}' in {attribute_name}",
|
|
257
|
+
target=attribute_name,
|
|
268
258
|
value=text,
|
|
269
259
|
) from None
|
|
270
260
|
|
|
@@ -20,9 +20,7 @@ from ._utils.path_utils import normalize_uri_to_path, resolve_package_path
|
|
|
20
20
|
from .exceptions import (
|
|
21
21
|
LinkForgeError,
|
|
22
22
|
RobotGeneratorError,
|
|
23
|
-
|
|
24
|
-
RobotParserError,
|
|
25
|
-
XacroDetectedError,
|
|
23
|
+
RobotGeneratorUnsupportedTypeError,
|
|
26
24
|
)
|
|
27
25
|
|
|
28
26
|
if TYPE_CHECKING:
|
|
@@ -32,15 +30,10 @@ if TYPE_CHECKING:
|
|
|
32
30
|
T = TypeVar("T")
|
|
33
31
|
|
|
34
32
|
__all__ = [
|
|
33
|
+
"FileSystemResolver",
|
|
34
|
+
"IResourceResolver",
|
|
35
35
|
"RobotGenerator",
|
|
36
36
|
"RobotParser",
|
|
37
|
-
"IResourceResolver",
|
|
38
|
-
"FileSystemResolver",
|
|
39
|
-
"LinkForgeError",
|
|
40
|
-
"RobotGeneratorError",
|
|
41
|
-
"RobotModelError",
|
|
42
|
-
"RobotParserError",
|
|
43
|
-
"XacroDetectedError",
|
|
44
37
|
]
|
|
45
38
|
|
|
46
39
|
|
|
@@ -98,7 +91,7 @@ class RobotGenerator(ABC, Generic[T]):
|
|
|
98
91
|
elif isinstance(content, bytes):
|
|
99
92
|
filepath.write_bytes(content)
|
|
100
93
|
else:
|
|
101
|
-
raise
|
|
94
|
+
raise RobotGeneratorUnsupportedTypeError(type(content), self.__class__.__name__)
|
|
102
95
|
|
|
103
96
|
|
|
104
97
|
class RobotParser(ABC, Generic[T]):
|
|
@@ -5,7 +5,8 @@ by programmatically constructing links, joints, and semantic data.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .helpers import box, cylinder, mesh, sphere
|
|
8
|
+
from .interfaces import IComposer
|
|
8
9
|
from .link_builder import LinkBuilder
|
|
9
10
|
from .robot_builder import RobotBuilder
|
|
10
11
|
|
|
11
|
-
__all__ = ["RobotBuilder", "LinkBuilder", "box", "cylinder", "sphere", "mesh"]
|
|
12
|
+
__all__ = ["IComposer", "RobotBuilder", "LinkBuilder", "box", "cylinder", "sphere", "mesh"]
|
|
@@ -6,19 +6,18 @@ communicate without tight coupling.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from typing import TYPE_CHECKING, Protocol
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from ..models.robot import Robot
|
|
13
13
|
from .link_builder import LinkBuilder
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
@runtime_checkable
|
|
16
17
|
class IComposer(Protocol):
|
|
17
18
|
"""Interface for a builder that can contain and manage links."""
|
|
18
19
|
|
|
19
20
|
robot: Robot
|
|
20
|
-
_active_link_builders: list[LinkBuilder]
|
|
21
|
-
_parent_stack: list[str]
|
|
22
21
|
|
|
23
22
|
def link(
|
|
24
23
|
self, name: str, parent: str | None = None, joint_name: str | None = None
|
|
@@ -35,3 +34,15 @@ class IComposer(Protocol):
|
|
|
35
34
|
def build(self, validate: bool = True) -> Robot:
|
|
36
35
|
"""Finalize the assembly and return the completed Robot model."""
|
|
37
36
|
...
|
|
37
|
+
|
|
38
|
+
def register_link_builder(self, builder: LinkBuilder) -> None:
|
|
39
|
+
"""Register an active LinkBuilder with this composer."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def push_parent(self, name: str) -> None:
|
|
43
|
+
"""Push a parent link name onto the hierarchy context stack."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def pop_parent(self, name: str | None = None) -> None:
|
|
47
|
+
"""Pop a parent link name from the hierarchy context stack."""
|
|
48
|
+
...
|
|
@@ -11,7 +11,6 @@ Core Components:
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
import logging
|
|
15
14
|
from dataclasses import dataclass, field, replace
|
|
16
15
|
from typing import TYPE_CHECKING, Any
|
|
17
16
|
|
|
@@ -34,6 +33,7 @@ from ..constants import (
|
|
|
34
33
|
HW_IF_EFFORT,
|
|
35
34
|
)
|
|
36
35
|
from ..exceptions import RobotValidationError, ValidationErrorCode
|
|
36
|
+
from ..logging_config import get_logger
|
|
37
37
|
from ..models.gazebo import GazeboElement
|
|
38
38
|
from ..models.geometry import Geometry, Transform, Vector3
|
|
39
39
|
from ..models.joint import (
|
|
@@ -65,7 +65,7 @@ from ..physics.inertia import calculate_inertia
|
|
|
65
65
|
if TYPE_CHECKING:
|
|
66
66
|
from .interfaces import IComposer
|
|
67
67
|
|
|
68
|
-
logger =
|
|
68
|
+
logger = get_logger(__name__)
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
@dataclass
|
|
@@ -126,7 +126,7 @@ class LinkBuilder:
|
|
|
126
126
|
self._committed = False
|
|
127
127
|
self._in_context = False
|
|
128
128
|
|
|
129
|
-
self._builder.
|
|
129
|
+
self._builder.register_link_builder(self)
|
|
130
130
|
|
|
131
131
|
def __enter__(self) -> LinkBuilder:
|
|
132
132
|
"""Enter the context of this link.
|
|
@@ -140,7 +140,7 @@ class LinkBuilder:
|
|
|
140
140
|
skeletal_link = Link(name=self._link_name)
|
|
141
141
|
self._builder.robot.add_link(skeletal_link, overwrite=True)
|
|
142
142
|
|
|
143
|
-
self._builder.
|
|
143
|
+
self._builder.push_parent(self._link_name)
|
|
144
144
|
return self
|
|
145
145
|
|
|
146
146
|
def __exit__(
|
|
@@ -154,10 +154,7 @@ class LinkBuilder:
|
|
|
154
154
|
Pops this link's name from the parent stack and automatically commits the
|
|
155
155
|
link to flush its configured properties if no exception occurred.
|
|
156
156
|
"""
|
|
157
|
-
|
|
158
|
-
self._builder._parent_stack.pop()
|
|
159
|
-
elif self._link_name in self._builder._parent_stack:
|
|
160
|
-
self._builder._parent_stack.remove(self._link_name)
|
|
157
|
+
self._builder.pop_parent(self._link_name)
|
|
161
158
|
|
|
162
159
|
if exc_type is None:
|
|
163
160
|
self._commit()
|
|
@@ -59,6 +59,24 @@ class RobotBuilder:
|
|
|
59
59
|
self._active_link_builders: list[LinkBuilder] = []
|
|
60
60
|
self._parent_stack: list[str] = []
|
|
61
61
|
|
|
62
|
+
def register_link_builder(self, builder: LinkBuilder) -> None:
|
|
63
|
+
"""Register an active LinkBuilder with this composer."""
|
|
64
|
+
self._active_link_builders.append(builder)
|
|
65
|
+
|
|
66
|
+
def push_parent(self, name: str) -> None:
|
|
67
|
+
"""Push a parent link name onto the hierarchy context stack."""
|
|
68
|
+
self._parent_stack.append(name)
|
|
69
|
+
|
|
70
|
+
def pop_parent(self, name: str | None = None) -> None:
|
|
71
|
+
"""Pop a parent link name from the hierarchy context stack."""
|
|
72
|
+
if name is not None:
|
|
73
|
+
if self._parent_stack and self._parent_stack[-1] == name:
|
|
74
|
+
self._parent_stack.pop()
|
|
75
|
+
elif name in self._parent_stack:
|
|
76
|
+
self._parent_stack.remove(name)
|
|
77
|
+
elif self._parent_stack:
|
|
78
|
+
self._parent_stack.pop()
|
|
79
|
+
|
|
62
80
|
def link(
|
|
63
81
|
self, name: str, parent: str | None = None, joint_name: str | None = None
|
|
64
82
|
) -> LinkBuilder:
|