Compare commits

...

14 Commits

Author SHA1 Message Date
Mucahit Bilal GOKER 040ea64f86 Merge branch 'v3-dev' into bilal/preserve-uv-maps 2025-07-22 12:44:20 +03:00
Mucahit Bilal GOKER bd154c1575 track by application ID instead of name 2025-07-13 19:19:21 +03:00
Mucahit Bilal GOKER bbc20e2353 Merge branch 'bilal/cnx-2065-keep-track-of-loaded-object-properties' into bilal/preserve-uv-maps 2025-07-13 09:12:52 +03:00
Mucahit Bilal GOKER 716347b497 Merge branch 'v3-dev' into bilal/preserve-uv-maps 2025-07-13 09:11:03 +03:00
Mucahit Bilal GOKER 58283439ab comments cleanup 2025-07-11 15:19:43 +03:00
Mucahit Bilal GOKER 0c29a2ec0a replace selecting by name logic to applicationId 2025-07-11 12:50:31 +03:00
Mucahit Bilal GOKER 4ec62d4168 Revert "rename speckle_application_id to applicationId"
This reverts commit 8d596823ed.
2025-07-11 12:09:15 +03:00
Mucahit Bilal GOKER 8d596823ed rename speckle_application_id to applicationId 2025-07-11 12:06:47 +03:00
Mucahit Bilal GOKER ccd62e3452 remove 8 char limit from ids 2025-07-11 11:45:41 +03:00
Mucahit Bilal GOKER 1bd08497e6 Merge remote-tracking branch 'origin/v3-dev' into bilal/cnx-2065-keep-track-of-loaded-object-properties 2025-07-11 11:38:18 +03:00
Mucahit Bilal GOKER 3e2ac4b5b6 preserve modifiers 2025-07-06 22:14:28 +03:00
Mucahit Bilal GOKER 26927ca6f4 uv map preservation first pass 2025-07-06 21:04:14 +03:00
Mucahit Bilal GOKER 928bc15ff1 preserve layer collection visibility settings 2025-07-05 21:05:19 +03:00
Mucahit Bilal GOKER e410e40060 preserve object visibility settings and update object removal function 2025-07-05 19:54:39 +03:00
5 changed files with 459 additions and 27 deletions
@@ -6,6 +6,9 @@ from ..operations.load_operation import load_operation
from ..utils.model_card_utils import (
delete_model_card_objects,
update_model_card_objects,
store_visibility_settings,
store_uv_mappings,
store_modifier_settings,
)
@@ -27,6 +30,10 @@ class SPECKLE_OT_load_model_card(bpy.types.Operator):
self.report({"ERROR"}, "Model card not found")
return {"CANCELLED"}
store_visibility_settings(model_card)
store_modifier_settings(model_card)
store_uv_mappings(model_card)
delete_model_card_objects(model_card, context)
# set wm
@@ -102,7 +102,7 @@ def load_operation(
traversal_function = create_default_traversal_function()
root_collection_name = f"{wm.selected_model_name} - {wm.selected_version_id[:8]}"
root_collection_name = f"{wm.selected_model_name} - {wm.selected_version_id}"
root_collection = bpy.data.collections.new(root_collection_name)
context.scene.collection.children.link(root_collection)
@@ -139,7 +139,7 @@ def load_operation(
speckle_root_id = speckle_obj.id
collection_name = getattr(
speckle_obj, "name", f"Collection_{speckle_obj.id[:8]}"
speckle_obj, "name", f"Collection_{speckle_obj.id}"
)
parent_id = None
@@ -183,6 +183,8 @@ def load_operation(
if speckle_root_id and speckle_root_id in collection_hierarchy:
collection_hierarchy[speckle_root_id]["blender_collection"] = root_collection
converted_objects[speckle_root_id] = root_collection
# Add root collection name as key for UV mapping preservation
converted_objects[root_collection.name] = root_collection
# create collections in depth order (skip the root that's already mapped)
for coll_id in sorted_collections:
@@ -212,6 +214,8 @@ def load_operation(
coll_info["blender_collection"] = blender_collection
converted_objects[coll_id] = blender_collection
# Add collection name as key for UV mapping preservation
converted_objects[blender_collection.name] = blender_collection
conversion_count = 0
for traversal_item in traversal_function.traverse(version_data):
@@ -261,6 +265,8 @@ def load_operation(
converted_objects[speckle_obj.id] = blender_obj
if hasattr(speckle_obj, "applicationId"):
converted_objects[speckle_obj.applicationId] = blender_obj
# Add object name as key for UV mapping preservation
converted_objects[blender_obj.name] = blender_obj
if not isinstance(blender_obj, bpy.types.Collection):
try:
+404 -3
View File
@@ -1,13 +1,216 @@
import bpy
import json
from bpy.types import Context
from typing import Dict
import json
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 store_visibility_settings(model_card: speckle_model_card):
"""
Store current visibility settings of model card objects and collections
This is used to restore the visibility settings of the loaded objects after loading a new version
"""
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
if blender_obj:
s_obj.hide_get = blender_obj.hide_get()
s_obj.hide_viewport = blender_obj.hide_viewport
s_obj.hide_select = blender_obj.hide_select
s_obj.hide_render = blender_obj.hide_render
for s_col in model_card.collections:
blender_col = bpy.data.collections.get(s_col.name)
if blender_col:
# For collections, visibility is controlled through the view layer system
view_layer = bpy.context.view_layer
if view_layer:
# Find the layer collection for this collection
layer_col = find_layer_collection(
view_layer.layer_collection, blender_col.name
)
if layer_col:
s_col.hide_viewport = layer_col.hide_viewport
s_col.hide_select = layer_col.collection.hide_select
s_col.hide_render = layer_col.collection.hide_render
s_col.exclude_from_view_layer = layer_col.exclude
else:
s_col.hide_viewport = False
s_col.hide_select = False
s_col.hide_render = False
s_col.exclude_from_view_layer = False
def store_uv_mappings(model_card: speckle_model_card):
"""
Store current UV mapping data of model card mesh objects
This is used to restore the UV mappings after loading a new version
"""
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
if blender_obj and blender_obj.type == "MESH" and blender_obj.data:
mesh = blender_obj.data
uv_data = {"active_uv_layer": "", "uv_layers": []}
# Store active UV layer name
if mesh.uv_layers.active:
uv_data["active_uv_layer"] = mesh.uv_layers.active.name
# Store UV data for each UV layer
for uv_layer in mesh.uv_layers:
# Extract UV coordinates for all loops in this layer
uv_coords = []
for uv_loop in uv_layer.data:
uv_coords.extend([uv_loop.uv.x, uv_loop.uv.y])
uv_data["uv_layers"].append(
{"name": uv_layer.name, "uv_coords": uv_coords}
)
# Serialize complete UV data as JSON string
s_obj.uv_data_serialized = json.dumps(uv_data)
def restore_uv_mappings(
model_card: speckle_model_card,
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
):
"""
Restore UV mapping data to reloaded mesh objects
"""
# First, collect UV mapping data from property groups before they are cleared
uv_mapping_data = {}
for s_obj in model_card.objects:
if s_obj.uv_data_serialized: # Only process objects that have UV data stored
try:
uv_data = json.loads(s_obj.uv_data_serialized)
uv_mapping_data[s_obj.applicationId] = uv_data
except (json.JSONDecodeError, ValueError):
# Skip invalid UV data
continue
# Now restore UV mappings to the new objects
for app_id, uv_data in uv_mapping_data.items():
# Find the blender object by applicationId in converted_objects
blender_obj = None
for obj in converted_objects.values():
if isinstance(obj, bpy.types.Object) and obj.get("applicationId") == app_id:
blender_obj = obj
break
if blender_obj:
# Only process mesh objects
if (
isinstance(blender_obj, bpy.types.Object)
and blender_obj.type == "MESH"
and blender_obj.data
):
mesh = blender_obj.data
# Restore UV layers
for uv_layer_data in uv_data.get("uv_layers", []):
layer_name = uv_layer_data["name"]
uv_coords = uv_layer_data["uv_coords"]
# Find or create the UV layer
uv_layer = mesh.uv_layers.get(layer_name)
if not uv_layer:
uv_layer = mesh.uv_layers.new(name=layer_name)
# Restore UV coordinates
expected_coords = len(mesh.loops) * 2 # 2 coords per loop
if len(uv_coords) == expected_coords:
for i, uv_loop in enumerate(uv_layer.data):
coord_idx = i * 2
if coord_idx + 1 < len(uv_coords):
uv_loop.uv = (
uv_coords[coord_idx],
uv_coords[coord_idx + 1],
)
# Restore active UV layer
active_uv_layer = uv_data.get("active_uv_layer", "")
if active_uv_layer and mesh.uv_layers.get(active_uv_layer):
mesh.uv_layers.active = mesh.uv_layers[active_uv_layer]
def update_model_card_objects(
model_card: speckle_model_card,
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
):
# Restore UV mappings before clearing property groups
restore_uv_mappings(model_card, converted_objects)
# Store visibility settings from property group before clearing
visibility_settings = {}
for s_obj in model_card.objects:
if s_obj.applicationId:
visibility_settings[s_obj.applicationId] = {
"hide_get": s_obj.hide_get,
"hide_viewport": s_obj.hide_viewport,
"hide_select": s_obj.hide_select,
"hide_render": s_obj.hide_render,
}
# Store modifier settings from property group before clearing
modifier_settings = {}
for s_obj in model_card.objects:
if s_obj.applicationId:
modifier_settings[s_obj.applicationId] = s_obj.modifiers
# Store collection visibility settings from property group before clearing
collection_visibility_settings = {}
for s_col in model_card.collections:
collection_visibility_settings[s_col.name] = {
"hide_viewport": s_col.hide_viewport,
"hide_select": s_col.hide_select,
"hide_render": s_col.hide_render,
"exclude_from_view_layer": s_col.exclude_from_view_layer,
}
# clear model card objects
model_card.objects.clear()
model_card.collections.clear()
@@ -23,20 +226,101 @@ def update_model_card_objects(
continue
s_col = model_card.collections.add()
s_col.name = obj.name
# Restore collection visibility settings if they exist
if obj.name in collection_visibility_settings:
s_col.hide_viewport = collection_visibility_settings[obj.name][
"hide_viewport"
]
s_col.hide_select = collection_visibility_settings[obj.name][
"hide_select"
]
s_col.hide_render = collection_visibility_settings[obj.name][
"hide_render"
]
s_col.exclude_from_view_layer = collection_visibility_settings[
obj.name
]["exclude_from_view_layer"]
# Apply the visibility settings to the new collection through view layer
view_layer = bpy.context.view_layer
if view_layer:
# Find the layer collection for this collection
layer_col = find_layer_collection(
view_layer.layer_collection, obj.name
)
if layer_col:
# Apply viewport visibility (controlled by layer collection)
layer_col.hide_viewport = collection_visibility_settings[
obj.name
]["hide_viewport"]
# Apply selectability and render visibility (controlled by collection)
obj.hide_select = collection_visibility_settings[obj.name][
"hide_select"
]
obj.hide_render = collection_visibility_settings[obj.name][
"hide_render"
]
# Apply view layer exclusion
layer_col.exclude = collection_visibility_settings[obj.name][
"exclude_from_view_layer"
]
# if its an object, add it to the objects field of model card
if 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", "")
# Restore visibility settings if they exist
if s_obj.applicationId and s_obj.applicationId in visibility_settings:
s_obj.hide_get = visibility_settings[s_obj.applicationId]["hide_get"]
s_obj.hide_viewport = visibility_settings[s_obj.applicationId][
"hide_viewport"
]
s_obj.hide_select = visibility_settings[s_obj.applicationId][
"hide_select"
]
s_obj.hide_render = visibility_settings[s_obj.applicationId][
"hide_render"
]
# Apply the visibility settings to the new object
obj.hide_set(visibility_settings[s_obj.applicationId]["hide_get"])
obj.hide_viewport = visibility_settings[s_obj.applicationId][
"hide_viewport"
]
obj.hide_select = visibility_settings[s_obj.applicationId][
"hide_select"
]
obj.hide_render = visibility_settings[s_obj.applicationId][
"hide_render"
]
# Restore modifier settings if they exist
if s_obj.applicationId and s_obj.applicationId in modifier_settings:
s_obj.modifiers = modifier_settings[s_obj.applicationId]
restore_modifier_settings(obj, modifier_settings[s_obj.applicationId])
def delete_model_card_objects(model_card: speckle_model_card, context: Context) -> None:
"""
deletes the model card objects
"""
select_model_card_objects(model_card, context)
bpy.ops.object.delete()
# 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)
@@ -54,7 +338,7 @@ def select_model_card_objects(model_card, context: Context):
bpy.ops.object.select_all(action="DESELECT")
# select objects in model card
for obj in model_card.objects:
blender_obj = bpy.data.objects.get(obj.name)
blender_obj = get_object_by_application_id(obj.applicationId)
if not blender_obj:
continue
if blender_obj.name in context.view_layer.objects:
@@ -86,3 +370,120 @@ def model_card_exists(
):
return True
return False
def serialize_modifier(modifier):
"""
Serialize a Blender modifier to a dictionary
"""
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": {},
}
# Store all 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:
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):
# Skip properties that can't be serialized
continue
return modifier_data
def deserialize_modifier(obj, modifier_data):
"""
Recreate a modifier from serialized data
"""
try:
modifier = obj.modifiers.new(modifier_data["name"], modifier_data["type"])
# Set visibility properties
modifier.show_viewport = modifier_data.get("show_viewport", True)
modifier.show_render = modifier_data.get("show_render", True)
modifier.show_in_editmode = modifier_data.get("show_in_editmode", True)
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(modifier, prop_name):
current_value = getattr(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(modifier, prop_name, referenced_obj)
else:
setattr(modifier, prop_name, prop_value)
except (AttributeError, TypeError):
# Skip properties that can't be set
continue
return modifier
except Exception as e:
print(f"Error deserializing modifier {modifier_data['name']}: {e}")
return None
def store_modifier_settings(model_card: speckle_model_card):
"""
Store current modifier settings of model card objects
This is used to restore the modifier settings of the loaded objects after loading a new version
"""
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
if blender_obj and hasattr(blender_obj, "modifiers"):
modifiers_data = []
for modifier in blender_obj.modifiers:
modifier_data = serialize_modifier(modifier)
modifiers_data.append(modifier_data)
# Store as JSON string
s_obj.modifiers = json.dumps(modifiers_data)
def restore_modifier_settings(blender_obj, modifier_data_json):
"""
Restore modifier settings to a Blender object
"""
if not modifier_data_json or not hasattr(blender_obj, "modifiers"):
return
try:
modifiers_data = json.loads(modifier_data_json)
# Clear existing modifiers
blender_obj.modifiers.clear()
# Recreate modifiers
for modifier_data in modifiers_data:
deserialize_modifier(blender_obj, modifier_data)
except (json.JSONDecodeError, KeyError, TypeError) as e:
print(f"Error restoring modifiers for {blender_obj.name}: {e}")
+15 -3
View File
@@ -1,5 +1,4 @@
import bpy
from typing import Dict, Any
class speckle_project(bpy.types.PropertyGroup):
@@ -37,18 +36,31 @@ class speckle_version(bpy.types.PropertyGroup):
class speckle_object(bpy.types.PropertyGroup):
"""
PropertyGroup for storing object names
PropertyGroup for storing object names, visibility settings, and UV mapping data
"""
name: bpy.props.StringProperty() # type: ignore
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
hide_get: bpy.props.BoolProperty(name="Hide Get", default=False) # type: ignore
hide_viewport: bpy.props.BoolProperty(name="Hide Viewport", default=False) # type: ignore
hide_select: bpy.props.BoolProperty(name="Hide Select", default=False) # type: ignore
hide_render: bpy.props.BoolProperty(name="Hide Render", default=False) # type: ignore
modifiers: bpy.props.StringProperty(name="Modifiers", default="") # type: ignore
uv_data_serialized: bpy.props.StringProperty() # type: ignore
class speckle_collection(bpy.types.PropertyGroup):
"""
PropertyGroup for storing collection information
PropertyGroup for storing collection information and visibility settings
"""
name: bpy.props.StringProperty() # type: ignore
hide_viewport: bpy.props.BoolProperty(name="Hide Viewport", default=False) # type: ignore
hide_select: bpy.props.BoolProperty(name="Hide Select", default=False) # type: ignore
hide_render: bpy.props.BoolProperty(name="Hide Render", default=False) # type: ignore
exclude_from_view_layer: bpy.props.BoolProperty(
name="Exclude From View Layer", default=False
) # type: ignore
class speckle_model_card(bpy.types.PropertyGroup):
+25 -19
View File
@@ -186,7 +186,7 @@ def convert_to_native(
# Store Speckle ID in custom property
converted_object["speckle_id"] = speckle_object.id
if hasattr(speckle_object, "applicationId"):
converted_object["speckle_application_id"] = speckle_object.applicationId
converted_object["applicationId"] = speckle_object.applicationId
return converted_object
@@ -320,7 +320,9 @@ def _members_to_native(
for item in others:
try:
blender_object = convert_to_native(item, material_mapping, instance_loading_mode="INSTANCE_PROXIES")
blender_object = convert_to_native(
item, material_mapping, instance_loading_mode="INSTANCE_PROXIES"
)
if blender_object:
# If the parent is a DataObject, override the name of the converted child
if is_data_object:
@@ -1219,7 +1221,7 @@ def instance_definition_proxy_to_native(
"Must be 'INSTANCE_PROXIES' or 'LINKED_DUPLICATES'"
)
assert isinstance(material_mapping, dict), "material_mapping must be a dictionary"
processed_definitions = processed_definitions or {}
definition_collections = {}
converted_objects = {}
@@ -1240,7 +1242,7 @@ def instance_definition_proxy_to_native(
sorted_components = sort_instance_components(definitions, [])
for _, _, def_id, definition in sorted_components:
collection_name = getattr(definition, "name", f"Definition_{def_id[:8]}")
collection_name = getattr(definition, "name", f"Definition_{def_id}")
if def_id in processed_definitions:
definition_collections[def_id] = processed_definitions[def_id]
@@ -1272,10 +1274,10 @@ def instance_definition_proxy_to_native(
nested_def = definitions[found_obj.definitionId]
max_depth = getattr(nested_def, "maxDepth", 0)
if max_depth > 0: # Only process if max_depth allows
assert found_obj.definitionId in definition_collections, (
f"Definition collection not found for nested instance {found_obj.definitionId}"
)
assert (
found_obj.definitionId in definition_collections
), f"Definition collection not found for nested instance {found_obj.definitionId}"
if instance_loading_mode == "LINKED_DUPLICATES":
blender_obj = instance_proxy_to_linked_duplicates(
found_obj,
@@ -1293,7 +1295,11 @@ def instance_definition_proxy_to_native(
if blender_obj:
converted_objects[obj_id] = blender_obj
else:
blender_obj = convert_to_native(found_obj, material_mapping, instance_loading_mode="INSTANCE_PROXIES")
blender_obj = convert_to_native(
found_obj,
material_mapping,
instance_loading_mode="INSTANCE_PROXIES",
)
if blender_obj:
definition_collection.objects.link(blender_obj)
converted_objects[obj_id] = blender_obj
@@ -1398,16 +1404,16 @@ def instance_proxy_to_linked_duplicates(
@ mathutils.Matrix.Diagonal(scale_vector).to_4x4()
)
instance_name = f"Instance_{speckle_instance.id[:8]}"
instance_name = f"Instance_{speckle_instance.id}"
parent_empty = bpy.data.objects.new(instance_name, None)
parent_empty.empty_display_type = 'PLAIN_AXES'
parent_empty.empty_display_type = "PLAIN_AXES"
parent_empty.empty_display_size = 0.1
parent_empty.matrix_world = final_matrix
# link parent to root collection
root_collection.objects.link(parent_empty)
parent_empty["speckle_id"] = speckle_instance.id
parent_empty["speckle_type"] = speckle_instance.speckle_type
parent_empty["definition_id"] = speckle_instance.definitionId
@@ -1418,14 +1424,14 @@ def instance_proxy_to_linked_duplicates(
for obj in definition_collection.objects:
# create a copy of the object with linked data
duplicate_obj = obj.copy()
duplicate_obj.name = f"{obj.name}_{speckle_instance.id[:8]}"
root_collection.objects.link(duplicate_obj)
# apply the instance transformation directly to each object
duplicate_obj.matrix_world = final_matrix @ obj.matrix_world
duplicated_objects.append(duplicate_obj)
return parent_empty
@@ -1492,7 +1498,7 @@ def instance_proxy_to_native(
instance_obj.empty_display_size = 0
instance_name = f"Instance_{speckle_instance.id[:8]}"
instance_name = f"Instance_{speckle_instance.id}"
instance_obj.name = instance_name
if instance_obj.name not in root_collection.objects: