linkforge-core 1.4.0__py3-none-any.whl
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/__init__.py +301 -0
- linkforge/core/_utils/__init__.py +1 -0
- linkforge/core/_utils/dict_utils.py +122 -0
- linkforge/core/_utils/math_utils.py +75 -0
- linkforge/core/_utils/path_utils.py +169 -0
- linkforge/core/_utils/string_utils.py +96 -0
- linkforge/core/_utils/xml_utils.py +396 -0
- linkforge/core/base.py +218 -0
- linkforge/core/composer/__init__.py +11 -0
- linkforge/core/composer/helpers.py +25 -0
- linkforge/core/composer/interfaces.py +37 -0
- linkforge/core/composer/link_builder.py +1098 -0
- linkforge/core/composer/robot_builder.py +271 -0
- linkforge/core/composer/semantic_builder.py +253 -0
- linkforge/core/constants.py +293 -0
- linkforge/core/exceptions.py +217 -0
- linkforge/core/generators/__init__.py +8 -0
- linkforge/core/generators/srdf_generator.py +217 -0
- linkforge/core/generators/urdf_generator.py +1128 -0
- linkforge/core/generators/xacro_generator.py +895 -0
- linkforge/core/generators/xml_base.py +159 -0
- linkforge/core/io.py +125 -0
- linkforge/core/logging_config.py +66 -0
- linkforge/core/models/__init__.py +135 -0
- linkforge/core/models/gazebo.py +137 -0
- linkforge/core/models/geometry.py +193 -0
- linkforge/core/models/graph.py +250 -0
- linkforge/core/models/joint.py +294 -0
- linkforge/core/models/link.py +228 -0
- linkforge/core/models/material.py +85 -0
- linkforge/core/models/robot.py +1147 -0
- linkforge/core/models/ros2_control.py +195 -0
- linkforge/core/models/sensor.py +388 -0
- linkforge/core/models/srdf.py +555 -0
- linkforge/core/models/transmission.py +353 -0
- linkforge/core/parsers/__init__.py +21 -0
- linkforge/core/parsers/srdf_parser.py +570 -0
- linkforge/core/parsers/urdf_parser.py +1253 -0
- linkforge/core/parsers/xacro_parser.py +1091 -0
- linkforge/core/parsers/xml_base.py +324 -0
- linkforge/core/physics/__init__.py +29 -0
- linkforge/core/physics/inertia.py +399 -0
- linkforge/core/physics/mesh_validation.py +324 -0
- linkforge/core/py.typed +0 -0
- linkforge/core/validation/__init__.py +58 -0
- linkforge/core/validation/checks.py +728 -0
- linkforge/core/validation/result.py +150 -0
- linkforge/core/validation/security.py +175 -0
- linkforge/core/validation/validator.py +113 -0
- linkforge_core-1.4.0.dist-info/METADATA +236 -0
- linkforge_core-1.4.0.dist-info/RECORD +53 -0
- linkforge_core-1.4.0.dist-info/WHEEL +4 -0
- linkforge_core-1.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""String utility functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..exceptions import RobotValidationError, ValidationErrorCode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sanitize_name(name: str | None, allow_hyphen: bool = True) -> str:
|
|
9
|
+
"""Sanitize a name for robot model and Python identifier compatibility.
|
|
10
|
+
|
|
11
|
+
Replaces invalid characters with underscores and ensures it doesn't
|
|
12
|
+
start with a digit.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
name: Original name
|
|
16
|
+
allow_hyphen: Whether to allow hyphens (valid in many formats like URDF/SDF, invalid in Python)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Sanitized name
|
|
20
|
+
"""
|
|
21
|
+
if not name:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
# Prevent ReDoS: limit input length before processing
|
|
25
|
+
if len(name) > 1000:
|
|
26
|
+
raise RobotValidationError(
|
|
27
|
+
ValidationErrorCode.OUT_OF_RANGE,
|
|
28
|
+
f"Name length {len(name)} exceeds 1000 characters",
|
|
29
|
+
target="NameLength",
|
|
30
|
+
value=len(name),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Replace spaces with underscores
|
|
34
|
+
name = name.replace(" ", "_")
|
|
35
|
+
|
|
36
|
+
# Character iteration (safer than regex)
|
|
37
|
+
allowed_special = ("_", "-") if allow_hyphen else ("_",)
|
|
38
|
+
sanitized = "".join(c if c.isalnum() or c in allowed_special else "_" for c in name)
|
|
39
|
+
|
|
40
|
+
# Ensure it doesn't start with a digit
|
|
41
|
+
if sanitized and sanitized[0].isdigit():
|
|
42
|
+
sanitized = f"_{sanitized}"
|
|
43
|
+
|
|
44
|
+
return sanitized
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_valid_name(name: str, allow_hyphen: bool = True) -> bool:
|
|
48
|
+
"""Check if a name is valid for robot components.
|
|
49
|
+
|
|
50
|
+
A valid name:
|
|
51
|
+
- Is not empty
|
|
52
|
+
- Does not start with a digit
|
|
53
|
+
- Contains only alphanumeric characters, underscores, and optionally hyphens
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
name: Name to validate
|
|
57
|
+
allow_hyphen: Whether to allow hyphens (valid in URDF/SDF, invalid in Python)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if name is valid, False otherwise
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
>>> is_valid_name("base_link")
|
|
64
|
+
True
|
|
65
|
+
>>> is_valid_name("base-link")
|
|
66
|
+
True
|
|
67
|
+
>>> is_valid_name("base-link", allow_hyphen=False)
|
|
68
|
+
False
|
|
69
|
+
>>> is_valid_name("2nd_link")
|
|
70
|
+
False
|
|
71
|
+
>>> is_valid_name("base link")
|
|
72
|
+
False
|
|
73
|
+
"""
|
|
74
|
+
if not name:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
# Must not start with a digit
|
|
78
|
+
if name[0].isdigit():
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# All characters must be alphanumeric or allowed special chars
|
|
82
|
+
allowed_special = ("_", "-") if allow_hyphen else ("_",)
|
|
83
|
+
return all(c.isalnum() or c in allowed_special for c in name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_scientific(value: float) -> str:
|
|
87
|
+
"""Format a float to scientific notation for UI display."""
|
|
88
|
+
return f"{value:.2e}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_scientific(value: str, fallback: float) -> float:
|
|
92
|
+
"""Parse a scientific notation string back to float."""
|
|
93
|
+
try:
|
|
94
|
+
return float(value)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return fallback
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""XML utility functions for LinkForge."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..constants import (
|
|
10
|
+
MAX_REASONABLE_FLOAT,
|
|
11
|
+
MAX_REASONABLE_INT,
|
|
12
|
+
MAX_XML_DEPTH,
|
|
13
|
+
XACRO_URIS,
|
|
14
|
+
)
|
|
15
|
+
from ..exceptions import RobotMathError, RobotValidationError, ValidationErrorCode
|
|
16
|
+
from ..models import Vector3
|
|
17
|
+
|
|
18
|
+
# Register XACRO namespace to ensure standard 'xacro:' prefix in exports
|
|
19
|
+
ET.register_namespace("xacro", next(iter(XACRO_URIS)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def strip_xml_namespace(tag: str) -> str:
|
|
23
|
+
"""Strip XML namespace (Clark notation) from a tag string.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
'{http://www.ros.org/wiki/xacro}macro' -> 'macro'
|
|
27
|
+
'robot' -> 'robot'
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tag: The XML tag string to process.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The local tag name without the namespace.
|
|
34
|
+
"""
|
|
35
|
+
if tag.startswith("{"):
|
|
36
|
+
return tag.split("}", 1)[-1]
|
|
37
|
+
return tag
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_xml_namespace(tag: str) -> str | None:
|
|
41
|
+
"""Extract XML namespace URI from a tag string in Clark notation.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
'{http://www.ros.org/wiki/xacro}macro' -> 'http://www.ros.org/wiki/xacro'
|
|
45
|
+
'robot' -> None
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
tag: The XML tag string to process.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The namespace URI if present, otherwise None.
|
|
52
|
+
"""
|
|
53
|
+
if tag.startswith("{"):
|
|
54
|
+
return tag[1:].split("}", 1)[0]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_xml_depth(element: ET.Element, depth: int = 0) -> None:
|
|
59
|
+
"""Validate XML depth to prevent billion laughs attack.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
element: XML element to validate
|
|
63
|
+
depth: Current nesting depth
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
RobotValidationError: If depth exceeds MAX_XML_DEPTH
|
|
67
|
+
"""
|
|
68
|
+
if depth > MAX_XML_DEPTH:
|
|
69
|
+
raise RobotValidationError(
|
|
70
|
+
ValidationErrorCode.OUT_OF_RANGE,
|
|
71
|
+
f"XML nesting depth {depth} exceeds limit {MAX_XML_DEPTH}",
|
|
72
|
+
target="XMLDepth",
|
|
73
|
+
value=depth,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for child in element:
|
|
77
|
+
validate_xml_depth(child, depth + 1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_xml_header(element: ET.Element, version: str) -> str:
|
|
81
|
+
"""Standardized header comment for XML files.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
element: Root XML element (to extract robot name)
|
|
85
|
+
version: LinkForge version
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Header string containing XML declaration and meta comment
|
|
89
|
+
"""
|
|
90
|
+
return f"""<?xml version="1.0"?>
|
|
91
|
+
<!--
|
|
92
|
+
Robot: {element.get("name", "robot")}
|
|
93
|
+
Generated by: LinkForge v{version}
|
|
94
|
+
https://github.com/arounamounchili/linkforge
|
|
95
|
+
Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
96
|
+
-->
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def indent_xml(element: ET.Element, space: str = " ", level: int = 0) -> None:
|
|
101
|
+
"""Format XML elements with indentation.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
element: XML element to indent
|
|
105
|
+
space: Indentation string
|
|
106
|
+
level: Starting level
|
|
107
|
+
"""
|
|
108
|
+
ET.indent(element, space=space, level=level)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def serialize_xml(
|
|
112
|
+
element: ET.Element,
|
|
113
|
+
pretty_print: bool = True,
|
|
114
|
+
version: str = "unknown",
|
|
115
|
+
namespaces: dict[str, str] | None = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Convert XML element to standardized LinkForge string.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
element: XML element to serialize
|
|
121
|
+
pretty_print: Whether to indent
|
|
122
|
+
version: LinkForge version for header
|
|
123
|
+
namespaces: Optional dict of {prefix: uri} to register
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
XML string
|
|
127
|
+
"""
|
|
128
|
+
if namespaces:
|
|
129
|
+
for prefix, uri in namespaces.items():
|
|
130
|
+
ET.register_namespace(prefix, uri)
|
|
131
|
+
|
|
132
|
+
if pretty_print:
|
|
133
|
+
indent_xml(element)
|
|
134
|
+
|
|
135
|
+
xml_str = ET.tostring(element, encoding="unicode")
|
|
136
|
+
|
|
137
|
+
# Ensure namespaces are explicitly present on root if ElementTree dropped them
|
|
138
|
+
if namespaces:
|
|
139
|
+
for prefix, uri in namespaces.items():
|
|
140
|
+
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)
|
|
144
|
+
|
|
145
|
+
return get_xml_header(element, version) + xml_str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_float(
|
|
149
|
+
text: str | None,
|
|
150
|
+
attribute_name: str = "value",
|
|
151
|
+
default: float | None = None,
|
|
152
|
+
check_name: str | None = None,
|
|
153
|
+
) -> float:
|
|
154
|
+
"""Parse float value from XML with comprehensive validation.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
text: String to parse
|
|
158
|
+
attribute_name: Context for error messages
|
|
159
|
+
default: Default value if text is None
|
|
160
|
+
check_name: Alias for attribute_name used in some parsers
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Parsed float
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
RobotMathError: If input is invalid
|
|
167
|
+
RobotValidationError: If attribute is missing
|
|
168
|
+
"""
|
|
169
|
+
# Alias check_name if provided
|
|
170
|
+
report_name = check_name or attribute_name
|
|
171
|
+
|
|
172
|
+
if text is not None and not text.strip():
|
|
173
|
+
text = None
|
|
174
|
+
|
|
175
|
+
if text is None:
|
|
176
|
+
if default is not None:
|
|
177
|
+
return default
|
|
178
|
+
raise RobotValidationError(
|
|
179
|
+
ValidationErrorCode.VALUE_EMPTY,
|
|
180
|
+
f"Missing required attribute: {report_name}",
|
|
181
|
+
target=report_name,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
value = float(text)
|
|
186
|
+
if math.isnan(value) or math.isinf(value):
|
|
187
|
+
raise RobotMathError(
|
|
188
|
+
ValidationErrorCode.INVALID_VALUE,
|
|
189
|
+
f"Non-finite float value '{value}' in {report_name}",
|
|
190
|
+
target=report_name,
|
|
191
|
+
value=value,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Sanity check for reasonable values (Standard LinkForge limit)
|
|
195
|
+
# Physics constants like kp (stiffness) can be very large (e.g. 1e12)
|
|
196
|
+
if not (-MAX_REASONABLE_FLOAT < value < MAX_REASONABLE_FLOAT):
|
|
197
|
+
raise RobotMathError(
|
|
198
|
+
ValidationErrorCode.OUT_OF_RANGE,
|
|
199
|
+
f"Float value '{value}' in {report_name} is outside reasonable range",
|
|
200
|
+
target=report_name,
|
|
201
|
+
value=value,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return value
|
|
205
|
+
except ValueError:
|
|
206
|
+
raise RobotMathError(
|
|
207
|
+
ValidationErrorCode.INVALID_VALUE,
|
|
208
|
+
f"Invalid float format '{text}' in {report_name}",
|
|
209
|
+
target=report_name,
|
|
210
|
+
value=text,
|
|
211
|
+
) from None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_int(
|
|
215
|
+
text: str | None,
|
|
216
|
+
attribute_name: str = "value",
|
|
217
|
+
default: int | None = None,
|
|
218
|
+
check_name: str | None = None,
|
|
219
|
+
) -> int:
|
|
220
|
+
"""Parse integer value from XML with comprehensive validation.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
text: String to parse
|
|
224
|
+
attribute_name: Context for error messages
|
|
225
|
+
default: Default value if text is None
|
|
226
|
+
check_name: Alias for attribute_name for consistent reporting
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Parsed integer
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
RobotMathError: If input format is invalid or value is out of range
|
|
233
|
+
RobotValidationError: If attribute is missing and no default is provided
|
|
234
|
+
"""
|
|
235
|
+
report_name = check_name or attribute_name
|
|
236
|
+
|
|
237
|
+
if text is not None and not text.strip():
|
|
238
|
+
text = None
|
|
239
|
+
|
|
240
|
+
if text is None:
|
|
241
|
+
if default is not None:
|
|
242
|
+
return default
|
|
243
|
+
raise RobotValidationError(
|
|
244
|
+
ValidationErrorCode.VALUE_EMPTY,
|
|
245
|
+
f"Missing required attribute: {report_name}",
|
|
246
|
+
target=report_name,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
value = int(text)
|
|
251
|
+
# Sanity check for reasonable values (standard LinkForge limit)
|
|
252
|
+
if not (-MAX_REASONABLE_INT < value < MAX_REASONABLE_INT):
|
|
253
|
+
raise RobotMathError(
|
|
254
|
+
ValidationErrorCode.OUT_OF_RANGE,
|
|
255
|
+
f"Integer value '{value}' in {report_name} is outside reasonable range",
|
|
256
|
+
target=report_name,
|
|
257
|
+
value=value,
|
|
258
|
+
)
|
|
259
|
+
return value
|
|
260
|
+
except ValueError:
|
|
261
|
+
raise RobotMathError(
|
|
262
|
+
ValidationErrorCode.INVALID_VALUE,
|
|
263
|
+
f"Invalid integer format '{text}' in {report_name}",
|
|
264
|
+
target=report_name,
|
|
265
|
+
value=text,
|
|
266
|
+
) from None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def parse_vector3(text: str) -> Vector3:
|
|
270
|
+
"""Parse space-separated vector3 string.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
text: Space-separated string (e.g. "1.0 2.0 3.0")
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Vector3 model
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
RobotValidationError: If vector format is invalid or components are missing
|
|
280
|
+
RobotMathError: If component values are invalid
|
|
281
|
+
"""
|
|
282
|
+
parts = text.strip().split()
|
|
283
|
+
if len(parts) != 3:
|
|
284
|
+
raise RobotValidationError(
|
|
285
|
+
ValidationErrorCode.INVALID_VALUE,
|
|
286
|
+
f"Expected 3 values for Vector3, got {len(parts)}",
|
|
287
|
+
target="Vector3",
|
|
288
|
+
value=text,
|
|
289
|
+
)
|
|
290
|
+
try:
|
|
291
|
+
x = parse_float(parts[0], "x")
|
|
292
|
+
y = parse_float(parts[1], "y")
|
|
293
|
+
z = parse_float(parts[2], "z")
|
|
294
|
+
return Vector3(x, y, z)
|
|
295
|
+
except (RobotMathError, RobotValidationError):
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def parse_optional_bool(elem: ET.Element, tag: str, default: str = "false") -> bool | None:
|
|
300
|
+
"""Parse optional boolean element.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
elem: Parent XML element
|
|
304
|
+
tag: Sub-element tag to find
|
|
305
|
+
default: Default string value if tag is found but content is empty
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Boolean value if tag exists, else None
|
|
309
|
+
"""
|
|
310
|
+
if elem.find(f"{{*}}{tag}") is not None:
|
|
311
|
+
return elem.findtext(f"{{*}}{tag}", default).lower() == "true"
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def parse_optional_float(elem: ET.Element, tag: str, default: float | None = 0.0) -> float | None:
|
|
316
|
+
"""Parse optional float element.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
elem: Parent XML element
|
|
320
|
+
tag: Sub-element tag to find
|
|
321
|
+
default: Default float value if tag content is missing
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Parsed float if tag exists, else None
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
RobotMathError: If input value is invalid or out of range
|
|
328
|
+
RobotValidationError: If parsing logic fails
|
|
329
|
+
"""
|
|
330
|
+
if elem.find(f"{{*}}{tag}") is not None:
|
|
331
|
+
text = elem.findtext(f"{{*}}{tag}")
|
|
332
|
+
return parse_float(text, tag, default=default)
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def xml_add_text(parent: ET.Element, tag: str, value: Any) -> ET.Element:
|
|
337
|
+
"""Create a sub-element with text content.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
parent: The parent XML element.
|
|
341
|
+
tag: The tag name for the new element.
|
|
342
|
+
value: The text content to set. Will be converted to string if not None.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
The newly created XML element.
|
|
346
|
+
"""
|
|
347
|
+
elem = ET.SubElement(parent, tag)
|
|
348
|
+
if value is not None:
|
|
349
|
+
elem.text = str(value)
|
|
350
|
+
return elem
|
|
351
|
+
|
|
352
|
+
|
|
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
|
+
def create_xml_element(
|
|
376
|
+
parent: ET.Element,
|
|
377
|
+
tag: str,
|
|
378
|
+
formatter: Callable[[Any], str] | None = None,
|
|
379
|
+
**kwargs: Any,
|
|
380
|
+
) -> ET.Element:
|
|
381
|
+
"""Create an XML element, stripping None values and converting types to str.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
parent: Parent XML element
|
|
385
|
+
tag: Tag name for the new element
|
|
386
|
+
formatter: Optional callable to format values before string conversion
|
|
387
|
+
**kwargs: Attributes for the new element
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
The newly created XML element
|
|
391
|
+
"""
|
|
392
|
+
if formatter:
|
|
393
|
+
attrib = {k: formatter(v) for k, v in kwargs.items() if v is not None}
|
|
394
|
+
else:
|
|
395
|
+
attrib = {k: str(v) for k, v in kwargs.items() if v is not None}
|
|
396
|
+
return ET.SubElement(parent, tag, attrib)
|
linkforge/core/base.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Base classes for Robot Generators and Parsers.
|
|
2
|
+
|
|
3
|
+
This module defines the abstract base classes that facilitate translation
|
|
4
|
+
between the LinkForge Intermediate Representation (IR) and various external
|
|
5
|
+
robot description formats.
|
|
6
|
+
|
|
7
|
+
Core Components:
|
|
8
|
+
- RobotGenerator: Abstract base for format-specific exporters (URDF, SRDF).
|
|
9
|
+
- RobotParser: Abstract base for format-specific importers.
|
|
10
|
+
- IResourceResolver: Protocol for resolving package:// and file:// URIs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from ._utils.path_utils import normalize_uri_to_path, resolve_package_path
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
LinkForgeError,
|
|
22
|
+
RobotGeneratorError,
|
|
23
|
+
RobotModelError,
|
|
24
|
+
RobotParserError,
|
|
25
|
+
XacroDetectedError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .models.robot import Robot
|
|
30
|
+
|
|
31
|
+
# Generic type for the output format (e.g., str for XML, dict for JSON)
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"RobotGenerator",
|
|
36
|
+
"RobotParser",
|
|
37
|
+
"IResourceResolver",
|
|
38
|
+
"FileSystemResolver",
|
|
39
|
+
"NetworkResolver",
|
|
40
|
+
"LinkForgeError",
|
|
41
|
+
"RobotGeneratorError",
|
|
42
|
+
"RobotModelError",
|
|
43
|
+
"RobotParserError",
|
|
44
|
+
"XacroDetectedError",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RobotGenerator(ABC, Generic[T]):
|
|
49
|
+
"""Abstract base class for all Robot Generators."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def generate(self, robot: Robot, **kwargs: Any) -> T:
|
|
53
|
+
"""Generate the output representation from the Robot model.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
robot: The generic Robot model (Intermediate Representation)
|
|
57
|
+
**kwargs: Format-specific generation options
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The generated output (e.g. XML string, JSON dict)
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def write(self, robot: Robot, filepath: Path, **kwargs: Any) -> None:
|
|
65
|
+
"""Write the generated output to a file.
|
|
66
|
+
|
|
67
|
+
This is a template method that handles directory creation and
|
|
68
|
+
delegates the actual writing to the _save_to_file hook.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
robot: Robot model to export
|
|
72
|
+
filepath: Destination file path
|
|
73
|
+
**kwargs: Options passed to generate() and _save_to_file()
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
# Ensure parent directory exists
|
|
77
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
content = self.generate(robot, **kwargs)
|
|
80
|
+
self._save_to_file(content, filepath, **kwargs)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
if isinstance(e, LinkForgeError):
|
|
83
|
+
raise
|
|
84
|
+
raise RobotGeneratorError(str(filepath), str(e)) from e
|
|
85
|
+
|
|
86
|
+
def _save_to_file(self, content: T, filepath: Path, **_kwargs: Any) -> None:
|
|
87
|
+
"""Default I/O hook for saving content.
|
|
88
|
+
|
|
89
|
+
Supports both string (text) and binary (bytes) content by default.
|
|
90
|
+
Formats requiring specialized serialization should override this.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
content: Generated content from generate()
|
|
94
|
+
filepath: Target file path
|
|
95
|
+
**kwargs: Additional options
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(content, str):
|
|
98
|
+
filepath.write_text(content, encoding="utf-8")
|
|
99
|
+
elif isinstance(content, bytes):
|
|
100
|
+
filepath.write_bytes(content)
|
|
101
|
+
else:
|
|
102
|
+
raise RobotGeneratorError(self.__class__.__name__, type(content))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RobotParser(ABC, Generic[T]):
|
|
106
|
+
"""Abstract base class for all Robot Parsers."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def parse(self, filepath: Path, **kwargs: Any) -> T:
|
|
110
|
+
"""Parse a file into a model.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
filepath: Path to the input file
|
|
114
|
+
**kwargs: Format-specific parsing options
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The parsed model (e.g. Robot, SemanticRobotDescription)
|
|
118
|
+
"""
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def parse_string(self, content: str, **kwargs: Any) -> T:
|
|
123
|
+
"""Parse a string representation into a model.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
content: The string content to parse
|
|
127
|
+
**kwargs: Format-specific parsing options
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The parsed model (e.g. Robot, SemanticRobotDescription)
|
|
131
|
+
"""
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@runtime_checkable
|
|
136
|
+
class IResourceResolver(Protocol):
|
|
137
|
+
"""Protocol for resolving resource URIs (e.g. package://, file://, https://)."""
|
|
138
|
+
|
|
139
|
+
def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
|
|
140
|
+
"""Resolve a URI to a local filesystem Path.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
uri: The resource URI to resolve.
|
|
144
|
+
relative_to: Optional base directory for relative path resolution.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The resolved absolute Path.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
FileNotFoundError: If the resource cannot be located.
|
|
151
|
+
"""
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FileSystemResolver:
|
|
156
|
+
"""Default resolver for local file paths, file://, and package:// URIs."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, additional_search_paths: list[Path] | None = None) -> None:
|
|
159
|
+
"""Initialize the resolver.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
additional_search_paths: Optional list of paths to check before ROS_PACKAGE_PATH.
|
|
163
|
+
"""
|
|
164
|
+
self.additional_search_paths = additional_search_paths
|
|
165
|
+
|
|
166
|
+
def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
|
|
167
|
+
"""Resolve standard file paths, file:// URIs, and package:// URIs."""
|
|
168
|
+
# Handle package:// URIs
|
|
169
|
+
if "package://" in uri or "package:/" in uri:
|
|
170
|
+
# We use an empty Path if relative_to is not provided,
|
|
171
|
+
# though package resolution usually doesn't strictly need it if ROS_PACKAGE_PATH is set.
|
|
172
|
+
resolved = resolve_package_path(
|
|
173
|
+
uri, relative_to or Path.cwd(), additional_search_paths=self.additional_search_paths
|
|
174
|
+
)
|
|
175
|
+
if resolved and resolved.exists():
|
|
176
|
+
return resolved.absolute()
|
|
177
|
+
raise FileNotFoundError(uri)
|
|
178
|
+
|
|
179
|
+
# Handle file:// URIs
|
|
180
|
+
if uri.startswith("file://"):
|
|
181
|
+
path = normalize_uri_to_path(uri)
|
|
182
|
+
if path.exists():
|
|
183
|
+
return path.absolute()
|
|
184
|
+
raise FileNotFoundError(uri)
|
|
185
|
+
|
|
186
|
+
# Handle standard paths (absolute or relative)
|
|
187
|
+
path = Path(uri)
|
|
188
|
+
if path.is_absolute():
|
|
189
|
+
if path.exists():
|
|
190
|
+
return path.absolute()
|
|
191
|
+
elif relative_to is not None:
|
|
192
|
+
# Try relative to the provided directory
|
|
193
|
+
rel_path = (relative_to / path).resolve()
|
|
194
|
+
if rel_path.exists():
|
|
195
|
+
return rel_path
|
|
196
|
+
|
|
197
|
+
# Final fallback: current working directory if it exists there
|
|
198
|
+
if path.exists():
|
|
199
|
+
return path.absolute()
|
|
200
|
+
|
|
201
|
+
raise FileNotFoundError(uri)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class NetworkResolver:
|
|
205
|
+
"""Mock network resolver for URL-based meshes.
|
|
206
|
+
|
|
207
|
+
This is a placeholder for future cloud integrations (e.g. AWS S3, HTTP).
|
|
208
|
+
Currently raises a NotImplementedError if a network URI is detected.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
|
|
212
|
+
"""Simulate network resolution."""
|
|
213
|
+
if any(uri.startswith(p) for p in ("http://", "https://", "s3://")):
|
|
214
|
+
# In a real implementation, this would download to a /tmp cache
|
|
215
|
+
raise NotImplementedError(uri)
|
|
216
|
+
|
|
217
|
+
# Fallback to standard filesystem if it's a local path
|
|
218
|
+
return FileSystemResolver().resolve(uri, relative_to=relative_to)
|