Skip to content

Commit 9f26e43

Browse files
refactor: modernize Blender-to-Core translation pipeline and stabilize integration tests
- Implemented a Registry-based Translator pattern (ITranslator) for specialized component handling. - Decoupled physical, mechanical, and control layers into dedicated Translators (Link, Joint, Sensor, Transmission, Ros2Control). - Purged legacy shims, diagnostic prints, and dead code from the adapter layer. - Fixed a critical 'MagicMock' leakage issue in integration tests by correcting property access in Ros2ControlTranslator. - Standardized ROS 2 control property mapping to use 'hardware_plugin' per the core model specification. - Achieved 100% test coverage for the new architecture with 58 unit tests and 16 integration tests. - Sanitized imports and applied professional formatting across the adapter and test suites. Signed-off-by: arounamounchili <patouossa.mounchili@gmail.com>
1 parent a6d6a93 commit 9f26e43

29 files changed

Lines changed: 1201 additions & 1075 deletions

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ def test_link_creation():
214214
"""Test creating a link with valid parameters."""
215215
link = Link(
216216
name="test_link",
217-
initial_visuals=[],
218-
initial_collisions=[],
217+
visuals=[],
218+
collisions=[],
219219
inertial=Inertial(mass=1.0, inertia=InertiaTensor.zero())
220220
)
221221
assert link.name == "test_link"

core/src/linkforge_core/composer/link_builder.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -476,43 +476,34 @@ def calibration(self, rising: float | None = None, falling: float | None = None)
476476
return self
477477

