"""Add a joint to the ros2_control system.
This operator allows users to select a joint from the robot's kinematic
tree and include it in the ROS 2 control configuration, setting up
default command and state interfaces.
"""
from __future__ import annotations
import typing
import bpy
from bpy.props import IntProperty, StringProperty
from ..utils.decorators import OperatorReturn, safe_execute
from ..utils.property_helpers import get_joint_props, get_robot_props
if typing.TYPE_CHECKING:
from bpy.types import Context, Operator
else:
# Runtime fallback for mock environments where bpy.types might be partially loaded.
Context = typing.Any
Operator = getattr(getattr(bpy, "types", object), "Operator", object)
[docs]
class LINKFORGE_OT_add_ros2_control_joint(Operator):
bl_idname = "linkforge.add_ros2_control_joint"
bl_label = "Add Joint"
bl_description = "Add a joint from the robot's kinematic tree to the control system"
bl_options = {"REGISTER", "UNDO"}
joint_name: StringProperty(name="Joint Name") # type: ignore
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if operators can run.
Args:
context: The current Blender context.
Returns:
True if the scene has LinkForge properties initialized.
"""
return bool(context.scene and get_robot_props(context.scene))
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the operator.
Args:
context: The execution context.
Returns:
Set containing the execution state (e.g., {'FINISHED'}).
"""
scene = context.scene
if not scene or not (props := get_robot_props(scene)):
return {"CANCELLED"}
# Find the target joint object we intend to add
target_joint_obj = next(
(
obj
for obj in scene.objects
if obj.type == "EMPTY"
and (jp := get_joint_props(obj))
and jp.is_robot_joint
and jp.joint_name == self.joint_name
),
None,
)
# Check if joint or its specific object already exists in collection
for item in props.ros2_control_joints:
# Check if name exactly matches OR physical object exactly matches
if item.name == self.joint_name or (
item.joint_obj is not None
and target_joint_obj is not None
and item.joint_obj == target_joint_obj
):
self.report(
{"WARNING"}, f"Joint '{self.joint_name}' is already in the control system"
)
return {"CANCELLED"}
# Add to collection
item = props.ros2_control_joints.add()
item.name = self.joint_name
item.joint_obj = target_joint_obj
# Set default interfaces
item.cmd_position = True
item.state_position = True
item.state_velocity = True
# Set as active index
props.ros2_control_active_joint_index = len(props.ros2_control_joints) - 1
self.report({"INFO"}, f"Added joint '{self.joint_name}' to control system")
return {"FINISHED"}
[docs]
class LINKFORGE_OT_remove_ros2_control_joint(Operator):
"""Remove a joint from the ros2_control system.
This operator removes the currently selected joint from the ROS 2
control configuration list.
"""
bl_idname = "linkforge.remove_ros2_control_joint"
bl_label = "Remove Joint"
bl_description = "Remove the selected joint from the control system"
bl_options = {"REGISTER", "UNDO"}
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if operators can run.
Args:
context: The current Blender context.
Returns:
True if there are joints in the control system.
"""
if not context.scene:
return False
props = get_robot_props(context.scene)
return bool(props and len(props.ros2_control_joints) > 0)
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the operator.
Args:
context: The execution context.
Returns:
Set containing the execution state.
"""
if not context.scene or not (props := get_robot_props(context.scene)):
return {"CANCELLED"}
index = props.ros2_control_active_joint_index
if 0 <= index < len(props.ros2_control_joints):
joint_name = props.ros2_control_joints[index].name
props.ros2_control_joints.remove(index)
# Update active index
props.ros2_control_active_joint_index = max(0, index - 1)
self.report({"INFO"}, f"Removed joint '{joint_name}' from control system")
return {"FINISHED"}
return {"CANCELLED"}
[docs]
class LINKFORGE_OT_move_ros2_control_joint(Operator):
"""Move a joint up or down in the control interface list.
This is a UI helper operator to reorder how joints appear in the
LinkForge control panel.
"""
bl_idname = "linkforge.move_ros2_control_joint"
bl_label = "Move Joint"
bl_description = "Move joint up or down in the list (cosmetic only)"
bl_options = {"REGISTER", "UNDO"}
direction: StringProperty() # type: ignore
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if operators can run.
Args:
context: The current Blender context.
Returns:
True if there are multiple joints to move.
"""
if not context.scene:
return False
props = get_robot_props(context.scene)
return bool(props and len(props.ros2_control_joints) > 1)
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the operator.
Args:
context: The execution context.
Returns:
Set containing the execution state.
"""
if not context.scene or not (props := get_robot_props(context.scene)):
return {"CANCELLED"}
index = props.ros2_control_active_joint_index
new_index = index
if self.direction == "UP" and index > 0:
new_index = index - 1
elif self.direction == "DOWN" and index < len(props.ros2_control_joints) - 1:
new_index = index + 1
else:
return {"CANCELLED"}
props.ros2_control_joints.move(index, new_index)
props.ros2_control_active_joint_index = new_index
return {"FINISHED"}
[docs]
class LINKFORGE_OT_add_ros2_control_parameter(Operator):
"""Add a parameter to ros2_control (global or joint).
This operator adds a new key-value pair to either the global hardware
parameters or the parameters of the currently selected joint.
"""
bl_idname = "linkforge.add_ros2_control_parameter"
bl_label = "Add Parameter"
bl_description = "Add a key-value parameter to the control system"
bl_options = {"REGISTER", "UNDO"}
target: StringProperty(default="GLOBAL") # type: ignore
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if the operator can be executed.
Args:
context: The current Blender context.
Returns:
True if LinkForge properties are initialized in the scene.
"""
return bool(context.scene and get_robot_props(context.scene))
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the addition of a parameter.
Args:
context: The execution context.
Returns:
Set containing the execution state.
"""
scene = context.scene
if not scene or not (props := get_robot_props(scene)):
return {"CANCELLED"}
if self.target == "GLOBAL":
param = props.ros2_control_parameters.add()
param.name = "param"
param.value = "0.0"
else:
# Joint-level
index = props.ros2_control_active_joint_index
if 0 <= index < len(props.ros2_control_joints):
joint = props.ros2_control_joints[index]
param = joint.parameters.add()
param.name = "param"
param.value = "0.0"
return {"FINISHED"}
[docs]
class LINKFORGE_OT_remove_ros2_control_parameter(Operator):
"""Remove a parameter from ros2_control.
This operator deletes a key-value pair from either the global
hardware parameters or a specific joint's parameter list.
"""
bl_idname = "linkforge.remove_ros2_control_parameter"
bl_label = "Remove Parameter"
bl_description = "Remove a hardware or joint parameter"
bl_options = {"REGISTER", "UNDO"}
target: StringProperty(default="GLOBAL") # type: ignore
index: IntProperty(default=-1) # type: ignore
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if the operator can be executed.
Args:
context: The current Blender context.
Returns:
True if LinkForge properties are initialized.
"""
return bool(context.scene and get_robot_props(context.scene))
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the removal of a parameter.
Args:
context: The execution context.
Returns:
Set containing the execution state.
"""
scene = context.scene
if not scene or not (props := get_robot_props(scene)):
return {"CANCELLED"}
if self.target == "GLOBAL":
items = props.ros2_control_parameters
idx = self.index
if 0 <= idx < len(items):
items.remove(idx)
else:
# Joint-level
joint_idx = props.ros2_control_active_joint_index
if 0 <= joint_idx < len(props.ros2_control_joints):
joint = props.ros2_control_joints[joint_idx]
items = joint.parameters
idx = self.index if self.index >= 0 else len(items) - 1
if 0 <= idx < len(items):
items.remove(idx)
return {"FINISHED"}
[docs]
class LINKFORGE_OT_purge_ros2_control_data(Operator):
"""Clear all joints and parameters from the ros2_control configuration."""
bl_idname = "linkforge.purge_ros2_control_data"
bl_label = "Purge Control Data"
bl_description = "Clear all joints and parameters from the control system"
bl_options = {"REGISTER", "UNDO"}
[docs]
@classmethod
def poll(cls, context: Context) -> bool:
"""Check if operators can run."""
return bool(context.scene and get_robot_props(context.scene))
[docs]
@safe_execute
def execute(self, context: Context) -> OperatorReturn:
"""Execute the purge."""
scene = context.scene
if not scene or not (props := get_robot_props(scene)):
return {"CANCELLED"}
props.ros2_control_joints.clear()
props.ros2_control_parameters.clear()
props.ros2_control_active_joint_index = 0
self.report({"INFO"}, "Purged all ROS 2 control data")
return {"FINISHED"}
# Registration
classes = [
LINKFORGE_OT_add_ros2_control_joint,
LINKFORGE_OT_remove_ros2_control_joint,
LINKFORGE_OT_move_ros2_control_joint,
LINKFORGE_OT_add_ros2_control_parameter,
LINKFORGE_OT_remove_ros2_control_parameter,
LINKFORGE_OT_purge_ros2_control_data,
]
[docs]
def register() -> None:
"""Register operators."""
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError:
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
[docs]
def unregister() -> None:
"""Unregister operators."""
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()