Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f75afc2b37 | |||
| 34c922feb1 | |||
| cee05260c1 | |||
| add470699b | |||
| f5bcd805e8 | |||
| cf4bb14240 | |||
| 1ee1650fef | |||
| 70f5f672a6 | |||
| b329ec8c97 | |||
| b54cfe16e8 | |||
| 357859827c | |||
| f35457dff8 | |||
| f993c38ea9 | |||
| 624537cc5d | |||
| ebb7f1b3bf | |||
| ac2a95d968 | |||
| 2440c44f44 | |||
| 33dfa1229c | |||
| ea61bd06b8 | |||
| e071aca299 | |||
| 4a8a980034 | |||
| b05447dc30 | |||
| 7a36f9ec08 | |||
| 80e3971706 | |||
| dc770b7a79 | |||
| f8e7d391be | |||
| 3092ba3056 | |||
| 9d10006116 | |||
| 95f4d051d6 | |||
| c79ad8e87d | |||
| 9797dfbfc0 | |||
| 63b00a6257 | |||
| 36091845a6 | |||
| 89e1855e2c | |||
| b7f5725282 | |||
| dc8c8cedf4 | |||
| 31e8b838dd | |||
| baf7f32c2a | |||
| ad1d58bd4c | |||
| ec86688750 | |||
| 84098f4c42 | |||
| 77f9d73698 | |||
| 812e8dd2f3 |
@@ -14,4 +14,4 @@ workflows:
|
|||||||
when:
|
when:
|
||||||
false
|
false
|
||||||
jobs:
|
jobs:
|
||||||
- build
|
- build
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ jobs:
|
|||||||
- name: Install the project
|
- name: Install the project
|
||||||
run: uv sync --all-extras --dev
|
run: uv sync --all-extras --dev
|
||||||
|
|
||||||
# - uses: actions/cache@v3
|
- uses: actions/cache@v3
|
||||||
# with:
|
with:
|
||||||
# path: ~/.cache/pre-commit/
|
path: ~/.cache/pre-commit/
|
||||||
# key: ${{ hashFiles('.pre-commit-config.yaml') }}
|
key: ${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
|
||||||
# - name: Run pre-commit
|
- name: Run pre-commit
|
||||||
# run: uv run pre-commit run --all-files
|
run: uv run pre-commit run --all-files
|
||||||
|
|
||||||
- name: Minimize uv cache
|
- name: Minimize uv cache
|
||||||
run: uv cache prune --ci
|
run: uv cache prune --ci
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ modules/
|
|||||||
.tool-versions
|
.tool-versions
|
||||||
requirements.txt
|
requirements.txt
|
||||||
SEMVER
|
SEMVER
|
||||||
dui3/
|
dui3/
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<p align="center"><b>Speckle</b> is the data infrastructure for the AEC industry.</p><br/>
|
<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&style=flat-square&logo=discourse&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&logo=read-the-docs&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&style=flat-square&logo=discourse&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&logo=read-the-docs&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&circle-token=76eabd350ea243575cbb258b746ed3f471f7ac29" alt="Speckle-Next"></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&circle-token=76eabd350ea243575cbb258b746ed3f471f7ac29" alt="Speckle-Next"></a> </p>
|
||||||
|
|
||||||
# About Speckle
|
# About Speckle
|
||||||
@@ -25,31 +25,30 @@ What is Speckle? Check our ](https://speckle.xyz) ⇒ creating an account at our public server
|
- [](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
|
||||||
|
|
||||||
### Resources
|
### Resources
|
||||||
|
|
||||||
- [](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
|
- [](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
|
||||||
- [](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
|
- [](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
|
||||||
- [](https://speckle.guide/user/blender.html) reference on almost any end-user and developer functionality
|
- [](https://docs.speckle.systems/connectors/blender) reference on almost any end-user and developer functionality
|
||||||
|
|
||||||
|
|
||||||
# Blender Connector
|
# Blender Connector
|
||||||
|
|
||||||
The Speckle UI can be found in the 3d viewport toolbar (N), under the Speckle tab.
|
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.
|
We officially support Blender 4.2 and newer, on Windows.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Once enabled in `Preferences -> Addons`,
|
Once enabled in `Preferences -> Addons`,
|
||||||
The Speckle connector UI can be found in the 3d viewport toolbar (N), under the `Speckle` tab.
|
The Speckle connector UI can be found in the 3d viewport toolbar (N), under the `Speckle` tab.
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ 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!
|
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
|
## 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.
|
Upon first launch of the addon, the Speckle connector installs its SpecklePy dependencies in `%appdata%/Speckle/connector_installations` on Windows.
|
||||||
@@ -78,6 +76,40 @@ 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).
|
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
|
## Contributing
|
||||||
|
|
||||||
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
|
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
|
||||||
@@ -89,6 +121,3 @@ The Speckle Community hangs out on [the forum](https://discourse.speckle.works),
|
|||||||
## License
|
## 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).
|
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!
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ bl_info = {
|
|||||||
|
|
||||||
# UI
|
# UI
|
||||||
from .connector.ui.main_panel import SPECKLE_PT_main_panel
|
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.utils.account_manager import speckle_workspace
|
||||||
from .connector.ui.project_selection_dialog import (
|
from .connector.ui.project_selection_dialog import (
|
||||||
SPECKLE_OT_project_selection_dialog,
|
SPECKLE_OT_project_selection_dialog,
|
||||||
@@ -68,6 +70,10 @@ from .connector.blender_operators.model_card_settings import (
|
|||||||
)
|
)
|
||||||
from .connector.blender_operators.select_objects import SPECKLE_OT_select_objects
|
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_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 (
|
from .connector.blender_operators.model_card_load_button import (
|
||||||
SPECKLE_OT_load_model_card,
|
SPECKLE_OT_load_model_card,
|
||||||
)
|
)
|
||||||
@@ -80,6 +86,8 @@ 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_project import SPECKLE_OT_create_project
|
||||||
from .connector.blender_operators.create_model import SPECKLE_OT_create_model
|
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 (
|
from .connector.utils.account_manager import (
|
||||||
speckle_account,
|
speckle_account,
|
||||||
get_default_account_id,
|
get_default_account_id,
|
||||||
@@ -105,6 +113,14 @@ 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():
|
def invoke_window_manager_properties():
|
||||||
# Accounts
|
# Accounts
|
||||||
WindowManager.speckle_accounts = bpy.props.CollectionProperty(type=speckle_account)
|
WindowManager.speckle_accounts = bpy.props.CollectionProperty(type=speckle_account)
|
||||||
@@ -139,11 +155,17 @@ def invoke_window_manager_properties():
|
|||||||
)
|
)
|
||||||
# Objects
|
# Objects
|
||||||
WindowManager.speckle_objects = bpy.props.CollectionProperty(type=speckle_object)
|
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 to load
|
||||||
classes = (
|
classes = (
|
||||||
|
SPECKLE_PT_update_panel,
|
||||||
SPECKLE_PT_main_panel,
|
SPECKLE_PT_main_panel,
|
||||||
|
SPECKLE_PT_model_cards_panel,
|
||||||
SPECKLE_OT_publish,
|
SPECKLE_OT_publish,
|
||||||
SPECKLE_OT_load,
|
SPECKLE_OT_load,
|
||||||
SPECKLE_OT_project_selection_dialog,
|
SPECKLE_OT_project_selection_dialog,
|
||||||
@@ -166,11 +188,15 @@ classes = (
|
|||||||
SPECKLE_OT_delete_model_card,
|
SPECKLE_OT_delete_model_card,
|
||||||
SPECKLE_OT_select_objects,
|
SPECKLE_OT_select_objects,
|
||||||
SPECKLE_OT_add_account,
|
SPECKLE_OT_add_account,
|
||||||
|
SPECKLE_OT_show_auth_error,
|
||||||
|
SPECKLE_OT_dismiss_popup,
|
||||||
SPECKLE_OT_load_model_card,
|
SPECKLE_OT_load_model_card,
|
||||||
SPECKLE_OT_publish_model_card,
|
SPECKLE_OT_publish_model_card,
|
||||||
SPECKLE_OT_add_project_by_url,
|
SPECKLE_OT_add_project_by_url,
|
||||||
SPECKLE_OT_create_project,
|
SPECKLE_OT_create_project,
|
||||||
SPECKLE_OT_create_model,
|
SPECKLE_OT_create_model,
|
||||||
|
SPECKLE_OT_version_check,
|
||||||
|
SPECKLE_OT_update_button,
|
||||||
speckle_account,
|
speckle_account,
|
||||||
SPECKLE_UL_workspaces_list,
|
SPECKLE_UL_workspaces_list,
|
||||||
SPECKLE_OT_workspace_selection_dialog,
|
SPECKLE_OT_workspace_selection_dialog,
|
||||||
@@ -203,8 +229,20 @@ def register():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Speckle] Failed to pre-warm client: {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():
|
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()
|
icons.unload_icons()
|
||||||
unregister_speckle_state() # Unregister SpeckleState
|
unregister_speckle_state() # Unregister SpeckleState
|
||||||
_client_cache.clear()
|
_client_cache.clear()
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import bpy
|
import bpy
|
||||||
import webbrowser
|
import textwrap
|
||||||
from bpy.types import Event, Context
|
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):
|
class SPECKLE_OT_add_account(bpy.types.Operator):
|
||||||
@@ -16,6 +25,10 @@ class SPECKLE_OT_add_account(bpy.types.Operator):
|
|||||||
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]:
|
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||||
return context.window_manager.invoke_props_dialog(self)
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
@@ -25,14 +38,222 @@ class SPECKLE_OT_add_account(bpy.types.Operator):
|
|||||||
layout.prop(self, "server_url", text="Server URL")
|
layout.prop(self, "server_url", text="Server URL")
|
||||||
|
|
||||||
def execute(self, context: Context) -> set[str]:
|
def execute(self, context: Context) -> set[str]:
|
||||||
# Logic to handle sign in
|
print(f"[Add Account] Starting authentication for server: {self.server_url}")
|
||||||
api_url = "http://localhost:29364"
|
cleanup_auth_server()
|
||||||
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
|
# Try to start own auth server first - it will fail gracefully if port is in use
|
||||||
context.window.screen = context.window.screen
|
global _auth_server
|
||||||
context.area.tag_redraw()
|
_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"}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from bpy.types import Context, Event, UILayout
|
|||||||
from specklepy.core.api.inputs import CreateModelInput
|
from specklepy.core.api.inputs import CreateModelInput
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from ..utils.account_manager import _client_cache
|
from ..utils.account_manager import _client_cache, can_create_model
|
||||||
|
|
||||||
|
|
||||||
class SPECKLE_OT_create_model(bpy.types.Operator):
|
class SPECKLE_OT_create_model(bpy.types.Operator):
|
||||||
@@ -11,11 +11,26 @@ class SPECKLE_OT_create_model(bpy.types.Operator):
|
|||||||
bl_label = "Create Model"
|
bl_label = "Create Model"
|
||||||
bl_description = "Create a new Speckle model"
|
bl_description = "Create a new Speckle model"
|
||||||
|
|
||||||
|
_can_create: bool = True
|
||||||
|
|
||||||
model_name: bpy.props.StringProperty(name="Model Name") # type: ignore
|
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]:
|
def execute(self, context: Context) -> set[str]:
|
||||||
wm = context.window_manager
|
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():
|
if not self.model_name.strip():
|
||||||
self.report({"ERROR"}, "Model name cannot be empty")
|
self.report({"ERROR"}, "Model name cannot be empty")
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Context, Event, UILayout
|
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.inputs.project_inputs import WorkspaceProjectCreateInput
|
||||||
from specklepy.core.api.enums import ProjectVisibility
|
from specklepy.core.api.enums import ProjectVisibility
|
||||||
from typing import Tuple, Optional
|
from typing import Tuple
|
||||||
|
|
||||||
from ..utils.account_manager import _client_cache
|
from ..utils.account_manager import _client_cache
|
||||||
|
|
||||||
@@ -25,9 +24,7 @@ class SPECKLE_OT_create_project(bpy.types.Operator):
|
|||||||
project_id, project_name = create_project(
|
project_id, project_name = create_project(
|
||||||
wm.selected_account_id,
|
wm.selected_account_id,
|
||||||
self.project_name,
|
self.project_name,
|
||||||
None
|
wm.selected_workspace.id,
|
||||||
if wm.selected_workspace.id == "personal"
|
|
||||||
else wm.selected_workspace.id,
|
|
||||||
)
|
)
|
||||||
wm.selected_project_id = project_id
|
wm.selected_project_id = project_id
|
||||||
wm.selected_project_name = project_name
|
wm.selected_project_name = project_name
|
||||||
@@ -54,30 +51,21 @@ def unregister() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def create_project(
|
def create_project(
|
||||||
account_id: str, project_name: str, workspace_id: Optional[str]
|
account_id: str, project_name: str, workspace_id: str
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
try:
|
try:
|
||||||
# Get cached client
|
# Get cached client
|
||||||
client = _client_cache.get_client(account_id)
|
client = _client_cache.get_client(account_id)
|
||||||
if not client:
|
if not client:
|
||||||
raise Exception(f"Could not get client for account: {account_id}")
|
raise Exception(f"Could not get client for account: {account_id}")
|
||||||
if workspace_id:
|
project = client.project.create_in_workspace(
|
||||||
project = client.project.create_in_workspace(
|
input=WorkspaceProjectCreateInput(
|
||||||
input=WorkspaceProjectCreateInput(
|
name=project_name,
|
||||||
name=project_name,
|
description="",
|
||||||
description="",
|
visibility=ProjectVisibility("PUBLIC"),
|
||||||
visibility=ProjectVisibility("PUBLIC"),
|
workspaceId=workspace_id,
|
||||||
workspaceId=workspace_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
project = client.project.create(
|
|
||||||
input=ProjectCreateInput(
|
|
||||||
name=project_name,
|
|
||||||
description="",
|
|
||||||
visibility=ProjectVisibility("PUBLIC"),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return (project.id, project.name)
|
return (project.id, project.name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import bpy
|
|||||||
from typing import Set
|
from typing import Set
|
||||||
from bpy.types import Context, Event
|
from bpy.types import Context, Event
|
||||||
from ..operations.publish_operation import publish_operation
|
from ..operations.publish_operation import publish_operation
|
||||||
|
from ..utils.account_manager import can_create_version
|
||||||
|
|
||||||
|
|
||||||
class SPECKLE_OT_publish_model_card(bpy.types.Operator):
|
class SPECKLE_OT_publish_model_card(bpy.types.Operator):
|
||||||
@@ -27,6 +28,14 @@ class SPECKLE_OT_publish_model_card(bpy.types.Operator):
|
|||||||
self.model_card_id
|
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
|
# set wm
|
||||||
wm.selected_account_id = model_card.account_id
|
wm.selected_account_id = model_card.account_id
|
||||||
wm.selected_project_id = model_card.project_id
|
wm.selected_project_id = model_card.project_id
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from bpy.types import Event
|
|||||||
from typing import Set
|
from typing import Set
|
||||||
|
|
||||||
from ..operations.publish_operation import publish_operation
|
from ..operations.publish_operation import publish_operation
|
||||||
from ..utils.account_manager import get_server_url_by_account_id
|
from ..utils.account_manager import get_server_url_by_account_id, can_create_version
|
||||||
from ..utils.model_card_utils import model_card_exists, update_model_card_objects
|
from ..utils.model_card_utils import model_card_exists, update_model_card_objects
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +55,11 @@ class SPECKLE_OT_publish(bpy.types.Operator):
|
|||||||
self.report({"ERROR"}, "No model selected")
|
self.report({"ERROR"}, "No model selected")
|
||||||
return {"CANCELLED"}
|
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 = []
|
objects_to_convert = []
|
||||||
for speckle_obj in wm.speckle_objects:
|
for speckle_obj in wm.speckle_objects:
|
||||||
blender_obj = bpy.data.objects.get(speckle_obj.name)
|
blender_obj = bpy.data.objects.get(speckle_obj.name)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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"}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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,25 +1,29 @@
|
|||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Context
|
from bpy.types import Context
|
||||||
from specklepy.transports.server import ServerTransport
|
from specklepy.core.api import host_applications, operations
|
||||||
from specklepy.core.api import operations
|
from specklepy.core.api.inputs.version_inputs import MarkReceivedVersionInput
|
||||||
from specklepy.objects.models.collections.collection import Collection as SCollection
|
from specklepy.logging import metrics
|
||||||
from specklepy.objects.graph_traversal.default_traversal import (
|
from specklepy.objects.graph_traversal.default_traversal import (
|
||||||
create_default_traversal_function,
|
create_default_traversal_function,
|
||||||
)
|
)
|
||||||
from specklepy.core.api import host_applications
|
from specklepy.objects.models.collections.collection import Collection as SCollection
|
||||||
|
from specklepy.transports.server import ServerTransport
|
||||||
|
|
||||||
from ..utils.get_ascendants import get_ascendants
|
from ... import bl_info
|
||||||
from ..utils.account_manager import _client_cache
|
|
||||||
from ...converter.utils import find_object_by_id, get_project_workspace_id
|
|
||||||
from ...converter.to_native import (
|
from ...converter.to_native import (
|
||||||
convert_to_native,
|
convert_to_native,
|
||||||
render_material_proxy_to_native,
|
|
||||||
instance_definition_proxy_to_native,
|
|
||||||
find_instance_definitions,
|
find_instance_definitions,
|
||||||
|
instance_definition_proxy_to_native,
|
||||||
|
render_material_proxy_to_native,
|
||||||
)
|
)
|
||||||
from specklepy.logging import metrics
|
from ...converter.utils import (
|
||||||
from ... import bl_info
|
build_object_id_map,
|
||||||
from typing import Dict, Union
|
get_project_workspace_id,
|
||||||
|
)
|
||||||
|
from ..utils.account_manager import _client_cache
|
||||||
|
from ..utils.get_ascendants import get_ascendants
|
||||||
|
|
||||||
|
|
||||||
def load_operation(
|
def load_operation(
|
||||||
@@ -30,59 +34,62 @@ def load_operation(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
wm = context.window_manager
|
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
|
# get cached client
|
||||||
client = _client_cache.get_client(context.window_manager.selected_account_id)
|
client = _client_cache.get_client(accountId)
|
||||||
if not client:
|
if not client:
|
||||||
print("No Speckle client found")
|
print("No Speckle client found")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
print(f"Using client for account: {context.window_manager.selected_account_id}")
|
print(f"Using client for account: {accountId}")
|
||||||
|
|
||||||
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
|
transport = ServerTransport(stream_id=projectId, client=client)
|
||||||
|
|
||||||
version = client.version.get(wm.selected_version_id, wm.selected_project_id)
|
version = client.version.get(versionId, projectId)
|
||||||
obj_id = version.referenced_object
|
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)
|
version_data = operations.receive(obj_id, transport)
|
||||||
|
|
||||||
metrics.set_host_app("blender")
|
metrics.set_host_app("blender")
|
||||||
|
client.version.received(
|
||||||
# Get account for metrics tracking
|
MarkReceivedVersionInput(
|
||||||
from specklepy.core.api.credentials import get_local_accounts
|
version_id=version.id,
|
||||||
|
project_id=projectId,
|
||||||
account = next(
|
source_application="blender",
|
||||||
(
|
)
|
||||||
acc
|
|
||||||
for acc in get_local_accounts()
|
|
||||||
if acc.id == context.window_manager.selected_account_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if account:
|
metrics.track(
|
||||||
metrics.track(
|
metrics.RECEIVE,
|
||||||
metrics.RECEIVE,
|
client.account,
|
||||||
account,
|
{
|
||||||
{
|
"ui": "dui3",
|
||||||
"ui": "dui3",
|
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
||||||
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
"core_version": ".".join(map(str, bl_info["version"])),
|
||||||
"core_version": ".".join(map(str, bl_info["version"])),
|
"sourceHostApp": host_applications.get_host_app_from_string(
|
||||||
"sourceHostApp": host_applications.get_host_app_from_string(
|
version.source_application
|
||||||
version.source_application
|
).slug,
|
||||||
).slug,
|
"isMultiplayer": version.author_user.id != client.account.userInfo.id,
|
||||||
"isMultiplayer": version.author_user.id != account.userInfo.id,
|
"workspace_id": get_project_workspace_id(client, wm.selected_project_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
|
# Create material mapping first
|
||||||
material_mapping = render_material_proxy_to_native(version_data)
|
material_mapping = render_material_proxy_to_native(version_data)
|
||||||
|
|
||||||
definition_collections, definition_objects = instance_definition_proxy_to_native(
|
definition_collections, definition_objects = instance_definition_proxy_to_native(
|
||||||
version_data, material_mapping, instance_loading_mode=instance_loading_mode
|
version_data,
|
||||||
|
material_mapping,
|
||||||
|
instance_loading_mode=instance_loading_mode,
|
||||||
|
object_id_map=object_id_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
definitions_root_collection = None
|
definitions_root_collection = None
|
||||||
@@ -96,7 +103,8 @@ def load_operation(
|
|||||||
for definition in find_instance_definitions(version_data).values():
|
for definition in find_instance_definitions(version_data).values():
|
||||||
definition_object_ids.update(definition.objects)
|
definition_object_ids.update(definition.objects)
|
||||||
for obj_id in definition.objects:
|
for obj_id in definition.objects:
|
||||||
found_obj = find_object_by_id(version_data, obj_id)
|
# Use ID map
|
||||||
|
found_obj = object_id_map.get(obj_id)
|
||||||
if found_obj:
|
if found_obj:
|
||||||
if hasattr(found_obj, "id"):
|
if hasattr(found_obj, "id"):
|
||||||
definition_object_ids.add(found_obj.id)
|
definition_object_ids.add(found_obj.id)
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ from specklepy.core.api import operations
|
|||||||
from specklepy.transports.server import ServerTransport
|
from specklepy.transports.server import ServerTransport
|
||||||
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
|
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
|
||||||
from specklepy.objects.models.units import Units
|
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 import convert_to_speckle
|
||||||
from ...converter.to_speckle.material_to_speckle import (
|
from ...converter.to_speckle.material_to_speckle import (
|
||||||
@@ -19,6 +27,108 @@ from specklepy.logging import metrics
|
|||||||
from ... import bl_info
|
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(
|
def publish_operation(
|
||||||
context: Context,
|
context: Context,
|
||||||
objects_to_convert: List,
|
objects_to_convert: List,
|
||||||
@@ -36,7 +146,13 @@ def publish_operation(
|
|||||||
if not client:
|
if not client:
|
||||||
return False, "No Speckle client found", None
|
return False, "No Speckle client found", None
|
||||||
|
|
||||||
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
|
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)
|
||||||
|
|
||||||
# build collection hierarchy and convert objects
|
# build collection hierarchy and convert objects
|
||||||
root_collection = build_collection_hierarchy(
|
root_collection = build_collection_hierarchy(
|
||||||
@@ -51,16 +167,14 @@ def publish_operation(
|
|||||||
|
|
||||||
obj_id = operations.send(root_collection, [transport])
|
obj_id = operations.send(root_collection, [transport])
|
||||||
|
|
||||||
version_input = CreateVersionInput(
|
if use_ingestion:
|
||||||
objectId=obj_id,
|
version_id = _send_via_ingestion(
|
||||||
modelId=wm.selected_model_id,
|
client, project_id, model_id, obj_id, version_message
|
||||||
projectId=wm.selected_project_id,
|
)
|
||||||
message=version_message,
|
else:
|
||||||
sourceApplication="blender",
|
version_id = _send_via_version_create(
|
||||||
)
|
client, project_id, model_id, obj_id, version_message
|
||||||
|
)
|
||||||
version = client.version.create(version_input)
|
|
||||||
version_id = version.id
|
|
||||||
|
|
||||||
# Get account for metrics tracking
|
# Get account for metrics tracking
|
||||||
from specklepy.core.api.credentials import get_local_accounts
|
from specklepy.core.api.credentials import get_local_accounts
|
||||||
@@ -80,9 +194,7 @@ def publish_operation(
|
|||||||
"ui": "dui3",
|
"ui": "dui3",
|
||||||
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
||||||
"core_version": ".".join(map(str, bl_info["version"])),
|
"core_version": ".".join(map(str, bl_info["version"])),
|
||||||
"workspace_id": get_project_workspace_id(
|
"workspace_id": get_project_workspace_id(client, project_id),
|
||||||
client, wm.selected_project_id
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,6 +207,9 @@ def publish_operation(
|
|||||||
version_id,
|
version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except WorkspacePermissionException as e:
|
||||||
|
return False, f"Permission denied: {str(e)}", None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ def update_workspaces_list(context: Context) -> None:
|
|||||||
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
||||||
workspace.id = id
|
workspace.id = id
|
||||||
workspace.name = name
|
workspace.name = name
|
||||||
wm.selected_workspace.id = get_active_workspace(wm.selected_account_id)["id"]
|
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
|
||||||
print("Updated Workspaces List!")
|
print("Updated Workspaces List!")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,70 +104,3 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
|
|||||||
row = layout.row()
|
row = layout.row()
|
||||||
row.enabled = project_selected and model_selected and version_selected
|
row.enabled = project_selected and model_selected and version_selected
|
||||||
row.operator("speckle.load", text="Load Model", icon="IMPORT")
|
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()
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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,6 +2,8 @@ import bpy
|
|||||||
from bpy.types import UILayout, Context, PropertyGroup, Event
|
from bpy.types import UILayout, Context, PropertyGroup, Event
|
||||||
from ..utils.model_manager import get_models_for_project
|
from ..utils.model_manager import get_models_for_project
|
||||||
from ..utils.version_manager import get_latest_version
|
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
|
||||||
|
|
||||||
|
|
||||||
class SPECKLE_UL_models_list(bpy.types.UIList):
|
class SPECKLE_UL_models_list(bpy.types.UIList):
|
||||||
@@ -94,6 +96,11 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
|||||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||||
self.update_models_list(context)
|
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)
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
def draw(self, context: Context) -> None:
|
def draw(self, context: Context) -> None:
|
||||||
@@ -104,7 +111,9 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
|||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
row.prop(self, "search_query", icon="VIEWZOOM", text="") # search bar
|
row.prop(self, "search_query", icon="VIEWZOOM", text="") # search bar
|
||||||
if wm.ui_mode != "LOAD":
|
if wm.ui_mode != "LOAD":
|
||||||
row.operator("speckle.create_model", icon="ADD", text="")
|
sub = row.row(align=True)
|
||||||
|
sub.enabled = getattr(self, "_can_create_model", True)
|
||||||
|
sub.operator("speckle.create_model", icon="ADD", text="")
|
||||||
|
|
||||||
layout.template_list(
|
layout.template_list(
|
||||||
"SPECKLE_UL_models_list",
|
"SPECKLE_UL_models_list",
|
||||||
|
|||||||
@@ -120,10 +120,18 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
|||||||
if wm.selected_account_id == "":
|
if wm.selected_account_id == "":
|
||||||
wm.selected_account_id = get_default_account_id()
|
wm.selected_account_id = get_default_account_id()
|
||||||
|
|
||||||
wm.selected_workspace.id = get_active_workspace(wm.selected_account_id)["id"]
|
active_workspace = get_active_workspace(wm.selected_account_id)
|
||||||
wm.selected_workspace.name = get_active_workspace(wm.selected_account_id)[
|
if active_workspace:
|
||||||
"name"
|
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
|
||||||
|
|
||||||
# Fetch projects from server
|
# Fetch projects from server
|
||||||
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import List
|
|||||||
from bpy.types import Operator, Context, Object
|
from bpy.types import Operator, Context, Object
|
||||||
from bpy.props import EnumProperty
|
from bpy.props import EnumProperty
|
||||||
from ..utils.model_card_utils import update_model_card_objects
|
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):
|
class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||||
@@ -45,6 +46,14 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
|
|||||||
update_model_card_objects(model_card, user_selection)
|
update_model_card_objects(model_card, user_selection)
|
||||||
self.report({"INFO"}, "Selection updated")
|
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
|
# Call the publish operator
|
||||||
bpy.ops.speckle.model_card_publish(
|
bpy.ops.speckle.model_card_publish(
|
||||||
model_card_id=self.model_card_id, version_message=self.version_message
|
model_card_id=self.model_card_id, version_message=self.version_message
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import bpy
|
import bpy
|
||||||
from specklepy.core.api.credentials import get_local_accounts
|
from specklepy.core.api.credentials import get_local_accounts
|
||||||
from typing import List, Tuple, Optional, Dict
|
from typing import List, Tuple, Optional, Dict
|
||||||
|
from urllib.parse import urlparse
|
||||||
from specklepy.core.api.credentials import Account
|
from specklepy.core.api.credentials import Account
|
||||||
from specklepy.core.api.client import SpeckleClient
|
from specklepy.core.api.client import SpeckleClient
|
||||||
from specklepy.core.api.wrapper import StreamWrapper
|
from specklepy.core.api.wrapper import StreamWrapper
|
||||||
@@ -23,7 +24,9 @@ class SpeckleClientCache:
|
|||||||
if not account:
|
if not account:
|
||||||
raise ValueError(f"No account found for ID: {account_id}")
|
raise ValueError(f"No account found for ID: {account_id}")
|
||||||
|
|
||||||
client = SpeckleClient(host=account.serverInfo.url)
|
url = account.serverInfo.url
|
||||||
|
use_ssl = urlparse(url).scheme.lower() != "http"
|
||||||
|
client = SpeckleClient(host=url, use_ssl=use_ssl)
|
||||||
client.authenticate_with_account(account)
|
client.authenticate_with_account(account)
|
||||||
self._clients[account_id] = client
|
self._clients[account_id] = client
|
||||||
return client
|
return client
|
||||||
@@ -92,22 +95,20 @@ def get_workspaces(account_id: str) -> List[Tuple[str, str]]:
|
|||||||
for ws in workspaces
|
for ws in workspaces
|
||||||
if ws.creation_state is None or ws.creation_state.completed
|
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()
|
active_workspace = client.active_user.get_active_workspace()
|
||||||
default_workspace_id = (
|
default_workspace_id = (
|
||||||
active_workspace.id if active_workspace else "personal"
|
active_workspace.id
|
||||||
|
if active_workspace
|
||||||
|
else (workspaces[0].id if workspaces else None)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = reorder_tuple(workspace_list, default_workspace_id)
|
if default_workspace_id:
|
||||||
|
result = reorder_tuple(workspace_list, default_workspace_id)
|
||||||
|
else:
|
||||||
|
result = workspace_list
|
||||||
else:
|
else:
|
||||||
result = workspace_list
|
result = []
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -145,7 +146,7 @@ def get_active_workspace(account_id: str) -> Optional[Dict[str, str]]:
|
|||||||
active_workspace = client.active_user.get_active_workspace()
|
active_workspace = client.active_user.get_active_workspace()
|
||||||
if active_workspace:
|
if active_workspace:
|
||||||
return {"id": active_workspace.id, "name": active_workspace.name}
|
return {"id": active_workspace.id, "name": active_workspace.name}
|
||||||
return {"id": "personal", "name": "Personal Projects"}
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in get_active_workspace: {str(e)}")
|
print(f"Error in get_active_workspace: {str(e)}")
|
||||||
_client_cache.clear()
|
_client_cache.clear()
|
||||||
@@ -261,16 +262,42 @@ def can_load(client, project) -> Tuple[bool, str]:
|
|||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
|
||||||
def can_publish(client, project) -> Tuple[bool, str]:
|
def can_create_version(
|
||||||
|
account_id: str, project_id: str, model_id: str
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
permissions = client.project.get_permissions(project.id)
|
client = _client_cache.get_client(account_id)
|
||||||
|
permissions = client.model.get_permissions(project_id, model_id)
|
||||||
|
|
||||||
if permissions.can_publish.authorized:
|
if permissions.can_create_version.authorized:
|
||||||
return True, ""
|
return True, ""
|
||||||
else:
|
else:
|
||||||
|
message = getattr(permissions.can_create_version, "message", None)
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
"Your role on this project doesn't give you permission to publish.",
|
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.",
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -286,15 +313,12 @@ def can_create_project_in_workspace(account_id: str, workspace_id: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
client = _client_cache.get_client(account_id)
|
client = _client_cache.get_client(account_id)
|
||||||
|
|
||||||
if workspace_id == "personal":
|
try:
|
||||||
return client.active_user.can_create_personal_projects().authorized
|
workspace = client.workspace.get(workspace_id)
|
||||||
else:
|
return workspace.permissions.can_create_project.authorized
|
||||||
try:
|
except Exception as e:
|
||||||
workspace = client.workspace.get(workspace_id)
|
print(f"Failed to get workspace: {str(e)}")
|
||||||
return workspace.permissions.can_create_project.authorized
|
return False
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to get workspace: {str(e)}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in can_create_project_in_workspace: {str(e)}")
|
print(f"Error in can_create_project_in_workspace: {str(e)}")
|
||||||
_client_cache.clear() # Clear cache on error
|
_client_cache.clear() # Clear cache on error
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
"""
|
||||||
|
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,6 +1,8 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Context
|
from bpy.types import Context
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from ..utils.property_groups import speckle_model_card
|
from ..utils.property_groups import speckle_model_card
|
||||||
|
|
||||||
|
|
||||||
@@ -316,11 +318,17 @@ def update_model_card_objects(
|
|||||||
if isinstance(converted_objects, list):
|
if isinstance(converted_objects, list):
|
||||||
converted_objects = {obj.name: obj for obj in converted_objects}
|
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():
|
for obj in converted_objects.values():
|
||||||
# Handle collections
|
# Handle collections
|
||||||
if isinstance(obj, bpy.types.Collection):
|
if isinstance(obj, bpy.types.Collection):
|
||||||
if obj.name in (o.name for o in model_card.collections):
|
if obj.name in collection_names:
|
||||||
continue
|
continue
|
||||||
|
collection_names.add(obj.name)
|
||||||
|
|
||||||
s_col = model_card.collections.add()
|
s_col = model_card.collections.add()
|
||||||
s_col.name = obj.name
|
s_col.name = obj.name
|
||||||
s_col.applicationId = obj.get("applicationId", "")
|
s_col.applicationId = obj.get("applicationId", "")
|
||||||
@@ -334,8 +342,10 @@ def update_model_card_objects(
|
|||||||
|
|
||||||
# Handle objects
|
# Handle objects
|
||||||
elif isinstance(obj, bpy.types.Object):
|
elif isinstance(obj, bpy.types.Object):
|
||||||
if obj.name in (o.name for o in model_card.objects):
|
if obj.name in object_names:
|
||||||
continue
|
continue
|
||||||
|
object_names.add(obj.name)
|
||||||
|
|
||||||
s_obj = model_card.objects.add()
|
s_obj = model_card.objects.add()
|
||||||
s_obj.name = obj.name
|
s_obj.name = obj.name
|
||||||
s_obj.applicationId = obj.get("applicationId", "")
|
s_obj.applicationId = obj.get("applicationId", "")
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ def get_projects_for_account(
|
|||||||
print(f"Error: Could not find account with ID: {account_id}")
|
print(f"Error: Could not find account with ID: {account_id}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if workspace_id == "personal":
|
|
||||||
return _get_personal_projects_with_permissions(client, search)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workspace_resource = WorkspaceResource(
|
workspace_resource = WorkspaceResource(
|
||||||
account, client.url, client.httpclient, client.server.version()
|
account, client.url, client.httpclient, client.server.version()
|
||||||
@@ -95,43 +92,6 @@ def get_projects_for_account(
|
|||||||
return []
|
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(
|
def _get_projects_with_individual_permissions(
|
||||||
client: SpeckleClient,
|
client: SpeckleClient,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
from typing import Any, Iterable, List, Optional, Tuple, Dict
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
from specklepy.objects import Base
|
|
||||||
from specklepy.objects import DataObject
|
import bpy
|
||||||
|
import mathutils
|
||||||
|
from bpy.types import Object
|
||||||
|
from specklepy.objects import Base, DataObject
|
||||||
from specklepy.objects.geometry import (
|
from specklepy.objects.geometry import (
|
||||||
Line,
|
|
||||||
Polyline,
|
|
||||||
Mesh,
|
|
||||||
Arc,
|
Arc,
|
||||||
Circle,
|
Circle,
|
||||||
Ellipse,
|
|
||||||
Curve,
|
Curve,
|
||||||
Polycurve,
|
Ellipse,
|
||||||
|
Line,
|
||||||
|
Mesh,
|
||||||
Point,
|
Point,
|
||||||
|
Polycurve,
|
||||||
|
Polyline,
|
||||||
|
)
|
||||||
|
from specklepy.objects.models.units import (
|
||||||
|
get_scale_factor_to_meters,
|
||||||
|
get_units_from_string,
|
||||||
)
|
)
|
||||||
from specklepy.objects.proxies import InstanceProxy
|
from specklepy.objects.proxies import InstanceProxy
|
||||||
from specklepy.objects.models.units import (
|
|
||||||
get_units_from_string,
|
from ..converter.utils import create_material_from_proxy
|
||||||
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 to check for
|
||||||
DISPLAY_VALUE_PROPERTY_ALIASES = [
|
DISPLAY_VALUE_PROPERTY_ALIASES = [
|
||||||
@@ -159,7 +160,14 @@ def convert_to_native(
|
|||||||
else:
|
else:
|
||||||
# Fallback to display value if direct conversion not supported
|
# Fallback to display value if direct conversion not supported
|
||||||
mesh, children = display_value_to_native(
|
mesh, children = display_value_to_native(
|
||||||
speckle_object, object_name, data_block_name, scale, material_mapping
|
speckle_object,
|
||||||
|
object_name,
|
||||||
|
data_block_name,
|
||||||
|
scale,
|
||||||
|
material_mapping,
|
||||||
|
definition_collections,
|
||||||
|
root_collection,
|
||||||
|
instance_loading_mode,
|
||||||
)
|
)
|
||||||
if mesh:
|
if mesh:
|
||||||
# Create a mesh object with the object_name (simple name) and mesh data
|
# Create a mesh object with the object_name (simple name) and mesh data
|
||||||
@@ -176,7 +184,11 @@ def convert_to_native(
|
|||||||
# Ensure the converted object has the correct name (especially for DataObjects)
|
# Ensure the converted object has the correct name (especially for DataObjects)
|
||||||
if isinstance(speckle_object, DataObject):
|
if isinstance(speckle_object, DataObject):
|
||||||
converted_object.name = object_name
|
converted_object.name = object_name
|
||||||
data_block_name = converted_object.data.name
|
if (
|
||||||
|
hasattr(converted_object, "data")
|
||||||
|
and converted_object.data is not None
|
||||||
|
):
|
||||||
|
data_block_name = converted_object.data.name
|
||||||
|
|
||||||
# If there are multiple objects, parent remaining ones to the first
|
# If there are multiple objects, parent remaining ones to the first
|
||||||
for child in children[1:]:
|
for child in children[1:]:
|
||||||
@@ -197,6 +209,9 @@ def display_value_to_native(
|
|||||||
data_block_name: str,
|
data_block_name: str,
|
||||||
scale: float,
|
scale: float,
|
||||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
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]]:
|
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
|
||||||
"""
|
"""
|
||||||
fallback conversion mechanism using displayValue if present
|
fallback conversion mechanism using displayValue if present
|
||||||
@@ -215,6 +230,9 @@ def display_value_to_native(
|
|||||||
DISPLAY_VALUE_PROPERTY_ALIASES,
|
DISPLAY_VALUE_PROPERTY_ALIASES,
|
||||||
True,
|
True,
|
||||||
material_mapping,
|
material_mapping,
|
||||||
|
definition_collections,
|
||||||
|
root_collection,
|
||||||
|
instance_loading_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the parent had an applicationId and we created a mesh, apply the material
|
# If the parent had an applicationId and we created a mesh, apply the material
|
||||||
@@ -247,6 +265,9 @@ def elements_to_native(
|
|||||||
data_block_name: str,
|
data_block_name: str,
|
||||||
scale: float,
|
scale: float,
|
||||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
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]:
|
) -> List[Object]:
|
||||||
"""
|
"""
|
||||||
convert elements collection of a speckle object
|
convert elements collection of a speckle object
|
||||||
@@ -259,6 +280,9 @@ def elements_to_native(
|
|||||||
ELEMENTS_PROPERTY_ALIASES,
|
ELEMENTS_PROPERTY_ALIASES,
|
||||||
False,
|
False,
|
||||||
material_mapping,
|
material_mapping,
|
||||||
|
definition_collections,
|
||||||
|
root_collection,
|
||||||
|
instance_loading_mode,
|
||||||
)
|
)
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
@@ -271,12 +295,16 @@ def _members_to_native(
|
|||||||
members: Iterable[str],
|
members: Iterable[str],
|
||||||
combineMeshes: bool,
|
combineMeshes: bool,
|
||||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
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]]:
|
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
|
||||||
"""
|
"""
|
||||||
converts a given speckle_object by converting specified members
|
converts a given speckle_object by converting specified members
|
||||||
"""
|
"""
|
||||||
meshes: List[Mesh] = []
|
meshes: List[Mesh] = []
|
||||||
others: List[Base] = []
|
others: List[Base] = []
|
||||||
|
instance_proxies: List[InstanceProxy] = []
|
||||||
|
|
||||||
for alias in members:
|
for alias in members:
|
||||||
display = getattr(speckle_object, alias, None)
|
display = getattr(speckle_object, alias, None)
|
||||||
@@ -285,10 +313,13 @@ def _members_to_native(
|
|||||||
MAX_DEPTH = 255 # some large value, to prevent infinite recursion
|
MAX_DEPTH = 255 # some large value, to prevent infinite recursion
|
||||||
|
|
||||||
def separate(value: Any) -> bool:
|
def separate(value: Any) -> bool:
|
||||||
nonlocal meshes, others, count, MAX_DEPTH
|
nonlocal meshes, others, instance_proxies, count, MAX_DEPTH
|
||||||
|
|
||||||
if combineMeshes and isinstance(value, Mesh):
|
if combineMeshes and isinstance(value, Mesh):
|
||||||
meshes.append(value)
|
meshes.append(value)
|
||||||
|
elif isinstance(value, InstanceProxy):
|
||||||
|
# Handle InstanceProxy objects separately - they need definition_collections
|
||||||
|
instance_proxies.append(value)
|
||||||
elif isinstance(value, Base):
|
elif isinstance(value, Base):
|
||||||
others.append(value)
|
others.append(value)
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
@@ -318,10 +349,28 @@ def _members_to_native(
|
|||||||
# Check if the original object is a DataObject
|
# Check if the original object is a DataObject
|
||||||
is_data_object = isinstance(speckle_object, 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:
|
for item in others:
|
||||||
try:
|
try:
|
||||||
blender_object = convert_to_native(
|
blender_object = convert_to_native(
|
||||||
item, material_mapping, instance_loading_mode="INSTANCE_PROXIES"
|
item,
|
||||||
|
material_mapping,
|
||||||
|
definition_collections=definition_collections,
|
||||||
|
root_collection=root_collection,
|
||||||
|
instance_loading_mode=instance_loading_mode,
|
||||||
)
|
)
|
||||||
if blender_object:
|
if blender_object:
|
||||||
# If the parent is a DataObject, override the name of the converted child
|
# If the parent is a DataObject, override the name of the converted child
|
||||||
@@ -647,7 +696,7 @@ def render_material_proxy_to_native(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
render_material = proxy.value
|
render_material = proxy.value
|
||||||
material_name = getattr(render_material, "name", "Material")
|
material_name = getattr(render_material, "name", None) or "Material"
|
||||||
|
|
||||||
# create or get existing material
|
# create or get existing material
|
||||||
blender_material = create_material_from_proxy(render_material, material_name)
|
blender_material = create_material_from_proxy(render_material, material_name)
|
||||||
@@ -666,6 +715,7 @@ def arc_to_native(
|
|||||||
converts a Speckle arc to a Blender NURBS curve.
|
converts a Speckle arc to a Blender NURBS curve.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import mathutils
|
import mathutils
|
||||||
|
|
||||||
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
||||||
@@ -800,6 +850,7 @@ def circle_to_native(
|
|||||||
converts a Speckle circle to a Blender NURBS curve.
|
converts a Speckle circle to a Blender NURBS curve.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import mathutils
|
import mathutils
|
||||||
|
|
||||||
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
||||||
@@ -987,7 +1038,14 @@ def curve_to_native(
|
|||||||
):
|
):
|
||||||
print("curve_to_native: degree 2 curve, falling back to displayValue")
|
print("curve_to_native: degree 2 curve, falling back to displayValue")
|
||||||
mesh, children = display_value_to_native(
|
mesh, children = display_value_to_native(
|
||||||
speckle_curve, object_name, data_block_name, scale
|
speckle_curve,
|
||||||
|
object_name,
|
||||||
|
data_block_name,
|
||||||
|
scale,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"INSTANCE_PROXIES",
|
||||||
)
|
)
|
||||||
if mesh:
|
if mesh:
|
||||||
curve_obj = bpy.data.objects.new(object_name, mesh)
|
curve_obj = bpy.data.objects.new(object_name, mesh)
|
||||||
@@ -1059,7 +1117,14 @@ def polycurve_to_native(
|
|||||||
and speckle_polycurve.displayValue
|
and speckle_polycurve.displayValue
|
||||||
):
|
):
|
||||||
mesh, children = display_value_to_native(
|
mesh, children = display_value_to_native(
|
||||||
speckle_polycurve, object_name, data_block_name, scale
|
speckle_polycurve,
|
||||||
|
object_name,
|
||||||
|
data_block_name,
|
||||||
|
scale,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"INSTANCE_PROXIES",
|
||||||
)
|
)
|
||||||
if mesh:
|
if mesh:
|
||||||
curve_obj = bpy.data.objects.new(object_name, mesh)
|
curve_obj = bpy.data.objects.new(object_name, mesh)
|
||||||
@@ -1211,6 +1276,7 @@ def instance_definition_proxy_to_native(
|
|||||||
material_mapping: Dict[str, Any],
|
material_mapping: Dict[str, Any],
|
||||||
processed_definitions: Dict[str, Any] = None,
|
processed_definitions: Dict[str, Any] = None,
|
||||||
instance_loading_mode: str = "INSTANCE_PROXIES",
|
instance_loading_mode: str = "INSTANCE_PROXIES",
|
||||||
|
object_id_map: Optional[Dict[str, Base]] = None,
|
||||||
) -> Tuple[Dict[str, bpy.types.Collection], Dict[str, Any]]:
|
) -> Tuple[Dict[str, bpy.types.Collection], Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
converts instance definition proxies to Blender collections recursively
|
converts instance definition proxies to Blender collections recursively
|
||||||
@@ -1262,7 +1328,8 @@ def instance_definition_proxy_to_native(
|
|||||||
# Process objects, including nested instances
|
# Process objects, including nested instances
|
||||||
if hasattr(definition, "objects") and isinstance(definition.objects, list):
|
if hasattr(definition, "objects") and isinstance(definition.objects, list):
|
||||||
for obj_id in definition.objects:
|
for obj_id in definition.objects:
|
||||||
found_obj = find_object_by_id(root_object, obj_id)
|
# Use the ID map for lookup
|
||||||
|
found_obj = object_id_map.get(obj_id) if object_id_map else None
|
||||||
|
|
||||||
if found_obj:
|
if found_obj:
|
||||||
try:
|
try:
|
||||||
@@ -1276,7 +1343,9 @@ def instance_definition_proxy_to_native(
|
|||||||
if max_depth > 0: # Only process if max_depth allows
|
if max_depth > 0: # Only process if max_depth allows
|
||||||
assert (
|
assert (
|
||||||
found_obj.definitionId in definition_collections
|
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":
|
if instance_loading_mode == "LINKED_DUPLICATES":
|
||||||
blender_obj = instance_proxy_to_linked_duplicates(
|
blender_obj = instance_proxy_to_linked_duplicates(
|
||||||
@@ -1362,7 +1431,8 @@ def instance_proxy_to_linked_duplicates(
|
|||||||
print(f"Definition collection not found for instance {speckle_instance.id}")
|
print(f"Definition collection not found for instance {speckle_instance.id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
unit_scale = proxy_scale(speckle_instance)
|
# Use the scale from the parent context
|
||||||
|
unit_scale = scale
|
||||||
|
|
||||||
# convert transformation matrix
|
# convert transformation matrix
|
||||||
matrix = mathutils.Matrix(
|
matrix = mathutils.Matrix(
|
||||||
@@ -1397,7 +1467,6 @@ def instance_proxy_to_linked_duplicates(
|
|||||||
location, rotation, scale_vector = matrix.decompose()
|
location, rotation, scale_vector = matrix.decompose()
|
||||||
location = location * unit_scale
|
location = location * unit_scale
|
||||||
|
|
||||||
# create transformation matrix
|
|
||||||
final_matrix = (
|
final_matrix = (
|
||||||
mathutils.Matrix.Translation(location)
|
mathutils.Matrix.Translation(location)
|
||||||
@ rotation.to_matrix().to_4x4()
|
@ rotation.to_matrix().to_4x4()
|
||||||
@@ -1409,10 +1478,8 @@ def instance_proxy_to_linked_duplicates(
|
|||||||
parent_empty.empty_display_type = "PLAIN_AXES"
|
parent_empty.empty_display_type = "PLAIN_AXES"
|
||||||
parent_empty.empty_display_size = 0.1
|
parent_empty.empty_display_size = 0.1
|
||||||
|
|
||||||
parent_empty.matrix_world = final_matrix
|
|
||||||
|
|
||||||
# link parent to root collection
|
|
||||||
root_collection.objects.link(parent_empty)
|
root_collection.objects.link(parent_empty)
|
||||||
|
parent_empty.matrix_world = final_matrix
|
||||||
|
|
||||||
parent_empty["speckle_id"] = speckle_instance.id
|
parent_empty["speckle_id"] = speckle_instance.id
|
||||||
parent_empty["speckle_type"] = speckle_instance.speckle_type
|
parent_empty["speckle_type"] = speckle_instance.speckle_type
|
||||||
@@ -1422,15 +1489,14 @@ def instance_proxy_to_linked_duplicates(
|
|||||||
|
|
||||||
duplicated_objects = []
|
duplicated_objects = []
|
||||||
for obj in definition_collection.objects:
|
for obj in definition_collection.objects:
|
||||||
# create a copy of the object with linked data
|
|
||||||
duplicate_obj = obj.copy()
|
duplicate_obj = obj.copy()
|
||||||
|
|
||||||
duplicate_obj.name = f"{obj.name}_{speckle_instance.id[:8]}"
|
duplicate_obj.name = f"{obj.name}_{speckle_instance.id[:8]}"
|
||||||
|
|
||||||
root_collection.objects.link(duplicate_obj)
|
root_collection.objects.link(duplicate_obj)
|
||||||
|
|
||||||
# apply the instance transformation directly to each object
|
duplicate_obj.parent = parent_empty
|
||||||
duplicate_obj.matrix_world = final_matrix @ obj.matrix_world
|
duplicate_obj.matrix_parent_inverse.identity()
|
||||||
|
duplicate_obj.matrix_basis = obj.matrix_world
|
||||||
|
|
||||||
duplicated_objects.append(duplicate_obj)
|
duplicated_objects.append(duplicate_obj)
|
||||||
|
|
||||||
@@ -1450,7 +1516,8 @@ def instance_proxy_to_native(
|
|||||||
print(f"Definition collection not found for instance {speckle_instance.id}")
|
print(f"Definition collection not found for instance {speckle_instance.id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
unit_scale = proxy_scale(speckle_instance)
|
# Use the scale from the parent context
|
||||||
|
unit_scale = scale
|
||||||
|
|
||||||
# convert transformation matrix
|
# convert transformation matrix
|
||||||
matrix = mathutils.Matrix(
|
matrix = mathutils.Matrix(
|
||||||
@@ -1483,35 +1550,24 @@ def instance_proxy_to_native(
|
|||||||
)
|
)
|
||||||
|
|
||||||
location, rotation, scale_vector = matrix.decompose()
|
location, rotation, scale_vector = matrix.decompose()
|
||||||
|
|
||||||
location = location * unit_scale
|
location = location * unit_scale
|
||||||
|
instance_name = f"Instance_{speckle_instance.id}"
|
||||||
bpy.ops.object.collection_instance_add(
|
instance_obj = bpy.data.objects.new(instance_name, None)
|
||||||
collection=definition_collection.name,
|
instance_obj.instance_type = "COLLECTION"
|
||||||
align="WORLD",
|
instance_obj.instance_collection = definition_collection
|
||||||
location=(0, 0, 0),
|
|
||||||
rotation=(0, 0, 0),
|
|
||||||
scale=(1, 1, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
instance_obj = bpy.context.active_object
|
|
||||||
|
|
||||||
instance_obj.empty_display_size = 0
|
instance_obj.empty_display_size = 0
|
||||||
|
|
||||||
instance_name = f"Instance_{speckle_instance.id}"
|
# Link to root collection
|
||||||
instance_obj.name = instance_name
|
root_collection.objects.link(instance_obj)
|
||||||
|
|
||||||
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_id"] = speckle_instance.id
|
||||||
instance_obj["speckle_type"] = speckle_instance.speckle_type
|
instance_obj["speckle_type"] = speckle_instance.speckle_type
|
||||||
instance_obj["definition_id"] = speckle_instance.definitionId
|
instance_obj["definition_id"] = speckle_instance.definitionId
|
||||||
if hasattr(speckle_instance, "maxDepth"):
|
if hasattr(speckle_instance, "maxDepth"):
|
||||||
instance_obj["max_depth"] = speckle_instance.maxDepth
|
instance_obj["max_depth"] = speckle_instance.maxDepth
|
||||||
|
|
||||||
|
# Apply transformation
|
||||||
final_matrix = (
|
final_matrix = (
|
||||||
mathutils.Matrix.Translation(location)
|
mathutils.Matrix.Translation(location)
|
||||||
@ rotation.to_matrix().to_4x4()
|
@ rotation.to_matrix().to_4x4()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Tuple, List, Optional
|
from typing import Tuple, List, Optional, Dict
|
||||||
import bpy
|
import bpy
|
||||||
import mathutils
|
import mathutils
|
||||||
from specklepy.objects import Base
|
from specklepy.objects import Base
|
||||||
@@ -118,6 +118,25 @@ 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]:
|
def find_object_by_id(root_object: Base, target_id: str) -> Optional[Base]:
|
||||||
"""
|
"""
|
||||||
finds an object using traversal, checking both id and applicationId
|
finds an object using traversal, checking both id and applicationId
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
|
||||||
+1
-1
@@ -30,7 +30,7 @@ def patch_manifest(simple_version: str):
|
|||||||
|
|
||||||
for index, line in enumerate(lines):
|
for index, line in enumerate(lines):
|
||||||
if line.startswith("version ="):
|
if line.startswith("version ="):
|
||||||
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}",\n'
|
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}"\n'
|
||||||
print(f"Patched connector version number in {FILE_PATH}")
|
print(f"Patched connector version number in {FILE_PATH}")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -5,12 +5,12 @@ description = "Next-Gen Speckle connector for Blender!"
|
|||||||
requires-python = ">=3.11.9, <4.0.0"
|
requires-python = ">=3.11.9, <4.0.0"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"specklepy>=3.0.3",
|
"specklepy>=3.2.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"fake-bpy-module-latest>=20240524,<20240525",
|
"fake-bpy-module-latest>=20260126",
|
||||||
"ruff>=0.4.4,<0.5",
|
"ruff==0.14.14",
|
||||||
|
"pre-commit>=4.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user