"""Helper utilities for Blender property groups.
This module provides optimized helper functions for property update callbacks.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import bpy
from bpy.types import Context
from ..constants import (
PROP_JOINT,
PROP_LINK,
PROP_ROBOT,
PROP_SENSOR,
SUFFIX_COLLISION,
SUFFIX_VISUAL,
)
if TYPE_CHECKING:
from ..properties.joint_props import JointPropertyGroup
from ..properties.link_props import LinkPropertyGroup
from ..properties.robot_props import RobotPropertyGroup
from ..properties.sensor_props import SensorPropertyGroup
[docs]
def find_property_owner(context: Context, property_group: Any, property_attr: str) -> Any | None:
"""Find the Blender object that owns a given property group instance.
This is an optimized helper for property update callbacks that need to find
their owning object. It tries multiple strategies from fastest to slowest:
1. Check id_data (most reliable and fastest)
2. Check context.object (active object) first
3. Check context.selected_objects
4. Fall back to full scene search as last resort
Args:
context: Blender context
property_group: The property group instance (self in update callback)
property_attr: The attribute name on objects (e.g., "linkforge_sensor")
Returns:
The object that owns this property group, or None if not found
"""
# Check id_data (most reliable and fastest)
if (
hasattr(property_group, "id_data")
and property_group.id_data
and isinstance(property_group.id_data, bpy.types.Object)
and hasattr(property_group.id_data, property_attr)
and getattr(property_group.id_data, property_attr) == property_group
):
return property_group.id_data
# Check active object (fast fallback)
if (
hasattr(context, "object")
and context.object
and hasattr(context.object, property_attr)
and getattr(context.object, property_attr) == property_group
):
return context.object
# Check selected objects (faster than full scene search)
if hasattr(context, "selected_objects"):
for obj in context.selected_objects:
if hasattr(obj, property_attr) and getattr(obj, property_attr) == property_group:
return obj
# Fall back to full scene search (slowest)
if hasattr(context, "scene") and context.scene:
for obj in context.scene.objects:
if hasattr(obj, property_attr) and getattr(obj, property_attr) == property_group:
return obj
return None
[docs]
def get_link_props(obj: bpy.types.Object | None) -> LinkPropertyGroup | None:
"""Type-safe access to LinkForge link properties on a Blender object."""
if obj is None:
return None
return cast("LinkPropertyGroup | None", getattr(obj, PROP_LINK, None))
[docs]
def get_joint_props(obj: bpy.types.Object | None) -> JointPropertyGroup | None:
"""Type-safe access to LinkForge joint properties on a Blender object."""
if obj is None:
return None
return cast("JointPropertyGroup | None", getattr(obj, PROP_JOINT, None))
[docs]
def get_sensor_props(obj: bpy.types.Object | None) -> SensorPropertyGroup | None:
"""Type-safe access to LinkForge sensor properties on a Blender object."""
if obj is None:
return None
return cast("SensorPropertyGroup | None", getattr(obj, PROP_SENSOR, None))
[docs]
def get_robot_props(scene: bpy.types.Scene | None) -> RobotPropertyGroup | None:
"""Type-safe access to LinkForge robot properties on a Blender scene."""
if scene is None:
return None
return cast("RobotPropertyGroup | None", getattr(scene, PROP_ROBOT, None))
# Global queue for deferred datablock renames (used when RNA is locked or in background mode)
PENDING_RENAMES: list[tuple[Any, str]] = []
[docs]
def flush_deferred_renames() -> None:
"""Execute all pending datablock renames in the queue.
Used primarily in background mode or during tests to ensure synchronization
is complete after depsgraph evaluation.
"""
global PENDING_RENAMES
remaining = []
while PENDING_RENAMES:
obj, new_name = PENDING_RENAMES.pop(0)
try:
if obj and hasattr(obj, "name"):
obj.name = new_name
except ReferenceError:
# Datablock was deleted from Blender, drop it
continue
except Exception:
# If it fails (likely read-only / RNA still locked), keep it for next flush
remaining.append((obj, new_name))
PENDING_RENAMES.extend(remaining)
[docs]
def safe_set_id_name(id_data: Any, sanitized_name: str) -> None:
"""Safely update a Blender datablock's name, deferring if RNA is locked.
During property updates or depsgraph evaluation, writing directly to
ID datablocks can raise RuntimeError or AttributeError.
In GUI mode, updates are deferred via bpy.app.timers.
In background mode (or headless environments where timers do not run),
updates are queued into PENDING_RENAMES and flushed on depsgraph updates.
Note:
Blender automatically handles duplicate object name collisions by
appending numeric suffixes (e.g. '.001', '.002'). The LinkForge
model identity is preserved in `source_name_stored`.
"""
try:
if not id_data or not hasattr(id_data, "name") or id_data.name == sanitized_name:
return
except (ReferenceError, Exception):
return
try:
id_data.name = sanitized_name
except (AttributeError, RuntimeError):
if not getattr(bpy.app, "background", False) and hasattr(bpy.app, "timers"):
def deferred_rename() -> None:
try:
if id_data and hasattr(id_data, "name"):
id_data.name = sanitized_name
except (ReferenceError, Exception):
pass
return None
bpy.app.timers.register(deferred_rename, first_interval=0.01)
else:
PENDING_RENAMES.append((id_data, sanitized_name))
[docs]
def should_rename_child(child_name: str, parent_old_name: str) -> bool:
"""Check if a child object was auto-named by LinkForge and should be synced.
Only renames if the child follows the exact [parent_old_name]_visual or
[parent_old_name]_collision convention. Custom names are preserved.
Args:
child_name: Current name of the child object.
parent_old_name: The previous name of the parent link before the current rename.
Returns:
True if the child is a standard LinkForge visual/collision object that should be renamed.
"""
prefix_v = f"{parent_old_name}{SUFFIX_VISUAL}"
prefix_c = f"{parent_old_name}{SUFFIX_COLLISION}"
is_visual = child_name.startswith(prefix_v) and (
len(child_name) == len(prefix_v) or child_name[len(prefix_v)] in "._"
)
is_collision = child_name.startswith(prefix_c) and (
len(child_name) == len(prefix_c) or child_name[len(prefix_c)] in "._"
)
return is_visual or is_collision