Skip to content

Commit 78ed16a

Browse files
Merge branch 'main' into dependabot/uv/urllib3-2.7.0
2 parents 627c7f7 + ebbcac9 commit 78ed16a

70 files changed

Lines changed: 3671 additions & 1836 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
JointSafetyController,
1919
JointType,
2020
)
21-
from ..models.link import Collision, Inertial, InertiaTensor, Link, Visual
21+
from ..models.link import Collision, Inertial, InertiaTensor, Link, LinkPhysics, Visual
2222
from ..models.material import Material
2323
from ..models.robot import Robot
2424
from ..models.ros2_control import Ros2ControlJoint
@@ -57,6 +57,7 @@ class _LinkState:
5757
visuals: list[Visual] = field(default_factory=list)
5858
collisions: list[Collision] = field(default_factory=list)
5959
sensors: list[Sensor] = field(default_factory=list)
60+
physics: LinkPhysics = field(default_factory=LinkPhysics)
6061
gazebo_params: dict[str, Any] = field(default_factory=dict)
6162

6263

@@ -363,6 +364,49 @@ def prismatic(
363364
)
364365
return self._configure_joint(name, xyz, rpy)
365366

367+
def floating(
368+
self,
369+
name: str | None = None,
370+
xyz: tuple[float, float, float] | None = None,
371+
rpy: tuple[float, float, float] | None = None,
372+
) -> LinkBuilder:
373+
"""Configure the connection as a FLOATING (6 DOF) joint.
374+
375+
Args:
376+
name: Unique joint name.
377+
xyz: Joint origin translation.
378+
rpy: Joint origin rotation.
379+
380+
Returns:
381+
The LinkBuilder instance.
382+
"""
383+
self._check_not_committed()
384+
self._joint.type = JointType.FLOATING
385+
return self._configure_joint(name, xyz, rpy)
386+
387+
def planar(
388+
self,
389+
axis: tuple[float, float, float],
390+
name: str | None = None,
391+
xyz: tuple[float, float, float] | None = None,
392+
rpy: tuple[float, float, float] | None = None,
393+
) -> LinkBuilder:
394+
"""Configure the connection as a PLANAR joint.
395+
396+
Args:
397+
axis: Plane normal unit vector.
398+
name: Unique joint name.
399+
xyz: Joint origin translation.
400+
rpy: Joint origin rotation.
401+
402+
Returns:
403+
The LinkBuilder instance.
404+
"""
405+
self._check_not_committed()
406+
self._joint.type = JointType.PLANAR
407+
self._joint.axis = self._normalize_axis(axis)
408+
return self._configure_joint(name, xyz, rpy)
409+
366410
def dynamics(self, damping: float = 0.0, friction: float = 0.0) -> LinkBuilder:
367411
"""Set the physical dynamics for the joint.
368412
@@ -431,21 +475,33 @@ def calibration(self, rising: float | None = None, falling: float | None = None)
431475
self._joint.calibration = JointCalibration(rising=rising, falling=falling)
432476
return self
433477

434-
def simulation(self, **kwargs: Any) -> LinkBuilder:
435-
"""Set Gazebo-specific simulation properties for this link.
478+
def physics(self, **kwargs: Any) -> LinkBuilder:
479+
"""Set surface and contact physics properties for this link.
480+
481+
Supports both typed LinkPhysics fields and raw engine-specific parameters.
436482
437483
Common arguments:
438484
self_collide (bool): Enable self-collision.
439485
gravity (bool): Enable gravity.
440-
static (bool): Mark link as static.
441-
mu1, mu2 (float): Friction coefficients.
486+
mu, mu2 (float): Friction coefficients.
442487
kp, kd (float): Contact stiffness and damping.
443488
444489
Returns:
445490
The LinkBuilder instance.
446491
"""
447492
self._check_not_committed()
448-
self._link.gazebo_params.update(kwargs)
493+
494+
phys_fields = {f.name for f in LinkPhysics.__dataclass_fields__.values()}
495+
phys_updates = {k: v for k, v in kwargs.items() if k in phys_fields}
496+
497+
if phys_updates:
498+
self._link.physics = replace(self._link.physics, **phys_updates)
499+
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)
504+
449505
return self
450506

