Compare commits

..

6 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
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 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
22 changed files with 233 additions and 559 deletions
+5 -4
View File
@@ -7,7 +7,7 @@
</h3>
<p align="center"><b>Speckle</b> is the data infrastructure for the AEC industry.</p><br/>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://docs.speckle.systems/dev/"><img src="https://img.shields.io/badge/docs-docs.speckle.systems-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center"><a href="https://github.com/specklesystems/speckle-blender/"><img src="https://circleci.com/gh/specklesystems/speckle-blender.svg?style=svg&amp;circle-token=76eabd350ea243575cbb258b746ed3f471f7ac29" alt="Speckle-Next"></a> </p>
# About Speckle
@@ -25,19 +25,20 @@ What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube
- **GraphQL API:** get what you need anywhere you want it
- **Webhooks:** the base for a automation and next-gen pipelines
- **Built for developers:** we are building Speckle with developers in mind and got tools for every stack
- **Built for the AEC industry:** Speckle connectors are plugins for the most common software used in the industry such as Revit, Rhino, Grasshopper, AutoCAD, Civil 3D, Blender and more!
- **Built for the AEC industry:** Speckle connectors are plugins for the most common software used in the industry such as Revit, Rhino, Grasshopper, AutoCAD, Civil 3D, Excel, Unreal Engine, Unity, QGIS, Blender and more!
### Try Speckle now!
Give Speckle a try in no time by:
- [![speckle XYZ](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
- [![speckle XYZ](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://speckle.xyz) ⇒ creating an account at our public server
- [![create a droplet](https://img.shields.io/badge/Create%20a%20Droplet-0069ff?style=flat-square&logo=digitalocean&logoColor=white)](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
### Resources
- [![Community forum users](https://img.shields.io/badge/community-forum-green?style=for-the-badge&logo=discourse&logoColor=white)](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-docs.speckle.systems-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://docs.speckle.systems/connectors/blender) reference on almost any end-user and developer functionality
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/user/blender.html) reference on almost any end-user and developer functionality
# Blender Connector
-27
View File
@@ -34,8 +34,6 @@ bl_info = {
# UI
from .connector.ui.main_panel import SPECKLE_PT_main_panel
from .connector.ui.update_panel import SPECKLE_PT_update_panel
from .connector.ui.model_cards_panel import SPECKLE_PT_model_cards_panel
from .connector.utils.account_manager import speckle_workspace
from .connector.ui.project_selection_dialog import (
SPECKLE_OT_project_selection_dialog,
@@ -82,8 +80,6 @@ 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.blender_operators.version_check import SPECKLE_OT_version_check
from .connector.blender_operators.update_button import SPECKLE_OT_update_button
from .connector.utils.account_manager import (
speckle_account,
get_default_account_id,
@@ -109,14 +105,6 @@ from .connector.ui.account_selection_dialog import (
)
def delayed_version_check():
"""Timer function to check for updates after addon startup"""
try:
bpy.ops.speckle.version_check()
except Exception as e:
print(f"[Speckle] Failed to check for updates: {e}")
def invoke_window_manager_properties():
# Accounts
WindowManager.speckle_accounts = bpy.props.CollectionProperty(type=speckle_account)
@@ -151,17 +139,11 @@ def invoke_window_manager_properties():
)
# Objects
WindowManager.speckle_objects = bpy.props.CollectionProperty(type=speckle_object)
# Update checking
WindowManager.update_available = bpy.props.BoolProperty(default=False)
WindowManager.latest_version = bpy.props.StringProperty(default="")
WindowManager.update_url = bpy.props.StringProperty(default="")
# Classes to load
classes = (
SPECKLE_PT_update_panel,
SPECKLE_PT_main_panel,
SPECKLE_PT_model_cards_panel,
SPECKLE_OT_publish,
SPECKLE_OT_load,
SPECKLE_OT_project_selection_dialog,
@@ -189,8 +171,6 @@ classes = (
SPECKLE_OT_add_project_by_url,
SPECKLE_OT_create_project,
SPECKLE_OT_create_model,
SPECKLE_OT_version_check,
SPECKLE_OT_update_button,
speckle_account,
SPECKLE_UL_workspaces_list,
SPECKLE_OT_workspace_selection_dialog,
@@ -223,15 +203,8 @@ def register():
except Exception as e:
print(f"[Speckle] Failed to pre-warm client: {e}")
# Use a timer to delay the version check
bpy.app.timers.register(delayed_version_check, first_interval=2.0)
def unregister():
# Clear any pending timers to prevent duplicate calls
if bpy.app.timers.is_registered(delayed_version_check):
bpy.app.timers.unregister(delayed_version_check)
icons.unload_icons()
unregister_speckle_state() # Unregister SpeckleState
_client_cache.clear()
@@ -1,7 +1,7 @@
import bpy
from bpy.types import Context, Event, UILayout
from specklepy.core.api.inputs import CreateModelInput
from typing import Tuple
from specklepy.core.api.models import Model
from ..utils.account_manager import _client_cache
@@ -21,12 +21,12 @@ class SPECKLE_OT_create_model(bpy.types.Operator):
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,17 @@ 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]:
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 client:
raise ValueError(f"Could not get client for account: {account_id}")
model = client.model.create(
input=CreateModelInput(
name=model_name, description="", project_id=project_id
)
)
return (model.id, model.name)
return model
except Exception as e:
# Clear cache on error to prevent stale clients
_client_cache.clear()
@@ -1,10 +1,11 @@
from typing import Optional
import bpy
from bpy.types import Context, Event, UILayout
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 Tuple, Optional
from specklepy.core.api.models import Project
from ..utils.account_manager import _client_cache
@@ -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,20 +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:
# Get cached client
client = _client_cache.get_client(account_id)
if not client:
raise Exception(f"Could not get client for account: {account_id}")
if workspace_id:
project = client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
visibility=ProjectVisibility.PUBLIC,
workspaceId=workspace_id,
)
)
@@ -75,11 +75,11 @@ 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
@@ -1,27 +0,0 @@
import bpy
import webbrowser
from bpy.types import Context
class SPECKLE_OT_update_button(bpy.types.Operator):
"""Operator for opening the download URL for the latest Speckle Blender connector"""
bl_idname = "speckle.update_button"
bl_label = "Update"
bl_description = "Download the latest version of the Speckle Blender connector"
def execute(self, context: Context) -> set[str]:
wm = context.window_manager
if not wm.update_url:
self.report({"ERROR"}, "No update URL available")
return {"CANCELLED"}
try:
webbrowser.open(wm.update_url)
self.report({"INFO"}, f"Opening download page for v{wm.latest_version}")
except Exception as e:
self.report({"ERROR"}, f"Failed to open download page: {str(e)}")
return {"CANCELLED"}
return {"FINISHED"}
@@ -1,51 +0,0 @@
import bpy
from bpy.types import Context
from specklepy.core.api.connector_versions import get_latest_version
# Get current version from bl_info
from ... import bl_info
class SPECKLE_OT_version_check(bpy.types.Operator):
"""Operator for checking if a newer version of the Speckle Blender connector is available"""
bl_idname = "speckle.version_check"
bl_label = "Check for Updates"
bl_description = (
"Check if a newer version of the Speckle Blender connector is available"
)
def execute(self, context: Context) -> set[str]:
wm = context.window_manager
# Reset previous state
wm.update_available = False
wm.latest_version = ""
wm.update_url = ""
try:
current_version = bl_info["version"]
current_version_str = (
f"{current_version[0]}.{current_version[1]}.{current_version[2]}"
)
# Get latest version info
latest_version_info = get_latest_version("blender", False)
latest_version_str = latest_version_info.number # semantic version string
# Compare versions - if they're different, show update
if latest_version_str != current_version_str:
wm.update_available = True
wm.latest_version = latest_version_str
wm.update_url = str(
latest_version_info.url
) # Convert HttpUrl to string
self.report({"INFO"}, f"Update available: v{latest_version_str}")
else:
self.report({"INFO"}, "You have the latest version")
except Exception as e:
error_msg = f"Failed to check for updates: {str(e)}"
self.report({"ERROR"}, error_msg)
return {"FINISHED"}
@@ -1,29 +1,25 @@
from typing import Dict, Union
import bpy
from bpy.types import Context
from specklepy.transports.server import ServerTransport
from specklepy.core.api import operations
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 ..utils.account_manager import _client_cache
from ...converter.utils import (
find_object_by_id,
get_project_workspace_id,
build_object_id_map,
)
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(
@@ -37,9 +33,6 @@ def load_operation(
# get cached client
client = _client_cache.get_client(context.window_manager.selected_account_id)
if not client:
print("No Speckle client found")
return {}
print(f"Using client for account: {context.window_manager.selected_account_id}")
@@ -52,47 +45,26 @@ def load_operation(
metrics.set_host_app("blender")
# Get account for metrics tracking
from specklepy.core.api.credentials import get_local_accounts
account = next(
(
acc
for acc in get_local_accounts()
if acc.id == context.window_manager.selected_account_id
),
None,
metrics.track(
metrics.RECEIVE,
client.account,
{
"ui": "dui3",
"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,
"isMultiplayer": version.author_user.id != client.account.userInfo.id,
"workspace_id": client.project.get(wm.selected_project_id).workspace_id,
},
)
if account:
metrics.track(
metrics.RECEIVE,
account,
{
"ui": "dui3",
"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,
"isMultiplayer": version.author_user.id != account.userInfo.id,
"workspace_id": get_project_workspace_id(
client, wm.selected_project_id
),
},
)
# Build object ID map once
object_id_map = build_object_id_map(version_data)
# Create material mapping first
material_mapping = render_material_proxy_to_native(version_data)
definition_collections, definition_objects = instance_definition_proxy_to_native(
version_data,
material_mapping,
instance_loading_mode=instance_loading_mode,
object_id_map=object_id_map,
version_data, material_mapping, instance_loading_mode=instance_loading_mode
)
definitions_root_collection = None
@@ -106,8 +78,7 @@ def load_operation(
for definition in find_instance_definitions(version_data).values():
definition_object_ids.update(definition.objects)
for obj_id in definition.objects:
# Use ID map
found_obj = object_id_map.get(obj_id)
found_obj = find_object_by_id(version_data, obj_id)
if found_obj:
if hasattr(found_obj, "id"):
definition_object_ids.add(found_obj.id)
@@ -1,22 +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.transports.server import ServerTransport
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
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 ..utils.account_manager import _client_cache
from specklepy.logging import metrics
from ... import bl_info
def publish_operation(
@@ -33,8 +33,6 @@ def publish_operation(
try:
# get cached client
client = _client_cache.get_client(wm.selected_account_id)
if not client:
return False, "No Speckle client found", None
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
@@ -52,40 +50,29 @@ 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)
version_id = version.id
# Get account for metrics tracking
from specklepy.core.api.credentials import get_local_accounts
account = next(
(acc for acc in get_local_accounts() if acc.id == wm.selected_account_id),
None,
# track metrics
metrics.set_host_app("blender")
metrics.track(
metrics.SEND,
client.account,
{
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"workspace_id": client.project.get(wm.selected_project_id).workspace_id,
},
)
if account:
# 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)
@@ -123,11 +123,7 @@ def update_workspaces_list(context: Context) -> None:
workspace: speckle_workspace = wm.speckle_workspaces.add()
workspace.id = id
workspace.name = name
active_workspace = get_active_workspace(wm.selected_account_id)
if active_workspace:
wm.selected_workspace.id = active_workspace["id"]
else:
wm.selected_workspace.id = "personal"
wm.selected_workspace.id = get_active_workspace(wm.selected_account_id)["id"]
print("Updated Workspaces List!")
+67
View File
@@ -104,3 +104,70 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
row = layout.row()
row.enabled = project_selected and model_selected and version_selected
row.operator("speckle.load", text="Load Model", icon="IMPORT")
layout.separator()
# group model cards by project name
project_groups = {}
for model_card in context.scene.speckle_state.model_cards:
project_name = (
model_card.project_name if model_card.project_name else "No Project"
)
if project_name not in project_groups:
project_groups[project_name] = []
project_groups[project_name].append(model_card)
for project_name, model_cards in project_groups.items():
project_box = layout.box()
project_row = project_box.row()
project_row.label(text=f"Project: {project_name}", icon="TRIA_RIGHT")
for model_card in model_cards:
box: UILayout = project_box.box()
row_1: UILayout = box.row()
row_2: UILayout = box.row()
if model_card.is_publish:
# Publish button in the model card
row_1.operator(
"speckle.model_card_publish", text="", icon="EXPORT"
).model_card_id = model_card.get_model_card_id()
# Selection filter button in the model card
row_2.operator(
"speckle.selection_filter_dialog",
text=f"Selection: {len(model_card.objects)} objects",
).model_card_id = model_card.get_model_card_id()
elif not model_card.is_publish:
# Load button in the model card
row_1.operator(
"speckle.model_card_load", text="", icon="IMPORT"
).model_card_id = model_card.get_model_card_id()
version_button_text = (
f"Latest: {model_card.version_id}"
if model_card.load_option == "LATEST"
else f"{model_card.version_id}"
)
row_2.operator(
"speckle.version_selection_dialog",
text=version_button_text,
).model_card_id = model_card.get_model_card_id()
# TODO: Get last updated time
else:
print({"ERROR"}, "Model card state unknown")
return
row_1.label(text=f"{model_card.model_name}")
# Select button in the model card
select_op = row_1.operator(
"speckle.select_objects",
text="",
icon_value=get_icon("object_highlight"),
)
select_op.model_card_id = model_card.get_model_card_id()
# Settings button in the model card
row_1.operator(
"speckle.model_card_settings", text="", icon="COLLAPSEMENU"
).model_card_id = model_card.get_model_card_id()
@@ -1,89 +0,0 @@
import bpy
from bpy.types import UILayout, Context
from .icons import get_icon
class SPECKLE_PT_model_cards_panel(bpy.types.Panel):
"""
Panel for displaying Speckle model cards.
"""
bl_label = "Model Cards"
bl_idname = "SPECKLE_PT_model_cards_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Speckle"
bl_order = 1
@classmethod
def poll(cls, context: Context) -> bool:
"""Only show panel when model cards exist"""
return bool(context.scene.speckle_state.model_cards)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
# group model cards by project name
project_groups = {}
for model_card in context.scene.speckle_state.model_cards:
project_name = (
model_card.project_name if model_card.project_name else "No Project"
)
if project_name not in project_groups:
project_groups[project_name] = []
project_groups[project_name].append(model_card)
for project_name, model_cards in project_groups.items():
project_box = layout.box()
project_row = project_box.row()
project_row.label(text=f"Project: {project_name}", icon="TRIA_RIGHT")
for model_card in model_cards:
box: UILayout = project_box.box()
row_1: UILayout = box.row()
row_2: UILayout = box.row()
if model_card.is_publish:
# Publish button in the model card
row_1.operator(
"speckle.model_card_publish", text="", icon="EXPORT"
).model_card_id = model_card.get_model_card_id()
# Selection filter button in the model card
row_2.operator(
"speckle.selection_filter_dialog",
text=f"Selection: {len(model_card.objects)} objects",
).model_card_id = model_card.get_model_card_id()
elif not model_card.is_publish:
# Load button in the model card
row_1.operator(
"speckle.model_card_load", text="", icon="IMPORT"
).model_card_id = model_card.get_model_card_id()
version_button_text = (
f"Latest: {model_card.version_id}"
if model_card.load_option == "LATEST"
else f"{model_card.version_id}"
)
row_2.operator(
"speckle.version_selection_dialog",
text=version_button_text,
).model_card_id = model_card.get_model_card_id()
# TODO: Get last updated time
else:
print({"ERROR"}, "Model card state unknown")
return
row_1.label(text=f"{model_card.model_name}")
# Select button in the model card
select_op = row_1.operator(
"speckle.select_objects",
text="",
icon_value=get_icon("object_highlight"),
)
select_op.model_card_id = model_card.get_model_card_id()
# Settings button in the model card
row_1.operator(
"speckle.model_card_settings", text="", icon="COLLAPSEMENU"
).model_card_id = model_card.get_model_card_id()
@@ -1,5 +1,6 @@
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
@@ -120,13 +120,10 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
if wm.selected_account_id == "":
wm.selected_account_id = get_default_account_id()
active_workspace = get_active_workspace(wm.selected_account_id)
if active_workspace:
wm.selected_workspace.id = active_workspace["id"]
wm.selected_workspace.name = active_workspace["name"]
else:
wm.selected_workspace.id = "personal"
wm.selected_workspace.name = "Personal Projects"
wm.selected_workspace.id = get_active_workspace(wm.selected_account_id)["id"]
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(
-48
View File
@@ -1,48 +0,0 @@
import bpy
from bpy.types import UILayout, Context
class SPECKLE_PT_update_panel(bpy.types.Panel):
"""Panel for displaying connector update notifications"""
bl_label = "Update Speckle"
bl_idname = "SPECKLE_PT_update_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Speckle"
bl_order = 0 # This ensures it appears above the main panel
@classmethod
def poll(cls, context: Context) -> bool:
"""Only show this panel when an update is available"""
wm = context.window_manager
return getattr(wm, "update_available", False)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
wm = context.window_manager
# Get current version from bl_info
from ... import bl_info
current_version = bl_info["version"]
current_version_str = (
f"{current_version[0]}.{current_version[1]}.{current_version[2]}"
)
# Update notification
box = layout.box()
box.alert = True # Makes the box stand out with alert styling
col = box.column()
col.label(text="New version available!", icon="INFO")
row = col.row()
row.label(text=f"Current: v{current_version_str}")
row = col.row()
row.label(text=f"Latest: v{wm.latest_version}")
# Update button
row = col.row()
row.operator("speckle.update_button", text="Download Update", icon="LINKED")
+10 -7
View File
@@ -1,10 +1,10 @@
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 urllib.parse import urlparse
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
@@ -24,9 +24,11 @@ class SpeckleClientCache:
if not account:
raise ValueError(f"No account found for ID: {account_id}")
url = account.serverInfo.url
use_ssl = urlparse(url).scheme.lower() != "http"
client = SpeckleClient(host=url, use_ssl=use_ssl)
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
@@ -182,6 +184,7 @@ def get_project_from_url(
try:
wrapper = StreamWrapper(url)
account = wrapper.get_account()
assert account.id
client = _client_cache.get_client(account.id)
# get the stream_id (project_id) from the wrapper
@@ -1,8 +1,6 @@
from typing import Any, Dict, Optional
import bpy
from bpy.types import Context
from typing import Dict, Any, Optional
from ..utils.property_groups import speckle_model_card
@@ -318,17 +316,11 @@ def update_model_card_objects(
if isinstance(converted_objects, list):
converted_objects = {obj.name: obj for obj in converted_objects}
# Using a set keeps lookup O(1)
object_names = set()
collection_names = set()
for obj in converted_objects.values():
# Handle collections
if isinstance(obj, bpy.types.Collection):
if obj.name in collection_names:
if obj.name in (o.name for o in model_card.collections):
continue
collection_names.add(obj.name)
s_col = model_card.collections.add()
s_col.name = obj.name
s_col.applicationId = obj.get("applicationId", "")
@@ -342,10 +334,8 @@ def update_model_card_objects(
# Handle objects
elif isinstance(obj, bpy.types.Object):
if obj.name in object_names:
if obj.name in (o.name for o in model_card.objects):
continue
object_names.add(obj.name)
s_obj = model_card.objects.add()
s_obj.name = obj.name
s_obj.applicationId = obj.get("applicationId", "")
+5 -11
View File
@@ -1,8 +1,10 @@
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 .misc import format_relative_time, strip_non_ascii
from .account_manager import _client_cache
from .misc import format_relative_time, strip_non_ascii
def get_models_for_project(
@@ -18,17 +20,9 @@ def get_models_for_project(
)
return []
# 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 []
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
+13 -9
View File
@@ -1,14 +1,16 @@
from typing import List, Optional, Tuple
from specklepy.core.api.client import SpeckleClient
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 .misc import format_relative_time, format_role, strip_non_ascii
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
@@ -102,12 +104,13 @@ def _get_personal_projects_with_permissions(
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,
)
@@ -141,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,
)
+7 -15
View File
@@ -1,10 +1,11 @@
from specklepy.core.api.client import SpeckleClient
from .account_manager import _client_cache
from typing import List, Tuple
from .misc import format_relative_time
from specklepy.core.api.inputs.model_inputs import ModelVersionsFilter
from specklepy.core.api.client import SpeckleClient
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,17 +21,11 @@ def get_versions_for_model(
)
return []
# Get cached client
client: SpeckleClient = _client_cache.get_client(account_id)
if not client:
print(f"Error: Could not get client for account: {account_id}")
return []
filter: ModelVersionsFilter = ModelVersionsFilter(priorityIds=[])
# 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:
@@ -66,9 +61,6 @@ def get_latest_version(
# Get cached client
client: SpeckleClient = _client_cache.get_client(account_id)
if not client:
print(f"Error: Could not get client for account: {account_id}")
return ("", "", "")
# Get versions (limit to 1 since we only need the latest)
versions: List[Version] = client.version.get_versions(
+37 -88
View File
@@ -159,14 +159,7 @@ def convert_to_native(
else:
# Fallback to display value if direct conversion not supported
mesh, children = display_value_to_native(
speckle_object,
object_name,
data_block_name,
scale,
material_mapping,
definition_collections,
root_collection,
instance_loading_mode,
speckle_object, object_name, data_block_name, scale, material_mapping
)
if mesh:
# Create a mesh object with the object_name (simple name) and mesh data
@@ -183,11 +176,7 @@ def convert_to_native(
# Ensure the converted object has the correct name (especially for DataObjects)
if isinstance(speckle_object, DataObject):
converted_object.name = object_name
if (
hasattr(converted_object, "data")
and converted_object.data is not None
):
data_block_name = converted_object.data.name
data_block_name = converted_object.data.name
# If there are multiple objects, parent remaining ones to the first
for child in children[1:]:
@@ -208,9 +197,6 @@ def display_value_to_native(
data_block_name: str,
scale: float,
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
root_collection: Optional[bpy.types.Collection] = None,
instance_loading_mode: str = "INSTANCE_PROXIES",
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
"""
fallback conversion mechanism using displayValue if present
@@ -229,9 +215,6 @@ def display_value_to_native(
DISPLAY_VALUE_PROPERTY_ALIASES,
True,
material_mapping,
definition_collections,
root_collection,
instance_loading_mode,
)
# If the parent had an applicationId and we created a mesh, apply the material
@@ -264,9 +247,6 @@ def elements_to_native(
data_block_name: str,
scale: float,
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
root_collection: Optional[bpy.types.Collection] = None,
instance_loading_mode: str = "INSTANCE_PROXIES",
) -> List[Object]:
"""
convert elements collection of a speckle object
@@ -279,9 +259,6 @@ def elements_to_native(
ELEMENTS_PROPERTY_ALIASES,
False,
material_mapping,
definition_collections,
root_collection,
instance_loading_mode,
)
return elements
@@ -294,16 +271,12 @@ def _members_to_native(
members: Iterable[str],
combineMeshes: bool,
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
root_collection: Optional[bpy.types.Collection] = None,
instance_loading_mode: str = "INSTANCE_PROXIES",
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
"""
converts a given speckle_object by converting specified members
"""
meshes: List[Mesh] = []
others: List[Base] = []
instance_proxies: List[InstanceProxy] = []
for alias in members:
display = getattr(speckle_object, alias, None)
@@ -312,13 +285,10 @@ def _members_to_native(
MAX_DEPTH = 255 # some large value, to prevent infinite recursion
def separate(value: Any) -> bool:
nonlocal meshes, others, instance_proxies, count, MAX_DEPTH
nonlocal meshes, others, count, MAX_DEPTH
if combineMeshes and isinstance(value, Mesh):
meshes.append(value)
elif isinstance(value, InstanceProxy):
# Handle InstanceProxy objects separately - they need definition_collections
instance_proxies.append(value)
elif isinstance(value, Base):
others.append(value)
elif isinstance(value, list):
@@ -348,28 +318,10 @@ def _members_to_native(
# Check if the original object is a DataObject
is_data_object = isinstance(speckle_object, DataObject)
# Process InstanceProxy objects - do not add to children list as they are already
for item in instance_proxies:
try:
convert_to_native(
item,
material_mapping,
definition_collections=definition_collections,
root_collection=root_collection,
instance_loading_mode="LINKED_DUPLICATES", # always use Linked Duplicates for displayValue proxies
)
except Exception as ex:
print(f"Failed to convert instance proxy in display value {item}: {ex}")
# Process other objects
for item in others:
try:
blender_object = convert_to_native(
item,
material_mapping,
definition_collections=definition_collections,
root_collection=root_collection,
instance_loading_mode=instance_loading_mode,
item, material_mapping, instance_loading_mode="INSTANCE_PROXIES"
)
if blender_object:
# If the parent is a DataObject, override the name of the converted child
@@ -695,7 +647,7 @@ def render_material_proxy_to_native(
continue
render_material = proxy.value
material_name = getattr(render_material, "name", None) or "Material"
material_name = getattr(render_material, "name", "Material")
# create or get existing material
blender_material = create_material_from_proxy(render_material, material_name)
@@ -1035,14 +987,7 @@ def curve_to_native(
):
print("curve_to_native: degree 2 curve, falling back to displayValue")
mesh, children = display_value_to_native(
speckle_curve,
object_name,
data_block_name,
scale,
None,
None,
None,
"INSTANCE_PROXIES",
speckle_curve, object_name, data_block_name, scale
)
if mesh:
curve_obj = bpy.data.objects.new(object_name, mesh)
@@ -1114,14 +1059,7 @@ def polycurve_to_native(
and speckle_polycurve.displayValue
):
mesh, children = display_value_to_native(
speckle_polycurve,
object_name,
data_block_name,
scale,
None,
None,
None,
"INSTANCE_PROXIES",
speckle_polycurve, object_name, data_block_name, scale
)
if mesh:
curve_obj = bpy.data.objects.new(object_name, mesh)
@@ -1273,7 +1211,6 @@ def instance_definition_proxy_to_native(
material_mapping: Dict[str, Any],
processed_definitions: Dict[str, Any] = None,
instance_loading_mode: str = "INSTANCE_PROXIES",
object_id_map: Optional[Dict[str, Base]] = None,
) -> Tuple[Dict[str, bpy.types.Collection], Dict[str, Any]]:
"""
converts instance definition proxies to Blender collections recursively
@@ -1325,8 +1262,7 @@ def instance_definition_proxy_to_native(
# Process objects, including nested instances
if hasattr(definition, "objects") and isinstance(definition.objects, list):
for obj_id in definition.objects:
# Use the ID map for lookup
found_obj = object_id_map.get(obj_id) if object_id_map else None
found_obj = find_object_by_id(root_object, obj_id)
if found_obj:
try:
@@ -1426,8 +1362,7 @@ def instance_proxy_to_linked_duplicates(
print(f"Definition collection not found for instance {speckle_instance.id}")
return None
# Use the scale from the parent context
unit_scale = scale
unit_scale = proxy_scale(speckle_instance)
# convert transformation matrix
matrix = mathutils.Matrix(
@@ -1462,6 +1397,7 @@ def instance_proxy_to_linked_duplicates(
location, rotation, scale_vector = matrix.decompose()
location = location * unit_scale
# create transformation matrix
final_matrix = (
mathutils.Matrix.Translation(location)
@ rotation.to_matrix().to_4x4()
@@ -1473,9 +1409,11 @@ def instance_proxy_to_linked_duplicates(
parent_empty.empty_display_type = "PLAIN_AXES"
parent_empty.empty_display_size = 0.1
root_collection.objects.link(parent_empty)
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
@@ -1484,14 +1422,15 @@ def instance_proxy_to_linked_duplicates(
duplicated_objects = []
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)
duplicate_obj.parent = parent_empty
duplicate_obj.matrix_parent_inverse.identity()
duplicate_obj.matrix_basis = obj.matrix_world
# apply the instance transformation directly to each object
duplicate_obj.matrix_world = final_matrix @ obj.matrix_world
duplicated_objects.append(duplicate_obj)
@@ -1511,8 +1450,7 @@ def instance_proxy_to_native(
print(f"Definition collection not found for instance {speckle_instance.id}")
return None
# Use the scale from the parent context
unit_scale = scale
unit_scale = proxy_scale(speckle_instance)
# convert transformation matrix
matrix = mathutils.Matrix(
@@ -1545,24 +1483,35 @@ def instance_proxy_to_native(
)
location, rotation, scale_vector = matrix.decompose()
location = location * unit_scale
instance_name = f"Instance_{speckle_instance.id}"
instance_obj = bpy.data.objects.new(instance_name, None)
instance_obj.instance_type = "COLLECTION"
instance_obj.instance_collection = definition_collection
bpy.ops.object.collection_instance_add(
collection=definition_collection.name,
align="WORLD",
location=(0, 0, 0),
rotation=(0, 0, 0),
scale=(1, 1, 1),
)
instance_obj = bpy.context.active_object
instance_obj.empty_display_size = 0
# Link to root collection
root_collection.objects.link(instance_obj)
instance_name = f"Instance_{speckle_instance.id}"
instance_obj.name = instance_name
if instance_obj.name not in root_collection.objects:
for coll in instance_obj.users_collection:
coll.objects.unlink(instance_obj)
root_collection.objects.link(instance_obj)
# Store metadata
instance_obj["speckle_id"] = speckle_instance.id
instance_obj["speckle_type"] = speckle_instance.speckle_type
instance_obj["definition_id"] = speckle_instance.definitionId
if hasattr(speckle_instance, "maxDepth"):
instance_obj["max_depth"] = speckle_instance.maxDepth
# Apply transformation
final_matrix = (
mathutils.Matrix.Translation(location)
@ rotation.to_matrix().to_4x4()
+1 -39
View File
@@ -1,11 +1,10 @@
from typing import Tuple, List, Optional, Dict
from typing import Tuple, List, Optional
import bpy
import mathutils
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]:
@@ -118,25 +117,6 @@ def transform_matrix(transform: List[float]) -> mathutils.Matrix:
)
def build_object_id_map(root_object: Base) -> Dict[str, Base]:
"""
Builds a dictionary mapping object IDs (both id and applicationId) to objects.
"""
id_map = {}
traversal_function = create_default_traversal_function()
for traversal_item in traversal_function.traverse(root_object):
obj = traversal_item.current
if hasattr(obj, "id") and obj.id:
id_map[obj.id] = obj
if hasattr(obj, "applicationId") and obj.applicationId:
id_map[obj.applicationId] = obj
return id_map
def find_object_by_id(root_object: Base, target_id: str) -> Optional[Base]:
"""
finds an object using traversal, checking both id and applicationId
@@ -206,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
+6 -2
View File
@@ -5,8 +5,12 @@ description = "Next-Gen Speckle connector for Blender!"
requires-python = ">=3.11.9, <4.0.0"
license = "Apache-2.0"
dependencies = [
"specklepy>=3.2.3",
"specklepy>=3.0.3",
]
[dependency-groups]
dev = ["fake-bpy-module-latest>=20240524,<20240525", "ruff>=0.4.4,<0.5"]
dev = [
"fake-bpy-module-latest>=20240524,<20240525",
"ruff>=0.4.4,<0.5",
]