Compare commits

...

9 Commits

Author SHA1 Message Date
Dogukan Karatas a1f835dc77 Merge pull request #272 from specklesystems/dogukan/cnx-1976-conversion-of-collections-in-blender-send
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
feat: collection conversion
2025-06-06 10:59:08 +02:00
Dogukan Karatas e2172216a5 Merge pull request #275 from specklesystems/jrm/uv-installer
chore(installer): Revert uv implementation of the installer
2025-06-06 10:58:53 +02:00
Jedd Morgan 965c3e9c6e Removed uv from installer 2025-06-05 14:16:09 +01:00
Dogukan Karatas 65e4812ba1 fixes the object selection 2025-06-05 15:16:01 +02:00
Jedd Morgan 87df86f723 Format 2025-06-05 14:15:53 +01:00
Dogukan Karatas fd32371be3 adds ancestor collections 2025-06-05 12:56:26 +02:00
Dogukan Karatas 19c1334bb3 adds collection conversion 2025-06-04 16:38:34 +02:00
Dogukan Karatas 7a36450143 Merge pull request #271 from specklesystems/dogukan/fix-version-import
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
fix: global import of bl_info
2025-06-04 10:10:44 +02:00
Dogukan Karatas d37fce644b adds global import 2025-06-04 10:07:41 +02:00
5 changed files with 406 additions and 213 deletions
@@ -1,29 +1,14 @@
import bpy
from bpy.types import Context
from bpy.types import Event
from typing import Set, List, Optional
from typing import Set
from specklepy.objects import Base
from specklepy.objects.models.collections.collection import Collection
from specklepy.core.api import operations
from specklepy.core.api.client import SpeckleClient
from specklepy.transports.server import ServerTransport
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.credentials import get_local_accounts
from specklepy.objects.models.units import Units
from ...converter.to_speckle import convert_to_speckle
from ...converter.to_speckle.material_to_speckle import (
add_render_material_proxies_to_base,
)
from ...converter.utils import get_project_workspace_id
from specklepy.logging import metrics
from ....bpy_speckle import bl_info
from ..operations.publish_operation import publish_operation
from ..utils.account_manager import get_server_url_by_account_id
class SPECKLE_OT_publish(bpy.types.Operator):
bl_idname = "speckle.publish"
bl_label = "Publish to Speckle"
bl_description = "Publish selected objects to Speckle"
@@ -33,8 +18,12 @@ class SPECKLE_OT_publish(bpy.types.Operator):
def execute(self, context: Context) -> Set[str]:
wm = context.window_manager
if not context.selected_objects and not context.active_object:
self.report({"ERROR"}, "No objects selected to publish")
# check if we have stored objects from selection dialog
if not wm.speckle_objects:
self.report(
{"ERROR"},
"No objects selected to publish. Please use 'Select Objects' first.",
)
return {"CANCELLED"}
account_id = getattr(wm, "selected_account_id", "")
@@ -53,155 +42,52 @@ class SPECKLE_OT_publish(bpy.types.Operator):
self.report({"ERROR"}, "No model selected")
return {"CANCELLED"}
try:
account = next(
(acc for acc in get_local_accounts() if acc.id == account_id),
None,
)
if account is None:
self.report({"ERROR"}, "No Speckle account found")
return {"CANCELLED"}
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
transport = ServerTransport(stream_id=project_id, client=client)
# get objects to convert
objects_to_convert = context.selected_objects or [context.active_object]
speckle_objects = self.convert_selected_objects(context)
if not speckle_objects:
objects_to_convert = []
for speckle_obj in wm.speckle_objects:
blender_obj = bpy.data.objects.get(speckle_obj.name)
if blender_obj:
objects_to_convert.append(blender_obj)
else:
self.report(
{"ERROR"}, "No objects could be converted to Speckle format"
)
return {"CANCELLED"}
# get the Blender file name to set the name
file_name = bpy.path.basename(bpy.data.filepath)
collection_name = file_name if file_name else "Untitled.blend"
# create a collection to hold all objects
collection = Collection(name=collection_name)
collection.units = Units.m.value
collection["version"] = 3
for obj in speckle_objects:
if obj is not None:
collection.elements.append(obj)
add_render_material_proxies_to_base(collection, objects_to_convert)
obj_id = operations.send(collection, [transport])
version_input = CreateVersionInput(
objectId=obj_id,
modelId=model_id,
projectId=project_id,
message="",
sourceApplication="blender",
)
version = client.version.create(version_input)
version_id = version.id
metrics.set_host_app("blender")
metrics.track(
metrics.SEND,
account,
{
"ui": "dui3",
"hostAppVersion": ",".join(map(str, bl_info["blender"])),
"core_version": ",".join(map(str, bl_info["version"])),
"workspace_id": get_project_workspace_id(client, project_id),
},
)
# Update model card if needed
if hasattr(context.scene, "speckle_state") and hasattr(
context.scene.speckle_state, "model_cards"
):
model_card = context.scene.speckle_state.model_cards.add()
model_card.account_id = account_id
model_card.server_url = account.serverInfo.url
model_card.project_id = project_id
model_card.project_name = getattr(wm, "selected_project_name", "")
model_card.model_id = model_id
model_card.model_name = getattr(wm, "selected_model_name", "")
model_card.is_publish = True
model_card.load_option = "SPECIFIC" # Published versions are specific
model_card.version_id = version_id
model_card.collection_name = (
f"{getattr(wm, 'selected_model_name', 'Model')} - {version_id[:8]}"
{"WARNING"}, f"Object '{speckle_obj.name}' not found, skipping"
)
# Clear selected model details from Window Manager AFTER creating model card
wm.selected_account_id = ""
wm.selected_project_id = ""
wm.selected_project_name = ""
wm.selected_model_id = ""
wm.selected_model_name = ""
wm.selected_version_load_option = ""
wm.selected_version_id = ""
self.report(
{"INFO"},
f"Successfully published {len(speckle_objects)} objects to Speckle with materials",
)
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to publish: {str(e)}")
import traceback
traceback.print_exc()
if not objects_to_convert:
self.report({"ERROR"}, "None of the selected objects could be found")
return {"CANCELLED"}
def convert_selected_objects(self, context: Context) -> List[Optional[Base]]:
scene = context.scene
unit_settings = scene.unit_settings
success, message, version_id = publish_operation(context, objects_to_convert)
# get units from Blender's unit system
if unit_settings.system == "METRIC":
if unit_settings.length_unit == "METERS":
units = Units.m
elif unit_settings.length_unit == "CENTIMETERS":
units = Units.cm
elif unit_settings.length_unit == "MILLIMETERS":
units = Units.mm
elif unit_settings.length_unit == "KILOMETERS":
units = Units.km
else:
units = Units.m
elif unit_settings.system == "IMPERIAL":
if unit_settings.length_unit == "FEET":
units = Units.feet
elif unit_settings.length_unit == "INCHES":
units = Units.inches
elif unit_settings.length_unit == "YARDS":
units = Units.yards
elif unit_settings.length_unit == "MILES":
units = Units.miles
else:
units = Units.feet # default to feet
else:
units = Units.m # default to meters
if not success:
self.report({"ERROR"}, message)
return {"CANCELLED"}
scale_factor = unit_settings.scale_length
# create model card if operation was successful
if hasattr(context.scene, "speckle_state") and hasattr(
context.scene.speckle_state, "model_cards"
):
model_card = context.scene.speckle_state.model_cards.add()
model_card.account_id = account_id
model_card.server_url = get_server_url_by_account_id(account_id)
model_card.project_id = project_id
model_card.project_name = getattr(wm, "selected_project_name", "")
model_card.model_id = model_id
model_card.model_name = getattr(wm, "selected_model_name", "")
model_card.is_publish = True
model_card.load_option = "SPECIFIC" # published versions are specific
model_card.version_id = version_id
model_card.collection_name = (
f"{getattr(wm, 'selected_model_name', 'Model')} - {version_id[:8]}"
)
# convert each selected object
speckle_objects = []
objects_to_convert = context.selected_objects or [context.active_object]
for obj in objects_to_convert:
# Skip objects that are not supported
if not obj or obj.type not in ["MESH", "CURVE", "EMPTY"]:
continue
# clear selected model details from Window Manager
wm.selected_account_id = ""
wm.selected_project_id = ""
wm.selected_project_name = ""
wm.selected_model_id = ""
wm.selected_model_name = ""
wm.selected_version_load_option = ""
wm.selected_version_id = ""
# convert the object
speckle_obj = convert_to_speckle(obj, scale_factor, units.value)
if speckle_obj:
speckle_objects.append(speckle_obj)
return speckle_objects
self.report({"INFO"}, message)
return {"FINISHED"}
@@ -1 +1,2 @@
from ..operations.load_operation import load_operation # noqa: F401
from ..operations.publish_operation import publish_operation # noqa: F401
@@ -19,7 +19,7 @@ from ...converter.to_native import (
find_instance_definitions,
)
from specklepy.logging import metrics
from ....bpy_speckle import bl_info
from ... import bl_info
def load_operation(context: Context) -> None:
@@ -62,8 +62,8 @@ def load_operation(context: Context) -> None:
account,
{
"ui": "dui3",
"hostAppVersion": ",".join(map(str, bl_info["blender"])),
"core_version": ",".join(map(str, bl_info["version"])),
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"sourceHostApp": host_applications.get_host_app_from_string(
version.source_application
).slug,
@@ -0,0 +1,325 @@
import bpy
from bpy.types import Context, Collection as BlenderCollection
from typing import List, Optional, Dict, Tuple
from specklepy.objects import Base
from specklepy.objects.models.collections.collection import Collection
from specklepy.core.api import operations
from specklepy.core.api.client import SpeckleClient
from specklepy.transports.server import ServerTransport
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.credentials import get_local_accounts
from specklepy.objects.models.units import Units
from ...converter.to_speckle import convert_to_speckle
from ...converter.to_speckle.material_to_speckle import (
add_render_material_proxies_to_base,
)
from ...converter.utils import get_project_workspace_id
from specklepy.logging import metrics
from ... import bl_info
def publish_operation(
context: Context, objects_to_convert: List
) -> Tuple[bool, str, Optional[str]]:
"""
publish objects to speckle
"""
wm = context.window_manager
try:
# get account and authenticate
account = next(
(acc for acc in get_local_accounts() if acc.id == wm.selected_account_id),
None,
)
if account is None:
return False, "No Speckle account found", None
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
# build collection hierarchy and convert objects
root_collection = build_collection_hierarchy(context, objects_to_convert)
if not root_collection:
return False, "No objects could be converted to Speckle format", None
# add material proxies
add_render_material_proxies_to_base(root_collection, objects_to_convert)
obj_id = operations.send(root_collection, [transport])
version_input = CreateVersionInput(
objectId=obj_id,
modelId=wm.selected_model_id,
projectId=wm.selected_project_id,
message="",
sourceApplication="blender",
)
version = client.version.create(version_input)
version_id = version.id
# track metrics
metrics.set_host_app("blender")
metrics.track(
metrics.SEND,
account,
{
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"workspace_id": get_project_workspace_id(
client, wm.selected_project_id
),
},
)
# count total objects for success message
total_objects = count_objects_in_collection(root_collection)
return (
True,
f"Successfully published {total_objects} objects with hierarchy to Speckle",
version_id,
)
except Exception as e:
import traceback
traceback.print_exc()
return False, f"Failed to publish: {str(e)}", None
def build_collection_hierarchy(
context: Context, objects_to_convert: List
) -> Optional[Collection]:
"""
build a speckle collection hierarchy that mimicks blender's collection structure
"""
# set name for root collection
file_name = bpy.path.basename(bpy.data.filepath)
collection_name = file_name if file_name else "Untitled.blend"
collection_data = analyze_collection_structure(objects_to_convert)
if not collection_data["objects"] and not collection_data["collections"]:
return None
converted_objects = convert_selected_objects(context, objects_to_convert)
if not converted_objects:
return None
# create the root Speckle collection
root_collection = Collection(name=collection_name)
root_collection.units = get_scene_units(context.scene).value
root_collection["version"] = 3
# maps Blender collection to Speckle collection
collection_mapping = {} #
# create Speckle collections for each blender collection
for blender_coll in collection_data["collections"]:
speckle_coll = Collection(name=blender_coll.name)
speckle_coll.units = root_collection.units
collection_mapping[blender_coll] = speckle_coll
for blender_coll in collection_data["collections"]:
speckle_coll = collection_mapping[blender_coll]
parent_coll = find_parent_collection(
blender_coll, collection_data["collections"]
)
if parent_coll and parent_coll in collection_mapping:
parent_speckle_coll = collection_mapping[parent_coll]
parent_speckle_coll.elements.append(speckle_coll)
else:
root_collection.elements.append(speckle_coll)
# assign objects to their collections
object_mapping = {}
for i, blender_obj in enumerate(objects_to_convert):
if i < len(converted_objects) and converted_objects[i] is not None:
object_mapping[blender_obj] = converted_objects[i]
for blender_obj, speckle_obj in object_mapping.items():
placed = False
target_collection = find_target_collection_for_object(
blender_obj, collection_data["collections"]
)
if target_collection and target_collection in collection_mapping:
collection_mapping[target_collection].elements.append(speckle_obj)
placed = True
# if not placed in any subcollection, add to root
if not placed:
root_collection.elements.append(speckle_obj)
return root_collection
def analyze_collection_structure(objects: List) -> Dict:
"""
analyze the collection structure of the given objects
"""
collections_set = set()
objects_collections = {}
direct_collections = set()
for obj in objects:
obj_collections = []
for collection in bpy.data.collections:
if obj.name in collection.objects:
direct_collections.add(collection)
obj_collections.append(collection)
objects_collections[obj] = obj_collections
# find all ancestor collections
def find_all_ancestors(collection):
"""recursively find all ancestor collections"""
ancestors = set()
for potential_parent in bpy.data.collections:
if collection.name in potential_parent.children:
ancestors.add(potential_parent)
# Recursively find ancestors of the parent
ancestors.update(find_all_ancestors(potential_parent))
return ancestors
for collection in direct_collections:
collections_set.add(collection)
ancestors = find_all_ancestors(collection)
collections_set.update(ancestors)
collections_list = list(collections_set)
collections_list.sort(key=lambda c: get_collection_depth(c))
return {
"collections": collections_list,
"objects": objects,
"object_collections": objects_collections,
}
def get_collection_depth(collection: BlenderCollection) -> int:
"""
get the depth of a collection in the hierarchy
"""
depth = 0
for scene in bpy.data.scenes:
if collection.name in scene.collection.children:
return depth
for parent_coll in bpy.data.collections:
if collection.name in parent_coll.children:
return get_collection_depth(parent_coll) + 1
return depth
def find_parent_collection(
collection: BlenderCollection, all_collections: List[BlenderCollection]
) -> Optional[BlenderCollection]:
"""
find the parent collection
"""
for potential_parent in all_collections:
if collection.name in potential_parent.children:
return potential_parent
return None
def find_target_collection_for_object(
obj, collections: List[BlenderCollection]
) -> Optional[BlenderCollection]:
"""
find the deepest collection that contains this object
"""
target_collection = None
max_depth = -1
for collection in collections:
if obj.name in collection.objects:
depth = get_collection_depth(collection)
if depth > max_depth:
max_depth = depth
target_collection = collection
return target_collection
def convert_selected_objects(
context: Context, objects_to_convert: List
) -> List[Optional[Base]]:
"""
convert selected objects to Speckle format with proper units
"""
scene = context.scene
units = get_scene_units(scene)
scale_factor = scene.unit_settings.scale_length
speckle_objects = []
for obj in objects_to_convert:
if not obj or obj.type not in ["MESH", "CURVE", "EMPTY"]:
speckle_objects.append(None)
continue
speckle_obj = convert_to_speckle(obj, scale_factor, units.value)
speckle_objects.append(speckle_obj)
return speckle_objects
def get_scene_units(scene) -> Units:
"""
get units from Blender's unit system
"""
unit_settings = scene.unit_settings
if unit_settings.system == "METRIC":
if unit_settings.length_unit == "METERS":
return Units.m
elif unit_settings.length_unit == "CENTIMETERS":
return Units.cm
elif unit_settings.length_unit == "MILLIMETERS":
return Units.mm
elif unit_settings.length_unit == "KILOMETERS":
return Units.km
else:
return Units.m
elif unit_settings.system == "IMPERIAL":
if unit_settings.length_unit == "FEET":
return Units.feet
elif unit_settings.length_unit == "INCHES":
return Units.inches
elif unit_settings.length_unit == "YARDS":
return Units.yards
elif unit_settings.length_unit == "MILES":
return Units.miles
else:
return Units.feet
else:
return Units.m # default to meters
def count_objects_in_collection(collection: Collection) -> int:
"""
recursively count all objects in a collection and its sub-collections
"""
count = 0
if hasattr(collection, "elements"):
for element in collection.elements:
if isinstance(element, Collection):
count += count_objects_in_collection(element)
else:
count += 1
return count
+28 -47
View File
@@ -1,6 +1,7 @@
"""
Provides uniform and consistent path helpers for `specklepy`
"""
import os
import sys
from pathlib import Path
@@ -55,9 +56,7 @@ def user_application_data_path() -> Path:
if sys.platform.startswith("win"):
app_data_path = os.getenv("APPDATA")
if not app_data_path:
raise Exception(
"Cannot get appdata path from environment."
)
raise Exception("Cannot get appdata path from environment.")
return Path(app_data_path)
else:
# try getting the standard XDG_DATA_HOME value
@@ -68,9 +67,7 @@ def user_application_data_path() -> Path:
else:
return _ensure_folder_exists(Path.home(), ".config")
except Exception as ex:
raise Exception(
"Failed to initialize user application data path.", ex
)
raise Exception("Failed to initialize user application data path.", ex)
def user_speckle_folder_path() -> Path:
@@ -90,19 +87,16 @@ def user_speckle_connector_installation_path(host_application: str) -> Path:
)
print("Starting module dependency installation")
print(sys.executable)
PYTHON_PATH = sys.executable
def connector_installation_path(host_application: str) -> Path:
connector_installation_path = user_speckle_connector_installation_path(host_application)
connector_installation_path = user_speckle_connector_installation_path(
host_application
)
connector_installation_path.mkdir(exist_ok=True, parents=True)
# set user modules path at beginning of paths for earlier hit
@@ -113,7 +107,6 @@ def connector_installation_path(host_application: str) -> Path:
return connector_installation_path
def is_pip_available() -> bool:
try:
import_module("pip") # noqa F401
@@ -132,25 +125,9 @@ def ensure_pip() -> None:
if completed_process.returncode == 0:
print("Successfully installed pip")
else:
raise Exception(f"Failed to install pip, got {completed_process.returncode} return code")
def is_uv_available() -> bool:
try:
import_module("uv") # noqa F401
return True
except ImportError:
return False
def ensure_uv() -> None:
print("Installing uv... ")
from subprocess import run
completed_process = run([PYTHON_PATH, "-m", "pip", "install", "uv"])
if completed_process.returncode == 0:
print("Successfully installed uv")
else:
raise Exception(f"Failed to install uv, got {completed_process.returncode} return code")
raise Exception(
f"Failed to install pip, got {completed_process.returncode} return code"
)
def get_requirements_path() -> Path:
@@ -169,12 +146,12 @@ def install_requirements(host_application: str) -> None:
def debugger_is_active() -> bool:
"""Return if the debugger is currently active"""
return hasattr(sys, 'gettrace') and sys.gettrace() is not None
return hasattr(sys, "gettrace") and sys.gettrace() is not None
requirements_path = get_requirements_path()
is_debug = debugger_is_active()
if not is_debug and not requirements_path.exists():
print("Skipped installing dependencies")
return
@@ -184,11 +161,15 @@ def install_requirements(host_application: str) -> None:
[
PYTHON_PATH,
"-m",
"uv",
"pip",
"-q",
"--disable-pip-version-check",
"install",
"--system",
"--target",
"--prefer-binary",
"--ignore-installed",
"--no-compile",
"--no-deps",
"-t",
str(path),
"-r",
str(requirements_path),
@@ -198,10 +179,12 @@ def install_requirements(host_application: str) -> None:
)
if completed_process.returncode != 0:
m = f"Failed to install dependencies through uv, got {completed_process.returncode} return code"
print(completed_process.stdout)
print(completed_process.stderr)
m = f"Failed to install dependencies through pip, got {completed_process.returncode} return code"
print(m)
raise Exception(m)
print("Successfully installed dependencies")
if not is_debug:
@@ -211,9 +194,6 @@ def install_requirements(host_application: str) -> None:
def install_dependencies(host_application: str) -> None:
if not is_pip_available():
ensure_pip()
if not is_uv_available():
ensure_uv()
install_requirements(host_application)
@@ -223,7 +203,7 @@ def _import_dependencies() -> None:
# the code above doesn't work for now, it fails on importing graphql-core
# despite that, the connector seams to be working as expected
# But it would be nice to make this solution work
# it would ensure that all dependencies are fully loaded
# it would ensure that all dependencies are fully loaded
# requirements = get_requirements_path().read_text()
# reqs = [
# req.split(" ; ")[0].split("==")[0].split("[")[0].replace("-", "_")
@@ -234,6 +214,7 @@ def _import_dependencies() -> None:
# print(req)
# import_module("specklepy")
def ensure_dependencies(host_application: str) -> None:
try:
install_dependencies(host_application)
@@ -241,6 +222,6 @@ def ensure_dependencies(host_application: str) -> None:
_import_dependencies()
print("Successfully found dependencies")
except ImportError:
raise Exception(f"Cannot automatically ensure Speckle dependencies. Please try restarting the host application {host_application}!")
raise Exception(
f"Cannot automatically ensure Speckle dependencies. Please try restarting the host application {host_application}!"
)