"""UI Panel for managing robot joints."""
from __future__ import annotations
import contextlib
import bpy
from bpy.types import Context, Panel
from ..core.constants import (
JOINT_CONTINUOUS,
JOINT_PRISMATIC,
JOINT_REVOLUTE,
)
from ..utils.property_helpers import get_joint_props, get_link_props
[docs]
class LINKFORGE_PT_joints(Panel):
"""Panel for robot joint properties in 3D Viewport sidebar."""
bl_label = "Joints"
bl_idname = "LINKFORGE_PT_joints"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "LinkForge"
bl_parent_id = "LINKFORGE_PT_forge"
bl_order = 2
[docs]
def draw(self, context: Context) -> None:
"""Draw the panel."""
layout = self.layout
if not layout:
return
obj = context.active_object
# Check if selected object is a joint (edit mode vs create mode)
is_joint = bool(
obj
and obj.select_get()
and obj.type == "EMPTY"
and (jp := get_joint_props(obj))
and jp.is_robot_joint
)
# Only show Create button when NOT editing a joint
if not is_joint:
# Detect target link (either selected link or parent of selected visual)
target_link = None
if obj and obj.select_get():
if (lp := get_link_props(obj)) and lp.is_robot_link:
target_link = obj
elif obj.parent and (lp_p := get_link_props(obj.parent)) and lp_p.is_robot_link:
# Selected object is a visual/collision child of a link
target_link = obj.parent
# Create Joint button (creation mode)
box = layout.box()
row = box.row()
row.enabled = target_link is not None
row.operator("linkforge.create_joint", icon="ADD", text="Create Joint")
# Show joint properties only if a joint is selected (edit mode)
if not is_joint or not obj or not (props := get_joint_props(obj)):
return
# Joint properties
box = layout.box()
box.label(text=f"Joint: {props.joint_name}", icon="EMPTY_ARROWS")
# Display Blender object name if it differs from persistent joint name
if obj.name != props.joint_name:
sub = box.row()
sub.active = False
sub.label(text=f"Blender Obj: {obj.name}", icon="INFO")
box.prop(props, "joint_name")
# Joint type
box.prop(props, "joint_type")
# Parent and child
box.separator()
row = box.row()
row.label(text="Connection:", icon="LINKED")
row.operator("linkforge.auto_detect_parent_child", icon="AUTO", text="Detect")
# Parent Link with 1-click select
p_row = box.row(align=True)
p_row.prop(props, "parent_link", icon="OUTLINER_OB_EMPTY")
if props.parent_link:
op = p_row.operator("linkforge.select_tree_object", text="", icon="RESTRICT_SELECT_OFF")
op.object_name = props.parent_link.name
op.object_type = "link"
# Child Link with 1-click select
c_row = box.row(align=True)
c_row.prop(props, "child_link", icon="OUTLINER_OB_EMPTY")
if props.child_link:
op = c_row.operator("linkforge.select_tree_object", text="", icon="RESTRICT_SELECT_OFF")
op.object_name = props.child_link.name
op.object_type = "link"
# Joint axis (only for revolute, continuous, prismatic)
if props.joint_type in {JOINT_REVOLUTE, JOINT_CONTINUOUS, JOINT_PRISMATIC}:
box.separator()
box.label(text="Axis:", icon="ORIENTATION_GIMBAL")
box.prop(props, "axis", expand=True)
if props.axis == "CUSTOM":
col = box.column(align=True)
col.prop(props, "custom_axis_x", text="X")
col.prop(props, "custom_axis_y", text="Y")
col.prop(props, "custom_axis_z", text="Z")
# Joint limits (dynamic based on joint type per robot model spec)
if props.joint_type in {JOINT_REVOLUTE, JOINT_PRISMATIC}:
# REVOLUTE/PRISMATIC: Limits are REQUIRED (no checkbox)
box.separator()
col = box.column(align=True)
col.label(text="Limits (Required):", icon="DRIVER_DISTANCE")
col.prop(props, "limit_lower")
col.prop(props, "limit_upper")
col.prop(props, "limit_effort")
col.prop(props, "limit_velocity")
elif props.joint_type == JOINT_CONTINUOUS:
# CONTINUOUS: Limits are OPTIONAL (show checkbox, only effort/velocity)
box.separator()
box.prop(props, "use_limits", text="Use Limits (Optional)")
if props.use_limits:
col = box.column(align=True)
col.label(text="Effort & Velocity Limits:", icon="DRIVER_DISTANCE")
col.prop(props, "limit_effort")
col.prop(props, "limit_velocity")
# FIXED/FLOATING/PLANAR: No limits section (not allowed per robot model spec)
# Joint Dynamics settings (optional)
box.separator()
box.prop(props, "use_dynamics")
if props.use_dynamics:
col = box.column(align=True)
col.prop(props, "dynamics_damping")
col.prop(props, "dynamics_friction")
# Joint Mimic settings (optional)
box.separator()
box.prop(props, "use_mimic")
if props.use_mimic:
box.prop(props, "mimic_joint")
col = box.column(align=True)
col.prop(props, "mimic_multiplier")
col.prop(props, "mimic_offset")
# Joint Safety Controller (optional)
box.separator()
box.prop(props, "use_safety_controller")
if props.use_safety_controller:
col = box.column(align=True)
col.prop(props, "safety_soft_lower_limit")
col.prop(props, "safety_soft_upper_limit")
col.prop(props, "safety_k_position")
col.prop(props, "safety_k_velocity")
# Joint Calibration (optional)
box.separator()
box.prop(props, "use_calibration")
if props.use_calibration:
col = box.column(align=True)
# Rising edge with its own toggle
row = col.row(align=True)
row.prop(props, "use_calibration_rising", text="")
sub = row.row()
sub.active = props.use_calibration_rising
sub.prop(props, "calibration_rising", text="Rising")
# Falling edge with its own toggle
row = col.row(align=True)
row.prop(props, "use_calibration_falling", text="")
sub = row.row()
sub.active = props.use_calibration_falling
sub.prop(props, "calibration_falling", text="Falling")
# Remove Joint button (Danger Zone)
box.separator()
box.separator()
row = box.row()
row.scale_y = 1.2 # Make it slightly bigger
row.operator("linkforge.delete_joint", icon="TRASH", text="Remove Joint")
# Registration
classes = [
LINKFORGE_PT_joints,
]
[docs]
def register() -> None:
"""Register panel."""
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 panel."""
for cls in reversed(classes):
with contextlib.suppress(RuntimeError):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()