Skip to content

Commit e298e65

Browse files
Merge pull request #25 from neurobionics/feature/inertia-matrix-wrt
Added routine to modify inertial matrix w/r/t a new transformation matrix. Added a few other minor changes that are out of the scope of this PR: Switching from "-" to "_" as the default str joiner for sanitized names Added an argument to remove onshape's tags to link & joint names Made changes to parse and urdf modules to use _ instead of - in visual and collision names. Made name tags in visual and collision elements to be optional
2 parents 22d3ca7 + e020194 commit e298e65

File tree

6 files changed

+128
-34
lines changed

6 files changed

+128
-34
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ benchmark/**/*.png
189189
benchmark/**/*.prof
190190
benchmark/**/*.json
191191
examples/**/*.xml
192+
examples/simulation/scenes
192193

193194
tests/**/*.urdf
194195
tests/**/*.stl

examples/export/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@
1818

1919
save_model_as_json(robot.assembly, "quadruped.json")
2020

21-
robot.show_graph(file_name="quadruped.png")
21+
# robot.show_graph(file_name="quadruped.png")
2222
robot.save()

onshape_robotics_toolkit/models/link.py

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,15 @@ def zero_inertia(cls) -> "Inertia":
434434
"""
435435
return cls(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
436436

437+
@property
438+
def to_matrix(self) -> np.array:
439+
return np.array([
440+
[self.ixx, self.ixy, self.ixz],
441+
[self.ixy, self.iyy, self.iyz],
442+
[self.ixz, self.iyz, self.izz]
443+
])
444+
445+
437446

438447
@dataclass
439448
class Material:
@@ -602,6 +611,77 @@ def to_mjcf(self, root: ET.Element) -> None:
602611
self.origin.to_mjcf(inertial)
603612
self.inertia.to_mjcf(inertial)
604613

614+
# Adding a method to transform (rotate and translate) an Inertia Matrix
615+
def transform(self, tf_matrix: np.matrix, inplace: bool = False) -> Union["InertialLink", None]:
616+
"""
617+
Apply a transformation matrix to the Inertial Properties of the a link.
618+
619+
Args:
620+
matrix: The transformation matrix to apply to the origin.
621+
inplace: Whether to apply the transformation in place.
622+
623+
Returns:
624+
An updated Inertial Link with the transformation applied to both:
625+
* the inertia tensor (giving a transformed "inertia tensor prime" = [ixx', iyy', izz', ixy', ixz', iyz'])
626+
* AND to the origin too (via the Origin class's transform logic [~line 100])
627+
628+
Examples {@}:
629+
>>> origin = Origin(xyz=(1.0, 2.0, 3.0), rpy=(0.0, 0.0, 0.0))
630+
>>> matrix = np.eye(4)
631+
>>> inertial.transform(matrix)
632+
633+
Analysis and References:
634+
The essence is to convert the Inertia tensor to a matrix and then transform the matrix via the equation
635+
I_prime = R·I·Transpose[R] + m(||d||^2·I - d·Transpose[d])
636+
Then we put the components into the resultant Inertial Link
637+
An analysis (on 100k runs) suggests that this is 3× faster than a direct approach on the tensor elements likely because numpy's libraries are optimized for matrix operations.
638+
Ref: https://chatgpt.com/share/6781b6ac-772c-8006-b1a9-7f2dc3e3ef4d
639+
"""
640+
# Extract the rotation matrix R and translation vector d from T
641+
R = tf_matrix[:3, :3] # Top-left 3x3 block is the rotation matrix
642+
p = tf_matrix[:3, 3] # Top-right 3x1 block is the translation vector
643+
644+
# Unpack the inertia tensor components
645+
# Example is ixx=1.0, iyy=2.0, izz=3.0, ixy=0.0, ixz=0.0, iyz=0.
646+
647+
# Construct the original inertia matrix
648+
inertia_matrix = self.inertia.to_matrix
649+
650+
# Rotate the inertia matrix
651+
I_rot = R @ inertia_matrix @ R.T
652+
653+
# Compute the parallel axis theorem adjustment
654+
parallel_axis_adjustment = self.mass * (np.dot(p, p) * np.eye(3) - np.outer(p, p))
655+
656+
# Final transformed inertia matrix
657+
I_transformed = I_rot + parallel_axis_adjustment
658+
659+
# Extract the components of the transformed inertia tensor
660+
ixx_prime = I_transformed[0, 0]
661+
iyy_prime = I_transformed[1, 1]
662+
izz_prime = I_transformed[2, 2]
663+
ixy_prime = I_transformed[0, 1]
664+
ixz_prime = I_transformed[0, 2]
665+
iyz_prime = I_transformed[1, 2]
666+
667+
# Transform the Origin (Don't replace the original in case the user keeps the inplace flag false)
668+
Origin_prime = self.origin.transform(tf_matrix)
669+
670+
# Update values and (if requested) put the extracted values into a new_InertialLink
671+
if inplace:
672+
# mass stays the same :-) ==> self.mass = new_InertialLink.mass
673+
self.inertia.ixx = ixx_prime
674+
self.inertia.iyy = iyy_prime
675+
self.inertia.izz = izz_prime
676+
self.inertia.ixy = ixy_prime
677+
self.inertia.ixz = ixz_prime
678+
self.inertia.iyz = iyz_prime
679+
self.origin = Origin_prime
680+
return None
681+
else:
682+
new_InertialLink = InertialLink(mass=self.mass, inertia=Inertia(ixx_prime, iyy_prime, izz_prime, ixy_prime, ixz_prime, iyz_prime), origin=Origin_prime)
683+
return new_InertialLink
684+
605685
@classmethod
606686
def from_xml(cls, xml: ET.Element) -> "InertialLink":
607687
"""
@@ -661,7 +741,7 @@ class VisualLink:
661741
<Element 'visual' at 0x7f8b3c0b4c70>
662742
"""
663743

664-
name: str
744+
name: Union[str, None]
665745
origin: Origin
666746
geometry: BaseGeometry
667747
material: Material
@@ -700,7 +780,8 @@ def to_xml(self, root: Optional[ET.Element] = None) -> ET.Element:
700780
<Element 'visual' at 0x7f8b3c0b4c70>
701781
"""
702782
visual = ET.Element("visual") if root is None else ET.SubElement(root, "visual")
703-
visual.set("name", self.name)
783+
if self.name:
784+
visual.set("name", self.name)
704785
self.origin.to_xml(visual)
705786
self.geometry.to_xml(visual)
706787
self.material.to_xml(visual)
@@ -722,7 +803,8 @@ def to_mjcf(self, root: ET.Element) -> None:
722803
<Element 'visual' at 0x7f8b3c0b4c70>
723804
"""
724805
visual = root if root.tag == "geom" else ET.SubElement(root, "geom")
725-
visual.set("name", self.name)
806+
if self.name:
807+
visual.set("name", self.name)
726808
# TODO: Parent body uses visual origin, these share the same?
727809
self.origin.to_mjcf(visual)
728810

@@ -754,7 +836,6 @@ def from_xml(cls, xml: ET.Element) -> "VisualLink":
754836
VisualLink(name='visual', origin=None, geometry=None, material=None)
755837
"""
756838
name = xml.get("name")
757-
758839
origin_element = xml.find("origin")
759840
origin = Origin.from_xml(origin_element) if origin_element is not None else None
760841

@@ -784,7 +865,7 @@ class CollisionLink:
784865
<Element 'collision' at 0x7f8b3c0b4c70>
785866
"""
786867

787-
name: str
868+
name: Union[str, None]
788869
origin: Origin
789870
geometry: BaseGeometry
790871

@@ -824,7 +905,8 @@ def to_xml(self, root: Optional[ET.Element] = None) -> ET.Element:
824905
<Element 'collision' at 0x7f8b3c0b4c70>
825906
"""
826907
collision = ET.Element("collision") if root is None else ET.SubElement(root, "collision")
827-
collision.set("name", self.name)
908+
if self.name:
909+
collision.set("name", self.name)
828910
self.origin.to_xml(collision)
829911
self.geometry.to_xml(collision)
830912
return collision
@@ -858,7 +940,8 @@ def to_mjcf(self, root: ET.Element) -> None:
858940
<Element 'collision' at 0x7f8b3c0b4c70>
859941
"""
860942
collision = root if root.tag == "geom" else ET.SubElement(root, "geom")
861-
collision.set("name", self.name)
943+
if self.name:
944+
collision.set("name", self.name)
862945
collision.set("contype", "1")
863946
collision.set("conaffinity", "1")
864947
self.origin.to_mjcf(collision)

onshape_robotics_toolkit/parse.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,7 @@
3131
from onshape_robotics_toolkit.models.document import WorkspaceType
3232
from onshape_robotics_toolkit.utilities.helpers import get_sanitized_name
3333

34-
os.environ["TCL_LIBRARY"] = "C:\\Users\\imsen\\AppData\\Local\\Programs\\Python\\Python313\\tcl\\tcl8.6"
35-
os.environ["TK_LIBRARY"] = "C:\\Users\\imsen\\AppData\\Local\\Programs\\Python\\Python313\\tcl\\tk8.6"
36-
37-
SUBASSEMBLY_JOINER = "-SUB-"
34+
SUBASSEMBLY_JOINER = "_SUB_"
3835
MATE_JOINER = "_to_"
3936

4037
CHILD = 0

onshape_robotics_toolkit/urdf.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def get_robot_link(
140140
_link = Link(
141141
name=name,
142142
visual=VisualLink(
143-
name=f"{name}-visual",
143+
name=f"{name}_visual",
144144
origin=_origin,
145145
geometry=MeshGeometry(_mesh_path),
146146
material=Material.from_color(name=f"{name}-material", color=random.SystemRandom().choice(list(Colors))),
@@ -161,7 +161,7 @@ def get_robot_link(
161161
),
162162
),
163163
collision=CollisionLink(
164-
name=f"{name}-collision",
164+
name=f"{name}_collision",
165165
origin=_origin,
166166
geometry=MeshGeometry(_mesh_path),
167167
),
@@ -269,15 +269,15 @@ def get_robot_joint(
269269

270270
elif mate.mateType == MateType.BALL:
271271
dummy_x = Link(
272-
name=f"{parent}-{get_sanitized_name(mate.name)}-x",
272+
name=f"{parent}_{get_sanitized_name(mate.name)}_x",
273273
inertial=InertialLink(
274274
mass=0.0,
275275
inertia=Inertia.zero_inertia(),
276276
origin=Origin.zero_origin(),
277277
),
278278
)
279279
dummy_y = Link(
280-
name=f"{parent}-{get_sanitized_name(mate.name)}-y",
280+
name=f"{parent}_{get_sanitized_name(mate.name)}_y",
281281
inertial=InertialLink(
282282
mass=0.0,
283283
inertia=Inertia.zero_inertia(),
@@ -289,7 +289,7 @@ def get_robot_joint(
289289

290290
return [
291291
RevoluteJoint(
292-
name=sanitized_name + "-x",
292+
name=sanitized_name + "_x",
293293
parent=parent,
294294
child=dummy_x.name,
295295
origin=origin,
@@ -304,7 +304,7 @@ def get_robot_joint(
304304
mimic=mimic,
305305
),
306306
RevoluteJoint(
307-
name=sanitized_name + "-y",
307+
name=sanitized_name + "_y",
308308
parent=dummy_x.name,
309309
child=dummy_y.name,
310310
origin=Origin.zero_origin(),
@@ -319,7 +319,7 @@ def get_robot_joint(
319319
mimic=mimic,
320320
),
321321
RevoluteJoint(
322-
name=sanitized_name + "-z",
322+
name=sanitized_name + "_z",
323323
parent=dummy_y.name,
324324
child=child,
325325
origin=Origin.zero_origin(),

onshape_robotics_toolkit/utilities/helpers.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -278,33 +278,46 @@ def make_unique_name(name: str, existing_names: set[str]) -> str:
278278
return f"{name}-{count}"
279279

280280

281-
def get_sanitized_name(name: str, replace_with: str = "-") -> str:
281+
def get_sanitized_name(name: str, replace_with: str = "_", remove_onshape_tags: bool = False) -> str:
282282
"""
283-
Sanitize a name by removing special characters, preserving "-" and "_", and
284-
replacing spaces with a specified character. Ensures no consecutive replacement
285-
characters in the result.
283+
Sanitize a name by removing special characters, preserving only the specified
284+
replacement character, and replacing spaces with it. Ensures no consecutive
285+
replacement characters in the result.
286+
287+
Optionally preserves a trailing " <n>" tag where n is a number.
286288
287289
Args:
288-
name: Name to sanitize
289-
replace_with: Character to replace spaces with (default is '-')
290+
name (str): Name to sanitize.
291+
replace_with (str): Character to replace spaces and other special characters with (default is '_').
292+
remove_onshape_tags (bool): If True, removes a trailing " <n>" tag where n is a number. Default is False.
290293
291294
Returns:
292-
Sanitized name
295+
str: Sanitized name.
293296
294297
Examples:
295-
>>> get_sanitized_name("wheel1 <3>", '-')
298+
>>> get_sanitized_name("wheel1 <3>")
299+
"wheel1"
300+
301+
>>> get_sanitized_name("wheel1 <3>", remove_onshape_tags=False)
302+
"wheel1_3"
303+
304+
>>> get_sanitized_name("wheel1 <3>", replace_with='-', remove_onshape_tags=False)
296305
"wheel1-3"
297-
>>> get_sanitized_name("Hello World!", '_')
298-
"Hello_World"
299-
>>> get_sanitized_name("my--robot!!", '-')
300-
"my-robot"
301-
>>> get_sanitized_name("bad__name__", '_')
302-
"bad_name"
303306
"""
304307

305308
if replace_with not in "-_":
306309
raise ValueError("replace_with must be either '-' or '_'")
307310

311+
tag = ""
312+
if remove_onshape_tags:
313+
# Regular expression to detect a trailing " <n>" where n is one or more digits
314+
tag_pattern = re.compile(r"\s<\d+>$")
315+
match = tag_pattern.search(name)
316+
if match:
317+
tag = match.group() # e.g., " <3>"
318+
if tag:
319+
name = name[: match.start()]
320+
308321
sanitized_name = "".join(char if char.isalnum() or char in "-_ " else "" for char in name)
309322
sanitized_name = sanitized_name.replace(" ", replace_with)
310323
sanitized_name = re.sub(f"{re.escape(replace_with)}{{2,}}", replace_with, sanitized_name)
@@ -333,4 +346,4 @@ def save_gif(frames, filename="sim.gif", framerate=60):
333346

334347

335348
if __name__ == "__main__":
336-
LOGGER.info(get_sanitized_name(input("Enter a name: ")))
349+
LOGGER.info(get_sanitized_name("Part 3 <1>", remove_onshape_tags=True))

0 commit comments

Comments
 (0)