Source code for linkforge.blender.properties.joint_props

"""Blender Property Groups for robot joints.

These properties are stored on Empty objects and define joint characteristics.
"""

from __future__ import annotations

import typing

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 (
    DEFAULT_JOINT_DAMPING,
    DEFAULT_JOINT_EFFORT,
    DEFAULT_JOINT_FRICTION,
    DEFAULT_JOINT_TYPE,
    DEFAULT_JOINT_VELOCITY,
    JOINT_CONTINUOUS,
    JOINT_FIXED,
    JOINT_FLOATING,
    JOINT_PLANAR,
    JOINT_PRISMATIC,
    JOINT_REVOLUTE,
    PI,
)

from ..constants import (
    PROP_JOINT,
)
from ..utils.property_helpers import find_property_owner, get_link_props
from ..utils.scene_utils import clear_stats_cache


[docs] def get_joint_name(self: JointPropertyGroup) -> str: """Getter for joint_name - returns the persistent source identity. Args: self: The JointPropertyGroup 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_joint_name(self: JointPropertyGroup, value: str) -> None: """Setter for joint_name - updates persistent identity and object name. Args: self: The JointPropertyGroup instance. value: The new name value to set. """ if not value or not self.id_data: return # Sanitize joint name for robot model sanitized_name = sanitize_name(value) # Store the persistent identity self.source_name_stored = sanitized_name # Update object name to match joint name # Blender will handle collisions by appending suffixes, but our stored name persists if self.id_data.name != sanitized_name: try: self.id_data.name = sanitized_name except AttributeError: # We are likely in a depsgraph update where names are read-only. import bpy if not bpy.app.background and hasattr(bpy.app, "timers"): # GUI mode: Use a standard timer def deferred_rename() -> None: import contextlib if self.id_data: with contextlib.suppress(Exception): self.id_data.name = sanitized_name return None bpy.app.timers.register(deferred_rename, first_interval=0.01) else: # Background mode: Use our internal queue from ..handlers.name_sync_handler import PENDING_RENAMES PENDING_RENAMES.append((self.id_data, sanitized_name)) # Clear statistics cache when name changes clear_stats_cache()
[docs] def update_joint_hierarchy(self: JointPropertyGroup, context: Context) -> None: """Update Blender object hierarchy when parent/child links change. Establishes hierarchy: parent_link → joint → child_link This matches import behavior and shows kinematic tree in outliner. """ if not bpy: return # Find the joint object that owns this property joint_obj = find_property_owner(context, self, PROP_JOINT) if joint_obj is None or not self.is_robot_joint: return from ..utils.transform_utils import ( clear_parent_keep_transform, set_parent_keep_transform, ) # Parent-child objects directly from pointers parent_obj = self.parent_link child_obj = self.child_link # Handle parent link hierarchy if parent_obj: # Parent the Joint to the Parent Link set_parent_keep_transform(joint_obj, parent_obj) # Move to parent's collection (organization) from ..utils.scene_utils import sync_object_collections sync_object_collections(joint_obj, parent_obj) elif joint_obj.parent: # Clear parent (unparent joint) while preserving world position clear_parent_keep_transform(joint_obj) # Handle child link hierarchy if child_obj: # Parent the Child Link to the Joint set_parent_keep_transform(child_obj, joint_obj) else: # Find and unparent any child link that was parented to this joint scene = context.scene if scene: for obj in scene.objects: if ( obj.parent == joint_obj and (props := get_link_props(obj)) and props.is_robot_link ): # Clear parent while preserving world position clear_parent_keep_transform(obj) break # Only unparent one child # Clear statistics cache when hierarchy changes clear_stats_cache(self, context)
[docs] def poll_robot_joint(self: JointPropertyGroup, obj: bpy.types.Object) -> bool: """Filter to only allow other robot joint objects in pointer selection.""" if not obj or obj.type != "EMPTY": return False joint_props = getattr(obj, PROP_JOINT, None) if not joint_props or not joint_props.is_robot_joint: return False # Prevent self-mimicry # We compare the objects that own the properties # find_property_owner is imported at the top current_obj = find_property_owner(bpy.context, self, PROP_JOINT) return bool(obj != current_obj)
[docs] class JointPropertyGroup(PropertyGroup): """Properties for a robot joint stored on an Empty object.""" # Joint identification is_robot_joint: BoolProperty( # type: ignore name="Is Robot Joint", description="Mark this Empty as a robot joint", default=False, ) # Persistent source Identity # Decouples logical robot model naming from physical Blender object names (resilient to .001 suffixes) source_name_stored: StringProperty( # type: ignore name="Source Name", description="Persistent source name. Prevents mapping breakage if Blender renames the object", default="", ) joint_name: StringProperty( # type: ignore name="Joint Name", description="Name of the joint in robot model (must be unique)", maxlen=64, get=get_joint_name, set=set_joint_name, update=clear_stats_cache, ) # Joint type joint_type: EnumProperty( # type: ignore name="Joint Type", description="Type of joint connection", items=[ (JOINT_REVOLUTE, "Revolute", "Rotates around axis with limits"), (JOINT_CONTINUOUS, "Continuous", "Rotates around axis without limits"), (JOINT_PRISMATIC, "Prismatic", "Slides along axis with limits"), (JOINT_FIXED, "Fixed", "No motion allowed"), (JOINT_FLOATING, "Floating", "6 DOF free in space"), (JOINT_PLANAR, "Planar", "2D motion in a plane"), ], default=DEFAULT_JOINT_TYPE, update=clear_stats_cache, ) # Parent and child links parent_link: PointerProperty( # type: ignore name="Parent Link", description="Link this joint connects from (base side)", type=bpy.types.Object, poll=poll_robot_link, update=update_joint_hierarchy, ) child_link: PointerProperty( # type: ignore name="Child Link", description="Link this joint connects to (moving side)", type=bpy.types.Object, poll=poll_robot_link, update=update_joint_hierarchy, ) # Joint axis axis: EnumProperty( # type: ignore name="Axis", description="Which direction the joint moves (rotation or sliding axis)", items=[ ("X", "X", "X axis (red)"), ("Y", "Y", "Y axis (green)"), ("Z", "Z", "Z axis (blue)"), ("CUSTOM", "Custom", "Custom axis direction"), ], default="Z", ) # Custom axis (when axis is CUSTOM) # Note: No min/max limits - values will be automatically normalized to unit vector custom_axis_x: FloatProperty( # type: ignore name="Axis X", description="Custom axis X component (will be normalized to unit vector)", default=0.0, soft_min=-10.0, soft_max=10.0, ) custom_axis_y: FloatProperty( # type: ignore name="Axis Y", description="Custom axis Y component (will be normalized to unit vector)", default=0.0, soft_min=-10.0, soft_max=10.0, ) custom_axis_z: FloatProperty( # type: ignore name="Axis Z", description="Custom axis Z component (will be normalized to unit vector)", default=1.0, soft_min=-10.0, soft_max=10.0, ) # Joint limits (for revolute and prismatic) use_limits: BoolProperty( # type: ignore name="Use Limits", description="Restrict how far the joint can move (safety limits)", default=False, ) limit_lower: FloatProperty( # type: ignore name="Lower Limit", description="Minimum position (radians for revolute/continuous joints, meters for prismatic joints)", default=-PI, soft_min=-2 * PI, soft_max=2 * PI, ) limit_upper: FloatProperty( # type: ignore name="Upper Limit", description="Maximum position (radians for revolute/continuous joints, meters for prismatic joints)", default=PI, soft_min=-2 * PI, soft_max=2 * PI, ) limit_effort: FloatProperty( # type: ignore name="Max Effort", description="Maximum force/torque the joint motor can apply", default=DEFAULT_JOINT_EFFORT, min=0.0, soft_max=100.0, ) limit_velocity: FloatProperty( # type: ignore name="Max Velocity", description="Maximum speed the joint can move", default=DEFAULT_JOINT_VELOCITY, min=0.0, soft_max=10.0, ) # Joint dynamics use_dynamics: BoolProperty( # type: ignore name="Use Dynamics", description="Add friction and damping for realistic motion (optional)", default=False, ) dynamics_damping: FloatProperty( # type: ignore name="Damping", description="Resistance to motion (slows down movement)", default=DEFAULT_JOINT_DAMPING, min=0.0, soft_max=10.0, ) dynamics_friction: FloatProperty( # type: ignore name="Friction", description="Static friction (resistance to starting motion)", default=DEFAULT_JOINT_FRICTION, min=0.0, soft_max=10.0, ) # Mimic joint use_mimic: BoolProperty( # type: ignore name="Mimic Another Joint", description="Make this joint copy another joint's movement (like coupled fingers)", default=False, ) mimic_joint: PointerProperty( # type: ignore name="Mimic Joint", description="Which joint to copy movement from", type=bpy.types.Object, poll=poll_robot_joint, ) mimic_multiplier: FloatProperty( # type: ignore name="Multiplier", description="Movement scale (2.0 = moves twice as much, 0.5 = half as much)", default=1.0, ) mimic_offset: FloatProperty( # type: ignore name="Offset", description="Position offset added to mimic joint movement (applied after multiplier)", default=0.0, ) # Joint Safety Controller use_safety_controller: BoolProperty( # type: ignore name="Use Safety Controller", description="Add a safety controller to the joint (standard robot model feature)", default=False, ) safety_soft_lower_limit: FloatProperty( # type: ignore name="Soft Lower Limit", description="Lower bound of the joint safety controller", default=0.0, ) safety_soft_upper_limit: FloatProperty( # type: ignore name="Soft Upper Limit", description="Upper bound of the joint safety controller", default=0.0, ) safety_k_position: FloatProperty( # type: ignore name="K Position", description="Position gain for safety controller", default=0.0, ) safety_k_velocity: FloatProperty( # type: ignore name="K Velocity", description="Velocity gain for safety controller", default=0.0, ) # Joint Calibration use_calibration: BoolProperty( # type: ignore name="Use Calibration", description="Add calibration settings to the joint", default=False, ) calibration_rising: FloatProperty( # type: ignore name="Rising Edge", description="Position of the rising edge (optional)", default=0.0, ) use_calibration_rising: BoolProperty( # type: ignore name="Specify Rising Edge", description="Whether to include the rising edge in calibration", default=False, ) calibration_falling: FloatProperty( # type: ignore name="Falling Edge", description="Position of the falling edge (optional)", default=0.0, ) use_calibration_falling: BoolProperty( # type: ignore name="Specify Falling Edge", description="Whether to include the falling edge in calibration", default=False, )
# Registration
[docs] def register() -> None: """Register property group.""" try: bpy.utils.register_class(JointPropertyGroup) except ValueError: # If already registered (e.g. from reload), unregister first to ensure clean state bpy.utils.unregister_class(JointPropertyGroup) bpy.utils.register_class(JointPropertyGroup) prop_name = PROP_JOINT setattr( bpy.types.Object, prop_name, typing.cast(typing.Any, PointerProperty(type=JointPropertyGroup)), )
[docs] def unregister() -> None: """Unregister property group.""" import contextlib with contextlib.suppress(AttributeError): delattr(bpy.types.Object, PROP_JOINT) with contextlib.suppress(RuntimeError): bpy.utils.unregister_class(JointPropertyGroup)
if __name__ == "__main__": register()