From bafc18b383339c7a7fdcb6e9da331df4ad58fe63 Mon Sep 17 00:00:00 2001 From: arounamounchili Date: Wed, 8 Apr 2026 00:12:06 +0200 Subject: [PATCH] refactor: migrate to structured error handling and stabilize blender platform Consolidated stabilization changes: - Migrated core and blender to ValidationErrorCode system. - Standardized absolute imports to resolve class identity issues. - Fixed core integration and blender unit tests. - Normalized import paths across the entire ecosystem. - Cleaned up author metadata in unit tests. Signed-off-by: arounamounchili --- core/src/linkforge_core/composer/naming.py | 14 +-- .../linkforge_core/composer/robot_assembly.py | 21 +++- core/src/linkforge_core/exceptions.py | 102 +++++++++++++++--- core/src/linkforge_core/models/gazebo.py | 18 +++- core/src/linkforge_core/models/geometry.py | 37 +++++-- core/src/linkforge_core/models/graph.py | 18 +++- core/src/linkforge_core/models/joint.py | 95 +++++++++++++--- core/src/linkforge_core/models/link.py | 32 ++++-- core/src/linkforge_core/models/material.py | 12 ++- core/src/linkforge_core/models/robot.py | 102 ++++++++++++++---- .../src/linkforge_core/models/ros2_control.py | 43 +++++--- core/src/linkforge_core/models/sensor.py | 81 ++++++++++---- .../src/linkforge_core/models/transmission.py | 76 ++++++++++--- .../src/linkforge_core/parsers/urdf_parser.py | 15 ++- core/src/linkforge_core/parsers/xml_base.py | 14 ++- core/src/linkforge_core/physics/inertia.py | 44 ++++++-- core/src/linkforge_core/utils/string_utils.py | 9 +- core/src/linkforge_core/utils/xml_utils.py | 59 ++++++++-- core/src/linkforge_core/validation/checks.py | 30 ++++-- .../blender/adapters/blender_to_core.py | 77 +++++++++---- .../blender/adapters/core_to_blender.py | 16 +-- .../linkforge/blender/adapters/mesh_io.py | 5 +- .../blender/logic/asynchronous_builder.py | 6 +- .../linkforge/blender/operators/export_ops.py | 12 ++- .../linkforge/blender/operators/import_ops.py | 17 ++- .../linkforge/blender/operators/link_ops.py | 12 ++- .../blender/properties/joint_props.py | 14 ++- .../blender/properties/link_props.py | 8 +- .../blender/properties/sensor_props.py | 7 +- .../blender/properties/transmission_props.py | 6 +- .../linkforge/blender/utils/decorators.py | 2 +- .../linkforge/blender/utils/joint_utils.py | 3 +- .../linkforge/blender/utils/physics.py | 7 +- pyproject.toml | 1 + tests/integration/core/test_urdf_parser.py | 6 +- tests/unit/core/test_base_interfaces.py | 4 +- tests/unit/core/test_gazebo.py | 2 +- tests/unit/core/test_robot.py | 12 +-- tests/unit/core/test_robot_assembly.py | 4 +- tests/unit/core/test_urdf_parser.py | 27 +++-- .../unit/core/test_urdf_parser_robustness.py | 3 +- tests/unit/core/test_xml_utils.py | 12 +-- .../platforms/blender/test_async_builder.py | 2 +- .../platforms/blender/test_blender_to_core.py | 19 ++-- .../blender/test_blender_to_core_errors.py | 29 ++--- .../test_blender_to_core_robustness.py | 8 +- .../platforms/blender/test_converters_deep.py | 2 +- .../platforms/blender/test_core_to_blender.py | 8 +- .../test_core_to_blender_robustness.py | 16 +-- .../unit/platforms/blender/test_decorators.py | 2 +- .../unit/platforms/blender/test_export_ops.py | 12 +-- .../unit/platforms/blender/test_import_ops.py | 30 ++---- 52 files changed, 881 insertions(+), 332 deletions(-) diff --git a/core/src/linkforge_core/composer/naming.py b/core/src/linkforge_core/composer/naming.py index d6b3a7cf..92f2c405 100644 --- a/core/src/linkforge_core/composer/naming.py +++ b/core/src/linkforge_core/composer/naming.py @@ -9,7 +9,7 @@ from dataclasses import replace from typing import TYPE_CHECKING -from ..exceptions import RobotModelError +from ..exceptions import RobotModelError, RobotValidationError, ValidationErrorCode from ..logging_config import get_logger if TYPE_CHECKING: @@ -35,7 +35,7 @@ def add_link_with_renaming(robot: Robot, link: Link) -> None: except RobotModelError as e: original_name = link.name counter = 1 - if "already exists" in str(e).lower(): + if isinstance(e, RobotValidationError) and e.code == ValidationErrorCode.DUPLICATE_NAME: while True: new_name = f"{original_name}_duplicate_{counter}" if new_name not in robot._link_index: @@ -67,9 +67,7 @@ def add_joint_with_renaming(robot: Robot, joint: Joint, fallback_name: str | Non robot.add_joint(joint) except RobotModelError as e: joint_name = joint.name or fallback_name or "unnamed_joint" - error_msg = str(e).lower() - - if "already exists" in error_msg: + if isinstance(e, RobotValidationError) and e.code == ValidationErrorCode.DUPLICATE_NAME: original_name = joint_name counter = 1 while True: @@ -81,8 +79,10 @@ def add_joint_with_renaming(robot: Robot, joint: Joint, fallback_name: str | Non logger.warning(f"Renamed duplicate joint '{original_name}' to '{new_name}'") break except RobotModelError as inner_e: - inner_msg = str(inner_e).lower() - if "not found" in inner_msg: + if ( + isinstance(inner_e, RobotValidationError) + and inner_e.code == ValidationErrorCode.NOT_FOUND + ): logger.warning(f"Skipping duplicate joint '{original_name}': {inner_e}") break counter += 1 diff --git a/core/src/linkforge_core/composer/robot_assembly.py b/core/src/linkforge_core/composer/robot_assembly.py index a980f502..ae728d77 100644 --- a/core/src/linkforge_core/composer/robot_assembly.py +++ b/core/src/linkforge_core/composer/robot_assembly.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode from ..generators.srdf_generator import SRDFGenerator from ..generators.urdf_generator import URDFGenerator from ..models.geometry import Transform, Vector3 @@ -114,7 +114,12 @@ def attach( """ # 0. Early validation of attachment point if not self.robot.get_link(at_link): - raise RobotValidationError("Attach", at_link, "Attachment link not found in assembly") + raise RobotValidationError( + ValidationErrorCode.NOT_FOUND, + f"Attachment link '{at_link}' not found in assembly", + target="Attach", + value=at_link, + ) # 1. Deep copy the component to ensure isolation sub_robot = component.clone() @@ -127,7 +132,12 @@ def attach( # 3. Identify the root link of the sub-robot root_link = sub_robot.get_root_link() if not root_link: - raise RobotValidationError("Attach", component.name, "No root link found in component") + raise RobotValidationError( + ValidationErrorCode.NO_ROOT, + f"No root link found in component '{component.name}'", + target="Attach", + value=component.name, + ) # 4. Merge links for link in sub_robot.links: @@ -371,9 +381,10 @@ def _get_connection_params(self, origin: Transform | None = None) -> tuple[str, """Resolve parent, joint name and origin for finalization.""" if self._pending_parent is None or self._pending_joint_name is None: raise RobotValidationError( - "LinkBuilder", - self._link.name, + ValidationErrorCode.GENERIC_FAILURE, "connect_to() must be called before finalizing the joint", + target="LinkBuilder", + value=self._link.name, ) resolved_origin = origin if origin is not None else self._pending_origin diff --git a/core/src/linkforge_core/exceptions.py b/core/src/linkforge_core/exceptions.py index 14c1401f..bbdf64ff 100644 --- a/core/src/linkforge_core/exceptions.py +++ b/core/src/linkforge_core/exceptions.py @@ -4,10 +4,39 @@ and generators to provide granular error handling. """ +from enum import Enum from pathlib import Path from typing import Any +class ValidationErrorCode(Enum): + """Categorized error codes for robot validation failures.""" + + # Naming and Identity + INVALID_NAME = "invalid_name" + DUPLICATE_NAME = "duplicate_name" + NAME_EMPTY = "name_empty" + + # Kinematic Structure + NOT_FOUND = "not_found" + HAS_CYCLE = "has_cycle" + NO_ROOT = "no_root" + MULTIPLE_ROOTS = "multiple_roots" + DISCONNECTED = "disconnected" + + # Physics and Values + OUT_OF_RANGE = "out_of_range" + VALUE_EMPTY = "value_empty" + INVALID_VALUE = "invalid_value" + PHYSICS_VIOLATION = "physics_violation" + MATH_ERROR = "math_error" + INERTIA_TRIANGLE_INEQUALITY = "inertia_triangle_inequality" + + # Configuration and Misc + MISMATCH = "mismatch" + GENERIC_FAILURE = "generic_failure" + + class LinkForgeError(Exception): """Base category for all LinkForge-related exceptions.""" @@ -60,22 +89,51 @@ class RobotPhysicsError(RobotModelError): """Exception raised for unphysical properties (e.g. negative mass or volume).""" def __init__( - self, property_name: str = "unknown", value: Any = None, reason: str | None = None + self, + code: ValidationErrorCode, + message: str, + target: str | None = None, + value: Any = None, ): - msg = f"Invalid physics property '{property_name}': {value}" - if reason: - msg += f" ({reason})" - super().__init__(msg) + self.code = code + self.target = target + self.value = value + self.raw_message = message + + full_msg = f"[PHYSICS_{code.name}] {message}" + if target: + full_msg += f" (target: {target})" + if value is not None: + full_msg += f" (value: {value})" + + super().__init__(full_msg) class RobotValidationError(RobotModelError): - """Exception raised for structural or logic validation failures.""" + """Exception raised for structural or logic validation failures. - def __init__(self, check_name: str = "unknown", value: Any = None, reason: str | None = None): - msg = f"Validation failed [{check_name}]: {value}" - if reason: - msg += f" ({reason})" - super().__init__(msg) + Now structured using ValidationErrorCode for robust error handling. + """ + + def __init__( + self, + code: ValidationErrorCode, + message: str, + target: str | None = None, + value: Any = None, + ): + self.code = code + self.target = target + self.value = value + self.raw_message = message + + full_msg = f"[{code.name}] {message}" + if target: + full_msg += f" (target: {target})" + if value is not None: + full_msg += f" (value: {value})" + + super().__init__(full_msg) class RobotSecurityError(RobotModelError): @@ -89,12 +147,24 @@ class RobotMathError(RobotModelError): """Exception raised for invalid numerical values (NaN, Inf, or Out of Range).""" def __init__( - self, value: float | str | None = None, check_name: str = "value", reason: str | None = None + self, + code: ValidationErrorCode, + message: str, + target: str | None = None, + value: Any = None, ): - msg = f"Invalid value '{value}' in {check_name}: must be a finite number" - if reason: - msg += f" ({reason})" - super().__init__(msg) + self.code = code + self.target = target + self.value = value + self.raw_message = message + + full_msg = f"[MATH_{code.name}] {message}" + if target: + full_msg += f" (target: {target})" + if value is not None: + full_msg += f" (value: {value})" + + super().__init__(full_msg) class RobotXacroError(RobotParserError): diff --git a/core/src/linkforge_core/models/gazebo.py b/core/src/linkforge_core/models/gazebo.py index 72093c79..1c68399f 100644 --- a/core/src/linkforge_core/models/gazebo.py +++ b/core/src/linkforge_core/models/gazebo.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode @dataclass(frozen=True) @@ -41,7 +41,11 @@ def __post_init__(self) -> None: """Validate Gazebo element.""" # If reference is specified, it must be non-empty if self.reference is not None and not self.reference: - raise RobotValidationError("GazeboReference", self.reference, "cannot be empty string") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Gazebo reference cannot be empty", + target="GazeboReference", + ) @dataclass(frozen=True) @@ -59,6 +63,12 @@ class GazeboPlugin: def __post_init__(self) -> None: """Validate plugin configuration.""" if not self.name: - raise RobotValidationError("PluginName", self.name, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, "Plugin name cannot be empty", target="PluginName" + ) if not self.filename: - raise RobotValidationError("PluginFilename", self.filename, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + "Plugin filename cannot be empty", + target="PluginFilename", + ) diff --git a/core/src/linkforge_core/models/geometry.py b/core/src/linkforge_core/models/geometry.py index 19ea9df6..0f312ae3 100644 --- a/core/src/linkforge_core/models/geometry.py +++ b/core/src/linkforge_core/models/geometry.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from enum import Enum -from ..exceptions import RobotPhysicsError +from ..exceptions import RobotPhysicsError, ValidationErrorCode class GeometryType(Enum): @@ -71,7 +71,12 @@ class Box: def __post_init__(self) -> None: """Validate box dimensions.""" if self.size.x <= 0 or self.size.y <= 0 or self.size.z <= 0: - raise RobotPhysicsError("BoxSize", self.size, "dimensions must be positive") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Box dimensions must be positive", + target="BoxSize", + value=self.size, + ) @property def type(self) -> GeometryType: @@ -92,9 +97,19 @@ class Cylinder: def __post_init__(self) -> None: """Validate cylinder dimensions.""" if self.radius <= 0: - raise RobotPhysicsError("CylinderRadius", self.radius, "radius must be positive") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Cylinder radius must be positive", + target="CylinderRadius", + value=self.radius, + ) if self.length <= 0: - raise RobotPhysicsError("CylinderLength", self.length, "length must be positive") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Cylinder length must be positive", + target="CylinderLength", + value=self.length, + ) @property def type(self) -> GeometryType: @@ -116,7 +131,12 @@ class Sphere: def __post_init__(self) -> None: """Validate sphere dimensions.""" if self.radius <= 0: - raise RobotPhysicsError("SphereRadius", self.radius, "radius must be positive") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Sphere radius must be positive", + target="SphereRadius", + value=self.radius, + ) @property def type(self) -> GeometryType: @@ -139,7 +159,12 @@ class Mesh: def __post_init__(self) -> None: """Validate mesh scale.""" if self.scale.x == 0 or self.scale.y == 0 or self.scale.z == 0: - raise RobotPhysicsError("MeshScale", self.scale, "scale components must be non-zero") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Mesh scale components must be non-zero", + target="MeshScale", + value=self.scale, + ) @property def type(self) -> GeometryType: diff --git a/core/src/linkforge_core/models/graph.py b/core/src/linkforge_core/models/graph.py index 01e2a7b6..2c638b64 100644 --- a/core/src/linkforge_core/models/graph.py +++ b/core/src/linkforge_core/models/graph.py @@ -10,7 +10,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode if TYPE_CHECKING: from .joint import Joint @@ -44,11 +44,17 @@ def __init__(self, links: Iterable[Link], joints: Iterable[Joint]) -> None: for joint in self.joints: if joint.parent not in self.link_names: raise RobotValidationError( - "ParentLink", joint.parent, f"referenced by joint '{joint.name}' is unknown" + ValidationErrorCode.NOT_FOUND, + f"Parent link '{joint.parent}' unknown", + target="ParentLink", + value=joint.parent, ) if joint.child not in self.link_names: raise RobotValidationError( - "ChildLink", joint.child, f"referenced by joint '{joint.name}' is unknown" + ValidationErrorCode.NOT_FOUND, + f"Child link '{joint.child}' unknown", + target="ChildLink", + value=joint.child, ) self.adj[joint.parent].append((joint.child, joint.name)) @@ -152,7 +158,11 @@ def get_topological_order(self) -> list[str]: RobotValidationError: If a cycle is detected """ if self.has_cycle(): - raise RobotValidationError("CyclicGraph", None, "Graph contains cycles") + raise RobotValidationError( + ValidationErrorCode.HAS_CYCLE, + "Kinematic graph contains cycles", + target="CyclicGraph", + ) order: list[str] = [] # Implement Kahn's algorithm for topological sorting. diff --git a/core/src/linkforge_core/models/joint.py b/core/src/linkforge_core/models/joint.py index f8a2f08d..44e01d72 100644 --- a/core/src/linkforge_core/models/joint.py +++ b/core/src/linkforge_core/models/joint.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from enum import Enum -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode from ..utils.string_utils import is_valid_urdf_name from .geometry import Transform, Vector3 @@ -38,12 +38,25 @@ def __post_init__(self) -> None: # Only validate lower/upper relationship if both are provided if self.lower is not None and self.upper is not None and self.lower > self.upper: raise RobotValidationError( - "JointLimitRange", f"{self.lower} > {self.upper}", "Lower must be <= Upper" + ValidationErrorCode.OUT_OF_RANGE, + "Lower limit must be <= Upper limit", + target="JointLimitRange", + value=(self.lower, self.upper), ) if self.effort < 0: - raise RobotValidationError("JointEffort", self.effort, "Must be non-negative") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + "Effort must be non-negative", + target="JointEffort", + value=self.effort, + ) if self.velocity < 0: - raise RobotValidationError("JointVelocity", self.velocity, "Must be non-negative") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + "Velocity must be non-negative", + target="JointVelocity", + value=self.velocity, + ) @dataclass(frozen=True) @@ -56,9 +69,19 @@ class JointDynamics: def __post_init__(self) -> None: """Validate dynamics.""" if self.damping < 0: - raise RobotValidationError("JointDamping", self.damping, "Must be non-negative") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + "Damping must be non-negative", + target="JointDamping", + value=self.damping, + ) if self.friction < 0: - raise RobotValidationError("JointFriction", self.friction, "Must be non-negative") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + "Friction must be non-negative", + target="JointFriction", + value=self.friction, + ) @dataclass(frozen=True) @@ -135,21 +158,39 @@ def __post_init__(self) -> None: axis, or limit requirements for the joint type are violated. """ if not self.name: - raise RobotValidationError("JointName", self.name, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, "Joint name cannot be empty", target="JointName" + ) # Validate naming convention if not is_valid_urdf_name(self.name): - raise RobotValidationError("JointName", self.name, "Invalid characters") + raise RobotValidationError( + ValidationErrorCode.INVALID_NAME, + "Invalid characters in joint name", + target="JointName", + value=self.name, + ) if not self.parent: - raise RobotValidationError("ParentLink", self.parent, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Parent link name cannot be empty", + target="ParentLink", + ) if not self.child: - raise RobotValidationError("ChildLink", self.child, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Child link name cannot be empty", + target="ChildLink", + ) if self.parent == self.child: raise RobotValidationError( - "JointTopology", self.parent, "Parent and child cannot be the same" + ValidationErrorCode.INVALID_VALUE, + "Parent and child links cannot be the same", + target="JointTopology", + value=self.parent, ) # Validate Axis requirements @@ -162,23 +203,35 @@ def __post_init__(self) -> None: ): if self.axis is None: raise RobotValidationError( - "JointAxis", self.type.value, "Axis required for this type" + ValidationErrorCode.INVALID_VALUE, + f"Axis required for joint type '{self.type.value}'", + target="JointAxis", + value=self.type.value, ) elif self.type in (JointType.FIXED, JointType.FLOATING) and self.axis is not None: raise RobotValidationError( - "JointAxis", self.type.value, "Axis not allowed for this type" + ValidationErrorCode.INVALID_VALUE, + f"Axis not allowed for joint type '{self.type.value}'", + target="JointAxis", + value=self.type.value, ) # Validate Limits if self.type in (JointType.REVOLUTE, JointType.PRISMATIC): if self.limits is None: raise RobotValidationError( - "JointLimits", self.type.value, "Limits required for this type" + ValidationErrorCode.INVALID_VALUE, + f"Limits required for joint type '{self.type.value}'", + target="JointLimits", + value=self.type.value, ) # Limit validity is already checked in JointLimits.__post_init__ (RobotModelError) elif self.type == JointType.FIXED and self.limits is not None: raise RobotValidationError( - "JointLimits", self.type.value, "Limits not allowed for this type" + ValidationErrorCode.INVALID_VALUE, + f"Limits not allowed for joint type '{self.type.value}'", + target="JointLimits", + value=self.type.value, ) # Validate and normalize axis if present @@ -187,12 +240,20 @@ def __post_init__(self) -> None: axis_magnitude = math.sqrt(self.axis.x**2 + self.axis.y**2 + self.axis.z**2) if axis_magnitude < 1e-10: - raise RobotValidationError("JointAxisMagnitude", axis_magnitude, "Too small") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + "Joint axis magnitude is too small", + target="JointAxisMagnitude", + value=axis_magnitude, + ) # Enforce normalized axis in model if abs(axis_magnitude - 1.0) > 1e-6: raise RobotValidationError( - "JointAxisNormalization", axis_magnitude, "Must be unit vector" + ValidationErrorCode.INVALID_VALUE, + "Joint axis must be a unit vector", + target="JointAxisNormalization", + value=axis_magnitude, ) @property diff --git a/core/src/linkforge_core/models/link.py b/core/src/linkforge_core/models/link.py index 05fc0207..100a3192 100644 --- a/core/src/linkforge_core/models/link.py +++ b/core/src/linkforge_core/models/link.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from dataclasses import InitVar, dataclass, field -from ..exceptions import RobotPhysicsError, RobotValidationError +from ..exceptions import RobotPhysicsError, RobotValidationError, ValidationErrorCode from ..utils.string_utils import is_valid_urdf_name from .geometry import Geometry, Transform from .material import Material @@ -36,7 +36,12 @@ def __post_init__(self) -> None: """Validate inertia tensor values.""" # All diagonal elements must be positive if self.ixx <= 0 or self.iyy <= 0 or self.izz <= 0: - raise RobotPhysicsError("DiagonalInertia", self.ixx, "Must be positive (ixx, iyy, izz)") + raise RobotPhysicsError( + ValidationErrorCode.OUT_OF_RANGE, + "Diagonal inertia components must be positive", + target="DiagonalInertia", + value=(self.ixx, self.iyy, self.izz), + ) # Triangle inequality for principal moments # https://en.wikipedia.org/wiki/Moment_of_inertia#Principal_axes @@ -48,7 +53,10 @@ def __post_init__(self) -> None: and self.izz + self.ixx >= self.iyy - epsilon ): raise RobotPhysicsError( - "InertiaTriangleInequality", self.ixx, "Violates physical bounds" + ValidationErrorCode.INERTIA_TRIANGLE_INEQUALITY, + "Inertia tensor violates triangle inequality (unphysical)", + target="InertiaTriangleInequality", + value=(self.ixx, self.iyy, self.izz), ) @classmethod @@ -73,7 +81,12 @@ def __post_init__(self) -> None: RobotPhysicsError: If mass is negative. """ if self.mass < 0: - raise RobotPhysicsError("Mass", self.mass, "Must be non-negative") + raise RobotPhysicsError( + ValidationErrorCode.OUT_OF_RANGE, + "Mass must be non-negative", + target="Mass", + value=self.mass, + ) @dataclass(frozen=True) @@ -118,11 +131,18 @@ def __post_init__( ) -> None: """Validate link.""" if not self.name: - raise RobotValidationError("LinkName", self.name, "cannot be empty") + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, "Link name cannot be empty", target="LinkName" + ) # URDF naming convention: lowercase with underscores if not is_valid_urdf_name(self.name): - raise RobotValidationError("LinkName", self.name, "Invalid characters") + raise RobotValidationError( + ValidationErrorCode.INVALID_NAME, + "Invalid characters in link name", + target="LinkName", + value=self.name, + ) if initial_visuals: self._visuals.extend(initial_visuals) diff --git a/core/src/linkforge_core/models/material.py b/core/src/linkforge_core/models/material.py index 8479c490..0fa4c2b0 100644 --- a/core/src/linkforge_core/models/material.py +++ b/core/src/linkforge_core/models/material.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode @dataclass(frozen=True) @@ -21,7 +21,10 @@ def __post_init__(self) -> None: for component in (self.r, self.g, self.b, self.a): if not 0.0 <= component <= 1.0: raise RobotValidationError( - "ColorComponent", component, "must be in range [0.0, 1.0]" + ValidationErrorCode.OUT_OF_RANGE, + "Color component must be in range [0.0, 1.0]", + target="ColorComponent", + value=component, ) def to_tuple(self) -> tuple[float, float, float, float]: @@ -45,5 +48,8 @@ def __post_init__(self) -> None: """Validate material has at least color or texture.""" if self.color is None and self.texture is None: raise RobotValidationError( - "MaterialDefinition", self.name, "must have either color or texture" + ValidationErrorCode.VALUE_EMPTY, + f"Material '{self.name}' must have either color or texture", + target="MaterialDefinition", + value=self.name, ) diff --git a/core/src/linkforge_core/models/robot.py b/core/src/linkforge_core/models/robot.py index d121138a..5e7cca80 100644 --- a/core/src/linkforge_core/models/robot.py +++ b/core/src/linkforge_core/models/robot.py @@ -13,7 +13,7 @@ from typing import Any from ..base import FileSystemResolver, IResourceResolver -from ..exceptions import RobotModelError, RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode from ..utils.string_utils import is_valid_urdf_name from .gazebo import GazeboElement from .graph import KinematicGraph @@ -91,11 +91,21 @@ def __post_init__( ) -> None: """Initialize and index the robot structure.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Robot name cannot be empty", + target="RobotName", + value=self.name, + ) # Validate naming convention if not is_valid_urdf_name(self.name): - raise RobotValidationError("RobotName", self.name, "Invalid characters") + raise RobotValidationError( + ValidationErrorCode.INVALID_NAME, + "Invalid name format", + target="RobotName", + value=self.name, + ) # Initialize storage if initial_links: @@ -127,14 +137,24 @@ def _reindex(self) -> None: self._link_index = {} for link in self._links: if link.name in self._link_index: - raise RobotValidationError("LinkName", link.name, "Duplicate found in index") + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Link '{link.name}'", + target="Link", + value=link.name, + ) self._link_index[link.name] = link # Validate joint names and build index self._joint_index = {} for joint in self._joints: if joint.name in self._joint_index: - raise RobotValidationError("JointName", joint.name, "Duplicate found in index") + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Joint '{joint.name}'", + target="Joint", + value=joint.name, + ) self._joint_index[joint.name] = joint self._sensor_index = {sensor.name: sensor for sensor in self._sensors} @@ -293,7 +313,12 @@ def add_link(self, link: Link) -> None: or if naming conventions are violated. """ if link.name in self._link_index: - raise RobotValidationError("LinkName", link.name, "Already exists") + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Link '{link.name}'", + target="Link", + value=link.name, + ) self._links.append(link) self._link_index[link.name] = link self._graph_cache = None @@ -309,13 +334,28 @@ def add_joint(self, joint: Joint) -> None: referenced parent/child links do not exist. """ if joint.name in self._joint_index: - raise RobotValidationError("JointName", joint.name, "Already exists") + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Joint '{joint.name}'", + target="Joint", + value=joint.name, + ) # Validate parent and child links exist if joint.parent not in self._link_index: - raise RobotValidationError("ParentLink", joint.parent, "Not found") + raise RobotValidationError( + ValidationErrorCode.NOT_FOUND, + f"Not found: Parent link '{joint.parent}'", + target="ParentLink", + value=joint.parent, + ) if joint.child not in self._link_index: - raise RobotValidationError("ChildLink", joint.child, "Not found") + raise RobotValidationError( + ValidationErrorCode.NOT_FOUND, + f"Not found: Child link '{joint.child}'", + target="ChildLink", + value=joint.child, + ) self._joints.append(joint) self._joint_index[joint.name] = joint @@ -360,13 +400,19 @@ def add_sensor(self, sensor: Sensor) -> None: """Add a sensor to the robot and update indices.""" if sensor.name in self._sensor_index: raise RobotValidationError( - check_name="DuplicateSensor", value=sensor.name, reason="Already exists" + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Sensor '{sensor.name}'", + target="Sensor", + value=sensor.name, ) # Validate that the link exists if sensor.link_name not in self._link_index: raise RobotValidationError( - check_name="LinkName", value=sensor.link_name, reason=sensor.name + ValidationErrorCode.NOT_FOUND, + f"Not found: Link '{sensor.link_name}'", + target="LinkName", + value=sensor.link_name, ) self._sensors.append(sensor) @@ -376,14 +422,20 @@ def add_transmission(self, transmission: Transmission) -> None: """Add a transmission to the robot.""" if any(t.name == transmission.name for t in self._transmissions): raise RobotValidationError( - check_name="DuplicateTransmission", value=transmission.name, reason="Already exists" + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: Transmission '{transmission.name}'", + target="Transmission", + value=transmission.name, ) # Validate that all referenced joints exist for trans_joint in transmission.joints: if trans_joint.name not in self._joint_index: raise RobotValidationError( - check_name="JointName", value=trans_joint.name, reason=transmission.name + ValidationErrorCode.NOT_FOUND, + f"Not found: Joint '{trans_joint.name}'", + target="JointName", + value=trans_joint.name, ) self._transmissions.append(transmission) @@ -397,9 +449,10 @@ def add_gazebo_element(self, element: GazeboElement) -> None: and self.get_joint(element.reference) is None ): raise RobotValidationError( - check_name="GazeboReference", + ValidationErrorCode.NOT_FOUND, + f"Not found: Gazebo reference '{element.reference}'", + target="GazeboReference", value=element.reference, - reason="No matching link or joint", ) self._gazebo_elements.append(element) @@ -408,7 +461,12 @@ def add_ros2_control(self, ros2_control: Ros2Control) -> None: """Add a ROS2 Control configuration to the robot.""" # Check for duplicate names if any(rc.name == ros2_control.name for rc in self._ros2_controls): - raise RobotValidationError("Ros2ControlName", ros2_control.name, "Already exists") + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Already exists: ROS2 control '{ros2_control.name}'", + target="Ros2Control", + value=ros2_control.name, + ) self._ros2_controls.append(ros2_control) @@ -433,10 +491,18 @@ def get_root_link(self) -> Link | None: roots = self.graph.get_root_links() if not roots: - raise RobotValidationError(check_name="Roots", value=0, reason="No root link found") + raise RobotValidationError( + ValidationErrorCode.NO_ROOT, + "No root link found in the kinematic tree", + target="Roots", + value=0, + ) if len(roots) > 1: raise RobotValidationError( - check_name="Roots", value=len(roots), reason="Multiple root links found" + ValidationErrorCode.MULTIPLE_ROOTS, + f"Multiple root links found ({len(roots)}): {roots}", + target="Roots", + value=len(roots), ) return self.get_link(roots[0]) diff --git a/core/src/linkforge_core/models/ros2_control.py b/core/src/linkforge_core/models/ros2_control.py index c68c24aa..f0f86f7c 100644 --- a/core/src/linkforge_core/models/ros2_control.py +++ b/core/src/linkforge_core/models/ros2_control.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from ..exceptions import RobotModelError, RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode @dataclass @@ -22,14 +22,20 @@ class Ros2ControlJoint: def __post_init__(self) -> None: """Validate joint configuration.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "ROS2 control joint name cannot be empty", + target="JointName", + value=self.name, + ) # For sensors, both can be empty initially, but at least one state interface is usually required. # However, we'll allow empty for now to support incremental building. if not self.command_interfaces and not self.state_interfaces: raise RobotValidationError( - check_name="Ros2ControlInterfaces", + ValidationErrorCode.VALUE_EMPTY, + f"ROS2 control joint '{self.name}' must have at least one command or state interface", + target="Ros2ControlInterfaces", value=self.name, - reason="Must have command or state interface", ) @@ -50,30 +56,43 @@ class Ros2Control: def __post_init__(self) -> None: """Validate ros2_control configuration.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "ROS2 control name cannot be empty", + target="Ros2ControlName", + value=self.name, + ) if self.type not in ("system", "actuator", "sensor"): raise RobotValidationError( - check_name="Ros2ControlType", + ValidationErrorCode.INVALID_VALUE, + f"Invalid ROS2 control type '{self.type}' (must be system, actuator, or sensor)", + target="Ros2ControlType", value=self.type, - reason="Must be system, actuator, or sensor", ) if not self.hardware_plugin: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + "Hardware plugin cannot be empty", + target="HardwarePlugin", + value=self.hardware_plugin, + ) # Hardware sensors are read-only and do not accept command interfaces if self.type == "sensor": for joint in self.joints: if joint.command_interfaces: raise RobotValidationError( - check_name="Ros2ControlMode", + ValidationErrorCode.INVALID_VALUE, + "Hardware sensors cannot have command interfaces", + target="Ros2ControlMode", value=self.type, - reason="Cannot have command interfaces", ) # Hardware actuators are designed for exactly one joint if self.type == "actuator" and len(self.joints) != 1: raise RobotValidationError( - check_name="Ros2ControlJoints", + ValidationErrorCode.INVALID_VALUE, + "Actuator type must have exactly one joint", + target="Ros2ControlJoints", value=len(self.joints), - reason="Actuator must have exactly one joint", ) diff --git a/core/src/linkforge_core/models/sensor.py b/core/src/linkforge_core/models/sensor.py index 2f0d934e..23b5ebd1 100644 --- a/core/src/linkforge_core/models/sensor.py +++ b/core/src/linkforge_core/models/sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from enum import Enum -from ..exceptions import RobotModelError, RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode from .gazebo import GazeboPlugin from .geometry import Transform @@ -54,21 +54,31 @@ def __post_init__(self) -> None: # Use small tolerance (1e-6) to handle floating-point precision from UI conversions if self.horizontal_fov <= 0 or self.horizontal_fov > (math.pi + 1e-6): raise RobotValidationError( - check_name="CameraFOV", value=self.horizontal_fov, reason="Must be 0-180 deg" + ValidationErrorCode.OUT_OF_RANGE, + "Camera FOV must be between 0 and 180 degrees (π radians)", + target="CameraFOV", + value=self.horizontal_fov, ) if self.width <= 0 or self.height <= 0: raise RobotValidationError( - check_name="ImageDimensions", + ValidationErrorCode.OUT_OF_RANGE, + "Image dimensions must be positive", + target="ImageDimensions", value=(self.width, self.height), - reason="Must be positive", ) if self.near_clip <= 0: raise RobotValidationError( - check_name="NearClip", value=self.near_clip, reason="Must be positive" + ValidationErrorCode.OUT_OF_RANGE, + "Near clip must be positive", + target="NearClip", + value=self.near_clip, ) if self.far_clip <= self.near_clip: raise RobotValidationError( - check_name="FarClip", value=self.far_clip, reason="Must be > near clip" + ValidationErrorCode.OUT_OF_RANGE, + "Far clip must be greater than near clip", + target="FarClip", + value=self.far_clip, ) @@ -100,21 +110,31 @@ def __post_init__(self) -> None: """Validate LIDAR parameters.""" if self.horizontal_samples <= 0: raise RobotValidationError( - check_name="LidarSamples", value=self.horizontal_samples, reason="Must be positive" + ValidationErrorCode.OUT_OF_RANGE, + "Lidar samples must be positive", + target="LidarSamples", + value=self.horizontal_samples, ) if self.range_min <= 0: raise RobotValidationError( - check_name="LidarRangeMin", value=self.range_min, reason="Must be positive" + ValidationErrorCode.OUT_OF_RANGE, + "Lidar range_min must be positive", + target="LidarRangeMin", + value=self.range_min, ) if self.range_max <= self.range_min: raise RobotValidationError( - check_name="LidarRangeMax", value=self.range_max, reason="Must be > min" + ValidationErrorCode.OUT_OF_RANGE, + "Lidar range_max must be greater than range_min", + target="LidarRangeMax", + value=self.range_max, ) if self.horizontal_min_angle >= self.horizontal_max_angle: raise RobotValidationError( - check_name="LidarAngleRange", + ValidationErrorCode.OUT_OF_RANGE, + "Lidar horizontal min_angle must be less than max_angle", + target="LidarAngleRange", value=(self.horizontal_min_angle, self.horizontal_max_angle), - reason="Min must be < Max", ) @@ -207,31 +227,56 @@ class Sensor: def __post_init__(self) -> None: """Validate sensor configuration.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Sensor name cannot be empty", + target="SensorName", + value=self.name, + ) if not self.link_name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Sensor link_name cannot be empty", + target="LinkName", + value=self.link_name, + ) if self.update_rate <= 0: raise RobotValidationError( - check_name="UpdateRate", value=self.update_rate, reason="Must be positive" + ValidationErrorCode.OUT_OF_RANGE, + "Sensor update rate must be positive", + target="UpdateRate", + value=self.update_rate, ) # Validate that appropriate info is set for sensor type if self.type in (SensorType.CAMERA, SensorType.DEPTH_CAMERA): if self.camera_info is None: raise RobotValidationError( - check_name="SensorInfo", value=self.name, reason="Requires camera_info" + ValidationErrorCode.INVALID_VALUE, + f"Sensor '{self.name}' [type: {self.type.value}] requires camera_info", + target="SensorInfo", + value=self.name, ) elif self.type == SensorType.LIDAR: if self.lidar_info is None: raise RobotValidationError( - check_name="SensorInfo", value=self.name, reason="Requires lidar_info" + ValidationErrorCode.INVALID_VALUE, + f"Sensor '{self.name}' [type: {self.type.value}] requires lidar_info", + target="SensorInfo", + value=self.name, ) elif self.type == SensorType.IMU: if self.imu_info is None: raise RobotValidationError( - check_name="SensorInfo", value=self.name, reason="Requires imu_info" + ValidationErrorCode.INVALID_VALUE, + f"Sensor '{self.name}' [type: {self.type.value}] requires imu_info", + target="SensorInfo", + value=self.name, ) elif self.type == SensorType.GPS and self.gps_info is None: raise RobotValidationError( - check_name="SensorInfo", value=self.name, reason="Requires gps_info" + ValidationErrorCode.INVALID_VALUE, + f"Sensor '{self.name}' [type: {self.type.value}] requires gps_info", + target="SensorInfo", + value=self.name, ) diff --git a/core/src/linkforge_core/models/transmission.py b/core/src/linkforge_core/models/transmission.py index 7bf6fcaa..be858f7a 100644 --- a/core/src/linkforge_core/models/transmission.py +++ b/core/src/linkforge_core/models/transmission.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from enum import Enum -from ..exceptions import RobotModelError, RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode from ..utils.string_utils import is_valid_urdf_name @@ -51,16 +51,25 @@ class TransmissionJoint: def __post_init__(self) -> None: """Validate transmission joint.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Transmission joint name cannot be empty", + target="JointName", + value=self.name, + ) if not self.hardware_interfaces: raise RobotValidationError( - check_name="HardwareInterfaces", + ValidationErrorCode.VALUE_EMPTY, + f"Transmission joint '{self.name}' must have at least one interface", + target="HardwareInterfaces", value=self.name, - reason="Must have at least one interface", ) if self.mechanical_reduction == 0: raise RobotValidationError( - check_name="MechanicalReduction", value=self.name, reason="Cannot be zero" + ValidationErrorCode.INVALID_VALUE, + f"Mechanical reduction for transmission joint '{self.name}' cannot be zero", + target="MechanicalReduction", + value=self.name, ) @@ -79,16 +88,25 @@ class TransmissionActuator: def __post_init__(self) -> None: """Validate transmission actuator.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Transmission actuator name cannot be empty", + target="ActuatorName", + value=self.name, + ) if not self.hardware_interfaces: raise RobotValidationError( - check_name="HardwareInterfaces", + ValidationErrorCode.VALUE_EMPTY, + f"Transmission actuator '{self.name}' must have at least one interface", + target="HardwareInterfaces", value=self.name, - reason="Must have at least one interface", ) if self.mechanical_reduction == 0: raise RobotValidationError( - check_name="MechanicalReduction", value=self.name, reason="Cannot be zero" + ValidationErrorCode.INVALID_VALUE, + f"Mechanical reduction for transmission actuator '{self.name}' cannot be zero", + target="MechanicalReduction", + value=self.name, ) @@ -111,32 +129,60 @@ class Transmission: def __post_init__(self) -> None: """Validate transmission configuration.""" if not self.name: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.NAME_EMPTY, + "Transmission name cannot be empty", + target="TransmissionName", + value=self.name, + ) if not self.type: - raise RobotModelError() + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + "Transmission type cannot be empty", + target="TransmissionType", + value=self.type, + ) # Validate naming convention if not is_valid_urdf_name(self.name): - raise RobotValidationError("TransmissionName", self.name, "Invalid characters") + raise RobotValidationError( + ValidationErrorCode.INVALID_NAME, + "Invalid characters in transmission name", + target="TransmissionName", + value=self.name, + ) # Must have at least one joint if not self.joints: raise RobotValidationError( - "TransmissionJoints", self.name, "Must have at least one joint" + ValidationErrorCode.VALUE_EMPTY, + "Transmission must have at least one joint", + target="TransmissionJoints", + value=self.name, ) # Check for duplicate joint names joint_names = [j.name for j in self.joints] duplicates = {name for name, count in Counter(joint_names).items() if count > 1} if duplicates: - raise RobotValidationError("DuplicateJoints", self.name, str(duplicates)) + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Duplicate joints in transmission: {duplicates}", + target="DuplicateJoints", + value=self.name, + ) # Check for duplicate actuator names if self.actuators: actuator_names = [a.name for a in self.actuators] duplicates = {name for name, count in Counter(actuator_names).items() if count > 1} if duplicates: - raise RobotValidationError("DuplicateActuators", self.name, str(duplicates)) + raise RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, + f"Duplicate actuators in transmission: {duplicates}", + target="DuplicateActuators", + value=self.name, + ) @classmethod def create_simple( diff --git a/core/src/linkforge_core/parsers/urdf_parser.py b/core/src/linkforge_core/parsers/urdf_parser.py index 3f8d904c..8659d8d1 100644 --- a/core/src/linkforge_core/parsers/urdf_parser.py +++ b/core/src/linkforge_core/parsers/urdf_parser.py @@ -32,6 +32,7 @@ RobotParserUnexpectedError, RobotParserXMLRootError, RobotValidationError, + ValidationErrorCode, XacroDetectedError, ) from ..logging_config import get_logger @@ -555,12 +556,22 @@ def _parse_sensor_from_gazebo(self, gazebo_elem: ET.Element) -> Sensor | None: if contact_elem is not None: collision = contact_elem.findtext("collision") if not collision: - raise RobotValidationError(check_name="SensorCollision", value=sensor_name) + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + f"Sensor '{sensor_name}' is missing collision reference", + target="SensorCollision", + value=sensor_name, + ) contact_info = ContactInfo( collision=collision, noise=self._parse_sensor_noise(contact_elem) ) else: - raise RobotValidationError(check_name="SensorCollision", value=sensor_name) + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + f"Sensor '{sensor_name}' expects contact info but none found", + target="SensorCollision", + value=sensor_name, + ) elif sensor_type == SensorType.FORCE_TORQUE: ft_elem = sensor_elem.find("force_torque") diff --git a/core/src/linkforge_core/parsers/xml_base.py b/core/src/linkforge_core/parsers/xml_base.py index cf34579a..56ec991f 100644 --- a/core/src/linkforge_core/parsers/xml_base.py +++ b/core/src/linkforge_core/parsers/xml_base.py @@ -9,7 +9,12 @@ from typing import TYPE_CHECKING from ..base import IResourceResolver, RobotParser -from ..exceptions import RobotModelError, RobotParserError, RobotValidationError +from ..exceptions import ( + RobotModelError, + RobotParserError, + RobotValidationError, + ValidationErrorCode, +) from ..logging_config import get_logger from ..models import ( Box, @@ -129,7 +134,12 @@ def _parse_mesh(self, mesh: ET.Element | None, base_dir: Path | None) -> Mesh | return None filename = mesh.get("filename", mesh.get("file", "")) if not filename: - raise RobotValidationError(check_name="Mesh", reason="Missing filename") + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + "Mesh filename is missing", + target="Mesh", + value=filename, + ) # Path Security Validation validation_path: Path | None = None diff --git a/core/src/linkforge_core/physics/inertia.py b/core/src/linkforge_core/physics/inertia.py index f42be686..4c5861dc 100644 --- a/core/src/linkforge_core/physics/inertia.py +++ b/core/src/linkforge_core/physics/inertia.py @@ -9,7 +9,7 @@ import os from functools import lru_cache -from ..exceptions import RobotPhysicsError +from ..exceptions import RobotPhysicsError, ValidationErrorCode from ..logging_config import get_logger from ..models.geometry import Box, Cylinder, Geometry, Mesh, Sphere from ..models.link import InertiaTensor @@ -201,10 +201,20 @@ def calculate_mesh_inertia_from_triangles( return InertiaTensor.zero() if not vertices: - raise RobotPhysicsError("Vertices", 0, "Cannot calculate inertia for empty mesh") + raise RobotPhysicsError( + ValidationErrorCode.VALUE_EMPTY, + "Cannot calculate inertia for empty mesh vertices", + target="Vertices", + value=0, + ) if not triangles: - raise RobotPhysicsError("Triangles", 0, "Cannot calculate inertia for empty mesh") + raise RobotPhysicsError( + ValidationErrorCode.VALUE_EMPTY, + "Cannot calculate inertia for empty mesh triangles", + target="Triangles", + value=0, + ) # Accumulators for volume-weighted properties total_volume = 0.0 @@ -223,11 +233,21 @@ def calculate_mesh_inertia_from_triangles( for tri in triangles: # Validate triangle indices if len(tri) != 3: - raise RobotPhysicsError("TriangleIndices", len(tri), "Expected exactly 3 indices") + raise RobotPhysicsError( + ValidationErrorCode.INVALID_VALUE, + "Expected exactly 3 indices for triangle", + target="TriangleIndices", + value=len(tri), + ) for idx in tri: if not (0 <= idx < len(vertices)): - raise RobotPhysicsError("TriangleIndex", idx, f"Valid range: 0-{len(vertices) - 1}") + raise RobotPhysicsError( + ValidationErrorCode.OUT_OF_RANGE, + f"Triangle index out of range (0-{len(vertices) - 1})", + target="TriangleIndex", + value=idx, + ) # Get vertices a = vertices[tri[0]] @@ -339,7 +359,12 @@ def calculate_mesh_inertia_from_triangles( # Check for degenerate mesh (all triangles are coplanar or have zero area) if abs(total_volume) < 1e-10: - raise RobotPhysicsError("TotalVolume", total_volume, "Degenerate mesh forms zero volume") + raise RobotPhysicsError( + ValidationErrorCode.PHYSICS_VIOLATION, + "Degenerate mesh forms zero volume", + target="TotalVolume", + value=total_volume, + ) # Compute center of mass cx = weighted_com[0] / total_volume @@ -440,4 +465,9 @@ def calculate_inertia(geometry: Geometry, mass: float) -> InertiaTensor: elif isinstance(geometry, Mesh): return calculate_mesh_inertia(geometry, mass) else: - raise RobotPhysicsError("Geometry", type(geometry), "Unsupported geometry type") + raise RobotPhysicsError( + ValidationErrorCode.INVALID_VALUE, + f"Unsupported geometry type: {type(geometry)}", + target="Geometry", + value=type(geometry), + ) diff --git a/core/src/linkforge_core/utils/string_utils.py b/core/src/linkforge_core/utils/string_utils.py index 88b8e283..60c70e32 100644 --- a/core/src/linkforge_core/utils/string_utils.py +++ b/core/src/linkforge_core/utils/string_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ..exceptions import RobotValidationError +from ..exceptions import RobotValidationError, ValidationErrorCode def sanitize_name(name: str, allow_hyphen: bool = True) -> str: @@ -23,7 +23,12 @@ def sanitize_name(name: str, allow_hyphen: bool = True) -> str: # Prevent ReDoS: limit input length before processing if len(name) > 1000: - raise RobotValidationError("NameLength", f"Length {len(name)} exceeds 1000") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + f"Name length {len(name)} exceeds 1000 characters", + target="NameLength", + value=len(name), + ) # Replace spaces with underscores name = name.replace(" ", "_") diff --git a/core/src/linkforge_core/utils/xml_utils.py b/core/src/linkforge_core/utils/xml_utils.py index 9bc4e95d..07166393 100644 --- a/core/src/linkforge_core/utils/xml_utils.py +++ b/core/src/linkforge_core/utils/xml_utils.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Any -from ..exceptions import RobotMathError, RobotValidationError +from ..exceptions import RobotMathError, RobotValidationError, ValidationErrorCode from ..models import Vector3 # Register XACRO namespace to ensure standard 'xacro:' prefix in exports @@ -25,7 +25,12 @@ def validate_xml_depth(element: ET.Element, depth: int = 0) -> None: RobotValidationError: If depth exceeds MAX_XML_DEPTH """ if depth > MAX_XML_DEPTH: - raise RobotValidationError(check_name="XMLDepth", value=depth, reason="Nesting too deep") + raise RobotValidationError( + ValidationErrorCode.OUT_OF_RANGE, + f"XML nesting depth {depth} exceeds limit {MAX_XML_DEPTH}", + target="XMLDepth", + value=depth, + ) for child in element: validate_xml_depth(child, depth + 1) @@ -130,22 +135,39 @@ def parse_float( if text is None: if default is not None: return default - raise RobotValidationError(check_name=report_name, reason="Missing required attribute") + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + f"Missing required attribute: {report_name}", + target=report_name, + ) try: value = float(text) if math.isnan(value) or math.isinf(value): - raise RobotMathError(value=value, check_name=report_name) + raise RobotMathError( + ValidationErrorCode.INVALID_VALUE, + f"Non-finite float value '{value}' in {report_name}", + target=report_name, + value=value, + ) # Sanity check for reasonable values (Standard LinkForge limit) if not (-1e10 < value < 1e10): raise RobotMathError( - value=value, check_name=report_name, reason="outside reasonable range" + ValidationErrorCode.OUT_OF_RANGE, + f"Float value '{value}' in {report_name} is outside reasonable range", + target=report_name, + value=value, ) return value except ValueError: - raise RobotMathError(value=text, check_name=report_name) from None + raise RobotMathError( + ValidationErrorCode.INVALID_VALUE, + f"Invalid float format '{text}' in {report_name}", + target=report_name, + value=text, + ) from None def parse_int( @@ -177,18 +199,30 @@ def parse_int( if text is None: if default is not None: return default - raise RobotValidationError(check_name=report_name, reason="Missing required attribute") + raise RobotValidationError( + ValidationErrorCode.VALUE_EMPTY, + f"Missing required attribute: {report_name}", + target=report_name, + ) try: value = int(text) # Sanity check for reasonable values (standard LinkForge limit) if not (-1000000 < value < 1000000): raise RobotMathError( - value=value, check_name=report_name, reason="outside reasonable range" + ValidationErrorCode.OUT_OF_RANGE, + f"Integer value '{value}' in {report_name} is outside reasonable range", + target=report_name, + value=value, ) return value except ValueError: - raise RobotMathError(value=text, check_name=report_name, reason="invalid format") from None + raise RobotMathError( + ValidationErrorCode.INVALID_VALUE, + f"Invalid integer format '{text}' in {report_name}", + target=report_name, + value=text, + ) from None def parse_vector3(text: str) -> Vector3: @@ -206,7 +240,12 @@ def parse_vector3(text: str) -> Vector3: """ parts = text.strip().split() if len(parts) != 3: - raise RobotValidationError(check_name="Vector3", value=text, reason="Expected 3 values") + raise RobotValidationError( + ValidationErrorCode.INVALID_VALUE, + f"Expected 3 values for Vector3, got {len(parts)}", + target="Vector3", + value=text, + ) try: x = parse_float(parts[0], "x") y = parse_float(parts[1], "y") diff --git a/core/src/linkforge_core/validation/checks.py b/core/src/linkforge_core/validation/checks.py index e4017cff..1c7ede70 100644 --- a/core/src/linkforge_core/validation/checks.py +++ b/core/src/linkforge_core/validation/checks.py @@ -11,7 +11,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from ..exceptions import RobotModelError +from ..exceptions import RobotModelError, RobotValidationError, ValidationErrorCode from .result import ValidationResult if TYPE_CHECKING: @@ -167,17 +167,29 @@ def _check_root(robot: Robot, result: ValidationResult) -> Link | None: ) return root except RobotModelError as e: - error_msg = str(e) - if "Multiple root links" in error_msg: - result.add_error( - title="Multiple root links", - message=error_msg, - suggestion="Ensure only one link has no parent joint. Connect other root links to the tree with joints", - ) + if isinstance(e, RobotValidationError): + if e.code == ValidationErrorCode.MULTIPLE_ROOTS: + result.add_error( + title="Multiple root links", + message=str(e), + suggestion="Ensure only one link has no parent joint. Connect other root links to the tree with joints", + ) + elif e.code == ValidationErrorCode.NO_ROOT: + result.add_error( + title="No root link", + message=str(e), + suggestion="Ensure exactly one link has no parent joint (the base/root link)", + ) + else: + result.add_error( + title="Root link error", + message=str(e), + suggestion="Check the joint connections in your robot tree", + ) else: result.add_error( title="Root link error", - message=error_msg, + message=str(e), suggestion="Check the joint connections in your robot tree", ) return None diff --git a/platforms/blender/linkforge/blender/adapters/blender_to_core.py b/platforms/blender/linkforge/blender/adapters/blender_to_core.py index 396f46fc..d634bf71 100644 --- a/platforms/blender/linkforge/blender/adapters/blender_to_core.py +++ b/platforms/blender/linkforge/blender/adapters/blender_to_core.py @@ -26,9 +26,9 @@ from dataclasses import dataclass -from ...linkforge_core.exceptions import RobotValidationError -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.models import ( +from linkforge_core.exceptions import RobotValidationError, ValidationErrorCode +from linkforge_core.logging_config import get_logger +from linkforge_core.models import ( Box, CameraInfo, Collision, @@ -64,16 +64,17 @@ Vector3, Visual, ) -from ...linkforge_core.models.transmission import ( +from linkforge_core.models.transmission import ( Transmission, TransmissionActuator, TransmissionJoint, TransmissionType, ) -from ...linkforge_core.physics import calculate_inertia, calculate_mesh_inertia_from_triangles -from ...linkforge_core.utils.math_utils import clean_float, normalize_vector -from ...linkforge_core.utils.string_utils import sanitize_name -from ..utils.physics import calculate_mesh_inertia_numpy +from linkforge_core.physics import calculate_inertia, calculate_mesh_inertia_from_triangles +from linkforge_core.utils.math_utils import clean_float, normalize_vector +from linkforge_core.utils.string_utils import sanitize_name + +from linkforge.blender.utils.physics import calculate_mesh_inertia_numpy # Constants logger = get_logger(__name__) @@ -799,16 +800,38 @@ def blender_joint_to_core(obj: Any) -> Joint | None: parent_obj = props.parent_link child_obj = props.child_link - parent = parent_obj.linkforge.link_name if parent_obj else "" - child = child_obj.linkforge.link_name if child_obj else "" + parent = ( + ( + parent_obj.linkforge.link_name + if parent_obj and parent_obj.linkforge.link_name + else parent_obj.name + ) + if parent_obj + else "" + ) + child = ( + ( + child_obj.linkforge.link_name + if child_obj and child_obj.linkforge.link_name + else child_obj.name + ) + if child_obj + else "" + ) if not parent: raise RobotValidationError( - "ParentLink", joint_name, "Joint has no parent link. Please select a Parent Link." + ValidationErrorCode.NOT_FOUND, + "Joint has no parent link. Please select a Parent Link.", + target="ParentLink", + value=joint_name, ) if not child: raise RobotValidationError( - "ChildLink", joint_name, "Joint has no child link. Please select a Child Link." + ValidationErrorCode.NOT_FOUND, + "Joint has no child link. Please select a Child Link.", + target="ChildLink", + value=joint_name, ) return Joint( @@ -906,10 +929,14 @@ def blender_transmission_to_core(obj: Any) -> Transmission | None: j2_obj = props.joint2_name if j1_obj and j2_obj: j1_name = ( - j1_obj.linkforge_joint.joint_name if hasattr(j1_obj, "linkforge_joint") else "" + j1_obj.linkforge_joint.joint_name + if hasattr(j1_obj, "linkforge_joint") and j1_obj.linkforge_joint.joint_name + else "" ) or j1_obj.name j2_name = ( - j2_obj.linkforge_joint.joint_name if hasattr(j2_obj, "linkforge_joint") else "" + j2_obj.linkforge_joint.joint_name + if hasattr(j2_obj, "linkforge_joint") and j2_obj.linkforge_joint.joint_name + else "" ) or j2_obj.name joints.append( @@ -1209,7 +1236,7 @@ def scene_to_robot( parameters=params, ) # Note: We wrap the plugin in a GazeboElement without a reference (global) - from ...linkforge_core.models.gazebo import GazeboElement + from linkforge_core.models.gazebo import GazeboElement robot.add_gazebo_element(GazeboElement(plugins=[gazebo_plugin])) except Exception as e: @@ -1222,7 +1249,10 @@ def scene_to_robot( error_summary = "\n".join(f" - {err}" for err in conversion_errors) # In non-strict mode, always raise with all collected errors raise RobotValidationError( - "RobotConversion", robot_name, f"Multiple configuration errors found:\n{error_summary}" + ValidationErrorCode.INVALID_VALUE, + f"Multiple configuration errors found:\n{error_summary}", + target="RobotConversion", + value=robot_name, ) return robot, conversion_errors @@ -1252,13 +1282,22 @@ def blender_sensor_to_core(obj: Any) -> Sensor | None: sensor_name = props.sensor_name if props.sensor_name else obj.name sensor_type = SensorType(props.sensor_type.lower()) link_obj = props.attached_link - link_name = link_obj.linkforge.link_name if link_obj else "" + link_name = ( + ( + link_obj.linkforge.link_name + if link_obj and link_obj.linkforge.link_name + else link_obj.name + ) + if link_obj + else "" + ) if not link_name: raise RobotValidationError( - "SensorAttachment", - sensor_name, + ValidationErrorCode.NOT_FOUND, "Sensor is not attached to any link. Please select a parent link.", + target="SensorAttachment", + value=sensor_name, ) # Build sensor origin from object transform diff --git a/platforms/blender/linkforge/blender/adapters/core_to_blender.py b/platforms/blender/linkforge/blender/adapters/core_to_blender.py index f8201ef1..ff4f5fe4 100644 --- a/platforms/blender/linkforge/blender/adapters/core_to_blender.py +++ b/platforms/blender/linkforge/blender/adapters/core_to_blender.py @@ -7,10 +7,8 @@ from pathlib import Path import bpy -from mathutils import Matrix - -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.models import ( +from linkforge_core.logging_config import get_logger +from linkforge_core.models import ( Box, Color, Cylinder, @@ -20,10 +18,12 @@ Robot, Sphere, ) -from ...linkforge_core.utils.kinematics import sort_joints_topological -from ..preferences import get_addon_prefs -from ..utils.joint_utils import resolve_mimic_joints -from ..utils.scene_utils import move_to_collection, sync_object_collections +from linkforge_core.utils.kinematics import sort_joints_topological +from mathutils import Matrix + +from linkforge.blender.preferences import get_addon_prefs +from linkforge.blender.utils.joint_utils import resolve_mimic_joints +from linkforge.blender.utils.scene_utils import move_to_collection, sync_object_collections logger = get_logger(__name__) diff --git a/platforms/blender/linkforge/blender/adapters/mesh_io.py b/platforms/blender/linkforge/blender/adapters/mesh_io.py index e364ce99..ed3f4618 100644 --- a/platforms/blender/linkforge/blender/adapters/mesh_io.py +++ b/platforms/blender/linkforge/blender/adapters/mesh_io.py @@ -9,11 +9,10 @@ from typing import Any import bpy +from linkforge_core.logging_config import get_logger +from linkforge_core.utils.string_utils import sanitize_name from mathutils import Matrix, Vector -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.utils.string_utils import sanitize_name - logger = get_logger(__name__) diff --git a/platforms/blender/linkforge/blender/logic/asynchronous_builder.py b/platforms/blender/linkforge/blender/logic/asynchronous_builder.py index d0bb55a7..ea6f19c9 100644 --- a/platforms/blender/linkforge/blender/logic/asynchronous_builder.py +++ b/platforms/blender/linkforge/blender/logic/asynchronous_builder.py @@ -11,10 +11,10 @@ from pathlib import Path import bpy +from linkforge_core.logging_config import get_logger +from linkforge_core.models import Robot +from linkforge_core.utils.kinematics import sort_joints_topological -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.models import Robot -from ...linkforge_core.utils.kinematics import sort_joints_topological from ..adapters.core_to_blender import ( create_joint_object, create_link_object, diff --git a/platforms/blender/linkforge/blender/operators/export_ops.py b/platforms/blender/linkforge/blender/operators/export_ops.py index 99b3c792..3f3b9786 100644 --- a/platforms/blender/linkforge/blender/operators/export_ops.py +++ b/platforms/blender/linkforge/blender/operators/export_ops.py @@ -14,8 +14,8 @@ import bpy from bpy.types import Context, Event, Operator from bpy_extras.io_utils import ExportHelper +from linkforge_core.logging_config import get_logger -from ...linkforge_core.logging_config import get_logger from ..utils.decorators import OperatorReturn, safe_execute if typing.TYPE_CHECKING: @@ -77,7 +77,8 @@ def invoke(self, context: Context, event: Event) -> typing.Any: def execute(self, context: Context) -> OperatorReturn: """Execute the export.""" # Import here to avoid circular dependencies - from ...linkforge_core import URDFGenerator, XACROGenerator + from linkforge_core import URDFGenerator, XACROGenerator + from ..adapters.blender_to_core import scene_to_robot if not context.scene or not hasattr(context.scene, "linkforge"): @@ -103,7 +104,7 @@ def execute(self, context: Context) -> OperatorReturn: logger.info(f"Exporting robot to: {output_path}") logger.debug(f"Mesh directory: {meshes_dir}") - from ...linkforge_core import LinkForgeError, RobotGeneratorError + from linkforge_core import LinkForgeError, RobotGeneratorError # Validate if requested if robot_props.validate_before_export: @@ -118,7 +119,7 @@ def execute(self, context: Context) -> OperatorReturn: logger.exception("Dry run build crashed") return {"CANCELLED"} - from ...linkforge_core.validation import RobotValidator + from linkforge_core.validation import RobotValidator validator = RobotValidator() result = validator.validate(robot_dry_run) @@ -194,7 +195,8 @@ class LINKFORGE_OT_validate_robot(Operator): @safe_execute def execute(self, context: Context) -> OperatorReturn: """Execute validation.""" - from ...linkforge_core.validation import RobotValidator + from linkforge_core.validation import RobotValidator + from ..adapters.blender_to_core import scene_to_robot # Clear previous results diff --git a/platforms/blender/linkforge/blender/operators/import_ops.py b/platforms/blender/linkforge/blender/operators/import_ops.py index de10ebe8..82d7cbc5 100644 --- a/platforms/blender/linkforge/blender/operators/import_ops.py +++ b/platforms/blender/linkforge/blender/operators/import_ops.py @@ -12,8 +12,8 @@ import bpy from bpy_extras.io_utils import ImportHelper +from linkforge_core.logging_config import get_logger -from ...linkforge_core.logging_config import get_logger from ..utils.decorators import OperatorReturn, safe_execute from ..utils.scene_utils import clear_stats_cache @@ -69,7 +69,7 @@ def execute(self, context: Context) -> OperatorReturn: Returns: Set containing the execution state (e.g., {'FINISHED'} or {'CANCELLED'}). """ - from ...linkforge_core.parsers import URDFParser + from linkforge_core.parsers import URDFParser # Parse URDF/XACRO file urdf_path = Path(self.filepath) @@ -112,7 +112,7 @@ def execute(self, context: Context) -> OperatorReturn: is_xacro = urdf_path.suffix == ".xacro" or urdf_path.name.endswith(".urdf.xacro") # Detect Sandbox Root for security (allows sibling folders like meshes/) - from ...linkforge_core.validation.security import find_sandbox_root + from linkforge_core.validation.security import find_sandbox_root sandbox_root = find_sandbox_root(urdf_path) logger.info(f"Importing robot from: {urdf_path}") @@ -121,8 +121,8 @@ def execute(self, context: Context) -> OperatorReturn: # Smart Import Logic: # 1. If it looks like URDF, try parsing as URDF. # 2. If parsing fails because of Xacro tags, catch the error and switch to Xacro mode. - from ...linkforge_core import RobotParserError, XacroDetectedError - from ...linkforge_core.base import FileSystemResolver + from linkforge_core import RobotParserError, XacroDetectedError + from linkforge_core.base import FileSystemResolver # Read additional package paths from preferences from ..preferences import get_addon_prefs @@ -160,7 +160,7 @@ def execute(self, context: Context) -> OperatorReturn: # XACRO PROCESSING (Triggered by extension OR fallback detection) if is_xacro: # Convert XACRO to URDF using native XacroResolver - from ...linkforge_core.parsers import XacroResolver + from linkforge_core.parsers import XacroResolver self.report({"INFO"}, f"Processing XACRO file: {urdf_path.name}") @@ -185,17 +185,16 @@ def execute(self, context: Context) -> OperatorReturn: logger.exception("Import process crashed") return {"CANCELLED"} - # Detect macro-only files (empty robots) if not robot.links and not robot.joints: self.report( - {"WARNING"}, + {"ERROR"}, f"The file '{urdf_path.name}' contains no links or joints. " "It may be a macro-only XACRO file. Please import the top-level robot description instead.", ) return {"CANCELLED"} # Validate robot structure - from ...linkforge_core.validation import RobotValidator + from linkforge_core.validation import RobotValidator validator = RobotValidator() result = validator.validate(robot) diff --git a/platforms/blender/linkforge/blender/operators/link_ops.py b/platforms/blender/linkforge/blender/operators/link_ops.py index f2b9c0b2..9b96d3dc 100644 --- a/platforms/blender/linkforge/blender/operators/link_ops.py +++ b/platforms/blender/linkforge/blender/operators/link_ops.py @@ -6,8 +6,9 @@ import time import typing -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.models.link import InertiaTensor +from linkforge_core.logging_config import get_logger +from linkforge_core.models.link import InertiaTensor + from ..properties.link_props import sanitize_urdf_name from ..utils.context import context_and_mode_guard from ..utils.decorators import OperatorReturn, safe_execute @@ -509,8 +510,9 @@ def calculate_inertia_for_link(link_obj: bpy.types.Object) -> bool: lf = typing.cast("LinkPropertyGroup", getattr(link_obj, "linkforge")) # Import here to avoid circular dependency - from ...linkforge_core.models.geometry import Box, Cylinder, Sphere - from ...linkforge_core.physics import calculate_inertia, calculate_mesh_inertia_from_triangles + from linkforge_core.models.geometry import Box, Cylinder, Sphere + from linkforge_core.physics import calculate_inertia, calculate_mesh_inertia_from_triangles + from ..adapters.blender_to_core import extract_mesh_triangles from ..utils.physics import calculate_mesh_inertia_numpy @@ -558,7 +560,7 @@ def calculate_inertia_for_link(link_obj: bpy.types.Object) -> bool: # Primitive calculation expects dimensions if prim_type == "BOX": # Convert mathutils.Vector to core Vector3 - from ...linkforge_core.models.geometry import Vector3 + from linkforge_core.models.geometry import Vector3 size = Vector3(dims.x, dims.y, dims.z) tensor = calculate_inertia(Box(size=size), mass) diff --git a/platforms/blender/linkforge/blender/properties/joint_props.py b/platforms/blender/linkforge/blender/properties/joint_props.py index c021f683..a46ed93b 100644 --- a/platforms/blender/linkforge/blender/properties/joint_props.py +++ b/platforms/blender/linkforge/blender/properties/joint_props.py @@ -20,9 +20,10 @@ if typing.TYPE_CHECKING: from .link_props import LinkPropertyGroup -from ...linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ..utils.property_helpers import find_property_owner -from ..utils.scene_utils import clear_stats_cache +from linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name + +from linkforge.blender.utils.property_helpers import find_property_owner +from linkforge.blender.utils.scene_utils import clear_stats_cache def get_joint_name(self: JointPropertyGroup) -> str: @@ -83,7 +84,10 @@ def update_joint_hierarchy(self: JointPropertyGroup, context: Context) -> None: if joint_obj is None or not self.is_robot_joint: return - from ..utils.transform_utils import clear_parent_keep_transform, set_parent_keep_transform + from linkforge.blender.utils.transform_utils import ( + clear_parent_keep_transform, + set_parent_keep_transform, + ) # Parent-child objects directly from pointers parent_obj = self.parent_link @@ -95,7 +99,7 @@ def update_joint_hierarchy(self: JointPropertyGroup, context: Context) -> None: set_parent_keep_transform(joint_obj, parent_obj) # Move to parent's collection (organization) - from ..utils.scene_utils import sync_object_collections + from linkforge.blender.utils.scene_utils import sync_object_collections sync_object_collections(joint_obj, parent_obj) elif joint_obj.parent: diff --git a/platforms/blender/linkforge/blender/properties/link_props.py b/platforms/blender/linkforge/blender/properties/link_props.py index 3ccd3b3a..04f4007e 100644 --- a/platforms/blender/linkforge/blender/properties/link_props.py +++ b/platforms/blender/linkforge/blender/properties/link_props.py @@ -17,11 +17,11 @@ StringProperty, ) from bpy.types import Context, PropertyGroup +from linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ...linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ..utils.link_utils import should_rename_child -from ..utils.scene_utils import clear_stats_cache -from ..visualization.inertia_gizmos import tag_redraw +from linkforge.blender.utils.link_utils import should_rename_child +from linkforge.blender.utils.scene_utils import clear_stats_cache +from linkforge.blender.visualization.inertia_gizmos import tag_redraw def get_link_name(self: LinkPropertyGroup) -> str: diff --git a/platforms/blender/linkforge/blender/properties/sensor_props.py b/platforms/blender/linkforge/blender/properties/sensor_props.py index 55bb25a1..39d66a05 100644 --- a/platforms/blender/linkforge/blender/properties/sensor_props.py +++ b/platforms/blender/linkforge/blender/properties/sensor_props.py @@ -18,13 +18,14 @@ ) from bpy.types import Context, PropertyGroup -from ..utils.scene_utils import clear_stats_cache +from linkforge.blender.utils.scene_utils import clear_stats_cache if typing.TYPE_CHECKING: from .link_props import LinkPropertyGroup -from ...linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ..utils.property_helpers import find_property_owner +from linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name + +from linkforge.blender.utils.property_helpers import find_property_owner def get_sensor_name(self: SensorPropertyGroup) -> str: diff --git a/platforms/blender/linkforge/blender/properties/transmission_props.py b/platforms/blender/linkforge/blender/properties/transmission_props.py index ed7f415b..f4171c3c 100644 --- a/platforms/blender/linkforge/blender/properties/transmission_props.py +++ b/platforms/blender/linkforge/blender/properties/transmission_props.py @@ -9,10 +9,10 @@ import bpy from bpy.props import BoolProperty, EnumProperty, FloatProperty, PointerProperty, StringProperty from bpy.types import Context, PropertyGroup +from linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ...linkforge_core.utils.string_utils import sanitize_name as sanitize_urdf_name -from ..utils.property_helpers import find_property_owner -from ..utils.scene_utils import clear_stats_cache +from linkforge.blender.utils.property_helpers import find_property_owner +from linkforge.blender.utils.scene_utils import clear_stats_cache def get_transmission_name(self: TransmissionPropertyGroup) -> str: diff --git a/platforms/blender/linkforge/blender/utils/decorators.py b/platforms/blender/linkforge/blender/utils/decorators.py index 47cdae16..82dde62b 100644 --- a/platforms/blender/linkforge/blender/utils/decorators.py +++ b/platforms/blender/linkforge/blender/utils/decorators.py @@ -5,7 +5,7 @@ import typing from collections.abc import Callable -from ...linkforge_core.logging_config import get_logger +from linkforge_core.logging_config import get_logger logger = get_logger(__name__) diff --git a/platforms/blender/linkforge/blender/utils/joint_utils.py b/platforms/blender/linkforge/blender/utils/joint_utils.py index 1d3735fa..bf3154d9 100644 --- a/platforms/blender/linkforge/blender/utils/joint_utils.py +++ b/platforms/blender/linkforge/blender/utils/joint_utils.py @@ -3,8 +3,7 @@ from __future__ import annotations import bpy - -from ...linkforge_core.models import Joint +from linkforge_core.models import Joint def resolve_mimic_joints(joints: list[Joint], joint_objects: dict[str, bpy.types.Object]) -> None: diff --git a/platforms/blender/linkforge/blender/utils/physics.py b/platforms/blender/linkforge/blender/utils/physics.py index e104e05b..ebb0e891 100644 --- a/platforms/blender/linkforge/blender/utils/physics.py +++ b/platforms/blender/linkforge/blender/utils/physics.py @@ -6,16 +6,15 @@ if TYPE_CHECKING: import numpy as np # type: ignore[import-not-found] - - from ...linkforge_core.models.link import InertiaTensor + from linkforge_core.models.link import InertiaTensor else: try: import numpy as np # type: ignore[import-not-found] except ImportError: np = None -from ...linkforge_core.logging_config import get_logger -from ...linkforge_core.models.link import InertiaTensor +from linkforge_core.logging_config import get_logger +from linkforge_core.models.link import InertiaTensor logger = get_logger(__name__) diff --git a/pyproject.toml b/pyproject.toml index 69507765..5b9dc41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ strict = true warn_return_any = true warn_unused_configs = true mypy_path = ["core/src", "platforms/blender"] +exclude = ["platforms/blender/linkforge/linkforge_core"] # Blender (bpy) stubs use Python 3.12 syntax. [[tool.mypy.overrides]] diff --git a/tests/integration/core/test_urdf_parser.py b/tests/integration/core/test_urdf_parser.py index 1b268985..656a5cd1 100644 --- a/tests/integration/core/test_urdf_parser.py +++ b/tests/integration/core/test_urdf_parser.py @@ -64,7 +64,7 @@ def test_parse_invalid_too_many_values(self) -> None: def test_parse_invalid_non_numeric(self) -> None: """Test that parsing non-numeric values raises RobotModelError.""" - with pytest.raises(RobotModelError, match="must be a finite number"): + with pytest.raises(RobotModelError, match="Invalid float format"): parse_vector3("1.0 abc 3.0") def test_parse_empty_string(self) -> None: @@ -970,7 +970,7 @@ def test_missing_joint_parent(self, tmp_path: Path, caplog) -> None: robot = URDFParser().parse(urdf_file) assert len(robot.joints) == 0 assert "Skipping invalid joint 'joint1'" in caplog.text - assert "Validation failed [ParentLink]" in caplog.text + assert "Parent link name cannot be empty" in caplog.text def test_missing_joint_child(self, tmp_path: Path, caplog) -> None: """Test that joint without child link is skipped gracefully.""" @@ -992,7 +992,7 @@ def test_missing_joint_child(self, tmp_path: Path, caplog) -> None: robot = URDFParser().parse(urdf_file) assert len(robot.joints) == 0 assert "Skipping invalid joint 'joint1'" in caplog.text - assert "Validation failed [ChildLink]" in caplog.text + assert "Child link name cannot be empty" in caplog.text def test_invalid_geometry_values(self, tmp_path: Path) -> None: """Test that invalid geometry dimensions are handled gracefully.""" diff --git a/tests/unit/core/test_base_interfaces.py b/tests/unit/core/test_base_interfaces.py index f115a8e8..e160a0f3 100644 --- a/tests/unit/core/test_base_interfaces.py +++ b/tests/unit/core/test_base_interfaces.py @@ -64,9 +64,9 @@ def test_binary_write_support(tmp_path) -> None: def test_robot_metadata_and_version() -> None: - robot = Robot(name="test_bot", version="2.0", metadata={"author": "Antigravity"}) + robot = Robot(name="test_bot", version="2.0", metadata={"author": "LinkForge"}) assert robot.version == "2.0" - assert robot.metadata["author"] == "Antigravity" + assert robot.metadata["author"] == "LinkForge" def test_custom_exception_wrapping(tmp_path) -> None: diff --git a/tests/unit/core/test_gazebo.py b/tests/unit/core/test_gazebo.py index 901f077c..d828f3d0 100644 --- a/tests/unit/core/test_gazebo.py +++ b/tests/unit/core/test_gazebo.py @@ -112,5 +112,5 @@ def test_element_with_properties(self) -> None: def test_empty_reference_string(self) -> None: """Test that empty string reference raises error.""" - with pytest.raises(RobotModelError, match="cannot be empty string"): + with pytest.raises(RobotModelError, match="cannot be empty"): GazeboElement(reference="") diff --git a/tests/unit/core/test_robot.py b/tests/unit/core/test_robot.py index 93828494..69abda81 100644 --- a/tests/unit/core/test_robot.py +++ b/tests/unit/core/test_robot.py @@ -53,7 +53,7 @@ def test_invalid_names(self) -> None: with pytest.raises(RobotModelError): Robot(name="") - with pytest.raises(RobotModelError, match="Invalid characters"): + with pytest.raises(RobotModelError, match="Invalid name format"): Robot(name="invalid name with spaces") def test_duplicate_components(self) -> None: @@ -208,7 +208,7 @@ def test_add_sensor_validation(self) -> None: robot.add_sensor(sensor) assert robot.sensors[0] == sensor - with pytest.raises(RobotModelError, match="cam2"): + with pytest.raises(RobotModelError, match="Not found"): robot.add_sensor( Sensor( name="cam2", @@ -247,7 +247,7 @@ def test_add_transmission_validation(self) -> None: joints=[TransmissionJoint(name="missing_joint", hardware_interfaces=["position"])], ) - with pytest.raises(RobotModelError, match="t2"): + with pytest.raises(RobotModelError): robot.add_transmission(bad_trans) def test_robot_properties(self) -> None: @@ -308,7 +308,7 @@ def test_add_gazebo_element(self) -> None: assert len(robot.gazebo_elements) == 2 # Invalid reference - with pytest.raises(RobotModelError, match="No matching link or joint"): + with pytest.raises(RobotModelError): robot.add_gazebo_element(GazeboElement(reference="missing")) def test_add_ros2_control(self) -> None: @@ -609,11 +609,11 @@ def test_robot_duplicate_initial_components_gap_fill(self) -> None: robot = Robot(name="test") link1 = Link(name="l1") robot._links = [link1, link1] - with pytest.raises(RobotModelError, match="Duplicate found in index"): + with pytest.raises(RobotModelError, match="Already exists"): robot.__post_init__(None, None) robot = Robot(name="test") joint1 = Joint(name="j1", parent="a", child="b", type=JointType.FIXED) robot._joints = [joint1, joint1] - with pytest.raises(RobotModelError, match="Duplicate found in index"): + with pytest.raises(RobotModelError, match="Already exists"): robot.__post_init__(None, None) diff --git a/tests/unit/core/test_robot_assembly.py b/tests/unit/core/test_robot_assembly.py index 1045e823..1f1ddc9b 100644 --- a/tests/unit/core/test_robot_assembly.py +++ b/tests/unit/core/test_robot_assembly.py @@ -154,7 +154,9 @@ def test_validation_error_on_attach(self) -> None: # 1. Test missing parent link in assembly bad_component = Robot(name="comp") bad_component.add_link(Link(name="l1")) - with pytest.raises(RobotValidationError, match="Attachment link not found"): + with pytest.raises( + RobotValidationError, match=r"\[NOT_FOUND\] Attachment link 'non_existent' not found" + ): assembly.attach(bad_component, at_link="non_existent", joint_name="j") # 2. Test component with no root (empty) diff --git a/tests/unit/core/test_urdf_parser.py b/tests/unit/core/test_urdf_parser.py index 10a2a63a..ea865c76 100644 --- a/tests/unit/core/test_urdf_parser.py +++ b/tests/unit/core/test_urdf_parser.py @@ -5,7 +5,12 @@ import pytest from linkforge_core.base import RobotParserError, XacroDetectedError from linkforge_core.composer.naming import add_joint_with_renaming, add_link_with_renaming -from linkforge_core.exceptions import RobotModelError, RobotParserIOError +from linkforge_core.exceptions import ( + RobotModelError, + RobotParserIOError, + RobotValidationError, + ValidationErrorCode, +) from linkforge_core.models import ( Box, CameraInfo, @@ -833,8 +838,8 @@ def test_joint_renaming_robustness_broken_ref(self) -> None: with patch.object(robot, "add_joint") as mock_add: mock_add.side_effect = [ - RobotModelError("Joint 'fixed_joint' already exists"), - RobotModelError("Link 'missing' not found"), + RobotValidationError(ValidationErrorCode.DUPLICATE_NAME, "Already exists"), + RobotValidationError(ValidationErrorCode.NOT_FOUND, "Link not found"), ] add_joint_with_renaming(robot, joint, fallback_name=elem.get("name")) @@ -892,8 +897,8 @@ def test_parse_geometry_invalid_mesh(self, parser) -> None: # 4. Transmission Errors # Invalid mechanicalReduction xml = 'not_number' - # parse_float raises RobotModelError for non-numeric strings - with pytest.raises(RobotModelError, match="Invalid value 'not_number'"): + # parse_float raises RobotMathError for non-numeric strings + with pytest.raises(RobotModelError, match="Invalid float format 'not_number'"): parser._parse_transmission_component(ET.fromstring(xml), "joint") # 5. Gazebo Sensor missing reference @@ -1008,8 +1013,10 @@ def test_add_joint_with_renaming_renaming(self) -> None: # First two names already exist in _joint_index # Third name 'j_duplicate_2' is free robot.add_joint.side_effect = [ - RobotModelError("already exists"), # j exists - RobotModelError("already exists"), # j_duplicate_1 exists + RobotValidationError(ValidationErrorCode.DUPLICATE_NAME, "already exists"), # j exists + RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, "already exists" + ), # j_duplicate_1 exists None, # Success for j_duplicate_2 ] @@ -1033,8 +1040,10 @@ def test_add_link_with_renaming_renaming(self) -> None: with patch.object(robot, "add_link") as mock_add_link: mock_add_link.side_effect = [ - RobotModelError("Link 'l' already exists"), - RobotModelError("Link 'l_duplicate_1' failed"), # Trigger exception inside loop + RobotValidationError(ValidationErrorCode.DUPLICATE_NAME, "Link 'l' already exists"), + RobotValidationError( + ValidationErrorCode.DUPLICATE_NAME, "Link 'l_duplicate_1' failed" + ), # Trigger exception inside loop None, # Succeeds for l_duplicate_2 ] diff --git a/tests/unit/core/test_urdf_parser_robustness.py b/tests/unit/core/test_urdf_parser_robustness.py index c9e3854d..8e82ef6b 100644 --- a/tests/unit/core/test_urdf_parser_robustness.py +++ b/tests/unit/core/test_urdf_parser_robustness.py @@ -66,7 +66,8 @@ def test_xml_base_geometry_error_handling() -> None: elem = ET.fromstring('') with patch("linkforge_core.parsers.xml_base.logger") as mock_logger: assert parser._parse_geometry_element(elem) is None - assert "Validation failed [Vector3]" in mock_logger.warning.call_args[0][0] + assert "[INVALID_VALUE]" in mock_logger.warning.call_args[0][0] + assert "(target: Vector3)" in mock_logger.warning.call_args[0][0] def test_xml_base_robust_joint_addition_errors() -> None: diff --git a/tests/unit/core/test_xml_utils.py b/tests/unit/core/test_xml_utils.py index 542c08c6..2c56ab96 100644 --- a/tests/unit/core/test_xml_utils.py +++ b/tests/unit/core/test_xml_utils.py @@ -80,19 +80,19 @@ def test_parsing_fallbacks() -> None: import pytest # Invalid floats - with pytest.raises(RobotModelError, match="must be a finite number"): + with pytest.raises(RobotModelError, match="Non-finite float value"): parse_float("NaN") - with pytest.raises(RobotModelError, match="must be a finite number"): + with pytest.raises(RobotModelError, match="Non-finite float value"): parse_float("inf") with pytest.raises(RobotModelError, match="outside reasonable range"): parse_float("1e11") - with pytest.raises(RobotModelError, match="must be a finite number"): + with pytest.raises(RobotModelError, match="Invalid float format"): parse_float("not-a-float") # Invalid ints with pytest.raises(RobotModelError, match="outside reasonable range"): parse_int("2000000") - with pytest.raises(RobotModelError, match="invalid format"): + with pytest.raises(RobotModelError, match="Invalid integer format"): parse_int("not-an-int") # Optional bool @@ -110,7 +110,7 @@ def test_parse_vector3_errors() -> None: parse_vector3("1 2") with pytest.raises(RobotModelError, match="Expected 3 values"): parse_vector3("1 2 3 4") - with pytest.raises(RobotModelError, match="must be a finite number"): + with pytest.raises(RobotModelError, match="Invalid float format"): parse_vector3("1 2 a") @@ -124,7 +124,7 @@ def test_validate_xml_depth_exceeded() -> None: for _ in range(MAX_XML_DEPTH + 1): curr = ET.SubElement(curr, "a") - with pytest.raises(RobotModelError, match="Nesting too deep"): + with pytest.raises(RobotModelError, match="exceeds limit"): validate_xml_depth(root) diff --git a/tests/unit/platforms/blender/test_async_builder.py b/tests/unit/platforms/blender/test_async_builder.py index 54db0aeb..3ac37fe4 100644 --- a/tests/unit/platforms/blender/test_async_builder.py +++ b/tests/unit/platforms/blender/test_async_builder.py @@ -3,7 +3,7 @@ import bpy from linkforge.blender.logic.asynchronous_builder import AsynchronousRobotBuilder -from linkforge.linkforge_core.exceptions import RobotModelError +from linkforge_core.exceptions import RobotModelError from linkforge_core.models import Joint, JointType, Link, Robot diff --git a/tests/unit/platforms/blender/test_blender_to_core.py b/tests/unit/platforms/blender/test_blender_to_core.py index 54e3654f..d2e6ea56 100644 --- a/tests/unit/platforms/blender/test_blender_to_core.py +++ b/tests/unit/platforms/blender/test_blender_to_core.py @@ -2,7 +2,6 @@ import bpy import pytest -from linkforge.linkforge_core.exceptions import RobotModelError try: import importlib.util @@ -23,7 +22,8 @@ sanitize_name, scene_to_robot, ) -from linkforge.linkforge_core.models import ( +from linkforge_core.exceptions import RobotValidationError, ValidationErrorCode +from linkforge_core.models import ( Box, Cylinder, JointType, @@ -755,7 +755,7 @@ def test_blender_link_to_core_complex() -> None: def test_blender_link_to_core_geometry_and_material() -> None: """Verify detailed geometry and material conversion.""" from linkforge.blender.adapters.blender_to_core import blender_link_to_core_with_origin - from linkforge.linkforge_core.models import GeometryType + from linkforge_core.models import GeometryType # 1. Link Setup bpy.ops.object.empty_add() @@ -1218,9 +1218,11 @@ def test_scene_to_robot_with_gazebo_and_errors(clean_scene) -> None: with ( mock.patch( "linkforge.blender.adapters.blender_to_core.blender_link_to_core_with_origin", - side_effect=Exception("Failed link"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Failed link"), + ), + pytest.raises( + RobotValidationError, match=r"\[INVALID_VALUE\] Multiple configuration errors found" ), - pytest.raises(RobotModelError, match=r"Multiple configuration errors found"), ): scene_to_robot(bpy.context) @@ -1382,7 +1384,7 @@ def test_blender_joint_advanced_cases(clean_scene) -> None: # Missing parent error j.linkforge_joint.parent_link = None - with pytest.raises(RobotModelError, match="no parent link"): + with pytest.raises(RobotValidationError, match=r"\[NOT_FOUND\] Joint has no parent link"): blender_joint_to_core(j) @@ -1672,8 +1674,7 @@ def test_blender_to_core_missing_errors(clean_scene) -> None: blender_transmission_to_core, get_object_geometry, ) - from linkforge.linkforge_core.exceptions import RobotModelError - from linkforge.linkforge_core.models.geometry import Box + from linkforge_core.models.geometry import Box # blender_link_to_core_with_origin None assert blender_link_to_core_with_origin(None) is None @@ -1735,7 +1736,7 @@ def test_blender_to_core_missing_errors(clean_scene) -> None: j.linkforge_joint.is_robot_joint = True j.linkforge_joint.parent_link = p_link j.linkforge_joint.child_link = None - with pytest.raises(RobotModelError, match="no child link"): + with pytest.raises(RobotValidationError, match=r"\[NOT_FOUND\] Joint has no child link"): blender_joint_to_core(j) # Empty transmission diff --git a/tests/unit/platforms/blender/test_blender_to_core_errors.py b/tests/unit/platforms/blender/test_blender_to_core_errors.py index 80b9c778..2658a0ae 100644 --- a/tests/unit/platforms/blender/test_blender_to_core_errors.py +++ b/tests/unit/platforms/blender/test_blender_to_core_errors.py @@ -8,7 +8,7 @@ blender_sensor_to_core, scene_to_robot, ) -from linkforge.linkforge_core.exceptions import RobotModelError +from linkforge_core.exceptions import RobotValidationError, ValidationErrorCode def test_scene_to_robot_strict_mode_links(clean_scene) -> None: @@ -25,9 +25,9 @@ def test_scene_to_robot_strict_mode_links(clean_scene) -> None: with ( mock.patch( "linkforge.blender.adapters.blender_to_core.blender_link_to_core_with_origin", - side_effect=RobotModelError("Link Fail"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Link Fail"), ), - pytest.raises(RobotModelError, match="Link Fail"), + pytest.raises(RobotValidationError, match=r"\[INVALID_VALUE\] Link Fail"), ): from unittest.mock import MagicMock @@ -54,14 +54,14 @@ def test_scene_to_robot_strict_mode_others(clean_scene) -> None: ), mock.patch( "linkforge.blender.adapters.blender_to_core.blender_joint_to_core", - side_effect=RobotModelError("Joint Fail"), + side_effect=RobotValidationError(ValidationErrorCode.NOT_FOUND, "Joint Fail"), ), ): from unittest.mock import MagicMock context = MagicMock() context.scene = scene - with pytest.raises(RobotModelError, match="Joint Fail"): + with pytest.raises(RobotValidationError, match=r"\[NOT_FOUND\] Joint Fail"): scene_to_robot(context) @@ -72,7 +72,9 @@ def test_sensor_attachment_error(clean_scene) -> None: s.linkforge_sensor.is_robot_sensor = True s.linkforge_sensor.attached_link = None - with pytest.raises(RobotModelError, match="is not attached to any link"): + with pytest.raises( + RobotValidationError, match=r"\[NOT_FOUND\] Sensor is not attached to any link" + ): blender_sensor_to_core(s) @@ -167,11 +169,11 @@ def test_scene_to_robot_non_strict_errors(clean_scene) -> None: with ( mock.patch( "linkforge.blender.adapters.blender_to_core.blender_joint_to_core", - side_effect=Exception("Joint Fail"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Joint Fail"), ), mock.patch( "linkforge.blender.adapters.blender_to_core.blender_sensor_to_core", - side_effect=Exception("Sensor Fail"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Sensor Fail"), ), ): # Also trigger transmission and ros2_control errors @@ -181,20 +183,23 @@ def test_scene_to_robot_non_strict_errors(clean_scene) -> None: with mock.patch( "linkforge.blender.adapters.blender_to_core.blender_transmission_to_core", - side_effect=Exception("Trans Fail"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Trans Fail"), ): scene.linkforge.use_ros2_control = True scene.linkforge.ros2_control_name = "test" with mock.patch( "linkforge.blender.adapters.blender_to_core.blender_ros2_control_to_core", - side_effect=Exception("Control Fail"), + side_effect=RobotValidationError(ValidationErrorCode.INVALID_VALUE, "Control Fail"), ): from unittest.mock import MagicMock context = MagicMock() context.scene = scene - # It always raises RobotModelError at the end if errors exist - with pytest.raises(RobotModelError, match=r"Multiple configuration errors found"): + # It always raises RobotValidationError at the end if errors exist + with pytest.raises( + RobotValidationError, + match=r"Multiple configuration errors found", + ): scene_to_robot(context) diff --git a/tests/unit/platforms/blender/test_blender_to_core_robustness.py b/tests/unit/platforms/blender/test_blender_to_core_robustness.py index baeaae5b..63f24899 100644 --- a/tests/unit/platforms/blender/test_blender_to_core_robustness.py +++ b/tests/unit/platforms/blender/test_blender_to_core_robustness.py @@ -12,8 +12,8 @@ matrix_to_transform, scene_to_robot, ) -from linkforge.linkforge_core.exceptions import RobotModelError -from linkforge.linkforge_core.models import ( +from linkforge_core.exceptions import RobotModelError +from linkforge_core.models import ( JointType, ) from mathutils import Matrix @@ -151,7 +151,7 @@ def test_ros2_control_gazebo_plugin(clean_scene) -> None: p.linkforge.is_robot_link = True # Mock ros2_control conversion to return something valid - from linkforge.linkforge_core.models.ros2_control import Ros2Control + from linkforge_core.models.ros2_control import Ros2Control with patch( "linkforge.blender.adapters.blender_to_core.blender_ros2_control_to_core", @@ -211,7 +211,7 @@ def test_sensor_origin_custom_mount(clean_scene) -> None: robot, _ = scene_to_robot(bpy.context) assert len(robot.sensors) == 1 # Compare with Vector3 - from linkforge.linkforge_core.models import Vector3 + from linkforge_core.models import Vector3 assert robot.sensors[0].origin.xyz == Vector3(1.0, 1.0, 1.0) diff --git a/tests/unit/platforms/blender/test_converters_deep.py b/tests/unit/platforms/blender/test_converters_deep.py index 974d47ce..d758fdf1 100644 --- a/tests/unit/platforms/blender/test_converters_deep.py +++ b/tests/unit/platforms/blender/test_converters_deep.py @@ -7,7 +7,7 @@ blender_ros2_control_to_core, scene_to_robot, ) -from linkforge.linkforge_core.exceptions import RobotModelError +from linkforge_core.exceptions import RobotModelError from linkforge_core.models import CameraInfo, Link, Sensor, SensorType if typing.TYPE_CHECKING: diff --git a/tests/unit/platforms/blender/test_core_to_blender.py b/tests/unit/platforms/blender/test_core_to_blender.py index 154b67f6..a224165a 100644 --- a/tests/unit/platforms/blender/test_core_to_blender.py +++ b/tests/unit/platforms/blender/test_core_to_blender.py @@ -13,7 +13,7 @@ import_robot_to_scene, normalize_and_consolidate_imported_objects, ) -from linkforge.linkforge_core.models import ( +from linkforge_core.models import ( Box, CameraInfo, Collision, @@ -187,7 +187,7 @@ def test_create_joint_object_complex() -> None: def test_create_joint_object_advanced_props() -> None: """Verify that safety controller and calibration are correctly synced to Blender properties.""" - from linkforge.linkforge_core.models import JointCalibration, JointSafetyController + from linkforge_core.models import JointCalibration, JointSafetyController # 1. Setup Links bpy.ops.object.empty_add() @@ -928,7 +928,7 @@ def test_normalize_and_consolidate_imported_objects() -> None: def test_create_joint_object_mimic_logic() -> None: """Test that mimics are correctly resolved even if created out of order.""" from linkforge.blender.adapters.core_to_blender import create_joint_object - from linkforge.linkforge_core.models import Joint, JointMimic, JointType + from linkforge_core.models import Joint, JointMimic, JointType bpy.ops.wm.read_factory_settings(use_empty=True) import linkforge.blender @@ -952,7 +952,7 @@ def test_create_joint_object_mimic_logic() -> None: # Mocking the discovery of the driver joint in scene # The actual implementation looks for objects by name - from linkforge.linkforge_core.models import JointLimits + from linkforge_core.models import JointLimits joint = Joint( name="follower", diff --git a/tests/unit/platforms/blender/test_core_to_blender_robustness.py b/tests/unit/platforms/blender/test_core_to_blender_robustness.py index 6c83a0ce..1d4cda6d 100644 --- a/tests/unit/platforms/blender/test_core_to_blender_robustness.py +++ b/tests/unit/platforms/blender/test_core_to_blender_robustness.py @@ -13,7 +13,8 @@ import_robot_to_scene, normalize_and_consolidate_imported_objects, ) -from linkforge.linkforge_core.models import ( +from linkforge_core.base import FileSystemResolver +from linkforge_core.models import ( Box, CameraInfo, Collision, @@ -39,8 +40,7 @@ Vector3, Visual, ) -from linkforge.linkforge_core.models.sensor import GPSInfo, IMUInfo -from linkforge_core.base import FileSystemResolver +from linkforge_core.models.sensor import GPSInfo, IMUInfo from mathutils import Vector @@ -246,8 +246,8 @@ def test_create_sensor_object(clean_scene) -> None: def test_import_robot_with_mimic_and_gazebo(clean_scene) -> None: """Test import of robot with mimic joints and gazebo plugins.""" - from linkforge.linkforge_core.models.gazebo import GazeboElement, GazeboPlugin - from linkforge.linkforge_core.models.joint import JointMimic + from linkforge_core.models.gazebo import GazeboElement, GazeboPlugin + from linkforge_core.models.joint import JointMimic robot = Robot( name="MimicBot", @@ -304,7 +304,7 @@ def test_create_material_no_tree(clean_scene) -> None: def test_import_robot_with_transmissions(clean_scene) -> None: """Test importing robot with transmissions.""" - from linkforge.linkforge_core.models.transmission import ( + from linkforge_core.models.transmission import ( Transmission, TransmissionActuator, TransmissionJoint, @@ -329,7 +329,7 @@ def test_import_robot_with_transmissions(clean_scene) -> None: def test_full_robot_import_integration(clean_scene) -> None: """A 'MegaBot' test to hit as many code paths as possible in core_to_blender.""" - from linkforge.linkforge_core.models.transmission import ( + from linkforge_core.models.transmission import ( Transmission, TransmissionActuator, TransmissionJoint, @@ -412,7 +412,7 @@ def test_full_robot_import_integration(clean_scene) -> None: ), ) - from linkforge.linkforge_core.models.sensor import LidarInfo + from linkforge_core.models.sensor import LidarInfo lidar = Sensor( name="lidar_sensor", diff --git a/tests/unit/platforms/blender/test_decorators.py b/tests/unit/platforms/blender/test_decorators.py index 9901934e..87a2a0a5 100644 --- a/tests/unit/platforms/blender/test_decorators.py +++ b/tests/unit/platforms/blender/test_decorators.py @@ -3,7 +3,7 @@ import logging from linkforge.blender.utils.decorators import safe_execute -from linkforge.linkforge_core.exceptions import RobotModelError +from linkforge_core.exceptions import RobotModelError class SpyOperator: diff --git a/tests/unit/platforms/blender/test_export_ops.py b/tests/unit/platforms/blender/test_export_ops.py index 8501aa1a..9f12da85 100644 --- a/tests/unit/platforms/blender/test_export_ops.py +++ b/tests/unit/platforms/blender/test_export_ops.py @@ -29,8 +29,8 @@ def test_export_urdf_execute(mocker, clean_scene, export_format) -> None: # Use real scene translation to exercise adapter logic without mocking scene_to_robot. # Generators are mocked to prevent file system operations. - mocker.patch("linkforge.linkforge_core.URDFGenerator") - mocker.patch("linkforge.linkforge_core.XACROGenerator") + mocker.patch("linkforge_core.URDFGenerator") + mocker.patch("linkforge_core.XACROGenerator") # Add a root object to make scene_to_robot succeed root = bpy.data.objects.new("root", None) @@ -114,9 +114,7 @@ def test_validate_robot_operator(mocker, clean_scene) -> None: val_res.errors = [] val_res.warnings = [warn] - mocker.patch( - "linkforge.linkforge_core.validation.RobotValidator.validate", return_value=val_res - ) + mocker.patch("linkforge_core.validation.RobotValidator.validate", return_value=val_res) result = LINKFORGE_OT_validate_robot.execute(mock_self, bpy.context) assert result == {"FINISHED"} @@ -286,7 +284,7 @@ def test_export_urdf_extension_correction(mocker, clean_scene) -> None: mocker.patch( "linkforge.blender.adapters.blender_to_core.scene_to_robot", return_value=(MagicMock(), {}) ) - mocker.patch("linkforge.linkforge_core.URDFGenerator") + mocker.patch("linkforge_core.URDFGenerator") LINKFORGE_OT_export_urdf.execute(mock_self, bpy.context) assert mock_self.filepath.endswith(".urdf") @@ -294,7 +292,7 @@ def test_export_urdf_extension_correction(mocker, clean_scene) -> None: # Case 2: XACRO format but .urdf extension props.export_format = "XACRO" mock_self.filepath = "/tmp/fake.urdf" - mocker.patch("linkforge.linkforge_core.XACROGenerator") + mocker.patch("linkforge_core.XACROGenerator") LINKFORGE_OT_export_urdf.execute(mock_self, bpy.context) assert mock_self.filepath.endswith(".xacro") diff --git a/tests/unit/platforms/blender/test_import_ops.py b/tests/unit/platforms/blender/test_import_ops.py index 272b465c..c954e23d 100644 --- a/tests/unit/platforms/blender/test_import_ops.py +++ b/tests/unit/platforms/blender/test_import_ops.py @@ -6,7 +6,7 @@ register, unregister, ) -from linkforge.linkforge_core.exceptions import RobotModelError +from linkforge_core.exceptions import RobotModelError def test_import_urdf_logic_paths(tmp_path) -> None: @@ -22,9 +22,7 @@ def test_import_urdf_logic_paths(tmp_path) -> None: # Mock the asynchronous builder to avoid side effects during logic verification. with ( patch("linkforge.blender.logic.asynchronous_builder.AsynchronousRobotBuilder"), - patch( - "linkforge.linkforge_core.validation.security.find_sandbox_root", return_value=tmp_path - ), + patch("linkforge_core.validation.security.find_sandbox_root", return_value=tmp_path), ): # Call execute directly to test the logic result = LINKFORGE_OT_import_urdf.execute(mock_self, context) @@ -56,22 +54,20 @@ def test_import_urdf_xacro_fallback(tmp_path) -> None: mock_self.filepath = str(urdf_file) context = MagicMock() - from linkforge.linkforge_core import XacroDetectedError + from linkforge_core import XacroDetectedError with ( patch( - "linkforge.linkforge_core.parsers.URDFParser.parse", + "linkforge_core.parsers.URDFParser.parse", side_effect=XacroDetectedError("Xacro detected"), ), patch( - "linkforge.linkforge_core.parsers.XacroResolver.resolve_file", + "linkforge_core.parsers.XacroResolver.resolve_file", return_value="", ), - patch("linkforge.linkforge_core.parsers.URDFParser.parse_string", return_value=MagicMock()), + patch("linkforge_core.parsers.URDFParser.parse_string", return_value=MagicMock()), patch("linkforge.blender.logic.asynchronous_builder.AsynchronousRobotBuilder"), - patch( - "linkforge.linkforge_core.validation.security.find_sandbox_root", return_value=tmp_path - ), + patch("linkforge_core.validation.security.find_sandbox_root", return_value=tmp_path), ): result = LINKFORGE_OT_import_urdf.execute(mock_self, context) assert result == {"FINISHED"} @@ -95,9 +91,7 @@ def test_import_urdf_directory_handling_more(tmp_path) -> None: with ( patch("linkforge.blender.logic.asynchronous_builder.AsynchronousRobotBuilder"), - patch( - "linkforge.linkforge_core.validation.security.find_sandbox_root", return_value=subdir - ), + patch("linkforge_core.validation.security.find_sandbox_root", return_value=subdir), ): result = LINKFORGE_OT_import_urdf.execute(mock_self, bpy.context) assert result == {"FINISHED"} @@ -120,9 +114,7 @@ def test_import_urdf_directory_candidates(tmp_path) -> None: with ( patch("linkforge.blender.logic.asynchronous_builder.AsynchronousRobotBuilder"), - patch( - "linkforge.linkforge_core.validation.security.find_sandbox_root", return_value=subdir - ), + patch("linkforge_core.validation.security.find_sandbox_root", return_value=subdir), ): result = LINKFORGE_OT_import_urdf.execute(mock_self, bpy.context) assert result == {"FINISHED"} @@ -178,7 +170,7 @@ def test_import_xacro_resolution_error(tmp_path) -> None: # Simulate a Xacro resolution error (e.g., PackageNotFoundError). with patch( - "linkforge.linkforge_core.parsers.XacroResolver.resolve_file", + "linkforge_core.parsers.XacroResolver.resolve_file", side_effect=Exception("PackageNotFoundError: my_pkg"), ): result = LINKFORGE_OT_import_urdf.execute(mock_self, bpy.context) @@ -199,7 +191,7 @@ def test_import_path_conversion_error(tmp_path) -> None: mock_self.filepath = str(urdf_file) with patch( - "linkforge.linkforge_core.parsers.URDFParser.parse", + "linkforge_core.parsers.URDFParser.parse", side_effect=RobotModelError("Path resolution failed"), ): result = LINKFORGE_OT_import_urdf.execute(mock_self, bpy.context)