478478
def physics(self, **kwargs: Any) -> LinkBuilder:
479-
"""Set simulation and contact physics properties for this link.
479+
"""Set surface and contact physics properties for this link.
480480
481-
Supports both typed LinkPhysics fields and raw gazebo_params.
481+
Supports both typed LinkPhysics fields and raw engine-specific parameters.
482482
483483
Common arguments:
484484
self_collide (bool): Enable self-collision.
485485
gravity (bool): Enable gravity.
486-
mu1, mu2 (float): Friction coefficients.
486+
mu, mu2 (float): Friction coefficients.
487487
kp, kd (float): Contact stiffness and damping.
488488
489489
Returns:
490490
The LinkBuilder instance.
491491
"""
492492
self._check_not_committed()
493493

494-
# Update both typed model and raw gazebo_params for backward compatibility
495494
phys_fields = {f.name for f in LinkPhysics.__dataclass_fields__.values()}
496-
497-
# Mapping for Blender/ODE specific names to Core Physics fields
498-
mapped_kwargs = kwargs.copy()
499-
if "mu1" in kwargs and "mu" not in kwargs:
500-
mapped_kwargs["mu"] = kwargs["mu1"]
501-
502-
phys_updates = {k: v for k, v in mapped_kwargs.items() if k in phys_fields}
495+
phys_updates = {k: v for k, v in kwargs.items() if k in phys_fields}
503496

504497
if phys_updates:
505498
self._link.physics = replace(self._link.physics, **phys_updates)
506499

507-
# Always update gazebo_params so generators/tests relying on it continue to work
508-
self._link.gazebo_params.update(kwargs)
500+
# Store non-physics fields in gazebo_params
501+
remaining_kwargs = {k: v for k, v in kwargs.items() if k not in phys_fields}
502+
if remaining_kwargs:
503+
self._link.gazebo_params.update(remaining_kwargs)
509504

510505
return self
511506

512-
def simulation(self, **kwargs: Any) -> LinkBuilder:
513-
"""Deprecated: Use physics() instead."""
514-
return self.physics(**kwargs)
515-
516507
def _configure_joint(
517508
self,
518509
name: str | None = None,
@@ -902,8 +893,8 @@ def _finalize_link(self, inertial: Inertial | None) -> None:
902893
l_state = self._link
903894
link = Link(
904895
name=self._link_name,
905-
initial_visuals=l_state.visuals,
906-
initial_collisions=l_state.collisions,
896+
visuals=l_state.visuals,
897+
collisions=l_state.collisions,
907898
inertial=inertial,
908899
physics=l_state.physics,
909900
)

core/src/linkforge_core/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# ----------------------------
77

88
# Default static friction coefficient (Coulomb)
9-
DEFAULT_FRICTION_MU1 = 1.0
9+
DEFAULT_FRICTION_MU = 1.0
1010

1111
# Default dynamic friction coefficient (Coulomb)
1212
DEFAULT_FRICTION_MU2 = 1.0

core/src/linkforge_core/generators/urdf_generator.py

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
]
1414

1515
import xml.etree.ElementTree as ET
16+
from collections import defaultdict
1617
from pathlib import Path
1718
from typing import Any
1819

@@ -22,7 +23,7 @@
2223
from ..models.gazebo import GazeboElement, GazeboPlugin
2324
from ..models.geometry import Transform
2425
from ..models.joint import Joint, JointType
25-
from ..models.link import Collision, Link, Visual
26+
from ..models.link import Collision, Link, LinkPhysics, Visual
2627
from ..models.material import Material
2728
from ..models.robot import Robot
2829
from ..models.ros2_control import Ros2Control
@@ -773,19 +774,13 @@ def _add_gazebo_element(self, parent: ET.Element, gazebo_elem: GazeboElement) ->
773774
xml_add_text(gz_elem, "material", gazebo_elem.material)
774775

775776
# Add boolean properties
776-
self._add_optional_bool_element(gz_elem, "selfCollide", gazebo_elem.self_collide)
777777
self._add_optional_bool_element(gz_elem, "static", gazebo_elem.static)
778-
self._add_optional_bool_element(gz_elem, "gravity", gazebo_elem.gravity)
779778
self._add_optional_bool_element(gz_elem, "provideFeedback", gazebo_elem.provide_feedback)
780779
self._add_optional_bool_element(
781780
gz_elem, "implicitSpringDamper", gazebo_elem.implicit_spring_damper
782781
)
783782

784783
# Add numeric properties
785-
self._add_optional_numeric_element(gz_elem, "mu1", gazebo_elem.mu1)
786-
self._add_optional_numeric_element(gz_elem, "mu2", gazebo_elem.mu2)
787-
self._add_optional_numeric_element(gz_elem, "kp", gazebo_elem.kp)
788-
self._add_optional_numeric_element(gz_elem, "kd", gazebo_elem.kd)
789784
self._add_optional_numeric_element(gz_elem, "stopCfm", gazebo_elem.stop_cfm)
790785
self._add_optional_numeric_element(gz_elem, "stopErp", gazebo_elem.stop_erp)
791786

@@ -885,12 +880,75 @@ def add_gazebo(self, parent: ET.Element, robot: Robot) -> None:
885880
parent: Parent XML element (robot)
886881
robot: Robot model
887882
"""
888-
if robot.gazebo_elements:
883+
if robot.links or robot.gazebo_elements:
889884
parent.append(ET.Comment(" Gazebo "))
890-
# Sort gazebo elements by reference for deterministic output
891-
# Empty reference (global) comes first
892-
for gazebo_elem in sorted(robot.gazebo_elements, key=lambda g: g.reference or ""):
893-
self._add_gazebo_element(parent, gazebo_elem)
885+
886+
grouped_elements: dict[str | None, list[GazeboElement]] = defaultdict(list)
887+
for gz in robot.gazebo_elements:
888+
grouped_elements[gz.reference].append(gz)
889+
890+
# 1. Handle Link-level Gazebo tags (Physics + Extensions)
891+
for link in sorted(robot.links, key=lambda lnk: lnk.name):
892+
# Create a single tag for this link
893+
gz_tag = ET.SubElement(parent, "gazebo", reference=link.name)
894+
895+
# Add Physics (Universal Truth)
896+
self._fill_link_physics(gz_tag, link.physics)
897+
898+
# Add any explicit Gazebo elements for this link
899+
if link.name in grouped_elements:
900+
for elem in grouped_elements[link.name]:
901+
self._fill_gazebo_element(gz_tag, elem)
902+
# Mark as handled
903+
del grouped_elements[link.name]
904+
905+
# 2. Handle Robot-level (reference=None) and other Gazebo tags
906+
# Sort by reference for deterministic output
907+
for ref in sorted(grouped_elements.keys(), key=lambda r: r or ""):
908+
attrib = {"reference": ref} if ref else {}
909+
gz_tag = ET.SubElement(parent, "gazebo", attrib)
910+
for elem in grouped_elements[ref]:
911+
self._fill_gazebo_element(gz_tag, elem)
912+
913+
def _fill_link_physics(self, gz_elem: ET.Element, phys: LinkPhysics) -> None:
914+
"""Fill an existing gazebo element with physics properties."""
915+
# Boolean properties
916+
ET.SubElement(gz_elem, "selfCollide").text = "true" if phys.self_collide else "false"
917+
ET.SubElement(gz_elem, "gravity").text = "true" if phys.gravity else "false"
918+
919+
# Friction parameters
920+
ET.SubElement(gz_elem, "mu1").text = format_float(phys.mu)
921+
ET.SubElement(gz_elem, "mu2").text = format_float(phys.mu2)
922+
923+
# Contact parameters
924+
ET.SubElement(gz_elem, "kp").text = format_float(phys.kp)
925+
ET.SubElement(gz_elem, "kd").text = format_float(phys.kd)
926+
927+
def _fill_gazebo_element(self, gz_elem: ET.Element, gazebo_elem: GazeboElement) -> None:
928+
"""Fill an existing gazebo element with properties from a GazeboElement model."""
929+
# Add material if specified
930+
if gazebo_elem.material is not None:
931+
xml_add_text(gz_elem, "material", gazebo_elem.material)
932+
933+
# Add boolean properties
934+
self._add_optional_bool_element(gz_elem, "static", gazebo_elem.static)
935+
self._add_optional_bool_element(gz_elem, "provideFeedback", gazebo_elem.provide_feedback)
936+
self._add_optional_bool_element(
937+
gz_elem, "implicitSpringDamper", gazebo_elem.implicit_spring_damper
938+
)
939+
940+
# Add numeric properties
941+
self._add_optional_numeric_element(gz_elem, "stopCfm", gazebo_elem.stop_cfm)
942+
self._add_optional_numeric_element(gz_elem, "stopErp", gazebo_elem.stop_erp)
943+
944+
# Add custom properties (sort by key for deterministic output)
945+
for key in sorted(gazebo_elem.properties.keys()):
946+
prop_elem = ET.SubElement(gz_elem, key)
947+
prop_elem.text = gazebo_elem.properties[key]
948+
949+
# Add plugins (sort by name for deterministic output)
950+
for plugin in sorted(gazebo_elem.plugins, key=lambda p: p.name):
951+
self._add_gazebo_plugin_element(gz_elem, plugin)
894952

