Files
speckle-blender/bpy_speckle/connector/utils/model_card_utils.py
2025-07-15 23:20:11 +03:00

419 lines
15 KiB
Python

import bpy
from bpy.types import Context
from typing import Dict, Any, Optional
from ..utils.property_groups import speckle_model_card
def find_layer_collection(layer_collection, collection_name):
"""
Recursively find a layer collection by collection name
"""
if layer_collection.collection.name == collection_name:
return layer_collection
for child in layer_collection.children:
result = find_layer_collection(child, collection_name)
if result:
return result
return None
def get_object_by_application_id(app_id: str):
"""
Find a Blender object by its applicationId stored in custom property
"""
if not app_id:
return None
for obj in bpy.data.objects:
if "applicationId" in obj and obj["applicationId"] == app_id:
return obj
return None
def get_objects_by_application_ids(app_ids: list):
"""
Find multiple Blender objects by their applicationIds
"""
if not app_ids:
return {}
result = {}
for obj in bpy.data.objects:
if "applicationId" in obj and obj["applicationId"] in app_ids:
result[obj["applicationId"]] = obj
return result
def get_collection_by_application_id(app_id: str):
"""
Find a Blender collection by its applicationId stored in custom property
"""
if not app_id:
return None
for collection in bpy.data.collections:
if "applicationId" in collection and collection["applicationId"] == app_id:
return collection
return None
def get_collection_identifier(blender_col: bpy.types.Collection) -> str:
"""
Get collection identifier: applicationId if exists, fallback to name
"""
if "applicationId" in blender_col and blender_col["applicationId"]:
return blender_col["applicationId"]
return blender_col.name
def find_collection_by_identifier(identifier: str):
"""
Find collection by identifier: try applicationId first, then name
"""
# first try to find by applicationId
collection = get_collection_by_application_id(identifier)
if collection:
return collection
# fallback to name-based lookup
return bpy.data.collections.get(identifier)
def capture_modifier_data(blender_obj: bpy.types.Object) -> list:
"""
Capture modifier data from a Blender object as dictionaries
"""
modifiers_data = []
for modifier in blender_obj.modifiers:
modifier_data = {
"name": modifier.name,
"type": modifier.type,
"show_viewport": modifier.show_viewport,
"show_render": modifier.show_render,
"show_in_editmode": modifier.show_in_editmode,
"show_on_cage": modifier.show_on_cage,
"properties": {},
}
# Capture modifier-specific properties
for prop_name in modifier.bl_rna.properties.keys():
if prop_name in [
"rna_type",
"name",
"type",
"show_viewport",
"show_render",
"show_in_editmode",
"show_on_cage",
]:
continue
try:
if hasattr(modifier, prop_name):
prop_value = getattr(modifier, prop_name)
# Handle different property types
if isinstance(prop_value, (int, float, bool, str)):
modifier_data["properties"][prop_name] = prop_value
elif hasattr(prop_value, "name"): # Object references
modifier_data["properties"][prop_name] = prop_value.name
elif (
hasattr(prop_value, "__len__") and len(prop_value) <= 4
): # Vectors/colors
modifier_data["properties"][prop_name] = list(prop_value)
except (AttributeError, TypeError):
continue
modifiers_data.append(modifier_data)
return modifiers_data
def has_visibility_modifications(obj: bpy.types.Object) -> bool:
"""Check if object has non-default visibility settings"""
return obj.hide_viewport or obj.hide_select or obj.hide_render or obj.hide_get()
def has_modifier_modifications(obj: bpy.types.Object) -> bool:
"""Check if object has any modifiers applied"""
return hasattr(obj, "modifiers") and len(obj.modifiers) > 0
def has_collection_visibility_modifications(layer_col, collection) -> bool:
"""Check if collection has non-default visibility settings"""
return (
layer_col.hide_viewport
or collection.hide_select
or collection.hide_render
or layer_col.exclude
)
def collect_objects_with_properties(
model_card: speckle_model_card,
) -> Dict[str, Dict[str, Any]]:
"""
Collect objects and collections with their current properties before deletion
Only stores data for objects that have been modified from defaults
"""
collected_data = {"objects": {}, "collections": {}}
# Collect object properties (only for modified objects)
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
if blender_obj:
obj_data = {}
# Only collect visibility if modified from defaults
if has_visibility_modifications(blender_obj):
obj_data["visibility"] = {
"hide_get": blender_obj.hide_get(),
"hide_viewport": blender_obj.hide_viewport,
"hide_select": blender_obj.hide_select,
"hide_render": blender_obj.hide_render,
}
# Only collect modifiers if object has any
if has_modifier_modifications(blender_obj):
obj_data["modifiers"] = capture_modifier_data(blender_obj)
# Only store object data if it has modifications
if obj_data:
collected_data["objects"][s_obj.applicationId] = obj_data
# Collect collection properties (only for modified collections)
for s_col in model_card.collections:
# try to find collection by applicationId first, then fallback to name
blender_col = None
if s_col.applicationId:
blender_col = get_collection_by_application_id(s_col.applicationId)
if not blender_col:
blender_col = bpy.data.collections.get(s_col.name)
if blender_col:
view_layer = bpy.context.view_layer
if view_layer:
layer_col = find_layer_collection(
view_layer.layer_collection, blender_col.name
)
if layer_col and has_collection_visibility_modifications(
layer_col, blender_col
):
# use collection identifier as key
collection_id = get_collection_identifier(blender_col)
collected_data["collections"][collection_id] = {
"hide_viewport": layer_col.hide_viewport,
"hide_select": layer_col.collection.hide_select,
"hide_render": layer_col.collection.hide_render,
"exclude_from_view_layer": layer_col.exclude,
}
return collected_data
def transfer_object_properties(
new_obj: bpy.types.Object, old_obj_data: Dict[str, Any]
) -> None:
"""
Transfer visibility and modifiers from old object data to new object
Handles sparse data gracefully - applies defaults when data is missing
"""
# Transfer visibility settings (if any were modified)
visibility = old_obj_data.get("visibility")
if visibility:
new_obj.hide_set(visibility.get("hide_get", False))
new_obj.hide_viewport = visibility.get("hide_viewport", False)
new_obj.hide_select = visibility.get("hide_select", False)
new_obj.hide_render = visibility.get("hide_render", False)
# If no visibility data, object keeps defaults (all False)
# Transfer modifiers (if any were present)
old_modifiers = old_obj_data.get("modifiers")
if old_modifiers and hasattr(new_obj, "modifiers"):
# Clear existing modifiers
new_obj.modifiers.clear()
# Transfer each modifier
for modifier_data in old_modifiers:
recreate_modifier_from_data(new_obj, modifier_data)
# If no modifier data, object keeps default (no modifiers)
def transfer_collection_properties(
new_col: bpy.types.Collection, old_col_data: Dict[str, Any]
) -> None:
"""
Transfer visibility properties from old collection data to new collection
Handles sparse data gracefully - applies defaults when data is missing
"""
view_layer = bpy.context.view_layer
if view_layer:
layer_col = find_layer_collection(view_layer.layer_collection, new_col.name)
if layer_col:
# Only apply properties if collection had modifications
# (otherwise it keeps defaults: all False)
layer_col.hide_viewport = old_col_data.get("hide_viewport", False)
layer_col.collection.hide_select = old_col_data.get("hide_select", False)
layer_col.collection.hide_render = old_col_data.get("hide_render", False)
layer_col.exclude = old_col_data.get("exclude_from_view_layer", False)
def recreate_modifier_from_data(
new_obj: bpy.types.Object, modifier_data: Dict[str, Any]
) -> Optional[bpy.types.Modifier]:
"""
Recreate a modifier from captured data
"""
try:
# Validate modifier data
if not modifier_data.get("type") or not modifier_data.get("name"):
print(f"Invalid modifier data: {modifier_data}")
return None
# Create new modifier
new_modifier = new_obj.modifiers.new(
modifier_data["name"], modifier_data["type"]
)
# Set visibility properties
new_modifier.show_viewport = modifier_data.get("show_viewport", True)
new_modifier.show_render = modifier_data.get("show_render", True)
new_modifier.show_in_editmode = modifier_data.get("show_in_editmode", True)
new_modifier.show_on_cage = modifier_data.get("show_on_cage", False)
# Set modifier-specific properties
for prop_name, prop_value in modifier_data.get("properties", {}).items():
try:
if hasattr(new_modifier, prop_name):
current_value = getattr(new_modifier, prop_name)
# Handle object references
if hasattr(current_value, "name") and isinstance(prop_value, str):
referenced_obj = bpy.data.objects.get(prop_value)
if referenced_obj:
setattr(new_modifier, prop_name, referenced_obj)
else:
setattr(new_modifier, prop_name, prop_value)
except (AttributeError, TypeError):
continue
return new_modifier
except Exception as e:
print(f"Error recreating modifier {modifier_data.get('name', 'unknown')}: {e}")
return None
def update_model_card_objects(
model_card: speckle_model_card,
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
old_properties: Optional[Dict[str, Dict[str, Any]]] = None,
):
"""
Update model card with new objects and apply properties from old objects if provided
"""
# Clear model card objects
model_card.objects.clear()
model_card.collections.clear()
# Convert list to dictionary if needed
if isinstance(converted_objects, list):
converted_objects = {obj.name: obj for obj in converted_objects}
for obj in converted_objects.values():
# Handle collections
if isinstance(obj, bpy.types.Collection):
if obj.name in (o.name for o in model_card.collections):
continue
s_col = model_card.collections.add()
s_col.name = obj.name
s_col.applicationId = obj.get("applicationId", "")
# apply old collection properties if available (use identifier-based lookup)
if old_properties:
collection_id = get_collection_identifier(obj)
if collection_id in old_properties.get("collections", {}):
old_col_data = old_properties["collections"][collection_id]
transfer_collection_properties(obj, old_col_data)
# Handle objects
elif isinstance(obj, bpy.types.Object):
if obj.name in (o.name for o in model_card.objects):
continue
s_obj = model_card.objects.add()
s_obj.name = obj.name
s_obj.applicationId = obj.get("applicationId", "")
# Apply old object properties if available
if (
old_properties
and s_obj.applicationId
and s_obj.applicationId in old_properties.get("objects", {})
):
old_obj_data = old_properties["objects"][s_obj.applicationId]
transfer_object_properties(obj, old_obj_data)
def delete_model_card_objects(model_card: speckle_model_card, context: Context) -> None:
"""
deletes the model card objects
"""
# Delete objects directly without requiring selection
for obj in model_card.objects:
blender_obj = get_object_by_application_id(obj.applicationId)
if not blender_obj:
continue
# Remove object from all collections first
for collection in blender_obj.users_collection:
collection.objects.unlink(blender_obj)
# Delete the object directly
bpy.data.objects.remove(blender_obj)
# delete model card/currently loaded collections
for col in model_card.collections:
coll = bpy.data.collections.get(col.name)
if not coll:
continue
# unlink from scenes
for scene in bpy.data.scenes:
if scene.collection.children.get(coll.name):
scene.collection.children.unlink(coll)
bpy.data.collections.remove(coll)
def select_model_card_objects(model_card, context: Context):
# deselect all objects first
bpy.ops.object.select_all(action="DESELECT")
# select objects in model card
for obj in model_card.objects:
blender_obj = get_object_by_application_id(obj.applicationId)
if not blender_obj:
continue
if blender_obj.name in context.view_layer.objects:
blender_obj.select_set(True)
selected = context.selected_objects
if selected:
context.view_layer.objects.active = selected[0]
def zoom_to_selected_objects(context: Context):
"""
zooms to the selected objects
"""
bpy.ops.view3d.view_selected()
def model_card_exists(
project_id: str, model_id: str, is_publish: bool, context: Context
) -> bool:
"""
checks if a model card exists
"""
for model_card in context.scene.speckle_state.model_cards:
if (
model_card.project_id == project_id
and model_card.model_id == model_id
and model_card.is_publish == is_publish
):
return True
return False