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.
Files changed (53) hide show
  1. linkforge/core/__init__.py +301 -0
  2. linkforge/core/_utils/__init__.py +1 -0
  3. linkforge/core/_utils/dict_utils.py +122 -0
  4. linkforge/core/_utils/math_utils.py +75 -0
  5. linkforge/core/_utils/path_utils.py +169 -0
  6. linkforge/core/_utils/string_utils.py +96 -0
  7. linkforge/core/_utils/xml_utils.py +396 -0
  8. linkforge/core/base.py +218 -0
  9. linkforge/core/composer/__init__.py +11 -0
  10. linkforge/core/composer/helpers.py +25 -0
  11. linkforge/core/composer/interfaces.py +37 -0
  12. linkforge/core/composer/link_builder.py +1098 -0
  13. linkforge/core/composer/robot_builder.py +271 -0
  14. linkforge/core/composer/semantic_builder.py +253 -0
  15. linkforge/core/constants.py +293 -0
  16. linkforge/core/exceptions.py +217 -0
  17. linkforge/core/generators/__init__.py +8 -0
  18. linkforge/core/generators/srdf_generator.py +217 -0
  19. linkforge/core/generators/urdf_generator.py +1128 -0
  20. linkforge/core/generators/xacro_generator.py +895 -0
  21. linkforge/core/generators/xml_base.py +159 -0
  22. linkforge/core/io.py +125 -0
  23. linkforge/core/logging_config.py +66 -0
  24. linkforge/core/models/__init__.py +135 -0
  25. linkforge/core/models/gazebo.py +137 -0
  26. linkforge/core/models/geometry.py +193 -0
  27. linkforge/core/models/graph.py +250 -0
  28. linkforge/core/models/joint.py +294 -0
  29. linkforge/core/models/link.py +228 -0
  30. linkforge/core/models/material.py +85 -0
  31. linkforge/core/models/robot.py +1147 -0
  32. linkforge/core/models/ros2_control.py +195 -0
  33. linkforge/core/models/sensor.py +388 -0
  34. linkforge/core/models/srdf.py +555 -0
  35. linkforge/core/models/transmission.py +353 -0
  36. linkforge/core/parsers/__init__.py +21 -0
  37. linkforge/core/parsers/srdf_parser.py +570 -0
  38. linkforge/core/parsers/urdf_parser.py +1253 -0
  39. linkforge/core/parsers/xacro_parser.py +1091 -0
  40. linkforge/core/parsers/xml_base.py +324 -0
  41. linkforge/core/physics/__init__.py +29 -0
  42. linkforge/core/physics/inertia.py +399 -0
  43. linkforge/core/physics/mesh_validation.py +324 -0
  44. linkforge/core/py.typed +0 -0
  45. linkforge/core/validation/__init__.py +58 -0
  46. linkforge/core/validation/checks.py +728 -0
  47. linkforge/core/validation/result.py +150 -0
  48. linkforge/core/validation/security.py +175 -0
  49. linkforge/core/validation/validator.py +113 -0
  50. linkforge_core-1.4.0.dist-info/METADATA +236 -0
  51. linkforge_core-1.4.0.dist-info/RECORD +53 -0
  52. linkforge_core-1.4.0.dist-info/WHEEL +4 -0
  53. 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