895953
def add_sensors(self, parent: ET.Element, robot: Robot) -> None:
896954
"""Add Sensors section to parent element.

core/src/linkforge_core/models/gazebo.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,16 @@ class GazeboElement:
1919
properties: dict[str, str] = field(default_factory=dict)
2020
plugins: list[GazeboPlugin] = field(default_factory=list)
2121

22-
# Common properties for links
22+
# Common properties for links (platform-specific)
2323
material: str | None = None # Gazebo material (e.g., "Gazebo/Red")
24-
self_collide: bool | None = None
2524
static: bool | None = None
26-
gravity: bool | None = None
2725

28-
# Common properties for joints
26+
# Common properties for joints (platform-specific)
2927
stop_cfm: float | None = None # Constraint force mixing for joint stops
3028
stop_erp: float | None = None # Error reduction parameter for joint stops
3129
provide_feedback: bool | None = None # Enable force-torque feedback
3230
implicit_spring_damper: bool | None = None
3331

34-
# Friction parameters
35-
mu1: float | None = None # Friction coefficient in first friction direction
36-
mu2: float | None = None # Friction coefficient in second friction direction
37-
kp: float | None = None # Contact stiffness
38-
kd: float | None = None # Contact damping
39-
4032
def __post_init__(self) -> None:
4133
"""Validate Gazebo element."""
4234
# If reference is specified, it must be non-empty

core/src/linkforge_core/models/link.py

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Sequence
6-
from dataclasses import InitVar, dataclass, field, replace
5+
from dataclasses import dataclass, field, replace
76

87
from ..constants import (
98
DEFAULT_CONTACT_KD,
109
DEFAULT_CONTACT_KP,
11-
DEFAULT_FRICTION_MU1,
10+
DEFAULT_FRICTION_MU,
1211
DEFAULT_FRICTION_MU2,
1312
DEFAULT_GRAVITY,
1413
DEFAULT_SELF_COLLIDE,
@@ -99,11 +98,17 @@ def __post_init__(self) -> None:
9998

10099
@dataclass(frozen=True)
101100
class LinkPhysics:
102-
"""Advanced physics settings for simulation engines (Gazebo/GZ)."""
101+
"""Surface and contact physics properties for a link.
102+
103+
These parameters govern how the link interacts with other objects in a
104+
physics simulator (e.g., Gazebo, MuJoCo, PyBullet). While naming follows
105+
common conventions, generators are responsible for mapping these to
106+
engine-specific equivalents.
107+
"""
103108

104109
self_collide: bool = DEFAULT_SELF_COLLIDE
105110
gravity: bool = DEFAULT_GRAVITY
106-
mu: float = DEFAULT_FRICTION_MU1
111+
mu: float = DEFAULT_FRICTION_MU
107112
mu2: float = DEFAULT_FRICTION_MU2
108113
kp: float = DEFAULT_CONTACT_KP
109114
kd: float = DEFAULT_CONTACT_KD
@@ -150,19 +155,12 @@ class Link:
150155
"""
151156

