Compare commits

...

46 Commits

Author SHA1 Message Date
Jedd Morgan 32dca397a3 Merge branch 'v3-dev' into jrm/speckle-client-https 2025-08-20 10:41:17 +01:00
Mucahit Bilal GOKER 5c19d9aa16 Merge pull request #300 from specklesystems/bilal/bump-specklepy-3.0.3
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
bump specklepy to 3.0.3
2025-07-24 21:21:09 +03:00
bimgeek 29d706e1b6 bump specklepy to 3.0.3 2025-07-24 21:18:14 +03:00
Mucahit Bilal GOKER 25d02673a7 Merge pull request #290 from specklesystems/bilal/cnx-2065-keep-track-of-loaded-object-properties
Persistent modifiers, visibility settings and tracking by applicationId
2025-07-22 17:19:53 +03:00
Jedd Morgan 6a4dc62622 small fix 2025-07-22 13:29:25 +01:00
Jedd Morgan bb9a5ea604 Merge branch 'v3-dev' into bilal/cnx-2065-keep-track-of-loaded-object-properties 2025-07-22 13:00:42 +01:00
Jedd Morgan 48759746dc fixed import 2025-07-22 11:57:56 +01:00
Jedd Morgan 4d5fb64893 Merge remote-tracking branch 'origin/v3-dev' into jrm/speckle-client-https 2025-07-22 11:50:27 +01:00
Jedd Morgan 6a3247aafa format 2025-07-22 11:49:32 +01:00
Jedd Morgan 2e995bd0fa chore(lint): Run Ruff (#298)
* ruff check

* format

* format
2025-07-22 11:48:25 +01:00
Jedd Morgan ae0280a630 format 2025-07-22 11:33:44 +01:00
Jedd Morgan 698b2a79fe Use ssl on client if only if url is https 2025-07-22 11:32:51 +01:00
Mucahit Bilal GOKER 38e6096ea9 Merge branch 'v3-dev' into bilal/cnx-2065-keep-track-of-loaded-object-properties 2025-07-22 12:43:49 +03:00
Mucahit Bilal GOKER 30e3398cd4 Merge pull request #296 from specklesystems/bilal/cnx-1798-update-bl_info
Update bl_info
2025-07-22 12:43:33 +03:00
bimgeek 3f7e98aff5 shorten description 2025-07-18 15:31:59 +03:00
Mucahit Bilal GOKER 1ad8429928 update bl_info and manifest 2025-07-18 15:24:36 +03:00
Mucahit Bilal GOKER a6c820183b fix: track collections by applicationId 2025-07-15 23:20:11 +03:00
Mucahit Bilal GOKER 56b6c813c0 remove logging of optimization metrics 2025-07-15 22:30:03 +03:00
Mucahit Bilal GOKER 8ab110f7ec only store modified objects and collections 2025-07-15 22:23:44 +03:00
Mucahit Bilal GOKER 11ff018f18 refactor visibility settings and modifiers storage: model cards > temp storage 2025-07-15 22:09:26 +03:00
Mucahit Bilal GOKER 227f63d266 Merge pull request #288 from specklesystems/bilal/cnx-2003-cache-speckle-clients
Bilal/cnx 2003 cache speckle clients
2025-07-15 16:04:58 +03:00
Mucahit Bilal GOKER 9e8aaf4f3b Merge branch 'v3-dev' into bilal/cnx-2003-cache-speckle-clients 2025-07-15 16:04:28 +03:00
Mucahit Bilal GOKER afcb760bbf added typing 2025-07-14 13:42:54 +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
Jedd Morgan d23cc5a738 Merge pull request #289 from specklesystems/jrm/requirementstxt
Ensure hashes are included in requirements.txt creaiton
2025-07-10 15:59:24 +01:00
Mucahit Bilal GOKER 3e2ac4b5b6 preserve modifiers 2025-07-06 22:14:28 +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
Mucahit Bilal GOKER d1f2c938b1 clear cache on unregister 2025-06-25 22:22:46 +03:00
Mucahit Bilal GOKER 388ec2bdfd use cached client for managers 2025-06-25 21:45:10 +03:00
Mucahit Bilal GOKER b057c6c0da use cached client for publish and load 2025-06-25 21:44:29 +03:00
Mucahit Bilal GOKER 40089bdbb8 resolve get_active_workspace 2025-06-25 21:44:10 +03:00
Mucahit Bilal GOKER 49dd688219 create project and model - use cached client 2025-06-25 21:43:51 +03:00
Mucahit Bilal GOKER 6993e8cb83 initialize client on startup 2025-06-25 21:41:54 +03:00
Mucahit Bilal GOKER 709015b9d8 Merge branch 'v3-dev' into bilal/cnx-2003-cache-speckle-clients 2025-06-25 16:18:07 +03:00
Dogukan Karatas c5e0dfa36b Merge pull request #287 from specklesystems/bilal/handle-no-account-state
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
Bilal/handle no account state
2025-06-23 11:54:04 +02:00
Mucahit Bilal GOKER 1f72741b62 update collapse icon 2025-06-23 11:34:43 +03:00
Mucahit Bilal GOKER 0f8f7e02be Added error handling in get_active_workspace to return default workspace if no account is found for the given ID. 2025-06-23 11:14:09 +03:00
Mucahit Bilal GOKER bbf8a3b45e remove unused time import 2025-06-16 14:09:52 +03:00
Mucahit Bilal GOKER f1eec55633 remove time limits 2025-06-16 14:09:48 +03:00
Mucahit Bilal GOKER f2bc9a9701 client caching first pass 2025-06-16 14:09:42 +03:00
29 changed files with 699 additions and 355 deletions
+25 -7
View File
@@ -21,24 +21,23 @@ from .installer import ensure_dependencies
ensure_dependencies(f"Blender {bpy.app.version[0]}.{bpy.app.version[1]}")
bl_info = {
"name": "Speckle Blender ",
"author": "Speckle Systems",
"name": "Speckle Connector",
"author": "Speckle",
"version": (3, 999, 999),
"blender": (4, 2, 0),
"location": "3d viewport toolbar (N), under the Speckle tab.",
"description": "The Speckle Connector using specklepy 3.x!",
"warning": "This add-on is WIP and should be used with caution",
"wiki_url": "https://github.com/specklesystems/speckle-blender",
"description": "Publish models to and load models from other AEC apps.",
"wiki_url": "https://speckle.systems/connectors/blender",
"category": "Scene",
}
# UI
from .connector.ui.main_panel import SPECKLE_PT_main_panel
from .connector.utils.account_manager import speckle_workspace
from .connector.ui.project_selection_dialog import (
SPECKLE_OT_project_selection_dialog,
SPECKLE_UL_projects_list,
speckle_workspace,
)
from .connector.ui.model_selection_dialog import (
SPECKLE_OT_model_selection_dialog,
@@ -81,7 +80,11 @@ from .connector.blender_operators.add_project_by_url import (
from .connector.blender_operators.create_project import SPECKLE_OT_create_project
from .connector.blender_operators.create_model import SPECKLE_OT_create_model
from .connector.utils.account_manager import speckle_account
from .connector.utils.account_manager import (
speckle_account,
get_default_account_id,
_client_cache,
)
# States
from .connector.states.speckle_state import (
@@ -186,10 +189,25 @@ def register():
invoke_window_manager_properties()
# Pre-warm client cache for default account
try:
default_account_id = get_default_account_id()
if default_account_id:
print(
f"[Speckle] Pre-warming client for default account: {default_account_id}"
)
_client_cache.get_client(default_account_id)
print(
f"[Speckle] Client pre-warming complete for account: {default_account_id}"
)
except Exception as e:
print(f"[Speckle] Failed to pre-warm client: {e}")
def unregister():
icons.unload_icons()
unregister_speckle_state() # Unregister SpeckleState
_client_cache.clear()
for cls in classes:
bpy.utils.unregister_class(cls)
+4 -12
View File
@@ -11,7 +11,7 @@ maintainer = "Speckle"
type = "add-on"
# Optional link to documentation, support, source files, etc
website = "https://app.speckle.systems/connectors"
website = "https://speckle.systems/connectors/blender"
# Optional list defined by Blender and server, see:
# https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html
@@ -24,13 +24,9 @@ blender_version_min = "4.2.0"
# License conforming to https://spdx.org/licenses/ (use "SPDX: prefix)
# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html
license = [
"SPDX:Apache-2.0",
]
license = ["SPDX:Apache-2.0"]
# Optional: required by some licenses.
copyright = [
"2022-2025 AEC SYSTEMS LTD",
]
copyright = ["2022-2025 AEC SYSTEMS LTD"]
# Optional list of supported platforms. If omitted, the extension will be available in all operating systems.
# platforms = ["windows-x64", "macos-arm64", "linux-x64"]
@@ -67,8 +63,4 @@ clipboard = "Copy and paste URLs and Names (UI)"
# Optional: build settings.
# https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build
[build]
paths_exclude_pattern = [
"__pycache__/",
"/.vscode",
"*.code-workspace",
]
paths_exclude_pattern = ["__pycache__/", "/.vscode", "*.code-workspace"]
@@ -2,37 +2,37 @@ import bpy
import webbrowser
from bpy.types import Event, Context
class SPECKLE_OT_add_account(bpy.types.Operator):
"""Operator for adding a new Speckle account.
"""
"""Operator for adding a new Speckle account."""
bl_idname = "speckle.add_account"
bl_label = "Add New Account"
bl_description = "Add a new account"
server_url: bpy.props.StringProperty( # type: ignore
name="Server URL",
description="Speckle server URL to connect to",
default="https://app.speckle.systems"
default="https://app.speckle.systems",
)
def invoke(self, context: Context, event: Event) -> set[str]:
return context.window_manager.invoke_props_dialog(self)
def draw(self, context: Context):
layout = self.layout
# Server URL textbox
layout.prop(self, "server_url", text="Server URL")
def execute(self, context: Context) -> set[str]:
# Logic to handle sign in
api_url = "http://localhost:29364"
url = f"{api_url}/auth/add-account?serverUrl={self.server_url}"
webbrowser.open(url)
self.report({'INFO'}, f"Adding account from {self.server_url}: {url}")
self.report({"INFO"}, f"Adding account from {self.server_url}: {url}")
# Force redraw
context.window.screen = context.window.screen
context.area.tag_redraw()
return {'FINISHED'}
return {"FINISHED"}
@@ -1,5 +1,5 @@
import bpy
from bpy.types import Context, Event, UILayout, WindowManager
from bpy.types import Context, Event, UILayout
from ..utils.account_manager import (
get_model_details_by_wrapper,
get_project_from_url,
@@ -1,9 +1,9 @@
import bpy
from bpy.types import Context, Event, UILayout
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import get_local_accounts, Account
from specklepy.core.api.inputs import CreateModelInput
from typing import List, Tuple, Optional
from specklepy.core.api.models import Model
from ..utils.account_manager import _client_cache
class SPECKLE_OT_create_model(bpy.types.Operator):
@@ -19,14 +19,14 @@ class SPECKLE_OT_create_model(bpy.types.Operator):
if not self.model_name.strip():
self.report({"ERROR"}, "Model name cannot be empty")
return {"CANCELLED"}
try:
model_id, model_name = create_model(
model = _create_model(
wm.selected_account_id, wm.selected_project_id, self.model_name
)
wm.selected_model_id = model_id
wm.selected_model_name = model_name
self.report({"INFO"}, f"Created model: {model_name} -> ID: {model_id}")
wm.selected_model_id = model.id
wm.selected_model_name = model.name
self.report({"INFO"}, f"Created model: {model.name} -> ID: {model.id}")
# Force redraw
context.window.screen = context.window.screen
context.area.tag_redraw()
@@ -51,19 +51,18 @@ def unregister() -> None:
bpy.utils.unregister_class(SPECKLE_OT_create_model)
def create_model(account_id: str, project_id: str, model_name: str) -> Tuple[str, str]:
accounts: List[Account] = get_local_accounts()
account: Optional[Account] = next(
(acc for acc in accounts if acc.id == account_id), None
)
def _create_model(account_id: str, project_id: str, model_name: str) -> Model:
try:
# Get cached client
client = _client_cache.get_client(account_id)
if not account:
raise ValueError(f"Account with ID {account_id} not found")
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
model = client.model.create(
input=CreateModelInput(name=model_name, description="", project_id=project_id)
)
# Function is annotated to return Tuple[str, str] but currently returns a list.
return (model.id, model.name)
model = client.model.create(
input=CreateModelInput(
name=model_name, description="", project_id=project_id
)
)
return model
except Exception as e:
# Clear cache on error to prevent stale clients
_client_cache.clear()
raise e
@@ -1,12 +1,13 @@
from typing import Optional
import bpy
from bpy.types import Context, Event, UILayout
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import get_local_accounts, Account
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs import ProjectCreateInput
from specklepy.core.api.inputs.project_inputs import WorkspaceProjectCreateInput
from specklepy.core.api.enums import ProjectVisibility
from typing import List, Tuple, Optional
from specklepy.core.api.models import Project
from ..utils.account_manager import _client_cache
class SPECKLE_OT_create_project(bpy.types.Operator):
@@ -22,16 +23,16 @@ class SPECKLE_OT_create_project(bpy.types.Operator):
def execute(self, context: Context) -> set[str]:
wm = context.window_manager
project_id, project_name = create_project(
project = _create_project(
wm.selected_account_id,
self.project_name,
None
if wm.selected_workspace.id == "personal"
else wm.selected_workspace.id,
)
wm.selected_project_id = project_id
wm.selected_project_name = project_name
self.report({"INFO"}, f"Created project: {project_name} -> ID: {project_id}")
wm.selected_project_id = project.id
wm.selected_project_name = project.name
self.report({"INFO"}, f"Created project: {project.name} -> ID: {project.id}")
# Force redraw
context.window.screen = context.window.screen
context.area.tag_redraw()
@@ -53,23 +54,19 @@ def unregister() -> None:
bpy.utils.unregister_class(SPECKLE_OT_create_project)
def create_project(
def _create_project(
account_id: str, project_name: str, workspace_id: Optional[str]
) -> Tuple[str, str]:
) -> Project:
try:
accounts: List[Account] = get_local_accounts()
account: Optional[Account] = next(
(acc for acc in accounts if acc.id == account_id), None
)
# Get cached client
client = _client_cache.get_client(account_id)
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
if workspace_id:
project = client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
visibility=ProjectVisibility.PUBLIC,
workspaceId=workspace_id,
)
)
@@ -78,11 +75,13 @@ def create_project(
input=ProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
visibility=ProjectVisibility.PUBLIC,
)
)
return (project.id, project.name)
return project
except Exception as e:
print(f"Failed to create project: {str(e)}")
# Clear cache on error to prevent stale clients
_client_cache.clear()
raise
@@ -6,6 +6,7 @@ from ..operations.load_operation import load_operation
from ..utils.model_card_utils import (
delete_model_card_objects,
update_model_card_objects,
collect_objects_with_properties,
)
@@ -27,6 +28,7 @@ class SPECKLE_OT_load_model_card(bpy.types.Operator):
self.report({"ERROR"}, "Model card not found")
return {"CANCELLED"}
old_properties = collect_objects_with_properties(model_card)
delete_model_card_objects(model_card, context)
# set wm
@@ -48,7 +50,7 @@ class SPECKLE_OT_load_model_card(bpy.types.Operator):
context, model_card.instance_loading_mode
)
# update model card details
update_model_card_objects(model_card, converted_objects)
update_model_card_objects(model_card, converted_objects, old_properties)
model_card.version_id = latest_version_id
else:
@@ -63,7 +65,7 @@ class SPECKLE_OT_load_model_card(bpy.types.Operator):
self.report({"ERROR"}, "Load operation failed")
return {"CANCELLED"}
# update model card details
update_model_card_objects(model_card, converted_objects)
update_model_card_objects(model_card, converted_objects, old_properties)
# Clear selected model details from Window Manager
wm.selected_account_id = ""
@@ -1,26 +1,25 @@
from typing import Dict, Union
import bpy
from bpy.types import Context
from specklepy.core.api.credentials import get_local_accounts
from specklepy.transports.server import ServerTransport
from specklepy.core.api import operations
from specklepy.core.api.client import SpeckleClient
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.core.api import host_applications, operations
from specklepy.logging import metrics
from specklepy.objects.graph_traversal.default_traversal import (
create_default_traversal_function,
)
from specklepy.core.api import host_applications
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.transports.server import ServerTransport
from ..utils.get_ascendants import get_ascendants
from ...converter.utils import find_object_by_id, get_project_workspace_id
from ... import bl_info
from ...converter.to_native import (
convert_to_native,
render_material_proxy_to_native,
instance_definition_proxy_to_native,
find_instance_definitions,
instance_definition_proxy_to_native,
render_material_proxy_to_native,
)
from specklepy.logging import metrics
from ... import bl_info
from typing import Dict, Union
from ...converter.utils import find_object_by_id
from ..utils.account_manager import _client_cache
from ..utils.get_ascendants import get_ascendants
def load_operation(
@@ -32,25 +31,10 @@ def load_operation(
wm = context.window_manager
# get account
account = next(
(
acc
for acc in get_local_accounts()
if acc.id == context.window_manager.selected_account_id
),
None,
)
# get cached client
client = _client_cache.get_client(context.window_manager.selected_account_id)
if account is None:
print("No Speckle account found")
return {}
print(f"Using account: {account.userInfo.email}")
# receive the data
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
print(f"Using client for account: {context.window_manager.selected_account_id}")
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
@@ -63,7 +47,7 @@ def load_operation(
metrics.track(
metrics.RECEIVE,
account,
client.account,
{
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
@@ -71,8 +55,8 @@ def load_operation(
"sourceHostApp": host_applications.get_host_app_from_string(
version.source_application
).slug,
"isMultiplayer": version.author_user.id != account.userInfo.id,
"workspace_id": get_project_workspace_id(client, wm.selected_project_id),
"isMultiplayer": version.author_user.id != client.account.userInfo.id,
"workspace_id": client.project.get(wm.selected_project_id).workspace_id,
},
)
@@ -103,7 +87,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)
@@ -140,7 +124,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
@@ -153,6 +137,7 @@ def load_operation(
"id": speckle_obj.id,
"name": collection_name,
"parent_id": parent_id,
"applicationId": getattr(speckle_obj, "applicationId", ""),
"blender_collection": None,
"full_path": [collection_name],
}
@@ -208,6 +193,8 @@ def load_operation(
blender_collection = created_collections[collection_key]
else:
blender_collection = bpy.data.collections.new(coll_name)
if coll_info.get("applicationId"):
blender_collection["applicationId"] = coll_info["applicationId"]
parent_collection.children.link(blender_collection)
created_collections[collection_key] = blender_collection
@@ -1,23 +1,22 @@
import bpy
from bpy.types import Context, Collection as BlenderCollection
from typing import List, Optional, Dict, Tuple
from typing import Dict, List, Optional, Tuple
import bpy
from bpy.types import Collection as BlenderCollection
from bpy.types import Context
from specklepy.core.api import operations
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.logging import metrics
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 specklepy.transports.server import ServerTransport
from ... import bl_info
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
from ..utils.account_manager import _client_cache
def publish_operation(
@@ -32,22 +31,15 @@ def publish_operation(
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)
# get cached client
client = _client_cache.get_client(wm.selected_account_id)
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, apply_modifiers)
root_collection = build_collection_hierarchy(
context, objects_to_convert, apply_modifiers
)
if not root_collection:
return False, "No objects could be converted to Speckle format", None
@@ -58,11 +50,11 @@ def publish_operation(
obj_id = operations.send(root_collection, [transport])
version_input = CreateVersionInput(
objectId=obj_id,
modelId=wm.selected_model_id,
projectId=wm.selected_project_id,
object_id=obj_id,
model_id=wm.selected_model_id,
project_id=wm.selected_project_id,
message=version_message,
sourceApplication="blender",
source_application="blender",
)
version = client.version.create(version_input)
@@ -72,14 +64,12 @@ def publish_operation(
metrics.set_host_app("blender")
metrics.track(
metrics.SEND,
account,
client.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
),
"workspace_id": client.project.get(wm.selected_project_id).workspace_id,
},
)
@@ -96,6 +86,8 @@ def publish_operation(
import traceback
traceback.print_exc()
# Clear cache on error to prevent stale clients
_client_cache.clear()
return False, f"Failed to publish: {str(e)}", None
@@ -114,7 +106,9 @@ def build_collection_hierarchy(
if not collection_data["objects"] and not collection_data["collections"]:
return None
converted_objects = convert_selected_objects(context, objects_to_convert, apply_modifiers)
converted_objects = convert_selected_objects(
context, objects_to_convert, apply_modifiers
)
if not converted_objects:
return None
@@ -276,7 +270,9 @@ def convert_selected_objects(
speckle_objects.append(None)
continue
speckle_obj = convert_to_speckle(obj, scale_factor, units.value, apply_modifiers)
speckle_obj = convert_to_speckle(
obj, scale_factor, units.value, apply_modifiers
)
speckle_objects.append(speckle_obj)
return speckle_objects
+1 -1
View File
@@ -1 +1 @@
from .main_panel import SPECKLE_PT_main_panel # noqa: F401
from .main_panel import SPECKLE_PT_main_panel # noqa: F401
+1 -1
View File
@@ -169,5 +169,5 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
# Settings button in the model card
row_1.operator(
"speckle.model_card_settings", text="", icon="THREE_DOTS"
"speckle.model_card_settings", text="", icon="COLLAPSEMENU"
).model_card_id = model_card.get_model_card_id()
@@ -1,8 +1,8 @@
import bpy
from bpy.types import UILayout, Context, PropertyGroup, Event
from bpy.types import Context, Event, PropertyGroup, UILayout
from ..utils.model_manager import get_models_for_project
from ..utils.version_manager import get_latest_version
from ..utils.property_groups import speckle_model
class SPECKLE_UL_models_list(bpy.types.UIList):
@@ -2,10 +2,6 @@ import bpy
from bpy.types import UILayout, Context, PropertyGroup, Event
from typing import List, Tuple
from ..utils.account_manager import (
get_account_enum_items,
speckle_account,
get_workspaces,
speckle_workspace,
can_create_project_in_workspace,
get_active_workspace,
get_default_account_id,
@@ -64,7 +60,6 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
"""
wm = context.window_manager
wm.can_create_project_in_workspace = can_create_project_in_workspace(
wm.selected_account_id, wm.selected_workspace.id
)
@@ -126,7 +121,9 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
wm.selected_account_id = get_default_account_id()
wm.selected_workspace.id = get_active_workspace(wm.selected_account_id)["id"]
wm.selected_workspace.name = get_active_workspace(wm.selected_account_id)["name"]
wm.selected_workspace.name = get_active_workspace(wm.selected_account_id)[
"name"
]
# Fetch projects from server
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
@@ -78,7 +78,7 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
layout.label(text=f"Project: {project_name}")
layout.label(text=f"Model: {model_name}")
#layout.prop(self, "selection_type")
# layout.prop(self, "selection_type")
layout.separator()
selected_objects: List[Object] = context.selected_objects
+102 -58
View File
@@ -1,13 +1,48 @@
from typing import Dict, List, Optional, Tuple
import bpy
from specklepy.core.api.credentials import get_local_accounts
from typing import List, Tuple, Optional, Dict
from specklepy.core.api.credentials import Account
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account, get_local_accounts
from specklepy.core.api.wrapper import StreamWrapper
from .misc import strip_non_ascii
class SpeckleClientCache:
def __init__(self):
self._clients: Dict[str, SpeckleClient] = {}
def get_client(self, account_id: str) -> SpeckleClient:
# Check cache first
if account_id in self._clients:
print(f"[Cache HIT] Using cached client for account {account_id}")
return self._clients[account_id]
# Create new client if needed
print(f"[Cache MISS] Creating new client for account {account_id}")
account = get_account_from_id(account_id)
if not account:
raise ValueError(f"No account found for ID: {account_id}")
assert account.serverInfo.url
client = SpeckleClient(
host=account.serverInfo.url,
use_ssl=account.serverInfo.url.startswith("https"),
)
client.authenticate_with_account(account)
self._clients[account_id] = client
return client
def clear(self) -> None:
"""Clear all cached clients."""
print("[Cache] Clearing all cached clients")
self._clients.clear()
# Global cache instance
_client_cache = SpeckleClientCache()
class speckle_account(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
user_name: bpy.props.StringProperty() # type: ignore
@@ -47,37 +82,44 @@ def get_workspaces(account_id: str) -> List[Tuple[str, str]]:
"""
retrieves the workspaces for a given account ID
"""
account = next((acc for acc in get_local_accounts() if acc.id == account_id), None)
if not account:
print("No accounts found > No workspaces!")
try:
# Get client from cache
client = _client_cache.get_client(account_id)
workspaces_enabled = client.server.get().workspaces.workspaces_enabled
if workspaces_enabled:
workspaces = client.active_user.get_workspaces().items
workspace_list = [
(ws.id, strip_non_ascii(ws.name))
for ws in workspaces
if ws.creation_state is None or ws.creation_state.completed
]
personal_projects_text = "Personal Projects (Legacy)"
else:
workspace_list = []
personal_projects_text = "Personal Projects"
workspace_list.append(("personal", personal_projects_text))
if workspaces_enabled:
active_workspace = client.active_user.get_active_workspace()
default_workspace_id = (
active_workspace.id if active_workspace else "personal"
)
result = reorder_tuple(workspace_list, default_workspace_id)
else:
result = workspace_list
return result
except Exception as e:
print(f"Error in get_workspaces: {str(e)}")
_client_cache.clear() # Clear cache on error
return [("", "")]
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
workspaces_enabled = client.server.get().workspaces.workspaces_enabled
if workspaces_enabled:
workspaces = client.active_user.get_workspaces().items
workspace_list = [
(ws.id, strip_non_ascii(ws.name))
for ws in workspaces
if ws.creation_state is None or ws.creation_state.completed
]
personal_projects_text = "Personal Projects (Legacy)"
else:
workspace_list = []
personal_projects_text = "Personal Projects"
workspace_list.append(("personal", personal_projects_text))
print("Workspaces added")
if workspaces_enabled:
active_workspace = client.active_user.get_active_workspace()
default_workspace_id = active_workspace.id if active_workspace else "personal"
return reorder_tuple(workspace_list, default_workspace_id)
else:
return workspace_list
def get_default_account_id() -> Optional[str]:
"""
@@ -103,13 +145,16 @@ def get_active_workspace(account_id: str) -> Optional[Dict[str, str]]:
"""
retrieves the ID of the default workspace for a given account ID
"""
account = next((acc for acc in get_local_accounts() if acc.id == account_id), None)
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
active_workspace = client.active_user.get_active_workspace()
if active_workspace:
return {"id": active_workspace.id, "name": active_workspace.name}
return {"id": "personal", "name": "Personal Projects"}
try:
client = _client_cache.get_client(account_id)
active_workspace = client.active_user.get_active_workspace()
if active_workspace:
return {"id": active_workspace.id, "name": active_workspace.name}
return {"id": "personal", "name": "Personal Projects"}
except Exception as e:
print(f"Error in get_active_workspace: {str(e)}")
_client_cache.clear()
return None
def get_account_from_id(account_id: str) -> Optional[Account]:
@@ -138,8 +183,9 @@ def get_project_from_url(
"""
try:
wrapper = StreamWrapper(url)
client = wrapper.get_client()
client.authenticate_with_account(wrapper.get_account())
account = wrapper.get_account()
assert account.id
client = _client_cache.get_client(account.id)
# get the stream_id (project_id) from the wrapper
if not wrapper.stream_id:
@@ -243,21 +289,19 @@ def can_create_project_in_workspace(account_id: str, workspace_id: str) -> bool:
"""
Check if the user can create a project in the specified workspace.
"""
account = get_account_from_id(account_id)
if not account:
print(f"No account found for ID: {account_id}")
try:
client = _client_cache.get_client(account_id)
if workspace_id == "personal":
return client.active_user.can_create_personal_projects().authorized
else:
try:
workspace = client.workspace.get(workspace_id)
return workspace.permissions.can_create_project.authorized
except Exception as e:
print(f"Failed to get workspace: {str(e)}")
return False
except Exception as e:
print(f"Error in can_create_project_in_workspace: {str(e)}")
_client_cache.clear() # Clear cache on error
return False
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
# wrap the workspace request in try/except and return False on any exception to keep the UI responsive.
if workspace_id == "personal":
return client.active_user.can_create_personal_projects().authorized
else:
try:
workspace = client.workspace.get(workspace_id)
return workspace.permissions.can_create_project.authorized
except Exception as e:
print(f"Failed to get workspace: {str(e)}")
return False
+3 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
import re
def format_relative_time(timestamp) -> str:
"""
convert UTC timestamp to local timezone and return relative time string
@@ -46,6 +47,7 @@ def format_role(role: str) -> str:
split_role = role.split(":")
return f"{split_role[1]}"
def strip_non_ascii(text):
# Keep English letters, digits, spaces and basic punctuation
return re.sub(r'[^a-zA-Z0-9\s.,!?]', '', text)
return re.sub(r"[^a-zA-Z0-9\s.,!?]", "", text)
+339 -9
View File
@@ -1,42 +1,372 @@
import bpy
from bpy.types import Context
from typing import Dict
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,
):
# clear model card objects
"""
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()
# if converted_objects is a list, convert it to a dictionary
# 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():
# if its a collection, add it to collections field of model card
# 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
# if its an object, add it to the objects field of model card
if isinstance(obj, bpy.types.Object):
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
"""
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 +384,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:
+8 -18
View File
@@ -1,8 +1,9 @@
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import get_local_accounts, Account
from typing import List, Optional, Tuple
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
from specklepy.core.api.models.current import Model
from typing import List, Tuple, Optional
from .account_manager import _client_cache
from .misc import format_relative_time, strip_non_ascii
@@ -19,22 +20,9 @@ def get_models_for_project(
)
return []
# Get the account info
account: Optional[Account] = next(
(acc for acc in get_local_accounts() if acc.id == account_id), None
)
if not account:
print(f"Error: Could not find account with ID: {account_id}")
return []
client = _client_cache.get_client(account_id)
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
try:
client.project.get(project_id)
except Exception as e:
print(f"Error: Project with ID {project_id} not found: {str(e)}")
return []
client.project.get(project_id)
filter = ProjectModelsFilter(search=search) if search else None
@@ -53,4 +41,6 @@ def get_models_for_project(
except Exception as e:
print(f"Error fetching models: {str(e)}")
# Clear cache on error to prevent stale clients
_client_cache.clear()
return []
+31 -20
View File
@@ -1,31 +1,39 @@
from typing import List, Optional, Tuple
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import get_local_accounts
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from typing import List, Tuple, Optional
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
from .account_manager import _client_cache
from .misc import format_relative_time, format_role, strip_non_ascii
def get_projects_for_account(
account_id: str, workspace_id: str = None, search: Optional[str] = None
account_id: str, workspace_id: str, search: Optional[str] = None
) -> List[Tuple[str, str, str, str, bool]]:
"""
fetches projects for a given account from the Speckle server
"""
try:
accounts: List[Account] = get_local_accounts()
account: Optional[Account] = next(
(acc for acc in accounts if acc.id == account_id), None
)
if not account:
# Get cached client
client = _client_cache.get_client(account_id)
if not client:
print(f"Error: Could not get client for account: {account_id}")
return []
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
# Get account for workspace operations that still need it
from specklepy.core.api.credentials import get_local_accounts
account: Optional[Account] = next(
(acc for acc in get_local_accounts() if acc.id == account_id), None
)
if not account:
print(f"Error: Could not find account with ID: {account_id}")
return []
if workspace_id == "personal":
return _get_personal_projects_with_permissions(client, account, search)
return _get_personal_projects_with_permissions(client, search)
try:
workspace_resource = WorkspaceResource(
@@ -75,7 +83,7 @@ def get_projects_for_account(
f"WorkspaceResource failed, falling back to old method: {workspace_error}"
)
return _get_projects_with_individual_permissions(
client, account, workspace_id, search
client, workspace_id, search
)
except Exception as e:
@@ -84,22 +92,25 @@ def get_projects_for_account(
error_msg = f"Error: {str(e)}\n"
error_msg += f"Traceback:\n{''.join(traceback.format_tb(e.__traceback__))}"
print(error_msg)
# Clear cache on error to prevent stale clients
_client_cache.clear()
return []
def _get_personal_projects_with_permissions(
client: SpeckleClient, account: Account, search: Optional[str] = None
client: SpeckleClient, search: Optional[str] = None
) -> List[Tuple[str, str, str, str, bool]]:
"""
helper function to get personal projects with permissions using the old method
"""
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from .account_manager import can_load
filter = UserProjectsFilter(
search=search,
workspaceId=None,
personalOnly=True,
workspace_id=None,
personal_only=True,
include_implicit_access=True,
)
@@ -126,7 +137,6 @@ def _get_personal_projects_with_permissions(
def _get_projects_with_individual_permissions(
client: SpeckleClient,
account: Account,
workspace_id: str,
search: Optional[str] = None,
) -> List[Tuple[str, str, str, str, bool]]:
@@ -134,12 +144,13 @@ def _get_projects_with_individual_permissions(
Fallback helper function to get projects with permissions using individual API calls
"""
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from .account_manager import can_load
filter = UserProjectsFilter(
search=search,
workspaceId=workspace_id,
personalOnly=False,
workspace_id=workspace_id,
personal_only=False,
include_implicit_access=True,
)
@@ -1,5 +1,4 @@
import bpy
from typing import Dict, Any
class speckle_project(bpy.types.PropertyGroup):
@@ -37,18 +36,20 @@ class speckle_version(bpy.types.PropertyGroup):
class speckle_object(bpy.types.PropertyGroup):
"""
PropertyGroup for storing object names
PropertyGroup for storing object names and applicationIds
"""
name: bpy.props.StringProperty() # type: ignore
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
class speckle_collection(bpy.types.PropertyGroup):
"""
PropertyGroup for storing collection information
PropertyGroup for storing collections
"""
name: bpy.props.StringProperty() # type: ignore
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
class speckle_model_card(bpy.types.PropertyGroup):
+14 -32
View File
@@ -1,10 +1,11 @@
from typing import List, Tuple
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import get_local_accounts, Account
from typing import List, Tuple, Optional
from .misc import format_relative_time
from specklepy.core.api.inputs.model_inputs import ModelVersionsFilter
from specklepy.core.api.models.current import Version
from .account_manager import _client_cache
from .misc import format_relative_time
def get_versions_for_model(
account_id: str, project_id: str, model_id: str
@@ -20,24 +21,11 @@ def get_versions_for_model(
)
return []
# Get the account info
account: Optional[Account] = next(
(acc for acc in get_local_accounts() if acc.id == account_id), None
)
if not account:
print(f"Error: Could not find account with ID: {account_id}")
return []
# Initialize the client
client: SpeckleClient = SpeckleClient(host=account.serverInfo.url)
# Authenticate
client.authenticate_with_account(account)
filter: ModelVersionsFilter = ModelVersionsFilter(priorityIds=[])
client: SpeckleClient = _client_cache.get_client(account_id)
# Get versions
versions: List[Version] = client.version.get_versions(
project_id=project_id, model_id=model_id, limit=10, filter=filter
versions = client.version.get_versions(
project_id=project_id, model_id=model_id, limit=10
)
versions_list: List[Tuple[str, str, str]] = []
for version in versions.items:
@@ -55,6 +43,8 @@ def get_versions_for_model(
except Exception as e:
print(f"Error fetching versions: {str(e)}")
# Clear cache on error to prevent stale clients
_client_cache.clear()
return []
@@ -69,18 +59,8 @@ def get_latest_version(
)
return ("", "", "")
# Get the account info
account: Optional[Account] = next(
(acc for acc in get_local_accounts() if acc.id == account_id), None
)
if not account:
print(f"Error: Could not find account with ID: {account_id}")
return ("", "", "")
# Initialize the client
client: SpeckleClient = SpeckleClient(host=account.serverInfo.url)
# Authenticate
client.authenticate_with_account(account)
# Get cached client
client: SpeckleClient = _client_cache.get_client(account_id)
# Get versions (limit to 1 since we only need the latest)
versions: List[Version] = client.version.get_versions(
@@ -100,4 +80,6 @@ def get_latest_version(
except Exception as e:
print(f"Error fetching latest version: {str(e)}")
# Clear cache on error to prevent stale clients
_client_cache.clear()
return ("", "", "")
+2 -2
View File
@@ -1,3 +1,3 @@
from ..converter.to_native import * #noqa: F403
from ..converter.to_speckle import * #noqa: F403
from ..converter.to_native import * # noqa: F403
from ..converter.to_speckle import * # noqa: F403
from ..converter.utils import * # noqa: F403
+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:
+2 -2
View File
@@ -2,5 +2,5 @@ from .to_speckle import convert_to_speckle # noqa: F401
from .material_to_speckle import ( # noqa: F401
blender_material_to_speckle,
create_render_material_proxies,
add_render_material_proxies_to_base
)
add_render_material_proxies_to_base,
)
+12 -10
View File
@@ -19,12 +19,12 @@ def convert_to_speckle(
# handle curve modifiers apply_modifiers is True
if apply_modifiers and blender_object.modifiers:
import bpy
# Convert curve with modifiers to mesh
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated_obj = blender_object.evaluated_get(depsgraph)
evaluated_mesh = evaluated_obj.to_mesh()
if evaluated_mesh:
meshes = mesh_to_speckle_meshes(
blender_object, evaluated_mesh, scale_factor, units
@@ -50,20 +50,22 @@ def convert_to_speckle(
mesh_data = blender_object.data
if apply_modifiers and blender_object.modifiers:
import bpy
# use evaluated object to get mesh with modifiers applied
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated_obj = blender_object.evaluated_get(depsgraph)
evaluated_mesh = evaluated_obj.to_mesh()
mesh_data = evaluated_mesh
meshes = mesh_to_speckle_meshes(
blender_object, mesh_data, scale_factor, units
)
if apply_modifiers and blender_object.modifiers and mesh_data != blender_object.data:
meshes = mesh_to_speckle_meshes(blender_object, mesh_data, scale_factor, units)
if (
apply_modifiers
and blender_object.modifiers
and mesh_data != blender_object.data
):
blender_object.to_mesh_clear()
if meshes:
display_value = meshes
-19
View File
@@ -5,7 +5,6 @@ from specklepy.objects import Base
from specklepy.objects.graph_traversal.default_traversal import (
create_default_traversal_function,
)
from specklepy.core.api.client import SpeckleClient
def to_rgba(argb_int: int) -> Tuple[float, float, float, float]:
@@ -187,21 +186,3 @@ def find_object_by_id(root_object: Base, target_id: str) -> Optional[Base]:
return None
return deep_search(root_object)
def get_project_workspace_id(client: SpeckleClient, project_id: str) -> Optional[str]:
workspace_id = None
server_version = client.project.server_version or client.server.version()
# Local yarn builds of server will report a server version if "dev"
# We'll assume that local builds are up-to-date with the latest features
if server_version[0] == "dev":
maj = 999
min = 999
else:
maj = server_version[0]
min = server_version[1]
if maj > 2 or (maj == 2 and min > 20):
workspace_id = client.project.get(project_id).workspace_id
return workspace_id
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash
set -e -o pipefail
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --all-extras
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
+9 -4
View File
@@ -1,6 +1,7 @@
import re
import sys
def patch_addon(simple_version: str):
"""Patches the __init__.py bl_info version within the connector init file"""
FILE_PATH = "bpy_speckle/__init__.py"
@@ -9,13 +10,16 @@ def patch_addon(simple_version: str):
with open(FILE_PATH, "r") as file:
lines = file.readlines()
for (index, line) in enumerate(lines):
for index, line in enumerate(lines):
if '"version":' in line:
lines[index] = f' "version": ({version[0]}, {version[1]}, {version[2]}),\n'
lines[index] = (
f' "version": ({version[0]}, {version[1]}, {version[2]}),\n'
)
with open(FILE_PATH, "w") as file:
file.writelines(lines)
def patch_manifest(simple_version: str):
"""Patches the connector version within the connector init file"""
FILE_PATH = "bpy_speckle/blender_manifest.toml"
@@ -24,8 +28,8 @@ def patch_manifest(simple_version: str):
with open(FILE_PATH, "r") as file:
lines = file.readlines()
for (index, line) in enumerate(lines):
if line.startswith('version ='):
for index, line in enumerate(lines):
if line.startswith("version ="):
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}",\n'
print(f"Patched connector version number in {FILE_PATH}")
break
@@ -33,6 +37,7 @@ def patch_manifest(simple_version: str):
with open(FILE_PATH, "w") as file:
file.writelines(lines)
def main():
tag = sys.argv[1]
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
+1 -1
View File
@@ -5,7 +5,7 @@ description = "Next-Gen Speckle connector for Blender!"
requires-python = ">=3.11.9, <4.0.0"
license = "Apache-2.0"
dependencies = [
"specklepy>=3.0.1",
"specklepy>=3.0.3",
]
[dependency-groups]