Source code for linkforge.blender.properties.link_props

"""Blender Property Groups for robot links.

These properties are stored on Blender objects and define link characteristics.
"""

from __future__ import annotations

import typing

import bpy
from bpy.props import (
    BoolProperty,
    FloatProperty,
    FloatVectorProperty,
    IntProperty,
    PointerProperty,
    StringProperty,
)
from bpy.types import Context, PropertyGroup

from ..constants import (
    PROP_LINK,
    SUFFIX_COLLISION,
    SUFFIX_VISUAL,
)
from ..core._utils.string_utils import (
    format_scientific,
    parse_scientific,
    sanitize_name,
)
from ..core.constants import (
    DEFAULT_CONTACT_KD,
    DEFAULT_CONTACT_KP,
    DEFAULT_FRICTION_MU,
    DEFAULT_FRICTION_MU2,
    DEFAULT_LINK_MASS,
    DEFAULT_SELF_COLLIDE,
    GRAVITY_ENABLED,
)
from ..utils.link_utils import should_rename_child
from ..utils.scene_utils import clear_stats_cache
from ..visualization.inertia_gizmos import tag_redraw


def get_kp_scientific(self: LinkPropertyGroup) -> str:
    """Getter for kp in scientific notation."""
    return format_scientific(self.kp)


def set_kp_scientific(self: LinkPropertyGroup, value: str) -> None:
    """Setter for kp in scientific notation."""
    self.kp = parse_scientific(value, self.kp)


def get_kd_scientific(self: LinkPropertyGroup) -> str:
    """Getter for kd in scientific notation."""
    return format_scientific(self.kd)


def set_kd_scientific(self: LinkPropertyGroup, value: str) -> None:
    """Setter for kd in scientific notation."""
    self.kd = parse_scientific(value, self.kd)