152157
name: str
153-
initial_visuals: InitVar[Sequence[Visual] | None] = None
154-
initial_collisions: InitVar[Sequence[Collision] | None] = None
155158
inertial: Inertial | None = None
156159
physics: LinkPhysics = field(default_factory=LinkPhysics)
160+
visuals: list[Visual] = field(default_factory=list)
161+
collisions: list[Collision] = field(default_factory=list)
157162

158-
_visuals: list[Visual] = field(default_factory=list, init=False)
159-
_collisions: list[Collision] = field(default_factory=list, init=False)
160-
161-
def __post_init__(
162-
self,
163-
initial_visuals: Sequence[Visual] | None = None,
164-
initial_collisions: Sequence[Collision] | None = None,
165-
) -> None:
163+
def __post_init__(self) -> None:
166164
"""Validate link."""
167165
if not self.name:
168166
raise RobotValidationError(
@@ -178,28 +176,19 @@ def __post_init__(
178176
value=self.name,
179177
)
180178

181-
if initial_visuals:
182-
self._visuals.extend(initial_visuals)
183-
if initial_collisions:
184-
self._collisions.extend(initial_collisions)
185-
186-
@property
187-
def visuals(self) -> list[Visual]:
188-
"""Get visual representations."""
189-
return list(self._visuals)
190-
191-
@property
192-
def collisions(self) -> list[Collision]:
193-
"""Get collision representations."""
194-
return list(self._collisions)
179+
# Ensure visuals and collisions are lists (in case they were passed as other sequences)
180+
if not isinstance(self.visuals, list):
181+
self.visuals = list(self.visuals)
182+
if not isinstance(self.collisions, list):
183+
self.collisions = list(self.collisions)
195184

196185
def add_visual(self, visual: Visual) -> None:
197186
"""Add a visual representation."""
198-
self._visuals.append(visual)
187+
self.visuals.append(visual)
199188

200189
def add_collision(self, collision: Collision) -> None:
201190
"""Add a collision representation."""
202-
self._collisions.append(collision)
191+
self.collisions.append(collision)
203192

204193
@property
205194
def mass(self) -> float:
@@ -220,7 +209,7 @@ def with_prefix(self, prefix: str) -> Link:
220209
"""Create a new link with a prefixed name and sub-elements."""
221210
return Link(
222211
name=f"{prefix}{self.name}",
223-
initial_visuals=[v.with_prefix(prefix) for v in self._visuals],
224-
initial_collisions=[c.with_prefix(prefix) for c in self._collisions],
225-
inertial=self.inertial,
212+
visuals=[v.with_prefix(prefix) for v in self.visuals],
213+
collisions=[c.with_prefix(prefix) for c in self.collisions],
214+
physics=self.physics,
226215
)

0 commit comments

Comments
 (0)