"""Blender Property Groups for robot transmissions.
These properties are stored on Empty objects and define transmission characteristics
for ros2_control integration.
"""
from __future__ import annotations
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
from linkforge.core.constants import (
HW_IF_EFFORT,
HW_IF_POSITION,
HW_IF_VELOCITY,
TRANS_CUSTOM,
TRANS_DIFFERENTIAL,
TRANS_FOUR_BAR,
TRANS_SIMPLE,
)
from ..constants import (
PROP_TRANSMISSION,
)
from ..utils.property_helpers import find_property_owner, get_joint_props
from ..utils.scene_utils import clear_stats_cache
[docs]
def get_transmission_name(self: TransmissionPropertyGroup) -> str:
"""Getter for transmission_name - returns the persistent robot model identity.
Args:
self: The TransmissionPropertyGroup instance.
Returns:
The sanitized robot model name.
"""
# Prioritize the stored identity to avoid Blender's .001 suffixing
if self.source_name_stored:
return str(self.source_name_stored)
if not self.id_data:
return ""
return sanitize_name(str(self.id_data.name))
[docs]
def set_transmission_name(self: TransmissionPropertyGroup, value: str) -> None:
"""Setter for transmission_name - updates persistent identity and object name.
Args:
self: The TransmissionPropertyGroup instance.
value: The new name value to set.
"""
if not value or not self.id_data:
return
# Sanitize transmission name for URDF
sanitized_name = sanitize_name(value)
# Store the persistent identity
self.source_name_stored = sanitized_name
# Update object name to match transmission name
if self.id_data.name != sanitized_name:
# Block handler loop: We only update if they differ already
self.id_data.name = sanitized_name
[docs]
def update_transmission_hierarchy(self: TransmissionPropertyGroup, context: Context) -> None:
"""Update Blender object hierarchy when joint changes.
Automatically reparents transmission to new joint and moves to joint's collection.
This ensures visual hierarchy matches logical structure.
Args:
self: The property group instance.
context: The current Blender context.
"""
# Find the transmission object that owns this property
transmission_obj = find_property_owner(context, self, PROP_TRANSMISSION)
if transmission_obj is None or not self.is_robot_transmission:
return
# Determine which joint to use based on transmission type
joint_obj = (
self.joint1_name if self.transmission_type == TRANS_DIFFERENTIAL else self.joint_name
)
# Reparent transmission to the joint
if joint_obj:
transmission_obj.parent = joint_obj
# STRICT ALIGNMENT:
# We want the transmission to be exactly at the joint origin.
transmission_obj.matrix_parent_inverse.identity()
# Reset local position to be at joint origin
transmission_obj.location = (0, 0, 0)
transmission_obj.rotation_euler = (0, 0, 0)
# Move transmission to same collection as joint (for clean organization)
from ..utils.scene_utils import sync_object_collections
sync_object_collections(transmission_obj, joint_obj)
elif transmission_obj.parent:
# Clear parent (unparent transmission) while preserving world position
from ..utils.transform_utils import clear_parent_keep_transform
clear_parent_keep_transform(transmission_obj)
[docs]
def poll_robot_joint(_self: TransmissionPropertyGroup, obj: bpy.types.Object) -> bool:
"""Filter to only allow robot joint objects in pointer selection.
Args:
self: The property group instance.
obj: The object to check.
Returns:
True if the object is a valid robot joint.
"""
return bool(obj and (props := get_joint_props(obj)) and props.is_robot_joint)
[docs]
class TransmissionPropertyGroup(PropertyGroup):
"""Properties for a robot transmission stored on an Empty object."""
# Transmission identification
is_robot_transmission: BoolProperty( # type: ignore
name="Is Robot Transmission",
description="Mark this object as a robot transmission",
default=False,
)
# Persistent robot model Identity
# Decouples logical robot model naming from physical Blender object names (resilient to .001 suffixes)
source_name_stored: StringProperty( # type: ignore
name="robot model Name",
description="Persistent robot model name. Prevents mapping breakage if Blender renames the object",
default="",
)
transmission_name: StringProperty( # type: ignore
name="Transmission Name",
description="Name of the transmission in URDF (must be unique)",
maxlen=64,
get=get_transmission_name,
set=set_transmission_name,
update=clear_stats_cache,
)
# Transmission type
transmission_type: EnumProperty( # type: ignore
name="Transmission Type",
description="Type of transmission mechanism",
items=[
(TRANS_SIMPLE, "Simple", "1:1 joint to actuator transmission"),
(
TRANS_DIFFERENTIAL,
"Differential",
"2 joints to 2 actuators (differential drive)",
),
(TRANS_FOUR_BAR, "Four-Bar Linkage", "Four-bar linkage transmission"),
(TRANS_CUSTOM, "Custom", "Custom transmission type"),
],
default=TRANS_SIMPLE,
)
# Custom type (when transmission_type is CUSTOM)
custom_type: StringProperty( # type: ignore
name="Custom Type",
description="Custom transmission type identifier",
default="",
maxlen=128,
)
# Joint selection (for simple transmission)
joint_name: PointerProperty( # type: ignore
name="Joint",
description="Joint controlled by this transmission",
type=bpy.types.Object,
poll=poll_robot_joint,
update=update_transmission_hierarchy,
)
# Joint selection (for differential transmission)
joint1_name: PointerProperty( # type: ignore
name="Joint 1",
description="First joint in differential transmission",
type=bpy.types.Object,
poll=poll_robot_joint,
update=update_transmission_hierarchy,
)
joint2_name: PointerProperty( # type: ignore
name="Joint 2",
description="Second joint in differential transmission",
type=bpy.types.Object,
poll=poll_robot_joint,
)
# Hardware interface
hardware_interface: EnumProperty( # type: ignore
name="Hardware Interface",
description="Control interface type for ROS2 Control",
items=[
(HW_IF_POSITION, "Position", "Position control interface"),
(HW_IF_VELOCITY, "Velocity", "Velocity interface"),
(HW_IF_EFFORT, "Effort", "Effort/torque control interface"),
],
default=HW_IF_POSITION,
)
# Mechanical properties
mechanical_reduction: FloatProperty( # type: ignore
name="Mechanical Reduction",
description="Gear reduction ratio (actuator/joint)",
default=1.0,
min=0.001,
soft_max=1000.0,
precision=3,
)
offset: FloatProperty( # type: ignore
name="Offset",
description="Joint offset in radians or meters",
default=0.0,
precision=5,
)
# Actuator naming
use_custom_actuator_name: BoolProperty( # type: ignore
name="Custom Actuator Name",
description="Use custom actuator name instead of auto-generated",
default=False,
)
actuator_name: StringProperty( # type: ignore
name="Actuator Name",
description="Name of the actuator (motor)",
default="",
maxlen=64,
)
# For differential transmissions
actuator1_name: StringProperty( # type: ignore
name="Actuator 1 Name",
description="Name of the first actuator",
default="",
maxlen=64,
)
actuator2_name: StringProperty( # type: ignore
name="Actuator 2 Name",
description="Name of the second actuator",
default="",
maxlen=64,
)
# Registration
[docs]
def register() -> None:
"""Register property group."""
try:
bpy.utils.register_class(TransmissionPropertyGroup)
except ValueError:
# If already registered (e.g. from reload), unregister first to ensure clean state
bpy.utils.unregister_class(TransmissionPropertyGroup)
bpy.utils.register_class(TransmissionPropertyGroup)
setattr(
bpy.types.Object,
PROP_TRANSMISSION,
PointerProperty(type=TransmissionPropertyGroup), # type: ignore[func-returns-value]
)
[docs]
def unregister() -> None:
"""Unregister property group."""
import contextlib
with contextlib.suppress(AttributeError):
delattr(bpy.types.Object, PROP_TRANSMISSION)
with contextlib.suppress(RuntimeError):
bpy.utils.unregister_class(TransmissionPropertyGroup)
if __name__ == "__main__":
register()