451507
def _configure_joint(
@@ -837,9 +893,10 @@ def _finalize_link(self, inertial: Inertial | None) -> None:
837893
l_state = self._link
838894
link = Link(
839895
name=self._link_name,
840-
initial_visuals=l_state.visuals,
841-
initial_collisions=l_state.collisions,
896+
visuals=l_state.visuals,
897+
collisions=l_state.collisions,
842898
inertial=inertial,
899+
physics=l_state.physics,
843900
)
844901
self._builder.robot.add_link(link)
845902

@@ -914,11 +971,16 @@ def _finalize_ros2_control(self, joint: Joint) -> None:
914971
else:
915972
target_system = self._builder.robot.ros2_controls[0]
916973

917-
target_system.joints.append(
918-
Ros2ControlJoint(
919-
name=joint.name,
920-
command_interfaces=self._control_interfaces[0],
921-
state_interfaces=self._control_interfaces[1],
922-
parameters=self._control_interfaces[2],
923-
)
974+
new_system = replace(
975+
target_system,
976+
joints=(
977+
*target_system.joints,
978+
Ros2ControlJoint(
979+
name=joint.name,
980+
command_interfaces=self._control_interfaces[0],
981+
state_interfaces=self._control_interfaces[1],
982+
parameters=self._control_interfaces[2],
983+
),
984+
),
924985
)
986+
self._builder.robot.update_ros2_control(new_system)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Central constants for the LinkForge project."""
2+
3+
from __future__ import annotations
4+
5+
# Physics Defaults (Simulation)
6+
# ----------------------------
7+
8+
# Default static friction coefficient (Coulomb)
9+
DEFAULT_FRICTION_MU = 1.0
10+
11+
# Default dynamic friction coefficient (Coulomb)
12+
DEFAULT_FRICTION_MU2 = 1.0
13+
14+
# Default contact stiffness (N/m)
15+
# 1e12 is the industry standard for 'hard' contact in Gazebo/GZ
16+
DEFAULT_CONTACT_KP = 1e12
17+
18+
# Default contact damping (N s/m)
19+
DEFAULT_CONTACT_KD = 1.0
20+
21+
# Default gravity inclusion
22+
DEFAULT_GRAVITY = True
23+
24+
# Default self-collision inclusion
25+
DEFAULT_SELF_COLLIDE = False
26+
27+
28+
# XML and XACRO Namespaces
29+
# ----------------------------
30+
31+
# Official XACRO namespace URIs
32+
XACRO_URIS = {
33+
"http://www.ros.org/wiki/xacro",
34+
"http://wiki.ros.org/xacro",
35+
"http://ros.org/xacro",
36+
}
37+
38+
# Standard prefix for internal structural processing
39+
XACRO_PREFIX = "xacro:"
40+
41+
42+
# Validation Limits (Sanity Checks)
43+
# ----------------------------
44+
45+
# Maximum absolute value allowed for floats in robot models
46+
# 1e18 is safe for stiffness (kp) while preventing simulation-breaking overflows
47+
MAX_REASONABLE_FLOAT = 1e18
48+
49+
# Maximum absolute value allowed for integers (IDs, sample counts, etc.)
50+
MAX_REASONABLE_INT = 1000000

core/src/linkforge_core/generators/urdf_generator.py

Lines changed: 85 additions & 13 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,89 @@ 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:
889-
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)
883+
grouped_elements: dict[str | None, list[GazeboElement]] = defaultdict(list)
884+
for gz in robot.gazebo_elements:
885+
grouped_elements[gz.reference].append(gz)
886+
887+
# 0. Check if we have any Gazebo content at all before adding header
888+
default_physics = LinkPhysics()
889+
has_modified_physics = any(lnk.physics != default_physics for lnk in robot.links)
890+
if not robot.gazebo_elements and not has_modified_physics:
891+
return
892+
893+
parent.append(ET.Comment(" Gazebo "))
894+
895+
# 1. Handle Link-level Gazebo tags (Physics + Extensions)
896+
# We only generate these if physics are non-default or if there are explicit elements
897+
for link in sorted(robot.links, key=lambda lnk: lnk.name):
898+
has_explicit = link.name in grouped_elements
899+
is_physics_modified = link.physics != default_physics
900+
901+
# Skip if nothing to export for this link
902+
if not has_explicit and not is_physics_modified:
903+
continue
904+
905+
# Create a single tag for this link
906+
gz_tag = ET.SubElement(parent, "gazebo", reference=link.name)
907+
908+
# Add Physics (only if modified or if tag already created for explicit elements)
909+
if is_physics_modified:
910+
self._fill_link_physics(gz_tag, link.physics)
911+
912+
# Add any explicit Gazebo elements for this link
913+
if has_explicit:
914+
for elem in grouped_elements[link.name]:
915+
self._fill_gazebo_element(gz_tag, elem)
916+
# Mark as handled
917+
del grouped_elements[link.name]
918+
919+
# 2. Handle Robot-level (reference=None) and other Gazebo tags
920+
# Sort by reference for deterministic output
921+
for ref in sorted(grouped_elements.keys(), key=lambda r: r or ""):
922+
attrib = {"reference": ref} if ref else {}
923+
gz_tag = ET.SubElement(parent, "gazebo", attrib)
924+
for elem in grouped_elements[ref]:
925+
self._fill_gazebo_element(gz_tag, elem)
926+
927+
def _fill_link_physics(self, gz_elem: ET.Element, phys: LinkPhysics) -> None:
928+
"""Fill an existing gazebo element with physics properties."""
929+
# Boolean properties
930+
ET.SubElement(gz_elem, "selfCollide").text = "true" if phys.self_collide else "false"
931+
ET.SubElement(gz_elem, "gravity").text = "true" if phys.gravity else "false"
932+
933+
# Friction parameters
934+
ET.SubElement(gz_elem, "mu1").text = format_float(phys.mu)
935+
ET.SubElement(gz_elem, "mu2").text = format_float(phys.mu2)
936+
937+
# Contact parameters
938+
ET.SubElement(gz_elem, "kp").text = format_float(phys.kp)
939+
ET.SubElement(gz_elem, "kd").text = format_float(phys.kd)
940+
941+
def _fill_gazebo_element(self, gz_elem: ET.Element, gazebo_elem: GazeboElement) -> None:
942+
"""Fill an existing gazebo element with properties from a GazeboElement model."""
943+
# Add material if specified
944+
if gazebo_elem.material is not None:
945+
xml_add_text(gz_elem, "material", gazebo_elem.material)
946+
947+
# Add boolean properties
948+
self._add_optional_bool_element(gz_elem, "static", gazebo_elem.static)
949+
self._add_optional_bool_element(gz_elem, "provideFeedback", gazebo_elem.provide_feedback)
950+
self._add_optional_bool_element(
951+
gz_elem, "implicitSpringDamper", gazebo_elem.implicit_spring_damper
952+
)
953+
954+
# Add numeric properties
955+
self._add_optional_numeric_element(gz_elem, "stopCfm", gazebo_elem.stop_cfm)
956+
self._add_optional_numeric_element(gz_elem, "stopErp", gazebo_elem.stop_erp)
957+
958+
# Add custom properties (sort by key for deterministic output)
959+
for key in sorted(gazebo_elem.properties.keys()):
960+
prop_elem = ET.SubElement(gz_elem, key)
961+
prop_elem.text = gazebo_elem.properties[key]
962+
963+
# Add plugins (sort by name for deterministic output)
964+
for plugin in sorted(gazebo_elem.plugins, key=lambda p: p.name):
965+
self._add_gazebo_plugin_element(gz_elem, plugin)
894966

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

core/src/linkforge_core/models/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
JointSafetyController,
2828
JointType,
2929
)
30-
from .link import Collision, Inertial, InertiaTensor, Link, Visual
30+
from .link import Collision, Inertial, InertiaTensor, Link, LinkPhysics, Visual
3131
from .material import Color, Material
3232
from .robot import Robot
3333
from .ros2_control import Ros2Control, Ros2ControlJoint
@@ -56,7 +56,6 @@
5656
VirtualJoint,
5757
)
5858
from .transmission import (
59-
HardwareInterface,
6059
Transmission,
6160
TransmissionActuator,
6261
TransmissionJoint,
@@ -82,6 +81,7 @@
8281
"Visual",
8382
"Collision",
8483
"Link",
84+
"LinkPhysics",
8585
# Joint
8686
"JointType",
8787
"JointLimits",
@@ -107,7 +107,6 @@
107107
"Sensor",
108108
# Transmission
109109
"TransmissionType",
110-
"HardwareInterface",
111110
"TransmissionJoint",
112111
"TransmissionActuator",
113112
"Transmission",

0 commit comments

Comments
 (0)