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,301 @@
|
|
|
1
|
+
"""LinkForge Core Library.
|
|
2
|
+
|
|
3
|
+
The platform-independent heart of the LinkForge project, providing a
|
|
4
|
+
unified Intermediate Representation (IR) for robotics. This core library
|
|
5
|
+
handles the "Robotics Intelligence" isolated from design tools.
|
|
6
|
+
|
|
7
|
+
As the "LLVM for Robotics," LinkForge Core provides the essential IR and tools
|
|
8
|
+
for parsing, generating, and validating robot descriptions across formats.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Versioning
|
|
14
|
+
__version__ = "1.4.0" # x-release-please-version
|
|
15
|
+
|
|
16
|
+
# Sub-Package Exposure
|
|
17
|
+
# (Ensures lf.models, lf.parsers, etc. are accessible via dot-notation)
|
|
18
|
+
from . import (
|
|
19
|
+
composer,
|
|
20
|
+
constants,
|
|
21
|
+
exceptions,
|
|
22
|
+
generators,
|
|
23
|
+
models,
|
|
24
|
+
parsers,
|
|
25
|
+
physics,
|
|
26
|
+
validation,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Base Architecture: Interfaces and Resolvers
|
|
30
|
+
from .base import (
|
|
31
|
+
FileSystemResolver,
|
|
32
|
+
IResourceResolver,
|
|
33
|
+
NetworkResolver,
|
|
34
|
+
RobotGenerator,
|
|
35
|
+
RobotParser,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Composer API: The LinkForge way to build robots
|
|
39
|
+
from .composer import (
|
|
40
|
+
LinkBuilder,
|
|
41
|
+
RobotBuilder,
|
|
42
|
+
box,
|
|
43
|
+
cylinder,
|
|
44
|
+
mesh,
|
|
45
|
+
sphere,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Core Infrastructure: Constants and Exceptions
|
|
49
|
+
from .constants import (
|
|
50
|
+
DEFAULT_GRAVITY,
|
|
51
|
+
DEFAULT_LINK_MASS,
|
|
52
|
+
EPSILON,
|
|
53
|
+
PI,
|
|
54
|
+
)
|
|
55
|
+
from .exceptions import (
|
|
56
|
+
LinkForgeError,
|
|
57
|
+
RobotGeneratorError,
|
|
58
|
+
RobotMathError,
|
|
59
|
+
RobotModelError,
|
|
60
|
+
RobotParserError,
|
|
61
|
+
RobotParserIOError,
|
|
62
|
+
RobotPhysicsError,
|
|
63
|
+
RobotSecurityError,
|
|
64
|
+
RobotValidationError,
|
|
65
|
+
RobotXacroError,
|
|
66
|
+
RobotXacroExpressionError,
|
|
67
|
+
RobotXacroRecursionError,
|
|
68
|
+
ValidationErrorCode,
|
|
69
|
+
XacroDetectedError,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Generators and Functional I/O
|
|
73
|
+
from .generators import (
|
|
74
|
+
RobotXMLGenerator,
|
|
75
|
+
SRDFGenerator,
|
|
76
|
+
URDFGenerator,
|
|
77
|
+
XACROGenerator,
|
|
78
|
+
)
|
|
79
|
+
from .io import (
|
|
80
|
+
read_srdf,
|
|
81
|
+
read_urdf,
|
|
82
|
+
read_xacro,
|
|
83
|
+
validate_robot,
|
|
84
|
+
write_srdf,
|
|
85
|
+
write_urdf,
|
|
86
|
+
write_xacro,
|
|
87
|
+
)
|
|
88
|
+
from .logging_config import get_logger, setup_logging
|
|
89
|
+
|
|
90
|
+
# IR Models: Entities, Sensors and Hardware
|
|
91
|
+
from .models import (
|
|
92
|
+
Box,
|
|
93
|
+
CameraInfo,
|
|
94
|
+
Chain,
|
|
95
|
+
Collision,
|
|
96
|
+
CollisionPair,
|
|
97
|
+
Color,
|
|
98
|
+
ContactInfo,
|
|
99
|
+
Cylinder,
|
|
100
|
+
EndEffector,
|
|
101
|
+
ForceTorqueInfo,
|
|
102
|
+
GazeboElement,
|
|
103
|
+
GazeboPlugin,
|
|
104
|
+
Geometry,
|
|
105
|
+
GeometryType,
|
|
106
|
+
GPSInfo,
|
|
107
|
+
GroupState,
|
|
108
|
+
IMUInfo,
|
|
109
|
+
Inertial,
|
|
110
|
+
InertiaTensor,
|
|
111
|
+
Joint,
|
|
112
|
+
JointCalibration,
|
|
113
|
+
JointDynamics,
|
|
114
|
+
JointLimits,
|
|
115
|
+
JointMimic,
|
|
116
|
+
JointProperty,
|
|
117
|
+
JointSafetyController,
|
|
118
|
+
JointType,
|
|
119
|
+
KinematicGraph,
|
|
120
|
+
LidarInfo,
|
|
121
|
+
Link,
|
|
122
|
+
LinkPhysics,
|
|
123
|
+
LinkSphereApproximation,
|
|
124
|
+
Material,
|
|
125
|
+
Mesh,
|
|
126
|
+
PassiveJoint,
|
|
127
|
+
PlanningGroup,
|
|
128
|
+
Robot,
|
|
129
|
+
Ros2Control,
|
|
130
|
+
Ros2ControlJoint,
|
|
131
|
+
Ros2ControlSensor,
|
|
132
|
+
SemanticRobotDescription,
|
|
133
|
+
Sensor,
|
|
134
|
+
SensorNoise,
|
|
135
|
+
SensorType,
|
|
136
|
+
Sphere,
|
|
137
|
+
SrdfSphere,
|
|
138
|
+
Transform,
|
|
139
|
+
Transmission,
|
|
140
|
+
TransmissionActuator,
|
|
141
|
+
TransmissionJoint,
|
|
142
|
+
TransmissionType,
|
|
143
|
+
Vector3,
|
|
144
|
+
VirtualJoint,
|
|
145
|
+
Visual,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Parsers and Resolvers
|
|
149
|
+
from .parsers import (
|
|
150
|
+
RobotXMLParser,
|
|
151
|
+
SRDFParser,
|
|
152
|
+
URDFParser,
|
|
153
|
+
XACROParser,
|
|
154
|
+
XacroResolver,
|
|
155
|
+
clear_xacro_cache,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Processing and Validation: Physics and Checks
|
|
159
|
+
from .physics import (
|
|
160
|
+
calculate_inertia,
|
|
161
|
+
validate_mesh_topology,
|
|
162
|
+
)
|
|
163
|
+
from .validation import (
|
|
164
|
+
RobotValidator,
|
|
165
|
+
Severity,
|
|
166
|
+
ValidationCheck,
|
|
167
|
+
ValidationIssue,
|
|
168
|
+
ValidationResult,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
__all__ = [
|
|
172
|
+
# Sub-Packages
|
|
173
|
+
"composer",
|
|
174
|
+
"constants",
|
|
175
|
+
"exceptions",
|
|
176
|
+
"generators",
|
|
177
|
+
"models",
|
|
178
|
+
"parsers",
|
|
179
|
+
"physics",
|
|
180
|
+
"validation",
|
|
181
|
+
# Functional API
|
|
182
|
+
"read_urdf",
|
|
183
|
+
"write_urdf",
|
|
184
|
+
"read_xacro",
|
|
185
|
+
"write_xacro",
|
|
186
|
+
"read_srdf",
|
|
187
|
+
"write_srdf",
|
|
188
|
+
"validate_robot",
|
|
189
|
+
# Core Models
|
|
190
|
+
"Robot",
|
|
191
|
+
"Link",
|
|
192
|
+
"Joint",
|
|
193
|
+
"Visual",
|
|
194
|
+
"Collision",
|
|
195
|
+
"Inertial",
|
|
196
|
+
"InertiaTensor",
|
|
197
|
+
"KinematicGraph",
|
|
198
|
+
"LinkPhysics",
|
|
199
|
+
"Transform",
|
|
200
|
+
"Vector3",
|
|
201
|
+
"Geometry",
|
|
202
|
+
"GeometryType",
|
|
203
|
+
"Material",
|
|
204
|
+
"Color",
|
|
205
|
+
"Box",
|
|
206
|
+
"Cylinder",
|
|
207
|
+
"Sphere",
|
|
208
|
+
"Mesh",
|
|
209
|
+
# Sensors & Hardware
|
|
210
|
+
"Sensor",
|
|
211
|
+
"SensorType",
|
|
212
|
+
"SensorNoise",
|
|
213
|
+
"LidarInfo",
|
|
214
|
+
"CameraInfo",
|
|
215
|
+
"IMUInfo",
|
|
216
|
+
"GPSInfo",
|
|
217
|
+
"ContactInfo",
|
|
218
|
+
"ForceTorqueInfo",
|
|
219
|
+
"Transmission",
|
|
220
|
+
"TransmissionType",
|
|
221
|
+
"TransmissionJoint",
|
|
222
|
+
"TransmissionActuator",
|
|
223
|
+
"Ros2Control",
|
|
224
|
+
"Ros2ControlJoint",
|
|
225
|
+
"Ros2ControlSensor",
|
|
226
|
+
"GazeboPlugin",
|
|
227
|
+
"GazeboElement",
|
|
228
|
+
# Semantic API
|
|
229
|
+
"SemanticRobotDescription",
|
|
230
|
+
"PlanningGroup",
|
|
231
|
+
"Chain",
|
|
232
|
+
"GroupState",
|
|
233
|
+
"EndEffector",
|
|
234
|
+
"PassiveJoint",
|
|
235
|
+
"VirtualJoint",
|
|
236
|
+
"CollisionPair",
|
|
237
|
+
"LinkSphereApproximation",
|
|
238
|
+
"SrdfSphere",
|
|
239
|
+
"JointProperty",
|
|
240
|
+
# Properties & Dynamics
|
|
241
|
+
"JointLimits",
|
|
242
|
+
"JointDynamics",
|
|
243
|
+
"JointMimic",
|
|
244
|
+
"JointSafetyController",
|
|
245
|
+
"JointCalibration",
|
|
246
|
+
"JointType",
|
|
247
|
+
# File Parsers and Generators
|
|
248
|
+
"URDFParser",
|
|
249
|
+
"XACROParser",
|
|
250
|
+
"SRDFParser",
|
|
251
|
+
"RobotXMLParser",
|
|
252
|
+
"XacroResolver",
|
|
253
|
+
"clear_xacro_cache",
|
|
254
|
+
"URDFGenerator",
|
|
255
|
+
"XACROGenerator",
|
|
256
|
+
"SRDFGenerator",
|
|
257
|
+
"RobotXMLGenerator",
|
|
258
|
+
"RobotParser",
|
|
259
|
+
"RobotGenerator",
|
|
260
|
+
"IResourceResolver",
|
|
261
|
+
"FileSystemResolver",
|
|
262
|
+
"NetworkResolver",
|
|
263
|
+
# Composer API
|
|
264
|
+
"RobotBuilder",
|
|
265
|
+
"LinkBuilder",
|
|
266
|
+
"box",
|
|
267
|
+
"cylinder",
|
|
268
|
+
"sphere",
|
|
269
|
+
"mesh",
|
|
270
|
+
# Validation & Physics
|
|
271
|
+
"RobotValidator",
|
|
272
|
+
"ValidationResult",
|
|
273
|
+
"ValidationIssue",
|
|
274
|
+
"Severity",
|
|
275
|
+
"ValidationErrorCode",
|
|
276
|
+
"ValidationCheck",
|
|
277
|
+
"calculate_inertia",
|
|
278
|
+
"validate_mesh_topology",
|
|
279
|
+
# Exceptions
|
|
280
|
+
"LinkForgeError",
|
|
281
|
+
"RobotModelError",
|
|
282
|
+
"RobotParserError",
|
|
283
|
+
"RobotParserIOError",
|
|
284
|
+
"RobotGeneratorError",
|
|
285
|
+
"RobotValidationError",
|
|
286
|
+
"RobotPhysicsError",
|
|
287
|
+
"RobotMathError",
|
|
288
|
+
"RobotSecurityError",
|
|
289
|
+
"RobotXacroError",
|
|
290
|
+
"RobotXacroRecursionError",
|
|
291
|
+
"RobotXacroExpressionError",
|
|
292
|
+
"XacroDetectedError",
|
|
293
|
+
# Constants
|
|
294
|
+
"PI",
|
|
295
|
+
"EPSILON",
|
|
296
|
+
"DEFAULT_GRAVITY",
|
|
297
|
+
"DEFAULT_LINK_MASS",
|
|
298
|
+
# Logging
|
|
299
|
+
"get_logger",
|
|
300
|
+
"setup_logging",
|
|
301
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core utility functions for the LinkForge project."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Dictionary utility functions for LinkForge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
K = TypeVar("K")
|
|
8
|
+
V = TypeVar("V")
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AttrDict(dict[str, Any]):
|
|
13
|
+
"""Dictionary providing attribute-access for nested fields (e.g., config.mass).
|
|
14
|
+
Used for XACRO property resolution where YAML-loaded data uses dot notation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
18
|
+
"""Initialize the attribute dictionary and recursively wrap nested data.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
*args: Positional arguments passed to the dict constructor.
|
|
22
|
+
**kwargs: Keyword arguments passed to the dict constructor.
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
for key, value in self.items():
|
|
26
|
+
self[key] = self._wrap(value)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def _wrap(cls, value: Any) -> Any:
|
|
30
|
+
"""Recursively wrap dictionaries and lists.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
value: The value to potentially wrap in an AttrDict.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The wrapped value (AttrDict, list of wrapped values, or the original).
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return cls(value)
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [cls._wrap(v) for v in value]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
def __getattr__(self, name: str) -> Any:
|
|
45
|
+
"""Access dictionary keys as attributes.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Key name to access.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Value associated with the key.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
AttributeError: If key is not found.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
return self[name]
|
|
58
|
+
except KeyError:
|
|
59
|
+
raise AttributeError(name) from None
|
|
60
|
+
|
|
61
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
62
|
+
"""Set dictionary keys as attributes.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Key name to set.
|
|
66
|
+
value: Value to associate with the key.
|
|
67
|
+
"""
|
|
68
|
+
self[name] = value
|
|
69
|
+
|
|
70
|
+
def __delattr__(self, name: str) -> None:
|
|
71
|
+
"""Delete dictionary keys as attributes.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Key name to delete.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
AttributeError: If key is not found.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
del self[name]
|
|
81
|
+
except KeyError:
|
|
82
|
+
raise AttributeError(name) from None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@overload
|
|
86
|
+
def filter_items_by_name(items: dict[K, V], search_term: str | None) -> dict[K, V]: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def filter_items_by_name(items: list[T], search_term: str | None) -> list[T]: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def filter_items_by_name(
|
|
94
|
+
items: dict[Any, Any] | list[Any],
|
|
95
|
+
search_term: str | None,
|
|
96
|
+
) -> dict[Any, Any] | list[Any]:
|
|
97
|
+
"""Filter items by non case-sensitive substring matching on their names.
|
|
98
|
+
|
|
99
|
+
For dictionaries: filters by keys (dictionary keys treated as names).
|
|
100
|
+
For lists: filters by the '.name' attribute of objects.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
items: Dictionary (name->object) or list of objects to be filtered
|
|
104
|
+
search_term: Search string to match against item names
|
|
105
|
+
(non case-sensitive)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Filtered items in the same format as input. If search_term is empty
|
|
109
|
+
or None, returns all items.
|
|
110
|
+
"""
|
|
111
|
+
if search_term is None:
|
|
112
|
+
return items
|
|
113
|
+
|
|
114
|
+
if isinstance(search_term, str) and search_term.strip() == "":
|
|
115
|
+
return items
|
|
116
|
+
|
|
117
|
+
search_lower = search_term.lower()
|
|
118
|
+
|
|
119
|
+
if isinstance(items, dict):
|
|
120
|
+
return {name: obj for name, obj in items.items() if search_lower in name.lower()}
|
|
121
|
+
|
|
122
|
+
return [obj for obj in items if hasattr(obj, "name") and search_lower in obj.name.lower()]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Mathematical utility functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from ..constants import (
|
|
8
|
+
EPSILON,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clean_float(value: float, epsilon: float = EPSILON) -> float:
|
|
13
|
+
"""Clean up floating point values to avoid -0.0 and very small numbers.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
value: Float value to clean
|
|
17
|
+
epsilon: Threshold below which values become 0.0
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Cleaned float value
|
|
21
|
+
"""
|
|
22
|
+
if abs(value) < epsilon:
|
|
23
|
+
return 0.0
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_float(value: float, precision: int = 6) -> str:
|
|
28
|
+
"""Format float with reasonable precision, removing trailing zeros.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
value: Float value to format
|
|
32
|
+
precision: Maximum number of decimal places
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Formatted string
|
|
36
|
+
"""
|
|
37
|
+
# Clean up small values and -0.0 first
|
|
38
|
+
cleaned = clean_float(value)
|
|
39
|
+
|
|
40
|
+
# Format with specified precision
|
|
41
|
+
formatted = f"{cleaned:.{precision}f}"
|
|
42
|
+
# Remove trailing zeros and decimal point if not needed
|
|
43
|
+
formatted = formatted.rstrip("0").rstrip(".")
|
|
44
|
+
return formatted if formatted != "-0" else "0"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_vector(x: float, y: float, z: float) -> tuple[float, float, float]:
|
|
48
|
+
"""Normalize a 3D vector to unit length.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
x, y, z: Vector components
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Normalized components (x, y, z)
|
|
55
|
+
"""
|
|
56
|
+
magnitude = math.sqrt(x**2 + y**2 + z**2)
|
|
57
|
+
if magnitude < EPSILON:
|
|
58
|
+
return (0.0, 0.0, 0.0)
|
|
59
|
+
return (x / magnitude, y / magnitude, z / magnitude)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_vector(x: float, y: float, z: float, precision: int = 6) -> str:
|
|
63
|
+
"""Format 3D vector with reasonable precision.
|
|
64
|
+
|
|
65
|
+
Converts three float components into a space-separated string suitable
|
|
66
|
+
for URDF attributes like xyz, rpy, size, etc.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
x, y, z: Vector components
|
|
70
|
+
precision: Floating point precision
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Space-separated string (e.g., \"1.0 2.0 3.0\")
|
|
74
|
+
"""
|
|
75
|
+
return f"{format_float(x, precision)} {format_float(y, precision)} {format_float(z, precision)}"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Path and resource resolution utilities for LinkForge."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_package_path(
|
|
9
|
+
uri: str, start_dir: Path, additional_search_paths: list[Path] | None = None
|
|
10
|
+
) -> Path | None:
|
|
11
|
+
"""Resolve package:// URI by searching ROS_PACKAGE_PATH or upward in the tree.
|
|
12
|
+
|
|
13
|
+
This enables LinkForge to work both in standard ROS environments and in
|
|
14
|
+
standalone Blender workspaces without ROS installed.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
uri: URI starting with package:// or package:/
|
|
18
|
+
start_dir: Starting directory for upward search (usually URDF/XACRO directory)
|
|
19
|
+
additional_search_paths: Optional list of fallback directories to check first.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Path to the resolved resource or None
|
|
23
|
+
"""
|
|
24
|
+
if "package://" in uri:
|
|
25
|
+
path_remainder = uri.replace("package://", "")
|
|
26
|
+
elif "package:/" in uri:
|
|
27
|
+
path_remainder = uri.replace("package:/", "")
|
|
28
|
+
elif uri.startswith("package:"):
|
|
29
|
+
path_remainder = uri.replace("package:", "")
|
|
30
|
+
else:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if not path_remainder:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
parts = path_remainder.split("/")
|
|
37
|
+
package_name = parts[0]
|
|
38
|
+
relative_path = "/".join(parts[1:])
|
|
39
|
+
|
|
40
|
+
# Check provided additional search paths first (highest priority for overrides)
|
|
41
|
+
if additional_search_paths:
|
|
42
|
+
for p in additional_search_paths:
|
|
43
|
+
pkg_base = Path(p)
|
|
44
|
+
pkg_path = pkg_base / package_name
|
|
45
|
+
if pkg_path.exists():
|
|
46
|
+
return pkg_path / relative_path
|
|
47
|
+
|
|
48
|
+
# Also check if we ARE inside the package base already
|
|
49
|
+
if pkg_base.name == package_name:
|
|
50
|
+
return pkg_base / relative_path
|
|
51
|
+
|
|
52
|
+
# Check ROS_PACKAGE_PATH (ROS Mode)
|
|
53
|
+
ros_path = os.environ.get("ROS_PACKAGE_PATH")
|
|
54
|
+
if ros_path:
|
|
55
|
+
for path in ros_path.split(os.pathsep):
|
|
56
|
+
pkg_base = Path(path)
|
|
57
|
+
# Some paths in ROS_PACKAGE_PATH might be empty or invalid
|
|
58
|
+
if not path.strip():
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
pkg_path = pkg_base / package_name
|
|
62
|
+
if pkg_path.exists():
|
|
63
|
+
return pkg_path / relative_path
|
|
64
|
+
|
|
65
|
+
# Also check if we ARE inside the package base already (e.g. if path is the pkg itself)
|
|
66
|
+
if pkg_base.name == package_name:
|
|
67
|
+
return pkg_base / relative_path
|
|
68
|
+
|
|
69
|
+
# Fallback to upward search (Standalone Mode)
|
|
70
|
+
# This searches for a folder matching the package name or a package.xml
|
|
71
|
+
curr = start_dir.resolve()
|
|
72
|
+
if curr.is_file():
|
|
73
|
+
curr = curr.parent
|
|
74
|
+
|
|
75
|
+
# Search up to 10 levels or root
|
|
76
|
+
for _ in range(10):
|
|
77
|
+
# Case A: Current folder name matches package name
|
|
78
|
+
if curr.name == package_name:
|
|
79
|
+
return curr / relative_path
|
|
80
|
+
|
|
81
|
+
# Case B: Current folder contains package.xml
|
|
82
|
+
pkg_xml = curr / "package.xml"
|
|
83
|
+
if pkg_xml.exists() and _extract_package_name(pkg_xml) == package_name:
|
|
84
|
+
return curr / relative_path
|
|
85
|
+
|
|
86
|
+
if curr.parent == curr:
|
|
87
|
+
break
|
|
88
|
+
curr = curr.parent
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_package_name(xml_path: Path) -> str | None:
|
|
94
|
+
"""Extract <name> from package.xml using regex for performance."""
|
|
95
|
+
try:
|
|
96
|
+
# Lightweight scan of the beginning of the file
|
|
97
|
+
with open(xml_path, encoding="utf-8") as f:
|
|
98
|
+
content = f.read(1024)
|
|
99
|
+
match = re.search(r"<name>(.*?)</name>", content)
|
|
100
|
+
return match.group(1).strip() if match else None
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_uri_to_path(uri: str) -> Path:
|
|
106
|
+
"""Normalize a resource URI (specifically file://) to a local filesystem Path.
|
|
107
|
+
|
|
108
|
+
Handles Windows-style file:// URIs (e.g. file:///C:/) by stripping the
|
|
109
|
+
leading slash if a drive letter is detected. This ensures portability across
|
|
110
|
+
Posix and Windows environments.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
uri: The URI to normalize (e.g. file:///path/mesh.stl).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
A Path object representing the local path.
|
|
117
|
+
"""
|
|
118
|
+
if uri.startswith("file://"):
|
|
119
|
+
# Strip scheme (file://)
|
|
120
|
+
scheme = "file://"
|
|
121
|
+
path_str = uri[len(scheme) :]
|
|
122
|
+
# Windows handling: /C:/ -> C:/ (strip leading slash before drive letter)
|
|
123
|
+
if path_str.startswith("/") and len(path_str) > 2 and path_str[2] == ":":
|
|
124
|
+
path_str = path_str[1:]
|
|
125
|
+
return Path(path_str)
|
|
126
|
+
|
|
127
|
+
return Path(uri)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_export_path(resource: str, relative_to: Path | None = None) -> str:
|
|
131
|
+
"""Prepare a resource string for export in URDF/XACRO.
|
|
132
|
+
|
|
133
|
+
Ensures that URIs (package://, file://) are preserved correctly and not
|
|
134
|
+
mangled by standard Path normalization. If a base directory is provided,
|
|
135
|
+
local paths and file:// URIs are converted to relative paths where possible.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
resource: The resource URI or path string.
|
|
139
|
+
relative_to: Optional base directory to make paths relative to.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The string to be used in the 'filename' attribute.
|
|
143
|
+
"""
|
|
144
|
+
# Preserve package:// URIs (never make relative)
|
|
145
|
+
if resource.startswith("package://") or resource.startswith("package:/"):
|
|
146
|
+
return resource
|
|
147
|
+
|
|
148
|
+
# Handle file:// URIs
|
|
149
|
+
if resource.startswith("file://"):
|
|
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)
|
|
162
|
+
if relative_to and path.is_absolute():
|
|
163
|
+
try:
|
|
164
|
+
rel = path.relative_to(relative_to.absolute())
|
|
165
|
+
return str(rel)
|
|
166
|
+
except ValueError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return resource
|