def get_link_name(self: LinkPropertyGroup) -> str:
    """Getter for link_name - returns the persistent source identity.

    Args:
        self: The LinkPropertyGroup instance.

    Returns:
        The sanitized robot component 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))


def set_link_name(self: LinkPropertyGroup, value: str) -> None:
    """Setter for link_name - updates persistent identity and object name.

    Args:
        self: The LinkPropertyGroup instance.
        value: The new name value to set.
    """
    if not value or not self.id_data:
        return

    # Sanitize link name for robot model (remove invalid characters)
    sanitized_name = sanitize_name(value)

    # Store the old name before updating for child renaming logic
    old_source_name = getattr(self, "source_name_stored", "") or sanitize_name(self.id_data.name)

    # Store the persistent identity
    self.source_name_stored = sanitized_name

    # Update object name to match link 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))

    # Update visual and collision children names IF they followed the standard naming pattern
    for child in self.id_data.children:
        if should_rename_child(child.name, old_source_name):
            # Surgical replacement
            if SUFFIX_VISUAL in child.name:
                suffix = child.name[len(old_source_name) + len(SUFFIX_VISUAL) :]
                new_name = f"{sanitized_name}{SUFFIX_VISUAL}{suffix}"
            else:  # _collision
                suffix = child.name[len(old_source_name) + len(SUFFIX_COLLISION) :]
                new_name = f"{sanitized_name}{SUFFIX_COLLISION}{suffix}"

            if child.name != new_name:
                child.name = new_name

    # Clear statistics cache when names/structure changes
    clear_stats_cache()


def update_inertia_viz(_self: PropertyGroup, _context: Context) -> None:
    """Trigger visual update for inertia gizmos."""
    clear_stats_cache()
    tag_redraw()


def update_auto_inertia_toggle(self: PropertyGroup, _context: Context) -> None:
    """Enable visualization when switching to manual inertia."""
    if not hasattr(self, "use_auto_inertia"):
        return

    # Always clear cache to ensure the draw handler sees the update immediately
    clear_stats_cache()

    if not getattr(self, "use_auto_inertia", True):
        # User switched to Manual Mode -> Ensure handler is running
        from ..visualization.inertia_gizmos import ensure_inertia_handler

        ensure_inertia_handler()


def update_active_visual(self: LinkPropertyGroup, context: Context) -> None:
    """Sync active visual index with viewport selection."""
    if not self.id_data:
        return
    visuals = [c for c in self.id_data.children if SUFFIX_VISUAL in c.name.lower()]
    if 0 <= self.active_visual_index < len(visuals):
        target = visuals[self.active_visual_index]
        if context.view_layer and context.view_layer.objects.active != target:
            for obj in context.selected_objects:
                obj.select_set(False)
            target.select_set(True)
            context.view_layer.objects.active = target


def update_active_collision(self: LinkPropertyGroup, context: Context) -> None:
    """Sync active collision index with viewport selection."""
    if not self.id_data:
        return
    collisions = [c for c in self.id_data.children if SUFFIX_COLLISION in c.name.lower()]
    if 0 <= self.active_collision_index < len(collisions):
        target = collisions[self.active_collision_index]
        if context.view_layer and context.view_layer.objects.active != target:
            for obj in context.selected_objects:
                obj.select_set(False)
            target.select_set(True)
            context.view_layer.objects.active = target


[docs] class LinkPropertyGroup(PropertyGroup): """Properties for a robot link stored on a Blender object.""" # Link identification is_robot_link: BoolProperty( # type: ignore name="Is Robot Link", description="Mark this object as a robot link", 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="", ) link_name: StringProperty( # type: ignore name="Link Name", description="Name of the link in robot model (must be unique)", maxlen=64, get=get_link_name, set=set_link_name, update=clear_stats_cache, ) # Inertial properties use_auto_inertia: BoolProperty( # type: ignore name="Auto-Calculate Inertia", description="Let LinkForge calculate physics properties from the 3D shape (recommended)", default=True, update=update_auto_inertia_toggle, ) mass: FloatProperty( # type: ignore name="Mass", description="Weight of this link in kilograms (for physics simulation)", default=DEFAULT_LINK_MASS, min=0.0, soft_max=1000.0, max=1000000.0, unit="MASS", precision=3, update=clear_stats_cache, ) # Manual inertia tensor (when auto_inertia is disabled) inertia_ixx: FloatProperty( # type: ignore name="Ixx", description="Moment of inertia around X-axis - resistance to rotation (kg⋅m²)", default=1.0, min=0.0, precision=6, ) inertia_ixy: FloatProperty( # type: ignore name="Ixy", description="Product of inertia XY component - coupling between X and Y rotations (kg⋅m²)", default=0.0, precision=6, ) inertia_ixz: FloatProperty( # type: ignore name="Ixz", description="Product of inertia XZ component - coupling between X and Z rotations (kg⋅m²)", default=0.0, precision=6, ) inertia_iyy: FloatProperty( # type: ignore name="Iyy", description="Moment of inertia around Y-axis - resistance to rotation (kg⋅m²)", default=1.0, min=0.0, precision=6, ) inertia_iyz: FloatProperty( # type: ignore name="Iyz", description="Product of inertia YZ component - coupling between Y and Z rotations (kg⋅m²)", default=0.0, precision=6, ) inertia_izz: FloatProperty( # type: ignore name="Izz", description="Moment of inertia around Z-axis - resistance to rotation (kg⋅m²)", default=1.0, min=0.0, precision=6, ) inertia_origin_xyz: FloatVectorProperty( # type: ignore name="Inertia Position", description="Position of the center of mass relative to the link frame (meters)", default=(0.0, 0.0, 0.0), size=3, precision=3, unit="LENGTH", update=update_inertia_viz, ) inertia_origin_rpy: FloatVectorProperty( # type: ignore name="Inertia Rotation", description="Rotation of the principal axes of inertia relative to the link frame (radians, XYZ order)", default=(0.0, 0.0, 0.0), size=3, precision=3, unit="ROTATION", update=update_inertia_viz, ) # Gazebo / Simulation Properties use_simulation_props: BoolProperty( # type: ignore name="Advanced Simulation", description="Include advanced physics settings (Gazebo/GZ) in the exported model", default=False, ) self_collide: BoolProperty( # type: ignore name="Self Collide", description="Whether this link can collide with other links in the same robot", default=DEFAULT_SELF_COLLIDE, ) gravity: BoolProperty( # type: ignore name="Gravity", description="Whether this link is affected by gravity", default=GRAVITY_ENABLED, ) mu: FloatProperty( # type: ignore name="Friction mu", description="Static friction coefficient (Coulomb)", default=DEFAULT_FRICTION_MU, min=0.0, ) mu2: FloatProperty( # type: ignore name="Friction mu2", description="Dynamic friction coefficient", default=DEFAULT_FRICTION_MU2, min=0.0, ) kp: FloatProperty( # type: ignore name="Stiffness kp", description="Contact stiffness (N/m)", default=DEFAULT_CONTACT_KP, min=0.0, ) kp_ui: StringProperty( # type: ignore name="Stiffness kp", description="Contact stiffness (e.g. 1.0e+12)", get=get_kp_scientific, set=set_kp_scientific, ) kd: FloatProperty( # type: ignore name="Damping kd", description="Contact damping (N s/m)", default=DEFAULT_CONTACT_KD, min=0.0, ) kd_ui: StringProperty( # type: ignore name="Damping kd", description="Contact damping (e.g. 1.0e+00)", get=get_kd_scientific, set=set_kd_scientific, ) active_visual_index: IntProperty( # type: ignore name="Active Visual Index", default=0, update=update_active_visual, ) active_collision_index: IntProperty( # type: ignore name="Active Collision Index", default=0, update=update_active_collision, ) # Material properties use_material: BoolProperty( # type: ignore name="Export Material", description="Export color/appearance to robot model (enabled by default for auto-created materials)", default=False, # Set dynamically by operator based on material creation )
# Registration __all__ = [ "LinkPropertyGroup", "register", "unregister", "sanitize_name", ]
[docs] def register() -> None: """Register property group.""" try: bpy.utils.register_class(LinkPropertyGroup) except ValueError: # If already registered (e.g. from reload), unregister first to ensure clean state bpy.utils.unregister_class(LinkPropertyGroup) bpy.utils.register_class(LinkPropertyGroup) setattr( bpy.types.Object, PROP_LINK, typing.cast(typing.Any, PointerProperty(type=LinkPropertyGroup)), )
[docs] def unregister() -> None: """Unregister property group.""" import contextlib with contextlib.suppress(AttributeError): delattr(bpy.types.Object, PROP_LINK) with contextlib.suppress(RuntimeError): bpy.utils.unregister_class(LinkPropertyGroup)
if __name__ == "__main__": register()