Compare commits

..

5 Commits

Author SHA1 Message Date
Mucahit Bilal GOKER 040ea64f86 Merge branch 'v3-dev' into bilal/preserve-uv-maps 2025-07-22 12:44:20 +03:00
Mucahit Bilal GOKER bd154c1575 track by application ID instead of name 2025-07-13 19:19:21 +03:00
Mucahit Bilal GOKER bbc20e2353 Merge branch 'bilal/cnx-2065-keep-track-of-loaded-object-properties' into bilal/preserve-uv-maps 2025-07-13 09:12:52 +03:00
Mucahit Bilal GOKER 716347b497 Merge branch 'v3-dev' into bilal/preserve-uv-maps 2025-07-13 09:11:03 +03:00
Mucahit Bilal GOKER 26927ca6f4 uv map preservation first pass 2025-07-06 21:04:14 +03:00
40 changed files with 1166 additions and 2704 deletions
+1 -1
View File
@@ -14,4 +14,4 @@ workflows:
when:
false
jobs:
- build
- build
+6 -6
View File
@@ -19,13 +19,13 @@ jobs:
- name: Install the project
run: uv sync --all-extras --dev
- uses: actions/cache@v3
with:
path: ~/.cache/pre-commit/
key: ${{ hashFiles('.pre-commit-config.yaml') }}
# - uses: actions/cache@v3
# with:
# path: ~/.cache/pre-commit/
# key: ${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
run: uv run pre-commit run --all-files
# - name: Run pre-commit
# run: uv run pre-commit run --all-files
- name: Minimize uv cache
run: uv cache prune --ci
+1 -1
View File
@@ -14,4 +14,4 @@ modules/
.tool-versions
requirements.txt
SEMVER
dui3/
dui3/
-21
View File
@@ -1,21 +0,0 @@
repos:
- repo: local
hooks:
# Run the linter.
- id: ruff
name: ruff lint
entry: uv run ruff check --force-exclude
language: system
types_or: [python, pyi]
# Run the formatter.
- id: ruff-format
name: ruff format
entry: uv run ruff format --force-exclude
language: system
types_or: [python, pyi]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
+11 -40
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,30 +25,31 @@ 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
The Speckle UI can be found in the 3d viewport toolbar (N), under the Speckle tab.
## Installation
## Installation
We officially support Blender 4.2 and newer, on Windows.
## Usage
Once enabled in `Preferences -> Addons`,
The Speckle connector UI can be found in the 3d viewport toolbar (N), under the `Speckle` tab.
@@ -62,6 +63,7 @@ The Speckle connector UI can be found in the 3d viewport toolbar (N), under the
The Blender Connector is still a work in progress and, as such, data sent from the Blender connector is a highly lossy exchange. Our connectors are ever evolving to facilitate more and more Speckle usecases. We welcome feedback, requests, edge cases, and contributions!
## Dependency Installation and Compatibility with Other Blender Addons
Upon first launch of the addon, the Speckle connector installs its SpecklePy dependencies in `%appdata%/Speckle/connector_installations` on Windows.
@@ -76,40 +78,6 @@ If you find an addon that conflicts, please try using a different version of tha
If you can't find a version of an addon that works, please let us know on [our forums](https://speckle.community/) the name of the addon, the versions you've tried, the version of the Speckle connector you've tried, and your OS (win/mac/linux).
## Local Development
Pre-resquisits:
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
- A supported [blender](https://www.blender.org/download/) version
- [Blender Development](https://marketplace.visualstudio.com/items?itemName=JacquesLucke.blender-development) extension for VS Code (recommended)
First time setup (or anytime you change pyproject.toml)
Run the following commands
```sh
uv sync
./export_dependencies.sh
```
🪟 To activate the environment in a terminal (Windows):
```powershell
.venv\Scripts\activate
```
🐧 To activate the environment in a terminal (Linux / macOS):
```sh
source .venv/bin/activate.fish
```
---
To run the blender plugin, run the `>Blender: Start`
from VS code (`ctrl + shift + p`)
<img width="1469" height="379" alt="image" src="https://github.com/user-attachments/assets/9dc174a0-07fc-47c7-85d1-bd5a04d8f8c7" />
## Contributing
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
@@ -121,3 +89,6 @@ The Speckle Community hangs out on [the forum](https://discourse.speckle.works),
## License
Unless otherwise described, the code in this repository is licensed under the Apache-2.0 License. Please note that some modules, extensions or code herein might be otherwise licensed. This is indicated either in the root of the containing folder under a different license file, or in the respective file's header. If you have any questions, don't hesitate to get in touch with us via [email](mailto:hello@speckle.systems).
## Notes
Thanks to [Tom Svilans](http://tomsvilans.com) ([Github](https://github.com/tsvilans)) for the original v1 contribution!
+1 -39
View File
@@ -34,12 +34,10 @@ 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,
SPECKLE_UL_projects_list,
speckle_workspace,
)
from .connector.ui.model_selection_dialog import (
SPECKLE_OT_model_selection_dialog,
@@ -70,10 +68,6 @@ from .connector.blender_operators.model_card_settings import (
)
from .connector.blender_operators.select_objects import SPECKLE_OT_select_objects
from .connector.blender_operators.add_account_button import SPECKLE_OT_add_account
from .connector.blender_operators.add_account_button import (
SPECKLE_OT_show_auth_error,
SPECKLE_OT_dismiss_popup,
)
from .connector.blender_operators.model_card_load_button import (
SPECKLE_OT_load_model_card,
)
@@ -86,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,
@@ -113,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)
@@ -155,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,
@@ -188,15 +166,11 @@ classes = (
SPECKLE_OT_delete_model_card,
SPECKLE_OT_select_objects,
SPECKLE_OT_add_account,
SPECKLE_OT_show_auth_error,
SPECKLE_OT_dismiss_popup,
SPECKLE_OT_load_model_card,
SPECKLE_OT_publish_model_card,
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,
@@ -229,20 +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)
# Clean up authentication server
from .connector.blender_operators.add_account_button import cleanup_auth_server
cleanup_auth_server()
icons.unload_icons()
unregister_speckle_state() # Unregister SpeckleState
_client_cache.clear()
@@ -1,259 +1,38 @@
import bpy
import textwrap
import webbrowser
from bpy.types import Event, Context
from typing import Optional
from ..utils.authentication import (
AuthenticationServer,
SPECKLE_AUTH_PORT,
)
# Global auth server instance
_auth_server = None
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"
)
_timer = None
_timeout_counter = 0
_max_timeout = 300 # 5 minutes in seconds (300 checks at ~1 sec intervals)
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]:
print(f"[Add Account] Starting authentication for server: {self.server_url}")
cleanup_auth_server()
# 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}")
# Force redraw
context.window.screen = context.window.screen
context.area.tag_redraw()
# Try to start own auth server first - it will fail gracefully if port is in use
global _auth_server
_auth_server = AuthenticationServer(port=SPECKLE_AUTH_PORT)
if _auth_server.start():
return self._initiate_own_server_flow(context)
# Server failed to start - port is in use
_auth_server = None
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} is already in use")
self.report(
{"ERROR"},
f"Port {SPECKLE_AUTH_PORT} is already in use. Please close any application using it and try again.",
)
return {"CANCELLED"}
def _initiate_own_server_flow(self, context: Context) -> set[str]:
"""Start auth flow with our own server."""
try:
_auth_server.open_auth_url(self.server_url)
self._start_modal_timer(context)
return {"RUNNING_MODAL"}
except Exception as e:
print(f"[Add Account] Failed to open browser: {e}")
self.report({"ERROR"}, f"Failed to open browser: {e}")
cleanup_auth_server()
return {"CANCELLED"}
def _start_modal_timer(self, context: Context):
"""Start modal timer for auth polling."""
self._timeout_counter = 0
wm = context.window_manager
self._timer = wm.event_timer_add(1.0, window=context.window)
wm.modal_handler_add(self)
def modal(self, context: Context, event: Event) -> set[str]:
global _auth_server
if event.type != "TIMER":
return {"PASS_THROUGH"}
# Check for timeout
self._timeout_counter += 1
if self._timeout_counter >= self._max_timeout:
print("[Add Account] Authentication timed out after 5 minutes")
self._cleanup(context)
self.report(
{"WARNING"},
"Authentication timed out after 5 minutes. Please try again.",
)
return {"CANCELLED"}
# Check for no active auth server
if not _auth_server:
print("[Add Account] No active auth server, cancelling")
self._cleanup(context)
return {"CANCELLED"}
# Check auth server completion
if _auth_server.is_complete():
return self._finish_auth(
context,
_auth_server.is_successful(),
_auth_server.get_error_message(),
"Auth server",
)
# Still waiting
return {"RUNNING_MODAL"}
def _finish_auth(
self,
context: Context,
is_successful: bool,
error_msg: Optional[str],
auth_type: str,
) -> set[str]:
"""Complete authentication and cleanup."""
print(
f"[Add Account] {auth_type} authentication complete. Success: {is_successful}"
)
self._cleanup(context)
return self._handle_auth_complete(context, is_successful, error_msg)
def _handle_auth_complete(
self, context: Context, is_successful: bool, error_msg: Optional[str]
) -> set[str]:
"""Handle authentication completion and update UI state."""
if is_successful:
print("[Add Account] Account added successfully - refreshing UI")
# Import account management functions
from ..utils.account_manager import get_account_enum_items, _client_cache
from ..ui.account_selection_dialog import (
update_workspaces_list,
update_projects_list,
)
# Get the newly added account (most recent one)
accounts = get_account_enum_items()
if accounts and accounts[0][0] != "NO_ACCOUNTS":
new_account_id = accounts[-1][0] # Last account added
# Set as selected account
context.window_manager.selected_account_id = new_account_id
# Clear client cache to force re-authentication
_client_cache.clear()
# Refresh UI state
try:
update_workspaces_list(context)
update_projects_list(context)
except Exception as e:
print(f"[Add Account] Error refreshing UI state: {e}")
self.report({"INFO"}, "Account added successfully and is now active!")
else:
self.report({"INFO"}, "Account added successfully!")
return {"FINISHED"}
else:
error_details = error_msg if error_msg else "Unknown error"
print(f"[Add Account] Authentication failed: {error_details}")
self.report({"ERROR"}, f"Authentication failed: {error_details}")
# Show persistent error popup with details
# Store error in window manager for the popup operator
context.window_manager["speckle_auth_error"] = error_details
bpy.ops.speckle.show_auth_error("INVOKE_DEFAULT")
return {"CANCELLED"}
def _cleanup(self, context: Context):
# Remove timer
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
# Shutdown auth server/authenticator
cleanup_auth_server()
def cleanup_auth_server():
"""Shutdown auth server on addon unload."""
global _auth_server
if _auth_server is not None:
try:
_auth_server.shutdown()
except Exception as e:
print(f"[Add Account] Failed to cleanup auth server: {e}")
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} may still be occupied")
_auth_server = None
class SPECKLE_OT_show_auth_error(bpy.types.Operator):
"""Show persistent error dialog for authentication failures."""
bl_idname = "speckle.show_auth_error"
bl_label = "Authentication Error"
bl_options = {"INTERNAL"}
def execute(self, context: Context) -> set[str]:
# Clean up the temporary error message
if "speckle_auth_error" in context.window_manager:
del context.window_manager["speckle_auth_error"]
return {"FINISHED"}
def invoke(self, context: Context, event: Event) -> set[str]:
return context.window_manager.invoke_popup(self, width=450)
def draw(self, context: Context):
layout = self.layout
# Error header
box = layout.box()
row = box.row()
row.label(text="", icon="ERROR")
row.label(text="Authentication Failed", icon="NONE")
layout.separator()
# Error details
error_details = context.window_manager.get(
"speckle_auth_error", "Unknown error"
)
col = layout.column(align=True)
# Wrap long error messages
wrapper = textwrap.TextWrapper(width=60)
for line in error_details.split("\n"):
if line:
for wrapped_line in wrapper.wrap(line):
col.label(text=wrapped_line)
else:
col.label(text="")
layout.separator()
# Close button
layout.operator("speckle.dismiss_popup", text="Close", icon="X")
class SPECKLE_OT_dismiss_popup(bpy.types.Operator):
"""Dismiss popup dialog."""
bl_idname = "speckle.dismiss_popup"
bl_label = "Dismiss"
bl_options = {"INTERNAL"}
def execute(self, context: Context) -> set[str]:
# Clean up any temporary data
if "speckle_auth_error" in context.window_manager:
del context.window_manager["speckle_auth_error"]
return {"FINISHED"}
return {'FINISHED'}
@@ -1,5 +1,5 @@
import bpy
from bpy.types import Context, Event, UILayout
from bpy.types import Context, Event, UILayout, WindowManager
from ..utils.account_manager import (
get_model_details_by_wrapper,
get_project_from_url,
@@ -3,7 +3,7 @@ from bpy.types import Context, Event, UILayout
from specklepy.core.api.inputs import CreateModelInput
from typing import Tuple
from ..utils.account_manager import _client_cache, can_create_model
from ..utils.account_manager import _client_cache
class SPECKLE_OT_create_model(bpy.types.Operator):
@@ -11,30 +11,15 @@ class SPECKLE_OT_create_model(bpy.types.Operator):
bl_label = "Create Model"
bl_description = "Create a new Speckle model"
_can_create: bool = True
model_name: bpy.props.StringProperty(name="Model Name") # type: ignore
@classmethod
def description(cls, context: Context, properties) -> str:
if not cls._can_create:
return "Workspace limits have been reached"
return "Create a new Speckle model"
def execute(self, context: Context) -> set[str]:
wm = context.window_manager
authorized, auth_message = can_create_model(
wm.selected_account_id, wm.selected_project_id
)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
if not self.model_name.strip():
self.report({"ERROR"}, "Model name cannot be empty")
return {"CANCELLED"}
try:
model_id, model_name = create_model(
wm.selected_account_id, wm.selected_project_id, self.model_name
@@ -74,9 +59,7 @@ def create_model(account_id: str, project_id: str, model_name: str) -> Tuple[str
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
)
input=CreateModelInput(name=model_name, description="", project_id=project_id)
)
return (model.id, model.name)
except Exception as e:
@@ -1,9 +1,10 @@
import bpy
from bpy.types import Context, Event, UILayout
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
from typing import Tuple, Optional
from ..utils.account_manager import _client_cache
@@ -24,7 +25,9 @@ class SPECKLE_OT_create_project(bpy.types.Operator):
project_id, project_name = create_project(
wm.selected_account_id,
self.project_name,
wm.selected_workspace.id,
None
if wm.selected_workspace.id == "personal"
else wm.selected_workspace.id,
)
wm.selected_project_id = project_id
wm.selected_project_name = project_name
@@ -51,21 +54,30 @@ def unregister() -> None:
def create_project(
account_id: str, project_name: str, workspace_id: str
account_id: str, project_name: str, workspace_id: Optional[str]
) -> Tuple[str, str]:
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}")
project = client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
workspaceId=workspace_id,
if workspace_id:
project = client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
workspaceId=workspace_id,
)
)
else:
project = client.project.create(
input=ProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
)
)
)
return (project.id, project.name)
except Exception as e:
@@ -6,7 +6,9 @@ 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,
store_visibility_settings,
store_uv_mappings,
store_modifier_settings,
)
@@ -28,7 +30,10 @@ 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)
store_visibility_settings(model_card)
store_modifier_settings(model_card)
store_uv_mappings(model_card)
delete_model_card_objects(model_card, context)
# set wm
@@ -50,7 +55,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, old_properties)
update_model_card_objects(model_card, converted_objects)
model_card.version_id = latest_version_id
else:
@@ -65,7 +70,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, old_properties)
update_model_card_objects(model_card, converted_objects)
# Clear selected model details from Window Manager
wm.selected_account_id = ""
@@ -2,7 +2,6 @@ import bpy
from typing import Set
from bpy.types import Context, Event
from ..operations.publish_operation import publish_operation
from ..utils.account_manager import can_create_version
class SPECKLE_OT_publish_model_card(bpy.types.Operator):
@@ -28,14 +27,6 @@ class SPECKLE_OT_publish_model_card(bpy.types.Operator):
self.model_card_id
)
# On-demand permission check
authorized, auth_message = can_create_version(
model_card.account_id, model_card.project_id, model_card.model_id
)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
# set wm
wm.selected_account_id = model_card.account_id
wm.selected_project_id = model_card.project_id
@@ -4,7 +4,7 @@ from bpy.types import Event
from typing import Set
from ..operations.publish_operation import publish_operation
from ..utils.account_manager import get_server_url_by_account_id, can_create_version
from ..utils.account_manager import get_server_url_by_account_id
from ..utils.model_card_utils import model_card_exists, update_model_card_objects
@@ -55,11 +55,6 @@ class SPECKLE_OT_publish(bpy.types.Operator):
self.report({"ERROR"}, "No model selected")
return {"CANCELLED"}
authorized, auth_message = can_create_version(account_id, project_id, model_id)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
objects_to_convert = []
for speckle_obj in wm.speckle_objects:
blender_obj = bpy.data.objects.get(speckle_obj.name)
@@ -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.core.api import host_applications, operations
from specklepy.core.api.inputs.version_inputs import MarkReceivedVersionInput
from specklepy.logging import metrics
from specklepy.transports.server import ServerTransport
from specklepy.core.api import operations
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.objects.graph_traversal.default_traversal import (
create_default_traversal_function,
)
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.transports.server import ServerTransport
from specklepy.core.api import host_applications
from ... import bl_info
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
from ...converter.to_native import (
convert_to_native,
find_instance_definitions,
instance_definition_proxy_to_native,
render_material_proxy_to_native,
instance_definition_proxy_to_native,
find_instance_definitions,
)
from ...converter.utils import (
build_object_id_map,
get_project_workspace_id,
)
from ..utils.account_manager import _client_cache
from ..utils.get_ascendants import get_ascendants
from specklepy.logging import metrics
from ... import bl_info
from typing import Dict, Union
def load_operation(
@@ -34,62 +30,56 @@ def load_operation(
"""
wm = context.window_manager
accountId: str = wm.selected_account_id # type: ignore
projectId: str = wm.selected_project_id # type: ignore
versionId: str = wm.selected_version_id # type: ignore
# get cached client
client = _client_cache.get_client(accountId)
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: {accountId}")
print(f"Using client for account: {context.window_manager.selected_account_id}")
transport = ServerTransport(stream_id=projectId, client=client)
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
version = client.version.get(versionId, projectId)
version = client.version.get(wm.selected_version_id, wm.selected_project_id)
obj_id = version.referenced_object
if not obj_id:
raise ValueError("Unable to receive version beyond workspaces limit")
version_data = operations.receive(obj_id, transport)
metrics.set_host_app("blender")
client.version.received(
MarkReceivedVersionInput(
version_id=version.id,
project_id=projectId,
source_application="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,
)
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),
},
)
)
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": 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
@@ -103,8 +93,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)
@@ -163,7 +152,6 @@ 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],
}
@@ -195,6 +183,8 @@ def load_operation(
if speckle_root_id and speckle_root_id in collection_hierarchy:
collection_hierarchy[speckle_root_id]["blender_collection"] = root_collection
converted_objects[speckle_root_id] = root_collection
# Add root collection name as key for UV mapping preservation
converted_objects[root_collection.name] = root_collection
# create collections in depth order (skip the root that's already mapped)
for coll_id in sorted_collections:
@@ -219,13 +209,13 @@ 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
coll_info["blender_collection"] = blender_collection
converted_objects[coll_id] = blender_collection
# Add collection name as key for UV mapping preservation
converted_objects[blender_collection.name] = blender_collection
conversion_count = 0
for traversal_item in traversal_function.traverse(version_data):
@@ -275,6 +265,8 @@ def load_operation(
converted_objects[speckle_obj.id] = blender_obj
if hasattr(speckle_obj, "applicationId"):
converted_objects[speckle_obj.applicationId] = blender_obj
# Add object name as key for UV mapping preservation
converted_objects[blender_obj.name] = blender_obj
if not isinstance(blender_obj, bpy.types.Collection):
try:
@@ -8,14 +8,6 @@ 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.logging.exceptions import GraphQLException, WorkspacePermissionException
from specklepy.core.api.inputs.model_ingestion_inputs import (
ModelIngestionCreateInput,
ModelIngestionStartProcessingInput,
ModelIngestionSuccessInput,
ModelIngestionFailedInput,
SourceDataInput,
)
from ...converter.to_speckle import convert_to_speckle
from ...converter.to_speckle.material_to_speckle import (
@@ -27,108 +19,6 @@ from specklepy.logging import metrics
from ... import bl_info
def _check_use_model_ingestion_send(client, project_id: str, model_id: str) -> bool:
"""Check if the server supports model ingestion and the user is authorized."""
try:
result = client.model.can_create_model_ingestion(project_id, model_id)
result.ensure_authorised()
return True
except GraphQLException:
return False
def _build_source_data() -> SourceDataInput:
"""Build data input for model ingestion."""
file_name = bpy.path.basename(bpy.data.filepath)
if not file_name:
file_name = "Untitled.blend"
file_size_bytes: Optional[int] = None
if bpy.data.filepath:
import os
try:
file_size_bytes = os.path.getsize(bpy.data.filepath)
except OSError:
pass
blender_version = ".".join(map(str, bl_info["blender"]))
return SourceDataInput(
source_application_slug="blender",
source_application_version=blender_version,
file_name=file_name,
file_size_bytes=file_size_bytes,
)
def _send_via_ingestion(
client,
project_id: str,
model_id: str,
obj_id: str,
version_message: str,
) -> str:
"""Send via the model ingestion. Returns version_id."""
source_data = _build_source_data()
create_input = ModelIngestionCreateInput(
project_id=project_id,
model_id=model_id,
source_data=source_data,
progress_message="Model ingestion created",
)
ingestion = client.model_ingestion.create(create_input)
ingestion_id = ingestion.id
try:
start_input = ModelIngestionStartProcessingInput(
project_id=project_id,
ingestion_id=ingestion_id,
progress_message="Processing model ingestion",
source_data=source_data,
)
client.model_ingestion.start_processing(start_input)
success_input = ModelIngestionSuccessInput(
project_id=project_id,
ingestion_id=ingestion_id,
root_object_id=obj_id,
version_message=version_message,
)
version_id = client.model_ingestion.complete(success_input)
return version_id
except Exception:
try:
fail_input = ModelIngestionFailedInput(
project_id=project_id,
ingestion_id=ingestion_id,
error_reason="Failed during processing",
)
client.model_ingestion.fail_with_error(fail_input)
except Exception:
pass
raise
def _send_via_version_create(
client,
project_id: str,
model_id: str,
obj_id: str,
version_message: str,
) -> str:
"""Send via the legacy version.create() flow. Returns version_id."""
version_input = CreateVersionInput(
objectId=obj_id,
modelId=model_id,
projectId=project_id,
message=version_message,
sourceApplication="blender",
)
version = client.version.create(version_input)
return version.id
def publish_operation(
context: Context,
objects_to_convert: List,
@@ -146,18 +36,10 @@ def publish_operation(
if not client:
return False, "No Speckle client found", None
project_id = wm.selected_project_id
model_id = wm.selected_model_id
# check ingestion support before sending data (fail fast on permission errors)
use_ingestion = _check_use_model_ingestion_send(client, project_id, model_id)
transport = ServerTransport(stream_id=project_id, client=client)
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
@@ -167,23 +49,24 @@ def publish_operation(
obj_id = operations.send(root_collection, [transport])
if use_ingestion:
version_id = _send_via_ingestion(
client, project_id, model_id, obj_id, version_message
)
else:
version_id = _send_via_version_create(
client, project_id, model_id, obj_id, version_message
)
version_input = CreateVersionInput(
objectId=obj_id,
modelId=wm.selected_model_id,
projectId=wm.selected_project_id,
message=version_message,
sourceApplication="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,
)
if account:
# track metrics
metrics.set_host_app("blender")
@@ -194,7 +77,9 @@ def publish_operation(
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"workspace_id": get_project_workspace_id(client, project_id),
"workspace_id": get_project_workspace_id(
client, wm.selected_project_id
),
},
)
@@ -207,9 +92,6 @@ def publish_operation(
version_id,
)
except WorkspacePermissionException as e:
return False, f"Permission denied: {str(e)}", None
except Exception as e:
import traceback
@@ -234,9 +116,7 @@ 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
@@ -398,9 +278,7 @@ 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
@@ -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"]
elif wm.speckle_workspaces:
wm.selected_workspace.id = wm.speckle_workspaces[0].id
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()
@@ -2,8 +2,7 @@ import bpy
from bpy.types import UILayout, Context, PropertyGroup, Event
from ..utils.model_manager import get_models_for_project
from ..utils.version_manager import get_latest_version
from ..utils.account_manager import can_create_model
from ..blender_operators.create_model import SPECKLE_OT_create_model
from ..utils.property_groups import speckle_model
class SPECKLE_UL_models_list(bpy.types.UIList):
@@ -96,11 +95,6 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
def invoke(self, context: Context, event: Event) -> set[str]:
self.update_models_list(context)
wm = context.window_manager
authorized, _ = can_create_model(wm.selected_account_id, wm.selected_project_id)
self._can_create_model = authorized
SPECKLE_OT_create_model._can_create = authorized
return context.window_manager.invoke_props_dialog(self)
def draw(self, context: Context) -> None:
@@ -111,9 +105,7 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
row = layout.row(align=True)
row.prop(self, "search_query", icon="VIEWZOOM", text="") # search bar
if wm.ui_mode != "LOAD":
sub = row.row(align=True)
sub.enabled = getattr(self, "_can_create_model", True)
sub.operator("speckle.create_model", icon="ADD", text="")
row.operator("speckle.create_model", icon="ADD", text="")
layout.template_list(
"SPECKLE_UL_models_list",
@@ -2,6 +2,10 @@ 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,
@@ -60,6 +64,7 @@ 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
)
@@ -120,18 +125,8 @@ 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:
from .account_selection_dialog import update_workspaces_list
update_workspaces_list(context)
workspaces = list(wm.speckle_workspaces)
if workspaces:
wm.selected_workspace.id = workspaces[0].id
wm.selected_workspace.name = workspaces[0].name
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(
@@ -3,7 +3,6 @@ from typing import List
from bpy.types import Operator, Context, Object
from bpy.props import EnumProperty
from ..utils.model_card_utils import update_model_card_objects
from ..utils.account_manager import can_create_version
class SPECKLE_OT_selection_filter_dialog(Operator):
@@ -46,14 +45,6 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
update_model_card_objects(model_card, user_selection)
self.report({"INFO"}, "Selection updated")
# On-demand permission check before publishing
authorized, auth_message = can_create_version(
model_card.account_id, model_card.project_id, model_card.model_id
)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
# Call the publish operator
bpy.ops.speckle.model_card_publish(
model_card_id=self.model_card_id, version_message=self.version_message
@@ -87,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
-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")
+25 -49
View File
@@ -1,7 +1,6 @@
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.wrapper import StreamWrapper
@@ -24,9 +23,7 @@ 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)
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
self._clients[account_id] = client
return client
@@ -95,20 +92,22 @@ def get_workspaces(account_id: str) -> List[Tuple[str, str]]:
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 (workspaces[0].id if workspaces else None)
active_workspace.id if active_workspace else "personal"
)
if default_workspace_id:
result = reorder_tuple(workspace_list, default_workspace_id)
else:
result = workspace_list
result = reorder_tuple(workspace_list, default_workspace_id)
else:
result = []
result = workspace_list
return result
except Exception as e:
@@ -146,7 +145,7 @@ def get_active_workspace(account_id: str) -> Optional[Dict[str, str]]:
active_workspace = client.active_user.get_active_workspace()
if active_workspace:
return {"id": active_workspace.id, "name": active_workspace.name}
return None
return {"id": "personal", "name": "Personal Projects"}
except Exception as e:
print(f"Error in get_active_workspace: {str(e)}")
_client_cache.clear()
@@ -262,42 +261,16 @@ def can_load(client, project) -> Tuple[bool, str]:
return False, error_msg
def can_create_version(
account_id: str, project_id: str, model_id: str
) -> Tuple[bool, str]:
def can_publish(client, project) -> Tuple[bool, str]:
try:
client = _client_cache.get_client(account_id)
permissions = client.model.get_permissions(project_id, model_id)
permissions = client.project.get_permissions(project.id)
if permissions.can_create_version.authorized:
if permissions.can_publish.authorized:
return True, ""
else:
message = getattr(permissions.can_create_version, "message", None)
return (
False,
message
or "Your role on this project doesn't give you permission to publish.",
)
except Exception as e:
error_msg = f"Failed to check permissions: {str(e)}"
print(error_msg)
return False, error_msg
def can_create_model(account_id: str, project_id: str) -> Tuple[bool, str]:
try:
client = _client_cache.get_client(account_id)
permissions = client.project.get_permissions(project_id)
if permissions.can_create_model.authorized:
return True, ""
else:
message = getattr(permissions.can_create_model, "message", None)
return (
False,
message
or "You don't have permission to create models in this project.",
"Your role on this project doesn't give you permission to publish.",
)
except Exception as e:
@@ -313,12 +286,15 @@ def can_create_project_in_workspace(account_id: str, workspace_id: str) -> bool:
try:
client = _client_cache.get_client(account_id)
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
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
@@ -1,575 +0,0 @@
"""
Speckle authentication module for Blender connector.
Implements OAuth-style authentication flow with a local HTTP server,
eliminating the dependency on the desktop service.
"""
import errno
import json
import secrets
import string
import sys
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Optional, Dict, Any, Tuple
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
# Speckle Blender dedicated app constants (registered server-side)
SPECKLE_APP_ID = "sblndrdui" # Dedicated app ID for Blender connector
SPECKLE_AUTH_PORT = 29365 # Port for local auth callback server
def get_user_agent() -> str:
"""Get User-Agent string identifying the Blender connector to prevent Cloudflare blocking."""
try:
from pathlib import Path
# Get the extension directory
addon_dir = Path(__file__).parent.parent.parent
# Try to read version from blender_manifest.toml
manifest_path = addon_dir / "blender_manifest.toml"
if manifest_path.exists():
with open(manifest_path, "r") as f:
for line in f:
if line.startswith("version = "):
version = line.split("=")[1].strip().strip('"')
break
else:
version = "3.0.0"
else:
version = "3.0.0"
except Exception:
# Fallback if we can't determine version
version = "3.0.0"
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
return f"Speckle-Blender-Connector/{version} (Python/{python_version})"
class AuthenticationError(Exception):
"""Raised when authentication fails."""
pass
def generate_challenge() -> str:
"""Generate a random 12-character alphanumeric challenge string."""
chars = string.ascii_letters + string.digits
return "".join(secrets.choice(chars) for _ in range(12))
class ThreadSafeAuthServer(HTTPServer):
"""Thread-safe HTTP server for Speckle authentication with locked state management."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._lock = threading.Lock()
self._server_url: Optional[str] = None
self._challenge: Optional[str] = None
self._auth_complete: bool = False
self._auth_success: bool = False
self._error_message: Optional[str] = None
self._request_count: int = 0
@property
def server_url(self) -> Optional[str]:
with self._lock:
return self._server_url
@server_url.setter
def server_url(self, value: str) -> None:
with self._lock:
self._server_url = value
@property
def challenge(self) -> Optional[str]:
with self._lock:
return self._challenge
@challenge.setter
def challenge(self, value: str) -> None:
with self._lock:
self._challenge = value
@property
def is_complete(self) -> bool:
with self._lock:
return self._auth_complete
@property
def is_successful(self) -> bool:
with self._lock:
return self._auth_success
@property
def error_message(self) -> Optional[str]:
with self._lock:
return self._error_message
def set_auth_success(self) -> None:
"""Mark authentication as successful (sets auth_complete LAST for atomicity)."""
with self._lock:
self._auth_success = True
self._error_message = None
self._auth_complete = True # Set LAST to prevent partial reads
def set_auth_failure(self, error_message: str) -> None:
"""Mark authentication as failed (sets auth_complete LAST for atomicity)."""
with self._lock:
self._auth_success = False
self._error_message = error_message
self._auth_complete = True # Set LAST to prevent partial reads
def increment_request_count(self) -> int:
"""Increment and return request count."""
with self._lock:
self._request_count += 1
return self._request_count
@property
def request_count(self) -> int:
with self._lock:
return self._request_count
class SpeckleAuthHandler(BaseHTTPRequestHandler):
"""HTTP request handler for Speckle authentication flow with /auth/add-account and callback routes."""
def log_message(self, format, *args):
print(f"[Auth Server] {format % args}")
def do_GET(self):
self.server.increment_request_count()
parsed_path = urlparse(self.path)
query_params = parse_qs(parsed_path.query)
if parsed_path.path == "/auth/add-account":
self._handle_add_account(query_params)
elif parsed_path.path == "/":
self._handle_callback(query_params)
else:
self._send_error_response(404, "Not Found")
def _handle_add_account(self, query_params: Dict[str, list]):
"""Handle initial add-account request, generate challenge and redirect to Speckle server."""
# Get server URL from query params
server_url = query_params.get("serverUrl", ["https://app.speckle.systems"])[0]
self.server.server_url = server_url.rstrip("/")
# Generate challenge
self.server.challenge = generate_challenge()
# Construct redirect URL
auth_url = f"{self.server.server_url}/authn/verify/{SPECKLE_APP_ID}/{self.server.challenge}"
print(f"[Auth Server] Redirecting to: {auth_url}")
# Send redirect response
self.send_response(302)
self.send_header("Location", auth_url)
self.end_headers()
def _handle_callback(self, query_params: Dict[str, list]):
"""Handle callback from Speckle server, exchange access code for tokens and save account."""
# Get access code from query params
access_code_list = query_params.get("access_code", [])
if not access_code_list:
self._redirect_to_failure("fail-no-access-code")
return
access_code = access_code_list[0]
try:
# Exchange access code for tokens
tokens = exchange_access_code_for_tokens(
access_code, self.server.challenge, self.server.server_url
)
# Get user and server info
user_info, server_info = get_user_and_server_info(
tokens["token"], self.server.server_url
)
# Save account
save_account_to_storage(
tokens["token"], tokens["refreshToken"], user_info, server_info
)
# Mark as successful (sets auth_complete LAST atomically)
self.server.set_auth_success()
# Redirect to success page
self._redirect_to_success()
except Exception as e:
print(f"[Auth Server] Error during authentication: {e}")
# Mark as failed (sets auth_complete LAST atomically)
self.server.set_auth_failure(str(e))
self._redirect_to_failure("fail")
def _redirect_to_success(self):
self.send_response(302)
self.send_header(
"Location", "https://www.speckle.systems/connector-auth/success"
)
self.end_headers()
def _redirect_to_failure(self, reason: str):
self.send_response(302)
self.send_header(
"Location", f"https://www.speckle.systems/connector-auth/{reason}"
)
self.end_headers()
def _send_error_response(self, code: int, message: str):
self.send_response(code)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
f"<html><body><h1>{code} {message}</h1></body></html>".encode()
)
def _handle_auth_error(e: AuthenticationError) -> None:
"""Re-raise AuthenticationError with user-friendly message for network errors."""
error_str = str(e)
if "Network error" in error_str or "URLError" in error_str:
raise AuthenticationError(
"Network error while authenticating. Please check your internet connection."
) from e
raise
def _post_json(
url: str,
body: Dict[str, Any],
auth_token: Optional[str] = None,
error_context: str = "Request",
) -> Dict[str, Any]:
"""Make POST request with JSON body and optional Bearer token."""
# Encode body as JSON
data = json.dumps(body).encode("utf-8")
# Build headers
headers = {"Content-Type": "application/json", "User-Agent": get_user_agent()}
# Add Authorization header if token provided
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
try:
request = Request(url, data=data, headers=headers)
with urlopen(request, timeout=30) as response:
response_data = json.loads(response.read().decode("utf-8"))
return response_data
except HTTPError as e:
error_body = e.read().decode("utf-8") if e.fp else "No error details"
raise AuthenticationError(f"{error_context} failed: {e.code} {error_body}")
except URLError as e:
raise AuthenticationError(f"Network error during {error_context}: {e.reason}")
except json.JSONDecodeError as e:
raise AuthenticationError(f"Invalid JSON response from {error_context}: {e}")
def exchange_access_code_for_tokens(
access_code: str, challenge: str, server_url: str
) -> Dict[str, str]:
"""Exchange access code and challenge for authentication tokens."""
if not challenge:
raise AuthenticationError("No challenge available")
# Prepare request body
body = {
"appId": SPECKLE_APP_ID,
"appSecret": SPECKLE_APP_ID,
"accessCode": access_code,
"challenge": challenge,
}
# Make POST request
url = f"{server_url}/auth/token"
try:
response_data = _post_json(url, body, error_context="token exchange")
except AuthenticationError as e:
_handle_auth_error(e)
# Validate response
if "token" not in response_data or "refreshToken" not in response_data:
raise AuthenticationError("Invalid response from token endpoint")
return {
"token": response_data["token"],
"refreshToken": response_data["refreshToken"],
}
def get_user_and_server_info(
token: str, server_url: str
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Get user and server information using GraphQL query with auth token."""
# Prepare GraphQL query
query = """
query {
activeUser {
id
name
email
company
avatar
}
serverInfo {
name
company
adminContact
description
version
}
}
"""
body = {"query": query}
# Make POST request
url = f"{server_url}/graphql"
try:
response_data = _post_json(
url, body, auth_token=token, error_context="user info request"
)
except AuthenticationError as e:
_handle_auth_error(e)
# Validate response
if "data" not in response_data:
raise AuthenticationError("Invalid GraphQL response")
data = response_data["data"]
if "activeUser" not in data or "serverInfo" not in data:
raise AuthenticationError("Missing user or server info in response")
user_info = data["activeUser"]
server_info = data["serverInfo"]
# Ensure server URL is set correctly
server_info["url"] = server_url.rstrip("/")
return user_info, server_info
def save_account_to_storage(
token: str,
refresh_token: str,
user_info: Dict[str, Any],
server_info: Dict[str, Any],
) -> None:
"""Save account to Accounts.db SQLite database for compatibility with specklepy."""
try:
import sqlite3
import hashlib
import os
from specklepy.core.api.credentials import speckle_path_provider
# Generate account ID (hash of email + server URL)
account_id_string = f"{user_info['email']}-{server_info['url']}"
account_id = hashlib.md5(account_id_string.encode()).hexdigest().upper()
# Construct account object matching the expected format
account_data = {
"id": account_id,
"token": token,
"refreshToken": refresh_token,
"isDefault": True,
"isOnline": True,
"serverInfo": {
"name": server_info["name"],
"company": server_info.get("company"),
"version": server_info.get("version"),
"description": server_info.get("description"),
"url": server_info["url"],
},
"userInfo": {
"id": user_info["id"],
"name": user_info["name"],
"email": user_info["email"],
"company": user_info.get("company"),
"avatar": user_info.get("avatar"),
},
}
# Get database path
speckle_folder = speckle_path_provider.user_speckle_folder_path()
db_path = os.path.join(speckle_folder, "Accounts.db")
# Ensure the Speckle folder exists
os.makedirs(speckle_folder, exist_ok=True)
# Connect to database and save account
# Use IMMEDIATE isolation level to acquire write lock immediately,
# preventing race conditions in concurrent account additions
conn = sqlite3.connect(db_path, isolation_level="IMMEDIATE")
try:
with conn:
cursor = conn.cursor()
# Create table if it doesn't exist
cursor.execute("""
CREATE TABLE IF NOT EXISTS objects (
hash TEXT PRIMARY KEY,
content TEXT
)
""")
# If setting as default, remove default flag from other accounts
# Use batch update to make the operation more atomic
if account_data["isDefault"]:
cursor.execute("SELECT hash, content FROM objects")
rows = cursor.fetchall()
# Build list of updates to execute in batch
updates = []
for existing_id, existing_content in rows:
try:
existing_account = json.loads(existing_content)
if existing_account.get("isDefault", False):
existing_account["isDefault"] = False
updates.append(
(json.dumps(existing_account), existing_id)
)
except json.JSONDecodeError:
# Skip malformed accounts
continue
# Execute all updates in batch for better atomicity
if updates:
cursor.executemany(
"UPDATE objects SET content = ? WHERE hash = ?", updates
)
# Insert or replace the account
cursor.execute(
"INSERT OR REPLACE INTO objects (hash, content) VALUES (?, ?)",
(account_id, json.dumps(account_data)),
)
conn.commit()
finally:
conn.close()
print(
f"[Auth] Successfully saved account: {user_info['email']} @ {server_info['url']} (ID: {account_id})"
)
# Track account creation event
try:
from specklepy.logging import metrics
metrics.track(metrics.HOST_APP, "connector", "account", {"action": "add"})
except Exception as e:
# Don't fail if metrics tracking fails
print(f"[Auth] Failed to track metrics: {e}")
except Exception as e:
raise AuthenticationError(f"Failed to save account: {e}")
class AuthenticationServer:
"""Manages local HTTP server for Speckle authentication in a background thread."""
def __init__(self, port: int = SPECKLE_AUTH_PORT):
self.port = port
self.server: Optional[ThreadSafeAuthServer] = None
self.thread: Optional[threading.Thread] = None
self.shutdown_event = threading.Event()
def start(self) -> bool:
"""Start HTTP server in background thread."""
try:
# Create thread-safe server (state initialized in constructor)
self.server = ThreadSafeAuthServer(
("127.0.0.1", self.port), SpeckleAuthHandler
)
# Start server in background thread
self.thread = threading.Thread(target=self._run_server, daemon=True)
self.thread.start()
print(f"[Auth Server] Started on http://127.0.0.1:{self.port}")
return True
except OSError as e:
if e.errno in (errno.EADDRINUSE, 10048): # Address already in use
print(f"[Auth Server] Port {self.port} is already in use.")
else:
print(f"[Auth Server] Failed to start server: {e}")
return False
except Exception as e:
print(f"[Auth Server] Unexpected error starting server: {e}")
return False
def _run_server(self):
try:
# Set a timeout so handle_request doesn't block forever
self.server.timeout = 0.5
# Server should handle a maximum of 3 requests:
# 1. /auth/add-account (redirect to Speckle)
# 2. / callback (from Speckle with access_code)
# 3. Maybe a favicon or other browser request
# After that or when shutdown is signaled, stop
max_requests = 5 # Allow a few extra for browser quirks
while (
not self.shutdown_event.is_set()
and self.server.request_count < max_requests
):
self.server.handle_request()
# If auth is complete, we can stop serving
if self.server.is_complete:
print("[Auth Server] Authentication complete, stopping server")
break
except Exception as e:
print(f"[Auth Server] Error in server thread: {e}")
self.server.set_auth_failure(f"Server thread crashed: {e}")
def shutdown(self):
if self.server:
self.shutdown_event.set()
try:
# Give the server thread a moment to see the shutdown event
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)
self.server.server_close()
except Exception as e:
print(f"[Auth Server] Error during shutdown: {e}")
self.server = None
self.thread = None
print("[Auth Server] Shutdown complete")
def is_complete(self) -> bool:
return self.server.is_complete if self.server else False
def is_successful(self) -> bool:
return self.server.is_successful if self.server else False
def get_error_message(self) -> Optional[str]:
return self.server.error_message if self.server else None
def open_auth_url(self, server_url: str = "https://app.speckle.systems"):
"""Open authentication URL in browser to initiate auth flow."""
# Trigger the add-account endpoint
url = f"http://127.0.0.1:{self.port}/auth/add-account?serverUrl={server_url}"
webbrowser.open(url)
print("[Auth Server] Opening browser to initiate authentication...")
+1 -3
View File
@@ -1,7 +1,6 @@
from datetime import datetime, timezone
import re
def format_relative_time(timestamp) -> str:
"""
convert UTC timestamp to local timezone and return relative time string
@@ -47,7 +46,6 @@ 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)
+326 -265
View File
@@ -1,8 +1,8 @@
from typing import Any, Dict, Optional
import bpy
import json
from bpy.types import Context
from typing import Dict
import json
from ..utils.property_groups import speckle_model_card
@@ -46,318 +46,262 @@ def get_objects_by_application_ids(app_ids: list):
return result
def get_collection_by_application_id(app_id: str):
def store_visibility_settings(model_card: speckle_model_card):
"""
Find a Blender collection by its applicationId stored in custom property
Store current visibility settings of model card objects and collections
This is used to restore the visibility settings of the loaded objects after loading a new version
"""
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 = {}
s_obj.hide_get = blender_obj.hide_get()
s_obj.hide_viewport = blender_obj.hide_viewport
s_obj.hide_select = blender_obj.hide_select
s_obj.hide_render = blender_obj.hide_render
# 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)
blender_col = bpy.data.collections.get(s_col.name)
if blender_col:
# For collections, visibility is controlled through the view layer system
view_layer = bpy.context.view_layer
if view_layer:
# Find the layer collection for this collection
layer_col = find_layer_collection(
view_layer.layer_collection, blender_col.name
)
if layer_col 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
if layer_col:
s_col.hide_viewport = layer_col.hide_viewport
s_col.hide_select = layer_col.collection.hide_select
s_col.hide_render = layer_col.collection.hide_render
s_col.exclude_from_view_layer = layer_col.exclude
else:
s_col.hide_viewport = False
s_col.hide_select = False
s_col.hide_render = False
s_col.exclude_from_view_layer = False
def transfer_object_properties(
new_obj: bpy.types.Object, old_obj_data: Dict[str, Any]
) -> None:
def store_uv_mappings(model_card: speckle_model_card):
"""
Transfer visibility and modifiers from old object data to new object
Handles sparse data gracefully - applies defaults when data is missing
Store current UV mapping data of model card mesh objects
This is used to restore the UV mappings after loading a new version
"""
# 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)
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
# 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()
if blender_obj and blender_obj.type == "MESH" and blender_obj.data:
mesh = blender_obj.data
# 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)
uv_data = {"active_uv_layer": "", "uv_layers": []}
# Store active UV layer name
if mesh.uv_layers.active:
uv_data["active_uv_layer"] = mesh.uv_layers.active.name
# Store UV data for each UV layer
for uv_layer in mesh.uv_layers:
# Extract UV coordinates for all loops in this layer
uv_coords = []
for uv_loop in uv_layer.data:
uv_coords.extend([uv_loop.uv.x, uv_loop.uv.y])
uv_data["uv_layers"].append(
{"name": uv_layer.name, "uv_coords": uv_coords}
)
# Serialize complete UV data as JSON string
s_obj.uv_data_serialized = json.dumps(uv_data)
def transfer_collection_properties(
new_col: bpy.types.Collection, old_col_data: Dict[str, Any]
) -> None:
def restore_uv_mappings(
model_card: speckle_model_card,
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
):
"""
Transfer visibility properties from old collection data to new collection
Handles sparse data gracefully - applies defaults when data is missing
Restore UV mapping data to reloaded mesh objects
"""
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():
# First, collect UV mapping data from property groups before they are cleared
uv_mapping_data = {}
for s_obj in model_card.objects:
if s_obj.uv_data_serialized: # Only process objects that have UV data stored
try:
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):
uv_data = json.loads(s_obj.uv_data_serialized)
uv_mapping_data[s_obj.applicationId] = uv_data
except (json.JSONDecodeError, ValueError):
# Skip invalid UV data
continue
return new_modifier
except Exception as e:
print(f"Error recreating modifier {modifier_data.get('name', 'unknown')}: {e}")
return None
# Now restore UV mappings to the new objects
for app_id, uv_data in uv_mapping_data.items():
# Find the blender object by applicationId in converted_objects
blender_obj = None
for obj in converted_objects.values():
if isinstance(obj, bpy.types.Object) and obj.get("applicationId") == app_id:
blender_obj = obj
break
if blender_obj:
# Only process mesh objects
if (
isinstance(blender_obj, bpy.types.Object)
and blender_obj.type == "MESH"
and blender_obj.data
):
mesh = blender_obj.data
# Restore UV layers
for uv_layer_data in uv_data.get("uv_layers", []):
layer_name = uv_layer_data["name"]
uv_coords = uv_layer_data["uv_coords"]
# Find or create the UV layer
uv_layer = mesh.uv_layers.get(layer_name)
if not uv_layer:
uv_layer = mesh.uv_layers.new(name=layer_name)
# Restore UV coordinates
expected_coords = len(mesh.loops) * 2 # 2 coords per loop
if len(uv_coords) == expected_coords:
for i, uv_loop in enumerate(uv_layer.data):
coord_idx = i * 2
if coord_idx + 1 < len(uv_coords):
uv_loop.uv = (
uv_coords[coord_idx],
uv_coords[coord_idx + 1],
)
# Restore active UV layer
active_uv_layer = uv_data.get("active_uv_layer", "")
if active_uv_layer and mesh.uv_layers.get(active_uv_layer):
mesh.uv_layers.active = mesh.uv_layers[active_uv_layer]
def update_model_card_objects(
model_card: speckle_model_card,
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
old_properties: Optional[Dict[str, Dict[str, Any]]] = None,
):
"""
Update model card with new objects and apply properties from old objects if provided
"""
# Clear model card objects
# Restore UV mappings before clearing property groups
restore_uv_mappings(model_card, converted_objects)
# Store visibility settings from property group before clearing
visibility_settings = {}
for s_obj in model_card.objects:
if s_obj.applicationId:
visibility_settings[s_obj.applicationId] = {
"hide_get": s_obj.hide_get,
"hide_viewport": s_obj.hide_viewport,
"hide_select": s_obj.hide_select,
"hide_render": s_obj.hide_render,
}
# Store modifier settings from property group before clearing
modifier_settings = {}
for s_obj in model_card.objects:
if s_obj.applicationId:
modifier_settings[s_obj.applicationId] = s_obj.modifiers
# Store collection visibility settings from property group before clearing
collection_visibility_settings = {}
for s_col in model_card.collections:
collection_visibility_settings[s_col.name] = {
"hide_viewport": s_col.hide_viewport,
"hide_select": s_col.hide_select,
"hide_render": s_col.hide_render,
"exclude_from_view_layer": s_col.exclude_from_view_layer,
}
# clear model card objects
model_card.objects.clear()
model_card.collections.clear()
# Convert list to dictionary if needed
# if converted_objects is a list, convert it to a dictionary
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 its a collection, add it to collections field of model card
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", "")
# 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)
# Restore collection visibility settings if they exist
if obj.name in collection_visibility_settings:
s_col.hide_viewport = collection_visibility_settings[obj.name][
"hide_viewport"
]
s_col.hide_select = collection_visibility_settings[obj.name][
"hide_select"
]
s_col.hide_render = collection_visibility_settings[obj.name][
"hide_render"
]
s_col.exclude_from_view_layer = collection_visibility_settings[
obj.name
]["exclude_from_view_layer"]
# Handle objects
elif isinstance(obj, bpy.types.Object):
if obj.name in object_names:
# Apply the visibility settings to the new collection through view layer
view_layer = bpy.context.view_layer
if view_layer:
# Find the layer collection for this collection
layer_col = find_layer_collection(
view_layer.layer_collection, obj.name
)
if layer_col:
# Apply viewport visibility (controlled by layer collection)
layer_col.hide_viewport = collection_visibility_settings[
obj.name
]["hide_viewport"]
# Apply selectability and render visibility (controlled by collection)
obj.hide_select = collection_visibility_settings[obj.name][
"hide_select"
]
obj.hide_render = collection_visibility_settings[obj.name][
"hide_render"
]
# Apply view layer exclusion
layer_col.exclude = collection_visibility_settings[obj.name][
"exclude_from_view_layer"
]
# if its an object, add it to the objects field of model card
if isinstance(obj, bpy.types.Object):
if obj.name in (o.name for o in model_card.objects):
continue
object_names.add(obj.name)
s_obj = model_card.objects.add()
s_obj.name = obj.name
s_obj.applicationId = obj.get("applicationId", "")
# Restore visibility settings if they exist
if s_obj.applicationId and s_obj.applicationId in visibility_settings:
s_obj.hide_get = visibility_settings[s_obj.applicationId]["hide_get"]
s_obj.hide_viewport = visibility_settings[s_obj.applicationId][
"hide_viewport"
]
s_obj.hide_select = visibility_settings[s_obj.applicationId][
"hide_select"
]
s_obj.hide_render = visibility_settings[s_obj.applicationId][
"hide_render"
]
# Apply 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)
# Apply the visibility settings to the new object
obj.hide_set(visibility_settings[s_obj.applicationId]["hide_get"])
obj.hide_viewport = visibility_settings[s_obj.applicationId][
"hide_viewport"
]
obj.hide_select = visibility_settings[s_obj.applicationId][
"hide_select"
]
obj.hide_render = visibility_settings[s_obj.applicationId][
"hide_render"
]
# Restore modifier settings if they exist
if s_obj.applicationId and s_obj.applicationId in modifier_settings:
s_obj.modifiers = modifier_settings[s_obj.applicationId]
restore_modifier_settings(obj, modifier_settings[s_obj.applicationId])
def delete_model_card_objects(model_card: speckle_model_card, context: Context) -> None:
@@ -426,3 +370,120 @@ def model_card_exists(
):
return True
return False
def serialize_modifier(modifier):
"""
Serialize a Blender modifier to a dictionary
"""
modifier_data = {
"name": modifier.name,
"type": modifier.type,
"show_viewport": modifier.show_viewport,
"show_render": modifier.show_render,
"show_in_editmode": modifier.show_in_editmode,
"show_on_cage": modifier.show_on_cage,
"properties": {},
}
# Store all modifier-specific properties
for prop_name in modifier.bl_rna.properties.keys():
if prop_name in [
"rna_type",
"name",
"type",
"show_viewport",
"show_render",
"show_in_editmode",
"show_on_cage",
]:
continue
try:
prop_value = getattr(modifier, prop_name)
# Handle different property types
if isinstance(prop_value, (int, float, bool, str)):
modifier_data["properties"][prop_name] = prop_value
elif hasattr(prop_value, "name"): # Object references
modifier_data["properties"][prop_name] = prop_value.name
elif (
hasattr(prop_value, "__len__") and len(prop_value) <= 4
): # Vectors/colors
modifier_data["properties"][prop_name] = list(prop_value)
except (AttributeError, TypeError):
# Skip properties that can't be serialized
continue
return modifier_data
def deserialize_modifier(obj, modifier_data):
"""
Recreate a modifier from serialized data
"""
try:
modifier = obj.modifiers.new(modifier_data["name"], modifier_data["type"])
# Set visibility properties
modifier.show_viewport = modifier_data.get("show_viewport", True)
modifier.show_render = modifier_data.get("show_render", True)
modifier.show_in_editmode = modifier_data.get("show_in_editmode", True)
modifier.show_on_cage = modifier_data.get("show_on_cage", False)
# Set modifier-specific properties
for prop_name, prop_value in modifier_data.get("properties", {}).items():
try:
if hasattr(modifier, prop_name):
current_value = getattr(modifier, prop_name)
# Handle object references
if hasattr(current_value, "name") and isinstance(prop_value, str):
referenced_obj = bpy.data.objects.get(prop_value)
if referenced_obj:
setattr(modifier, prop_name, referenced_obj)
else:
setattr(modifier, prop_name, prop_value)
except (AttributeError, TypeError):
# Skip properties that can't be set
continue
return modifier
except Exception as e:
print(f"Error deserializing modifier {modifier_data['name']}: {e}")
return None
def store_modifier_settings(model_card: speckle_model_card):
"""
Store current modifier settings of model card objects
This is used to restore the modifier settings of the loaded objects after loading a new version
"""
for s_obj in model_card.objects:
blender_obj = get_object_by_application_id(s_obj.applicationId)
if blender_obj and hasattr(blender_obj, "modifiers"):
modifiers_data = []
for modifier in blender_obj.modifiers:
modifier_data = serialize_modifier(modifier)
modifiers_data.append(modifier_data)
# Store as JSON string
s_obj.modifiers = json.dumps(modifiers_data)
def restore_modifier_settings(blender_obj, modifier_data_json):
"""
Restore modifier settings to a Blender object
"""
if not modifier_data_json or not hasattr(blender_obj, "modifiers"):
return
try:
modifiers_data = json.loads(modifier_data_json)
# Clear existing modifiers
blender_obj.modifiers.clear()
# Recreate modifiers
for modifier_data in modifiers_data:
deserialize_modifier(blender_obj, modifier_data)
except (json.JSONDecodeError, KeyError, TypeError) as e:
print(f"Error restoring modifiers for {blender_obj.name}: {e}")
+41 -2
View File
@@ -19,10 +19,9 @@ def get_projects_for_account(
if not client:
print(f"Error: Could not get client for account: {account_id}")
return []
# 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
)
@@ -30,6 +29,9 @@ def get_projects_for_account(
print(f"Error: Could not find account with ID: {account_id}")
return []
if workspace_id == "personal":
return _get_personal_projects_with_permissions(client, search)
try:
workspace_resource = WorkspaceResource(
account, client.url, client.httpclient, client.server.version()
@@ -92,6 +94,43 @@ def get_projects_for_account(
return []
def _get_personal_projects_with_permissions(
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,
include_implicit_access=True,
)
projects = client.active_user.get_projects(limit=10, filter=filter).items
result = []
for project in projects:
can_load_permission, _ = can_load(client, project)
result.append(
(
strip_non_ascii(project.name),
format_role(getattr(project, "role", ""))
if hasattr(project, "role") and project.role
else "",
format_relative_time(project.updated_at),
project.id,
can_load_permission,
)
)
return result
def _get_projects_with_individual_permissions(
client: SpeckleClient,
workspace_id: str,
+14 -3
View File
@@ -36,20 +36,31 @@ class speckle_version(bpy.types.PropertyGroup):
class speckle_object(bpy.types.PropertyGroup):
"""
PropertyGroup for storing object names and applicationIds
PropertyGroup for storing object names, visibility settings, and UV mapping data
"""
name: bpy.props.StringProperty() # type: ignore
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
hide_get: bpy.props.BoolProperty(name="Hide Get", default=False) # type: ignore
hide_viewport: bpy.props.BoolProperty(name="Hide Viewport", default=False) # type: ignore
hide_select: bpy.props.BoolProperty(name="Hide Select", default=False) # type: ignore
hide_render: bpy.props.BoolProperty(name="Hide Render", default=False) # type: ignore
modifiers: bpy.props.StringProperty(name="Modifiers", default="") # type: ignore
uv_data_serialized: bpy.props.StringProperty() # type: ignore
class speckle_collection(bpy.types.PropertyGroup):
"""
PropertyGroup for storing collections
PropertyGroup for storing collection information and visibility settings
"""
name: bpy.props.StringProperty() # type: ignore
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
hide_viewport: bpy.props.BoolProperty(name="Hide Viewport", default=False) # type: ignore
hide_select: bpy.props.BoolProperty(name="Hide Select", default=False) # type: ignore
hide_render: bpy.props.BoolProperty(name="Hide Render", default=False) # type: ignore
exclude_from_view_layer: bpy.props.BoolProperty(
name="Exclude From View Layer", default=False
) # type: ignore
class speckle_model_card(bpy.types.PropertyGroup):
+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
+54 -110
View File
@@ -1,27 +1,26 @@
from typing import Any, Dict, Iterable, List, Optional, Tuple
import bpy
import mathutils
from bpy.types import Object
from specklepy.objects import Base, DataObject
from typing import Any, Iterable, List, Optional, Tuple, Dict
from specklepy.objects import Base
from specklepy.objects import DataObject
from specklepy.objects.geometry import (
Line,
Polyline,
Mesh,
Arc,
Circle,
Curve,
Ellipse,
Line,
Mesh,
Point,
Curve,
Polycurve,
Polyline,
)
from specklepy.objects.models.units import (
get_scale_factor_to_meters,
get_units_from_string,
Point,
)
from specklepy.objects.proxies import InstanceProxy
from ..converter.utils import create_material_from_proxy
from specklepy.objects.models.units import (
get_units_from_string,
get_scale_factor_to_meters,
)
import bpy
from bpy.types import Object
import mathutils
from ..converter.utils import create_material_from_proxy, find_object_by_id
# Display value property aliases to check for
DISPLAY_VALUE_PROPERTY_ALIASES = [
@@ -160,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
@@ -184,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:]:
@@ -209,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
@@ -230,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
@@ -265,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
@@ -280,9 +259,6 @@ def elements_to_native(
ELEMENTS_PROPERTY_ALIASES,
False,
material_mapping,
definition_collections,
root_collection,
instance_loading_mode,
)
return elements
@@ -295,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)
@@ -313,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):
@@ -349,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
@@ -696,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)
@@ -715,7 +666,6 @@ def arc_to_native(
converts a Speckle arc to a Blender NURBS curve.
"""
import math
import mathutils
curve = bpy.data.curves.new(data_block_name, type="CURVE")
@@ -850,7 +800,6 @@ def circle_to_native(
converts a Speckle circle to a Blender NURBS curve.
"""
import math
import mathutils
curve = bpy.data.curves.new(data_block_name, type="CURVE")
@@ -1038,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)
@@ -1117,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)
@@ -1276,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
@@ -1328,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:
@@ -1343,9 +1276,7 @@ def instance_definition_proxy_to_native(
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}"
)
), f"Definition collection not found for nested instance {found_obj.definitionId}"
if instance_loading_mode == "LINKED_DUPLICATES":
blender_obj = instance_proxy_to_linked_duplicates(
@@ -1431,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(
@@ -1467,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()
@@ -1478,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
@@ -1489,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)
@@ -1516,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(
@@ -1550,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()
+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
)
+10 -12
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,22 +50,20 @@ 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
+1 -20
View File
@@ -1,4 +1,4 @@
from typing import Tuple, List, Optional, Dict
from typing import Tuple, List, Optional
import bpy
import mathutils
from specklepy.objects import Base
@@ -118,25 +118,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
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env pwsh
$ErrorActionPreference = "Stop"
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
+5 -10
View File
@@ -1,7 +1,6 @@
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"
@@ -10,16 +9,13 @@ 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"
@@ -28,16 +24,15 @@ 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 ="):
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}"\n'
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
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):
+4 -4
View File
@@ -5,12 +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.4",
"specklepy>=3.0.1",
]
[dependency-groups]
dev = [
"fake-bpy-module-latest>=20260126",
"ruff==0.14.14",
"pre-commit>=4.0.1",
"fake-bpy-module-latest>=20240524,<20240525",
"ruff>=0.4.4,<0.5",
]
Generated
+459 -794
View File
File diff suppressed because it is too large Load Diff