Compare commits
236 Commits
3.1.0-beta2
...
v3-dev
| 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 | |||
| 5c19d9aa16 | |||
| 29d706e1b6 | |||
| 25d02673a7 | |||
| 6a4dc62622 | |||
| bb9a5ea604 | |||
| 2e995bd0fa | |||
| 38e6096ea9 | |||
| 30e3398cd4 | |||
| 3f7e98aff5 | |||
| 1ad8429928 | |||
| a6c820183b | |||
| 56b6c813c0 | |||
| 8ab110f7ec | |||
| 11ff018f18 | |||
| 227f63d266 | |||
| 9e8aaf4f3b | |||
| afcb760bbf | |||
| 58283439ab | |||
| 0c29a2ec0a | |||
| 4ec62d4168 | |||
| 8d596823ed | |||
| ccd62e3452 | |||
| 1bd08497e6 | |||
| d23cc5a738 | |||
| 3e2ac4b5b6 | |||
| 928bc15ff1 | |||
| e410e40060 | |||
| d1f2c938b1 | |||
| 388ec2bdfd | |||
| b057c6c0da | |||
| 40089bdbb8 | |||
| 49dd688219 | |||
| 6993e8cb83 | |||
| 709015b9d8 | |||
| c5e0dfa36b | |||
| 1f72741b62 | |||
| 0f8f7e02be | |||
| f3e188b4f8 | |||
| 346b0210a5 | |||
| 5d072bbb1d | |||
| fcc8527c9f | |||
| 2814dabe14 | |||
| 0f6150b272 | |||
| e3bc770369 | |||
| 1bb6ce7b63 | |||
| 6bf05f6ffd | |||
| 684c868cd0 | |||
| 27009a61e9 | |||
| 27ff32f584 | |||
| 223ede3ec7 | |||
| 3b2e609888 | |||
| f4caa55298 | |||
| 166d686b9d | |||
| f3f43eebd5 | |||
| 3c16b35232 | |||
| 86fb480a15 | |||
| abef8f85d9 | |||
| 33a7009585 | |||
| 0249ecb313 | |||
| bbf8a3b45e | |||
| f1eec55633 | |||
| f2bc9a9701 | |||
| 1eb662c6a7 | |||
| c08d4c398f | |||
| a81a44ca1d | |||
| c4c4431ed2 | |||
| fd23d40a5c | |||
| f2d6e03ad8 | |||
| d5c9097afa | |||
| 90cd0706ba | |||
| 2daf0de073 | |||
| 3653ece109 | |||
| c29e0bf8ee | |||
| 95a84eda53 | |||
| 8116402023 | |||
| f6238eb29c | |||
| 0a4c9ec380 | |||
| 4b8b1393ba | |||
| ee01b28645 | |||
| 06225a8aab | |||
| 1129d169cb | |||
| a787bfb8a3 | |||
| 700ec92ed2 | |||
| 7fbf0a827b | |||
| b6f659e91f | |||
| e9628cde77 | |||
| cd919353f7 | |||
| 6400138a3c | |||
| 9b8ddcc706 | |||
| ba3e0b275e | |||
| 45a68b3c5f | |||
| 49379a3e33 | |||
| b30ffdc7d1 | |||
| 2e53a2fea4 | |||
| 9dc51ee1dc | |||
| 58e7ac5138 | |||
| 6bc9bfdc28 | |||
| cce1f3b092 | |||
| ebcbe29398 | |||
| 59b6b21736 | |||
| 6c9823776a | |||
| c77e7642b7 | |||
| 9240fce897 | |||
| a87b7a830e | |||
| aad7943f1a | |||
| 589b309d91 | |||
| 95cb7c35de | |||
| aab3f1792a | |||
| 80a6e424e4 | |||
| f08cac36e7 | |||
| aa01b5ccf4 | |||
| da7d260542 | |||
| 64e0f65e29 | |||
| fedf036e2f | |||
| b9df9b09de | |||
| 3afdb916b3 | |||
| 8d7b0adea4 | |||
| 7a6d956ccf | |||
| d272743642 | |||
| c3ce77fe4d | |||
| 564aa58ebf | |||
| 48fe756fcd | |||
| 8e2f546cd6 | |||
| 3219172fed | |||
| 098d576a0b | |||
| 1950c412c2 | |||
| b646b2c7b1 | |||
| 19f22296b4 | |||
| 240fb6cbbe | |||
| 5ca6a3f2c2 | |||
| 06fef6ca91 | |||
| dcbe2d7814 | |||
| b66850b994 | |||
| 6a337ccdad | |||
| 92fd5387ef | |||
| 6205afdda5 | |||
| d060c3b8cf | |||
| 5dd3dd713a | |||
| 4fc1053af7 | |||
| 80204faedf | |||
| 06a47a2f9b | |||
| b65a7a3d4a | |||
| 7064574387 | |||
| 169ae4d32a | |||
| 4d44b41e2f | |||
| a1f835dc77 | |||
| e2172216a5 | |||
| 965c3e9c6e | |||
| 65e4812ba1 | |||
| 87df86f723 | |||
| fd32371be3 | |||
| 19c1334bb3 | |||
| 7a36450143 | |||
| d37fce644b | |||
| 00bcefba56 | |||
| d53bc064e1 | |||
| 50affbedf1 | |||
| f00aeecead | |||
| bbf2ee79be | |||
| abb1f042d4 | |||
| 04b4e02d05 | |||
| 144990db7a | |||
| a5592cdd7d | |||
| 249e3a0a84 | |||
| f869139d2a | |||
| 3954369db5 | |||
| 7165d99b76 | |||
| 8234db872d | |||
| 9acc84387f | |||
| 4eb1f2b773 | |||
| 4d60607a92 | |||
| 09f809fc8f | |||
| 9f6833c36e | |||
| 1cc8ed804a | |||
| eb0f036641 | |||
| f140c41af6 | |||
| 8a91634f55 | |||
| b025819307 | |||
| 3ec98d3dd2 | |||
| ba182d48c4 | |||
| f35853cff1 | |||
| d64ad50d32 | |||
| ab0b012f58 | |||
| 8c39da370f | |||
| 532eba37a5 | |||
| 39207e5716 | |||
| 75763d0929 | |||
| 2f2a67a569 | |||
| 7246d239be | |||
| 4d1fe83c1e | |||
| 1097bba539 | |||
| eb25b6d821 | |||
| 80515fdc69 |
@@ -14,4 +14,4 @@ workflows:
|
||||
when:
|
||||
false
|
||||
jobs:
|
||||
- build
|
||||
- build
|
||||
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
- name: Install the project
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
# - uses: actions/cache@v3
|
||||
# with:
|
||||
# path: ~/.cache/pre-commit/
|
||||
# key: ${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pre-commit/
|
||||
key: ${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
# - name: Run pre-commit
|
||||
# run: uv run pre-commit run --all-files
|
||||
- name: Run pre-commit
|
||||
run: uv run pre-commit run --all-files
|
||||
|
||||
- name: Minimize uv cache
|
||||
run: uv cache prune --ci
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ modules/
|
||||
.tool-versions
|
||||
requirements.txt
|
||||
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>
|
||||
<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>
|
||||
|
||||
# About Speckle
|
||||
@@ -25,31 +25,30 @@ What is Speckle? Check our ](https://speckle.xyz) ⇒ creating an account at our public server
|
||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
||||
- [](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||
|
||||
### Resources
|
||||
|
||||
- [](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.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
|
||||
|
||||
The Speckle UI can be found in the 3d viewport toolbar (N), under the Speckle tab.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
We officially support Blender 4.2 and newer, on Windows.
|
||||
|
||||
## Usage
|
||||
|
||||
Once enabled in `Preferences -> Addons`,
|
||||
The Speckle connector UI can be found in the 3d viewport toolbar (N), under the `Speckle` tab.
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -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).
|
||||
|
||||
## Local Development
|
||||
|
||||
Pre-resquisits:
|
||||
|
||||
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
- A supported [blender](https://www.blender.org/download/) version
|
||||
- [Blender Development](https://marketplace.visualstudio.com/items?itemName=JacquesLucke.blender-development) extension for VS Code (recommended)
|
||||
|
||||
First time setup (or anytime you change pyproject.toml)
|
||||
Run the following commands
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
./export_dependencies.sh
|
||||
```
|
||||
|
||||
🪟 To activate the environment in a terminal (Windows):
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\activate
|
||||
```
|
||||
|
||||
🐧 To activate the environment in a terminal (Linux / macOS):
|
||||
|
||||
```sh
|
||||
source .venv/bin/activate.fish
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
To run the blender plugin, run the `>Blender: Start`
|
||||
from VS code (`ctrl + shift + p`)
|
||||
<img width="1469" height="379" alt="image" src="https://github.com/user-attachments/assets/9dc174a0-07fc-47c7-85d1-bd5a04d8f8c7" />
|
||||
|
||||
## Contributing
|
||||
|
||||
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
|
||||
@@ -89,6 +121,3 @@ The Speckle Community hangs out on [the forum](https://discourse.speckle.works),
|
||||
## License
|
||||
|
||||
Unless otherwise described, the code in this repository is licensed under the Apache-2.0 License. Please note that some modules, extensions or code herein might be otherwise licensed. This is indicated either in the root of the containing folder under a different license file, or in the respective file's header. If you have any questions, don't hesitate to get in touch with us via [email](mailto:hello@speckle.systems).
|
||||
|
||||
## Notes
|
||||
Thanks to [Tom Svilans](http://tomsvilans.com) ([Github](https://github.com/tsvilans)) for the original v1 contribution!
|
||||
|
||||
+171
-62
@@ -14,110 +14,196 @@
|
||||
import bpy
|
||||
from bpy.types import WindowManager
|
||||
from .connector.ui import icons
|
||||
import json
|
||||
|
||||
# Ensure dependencies
|
||||
from .installer import ensure_dependencies
|
||||
|
||||
ensure_dependencies(f"Blender {bpy.app.version[0]}.{bpy.app.version[1]}")
|
||||
|
||||
bl_info = {
|
||||
"name": "Speckle Blender ",
|
||||
"author": "Speckle Systems",
|
||||
"name": "Speckle Connector",
|
||||
"author": "Speckle",
|
||||
"version": (3, 999, 999),
|
||||
"blender": (4, 2, 0),
|
||||
"location": "3d viewport toolbar (N), under the Speckle tab.",
|
||||
"description": "The Speckle Connector using specklepy 3.x!",
|
||||
"warning": "This add-on is WIP and should be used with caution",
|
||||
"wiki_url": "https://github.com/specklesystems/speckle-blender",
|
||||
"description": "Publish models to and load models from other AEC apps.",
|
||||
"wiki_url": "https://speckle.systems/connectors/blender",
|
||||
"category": "Scene",
|
||||
}
|
||||
|
||||
|
||||
# UI
|
||||
from .connector.ui.main_panel import SPECKLE_PT_main_panel
|
||||
from .connector.ui.project_selection_dialog import SPECKLE_OT_project_selection_dialog, speckle_project, SPECKLE_UL_projects_list, speckle_workspace
|
||||
from .connector.ui.model_selection_dialog import SPECKLE_OT_model_selection_dialog, speckle_model, SPECKLE_UL_models_list
|
||||
from .connector.ui.version_selection_dialog import SPECKLE_OT_version_selection_dialog, speckle_version, SPECKLE_UL_versions_list
|
||||
from .connector.ui.update_panel import SPECKLE_PT_update_panel
|
||||
from .connector.ui.model_cards_panel import SPECKLE_PT_model_cards_panel
|
||||
from .connector.utils.account_manager import speckle_workspace
|
||||
from .connector.ui.project_selection_dialog import (
|
||||
SPECKLE_OT_project_selection_dialog,
|
||||
SPECKLE_UL_projects_list,
|
||||
)
|
||||
from .connector.ui.model_selection_dialog import (
|
||||
SPECKLE_OT_model_selection_dialog,
|
||||
SPECKLE_UL_models_list,
|
||||
)
|
||||
from .connector.ui.version_selection_dialog import (
|
||||
SPECKLE_OT_version_selection_dialog,
|
||||
SPECKLE_UL_versions_list,
|
||||
)
|
||||
from .connector.ui.selection_filter_dialog import SPECKLE_OT_selection_filter_dialog
|
||||
from .connector.ui.model_card import speckle_model_card
|
||||
from .connector.utils.property_groups import (
|
||||
speckle_project,
|
||||
speckle_model,
|
||||
speckle_version,
|
||||
speckle_object,
|
||||
speckle_collection,
|
||||
speckle_model_card,
|
||||
)
|
||||
|
||||
# Operators
|
||||
from .connector.blender_operators.publish_button import SPECKLE_OT_publish
|
||||
from .connector.blender_operators.load_button import SPECKLE_OT_load
|
||||
from .connector.blender_operators.model_card_settings import SPECKLE_OT_model_card_settings, SPECKLE_OT_view_in_browser, SPECKLE_OT_view_model_versions, SPECKLE_OT_delete_model_card
|
||||
from .connector.blender_operators.model_card_settings import (
|
||||
SPECKLE_OT_model_card_settings,
|
||||
SPECKLE_OT_view_in_browser,
|
||||
SPECKLE_OT_view_model_versions,
|
||||
SPECKLE_OT_delete_model_card,
|
||||
)
|
||||
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.load_latest_button import SPECKLE_OT_load_latest
|
||||
from .connector.blender_operators.add_project_by_url import SPECKLE_OT_add_project_by_url
|
||||
from .connector.utils.account_manager import speckle_account
|
||||
from .connector.blender_operators.add_account_button import (
|
||||
SPECKLE_OT_show_auth_error,
|
||||
SPECKLE_OT_dismiss_popup,
|
||||
)
|
||||
from .connector.blender_operators.model_card_load_button import (
|
||||
SPECKLE_OT_load_model_card,
|
||||
)
|
||||
from .connector.blender_operators.model_card_publish_button import (
|
||||
SPECKLE_OT_publish_model_card,
|
||||
)
|
||||
from .connector.blender_operators.add_project_by_url import (
|
||||
SPECKLE_OT_add_project_by_url,
|
||||
)
|
||||
|
||||
from .connector.blender_operators.create_project import SPECKLE_OT_create_project
|
||||
from .connector.blender_operators.create_model import SPECKLE_OT_create_model
|
||||
from .connector.blender_operators.version_check import SPECKLE_OT_version_check
|
||||
from .connector.blender_operators.update_button import SPECKLE_OT_update_button
|
||||
from .connector.utils.account_manager import (
|
||||
speckle_account,
|
||||
get_default_account_id,
|
||||
_client_cache,
|
||||
)
|
||||
|
||||
# States
|
||||
from .connector.states.speckle_state import register as register_speckle_state, unregister as unregister_speckle_state
|
||||
from .connector.states.speckle_state import (
|
||||
register as register_speckle_state,
|
||||
unregister as unregister_speckle_state,
|
||||
)
|
||||
|
||||
|
||||
from .connector.ui.workspace_selection_dialog import (
|
||||
SPECKLE_OT_workspace_selection_dialog,
|
||||
SPECKLE_UL_workspaces_list,
|
||||
)
|
||||
|
||||
# Utils
|
||||
from .connector.ui.account_selection_dialog import (
|
||||
SPECKLE_OT_account_selection_dialog,
|
||||
SPECKLE_UL_accounts_list,
|
||||
)
|
||||
|
||||
|
||||
def delayed_version_check():
|
||||
"""Timer function to check for updates after addon startup"""
|
||||
try:
|
||||
bpy.ops.speckle.version_check()
|
||||
except Exception as e:
|
||||
print(f"[Speckle] Failed to check for updates: {e}")
|
||||
|
||||
|
||||
def invoke_window_manager_properties():
|
||||
# Accounts
|
||||
WindowManager.speckle_accounts = bpy.props.CollectionProperty(
|
||||
type = speckle_account
|
||||
)
|
||||
WindowManager.speckle_accounts = bpy.props.CollectionProperty(type=speckle_account)
|
||||
WindowManager.selected_account_id = bpy.props.StringProperty()
|
||||
# Workspaces
|
||||
WindowManager.speckle_workspaces = bpy.props.CollectionProperty(
|
||||
type = speckle_workspace
|
||||
type=speckle_workspace
|
||||
)
|
||||
WindowManager.selected_workspace_id = bpy.props.StringProperty()
|
||||
WindowManager.selected_workspace = bpy.props.PointerProperty(type=speckle_workspace)
|
||||
WindowManager.can_create_project_in_workspace = bpy.props.BoolProperty()
|
||||
# Projects
|
||||
WindowManager.speckle_projects = bpy.props.CollectionProperty(
|
||||
type=speckle_project
|
||||
)
|
||||
WindowManager.speckle_projects = bpy.props.CollectionProperty(type=speckle_project)
|
||||
WindowManager.selected_project_id = bpy.props.StringProperty()
|
||||
WindowManager.selected_project_name = bpy.props.StringProperty()
|
||||
WindowManager.selected_project_name = bpy.props.StringProperty()
|
||||
# Models
|
||||
WindowManager.speckle_models = bpy.props.CollectionProperty(
|
||||
type=speckle_model
|
||||
)
|
||||
WindowManager.speckle_models = bpy.props.CollectionProperty(type=speckle_model)
|
||||
WindowManager.selected_model_id = bpy.props.StringProperty()
|
||||
WindowManager.selected_model_name = bpy.props.StringProperty()
|
||||
# Versions
|
||||
WindowManager.speckle_versions = bpy.props.CollectionProperty(
|
||||
type=speckle_version
|
||||
)
|
||||
WindowManager.speckle_versions = bpy.props.CollectionProperty(type=speckle_version)
|
||||
WindowManager.selected_version_id = bpy.props.StringProperty()
|
||||
WindowManager.selected_version_load_option = bpy.props.StringProperty()
|
||||
|
||||
def save_model_cards(scene):
|
||||
model_cards_data = [card.to_dict() for card in scene.speckle_state.model_cards]
|
||||
scene["speckle_model_cards_data"] = json.dumps(model_cards_data)
|
||||
|
||||
def load_model_cards(scene):
|
||||
if "speckle_model_cards_data" in scene:
|
||||
model_cards_data = json.loads(scene["speckle_model_cards_data"])
|
||||
scene.speckle_state.model_cards.clear()
|
||||
for card_data in model_cards_data:
|
||||
card = speckle_model_card.from_dict(card_data)
|
||||
scene.speckle_state.model_cards.add().update(card)
|
||||
# Send / Publish buttons
|
||||
WindowManager.ui_mode = bpy.props.EnumProperty( # type: ignore
|
||||
name="UI Mode",
|
||||
description="Publish or Load a model",
|
||||
items=[
|
||||
("PUBLISH", "Publish", "Publish a model to Speckle", "EXPORT", 0),
|
||||
("LOAD", "Load", "Load a model from Speckle", "IMPORT", 1),
|
||||
],
|
||||
default="PUBLISH",
|
||||
)
|
||||
# Objects
|
||||
WindowManager.speckle_objects = bpy.props.CollectionProperty(type=speckle_object)
|
||||
# Update checking
|
||||
WindowManager.update_available = bpy.props.BoolProperty(default=False)
|
||||
WindowManager.latest_version = bpy.props.StringProperty(default="")
|
||||
WindowManager.update_url = bpy.props.StringProperty(default="")
|
||||
|
||||
|
||||
# Classes to load
|
||||
classes = (
|
||||
SPECKLE_PT_update_panel,
|
||||
SPECKLE_PT_main_panel,
|
||||
SPECKLE_PT_model_cards_panel,
|
||||
SPECKLE_OT_publish,
|
||||
SPECKLE_OT_load,
|
||||
SPECKLE_OT_project_selection_dialog, speckle_project, SPECKLE_UL_projects_list, speckle_workspace,
|
||||
SPECKLE_OT_model_selection_dialog, speckle_model, SPECKLE_UL_models_list,
|
||||
SPECKLE_OT_version_selection_dialog, speckle_version, SPECKLE_UL_versions_list,
|
||||
SPECKLE_OT_project_selection_dialog,
|
||||
speckle_project,
|
||||
SPECKLE_UL_projects_list,
|
||||
speckle_workspace,
|
||||
SPECKLE_OT_model_selection_dialog,
|
||||
speckle_model,
|
||||
SPECKLE_UL_models_list,
|
||||
SPECKLE_OT_version_selection_dialog,
|
||||
speckle_version,
|
||||
SPECKLE_UL_versions_list,
|
||||
SPECKLE_OT_selection_filter_dialog,
|
||||
speckle_model_card, SPECKLE_OT_model_card_settings, SPECKLE_OT_view_in_browser, SPECKLE_OT_view_model_versions, SPECKLE_OT_delete_model_card,
|
||||
speckle_object,
|
||||
speckle_collection,
|
||||
speckle_model_card,
|
||||
SPECKLE_OT_model_card_settings,
|
||||
SPECKLE_OT_view_in_browser,
|
||||
SPECKLE_OT_view_model_versions,
|
||||
SPECKLE_OT_delete_model_card,
|
||||
SPECKLE_OT_select_objects,
|
||||
SPECKLE_OT_add_account,
|
||||
SPECKLE_OT_load_latest,
|
||||
SPECKLE_OT_show_auth_error,
|
||||
SPECKLE_OT_dismiss_popup,
|
||||
SPECKLE_OT_load_model_card,
|
||||
SPECKLE_OT_publish_model_card,
|
||||
SPECKLE_OT_add_project_by_url,
|
||||
speckle_account)
|
||||
SPECKLE_OT_create_project,
|
||||
SPECKLE_OT_create_model,
|
||||
SPECKLE_OT_version_check,
|
||||
SPECKLE_OT_update_button,
|
||||
speckle_account,
|
||||
SPECKLE_UL_workspaces_list,
|
||||
SPECKLE_OT_workspace_selection_dialog,
|
||||
SPECKLE_OT_account_selection_dialog,
|
||||
SPECKLE_UL_accounts_list,
|
||||
)
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def load_handler(dummy):
|
||||
load_model_cards(bpy.context.scene)
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def save_handler(dummy):
|
||||
save_model_cards(bpy.context.scene)
|
||||
|
||||
# Register and Unregister
|
||||
def register():
|
||||
@@ -125,21 +211,44 @@ def register():
|
||||
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
register_speckle_state() # Register SpeckleState
|
||||
|
||||
bpy.app.handlers.load_post.append(load_handler)
|
||||
bpy.app.handlers.save_post.append(save_handler)
|
||||
register_speckle_state() # Register SpeckleState
|
||||
|
||||
invoke_window_manager_properties()
|
||||
|
||||
# Pre-warm client cache for default account
|
||||
try:
|
||||
default_account_id = get_default_account_id()
|
||||
if default_account_id:
|
||||
print(
|
||||
f"[Speckle] Pre-warming client for default account: {default_account_id}"
|
||||
)
|
||||
_client_cache.get_client(default_account_id)
|
||||
print(
|
||||
f"[Speckle] Client pre-warming complete for account: {default_account_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Speckle] Failed to pre-warm client: {e}")
|
||||
|
||||
# Use a timer to delay the version check
|
||||
bpy.app.timers.register(delayed_version_check, first_interval=2.0)
|
||||
|
||||
|
||||
def unregister():
|
||||
# Clear any pending timers to prevent duplicate calls
|
||||
if bpy.app.timers.is_registered(delayed_version_check):
|
||||
bpy.app.timers.unregister(delayed_version_check)
|
||||
|
||||
# Clean up authentication server
|
||||
from .connector.blender_operators.add_account_button import cleanup_auth_server
|
||||
|
||||
cleanup_auth_server()
|
||||
|
||||
icons.unload_icons()
|
||||
unregister_speckle_state() # Unregister SpeckleState
|
||||
unregister_speckle_state() # Unregister SpeckleState
|
||||
_client_cache.clear()
|
||||
for cls in classes:
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
bpy.app.handlers.load_post.remove(load_handler)
|
||||
bpy.app.handlers.save_post.remove(save_handler)
|
||||
|
||||
# Run the register function when the script is executed
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,14 +4,14 @@ schema_version = "1.0.0"
|
||||
# Change the values according to your extension
|
||||
id = "speckle_blender_addon"
|
||||
version = "3.0.0"
|
||||
name = "Speckle for Blender BETA"
|
||||
tagline = "Speckle connector for Blender"
|
||||
maintainer = "AEC SYSTEMS LTD"
|
||||
name = "Speckle Connector"
|
||||
tagline = "Load models from other AEC apps into Blender with Speckle."
|
||||
maintainer = "Speckle"
|
||||
# Supported types: "add-on", "theme"
|
||||
type = "add-on"
|
||||
|
||||
# Optional link to documentation, support, source files, etc
|
||||
website = "https://speckle.guide/user/blender.html"
|
||||
website = "https://speckle.systems/connectors/blender"
|
||||
|
||||
# Optional list defined by Blender and server, see:
|
||||
# https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html
|
||||
@@ -24,13 +24,9 @@ blender_version_min = "4.2.0"
|
||||
|
||||
# License conforming to https://spdx.org/licenses/ (use "SPDX: prefix)
|
||||
# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html
|
||||
license = [
|
||||
"SPDX:Apache-2.0",
|
||||
]
|
||||
license = ["SPDX:Apache-2.0"]
|
||||
# Optional: required by some licenses.
|
||||
copyright = [
|
||||
"2022-2025 AEC SYSTEMS LTD",
|
||||
]
|
||||
copyright = ["2022-2025 AEC SYSTEMS LTD"]
|
||||
|
||||
# Optional list of supported platforms. If omitted, the extension will be available in all operating systems.
|
||||
# platforms = ["windows-x64", "macos-arm64", "linux-x64"]
|
||||
@@ -67,8 +63,4 @@ clipboard = "Copy and paste URLs and Names (UI)"
|
||||
# Optional: build settings.
|
||||
# https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build
|
||||
[build]
|
||||
paths_exclude_pattern = [
|
||||
"__pycache__/",
|
||||
"/.vscode",
|
||||
"*.code-workspace",
|
||||
]
|
||||
paths_exclude_pattern = ["__pycache__/", "/.vscode", "*.code-workspace"]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from ..blender_operators.load_button import SPECKLE_OT_load # noqa: F401
|
||||
from ..blender_operators.load_latest_button import SPECKLE_OT_load_latest # noqa: F401
|
||||
from ..blender_operators.publish_button import SPECKLE_OT_publish # noqa: F401
|
||||
from ..blender_operators.load_button import SPECKLE_OT_load # noqa: F401
|
||||
from .model_card_load_button import SPECKLE_OT_load_model_card # noqa: F401
|
||||
from ..blender_operators.publish_button import SPECKLE_OT_publish # noqa: F401
|
||||
from ..blender_operators.model_card_settings import (
|
||||
SPECKLE_OT_model_card_settings, #noqa: F401
|
||||
SPECKLE_OT_view_in_browser, #noqa: F401
|
||||
SPECKLE_OT_view_model_versions, #noqa: F401
|
||||
SPECKLE_OT_delete_model_card #noqa: F401
|
||||
)
|
||||
SPECKLE_OT_model_card_settings, # noqa: F401
|
||||
SPECKLE_OT_view_in_browser, # noqa: F401
|
||||
SPECKLE_OT_view_model_versions, # noqa: F401
|
||||
SPECKLE_OT_delete_model_card, # noqa: F401
|
||||
)
|
||||
|
||||
@@ -1,38 +1,259 @@
|
||||
import bpy
|
||||
import webbrowser
|
||||
import textwrap
|
||||
from bpy.types import Event, Context
|
||||
from typing import Optional
|
||||
from ..utils.authentication import (
|
||||
AuthenticationServer,
|
||||
SPECKLE_AUTH_PORT,
|
||||
)
|
||||
|
||||
|
||||
# Global auth server instance
|
||||
_auth_server = None
|
||||
|
||||
|
||||
class SPECKLE_OT_add_account(bpy.types.Operator):
|
||||
"""Operator for adding a new Speckle account.
|
||||
"""
|
||||
"""Operator for adding a new Speckle account."""
|
||||
|
||||
bl_idname = "speckle.add_account"
|
||||
bl_label = "Add New Account"
|
||||
bl_description = "Add a new account"
|
||||
|
||||
|
||||
server_url: bpy.props.StringProperty( # type: ignore
|
||||
name="Server URL",
|
||||
description="Speckle server URL to connect to",
|
||||
default="https://app.speckle.systems"
|
||||
default="https://app.speckle.systems",
|
||||
)
|
||||
|
||||
|
||||
_timer = None
|
||||
_timeout_counter = 0
|
||||
_max_timeout = 300 # 5 minutes in seconds (300 checks at ~1 sec intervals)
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
# Server URL textbox
|
||||
layout.prop(self, "server_url", text="Server URL")
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Logic to handle sign in
|
||||
api_url = "http://localhost:29364"
|
||||
url = f"{api_url}/auth/add-account?serverUrl={self.server_url}"
|
||||
webbrowser.open(url)
|
||||
self.report({'INFO'}, f"Adding account from {self.server_url}: {url}")
|
||||
|
||||
# Force redraw
|
||||
context.window.screen = context.window.screen
|
||||
context.area.tag_redraw()
|
||||
|
||||
|
||||
return {'FINISHED'}
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
print(f"[Add Account] Starting authentication for server: {self.server_url}")
|
||||
cleanup_auth_server()
|
||||
|
||||
# Try to start own auth server first - it will fail gracefully if port is in use
|
||||
global _auth_server
|
||||
_auth_server = AuthenticationServer(port=SPECKLE_AUTH_PORT)
|
||||
|
||||
if _auth_server.start():
|
||||
return self._initiate_own_server_flow(context)
|
||||
|
||||
# Server failed to start - port is in use
|
||||
_auth_server = None
|
||||
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} is already in use")
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Port {SPECKLE_AUTH_PORT} is already in use. Please close any application using it and try again.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _initiate_own_server_flow(self, context: Context) -> set[str]:
|
||||
"""Start auth flow with our own server."""
|
||||
try:
|
||||
_auth_server.open_auth_url(self.server_url)
|
||||
self._start_modal_timer(context)
|
||||
return {"RUNNING_MODAL"}
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Failed to open browser: {e}")
|
||||
self.report({"ERROR"}, f"Failed to open browser: {e}")
|
||||
cleanup_auth_server()
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _start_modal_timer(self, context: Context):
|
||||
"""Start modal timer for auth polling."""
|
||||
self._timeout_counter = 0
|
||||
wm = context.window_manager
|
||||
self._timer = wm.event_timer_add(1.0, window=context.window)
|
||||
wm.modal_handler_add(self)
|
||||
|
||||
def modal(self, context: Context, event: Event) -> set[str]:
|
||||
global _auth_server
|
||||
|
||||
if event.type != "TIMER":
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Check for timeout
|
||||
self._timeout_counter += 1
|
||||
if self._timeout_counter >= self._max_timeout:
|
||||
print("[Add Account] Authentication timed out after 5 minutes")
|
||||
self._cleanup(context)
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
"Authentication timed out after 5 minutes. Please try again.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Check for no active auth server
|
||||
if not _auth_server:
|
||||
print("[Add Account] No active auth server, cancelling")
|
||||
self._cleanup(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Check auth server completion
|
||||
if _auth_server.is_complete():
|
||||
return self._finish_auth(
|
||||
context,
|
||||
_auth_server.is_successful(),
|
||||
_auth_server.get_error_message(),
|
||||
"Auth server",
|
||||
)
|
||||
|
||||
# Still waiting
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _finish_auth(
|
||||
self,
|
||||
context: Context,
|
||||
is_successful: bool,
|
||||
error_msg: Optional[str],
|
||||
auth_type: str,
|
||||
) -> set[str]:
|
||||
"""Complete authentication and cleanup."""
|
||||
print(
|
||||
f"[Add Account] {auth_type} authentication complete. Success: {is_successful}"
|
||||
)
|
||||
self._cleanup(context)
|
||||
return self._handle_auth_complete(context, is_successful, error_msg)
|
||||
|
||||
def _handle_auth_complete(
|
||||
self, context: Context, is_successful: bool, error_msg: Optional[str]
|
||||
) -> set[str]:
|
||||
"""Handle authentication completion and update UI state."""
|
||||
if is_successful:
|
||||
print("[Add Account] Account added successfully - refreshing UI")
|
||||
|
||||
# Import account management functions
|
||||
from ..utils.account_manager import get_account_enum_items, _client_cache
|
||||
from ..ui.account_selection_dialog import (
|
||||
update_workspaces_list,
|
||||
update_projects_list,
|
||||
)
|
||||
|
||||
# Get the newly added account (most recent one)
|
||||
accounts = get_account_enum_items()
|
||||
if accounts and accounts[0][0] != "NO_ACCOUNTS":
|
||||
new_account_id = accounts[-1][0] # Last account added
|
||||
|
||||
# Set as selected account
|
||||
context.window_manager.selected_account_id = new_account_id
|
||||
|
||||
# Clear client cache to force re-authentication
|
||||
_client_cache.clear()
|
||||
|
||||
# Refresh UI state
|
||||
try:
|
||||
update_workspaces_list(context)
|
||||
update_projects_list(context)
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Error refreshing UI state: {e}")
|
||||
|
||||
self.report({"INFO"}, "Account added successfully and is now active!")
|
||||
else:
|
||||
self.report({"INFO"}, "Account added successfully!")
|
||||
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
error_details = error_msg if error_msg else "Unknown error"
|
||||
print(f"[Add Account] Authentication failed: {error_details}")
|
||||
self.report({"ERROR"}, f"Authentication failed: {error_details}")
|
||||
|
||||
# Show persistent error popup with details
|
||||
# Store error in window manager for the popup operator
|
||||
context.window_manager["speckle_auth_error"] = error_details
|
||||
bpy.ops.speckle.show_auth_error("INVOKE_DEFAULT")
|
||||
|
||||
return {"CANCELLED"}
|
||||
|
||||
def _cleanup(self, context: Context):
|
||||
# Remove timer
|
||||
if self._timer is not None:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
|
||||
# Shutdown auth server/authenticator
|
||||
cleanup_auth_server()
|
||||
|
||||
|
||||
def cleanup_auth_server():
|
||||
"""Shutdown auth server on addon unload."""
|
||||
global _auth_server
|
||||
|
||||
if _auth_server is not None:
|
||||
try:
|
||||
_auth_server.shutdown()
|
||||
except Exception as e:
|
||||
print(f"[Add Account] Failed to cleanup auth server: {e}")
|
||||
print(f"[Add Account] Port {SPECKLE_AUTH_PORT} may still be occupied")
|
||||
_auth_server = None
|
||||
|
||||
|
||||
class SPECKLE_OT_show_auth_error(bpy.types.Operator):
|
||||
"""Show persistent error dialog for authentication failures."""
|
||||
|
||||
bl_idname = "speckle.show_auth_error"
|
||||
bl_label = "Authentication Error"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Clean up the temporary error message
|
||||
if "speckle_auth_error" in context.window_manager:
|
||||
del context.window_manager["speckle_auth_error"]
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_popup(self, width=450)
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
|
||||
# Error header
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="", icon="ERROR")
|
||||
row.label(text="Authentication Failed", icon="NONE")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Error details
|
||||
error_details = context.window_manager.get(
|
||||
"speckle_auth_error", "Unknown error"
|
||||
)
|
||||
col = layout.column(align=True)
|
||||
|
||||
# Wrap long error messages
|
||||
wrapper = textwrap.TextWrapper(width=60)
|
||||
for line in error_details.split("\n"):
|
||||
if line:
|
||||
for wrapped_line in wrapper.wrap(line):
|
||||
col.label(text=wrapped_line)
|
||||
else:
|
||||
col.label(text="")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Close button
|
||||
layout.operator("speckle.dismiss_popup", text="Close", icon="X")
|
||||
|
||||
|
||||
class SPECKLE_OT_dismiss_popup(bpy.types.Operator):
|
||||
"""Dismiss popup dialog."""
|
||||
|
||||
bl_idname = "speckle.dismiss_popup"
|
||||
bl_label = "Dismiss"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
# Clean up any temporary data
|
||||
if "speckle_auth_error" in context.window_manager:
|
||||
del context.window_manager["speckle_auth_error"]
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import bpy
|
||||
from bpy.types import Context, Event, UILayout, WindowManager
|
||||
from bpy.types import Context, Event, UILayout
|
||||
from ..utils.account_manager import (
|
||||
get_model_details_by_wrapper,
|
||||
get_project_from_url,
|
||||
@@ -68,38 +68,6 @@ class SPECKLE_OT_add_project_by_url(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
# Ensure all required properties exist in WindowManager
|
||||
if not hasattr(WindowManager, "selected_account_id"):
|
||||
WindowManager.selected_account_id = bpy.props.StringProperty()
|
||||
|
||||
if not hasattr(WindowManager, "selected_project_id"):
|
||||
WindowManager.selected_project_id = bpy.props.StringProperty(
|
||||
name="Selected Project ID"
|
||||
)
|
||||
if not hasattr(WindowManager, "selected_project_name"):
|
||||
WindowManager.selected_project_name = bpy.props.StringProperty(
|
||||
name="Selected Project Name"
|
||||
)
|
||||
|
||||
if not hasattr(WindowManager, "selected_model_id"):
|
||||
WindowManager.selected_model_id = bpy.props.StringProperty(
|
||||
name="Selected Model ID"
|
||||
)
|
||||
if not hasattr(WindowManager, "selected_model_name"):
|
||||
WindowManager.selected_model_name = bpy.props.StringProperty(
|
||||
name="Selected Model Name"
|
||||
)
|
||||
|
||||
if not hasattr(WindowManager, "selected_version_id"):
|
||||
WindowManager.selected_version_id = bpy.props.StringProperty(
|
||||
name="Selected Version ID"
|
||||
)
|
||||
|
||||
if not hasattr(WindowManager, "selected_version_load_option"):
|
||||
WindowManager.selected_version_load_option = bpy.props.StringProperty(
|
||||
name="Selected Version Load Option"
|
||||
)
|
||||
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import bpy
|
||||
from bpy.types import Context, Event, UILayout
|
||||
from specklepy.core.api.inputs import CreateModelInput
|
||||
from typing import Tuple
|
||||
|
||||
from ..utils.account_manager import _client_cache, can_create_model
|
||||
|
||||
|
||||
class SPECKLE_OT_create_model(bpy.types.Operator):
|
||||
bl_idname = "speckle.create_model"
|
||||
bl_label = "Create Model"
|
||||
bl_description = "Create a new Speckle model"
|
||||
|
||||
_can_create: bool = True
|
||||
|
||||
model_name: bpy.props.StringProperty(name="Model Name") # type: ignore
|
||||
|
||||
@classmethod
|
||||
def description(cls, context: Context, properties) -> str:
|
||||
if not cls._can_create:
|
||||
return "Workspace limits have been reached"
|
||||
return "Create a new Speckle model"
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
wm = context.window_manager
|
||||
|
||||
authorized, auth_message = can_create_model(
|
||||
wm.selected_account_id, wm.selected_project_id
|
||||
)
|
||||
if not authorized:
|
||||
self.report({"ERROR"}, auth_message)
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not self.model_name.strip():
|
||||
self.report({"ERROR"}, "Model name cannot be empty")
|
||||
return {"CANCELLED"}
|
||||
|
||||
try:
|
||||
model_id, model_name = create_model(
|
||||
wm.selected_account_id, wm.selected_project_id, self.model_name
|
||||
)
|
||||
wm.selected_model_id = model_id
|
||||
wm.selected_model_name = model_name
|
||||
self.report({"INFO"}, f"Created model: {model_name} -> ID: {model_id}")
|
||||
# Force redraw
|
||||
context.window.screen = context.window.screen
|
||||
context.area.tag_redraw()
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to create model: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
layout.prop(self, "model_name")
|
||||
|
||||
|
||||
def register() -> None:
|
||||
bpy.utils.register_class(SPECKLE_OT_create_model)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
bpy.utils.unregister_class(SPECKLE_OT_create_model)
|
||||
|
||||
|
||||
def create_model(account_id: str, project_id: str, model_name: str) -> Tuple[str, str]:
|
||||
try:
|
||||
# Get cached client
|
||||
client = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
raise ValueError(f"Could not get client for account: {account_id}")
|
||||
|
||||
model = client.model.create(
|
||||
input=CreateModelInput(
|
||||
name=model_name, description="", project_id=project_id
|
||||
)
|
||||
)
|
||||
return (model.id, model.name)
|
||||
except Exception as e:
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
raise e
|
||||
@@ -0,0 +1,75 @@
|
||||
import bpy
|
||||
from bpy.types import Context, Event, UILayout
|
||||
|
||||
from specklepy.core.api.inputs.project_inputs import WorkspaceProjectCreateInput
|
||||
from specklepy.core.api.enums import ProjectVisibility
|
||||
from typing import Tuple
|
||||
|
||||
from ..utils.account_manager import _client_cache
|
||||
|
||||
|
||||
class SPECKLE_OT_create_project(bpy.types.Operator):
|
||||
"""
|
||||
operator for adding a Speckle project by URL
|
||||
"""
|
||||
|
||||
bl_idname = "speckle.create_project"
|
||||
bl_label = "Create Project"
|
||||
bl_description = "Create a new Speckle project"
|
||||
|
||||
project_name: bpy.props.StringProperty(name="Project Name") # type: ignore
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
wm = context.window_manager
|
||||
project_id, project_name = create_project(
|
||||
wm.selected_account_id,
|
||||
self.project_name,
|
||||
wm.selected_workspace.id,
|
||||
)
|
||||
wm.selected_project_id = project_id
|
||||
wm.selected_project_name = project_name
|
||||
self.report({"INFO"}, f"Created project: {project_name} -> ID: {project_id}")
|
||||
# Force redraw
|
||||
context.window.screen = context.window.screen
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
layout.prop(self, "project_name")
|
||||
|
||||
|
||||
def register() -> None:
|
||||
bpy.utils.register_class(SPECKLE_OT_create_project)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
bpy.utils.unregister_class(SPECKLE_OT_create_project)
|
||||
|
||||
|
||||
def create_project(
|
||||
account_id: str, project_name: str, workspace_id: str
|
||||
) -> Tuple[str, str]:
|
||||
try:
|
||||
# Get cached client
|
||||
client = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
raise Exception(f"Could not get client for account: {account_id}")
|
||||
project = client.project.create_in_workspace(
|
||||
input=WorkspaceProjectCreateInput(
|
||||
name=project_name,
|
||||
description="",
|
||||
visibility=ProjectVisibility("PUBLIC"),
|
||||
workspaceId=workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
return (project.id, project.name)
|
||||
except Exception as e:
|
||||
print(f"Failed to create project: {str(e)}")
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
raise
|
||||
@@ -1,21 +1,58 @@
|
||||
import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Context
|
||||
from bpy.types import Context, Event
|
||||
from ..operations.load_operation import load_operation
|
||||
from ..utils.account_manager import get_server_url_by_account_id
|
||||
from ..utils.model_card_utils import (
|
||||
update_model_card_objects,
|
||||
delete_model_card_objects,
|
||||
model_card_exists,
|
||||
)
|
||||
|
||||
|
||||
class SPECKLE_OT_load(bpy.types.Operator):
|
||||
bl_idname = "speckle.load"
|
||||
bl_label = "Load from Speckle"
|
||||
bl_description = "Load objects from Speckle"
|
||||
bl_label = "Load model"
|
||||
bl_description = "Load selection from Speckle"
|
||||
|
||||
def invoke(self, context: Context, event: bpy.types.Event) -> Set[str]:
|
||||
return self.execute(context)
|
||||
instance_loading_mode: bpy.props.EnumProperty( # type: ignore
|
||||
name="Instance Loading",
|
||||
description="Choose how to load instances",
|
||||
items=[
|
||||
(
|
||||
"INSTANCE_PROXIES",
|
||||
"Collection Instances",
|
||||
"Load objects as collection instances",
|
||||
),
|
||||
(
|
||||
"LINKED_DUPLICATES",
|
||||
"Linked Duplicates",
|
||||
"Get objects as linked duplicates",
|
||||
),
|
||||
],
|
||||
default="INSTANCE_PROXIES",
|
||||
)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
row = layout.row()
|
||||
row.label(text="Instance Loading:")
|
||||
row.prop(self, "instance_loading_mode", text="")
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
wm = context.window_manager
|
||||
model_card = context.scene.speckle_state.model_cards.add()
|
||||
if model_card_exists(
|
||||
wm.selected_project_id, wm.selected_model_id, False, context
|
||||
):
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
f"{wm.ui_mode}-{wm.selected_project_id}-{wm.selected_model_id}"
|
||||
)
|
||||
delete_model_card_objects(model_card, context)
|
||||
else:
|
||||
model_card = context.scene.speckle_state.model_cards.add()
|
||||
model_card.account_id = wm.selected_account_id
|
||||
model_card.server_url = get_server_url_by_account_id(wm.selected_account_id)
|
||||
model_card.project_id = wm.selected_project_id
|
||||
@@ -25,10 +62,10 @@ class SPECKLE_OT_load(bpy.types.Operator):
|
||||
model_card.is_publish = False
|
||||
model_card.load_option = wm.selected_version_load_option
|
||||
model_card.version_id = wm.selected_version_id
|
||||
model_card.collection_name = f"{wm.selected_model_name} - {wm.selected_version_id[:8]}"
|
||||
model_card.instance_loading_mode = self.instance_loading_mode
|
||||
|
||||
# Load selected model version
|
||||
load_operation(context)
|
||||
converted_objects = load_operation(context, self.instance_loading_mode)
|
||||
update_model_card_objects(model_card, converted_objects)
|
||||
|
||||
# Clear selected model details from Window Manager
|
||||
wm.selected_account_id = ""
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Context
|
||||
from ..utils.version_manager import get_latest_version
|
||||
from ..operations.load_operation import load_operation
|
||||
|
||||
|
||||
class SPECKLE_OT_load_latest(bpy.types.Operator):
|
||||
bl_idname = "speckle.load_latest"
|
||||
bl_label = "Load Latest from Speckle"
|
||||
bl_description = "Load the latest version from Speckle"
|
||||
|
||||
model_card_id: bpy.props.StringProperty(name="Model Card ID", default="") # type: ignore
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
wm = context.window_manager
|
||||
|
||||
# Get the model card
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(self.model_card_id)
|
||||
|
||||
# Check if load_option is set to "LATEST"
|
||||
if model_card.load_option != "LATEST":
|
||||
# Do nothing if load_option is not "LATEST"
|
||||
return {"FINISHED"}
|
||||
|
||||
# Get the latest version from Speckle
|
||||
latest_version_id, message, timestamp = get_latest_version(
|
||||
model_card.account_id,
|
||||
model_card.project_id,
|
||||
model_card.model_id
|
||||
)
|
||||
# Throw error if latest version is not found
|
||||
if not latest_version_id:
|
||||
self.report({"ERROR"}, "Failed to get latest version")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Check if the collection exists and delete it if it does
|
||||
collection = bpy.data.collections.get(model_card.collection_name)
|
||||
|
||||
# Update the model card with the latest version ID
|
||||
original_version_id = model_card.version_id
|
||||
if latest_version_id == original_version_id:
|
||||
self.report({"INFO"}, "Latest version is already loaded")
|
||||
return {"FINISHED"}
|
||||
|
||||
if collection:
|
||||
# Remove the collection
|
||||
bpy.data.collections.remove(collection)
|
||||
self.report({"INFO"}, f"Deleted existing collection: {model_card.collection_name}")
|
||||
# overwrite version id of the model card stored in the doc
|
||||
model_card.version_id = latest_version_id
|
||||
|
||||
# overwrite version id store in wm
|
||||
# Set Window Manager properties
|
||||
wm.selected_account_id = model_card.account_id
|
||||
wm.selected_project_id = model_card.project_id
|
||||
wm.selected_model_name = model_card.model_name
|
||||
wm.selected_version_id = latest_version_id
|
||||
|
||||
# Load the latest version
|
||||
try:
|
||||
load_operation(context)
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Loaded latest version: {latest_version_id[:8]} (was: {original_version_id[:8]})"
|
||||
)
|
||||
# update collection name in model card
|
||||
model_card.collection_name = f"{model_card.model_name} - {latest_version_id[:8]}"
|
||||
except Exception as e:
|
||||
# Restore the original version ID if loading fails
|
||||
model_card.version_id = original_version_id
|
||||
self.report({"ERROR"}, f"Failed to load latest version: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Clear selected model details from Window Manager
|
||||
wm.selected_account_id = ""
|
||||
wm.selected_project_id = ""
|
||||
wm.selected_version_id = ""
|
||||
wm.selected_model_name = ""
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,81 @@
|
||||
import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Context
|
||||
from ..utils.version_manager import get_latest_version
|
||||
from ..operations.load_operation import load_operation
|
||||
from ..utils.model_card_utils import (
|
||||
delete_model_card_objects,
|
||||
update_model_card_objects,
|
||||
collect_objects_with_properties,
|
||||
)
|
||||
|
||||
|
||||
class SPECKLE_OT_load_model_card(bpy.types.Operator):
|
||||
bl_idname = "speckle.model_card_load"
|
||||
bl_label = "Load Latest from Speckle"
|
||||
bl_description = "Depending on the load option, loads the latest or a specific version from Speckle"
|
||||
|
||||
model_card_id: bpy.props.StringProperty(name="Model Card ID", default="") # type: ignore
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
wm = context.window_manager
|
||||
|
||||
# Get the model card
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
if model_card is None:
|
||||
self.report({"ERROR"}, "Model card not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
old_properties = collect_objects_with_properties(model_card)
|
||||
delete_model_card_objects(model_card, context)
|
||||
|
||||
# set wm
|
||||
wm.selected_account_id = model_card.account_id
|
||||
wm.selected_project_id = model_card.project_id
|
||||
wm.selected_model_name = model_card.model_name
|
||||
|
||||
# if load option is set to "LATEST"
|
||||
if model_card.load_option == "LATEST":
|
||||
# get latest version from speckle
|
||||
latest_version_id, message, timestamp = get_latest_version(
|
||||
model_card.account_id, model_card.project_id, model_card.model_id
|
||||
)
|
||||
# set version id in wm
|
||||
wm.selected_version_id = latest_version_id
|
||||
|
||||
# load latest version
|
||||
converted_objects = load_operation(
|
||||
context, model_card.instance_loading_mode
|
||||
)
|
||||
# update model card details
|
||||
update_model_card_objects(model_card, converted_objects, old_properties)
|
||||
model_card.version_id = latest_version_id
|
||||
|
||||
else:
|
||||
# set version id in wm
|
||||
wm.selected_version_id = model_card.version_id
|
||||
|
||||
# load version id
|
||||
converted_objects = load_operation(
|
||||
context, model_card.instance_loading_mode
|
||||
)
|
||||
if not converted_objects:
|
||||
self.report({"ERROR"}, "Load operation failed")
|
||||
return {"CANCELLED"}
|
||||
# update model card details
|
||||
update_model_card_objects(model_card, converted_objects, old_properties)
|
||||
|
||||
# Clear selected model details from Window Manager
|
||||
wm.selected_account_id = ""
|
||||
wm.selected_project_id = ""
|
||||
wm.selected_version_id = ""
|
||||
wm.selected_model_name = ""
|
||||
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"{len(converted_objects)} objects loaded from Speckle. Model: {model_card.model_name}, Version: {model_card.version_id}",
|
||||
)
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,81 @@
|
||||
import bpy
|
||||
from typing import Set
|
||||
from bpy.types import Context, Event
|
||||
from ..operations.publish_operation import publish_operation
|
||||
from ..utils.account_manager import can_create_version
|
||||
|
||||
|
||||
class SPECKLE_OT_publish_model_card(bpy.types.Operator):
|
||||
bl_idname = "speckle.model_card_publish"
|
||||
bl_label = "Publish model"
|
||||
bl_description = "Publish tracked objects to Speckle"
|
||||
|
||||
model_card_id: bpy.props.StringProperty(name="Model Card ID", default="") # type: ignore
|
||||
version_message: bpy.props.StringProperty(name="Version Message", default="") # type: ignore
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.prop(self, "version_message")
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
wm = context.window_manager
|
||||
|
||||
# Get the model card
|
||||
model_card = context.scene.speckle_state.get_model_card_by_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
|
||||
wm.selected_account_id = model_card.account_id
|
||||
wm.selected_project_id = model_card.project_id
|
||||
wm.selected_model_id = model_card.model_id
|
||||
|
||||
# get model card objects
|
||||
objects_to_convert = []
|
||||
for speckle_obj in model_card.objects:
|
||||
blender_obj = bpy.data.objects.get(speckle_obj.name)
|
||||
if blender_obj:
|
||||
objects_to_convert.append(blender_obj)
|
||||
else:
|
||||
self.report(
|
||||
{"WARNING"}, f"Object '{speckle_obj.name}' not found, skipping"
|
||||
)
|
||||
|
||||
if not objects_to_convert:
|
||||
self.report({"ERROR"}, "No objects to publish")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# publish to speckle
|
||||
success, message, version_id = publish_operation(
|
||||
context,
|
||||
objects_to_convert,
|
||||
self.version_message,
|
||||
model_card.apply_modifiers,
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.report({"ERROR"}, message)
|
||||
return {"CANCELLED"}
|
||||
|
||||
model_card.version_id = version_id
|
||||
model_card.is_publish = True
|
||||
|
||||
# Clear selected model details from Window Manager
|
||||
wm.selected_account_id = ""
|
||||
wm.selected_project_id = ""
|
||||
wm.selected_model_id = ""
|
||||
|
||||
self.report({"INFO"}, message)
|
||||
|
||||
return {"FINISHED"}
|
||||
@@ -11,7 +11,7 @@ class SPECKLE_OT_model_card_settings(bpy.types.Operator):
|
||||
|
||||
bl_idname = "speckle.model_card_settings"
|
||||
bl_label = "Model Card Settings"
|
||||
bl_description = "Settings for the model card"
|
||||
bl_description = "More options for the model card"
|
||||
model_card_id: bpy.props.StringProperty(name="Model Card ID", default="") # type:ignore
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
|
||||
@@ -3,18 +3,120 @@ from bpy.types import Context
|
||||
from bpy.types import Event
|
||||
from typing import Set
|
||||
|
||||
from ..operations.publish_operation import publish_operation
|
||||
from ..utils.account_manager import get_server_url_by_account_id, can_create_version
|
||||
from ..utils.model_card_utils import model_card_exists, update_model_card_objects
|
||||
|
||||
|
||||
class SPECKLE_OT_publish(bpy.types.Operator):
|
||||
bl_idname = "speckle.publish"
|
||||
|
||||
bl_label = "Publish to Speckle"
|
||||
bl_description = "Publish selected objects to Speckle"
|
||||
|
||||
version_message: bpy.props.StringProperty(name="Version Message") # type: ignore
|
||||
apply_modifiers: bpy.props.BoolProperty( # type: ignore
|
||||
name="Apply Modifiers",
|
||||
description="Apply all modifiers to objects before conversion",
|
||||
default=True,
|
||||
)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.prop(self, "version_message")
|
||||
layout.prop(self, "apply_modifiers")
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> Set[str]:
|
||||
return self.execute(context)
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context: Context) -> Set[str]:
|
||||
context.scene.speckle_state.ui_mode = "PUBLISH"
|
||||
wm = context.window_manager
|
||||
|
||||
bpy.ops.speckle.project_selection_dialog("INVOKE_DEFAULT")
|
||||
# check if we have stored objects from selection dialog
|
||||
if not wm.speckle_objects:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
"No objects selected to publish. Please use 'Select Objects' first.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
account_id = getattr(wm, "selected_account_id", "")
|
||||
project_id = getattr(wm, "selected_project_id", "")
|
||||
model_id = getattr(wm, "selected_model_id", "")
|
||||
|
||||
if not account_id:
|
||||
self.report({"ERROR"}, "No account selected")
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not project_id:
|
||||
self.report({"ERROR"}, "No project selected")
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not model_id:
|
||||
self.report({"ERROR"}, "No model selected")
|
||||
return {"CANCELLED"}
|
||||
|
||||
authorized, auth_message = can_create_version(account_id, project_id, model_id)
|
||||
if not authorized:
|
||||
self.report({"ERROR"}, auth_message)
|
||||
return {"CANCELLED"}
|
||||
|
||||
objects_to_convert = []
|
||||
for speckle_obj in wm.speckle_objects:
|
||||
blender_obj = bpy.data.objects.get(speckle_obj.name)
|
||||
if blender_obj:
|
||||
objects_to_convert.append(blender_obj)
|
||||
else:
|
||||
self.report(
|
||||
{"WARNING"}, f"Object '{speckle_obj.name}' not found, skipping"
|
||||
)
|
||||
|
||||
if not objects_to_convert:
|
||||
self.report({"ERROR"}, "None of the selected objects could be found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
success, message, version_id = publish_operation(
|
||||
context, objects_to_convert, self.version_message, self.apply_modifiers
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.report({"ERROR"}, message)
|
||||
return {"CANCELLED"}
|
||||
|
||||
# create model card if operation was successful
|
||||
if hasattr(context.scene, "speckle_state") and hasattr(
|
||||
context.scene.speckle_state, "model_cards"
|
||||
):
|
||||
if model_card_exists(
|
||||
wm.selected_project_id, wm.selected_model_id, True, context
|
||||
):
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
f"{wm.ui_mode}-{wm.selected_project_id}-{wm.selected_model_id}"
|
||||
)
|
||||
else:
|
||||
model_card = context.scene.speckle_state.model_cards.add()
|
||||
|
||||
model_card.account_id = account_id
|
||||
model_card.server_url = get_server_url_by_account_id(account_id)
|
||||
model_card.project_id = project_id
|
||||
model_card.project_name = getattr(wm, "selected_project_name", "")
|
||||
model_card.model_id = model_id
|
||||
model_card.model_name = getattr(wm, "selected_model_name", "")
|
||||
model_card.is_publish = True
|
||||
model_card.load_option = "SPECIFIC" # published versions are specific
|
||||
model_card.version_id = version_id
|
||||
model_card.apply_modifiers = self.apply_modifiers
|
||||
update_model_card_objects(model_card, objects_to_convert)
|
||||
|
||||
# clear selected model details from Window Manager
|
||||
wm.selected_account_id = ""
|
||||
wm.selected_project_id = ""
|
||||
wm.selected_project_name = ""
|
||||
wm.selected_model_id = ""
|
||||
wm.selected_model_name = ""
|
||||
wm.selected_version_load_option = ""
|
||||
wm.selected_version_id = ""
|
||||
wm.speckle_objects.clear()
|
||||
|
||||
self.report({"INFO"}, message)
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty
|
||||
from ..utils.model_card_utils import select_model_card_objects, zoom_to_selected_objects
|
||||
|
||||
|
||||
class SPECKLE_OT_select_objects(Operator):
|
||||
@@ -11,6 +11,9 @@ class SPECKLE_OT_select_objects(Operator):
|
||||
bl_idname = "speckle.select_objects"
|
||||
bl_label = "Select Objects"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = (
|
||||
"Selects and zooms extents to objects loaded from this Speckle model"
|
||||
)
|
||||
|
||||
model_card_id: StringProperty(
|
||||
name="Model Card ID", description="ID of the model card", default=""
|
||||
@@ -24,30 +27,8 @@ class SPECKLE_OT_select_objects(Operator):
|
||||
self.report({"ERROR"}, "Model card not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
collection_name = model_card.collection_name
|
||||
|
||||
collection = bpy.data.collections.get(collection_name)
|
||||
if not collection:
|
||||
self.report({"ERROR"}, f"Collection {collection_name} not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# deselect all objects first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
# select all objects in the collection and its child collections
|
||||
def select_collection_objects(collection):
|
||||
for obj in collection.objects:
|
||||
obj.select_set(True)
|
||||
for child in collection.children:
|
||||
select_collection_objects(child)
|
||||
|
||||
select_collection_objects(collection)
|
||||
|
||||
selected = context.selected_objects
|
||||
if selected:
|
||||
context.view_layer.objects.active = selected[0]
|
||||
|
||||
bpy.ops.view3d.view_selected()
|
||||
select_model_card_objects(model_card, context)
|
||||
zoom_to_selected_objects(context)
|
||||
|
||||
self.report({"INFO"}, f"Selected {len(context.selected_objects)} objects")
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
from ..operations.load_operation import load_operation # noqa: F401
|
||||
from ..operations.publish_operation import publish_operation # noqa: F401
|
||||
|
||||
@@ -1,62 +1,95 @@
|
||||
from typing import Dict, Union
|
||||
|
||||
import bpy
|
||||
from bpy.types import Context
|
||||
from specklepy.api.credentials import get_local_accounts
|
||||
from specklepy.transports.server import ServerTransport
|
||||
from specklepy.api import operations
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.objects.models.collections.collection import Collection as SCollection
|
||||
from specklepy.core.api import host_applications, operations
|
||||
from specklepy.core.api.inputs.version_inputs import MarkReceivedVersionInput
|
||||
from specklepy.logging import metrics
|
||||
from specklepy.objects.graph_traversal.default_traversal import (
|
||||
create_default_traversal_function,
|
||||
)
|
||||
from specklepy.objects.models.collections.collection import Collection as SCollection
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
from ..utils.get_ascendants import get_ascendants
|
||||
from ...converter.utils import find_object_by_id
|
||||
from ... import bl_info
|
||||
from ...converter.to_native import (
|
||||
convert_to_native,
|
||||
render_material_proxy_to_native,
|
||||
instance_definition_proxy_to_native,
|
||||
find_instance_definitions,
|
||||
instance_definition_proxy_to_native,
|
||||
render_material_proxy_to_native,
|
||||
)
|
||||
from ...converter.utils import (
|
||||
build_object_id_map,
|
||||
get_project_workspace_id,
|
||||
)
|
||||
from ..utils.account_manager import _client_cache
|
||||
from ..utils.get_ascendants import get_ascendants
|
||||
|
||||
|
||||
def load_operation(context: Context) -> None:
|
||||
def load_operation(
|
||||
context: Context, instance_loading_mode: str = "INSTANCE_PROXIES"
|
||||
) -> Dict[str, Union[bpy.types.Collection, bpy.types.Object]]:
|
||||
"""
|
||||
load objects from Speckle and maintain hierarchy.
|
||||
"""
|
||||
|
||||
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 account
|
||||
account = next(
|
||||
(
|
||||
acc
|
||||
for acc in get_local_accounts()
|
||||
if acc.id == context.window_manager.selected_account_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
# get cached client
|
||||
client = _client_cache.get_client(accountId)
|
||||
if not client:
|
||||
print("No Speckle client found")
|
||||
return {}
|
||||
|
||||
if account is None:
|
||||
print("No Speckle account found")
|
||||
return
|
||||
print(f"Using client for account: {accountId}")
|
||||
|
||||
print(f"Using account: {account.userInfo.email}")
|
||||
transport = ServerTransport(stream_id=projectId, client=client)
|
||||
|
||||
# receive the data
|
||||
client = SpeckleClient(host=account.serverInfo.url)
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
transport = ServerTransport(stream_id=wm.selected_project_id, 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
|
||||
if not obj_id:
|
||||
raise ValueError("Unable to receive version beyond workspaces limit")
|
||||
|
||||
version_data = operations.receive(obj_id, transport)
|
||||
|
||||
metrics.set_host_app("blender")
|
||||
client.version.received(
|
||||
MarkReceivedVersionInput(
|
||||
version_id=version.id,
|
||||
project_id=projectId,
|
||||
source_application="blender",
|
||||
)
|
||||
)
|
||||
|
||||
metrics.track(
|
||||
metrics.RECEIVE,
|
||||
client.account,
|
||||
{
|
||||
"ui": "dui3",
|
||||
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
||||
"core_version": ".".join(map(str, bl_info["version"])),
|
||||
"sourceHostApp": host_applications.get_host_app_from_string(
|
||||
version.source_application
|
||||
).slug,
|
||||
"isMultiplayer": version.author_user.id != client.account.userInfo.id,
|
||||
"workspace_id": get_project_workspace_id(client, wm.selected_project_id),
|
||||
},
|
||||
)
|
||||
|
||||
# Build object ID map once
|
||||
object_id_map = build_object_id_map(version_data)
|
||||
|
||||
# Create material mapping first
|
||||
material_mapping = render_material_proxy_to_native(version_data)
|
||||
|
||||
definition_collections, definition_objects = instance_definition_proxy_to_native(
|
||||
version_data, material_mapping
|
||||
version_data,
|
||||
material_mapping,
|
||||
instance_loading_mode=instance_loading_mode,
|
||||
object_id_map=object_id_map,
|
||||
)
|
||||
|
||||
definitions_root_collection = None
|
||||
@@ -70,7 +103,8 @@ def load_operation(context: Context) -> None:
|
||||
for definition in find_instance_definitions(version_data).values():
|
||||
definition_object_ids.update(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 hasattr(found_obj, "id"):
|
||||
definition_object_ids.add(found_obj.id)
|
||||
@@ -79,7 +113,7 @@ def load_operation(context: Context) -> None:
|
||||
|
||||
traversal_function = create_default_traversal_function()
|
||||
|
||||
root_collection_name = f"{wm.selected_model_name} - {wm.selected_version_id[:8]}"
|
||||
root_collection_name = f"{wm.selected_model_name} - {wm.selected_version_id}"
|
||||
root_collection = bpy.data.collections.new(root_collection_name)
|
||||
context.scene.collection.children.link(root_collection)
|
||||
|
||||
@@ -116,7 +150,7 @@ def load_operation(context: Context) -> None:
|
||||
speckle_root_id = speckle_obj.id
|
||||
|
||||
collection_name = getattr(
|
||||
speckle_obj, "name", f"Collection_{speckle_obj.id[:8]}"
|
||||
speckle_obj, "name", f"Collection_{speckle_obj.id}"
|
||||
)
|
||||
|
||||
parent_id = None
|
||||
@@ -129,6 +163,7 @@ def load_operation(context: Context) -> None:
|
||||
"id": speckle_obj.id,
|
||||
"name": collection_name,
|
||||
"parent_id": parent_id,
|
||||
"applicationId": getattr(speckle_obj, "applicationId", ""),
|
||||
"blender_collection": None,
|
||||
"full_path": [collection_name],
|
||||
}
|
||||
@@ -184,6 +219,8 @@ def load_operation(context: Context) -> None:
|
||||
blender_collection = created_collections[collection_key]
|
||||
else:
|
||||
blender_collection = bpy.data.collections.new(coll_name)
|
||||
if coll_info.get("applicationId"):
|
||||
blender_collection["applicationId"] = coll_info["applicationId"]
|
||||
parent_collection.children.link(blender_collection)
|
||||
created_collections[collection_key] = blender_collection
|
||||
|
||||
@@ -229,6 +266,7 @@ def load_operation(context: Context) -> None:
|
||||
material_mapping,
|
||||
definition_collections=definition_collections,
|
||||
root_collection=target_collection,
|
||||
instance_loading_mode=instance_loading_mode,
|
||||
)
|
||||
|
||||
if blender_obj is None:
|
||||
@@ -268,3 +306,5 @@ def load_operation(context: Context) -> None:
|
||||
area.tag_redraw()
|
||||
|
||||
print(f"\nLoad process completed. Imported {len(converted_objects)} objects.")
|
||||
|
||||
return converted_objects
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import bpy
|
||||
from bpy.types import Context, Collection as BlenderCollection
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
|
||||
from specklepy.objects import Base
|
||||
from specklepy.objects.models.collections.collection import Collection
|
||||
from specklepy.core.api import operations
|
||||
from specklepy.transports.server import ServerTransport
|
||||
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
|
||||
from specklepy.objects.models.units import Units
|
||||
from specklepy.logging.exceptions import GraphQLException, WorkspacePermissionException
|
||||
from specklepy.core.api.inputs.model_ingestion_inputs import (
|
||||
ModelIngestionCreateInput,
|
||||
ModelIngestionStartProcessingInput,
|
||||
ModelIngestionSuccessInput,
|
||||
ModelIngestionFailedInput,
|
||||
SourceDataInput,
|
||||
)
|
||||
|
||||
from ...converter.to_speckle import convert_to_speckle
|
||||
from ...converter.to_speckle.material_to_speckle import (
|
||||
add_render_material_proxies_to_base,
|
||||
)
|
||||
from ...converter.utils import get_project_workspace_id
|
||||
from ..utils.account_manager import _client_cache
|
||||
from specklepy.logging import metrics
|
||||
from ... import bl_info
|
||||
|
||||
|
||||
def _check_use_model_ingestion_send(client, project_id: str, model_id: str) -> bool:
|
||||
"""Check if the server supports model ingestion and the user is authorized."""
|
||||
try:
|
||||
result = client.model.can_create_model_ingestion(project_id, model_id)
|
||||
result.ensure_authorised()
|
||||
return True
|
||||
except GraphQLException:
|
||||
return False
|
||||
|
||||
|
||||
def _build_source_data() -> SourceDataInput:
|
||||
"""Build data input for model ingestion."""
|
||||
file_name = bpy.path.basename(bpy.data.filepath)
|
||||
if not file_name:
|
||||
file_name = "Untitled.blend"
|
||||
|
||||
file_size_bytes: Optional[int] = None
|
||||
if bpy.data.filepath:
|
||||
import os
|
||||
|
||||
try:
|
||||
file_size_bytes = os.path.getsize(bpy.data.filepath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
blender_version = ".".join(map(str, bl_info["blender"]))
|
||||
return SourceDataInput(
|
||||
source_application_slug="blender",
|
||||
source_application_version=blender_version,
|
||||
file_name=file_name,
|
||||
file_size_bytes=file_size_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _send_via_ingestion(
|
||||
client,
|
||||
project_id: str,
|
||||
model_id: str,
|
||||
obj_id: str,
|
||||
version_message: str,
|
||||
) -> str:
|
||||
"""Send via the model ingestion. Returns version_id."""
|
||||
source_data = _build_source_data()
|
||||
|
||||
create_input = ModelIngestionCreateInput(
|
||||
project_id=project_id,
|
||||
model_id=model_id,
|
||||
source_data=source_data,
|
||||
progress_message="Model ingestion created",
|
||||
)
|
||||
ingestion = client.model_ingestion.create(create_input)
|
||||
ingestion_id = ingestion.id
|
||||
|
||||
try:
|
||||
start_input = ModelIngestionStartProcessingInput(
|
||||
project_id=project_id,
|
||||
ingestion_id=ingestion_id,
|
||||
progress_message="Processing model ingestion",
|
||||
source_data=source_data,
|
||||
)
|
||||
client.model_ingestion.start_processing(start_input)
|
||||
|
||||
success_input = ModelIngestionSuccessInput(
|
||||
project_id=project_id,
|
||||
ingestion_id=ingestion_id,
|
||||
root_object_id=obj_id,
|
||||
version_message=version_message,
|
||||
)
|
||||
version_id = client.model_ingestion.complete(success_input)
|
||||
return version_id
|
||||
except Exception:
|
||||
try:
|
||||
fail_input = ModelIngestionFailedInput(
|
||||
project_id=project_id,
|
||||
ingestion_id=ingestion_id,
|
||||
error_reason="Failed during processing",
|
||||
)
|
||||
client.model_ingestion.fail_with_error(fail_input)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _send_via_version_create(
|
||||
client,
|
||||
project_id: str,
|
||||
model_id: str,
|
||||
obj_id: str,
|
||||
version_message: str,
|
||||
) -> str:
|
||||
"""Send via the legacy version.create() flow. Returns version_id."""
|
||||
version_input = CreateVersionInput(
|
||||
objectId=obj_id,
|
||||
modelId=model_id,
|
||||
projectId=project_id,
|
||||
message=version_message,
|
||||
sourceApplication="blender",
|
||||
)
|
||||
version = client.version.create(version_input)
|
||||
return version.id
|
||||
|
||||
|
||||
def publish_operation(
|
||||
context: Context,
|
||||
objects_to_convert: List,
|
||||
version_message: str = "",
|
||||
apply_modifiers: bool = True,
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
publish objects to speckle
|
||||
"""
|
||||
wm = context.window_manager
|
||||
|
||||
try:
|
||||
# get cached client
|
||||
client = _client_cache.get_client(wm.selected_account_id)
|
||||
if not client:
|
||||
return False, "No Speckle client found", None
|
||||
|
||||
project_id = wm.selected_project_id
|
||||
model_id = wm.selected_model_id
|
||||
|
||||
# check ingestion support before sending data (fail fast on permission errors)
|
||||
use_ingestion = _check_use_model_ingestion_send(client, project_id, model_id)
|
||||
|
||||
transport = ServerTransport(stream_id=project_id, client=client)
|
||||
|
||||
# build collection hierarchy and convert objects
|
||||
root_collection = build_collection_hierarchy(
|
||||
context, objects_to_convert, apply_modifiers
|
||||
)
|
||||
|
||||
if not root_collection:
|
||||
return False, "No objects could be converted to Speckle format", None
|
||||
|
||||
# add material proxies
|
||||
add_render_material_proxies_to_base(root_collection, objects_to_convert)
|
||||
|
||||
obj_id = operations.send(root_collection, [transport])
|
||||
|
||||
if use_ingestion:
|
||||
version_id = _send_via_ingestion(
|
||||
client, project_id, model_id, obj_id, version_message
|
||||
)
|
||||
else:
|
||||
version_id = _send_via_version_create(
|
||||
client, project_id, model_id, obj_id, version_message
|
||||
)
|
||||
|
||||
# Get account for metrics tracking
|
||||
from specklepy.core.api.credentials import get_local_accounts
|
||||
|
||||
account = next(
|
||||
(acc for acc in get_local_accounts() if acc.id == wm.selected_account_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if account:
|
||||
# track metrics
|
||||
metrics.set_host_app("blender")
|
||||
metrics.track(
|
||||
metrics.SEND,
|
||||
account,
|
||||
{
|
||||
"ui": "dui3",
|
||||
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
|
||||
"core_version": ".".join(map(str, bl_info["version"])),
|
||||
"workspace_id": get_project_workspace_id(client, project_id),
|
||||
},
|
||||
)
|
||||
|
||||
# count total objects for success message
|
||||
total_objects = count_objects_in_collection(root_collection)
|
||||
|
||||
return (
|
||||
True,
|
||||
f"Successfully published {total_objects} objects with hierarchy to Speckle",
|
||||
version_id,
|
||||
)
|
||||
|
||||
except WorkspacePermissionException as e:
|
||||
return False, f"Permission denied: {str(e)}", None
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
return False, f"Failed to publish: {str(e)}", None
|
||||
|
||||
|
||||
def build_collection_hierarchy(
|
||||
context: Context, objects_to_convert: List, apply_modifiers: bool = True
|
||||
) -> Optional[Collection]:
|
||||
"""
|
||||
build a speckle collection hierarchy that mimicks blender's collection structure
|
||||
"""
|
||||
# set name for root collection
|
||||
file_name = bpy.path.basename(bpy.data.filepath)
|
||||
collection_name = file_name if file_name else "Untitled.blend"
|
||||
|
||||
collection_data = analyze_collection_structure(objects_to_convert)
|
||||
|
||||
if not collection_data["objects"] and not collection_data["collections"]:
|
||||
return None
|
||||
|
||||
converted_objects = convert_selected_objects(
|
||||
context, objects_to_convert, apply_modifiers
|
||||
)
|
||||
if not converted_objects:
|
||||
return None
|
||||
|
||||
# create the root Speckle collection
|
||||
root_collection = Collection(name=collection_name)
|
||||
root_collection.units = get_scene_units(context.scene).value
|
||||
root_collection["version"] = 3
|
||||
|
||||
# maps Blender collection to Speckle collection
|
||||
collection_mapping = {} #
|
||||
|
||||
# create Speckle collections for each blender collection
|
||||
for blender_coll in collection_data["collections"]:
|
||||
speckle_coll = Collection(name=blender_coll.name)
|
||||
speckle_coll.units = root_collection.units
|
||||
collection_mapping[blender_coll] = speckle_coll
|
||||
|
||||
for blender_coll in collection_data["collections"]:
|
||||
speckle_coll = collection_mapping[blender_coll]
|
||||
|
||||
parent_coll = find_parent_collection(
|
||||
blender_coll, collection_data["collections"]
|
||||
)
|
||||
|
||||
if parent_coll and parent_coll in collection_mapping:
|
||||
parent_speckle_coll = collection_mapping[parent_coll]
|
||||
parent_speckle_coll.elements.append(speckle_coll)
|
||||
else:
|
||||
root_collection.elements.append(speckle_coll)
|
||||
|
||||
# assign objects to their collections
|
||||
object_mapping = {}
|
||||
for i, blender_obj in enumerate(objects_to_convert):
|
||||
if i < len(converted_objects) and converted_objects[i] is not None:
|
||||
object_mapping[blender_obj] = converted_objects[i]
|
||||
|
||||
for blender_obj, speckle_obj in object_mapping.items():
|
||||
placed = False
|
||||
|
||||
target_collection = find_target_collection_for_object(
|
||||
blender_obj, collection_data["collections"]
|
||||
)
|
||||
|
||||
if target_collection and target_collection in collection_mapping:
|
||||
collection_mapping[target_collection].elements.append(speckle_obj)
|
||||
placed = True
|
||||
|
||||
# if not placed in any subcollection, add to root
|
||||
if not placed:
|
||||
root_collection.elements.append(speckle_obj)
|
||||
|
||||
return root_collection
|
||||
|
||||
|
||||
def analyze_collection_structure(objects: List) -> Dict:
|
||||
"""
|
||||
analyze the collection structure of the given objects
|
||||
"""
|
||||
collections_set = set()
|
||||
objects_collections = {}
|
||||
|
||||
direct_collections = set()
|
||||
for obj in objects:
|
||||
obj_collections = []
|
||||
for collection in bpy.data.collections:
|
||||
if obj.name in collection.objects:
|
||||
direct_collections.add(collection)
|
||||
obj_collections.append(collection)
|
||||
objects_collections[obj] = obj_collections
|
||||
|
||||
# find all ancestor collections
|
||||
def find_all_ancestors(collection):
|
||||
"""recursively find all ancestor collections"""
|
||||
ancestors = set()
|
||||
|
||||
for potential_parent in bpy.data.collections:
|
||||
if collection.name in potential_parent.children:
|
||||
ancestors.add(potential_parent)
|
||||
# Recursively find ancestors of the parent
|
||||
ancestors.update(find_all_ancestors(potential_parent))
|
||||
|
||||
return ancestors
|
||||
|
||||
for collection in direct_collections:
|
||||
collections_set.add(collection)
|
||||
ancestors = find_all_ancestors(collection)
|
||||
collections_set.update(ancestors)
|
||||
|
||||
collections_list = list(collections_set)
|
||||
collections_list.sort(key=lambda c: get_collection_depth(c))
|
||||
|
||||
return {
|
||||
"collections": collections_list,
|
||||
"objects": objects,
|
||||
"object_collections": objects_collections,
|
||||
}
|
||||
|
||||
|
||||
def get_collection_depth(collection: BlenderCollection) -> int:
|
||||
"""
|
||||
get the depth of a collection in the hierarchy
|
||||
"""
|
||||
depth = 0
|
||||
for scene in bpy.data.scenes:
|
||||
if collection.name in scene.collection.children:
|
||||
return depth
|
||||
|
||||
for parent_coll in bpy.data.collections:
|
||||
if collection.name in parent_coll.children:
|
||||
return get_collection_depth(parent_coll) + 1
|
||||
|
||||
return depth
|
||||
|
||||
|
||||
def find_parent_collection(
|
||||
collection: BlenderCollection, all_collections: List[BlenderCollection]
|
||||
) -> Optional[BlenderCollection]:
|
||||
"""
|
||||
find the parent collection
|
||||
"""
|
||||
for potential_parent in all_collections:
|
||||
if collection.name in potential_parent.children:
|
||||
return potential_parent
|
||||
return None
|
||||
|
||||
|
||||
def find_target_collection_for_object(
|
||||
obj, collections: List[BlenderCollection]
|
||||
) -> Optional[BlenderCollection]:
|
||||
"""
|
||||
find the deepest collection that contains this object
|
||||
"""
|
||||
target_collection = None
|
||||
max_depth = -1
|
||||
|
||||
for collection in collections:
|
||||
if obj.name in collection.objects:
|
||||
depth = get_collection_depth(collection)
|
||||
if depth > max_depth:
|
||||
max_depth = depth
|
||||
target_collection = collection
|
||||
|
||||
return target_collection
|
||||
|
||||
|
||||
def convert_selected_objects(
|
||||
context: Context, objects_to_convert: List, apply_modifiers: bool = True
|
||||
) -> List[Optional[Base]]:
|
||||
"""
|
||||
convert selected objects to Speckle format with proper units
|
||||
"""
|
||||
scene = context.scene
|
||||
units = get_scene_units(scene)
|
||||
scale_factor = scene.unit_settings.scale_length
|
||||
|
||||
speckle_objects = []
|
||||
for obj in objects_to_convert:
|
||||
if not obj or obj.type not in ["MESH", "CURVE", "EMPTY"]:
|
||||
speckle_objects.append(None)
|
||||
continue
|
||||
|
||||
speckle_obj = convert_to_speckle(
|
||||
obj, scale_factor, units.value, apply_modifiers
|
||||
)
|
||||
speckle_objects.append(speckle_obj)
|
||||
|
||||
return speckle_objects
|
||||
|
||||
|
||||
def get_scene_units(scene) -> Units:
|
||||
"""
|
||||
get units from Blender's unit system
|
||||
"""
|
||||
unit_settings = scene.unit_settings
|
||||
|
||||
if unit_settings.system == "METRIC":
|
||||
if unit_settings.length_unit == "METERS":
|
||||
return Units.m
|
||||
elif unit_settings.length_unit == "CENTIMETERS":
|
||||
return Units.cm
|
||||
elif unit_settings.length_unit == "MILLIMETERS":
|
||||
return Units.mm
|
||||
elif unit_settings.length_unit == "KILOMETERS":
|
||||
return Units.km
|
||||
else:
|
||||
return Units.m
|
||||
elif unit_settings.system == "IMPERIAL":
|
||||
if unit_settings.length_unit == "FEET":
|
||||
return Units.feet
|
||||
elif unit_settings.length_unit == "INCHES":
|
||||
return Units.inches
|
||||
elif unit_settings.length_unit == "YARDS":
|
||||
return Units.yards
|
||||
elif unit_settings.length_unit == "MILES":
|
||||
return Units.miles
|
||||
else:
|
||||
return Units.feet
|
||||
else:
|
||||
return Units.m # default to meters
|
||||
|
||||
|
||||
def count_objects_in_collection(collection: Collection) -> int:
|
||||
"""
|
||||
recursively count all objects in a collection and its sub-collections
|
||||
"""
|
||||
count = 0
|
||||
if hasattr(collection, "elements"):
|
||||
for element in collection.elements:
|
||||
if isinstance(element, Collection):
|
||||
count += count_objects_in_collection(element)
|
||||
else:
|
||||
count += 1
|
||||
return count
|
||||
@@ -1,9 +1,9 @@
|
||||
import bpy
|
||||
from bpy.props import CollectionProperty, StringProperty
|
||||
from bpy.props import CollectionProperty
|
||||
from bpy.types import PropertyGroup
|
||||
from typing import Optional
|
||||
|
||||
from ..ui.model_card import speckle_model_card
|
||||
from ..utils.property_groups import speckle_model_card
|
||||
|
||||
|
||||
class SpeckleState(PropertyGroup):
|
||||
@@ -11,7 +11,6 @@ class SpeckleState(PropertyGroup):
|
||||
manages the state of the Speckle addon in Blender
|
||||
"""
|
||||
|
||||
ui_mode: StringProperty(name="UI Mode", default="NONE") # type: ignore
|
||||
model_cards: CollectionProperty(type=speckle_model_card) # type: ignore
|
||||
|
||||
def get_model_card_by_id(self, model_card_id: str) -> Optional[speckle_model_card]:
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .main_panel import SPECKLE_PT_main_panel # noqa: F401
|
||||
from .main_panel import SPECKLE_PT_main_panel # noqa: F401
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import bpy
|
||||
from bpy.types import Context, Event
|
||||
from typing import List, Tuple
|
||||
from ..utils.account_manager import (
|
||||
get_account_enum_items,
|
||||
speckle_account,
|
||||
speckle_workspace,
|
||||
get_workspaces,
|
||||
get_active_workspace,
|
||||
get_account_from_id,
|
||||
)
|
||||
from ..utils.project_manager import get_projects_for_account
|
||||
from ..ui.project_selection_dialog import speckle_project
|
||||
|
||||
|
||||
class SPECKLE_UL_accounts_list(bpy.types.UIList):
|
||||
"""
|
||||
UIList for displaying accounts
|
||||
"""
|
||||
|
||||
def draw_item(
|
||||
self,
|
||||
context: Context,
|
||||
layout: bpy.types.UILayout,
|
||||
data: bpy.types.PropertyGroup,
|
||||
item: bpy.types.PropertyGroup,
|
||||
icon: str,
|
||||
active_data: bpy.types.PropertyGroup,
|
||||
active_propname: str,
|
||||
) -> None:
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
row = layout.row()
|
||||
row.label(text=item.user_name)
|
||||
row.label(text=item.server_url)
|
||||
row.label(text=item.user_email)
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text=item.user_name)
|
||||
|
||||
|
||||
class SPECKLE_OT_account_selection_dialog(bpy.types.Operator):
|
||||
"""
|
||||
operator for displaying and handling the account selection dialog
|
||||
"""
|
||||
|
||||
bl_idname = "speckle.account_selection_dialog"
|
||||
bl_label = "Select Account"
|
||||
bl_description = "Select account"
|
||||
|
||||
account_index: bpy.props.IntProperty(default=0) # type: ignore
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
wm = context.window_manager
|
||||
# Clear existing accounts
|
||||
wm.speckle_accounts.clear()
|
||||
|
||||
# Save selected account
|
||||
current_account_index = 0
|
||||
|
||||
# Fetch accounts
|
||||
for i, (id, user_name, server_url, user_email) in enumerate(
|
||||
get_account_enum_items()
|
||||
):
|
||||
account: speckle_account = wm.speckle_accounts.add()
|
||||
account.id = id
|
||||
account.user_name = user_name
|
||||
account.server_url = server_url
|
||||
account.user_email = user_email
|
||||
if id == wm.selected_account_id:
|
||||
current_account_index = i
|
||||
|
||||
self.account_index = current_account_index
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
wm = context.window_manager
|
||||
row = layout.row()
|
||||
# add account button
|
||||
if wm.selected_account_id == "NO_ACCOUNTS":
|
||||
add_account_button_text = "Sign In"
|
||||
add_account_button_icon = "WORLD"
|
||||
else:
|
||||
add_account_button_text = "Add Account"
|
||||
add_account_button_icon = "ADD"
|
||||
row.operator(
|
||||
"speckle.add_account",
|
||||
icon=add_account_button_icon,
|
||||
text=add_account_button_text,
|
||||
)
|
||||
|
||||
if wm.selected_account_id != "NO_ACCOUNTS":
|
||||
layout.template_list(
|
||||
"SPECKLE_UL_accounts_list",
|
||||
"",
|
||||
context.window_manager,
|
||||
"speckle_accounts",
|
||||
self,
|
||||
"account_index",
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
wm = context.window_manager
|
||||
# update the selected account id
|
||||
account = get_account_from_id(wm.speckle_accounts[self.account_index].id)
|
||||
wm.selected_account_id = account.id
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Selected account: {account.userInfo.name} - {account.userInfo.email} - {account.serverInfo.url}",
|
||||
)
|
||||
update_workspaces_list(context)
|
||||
update_projects_list(context)
|
||||
# redraw the area
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def update_workspaces_list(context: Context) -> None:
|
||||
wm = context.window_manager
|
||||
wm.speckle_workspaces.clear()
|
||||
workspaces = get_workspaces(wm.selected_account_id)
|
||||
for id, name in workspaces:
|
||||
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
||||
workspace.id = id
|
||||
workspace.name = name
|
||||
active_workspace = get_active_workspace(wm.selected_account_id)
|
||||
if active_workspace:
|
||||
wm.selected_workspace.id = active_workspace["id"]
|
||||
elif wm.speckle_workspaces:
|
||||
wm.selected_workspace.id = wm.speckle_workspaces[0].id
|
||||
print("Updated Workspaces List!")
|
||||
|
||||
|
||||
def update_projects_list(context: Context) -> None:
|
||||
wm = context.window_manager
|
||||
wm.speckle_projects.clear()
|
||||
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
||||
wm.selected_account_id, workspace_id=wm.selected_workspace.id
|
||||
)
|
||||
for name, role, updated, id, can_receive in projects:
|
||||
project: speckle_project = wm.speckle_projects.add()
|
||||
project.name = name
|
||||
project.role = role
|
||||
project.updated = updated
|
||||
project.id = id
|
||||
project.can_receive = can_receive
|
||||
print("Updated Projects List!")
|
||||
@@ -4,19 +4,31 @@ import bpy.utils.previews
|
||||
|
||||
speckle_icons: Optional[Dict[str, bpy.types.ImagePreview]] = None
|
||||
|
||||
|
||||
def load_icons() -> None:
|
||||
global speckle_icons
|
||||
speckle_icons = bpy.utils.previews.new()
|
||||
icons_dir = os.path.dirname(__file__)
|
||||
speckle_icons.load("speckle_logo", os.path.join(icons_dir, "speckle-logo.png"), 'IMAGE')
|
||||
speckle_logo_icon_path = os.path.join(icons_dir, "speckle-logo.png")
|
||||
if os.path.exists(speckle_logo_icon_path):
|
||||
speckle_icons.load("speckle_logo", speckle_logo_icon_path, "IMAGE")
|
||||
else:
|
||||
print(f"[Speckle] WARNING ‑ icon file not found: {speckle_logo_icon_path}")
|
||||
object_highlight_icon_path = os.path.join(icons_dir, "object-highlight.png")
|
||||
if os.path.exists(object_highlight_icon_path):
|
||||
speckle_icons.load("object_highlight", object_highlight_icon_path, "IMAGE")
|
||||
else:
|
||||
print(f"[Speckle] WARNING ‑ icon file not found: {object_highlight_icon_path}")
|
||||
|
||||
|
||||
def unload_icons() -> None:
|
||||
global speckle_icons
|
||||
if speckle_icons is not None:
|
||||
bpy.utils.previews.remove(speckle_icons)
|
||||
|
||||
|
||||
def get_icon(icon_name: str) -> int:
|
||||
global speckle_icons
|
||||
if speckle_icons is None:
|
||||
raise ValueError("Icons not loaded")
|
||||
return speckle_icons[icon_name].icon_id
|
||||
return speckle_icons[icon_name].icon_id
|
||||
|
||||
@@ -17,7 +17,7 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
layout.label(text="Speckle Connector BETA", icon_value=get_icon("speckle_logo"))
|
||||
layout.label(text="Speckle Connector", icon_value=get_icon("speckle_logo"))
|
||||
|
||||
# check to see if there are any speckle models in the file
|
||||
if not context.scene.speckle_state.model_cards:
|
||||
@@ -30,6 +30,11 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
|
||||
project_selected = bool(getattr(wm, "selected_project_name", None))
|
||||
model_selected = bool(getattr(wm, "selected_model_name", None))
|
||||
version_selected = bool(getattr(wm, "selected_version_id", None))
|
||||
selection_made = bool(getattr(wm, "speckle_objects", None))
|
||||
|
||||
# UI Mode Switch
|
||||
row = layout.row()
|
||||
row.prop(wm, "ui_mode", expand=True)
|
||||
|
||||
# select Project button
|
||||
row = layout.row()
|
||||
@@ -53,83 +58,49 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
|
||||
text=model_button_text,
|
||||
icon=model_button_icon,
|
||||
)
|
||||
|
||||
# select Version button
|
||||
row = layout.row()
|
||||
version_id = getattr(wm, "selected_version_id", "")
|
||||
load_option = getattr(wm, "selected_version_load_option", "")
|
||||
if load_option == "LATEST":
|
||||
version_button_text = "Latest"
|
||||
elif load_option == "SPECIFIC":
|
||||
version_button_text = version_id
|
||||
else:
|
||||
version_button_text = "Select Version"
|
||||
|
||||
version_button_icon = "CHECKMARK" if version_selected else "PLUS"
|
||||
row.enabled = project_selected and model_selected
|
||||
row.operator(
|
||||
"speckle.version_selection_dialog",
|
||||
text=version_button_text,
|
||||
icon=version_button_icon,
|
||||
)
|
||||
|
||||
# load button
|
||||
row = layout.row()
|
||||
row.enabled = project_selected and model_selected and version_selected
|
||||
row.operator("speckle.load", text="Load Model", icon="IMPORT")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# group model cards by project name
|
||||
project_groups = {}
|
||||
for model_card in context.scene.speckle_state.model_cards:
|
||||
project_name = (
|
||||
model_card.project_name if model_card.project_name else "No Project"
|
||||
if wm.ui_mode == "PUBLISH":
|
||||
# TODO: implement Publish flow
|
||||
# Selection filter
|
||||
row = layout.row()
|
||||
row.enabled = project_selected and model_selected
|
||||
selection_button_text = (
|
||||
f"{len(wm.speckle_objects)} Objects"
|
||||
if wm.speckle_objects
|
||||
else "Select Objects"
|
||||
)
|
||||
if project_name not in project_groups:
|
||||
project_groups[project_name] = []
|
||||
project_groups[project_name].append(model_card)
|
||||
row.operator(
|
||||
"speckle.selection_filter_dialog",
|
||||
text=selection_button_text,
|
||||
icon="PLUS",
|
||||
).model_card_id = ""
|
||||
|
||||
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")
|
||||
# Publish button
|
||||
row = layout.row()
|
||||
row.enabled = project_selected and model_selected and selection_made
|
||||
row.operator("speckle.publish", text="Publish Model", icon="EXPORT")
|
||||
pass
|
||||
|
||||
for model_card in model_cards:
|
||||
box: UILayout = project_box.box()
|
||||
row: UILayout = box.row()
|
||||
icon: str = "EXPORT" if model_card.is_publish else "IMPORT"
|
||||
if wm.ui_mode == "LOAD":
|
||||
# select Version button
|
||||
row = layout.row()
|
||||
version_id = getattr(wm, "selected_version_id", "")
|
||||
load_option = getattr(wm, "selected_version_load_option", "")
|
||||
if load_option == "LATEST":
|
||||
version_button_text = "Latest"
|
||||
elif load_option == "SPECIFIC":
|
||||
version_button_text = version_id
|
||||
else:
|
||||
version_button_text = "Select Version"
|
||||
|
||||
# Load latest button in the model card
|
||||
row.operator(
|
||||
"speckle.load_latest", text="", icon=icon
|
||||
).model_card_id = model_card.get_model_card_id()
|
||||
row.label(text=f"{model_card.model_name}")
|
||||
version_button_icon = "CHECKMARK" if version_selected else "PLUS"
|
||||
row.enabled = project_selected and model_selected
|
||||
row.operator(
|
||||
"speckle.version_selection_dialog",
|
||||
text=version_button_text,
|
||||
icon=version_button_icon,
|
||||
).model_card_id = ""
|
||||
|
||||
# Select button in the model card
|
||||
select_op = row.operator(
|
||||
"speckle.select_objects", text="", icon="RESTRICT_SELECT_OFF"
|
||||
)
|
||||
select_op.model_card_id = model_card.get_model_card_id()
|
||||
|
||||
# Settings button in the model card
|
||||
row.operator(
|
||||
"speckle.model_card_settings", text="", icon="PREFERENCES"
|
||||
).model_card_id = model_card.get_model_card_id()
|
||||
row: UILayout = box.row()
|
||||
if model_card.is_publish:
|
||||
split: UILayout = row.split(factor=0.33)
|
||||
# TODO: Connect to selection operator
|
||||
split.operator("speckle.publish", text="Selection")
|
||||
split.label(text=f"{model_card.selection_summary}")
|
||||
else:
|
||||
split: UILayout = row.split(factor=0.33)
|
||||
# TODO: Connect to version operator
|
||||
if model_card.load_option == "LATEST":
|
||||
split.operator("speckle.load", text="Latest")
|
||||
split.enabled = False
|
||||
if model_card.load_option == "SPECIFIC":
|
||||
split.operator("speckle.load", text=f"{model_card.version_id}")
|
||||
split.enabled = False
|
||||
# TODO: Get last updated time
|
||||
split.label(text="Last updated: 2 days ago")
|
||||
# load button
|
||||
row = layout.row()
|
||||
row.enabled = project_selected and model_selected and version_selected
|
||||
row.operator("speckle.load", text="Load Model", icon="IMPORT")
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import bpy
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class speckle_model_card(bpy.types.PropertyGroup):
|
||||
"""
|
||||
represents a Speckle model card in the Blender UI
|
||||
"""
|
||||
|
||||
account_id: bpy.props.StringProperty(
|
||||
name="Account ID", description="ID of the account", default=""
|
||||
) # type: ignore
|
||||
server_url: bpy.props.StringProperty(
|
||||
name="Server URL",
|
||||
description="URL of the Server",
|
||||
default="app.speckle.systems",
|
||||
) # type: ignore
|
||||
project_name: bpy.props.StringProperty(
|
||||
name="Project Name", description="Name of the project", default=""
|
||||
) # type: ignore
|
||||
project_id: bpy.props.StringProperty(
|
||||
name="Project ID", description="ID of the selected project", default=""
|
||||
) # type: ignore
|
||||
model_id: bpy.props.StringProperty(
|
||||
name="Model ID", description="ID of the model", default=""
|
||||
) # type: ignore
|
||||
model_name: bpy.props.StringProperty(
|
||||
name="Model Name", description="Name of the model", default=""
|
||||
) # type: ignore
|
||||
is_publish: bpy.props.BoolProperty(
|
||||
name="Publish/Load",
|
||||
description="If the model is published or loaded",
|
||||
default=False,
|
||||
) # type: ignore
|
||||
selection_summary: bpy.props.StringProperty(
|
||||
name="Selection Summary", description="Summary of the selection", default=""
|
||||
) # type: ignore
|
||||
version_id: bpy.props.StringProperty(
|
||||
name="Version ID", description="ID of the selected version", default=""
|
||||
) # type: ignore
|
||||
load_option: bpy.props.StringProperty(
|
||||
name="Version ID", description="ID of the selected version", default=""
|
||||
) # type: ignore
|
||||
collection_name: bpy.props.StringProperty(
|
||||
name="Collection Name", description="Name of the collection", default=""
|
||||
) # type: ignore
|
||||
|
||||
def get_model_card_id(self) -> str:
|
||||
if not self.project_id or not self.model_id:
|
||||
raise ValueError(
|
||||
"Project ID and Model ID are required to generate a model card ID."
|
||||
)
|
||||
return self.project_id + "-" + self.model_id
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
converts the model card to a dictionary representation
|
||||
"""
|
||||
return {
|
||||
"account_id": self.account_id,
|
||||
"server_url": self.server_url,
|
||||
"project_name": self.project_name,
|
||||
"project_id": self.project_id,
|
||||
"model_id": self.model_id,
|
||||
"model_name": self.model_name,
|
||||
"is_publish": self.is_publish,
|
||||
"selection_summary": self.selection_summary,
|
||||
"version_id": self.version_id,
|
||||
"collection_name": self.collection_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
"""
|
||||
creates a new model card instance from a dictionary
|
||||
"""
|
||||
item = cls()
|
||||
item.account_id = data["account_id"]
|
||||
item.server_url = data["server_url"]
|
||||
item.project_name = data["project_name"]
|
||||
item.project_id = data["project_id"]
|
||||
item.model_id = data["model_id"]
|
||||
item.model_name = data["model_name"]
|
||||
item.is_publish = data["is_publish"]
|
||||
item.selection_summary = data["selection_summary"]
|
||||
item.version_id = data["version_id"]
|
||||
item.collection_name = data["collection_name"]
|
||||
@@ -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,16 +2,8 @@ import bpy
|
||||
from bpy.types import UILayout, Context, PropertyGroup, Event
|
||||
from ..utils.model_manager import get_models_for_project
|
||||
from ..utils.version_manager import get_latest_version
|
||||
|
||||
|
||||
class speckle_model(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing model information
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
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):
|
||||
@@ -50,6 +42,7 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
||||
|
||||
bl_idname = "speckle.model_selection_dialog"
|
||||
bl_label = "Select Model"
|
||||
bl_description = "Select a model to load"
|
||||
|
||||
def update_models_list(self, context: Context) -> None:
|
||||
wm = context.window_manager
|
||||
@@ -103,6 +96,11 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
self.update_models_list(context)
|
||||
|
||||
wm = context.window_manager
|
||||
authorized, _ = can_create_model(wm.selected_account_id, wm.selected_project_id)
|
||||
self._can_create_model = authorized
|
||||
SPECKLE_OT_create_model._can_create = authorized
|
||||
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
@@ -111,7 +109,11 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
||||
layout.label(text=f"Project: {wm.selected_project_name}")
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.prop(self, "search_query", icon="VIEWZOOM", text="")
|
||||
row.prop(self, "search_query", icon="VIEWZOOM", text="") # search bar
|
||||
if wm.ui_mode != "LOAD":
|
||||
sub = row.row(align=True)
|
||||
sub.enabled = getattr(self, "_can_create_model", True)
|
||||
sub.operator("speckle.create_model", icon="ADD", text="")
|
||||
|
||||
layout.template_list(
|
||||
"SPECKLE_UL_models_list",
|
||||
@@ -123,15 +125,3 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
|
||||
)
|
||||
|
||||
layout.separator()
|
||||
|
||||
|
||||
def register() -> None:
|
||||
bpy.utils.register_class(speckle_model)
|
||||
bpy.utils.register_class(SPECKLE_UL_models_list)
|
||||
bpy.utils.register_class(SPECKLE_OT_model_selection_dialog)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
bpy.utils.unregister_class(SPECKLE_OT_model_selection_dialog)
|
||||
bpy.utils.unregister_class(SPECKLE_UL_models_list)
|
||||
bpy.utils.unregister_class(speckle_model)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -2,48 +2,13 @@ import bpy
|
||||
from bpy.types import UILayout, Context, PropertyGroup, Event
|
||||
from typing import List, Tuple
|
||||
from ..utils.account_manager import (
|
||||
get_account_enum_items,
|
||||
speckle_account,
|
||||
get_workspaces,
|
||||
speckle_workspace,
|
||||
can_create_project_in_workspace,
|
||||
get_active_workspace,
|
||||
get_default_account_id,
|
||||
get_account_from_id,
|
||||
)
|
||||
from ..utils.project_manager import get_projects_for_account
|
||||
|
||||
|
||||
def get_accounts_callback(self, context):
|
||||
"""Callback to dynamically fetch account enum items."""
|
||||
wm = context.window_manager
|
||||
return [
|
||||
(
|
||||
account.id,
|
||||
f"{account.user_name} - {account.user_email} - {account.server_url}",
|
||||
"",
|
||||
)
|
||||
for account in wm.speckle_accounts
|
||||
]
|
||||
|
||||
|
||||
def get_workspaces_callback(self, context):
|
||||
"""
|
||||
Callback to dynamically fetch workspace enum items.
|
||||
"""
|
||||
wm = context.window_manager
|
||||
return [
|
||||
(workspace.id, workspace.name, "", "WORKSPACE", i)
|
||||
for i, workspace in enumerate(wm.speckle_workspaces)
|
||||
]
|
||||
|
||||
|
||||
class speckle_project(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing project information
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
role: bpy.props.StringProperty(name="Role") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
can_receive: bpy.props.BoolProperty(name="Can Receive", default=False) # type: ignore
|
||||
from ..utils.property_groups import speckle_project
|
||||
|
||||
|
||||
class SPECKLE_UL_projects_list(bpy.types.UIList):
|
||||
@@ -87,36 +52,7 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
|
||||
bl_idname = "speckle.project_selection_dialog"
|
||||
bl_label = "Select Project"
|
||||
|
||||
def update_workspaces_and_projects_list(self, context: Context) -> None:
|
||||
wm = context.window_manager
|
||||
wm.selected_account_id = self.accounts
|
||||
wm.speckle_workspaces.clear()
|
||||
workspaces = get_workspaces(self.accounts)
|
||||
for id, name in workspaces:
|
||||
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
||||
workspace.id = id
|
||||
workspace.name = name
|
||||
print("Updated Workspaces List!")
|
||||
|
||||
wm.speckle_projects.clear()
|
||||
|
||||
# get projects for the selected account, using search if provided
|
||||
search = self.search_query if self.search_query.strip() else None
|
||||
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
||||
self.accounts, search=search, workspace_id=self.workspaces
|
||||
)
|
||||
|
||||
for name, role, updated, id, can_receive in projects:
|
||||
project: speckle_project = wm.speckle_projects.add()
|
||||
project.name = name
|
||||
project.role = role
|
||||
project.updated = updated
|
||||
project.id = id
|
||||
project.can_receive = can_receive
|
||||
print("Updated Projects List!")
|
||||
|
||||
return None
|
||||
bl_description = "Select a project to load models from"
|
||||
|
||||
def update_projects_list(self, context: Context) -> None:
|
||||
"""
|
||||
@@ -124,15 +60,15 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
"""
|
||||
wm = context.window_manager
|
||||
|
||||
wm.selected_account_id = self.accounts
|
||||
wm.selected_workspace_id = self.workspaces
|
||||
|
||||
wm.can_create_project_in_workspace = can_create_project_in_workspace(
|
||||
wm.selected_account_id, wm.selected_workspace.id
|
||||
)
|
||||
wm.speckle_projects.clear()
|
||||
|
||||
# get projects for the selected account, using search if provided
|
||||
search = self.search_query if self.search_query.strip() else None
|
||||
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
||||
self.accounts, search=search, workspace_id=self.workspaces
|
||||
wm.selected_account_id, search=search, workspace_id=wm.selected_workspace.id
|
||||
)
|
||||
|
||||
for name, role, updated, id, can_receive in projects:
|
||||
@@ -152,20 +88,6 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
update=update_projects_list,
|
||||
)
|
||||
|
||||
accounts: bpy.props.EnumProperty( # type: ignore
|
||||
name="Account",
|
||||
description="Selected account to filter projects by",
|
||||
items=get_accounts_callback,
|
||||
update=update_workspaces_and_projects_list,
|
||||
)
|
||||
|
||||
workspaces: bpy.props.EnumProperty( # type: ignore
|
||||
name="Workspace",
|
||||
description="Selected workspace to filter projects by",
|
||||
items=get_workspaces_callback,
|
||||
update=update_projects_list,
|
||||
)
|
||||
|
||||
project_index: bpy.props.IntProperty(name="Project Index", default=0) # type: ignore
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
@@ -192,33 +114,28 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
wm = context.window_manager
|
||||
|
||||
# Clear existing accounts and projects
|
||||
wm.speckle_accounts.clear()
|
||||
# Clear existing projects
|
||||
wm.speckle_projects.clear()
|
||||
wm.speckle_workspaces.clear()
|
||||
|
||||
# Fetch accounts
|
||||
for id, user_name, server_url, user_email in get_account_enum_items():
|
||||
account: speckle_account = wm.speckle_accounts.add()
|
||||
account.id = id
|
||||
account.user_name = user_name
|
||||
account.server_url = server_url
|
||||
account.user_email = user_email
|
||||
if wm.selected_account_id == "":
|
||||
wm.selected_account_id = get_default_account_id()
|
||||
|
||||
selected_account_id = self.accounts
|
||||
wm.selected_account_id = selected_account_id
|
||||
active_workspace = get_active_workspace(wm.selected_account_id)
|
||||
if active_workspace:
|
||||
wm.selected_workspace.id = active_workspace["id"]
|
||||
wm.selected_workspace.name = active_workspace["name"]
|
||||
else:
|
||||
from .account_selection_dialog import update_workspaces_list
|
||||
|
||||
# Fetch workspaces from server
|
||||
for id, name in get_workspaces(selected_account_id):
|
||||
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
||||
workspace.id = id
|
||||
workspace.name = name
|
||||
selected_workspace_id = self.workspaces
|
||||
wm.selected_workspace_id = selected_workspace_id
|
||||
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
|
||||
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
|
||||
selected_account_id, workspace_id=selected_workspace_id
|
||||
wm.selected_account_id, wm.selected_workspace.id
|
||||
)
|
||||
|
||||
for name, role, updated, id, can_receive in projects:
|
||||
@@ -237,31 +154,39 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
|
||||
# Account selection
|
||||
row = layout.row()
|
||||
if wm.selected_account_id != "NO_ACCOUNTS":
|
||||
row.prop(self, "accounts", text="")
|
||||
add_account_button_text = (
|
||||
"Sign In" if wm.selected_account_id == "NO_ACCOUNTS" else ""
|
||||
)
|
||||
add_account_button_icon = (
|
||||
"WORLD" if wm.selected_account_id == "NO_ACCOUNTS" else "ADD"
|
||||
)
|
||||
row.operator(
|
||||
"speckle.add_account",
|
||||
icon=add_account_button_icon,
|
||||
text=add_account_button_text,
|
||||
)
|
||||
|
||||
if wm.selected_account_id == "NO_ACCOUNTS":
|
||||
row.operator("speckle.add_account", icon="WORLD", text="Sign In")
|
||||
|
||||
# if no accounts then don't show workspaces or projects list
|
||||
if wm.selected_account_id != "NO_ACCOUNTS":
|
||||
account = get_account_from_id(wm.selected_account_id)
|
||||
|
||||
row.operator(
|
||||
"speckle.account_selection_dialog",
|
||||
icon="USER",
|
||||
text=f"{account.userInfo.name} - {account.userInfo.email} - {account.serverInfo.url}",
|
||||
)
|
||||
# Workspace selection
|
||||
row = layout.row()
|
||||
if wm.selected_workspace_id != "NO_WORKSPACES":
|
||||
row.prop(self, "workspaces", text="")
|
||||
row.operator(
|
||||
"speckle.workspace_selection_dialog",
|
||||
icon="WORKSPACE",
|
||||
text=wm.selected_workspace.name,
|
||||
)
|
||||
|
||||
# Search field
|
||||
row = layout.row(align=True)
|
||||
row.prop(self, "search_query", icon="VIEWZOOM", text="")
|
||||
row.operator("speckle.add_project_by_url", icon="LINKED", text="")
|
||||
# add project by url button
|
||||
split = row.split()
|
||||
split.operator("speckle.add_project_by_url", icon="LINKED", text="")
|
||||
# create project button
|
||||
# hide if in load mode
|
||||
if wm.ui_mode != "LOAD":
|
||||
split = row.split()
|
||||
split.operator("speckle.create_project", icon="ADD", text="")
|
||||
split.enabled = wm.can_create_project_in_workspace
|
||||
|
||||
layout.template_list(
|
||||
"SPECKLE_UL_projects_list",
|
||||
@@ -272,15 +197,3 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
|
||||
"project_index",
|
||||
)
|
||||
layout.separator()
|
||||
|
||||
|
||||
def register() -> None:
|
||||
bpy.utils.register_class(speckle_project)
|
||||
bpy.utils.register_class(SPECKLE_UL_projects_list)
|
||||
bpy.utils.register_class(SPECKLE_OT_project_selection_dialog)
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
bpy.utils.unregister_class(SPECKLE_OT_project_selection_dialog)
|
||||
bpy.utils.unregister_class(SPECKLE_UL_projects_list)
|
||||
bpy.utils.unregister_class(speckle_project)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import bpy
|
||||
from typing import List
|
||||
from bpy.types import Operator, Context, Object
|
||||
from bpy.props import EnumProperty, StringProperty
|
||||
from bpy.props import EnumProperty
|
||||
from ..utils.model_card_utils import update_model_card_objects
|
||||
from ..utils.account_manager import can_create_version
|
||||
|
||||
|
||||
class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||
@@ -11,6 +13,7 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||
|
||||
bl_idname = "speckle.selection_filter_dialog"
|
||||
bl_label = "Select Objects"
|
||||
bl_description = "Select objects to publish"
|
||||
|
||||
selection_type: EnumProperty(
|
||||
name="Selection",
|
||||
@@ -20,44 +23,49 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||
default="SELECTION",
|
||||
) # type: ignore
|
||||
|
||||
project_name: StringProperty(
|
||||
name="Project Name", description="Name of the selected project", default=""
|
||||
model_card_id: bpy.props.StringProperty(
|
||||
name="Model Card ID",
|
||||
description="This is used to indicate the function is called from a model card",
|
||||
default="",
|
||||
) # type: ignore
|
||||
|
||||
project_id: StringProperty(
|
||||
name="Project ID", description="ID of the selected project", default=""
|
||||
) # type: ignore
|
||||
|
||||
model_name: StringProperty(
|
||||
name="Model Name", description="Name of the selected model", default=""
|
||||
) # type: ignore
|
||||
|
||||
model_id: StringProperty(
|
||||
name="Model ID", description="ID of the selected model", default=""
|
||||
version_message: bpy.props.StringProperty(
|
||||
name="Version Message",
|
||||
description="Message to be used for the version",
|
||||
default="",
|
||||
) # type: ignore
|
||||
|
||||
def execute(self, context: Context) -> set:
|
||||
model_card = context.scene.speckle_state.model_cards.add()
|
||||
model_card.project_name = self.project_name
|
||||
model_card.model_name = self.model_name
|
||||
model_card.model_id = self.model_id
|
||||
model_card.project_id = self.project_id
|
||||
model_card.is_publish = True
|
||||
wm = context.window_manager
|
||||
wm.speckle_objects.clear()
|
||||
user_selection = context.selected_objects
|
||||
if self.model_card_id != "":
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
update_model_card_objects(model_card, user_selection)
|
||||
self.report({"INFO"}, "Selection updated")
|
||||
|
||||
selected_objects: list[Object] = context.selected_objects
|
||||
total_selected: int = len(selected_objects)
|
||||
object_types: dict[str, int] = {}
|
||||
for obj in selected_objects:
|
||||
if obj.type not in object_types:
|
||||
object_types[obj.type] = 1
|
||||
else:
|
||||
object_types[obj.type] += 1
|
||||
# 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"}
|
||||
|
||||
summary: str = f"{total_selected} objects - "
|
||||
for obj_type, count in object_types.items():
|
||||
summary += f"{obj_type}: {count}, "
|
||||
# Call the publish operator
|
||||
bpy.ops.speckle.model_card_publish(
|
||||
model_card_id=self.model_card_id, version_message=self.version_message
|
||||
)
|
||||
|
||||
model_card.selection_summary = summary.strip()
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
for sel in user_selection:
|
||||
obj = wm.speckle_objects.add()
|
||||
obj.name = sel.name
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: bpy.types.Event) -> set:
|
||||
@@ -65,11 +73,21 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||
|
||||
def draw(self, context: Context):
|
||||
layout = self.layout
|
||||
wm = context.window_manager
|
||||
|
||||
layout.label(text=f"Project: {self.project_name}")
|
||||
layout.label(text=f"Model: {self.model_name}")
|
||||
project_name = wm.selected_project_name
|
||||
model_name = wm.selected_model_name
|
||||
if self.model_card_id != "":
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
project_name = model_card.project_name
|
||||
model_name = model_card.model_name
|
||||
|
||||
layout.prop(self, "selection_type")
|
||||
layout.label(text=f"Project: {project_name}")
|
||||
layout.label(text=f"Model: {model_name}")
|
||||
|
||||
# layout.prop(self, "selection_type")
|
||||
layout.separator()
|
||||
|
||||
selected_objects: List[Object] = context.selected_objects
|
||||
@@ -95,6 +113,14 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
|
||||
|
||||
layout.separator()
|
||||
|
||||
if self.model_card_id != "":
|
||||
layout.label(text="Version Message")
|
||||
layout.prop(self, "version_message", text="")
|
||||
layout.label(
|
||||
text="New version will be published after updating selection",
|
||||
icon="INFO_LARGE",
|
||||
)
|
||||
|
||||
def get_icon_for_type(self, obj_type: str) -> str:
|
||||
icon_map: dict[str, str] = {
|
||||
"MESH": "OUTLINER_OB_MESH",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 446 B |
@@ -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")
|
||||
@@ -3,17 +3,6 @@ from bpy.types import UILayout, Context, PropertyGroup, Event
|
||||
from ..utils.version_manager import get_versions_for_model, get_latest_version
|
||||
|
||||
|
||||
class speckle_version(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing version information
|
||||
"""
|
||||
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
message: bpy.props.StringProperty(name="Message") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
source_app: bpy.props.StringProperty(name="Source") # type: ignore
|
||||
|
||||
|
||||
class SPECKLE_UL_versions_list(bpy.types.UIList):
|
||||
"""
|
||||
UIList for displaying a list of Speckle versions
|
||||
@@ -46,10 +35,7 @@ class SPECKLE_UL_versions_list(bpy.types.UIList):
|
||||
class SPECKLE_OT_version_selection_dialog(bpy.types.Operator):
|
||||
bl_idname = "speckle.version_selection_dialog"
|
||||
bl_label = "Select Version"
|
||||
|
||||
search_query: bpy.props.StringProperty( # type: ignore
|
||||
name="Search", description="Search a project", default=""
|
||||
)
|
||||
bl_description = "Select a model version to load. Default is the latest version. You can also select a specific version."
|
||||
|
||||
version_index: bpy.props.IntProperty(name="Model Index", default=0) # type: ignore
|
||||
|
||||
@@ -67,16 +53,20 @@ class SPECKLE_OT_version_selection_dialog(bpy.types.Operator):
|
||||
default="LATEST",
|
||||
)
|
||||
|
||||
model_card_id: bpy.props.StringProperty(
|
||||
name="Model Card ID",
|
||||
description="This is used to indicate the function is called from a model card",
|
||||
default="",
|
||||
) # type: ignore
|
||||
|
||||
def update_versions_list(self, context: Context) -> None:
|
||||
wm = context.window_manager
|
||||
wm.speckle_versions.clear()
|
||||
|
||||
search = self.search_query if self.search_query.strip() else None
|
||||
versions = get_versions_for_model(
|
||||
account_id=wm.selected_account_id,
|
||||
project_id=wm.selected_project_id,
|
||||
model_id=wm.selected_model_id,
|
||||
search=search,
|
||||
)
|
||||
|
||||
for id, message, updated in versions:
|
||||
@@ -113,17 +103,49 @@ class SPECKLE_OT_version_selection_dialog(bpy.types.Operator):
|
||||
else:
|
||||
print(f"Invalid version index {self.version_index}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
wm.selected_version_id = version_id_to_store
|
||||
wm.selected_version_load_option = self.load_option
|
||||
|
||||
print(f"Selected version: {version_id_to_store} (Option: {self.load_option})")
|
||||
if self.model_card_id != "":
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
if model_card is None:
|
||||
self.report({"ERROR"}, f"Model card '{self.model_card_id}' not found")
|
||||
return {"CANCELLED"}
|
||||
|
||||
model_card.load_option = self.load_option
|
||||
model_card.version_id = version_id_to_store
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Model card updated: Selected version: {model_card.version_id}, Option: {self.load_option}",
|
||||
)
|
||||
bpy.ops.speckle.model_card_load(model_card_id=self.model_card_id)
|
||||
context.area.tag_redraw()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
wm.selected_version_load_option = self.load_option
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Selected version: {version_id_to_store} (Option: {self.load_option})",
|
||||
)
|
||||
|
||||
context.area.tag_redraw()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
if self.model_card_id != "":
|
||||
wm = context.window_manager
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
self.load_option = model_card.load_option
|
||||
wm.selected_account_id = model_card.account_id
|
||||
wm.selected_project_id = model_card.project_id
|
||||
wm.selected_model_id = model_card.model_id
|
||||
wm.selected_version_id = model_card.version_id
|
||||
|
||||
self.update_versions_list(context)
|
||||
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
@@ -131,15 +153,25 @@ class SPECKLE_OT_version_selection_dialog(bpy.types.Operator):
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
wm = context.window_manager
|
||||
layout.label(text=f"Project: {wm.selected_project_name}")
|
||||
layout.label(text=f"Model: {wm.selected_model_name}")
|
||||
project_name = wm.selected_project_name
|
||||
model_name = wm.selected_model_name
|
||||
if self.model_card_id != "":
|
||||
model_card = context.scene.speckle_state.get_model_card_by_id(
|
||||
self.model_card_id
|
||||
)
|
||||
project_name = model_card.project_name
|
||||
model_name = model_card.model_name
|
||||
|
||||
layout.prop(self, "load_option", expand=True)
|
||||
layout.label(text=f"Project: {project_name}")
|
||||
layout.label(text=f"Model: {model_name}")
|
||||
|
||||
layout.prop(
|
||||
self,
|
||||
"load_option",
|
||||
expand=True,
|
||||
)
|
||||
|
||||
if self.load_option == "SPECIFIC":
|
||||
# Search field
|
||||
row = layout.row(align=True)
|
||||
row.prop(self, "search_query", icon="VIEWZOOM", text="")
|
||||
# Versions UIList
|
||||
layout.template_list(
|
||||
"SPECKLE_UL_versions_list",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import bpy
|
||||
from bpy.types import Context, UILayout, Event, PropertyGroup
|
||||
from typing import List, Tuple
|
||||
from ..utils.account_manager import get_workspaces, speckle_workspace
|
||||
from ..utils.project_manager import get_projects_for_account
|
||||
from ..utils.account_manager import can_create_project_in_workspace
|
||||
|
||||
|
||||
class SPECKLE_UL_workspaces_list(bpy.types.UIList):
|
||||
"""
|
||||
UIList for workspaces
|
||||
"""
|
||||
|
||||
def draw_item(
|
||||
self,
|
||||
context: Context,
|
||||
layout: UILayout,
|
||||
data: PropertyGroup,
|
||||
item: PropertyGroup,
|
||||
icon: str,
|
||||
active_data: PropertyGroup,
|
||||
active_propname: str,
|
||||
) -> None:
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
row = layout.row(align=True)
|
||||
row.label(text=item.name)
|
||||
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text=item.name)
|
||||
|
||||
|
||||
class SPECKLE_OT_workspace_selection_dialog(bpy.types.Operator):
|
||||
"""
|
||||
Operator for selecting a workspace
|
||||
"""
|
||||
|
||||
bl_idname = "speckle.workspace_selection_dialog"
|
||||
bl_label = "Select Workspace"
|
||||
bl_description = "Select a workspace to load projects from"
|
||||
|
||||
workspace_index: bpy.props.IntProperty(name="Workspace Index", default=0) # type: ignore
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
wm = context.window_manager
|
||||
wm.speckle_workspaces.clear()
|
||||
workspaces: List[Tuple[str, str]] = get_workspaces(wm.selected_account_id)
|
||||
current_workspace_index = 0
|
||||
for i, (id, name) in enumerate(workspaces):
|
||||
workspace: speckle_workspace = wm.speckle_workspaces.add()
|
||||
workspace.id = id
|
||||
workspace.name = name
|
||||
if id == wm.selected_workspace.id:
|
||||
current_workspace_index = i
|
||||
self.workspace_index = current_workspace_index
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout: UILayout = self.layout
|
||||
wm = context.window_manager
|
||||
layout.label(text=f"Selected Workspace: {wm.selected_workspace.name}")
|
||||
layout.template_list(
|
||||
"SPECKLE_UL_workspaces_list",
|
||||
"",
|
||||
context.window_manager,
|
||||
"speckle_workspaces",
|
||||
self,
|
||||
"workspace_index",
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
wm = context.window_manager
|
||||
if 0 <= self.workspace_index < len(wm.speckle_workspaces):
|
||||
selected_workspace = wm.speckle_workspaces[self.workspace_index]
|
||||
wm.selected_workspace.id = selected_workspace.id
|
||||
wm.selected_workspace.name = selected_workspace.name
|
||||
update_projects_list(context)
|
||||
context.area.tag_redraw()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def update_projects_list(context):
|
||||
"""Update projects list when workspace changes"""
|
||||
|
||||
wm = context.window_manager
|
||||
wm.speckle_projects.clear()
|
||||
|
||||
# get projects for the selected account and workspace
|
||||
projects = get_projects_for_account(
|
||||
wm.selected_account_id, wm.selected_workspace.id
|
||||
)
|
||||
|
||||
for name, role, updated, id, can_receive in projects:
|
||||
project = wm.speckle_projects.add()
|
||||
project.name = name
|
||||
project.role = role
|
||||
project.updated = updated
|
||||
project.id = id
|
||||
project.can_receive = can_receive
|
||||
|
||||
# Update can_create_project_in_workspace flag
|
||||
wm.can_create_project_in_workspace = can_create_project_in_workspace(
|
||||
wm.selected_account_id, wm.selected_workspace.id
|
||||
)
|
||||
print(f"Workspace changed to: {wm.selected_workspace.id}")
|
||||
print("Projects list updated")
|
||||
|
||||
context.area.tag_redraw()
|
||||
@@ -1,13 +1,46 @@
|
||||
import bpy
|
||||
from specklepy.api.credentials import get_local_accounts
|
||||
from typing import List, Tuple, Optional
|
||||
from specklepy.core.api.credentials import get_local_accounts
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from urllib.parse import urlparse
|
||||
from specklepy.core.api.credentials import Account
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.wrapper import StreamWrapper
|
||||
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.core.api.wrapper import StreamWrapper
|
||||
from .misc import strip_non_ascii
|
||||
|
||||
|
||||
class SpeckleClientCache:
|
||||
def __init__(self):
|
||||
self._clients: Dict[str, SpeckleClient] = {}
|
||||
|
||||
def get_client(self, account_id: str) -> SpeckleClient:
|
||||
# Check cache first
|
||||
if account_id in self._clients:
|
||||
print(f"[Cache HIT] Using cached client for account {account_id}")
|
||||
return self._clients[account_id]
|
||||
|
||||
# Create new client if needed
|
||||
print(f"[Cache MISS] Creating new client for account {account_id}")
|
||||
account = get_account_from_id(account_id)
|
||||
if not account:
|
||||
raise ValueError(f"No account found for ID: {account_id}")
|
||||
|
||||
url = account.serverInfo.url
|
||||
use_ssl = urlparse(url).scheme.lower() != "http"
|
||||
client = SpeckleClient(host=url, use_ssl=use_ssl)
|
||||
client.authenticate_with_account(account)
|
||||
self._clients[account_id] = client
|
||||
return client
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached clients."""
|
||||
print("[Cache] Clearing all cached clients")
|
||||
self._clients.clear()
|
||||
|
||||
|
||||
# Global cache instance
|
||||
_client_cache = SpeckleClientCache()
|
||||
|
||||
|
||||
class speckle_account(bpy.types.PropertyGroup):
|
||||
id: bpy.props.StringProperty() # type: ignore
|
||||
user_name: bpy.props.StringProperty() # type: ignore
|
||||
@@ -47,35 +80,41 @@ def get_workspaces(account_id: str) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
retrieves the workspaces for a given account ID
|
||||
"""
|
||||
account = next((acc for acc in get_local_accounts() if acc.id == account_id), None)
|
||||
if not account:
|
||||
print("No accounts found > No workspaces!")
|
||||
|
||||
try:
|
||||
# Get client from cache
|
||||
client = _client_cache.get_client(account_id)
|
||||
|
||||
workspaces_enabled = client.server.get().workspaces.workspaces_enabled
|
||||
|
||||
if workspaces_enabled:
|
||||
workspaces = client.active_user.get_workspaces().items
|
||||
|
||||
workspace_list = [
|
||||
(ws.id, strip_non_ascii(ws.name))
|
||||
for ws in workspaces
|
||||
if ws.creation_state is None or ws.creation_state.completed
|
||||
]
|
||||
|
||||
active_workspace = client.active_user.get_active_workspace()
|
||||
default_workspace_id = (
|
||||
active_workspace.id
|
||||
if active_workspace
|
||||
else (workspaces[0].id if workspaces else None)
|
||||
)
|
||||
|
||||
if default_workspace_id:
|
||||
result = reorder_tuple(workspace_list, default_workspace_id)
|
||||
else:
|
||||
result = workspace_list
|
||||
else:
|
||||
result = []
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error in get_workspaces: {str(e)}")
|
||||
_client_cache.clear() # Clear cache on error
|
||||
return [("", "")]
|
||||
client = SpeckleClient(host=account.serverInfo.url)
|
||||
client.authenticate_with_account(account)
|
||||
workspaces_enabled = client.server.get().workspaces.workspaces_enabled
|
||||
|
||||
if workspaces_enabled:
|
||||
workspaces = client.active_user.get_workspaces().items
|
||||
workspace_list = [
|
||||
(ws.id, strip_non_ascii(ws.name))
|
||||
for ws in workspaces
|
||||
if ws.creation_state is None or ws.creation_state.completed
|
||||
]
|
||||
personal_projects_text = "Personal Projects (Legacy)"
|
||||
else:
|
||||
workspace_list = []
|
||||
personal_projects_text = "Personal Projects"
|
||||
# Append Personal Projects do workspace dropdown
|
||||
if client.active_user.can_create_personal_projects().authorized:
|
||||
workspace_list.append(("personal", personal_projects_text))
|
||||
|
||||
print("Workspaces added")
|
||||
return (
|
||||
reorder_tuple(workspace_list, get_default_workspace_id(account_id))
|
||||
if workspaces_enabled
|
||||
else workspace_list
|
||||
)
|
||||
|
||||
|
||||
def get_default_account_id() -> Optional[str]:
|
||||
@@ -98,18 +137,20 @@ def get_server_url_by_account_id(account_id: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def get_default_workspace_id(account_id: str) -> Optional[str]:
|
||||
def get_active_workspace(account_id: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
retrieves the ID of the default workspace for a given account ID
|
||||
"""
|
||||
account = next((acc for acc in get_local_accounts() if acc.id == account_id), None)
|
||||
client = SpeckleClient(host=account.serverInfo.url)
|
||||
client.authenticate_with_account(account)
|
||||
return (
|
||||
client.active_user.get_active_workspace().id
|
||||
if client.active_user.get_active_workspace()
|
||||
else "personal"
|
||||
)
|
||||
try:
|
||||
client = _client_cache.get_client(account_id)
|
||||
active_workspace = client.active_user.get_active_workspace()
|
||||
if active_workspace:
|
||||
return {"id": active_workspace.id, "name": active_workspace.name}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error in get_active_workspace: {str(e)}")
|
||||
_client_cache.clear()
|
||||
return None
|
||||
|
||||
|
||||
def get_account_from_id(account_id: str) -> Optional[Account]:
|
||||
@@ -138,8 +179,8 @@ def get_project_from_url(
|
||||
"""
|
||||
try:
|
||||
wrapper = StreamWrapper(url)
|
||||
client = wrapper.get_client()
|
||||
client.authenticate_with_account(wrapper.get_account())
|
||||
account = wrapper.get_account()
|
||||
client = _client_cache.get_client(account.id)
|
||||
|
||||
# get the stream_id (project_id) from the wrapper
|
||||
if not wrapper.stream_id:
|
||||
@@ -182,15 +223,16 @@ def get_model_details_by_wrapper(
|
||||
model = client.model.get(model_id, project_id)
|
||||
model_name = model.name
|
||||
load_option = "LATEST" if not wrapper.commit_id else "SPECIFIC"
|
||||
version_id = (
|
||||
wrapper.commit_id
|
||||
if wrapper.commit_id
|
||||
else client.version.get_versions(
|
||||
if wrapper.commit_id:
|
||||
version_id = wrapper.commit_id
|
||||
else:
|
||||
versions = client.version.get_versions(
|
||||
wrapper.model_id, wrapper.stream_id, limit=1
|
||||
)
|
||||
.items[0]
|
||||
.id
|
||||
)
|
||||
if versions.items and len(versions.items) > 0:
|
||||
version_id = versions.items[0].id
|
||||
else:
|
||||
version_id = ""
|
||||
return (
|
||||
account_id,
|
||||
project_id,
|
||||
@@ -220,19 +262,64 @@ def can_load(client, project) -> Tuple[bool, str]:
|
||||
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:
|
||||
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, ""
|
||||
else:
|
||||
message = getattr(permissions.can_create_version, "message", None)
|
||||
return (
|
||||
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:
|
||||
error_msg = f"Failed to check permissions: {str(e)}"
|
||||
print(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def can_create_project_in_workspace(account_id: str, workspace_id: str) -> bool:
|
||||
"""
|
||||
Check if the user can create a project in the specified workspace.
|
||||
"""
|
||||
try:
|
||||
client = _client_cache.get_client(account_id)
|
||||
|
||||
try:
|
||||
workspace = client.workspace.get(workspace_id)
|
||||
return workspace.permissions.can_create_project.authorized
|
||||
except Exception as e:
|
||||
print(f"Failed to get workspace: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error in can_create_project_in_workspace: {str(e)}")
|
||||
_client_cache.clear() # Clear cache on error
|
||||
return False
|
||||
|
||||
@@ -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,7 @@
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
|
||||
def format_relative_time(timestamp) -> str:
|
||||
"""
|
||||
convert UTC timestamp to local timezone and return relative time string
|
||||
@@ -46,6 +47,7 @@ def format_role(role: str) -> str:
|
||||
split_role = role.split(":")
|
||||
return f"{split_role[1]}"
|
||||
|
||||
|
||||
def strip_non_ascii(text):
|
||||
# Keep English letters, digits, spaces and basic punctuation
|
||||
return re.sub(r'[^a-zA-Z0-9\s.,!?]', '', text)
|
||||
return re.sub(r"[^a-zA-Z0-9\s.,!?]", "", text)
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import bpy
|
||||
from bpy.types import Context
|
||||
|
||||
from ..utils.property_groups import speckle_model_card
|
||||
|
||||
|
||||
def find_layer_collection(layer_collection, collection_name):
|
||||
"""
|
||||
Recursively find a layer collection by collection name
|
||||
"""
|
||||
if layer_collection.collection.name == collection_name:
|
||||
return layer_collection
|
||||
for child in layer_collection.children:
|
||||
result = find_layer_collection(child, collection_name)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def get_object_by_application_id(app_id: str):
|
||||
"""
|
||||
Find a Blender object by its applicationId stored in custom property
|
||||
"""
|
||||
if not app_id:
|
||||
return None
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if "applicationId" in obj and obj["applicationId"] == app_id:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def get_objects_by_application_ids(app_ids: list):
|
||||
"""
|
||||
Find multiple Blender objects by their applicationIds
|
||||
"""
|
||||
if not app_ids:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for obj in bpy.data.objects:
|
||||
if "applicationId" in obj and obj["applicationId"] in app_ids:
|
||||
result[obj["applicationId"]] = obj
|
||||
return result
|
||||
|
||||
|
||||
def get_collection_by_application_id(app_id: str):
|
||||
"""
|
||||
Find a Blender collection by its applicationId stored in custom property
|
||||
"""
|
||||
if not app_id:
|
||||
return None
|
||||
|
||||
for collection in bpy.data.collections:
|
||||
if "applicationId" in collection and collection["applicationId"] == app_id:
|
||||
return collection
|
||||
return None
|
||||
|
||||
|
||||
def get_collection_identifier(blender_col: bpy.types.Collection) -> str:
|
||||
"""
|
||||
Get collection identifier: applicationId if exists, fallback to name
|
||||
"""
|
||||
if "applicationId" in blender_col and blender_col["applicationId"]:
|
||||
return blender_col["applicationId"]
|
||||
return blender_col.name
|
||||
|
||||
|
||||
def find_collection_by_identifier(identifier: str):
|
||||
"""
|
||||
Find collection by identifier: try applicationId first, then name
|
||||
"""
|
||||
# first try to find by applicationId
|
||||
collection = get_collection_by_application_id(identifier)
|
||||
if collection:
|
||||
return collection
|
||||
|
||||
# fallback to name-based lookup
|
||||
return bpy.data.collections.get(identifier)
|
||||
|
||||
|
||||
def capture_modifier_data(blender_obj: bpy.types.Object) -> list:
|
||||
"""
|
||||
Capture modifier data from a Blender object as dictionaries
|
||||
"""
|
||||
modifiers_data = []
|
||||
for modifier in blender_obj.modifiers:
|
||||
modifier_data = {
|
||||
"name": modifier.name,
|
||||
"type": modifier.type,
|
||||
"show_viewport": modifier.show_viewport,
|
||||
"show_render": modifier.show_render,
|
||||
"show_in_editmode": modifier.show_in_editmode,
|
||||
"show_on_cage": modifier.show_on_cage,
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
# Capture modifier-specific properties
|
||||
for prop_name in modifier.bl_rna.properties.keys():
|
||||
if prop_name in [
|
||||
"rna_type",
|
||||
"name",
|
||||
"type",
|
||||
"show_viewport",
|
||||
"show_render",
|
||||
"show_in_editmode",
|
||||
"show_on_cage",
|
||||
]:
|
||||
continue
|
||||
try:
|
||||
if hasattr(modifier, prop_name):
|
||||
prop_value = getattr(modifier, prop_name)
|
||||
# Handle different property types
|
||||
if isinstance(prop_value, (int, float, bool, str)):
|
||||
modifier_data["properties"][prop_name] = prop_value
|
||||
elif hasattr(prop_value, "name"): # Object references
|
||||
modifier_data["properties"][prop_name] = prop_value.name
|
||||
elif (
|
||||
hasattr(prop_value, "__len__") and len(prop_value) <= 4
|
||||
): # Vectors/colors
|
||||
modifier_data["properties"][prop_name] = list(prop_value)
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
modifiers_data.append(modifier_data)
|
||||
|
||||
return modifiers_data
|
||||
|
||||
|
||||
def has_visibility_modifications(obj: bpy.types.Object) -> bool:
|
||||
"""Check if object has non-default visibility settings"""
|
||||
return obj.hide_viewport or obj.hide_select or obj.hide_render or obj.hide_get()
|
||||
|
||||
|
||||
def has_modifier_modifications(obj: bpy.types.Object) -> bool:
|
||||
"""Check if object has any modifiers applied"""
|
||||
return hasattr(obj, "modifiers") and len(obj.modifiers) > 0
|
||||
|
||||
|
||||
def has_collection_visibility_modifications(layer_col, collection) -> bool:
|
||||
"""Check if collection has non-default visibility settings"""
|
||||
return (
|
||||
layer_col.hide_viewport
|
||||
or collection.hide_select
|
||||
or collection.hide_render
|
||||
or layer_col.exclude
|
||||
)
|
||||
|
||||
|
||||
def collect_objects_with_properties(
|
||||
model_card: speckle_model_card,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Collect objects and collections with their current properties before deletion
|
||||
Only stores data for objects that have been modified from defaults
|
||||
"""
|
||||
collected_data = {"objects": {}, "collections": {}}
|
||||
|
||||
# Collect object properties (only for modified objects)
|
||||
for s_obj in model_card.objects:
|
||||
blender_obj = get_object_by_application_id(s_obj.applicationId)
|
||||
if blender_obj:
|
||||
obj_data = {}
|
||||
|
||||
# Only collect visibility if modified from defaults
|
||||
if has_visibility_modifications(blender_obj):
|
||||
obj_data["visibility"] = {
|
||||
"hide_get": blender_obj.hide_get(),
|
||||
"hide_viewport": blender_obj.hide_viewport,
|
||||
"hide_select": blender_obj.hide_select,
|
||||
"hide_render": blender_obj.hide_render,
|
||||
}
|
||||
|
||||
# Only collect modifiers if object has any
|
||||
if has_modifier_modifications(blender_obj):
|
||||
obj_data["modifiers"] = capture_modifier_data(blender_obj)
|
||||
|
||||
# Only store object data if it has modifications
|
||||
if obj_data:
|
||||
collected_data["objects"][s_obj.applicationId] = obj_data
|
||||
|
||||
# Collect collection properties (only for modified collections)
|
||||
for s_col in model_card.collections:
|
||||
# try to find collection by applicationId first, then fallback to name
|
||||
blender_col = None
|
||||
if s_col.applicationId:
|
||||
blender_col = get_collection_by_application_id(s_col.applicationId)
|
||||
if not blender_col:
|
||||
blender_col = bpy.data.collections.get(s_col.name)
|
||||
|
||||
if blender_col:
|
||||
view_layer = bpy.context.view_layer
|
||||
if view_layer:
|
||||
layer_col = find_layer_collection(
|
||||
view_layer.layer_collection, blender_col.name
|
||||
)
|
||||
if layer_col and has_collection_visibility_modifications(
|
||||
layer_col, blender_col
|
||||
):
|
||||
# use collection identifier as key
|
||||
collection_id = get_collection_identifier(blender_col)
|
||||
collected_data["collections"][collection_id] = {
|
||||
"hide_viewport": layer_col.hide_viewport,
|
||||
"hide_select": layer_col.collection.hide_select,
|
||||
"hide_render": layer_col.collection.hide_render,
|
||||
"exclude_from_view_layer": layer_col.exclude,
|
||||
}
|
||||
|
||||
return collected_data
|
||||
|
||||
|
||||
def transfer_object_properties(
|
||||
new_obj: bpy.types.Object, old_obj_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Transfer visibility and modifiers from old object data to new object
|
||||
Handles sparse data gracefully - applies defaults when data is missing
|
||||
"""
|
||||
# Transfer visibility settings (if any were modified)
|
||||
visibility = old_obj_data.get("visibility")
|
||||
if visibility:
|
||||
new_obj.hide_set(visibility.get("hide_get", False))
|
||||
new_obj.hide_viewport = visibility.get("hide_viewport", False)
|
||||
new_obj.hide_select = visibility.get("hide_select", False)
|
||||
new_obj.hide_render = visibility.get("hide_render", False)
|
||||
# If no visibility data, object keeps defaults (all False)
|
||||
|
||||
# Transfer modifiers (if any were present)
|
||||
old_modifiers = old_obj_data.get("modifiers")
|
||||
if old_modifiers and hasattr(new_obj, "modifiers"):
|
||||
# Clear existing modifiers
|
||||
new_obj.modifiers.clear()
|
||||
|
||||
# Transfer each modifier
|
||||
for modifier_data in old_modifiers:
|
||||
recreate_modifier_from_data(new_obj, modifier_data)
|
||||
# If no modifier data, object keeps default (no modifiers)
|
||||
|
||||
|
||||
def transfer_collection_properties(
|
||||
new_col: bpy.types.Collection, old_col_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Transfer visibility properties from old collection data to new collection
|
||||
Handles sparse data gracefully - applies defaults when data is missing
|
||||
"""
|
||||
view_layer = bpy.context.view_layer
|
||||
if view_layer:
|
||||
layer_col = find_layer_collection(view_layer.layer_collection, new_col.name)
|
||||
if layer_col:
|
||||
# Only apply properties if collection had modifications
|
||||
# (otherwise it keeps defaults: all False)
|
||||
layer_col.hide_viewport = old_col_data.get("hide_viewport", False)
|
||||
layer_col.collection.hide_select = old_col_data.get("hide_select", False)
|
||||
layer_col.collection.hide_render = old_col_data.get("hide_render", False)
|
||||
layer_col.exclude = old_col_data.get("exclude_from_view_layer", False)
|
||||
|
||||
|
||||
def recreate_modifier_from_data(
|
||||
new_obj: bpy.types.Object, modifier_data: Dict[str, Any]
|
||||
) -> Optional[bpy.types.Modifier]:
|
||||
"""
|
||||
Recreate a modifier from captured data
|
||||
"""
|
||||
try:
|
||||
# Validate modifier data
|
||||
if not modifier_data.get("type") or not modifier_data.get("name"):
|
||||
print(f"Invalid modifier data: {modifier_data}")
|
||||
return None
|
||||
|
||||
# Create new modifier
|
||||
new_modifier = new_obj.modifiers.new(
|
||||
modifier_data["name"], modifier_data["type"]
|
||||
)
|
||||
|
||||
# Set visibility properties
|
||||
new_modifier.show_viewport = modifier_data.get("show_viewport", True)
|
||||
new_modifier.show_render = modifier_data.get("show_render", True)
|
||||
new_modifier.show_in_editmode = modifier_data.get("show_in_editmode", True)
|
||||
new_modifier.show_on_cage = modifier_data.get("show_on_cage", False)
|
||||
|
||||
# Set modifier-specific properties
|
||||
for prop_name, prop_value in modifier_data.get("properties", {}).items():
|
||||
try:
|
||||
if hasattr(new_modifier, prop_name):
|
||||
current_value = getattr(new_modifier, prop_name)
|
||||
# Handle object references
|
||||
if hasattr(current_value, "name") and isinstance(prop_value, str):
|
||||
referenced_obj = bpy.data.objects.get(prop_value)
|
||||
if referenced_obj:
|
||||
setattr(new_modifier, prop_name, referenced_obj)
|
||||
else:
|
||||
setattr(new_modifier, prop_name, prop_value)
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
|
||||
return new_modifier
|
||||
except Exception as e:
|
||||
print(f"Error recreating modifier {modifier_data.get('name', 'unknown')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def update_model_card_objects(
|
||||
model_card: speckle_model_card,
|
||||
converted_objects: Dict[str, bpy.types.Object | bpy.types.Collection],
|
||||
old_properties: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
):
|
||||
"""
|
||||
Update model card with new objects and apply properties from old objects if provided
|
||||
"""
|
||||
# Clear model card objects
|
||||
model_card.objects.clear()
|
||||
model_card.collections.clear()
|
||||
|
||||
# Convert list to dictionary if needed
|
||||
if isinstance(converted_objects, list):
|
||||
converted_objects = {obj.name: obj for obj in converted_objects}
|
||||
|
||||
# Using a set keeps lookup O(1)
|
||||
object_names = set()
|
||||
collection_names = set()
|
||||
|
||||
for obj in converted_objects.values():
|
||||
# Handle collections
|
||||
if isinstance(obj, bpy.types.Collection):
|
||||
if obj.name in collection_names:
|
||||
continue
|
||||
collection_names.add(obj.name)
|
||||
|
||||
s_col = model_card.collections.add()
|
||||
s_col.name = obj.name
|
||||
s_col.applicationId = obj.get("applicationId", "")
|
||||
|
||||
# apply old collection properties if available (use identifier-based lookup)
|
||||
if old_properties:
|
||||
collection_id = get_collection_identifier(obj)
|
||||
if collection_id in old_properties.get("collections", {}):
|
||||
old_col_data = old_properties["collections"][collection_id]
|
||||
transfer_collection_properties(obj, old_col_data)
|
||||
|
||||
# Handle objects
|
||||
elif isinstance(obj, bpy.types.Object):
|
||||
if obj.name in object_names:
|
||||
continue
|
||||
object_names.add(obj.name)
|
||||
|
||||
s_obj = model_card.objects.add()
|
||||
s_obj.name = obj.name
|
||||
s_obj.applicationId = obj.get("applicationId", "")
|
||||
|
||||
# Apply old object properties if available
|
||||
if (
|
||||
old_properties
|
||||
and s_obj.applicationId
|
||||
and s_obj.applicationId in old_properties.get("objects", {})
|
||||
):
|
||||
old_obj_data = old_properties["objects"][s_obj.applicationId]
|
||||
transfer_object_properties(obj, old_obj_data)
|
||||
|
||||
|
||||
def delete_model_card_objects(model_card: speckle_model_card, context: Context) -> None:
|
||||
"""
|
||||
deletes the model card objects
|
||||
"""
|
||||
# Delete objects directly without requiring selection
|
||||
for obj in model_card.objects:
|
||||
blender_obj = get_object_by_application_id(obj.applicationId)
|
||||
if not blender_obj:
|
||||
continue
|
||||
|
||||
# Remove object from all collections first
|
||||
for collection in blender_obj.users_collection:
|
||||
collection.objects.unlink(blender_obj)
|
||||
|
||||
# Delete the object directly
|
||||
bpy.data.objects.remove(blender_obj)
|
||||
|
||||
# delete model card/currently loaded collections
|
||||
for col in model_card.collections:
|
||||
coll = bpy.data.collections.get(col.name)
|
||||
if not coll:
|
||||
continue
|
||||
# unlink from scenes
|
||||
for scene in bpy.data.scenes:
|
||||
if scene.collection.children.get(coll.name):
|
||||
scene.collection.children.unlink(coll)
|
||||
bpy.data.collections.remove(coll)
|
||||
|
||||
|
||||
def select_model_card_objects(model_card, context: Context):
|
||||
# deselect all objects first
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
# select objects in model card
|
||||
for obj in model_card.objects:
|
||||
blender_obj = get_object_by_application_id(obj.applicationId)
|
||||
if not blender_obj:
|
||||
continue
|
||||
if blender_obj.name in context.view_layer.objects:
|
||||
blender_obj.select_set(True)
|
||||
|
||||
selected = context.selected_objects
|
||||
if selected:
|
||||
context.view_layer.objects.active = selected[0]
|
||||
|
||||
|
||||
def zoom_to_selected_objects(context: Context):
|
||||
"""
|
||||
zooms to the selected objects
|
||||
"""
|
||||
bpy.ops.view3d.view_selected()
|
||||
|
||||
|
||||
def model_card_exists(
|
||||
project_id: str, model_id: str, is_publish: bool, context: Context
|
||||
) -> bool:
|
||||
"""
|
||||
checks if a model card exists
|
||||
"""
|
||||
for model_card in context.scene.speckle_state.model_cards:
|
||||
if (
|
||||
model_card.project_id == project_id
|
||||
and model_card.model_id == model_id
|
||||
and model_card.is_publish == is_publish
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -1,9 +1,8 @@
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.credentials import get_local_accounts, Account
|
||||
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
|
||||
from specklepy.core.api.models.current import Model
|
||||
from typing import List, Tuple, Optional
|
||||
from .misc import format_relative_time, strip_non_ascii
|
||||
from .account_manager import _client_cache
|
||||
|
||||
|
||||
def get_models_for_project(
|
||||
@@ -19,17 +18,12 @@ def get_models_for_project(
|
||||
)
|
||||
return []
|
||||
|
||||
# Get the account info
|
||||
account: Optional[Account] = next(
|
||||
(acc for acc in get_local_accounts() if acc.id == account_id), None
|
||||
)
|
||||
if not account:
|
||||
print(f"Error: Could not find account with ID: {account_id}")
|
||||
# Get cached client
|
||||
client = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
print(f"Error: Could not get client for account: {account_id}")
|
||||
return []
|
||||
|
||||
client = SpeckleClient(host=account.serverInfo.url)
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
try:
|
||||
client.project.get(project_id)
|
||||
except Exception as e:
|
||||
@@ -43,10 +37,16 @@ def get_models_for_project(
|
||||
).items
|
||||
|
||||
return [
|
||||
(strip_non_ascii(model.name), model.id, format_relative_time(model.updated_at))
|
||||
(
|
||||
strip_non_ascii(model.name),
|
||||
model.id,
|
||||
format_relative_time(model.updated_at),
|
||||
)
|
||||
for model in models
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching models: {str(e)}")
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
return []
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.credentials import get_local_accounts
|
||||
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
|
||||
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
|
||||
from typing import List, Tuple, Optional
|
||||
from specklepy.core.api.credentials import Account
|
||||
from .misc import format_relative_time, format_role, strip_non_ascii
|
||||
from .account_manager import can_load
|
||||
from .account_manager import _client_cache
|
||||
|
||||
|
||||
def get_projects_for_account(
|
||||
@@ -14,48 +14,72 @@ def get_projects_for_account(
|
||||
fetches projects for a given account from the Speckle server
|
||||
"""
|
||||
try:
|
||||
# Get the account info
|
||||
accounts: List[Account] = get_local_accounts()
|
||||
account: Optional[Account] = next(
|
||||
(acc for acc in accounts if acc.id == account_id), None
|
||||
)
|
||||
if not account:
|
||||
# Get cached client
|
||||
client = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
print(f"Error: Could not get client for account: {account_id}")
|
||||
return []
|
||||
|
||||
client = SpeckleClient(host=account.serverInfo.url)
|
||||
client.authenticate_with_account(account)
|
||||
# Get account for workspace operations that still need it
|
||||
from specklepy.core.api.credentials import get_local_accounts
|
||||
|
||||
personal_only = workspace_id == "personal"
|
||||
workspace_id_query = None if personal_only else workspace_id
|
||||
|
||||
# set include_implicit_access to True to get all projects
|
||||
filter = UserProjectsFilter(
|
||||
search=search,
|
||||
workspaceId=workspace_id_query,
|
||||
personalOnly=personal_only,
|
||||
include_implicit_access=True,
|
||||
account: Optional[Account] = next(
|
||||
(acc for acc in get_local_accounts() if acc.id == account_id), None
|
||||
)
|
||||
if not account:
|
||||
print(f"Error: Could not find account with ID: {account_id}")
|
||||
return []
|
||||
|
||||
projects = client.active_user.get_projects(limit=10, filter=filter).items
|
||||
try:
|
||||
workspace_resource = WorkspaceResource(
|
||||
account, client.url, client.httpclient, client.server.version()
|
||||
)
|
||||
|
||||
# determine if user can receive from project based on role
|
||||
result = []
|
||||
for project in projects:
|
||||
can_load_permission, _ = can_load(client, project)
|
||||
# create filter with search parameter
|
||||
filter = (
|
||||
WorksaceProjectsFilter(search=search, with_project_role_only=False)
|
||||
if search
|
||||
else None
|
||||
)
|
||||
|
||||
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,
|
||||
projects_with_permissions = (
|
||||
workspace_resource.get_projects_with_permissions(
|
||||
workspace_id=workspace_id, limit=10, filter=filter
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
result = []
|
||||
for project in projects_with_permissions.items:
|
||||
can_load_permission = False
|
||||
|
||||
if hasattr(project, "permissions") and project.permissions:
|
||||
can_load_permission = (
|
||||
hasattr(project.permissions, "can_load")
|
||||
and project.permissions.can_load
|
||||
and project.permissions.can_load.authorized
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
except Exception as workspace_error:
|
||||
print(
|
||||
f"WorkspaceResource failed, falling back to old method: {workspace_error}"
|
||||
)
|
||||
return _get_projects_with_individual_permissions(
|
||||
client, workspace_id, search
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -63,4 +87,45 @@ def get_projects_for_account(
|
||||
error_msg = f"Error: {str(e)}\n"
|
||||
error_msg += f"Traceback:\n{''.join(traceback.format_tb(e.__traceback__))}"
|
||||
print(error_msg)
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
return []
|
||||
|
||||
|
||||
def _get_projects_with_individual_permissions(
|
||||
client: SpeckleClient,
|
||||
workspace_id: str,
|
||||
search: Optional[str] = None,
|
||||
) -> List[Tuple[str, str, str, str, bool]]:
|
||||
"""
|
||||
Fallback helper function to get projects with permissions using individual API calls
|
||||
"""
|
||||
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
|
||||
from .account_manager import can_load
|
||||
|
||||
filter = UserProjectsFilter(
|
||||
search=search,
|
||||
workspaceId=workspace_id,
|
||||
personalOnly=False,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class speckle_project(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing project information
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
role: bpy.props.StringProperty(name="Role") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
can_receive: bpy.props.BoolProperty(name="Can Receive", default=False) # type: ignore
|
||||
|
||||
|
||||
class speckle_model(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing model information
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
|
||||
|
||||
class speckle_version(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing version information
|
||||
"""
|
||||
|
||||
id: bpy.props.StringProperty(name="ID") # type: ignore
|
||||
message: bpy.props.StringProperty(name="Message") # type: ignore
|
||||
updated: bpy.props.StringProperty(name="Updated") # type: ignore
|
||||
source_app: bpy.props.StringProperty(name="Source") # type: ignore
|
||||
|
||||
|
||||
class speckle_object(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing object names and applicationIds
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
|
||||
|
||||
|
||||
class speckle_collection(bpy.types.PropertyGroup):
|
||||
"""
|
||||
PropertyGroup for storing collections
|
||||
"""
|
||||
|
||||
name: bpy.props.StringProperty() # type: ignore
|
||||
applicationId: bpy.props.StringProperty(name="Application ID", default="") # type: ignore
|
||||
|
||||
|
||||
class speckle_model_card(bpy.types.PropertyGroup):
|
||||
"""
|
||||
represents a Speckle model card in the Blender UI
|
||||
"""
|
||||
|
||||
account_id: bpy.props.StringProperty(
|
||||
name="Account ID", description="ID of the account", default=""
|
||||
) # type: ignore
|
||||
server_url: bpy.props.StringProperty(
|
||||
name="Server URL",
|
||||
description="URL of the Server",
|
||||
default="app.speckle.systems",
|
||||
) # type: ignore
|
||||
project_name: bpy.props.StringProperty(
|
||||
name="Project Name", description="Name of the project", default=""
|
||||
) # type: ignore
|
||||
project_id: bpy.props.StringProperty(
|
||||
name="Project ID", description="ID of the selected project", default=""
|
||||
) # type: ignore
|
||||
model_id: bpy.props.StringProperty(
|
||||
name="Model ID", description="ID of the model", default=""
|
||||
) # type: ignore
|
||||
model_name: bpy.props.StringProperty(
|
||||
name="Model Name", description="Name of the model", default=""
|
||||
) # type: ignore
|
||||
is_publish: bpy.props.BoolProperty(
|
||||
name="Publish/Load",
|
||||
description="If the model is published or loaded",
|
||||
default=False,
|
||||
) # type: ignore
|
||||
selection_summary: bpy.props.StringProperty(
|
||||
name="Selection Summary", description="Summary of the selection", default=""
|
||||
) # type: ignore
|
||||
version_id: bpy.props.StringProperty(
|
||||
name="Version ID", description="ID of the selected version", default=""
|
||||
) # type: ignore
|
||||
load_option: bpy.props.StringProperty(
|
||||
name="Load Option", description="Option of loading the model", default=""
|
||||
) # type: ignore
|
||||
objects: bpy.props.CollectionProperty(type=speckle_object) # type: ignore
|
||||
collections: bpy.props.CollectionProperty(type=speckle_collection) # type: ignore
|
||||
instance_loading_mode: bpy.props.StringProperty(
|
||||
name="Instance Loading Mode",
|
||||
description="Mode of loading instances",
|
||||
default="INSTANCE_PROXIES",
|
||||
) # type: ignore
|
||||
apply_modifiers: bpy.props.BoolProperty(
|
||||
name="Apply Modifiers",
|
||||
description="Apply modifiers to the objects",
|
||||
default=True,
|
||||
) # type: ignore
|
||||
|
||||
def get_model_card_id(self) -> str:
|
||||
if not self.project_id or not self.model_id:
|
||||
raise ValueError(
|
||||
"Project ID and Model ID are required to generate a model card ID."
|
||||
)
|
||||
if self.is_publish:
|
||||
return f"PUBLISH-{self.project_id}-{self.model_id}"
|
||||
else:
|
||||
return f"LOAD-{self.project_id}-{self.model_id}"
|
||||
@@ -1,13 +1,13 @@
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.credentials import get_local_accounts, Account
|
||||
from typing import List, Tuple, Optional
|
||||
from .misc import format_relative_time, strip_non_ascii
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from .account_manager import _client_cache
|
||||
from typing import List, Tuple
|
||||
from .misc import format_relative_time
|
||||
from specklepy.core.api.inputs.model_inputs import ModelVersionsFilter
|
||||
from specklepy.core.api.models.current import Version
|
||||
|
||||
|
||||
def get_versions_for_model(
|
||||
account_id: str, project_id: str, model_id: str, search: Optional[str] = None
|
||||
account_id: str, project_id: str, model_id: str
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
fetches versions for a given model from the Speckle server
|
||||
@@ -20,38 +20,36 @@ def get_versions_for_model(
|
||||
)
|
||||
return []
|
||||
|
||||
# Get the account info
|
||||
account: Optional[Account] = next(
|
||||
(acc for acc in get_local_accounts() if acc.id == account_id), None
|
||||
)
|
||||
if not account:
|
||||
print(f"Error: Could not find account with ID: {account_id}")
|
||||
# Get cached client
|
||||
client: SpeckleClient = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
print(f"Error: Could not get client for account: {account_id}")
|
||||
return []
|
||||
|
||||
# Initialize the client
|
||||
client: SpeckleClient = SpeckleClient(host=account.serverInfo.url)
|
||||
# Authenticate
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
filter: ModelVersionsFilter = ModelVersionsFilter(search=search, priorityIds=[])
|
||||
filter: ModelVersionsFilter = ModelVersionsFilter(priorityIds=[])
|
||||
|
||||
# Get versions
|
||||
versions: List[Version] = client.version.get_versions(
|
||||
project_id=project_id, model_id=model_id, limit=10, filter=filter
|
||||
)
|
||||
|
||||
return [
|
||||
(
|
||||
version.id,
|
||||
version.message if version.message is not None else "No message",
|
||||
format_relative_time(version.created_at),
|
||||
)
|
||||
for version in versions
|
||||
if version.referenced_object is not None
|
||||
]
|
||||
versions_list: List[Tuple[str, str, str]] = []
|
||||
for version in versions.items:
|
||||
if version.referenced_object != "":
|
||||
versions_list.append(
|
||||
(
|
||||
version.id,
|
||||
version.message
|
||||
if version.message is not None
|
||||
else "No message",
|
||||
format_relative_time(version.created_at),
|
||||
)
|
||||
)
|
||||
return versions_list
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching versions: {str(e)}")
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
return []
|
||||
|
||||
|
||||
@@ -66,19 +64,12 @@ def get_latest_version(
|
||||
)
|
||||
return ("", "", "")
|
||||
|
||||
# Get the account info
|
||||
account: Optional[Account] = next(
|
||||
(acc for acc in get_local_accounts() if acc.id == account_id), None
|
||||
)
|
||||
if not account:
|
||||
print(f"Error: Could not find account with ID: {account_id}")
|
||||
# Get cached client
|
||||
client: SpeckleClient = _client_cache.get_client(account_id)
|
||||
if not client:
|
||||
print(f"Error: Could not get client for account: {account_id}")
|
||||
return ("", "", "")
|
||||
|
||||
# Initialize the client
|
||||
client: SpeckleClient = SpeckleClient(host=account.serverInfo.url)
|
||||
# Authenticate
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
# Get versions (limit to 1 since we only need the latest)
|
||||
versions: List[Version] = client.version.get_versions(
|
||||
project_id=project_id, model_id=model_id, limit=1
|
||||
@@ -97,4 +88,6 @@ def get_latest_version(
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching latest version: {str(e)}")
|
||||
# Clear cache on error to prevent stale clients
|
||||
_client_cache.clear()
|
||||
return ("", "", "")
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from ..converter.to_native import * #noqa: F403
|
||||
from ..converter.to_native import * # noqa: F403
|
||||
from ..converter.to_speckle import * # noqa: F403
|
||||
from ..converter.utils import * # noqa: F403
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
from typing import Any, Iterable, List, Optional, Tuple, Dict
|
||||
from specklepy.objects import Base
|
||||
from specklepy.objects import DataObject
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
from bpy.types import Object
|
||||
from specklepy.objects import Base, DataObject
|
||||
from specklepy.objects.geometry import (
|
||||
Line,
|
||||
Polyline,
|
||||
Mesh,
|
||||
Arc,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Curve,
|
||||
Polycurve,
|
||||
Ellipse,
|
||||
Line,
|
||||
Mesh,
|
||||
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.models.units import (
|
||||
get_units_from_string,
|
||||
get_scale_factor_to_meters,
|
||||
)
|
||||
import bpy
|
||||
from bpy.types import Object
|
||||
import mathutils
|
||||
from ..converter.utils import create_material_from_proxy, find_object_by_id
|
||||
|
||||
from ..converter.utils import create_material_from_proxy
|
||||
|
||||
# Display value property aliases to check for
|
||||
DISPLAY_VALUE_PROPERTY_ALIASES = [
|
||||
@@ -85,6 +86,7 @@ def convert_to_native(
|
||||
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",
|
||||
) -> Optional[Object]:
|
||||
"""
|
||||
converts a speckle object to blender object with material support
|
||||
@@ -109,9 +111,14 @@ def convert_to_native(
|
||||
if definition_collections:
|
||||
for def_id, coll in definition_collections.items():
|
||||
if def_id == speckle_object.definitionId:
|
||||
converted_object = instance_proxy_to_native(
|
||||
speckle_object, coll, root_collection, scale
|
||||
)
|
||||
if instance_loading_mode == "LINKED_DUPLICATES":
|
||||
converted_object = instance_proxy_to_linked_duplicates(
|
||||
speckle_object, coll, root_collection, scale
|
||||
)
|
||||
else: # INSTANCE_PROXIES (default)
|
||||
converted_object = instance_proxy_to_native(
|
||||
speckle_object, coll, root_collection, scale
|
||||
)
|
||||
else:
|
||||
print("No InstanceDefinitionProxy is found.")
|
||||
elif isinstance(speckle_object, Line):
|
||||
@@ -153,7 +160,14 @@ def convert_to_native(
|
||||
else:
|
||||
# Fallback to display value if direct conversion not supported
|
||||
mesh, children = display_value_to_native(
|
||||
speckle_object, object_name, data_block_name, scale, material_mapping
|
||||
speckle_object,
|
||||
object_name,
|
||||
data_block_name,
|
||||
scale,
|
||||
material_mapping,
|
||||
definition_collections,
|
||||
root_collection,
|
||||
instance_loading_mode,
|
||||
)
|
||||
if mesh:
|
||||
# Create a mesh object with the object_name (simple name) and mesh data
|
||||
@@ -170,7 +184,11 @@ def convert_to_native(
|
||||
# Ensure the converted object has the correct name (especially for DataObjects)
|
||||
if isinstance(speckle_object, DataObject):
|
||||
converted_object.name = object_name
|
||||
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
|
||||
for child in children[1:]:
|
||||
@@ -180,7 +198,7 @@ def convert_to_native(
|
||||
# Store Speckle ID in custom property
|
||||
converted_object["speckle_id"] = speckle_object.id
|
||||
if hasattr(speckle_object, "applicationId"):
|
||||
converted_object["speckle_application_id"] = speckle_object.applicationId
|
||||
converted_object["applicationId"] = speckle_object.applicationId
|
||||
|
||||
return converted_object
|
||||
|
||||
@@ -191,6 +209,9 @@ def display_value_to_native(
|
||||
data_block_name: str,
|
||||
scale: float,
|
||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
||||
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
|
||||
root_collection: Optional[bpy.types.Collection] = None,
|
||||
instance_loading_mode: str = "INSTANCE_PROXIES",
|
||||
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
|
||||
"""
|
||||
fallback conversion mechanism using displayValue if present
|
||||
@@ -209,6 +230,9 @@ def display_value_to_native(
|
||||
DISPLAY_VALUE_PROPERTY_ALIASES,
|
||||
True,
|
||||
material_mapping,
|
||||
definition_collections,
|
||||
root_collection,
|
||||
instance_loading_mode,
|
||||
)
|
||||
|
||||
# If the parent had an applicationId and we created a mesh, apply the material
|
||||
@@ -241,6 +265,9 @@ def elements_to_native(
|
||||
data_block_name: str,
|
||||
scale: float,
|
||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
||||
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
|
||||
root_collection: Optional[bpy.types.Collection] = None,
|
||||
instance_loading_mode: str = "INSTANCE_PROXIES",
|
||||
) -> List[Object]:
|
||||
"""
|
||||
convert elements collection of a speckle object
|
||||
@@ -253,6 +280,9 @@ def elements_to_native(
|
||||
ELEMENTS_PROPERTY_ALIASES,
|
||||
False,
|
||||
material_mapping,
|
||||
definition_collections,
|
||||
root_collection,
|
||||
instance_loading_mode,
|
||||
)
|
||||
return elements
|
||||
|
||||
@@ -265,12 +295,16 @@ def _members_to_native(
|
||||
members: Iterable[str],
|
||||
combineMeshes: bool,
|
||||
material_mapping: Optional[Dict[str, bpy.types.Material]] = None,
|
||||
definition_collections: Optional[Dict[str, bpy.types.Collection]] = None,
|
||||
root_collection: Optional[bpy.types.Collection] = None,
|
||||
instance_loading_mode: str = "INSTANCE_PROXIES",
|
||||
) -> Tuple[Optional[bpy.types.Mesh], List[Object]]:
|
||||
"""
|
||||
converts a given speckle_object by converting specified members
|
||||
"""
|
||||
meshes: List[Mesh] = []
|
||||
others: List[Base] = []
|
||||
instance_proxies: List[InstanceProxy] = []
|
||||
|
||||
for alias in members:
|
||||
display = getattr(speckle_object, alias, None)
|
||||
@@ -279,10 +313,13 @@ def _members_to_native(
|
||||
MAX_DEPTH = 255 # some large value, to prevent infinite recursion
|
||||
|
||||
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):
|
||||
meshes.append(value)
|
||||
elif isinstance(value, InstanceProxy):
|
||||
# Handle InstanceProxy objects separately - they need definition_collections
|
||||
instance_proxies.append(value)
|
||||
elif isinstance(value, Base):
|
||||
others.append(value)
|
||||
elif isinstance(value, list):
|
||||
@@ -312,9 +349,29 @@ def _members_to_native(
|
||||
# Check if the original object is a DataObject
|
||||
is_data_object = isinstance(speckle_object, DataObject)
|
||||
|
||||
# Process InstanceProxy objects - do not add to children list as they are already
|
||||
for item in instance_proxies:
|
||||
try:
|
||||
convert_to_native(
|
||||
item,
|
||||
material_mapping,
|
||||
definition_collections=definition_collections,
|
||||
root_collection=root_collection,
|
||||
instance_loading_mode="LINKED_DUPLICATES", # always use Linked Duplicates for displayValue proxies
|
||||
)
|
||||
except Exception as ex:
|
||||
print(f"Failed to convert instance proxy in display value {item}: {ex}")
|
||||
|
||||
# Process other objects
|
||||
for item in others:
|
||||
try:
|
||||
blender_object = convert_to_native(item, material_mapping)
|
||||
blender_object = convert_to_native(
|
||||
item,
|
||||
material_mapping,
|
||||
definition_collections=definition_collections,
|
||||
root_collection=root_collection,
|
||||
instance_loading_mode=instance_loading_mode,
|
||||
)
|
||||
if blender_object:
|
||||
# If the parent is a DataObject, override the name of the converted child
|
||||
if is_data_object:
|
||||
@@ -639,7 +696,7 @@ def render_material_proxy_to_native(
|
||||
continue
|
||||
|
||||
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
|
||||
blender_material = create_material_from_proxy(render_material, material_name)
|
||||
@@ -658,6 +715,7 @@ def arc_to_native(
|
||||
converts a Speckle arc to a Blender NURBS curve.
|
||||
"""
|
||||
import math
|
||||
|
||||
import mathutils
|
||||
|
||||
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
||||
@@ -792,6 +850,7 @@ def circle_to_native(
|
||||
converts a Speckle circle to a Blender NURBS curve.
|
||||
"""
|
||||
import math
|
||||
|
||||
import mathutils
|
||||
|
||||
curve = bpy.data.curves.new(data_block_name, type="CURVE")
|
||||
@@ -979,7 +1038,14 @@ def curve_to_native(
|
||||
):
|
||||
print("curve_to_native: degree 2 curve, falling back to displayValue")
|
||||
mesh, children = display_value_to_native(
|
||||
speckle_curve, object_name, data_block_name, scale
|
||||
speckle_curve,
|
||||
object_name,
|
||||
data_block_name,
|
||||
scale,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"INSTANCE_PROXIES",
|
||||
)
|
||||
if mesh:
|
||||
curve_obj = bpy.data.objects.new(object_name, mesh)
|
||||
@@ -1051,7 +1117,14 @@ def polycurve_to_native(
|
||||
and speckle_polycurve.displayValue
|
||||
):
|
||||
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:
|
||||
curve_obj = bpy.data.objects.new(object_name, mesh)
|
||||
@@ -1202,10 +1275,19 @@ def instance_definition_proxy_to_native(
|
||||
root_object: Base,
|
||||
material_mapping: Dict[str, Any],
|
||||
processed_definitions: Dict[str, Any] = None,
|
||||
instance_loading_mode: str = "INSTANCE_PROXIES",
|
||||
object_id_map: Optional[Dict[str, Base]] = None,
|
||||
) -> Tuple[Dict[str, bpy.types.Collection], Dict[str, Any]]:
|
||||
"""
|
||||
converts instance definition proxies to Blender collections recursively
|
||||
"""
|
||||
# Validate instance loading mode
|
||||
assert instance_loading_mode in ["INSTANCE_PROXIES", "LINKED_DUPLICATES"], (
|
||||
f"Invalid instance_loading_mode: {instance_loading_mode}. "
|
||||
"Must be 'INSTANCE_PROXIES' or 'LINKED_DUPLICATES'"
|
||||
)
|
||||
assert isinstance(material_mapping, dict), "material_mapping must be a dictionary"
|
||||
|
||||
processed_definitions = processed_definitions or {}
|
||||
definition_collections = {}
|
||||
converted_objects = {}
|
||||
@@ -1226,7 +1308,7 @@ def instance_definition_proxy_to_native(
|
||||
sorted_components = sort_instance_components(definitions, [])
|
||||
|
||||
for _, _, def_id, definition in sorted_components:
|
||||
collection_name = getattr(definition, "name", f"Definition_{def_id[:8]}")
|
||||
collection_name = getattr(definition, "name", f"Definition_{def_id}")
|
||||
|
||||
if def_id in processed_definitions:
|
||||
definition_collections[def_id] = processed_definitions[def_id]
|
||||
@@ -1246,7 +1328,8 @@ def instance_definition_proxy_to_native(
|
||||
# Process objects, including nested instances
|
||||
if hasattr(definition, "objects") and isinstance(definition.objects, list):
|
||||
for obj_id in definition.objects:
|
||||
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:
|
||||
try:
|
||||
@@ -1258,16 +1341,34 @@ def instance_definition_proxy_to_native(
|
||||
nested_def = definitions[found_obj.definitionId]
|
||||
max_depth = getattr(nested_def, "maxDepth", 0)
|
||||
if max_depth > 0: # Only process if max_depth allows
|
||||
blender_obj = instance_proxy_to_native(
|
||||
found_obj,
|
||||
definition_collections[found_obj.definitionId],
|
||||
definition_collection,
|
||||
scale=1.0,
|
||||
assert (
|
||||
found_obj.definitionId in definition_collections
|
||||
), (
|
||||
f"Definition collection not found for nested instance {found_obj.definitionId}"
|
||||
)
|
||||
|
||||
if instance_loading_mode == "LINKED_DUPLICATES":
|
||||
blender_obj = instance_proxy_to_linked_duplicates(
|
||||
found_obj,
|
||||
definition_collections[found_obj.definitionId],
|
||||
definition_collection,
|
||||
scale=1.0,
|
||||
)
|
||||
else: # INSTANCE_PROXIES (default)
|
||||
blender_obj = instance_proxy_to_native(
|
||||
found_obj,
|
||||
definition_collections[found_obj.definitionId],
|
||||
definition_collection,
|
||||
scale=1.0,
|
||||
)
|
||||
if blender_obj:
|
||||
converted_objects[obj_id] = blender_obj
|
||||
else:
|
||||
blender_obj = convert_to_native(found_obj, material_mapping)
|
||||
blender_obj = convert_to_native(
|
||||
found_obj,
|
||||
material_mapping,
|
||||
instance_loading_mode="INSTANCE_PROXIES",
|
||||
)
|
||||
if blender_obj:
|
||||
definition_collection.objects.link(blender_obj)
|
||||
converted_objects[obj_id] = blender_obj
|
||||
@@ -1317,20 +1418,21 @@ def proxy_scale(speckle_object: Base, fallback: float = 1.0) -> float:
|
||||
return final_scale
|
||||
|
||||
|
||||
def instance_proxy_to_native(
|
||||
def instance_proxy_to_linked_duplicates(
|
||||
speckle_instance: InstanceProxy,
|
||||
definition_collection: bpy.types.Collection,
|
||||
root_collection: bpy.types.Collection,
|
||||
scale: float = 1.0,
|
||||
) -> Optional[bpy.types.Object]:
|
||||
"""
|
||||
converts a Speckle InstanceProxy to Blender collection instance
|
||||
converts a Speckle InstanceProxy to linked duplicate objects
|
||||
"""
|
||||
if not definition_collection:
|
||||
print(f"Definition collection not found for instance {speckle_instance.id}")
|
||||
return None
|
||||
|
||||
unit_scale = proxy_scale(speckle_instance)
|
||||
# Use the scale from the parent context
|
||||
unit_scale = scale
|
||||
|
||||
# convert transformation matrix
|
||||
matrix = mathutils.Matrix(
|
||||
@@ -1363,35 +1465,109 @@ def instance_proxy_to_native(
|
||||
)
|
||||
|
||||
location, rotation, scale_vector = matrix.decompose()
|
||||
|
||||
location = location * unit_scale
|
||||
|
||||
bpy.ops.object.collection_instance_add(
|
||||
collection=definition_collection.name,
|
||||
align="WORLD",
|
||||
location=(0, 0, 0),
|
||||
rotation=(0, 0, 0),
|
||||
scale=(1, 1, 1),
|
||||
final_matrix = (
|
||||
mathutils.Matrix.Translation(location)
|
||||
@ rotation.to_matrix().to_4x4()
|
||||
@ mathutils.Matrix.Diagonal(scale_vector).to_4x4()
|
||||
)
|
||||
|
||||
instance_obj = bpy.context.active_object
|
||||
instance_name = f"Instance_{speckle_instance.id}"
|
||||
parent_empty = bpy.data.objects.new(instance_name, None)
|
||||
parent_empty.empty_display_type = "PLAIN_AXES"
|
||||
parent_empty.empty_display_size = 0.1
|
||||
|
||||
root_collection.objects.link(parent_empty)
|
||||
parent_empty.matrix_world = final_matrix
|
||||
|
||||
parent_empty["speckle_id"] = speckle_instance.id
|
||||
parent_empty["speckle_type"] = speckle_instance.speckle_type
|
||||
parent_empty["definition_id"] = speckle_instance.definitionId
|
||||
if hasattr(speckle_instance, "maxDepth"):
|
||||
parent_empty["max_depth"] = speckle_instance.maxDepth
|
||||
|
||||
duplicated_objects = []
|
||||
for obj in definition_collection.objects:
|
||||
duplicate_obj = obj.copy()
|
||||
duplicate_obj.name = f"{obj.name}_{speckle_instance.id[:8]}"
|
||||
|
||||
root_collection.objects.link(duplicate_obj)
|
||||
|
||||
duplicate_obj.parent = parent_empty
|
||||
duplicate_obj.matrix_parent_inverse.identity()
|
||||
duplicate_obj.matrix_basis = obj.matrix_world
|
||||
|
||||
duplicated_objects.append(duplicate_obj)
|
||||
|
||||
return parent_empty
|
||||
|
||||
|
||||
def instance_proxy_to_native(
|
||||
speckle_instance: InstanceProxy,
|
||||
definition_collection: bpy.types.Collection,
|
||||
root_collection: bpy.types.Collection,
|
||||
scale: float = 1.0,
|
||||
) -> Optional[bpy.types.Object]:
|
||||
"""
|
||||
converts a Speckle InstanceProxy to Blender collection instance
|
||||
"""
|
||||
if not definition_collection:
|
||||
print(f"Definition collection not found for instance {speckle_instance.id}")
|
||||
return None
|
||||
|
||||
# Use the scale from the parent context
|
||||
unit_scale = scale
|
||||
|
||||
# convert transformation matrix
|
||||
matrix = mathutils.Matrix(
|
||||
[
|
||||
[
|
||||
speckle_instance.transform[0],
|
||||
speckle_instance.transform[1],
|
||||
speckle_instance.transform[2],
|
||||
speckle_instance.transform[3],
|
||||
],
|
||||
[
|
||||
speckle_instance.transform[4],
|
||||
speckle_instance.transform[5],
|
||||
speckle_instance.transform[6],
|
||||
speckle_instance.transform[7],
|
||||
],
|
||||
[
|
||||
speckle_instance.transform[8],
|
||||
speckle_instance.transform[9],
|
||||
speckle_instance.transform[10],
|
||||
speckle_instance.transform[11],
|
||||
],
|
||||
[
|
||||
speckle_instance.transform[12],
|
||||
speckle_instance.transform[13],
|
||||
speckle_instance.transform[14],
|
||||
speckle_instance.transform[15],
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
location, rotation, scale_vector = matrix.decompose()
|
||||
location = location * unit_scale
|
||||
instance_name = f"Instance_{speckle_instance.id}"
|
||||
instance_obj = bpy.data.objects.new(instance_name, None)
|
||||
instance_obj.instance_type = "COLLECTION"
|
||||
instance_obj.instance_collection = definition_collection
|
||||
instance_obj.empty_display_size = 0
|
||||
|
||||
instance_name = f"Instance_{speckle_instance.id[:8]}"
|
||||
instance_obj.name = instance_name
|
||||
|
||||
if instance_obj.name not in root_collection.objects:
|
||||
for coll in instance_obj.users_collection:
|
||||
coll.objects.unlink(instance_obj)
|
||||
root_collection.objects.link(instance_obj)
|
||||
# Link to root collection
|
||||
root_collection.objects.link(instance_obj)
|
||||
|
||||
# Store metadata
|
||||
instance_obj["speckle_id"] = speckle_instance.id
|
||||
instance_obj["speckle_type"] = speckle_instance.speckle_type
|
||||
instance_obj["definition_id"] = speckle_instance.definitionId
|
||||
if hasattr(speckle_instance, "maxDepth"):
|
||||
instance_obj["max_depth"] = speckle_instance.maxDepth
|
||||
|
||||
# Apply transformation
|
||||
final_matrix = (
|
||||
mathutils.Matrix.Translation(location)
|
||||
@ rotation.to_matrix().to_4x4()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from .to_speckle import convert_to_speckle # noqa: F401
|
||||
from .material_to_speckle import ( # noqa: F401
|
||||
blender_material_to_speckle,
|
||||
create_render_material_proxies,
|
||||
add_render_material_proxies_to_base,
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
from bpy.types import Object
|
||||
from typing import Union, Optional, Tuple, List
|
||||
from specklepy.objects.geometry import Polyline, Curve
|
||||
from specklepy.objects.primitive import Interval
|
||||
from specklepy.objects.base import Base
|
||||
from mathutils import Matrix
|
||||
from mathutils.geometry import interpolate_bezier
|
||||
from .utils import nurb_make_curve, make_knots
|
||||
|
||||
|
||||
def curve_to_speckle(
|
||||
blender_obj: Object, scale_factor: float = 1.0
|
||||
) -> Union[Base, None]:
|
||||
assert blender_obj.type == "CURVE", "Object must be a curve"
|
||||
assert blender_obj.data is not None, "Curve data cannot be None"
|
||||
|
||||
curve_data = blender_obj.data
|
||||
matrix = blender_obj.matrix_world
|
||||
units = "m" # TODO: Use the unit system from the scene
|
||||
|
||||
base = Base()
|
||||
curves = []
|
||||
|
||||
for spline in curve_data.splines:
|
||||
if spline.type == "BEZIER":
|
||||
curves.append(
|
||||
bezier_to_speckle(matrix, spline, blender_obj.name, scale_factor, units)
|
||||
)
|
||||
elif spline.type == "NURBS":
|
||||
curves.append(
|
||||
nurbs_to_speckle(matrix, spline, blender_obj.name, scale_factor, units)
|
||||
)
|
||||
|
||||
if curves:
|
||||
base["@elements"] = curves
|
||||
base["name"] = blender_obj.name
|
||||
return base
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def bezier_to_speckle(
|
||||
matrix: Matrix,
|
||||
spline,
|
||||
name: Optional[str] = None,
|
||||
scale_factor: float = 1.0,
|
||||
units: str = "m",
|
||||
) -> Curve:
|
||||
degree = 3
|
||||
closed = spline.use_cyclic_u
|
||||
points: List[Tuple[float, float, float]] = []
|
||||
|
||||
for i, bp in enumerate(spline.bezier_points):
|
||||
if i > 0:
|
||||
transformed_point = matrix @ bp.handle_left * scale_factor
|
||||
points.append(
|
||||
(transformed_point.x, transformed_point.y, transformed_point.z)
|
||||
)
|
||||
|
||||
transformed_point = matrix @ bp.co * scale_factor
|
||||
points.append((transformed_point.x, transformed_point.y, transformed_point.z))
|
||||
|
||||
if i < len(spline.bezier_points) - 1:
|
||||
transformed_point = matrix @ bp.handle_right * scale_factor
|
||||
points.append(
|
||||
(transformed_point.x, transformed_point.y, transformed_point.z)
|
||||
)
|
||||
|
||||
if closed:
|
||||
transformed_point = (
|
||||
matrix @ spline.bezier_points[-1].handle_right * scale_factor
|
||||
)
|
||||
points.append((transformed_point.x, transformed_point.y, transformed_point.z))
|
||||
|
||||
transformed_point = matrix @ spline.bezier_points[0].handle_left * scale_factor
|
||||
points.append((transformed_point.x, transformed_point.y, transformed_point.z))
|
||||
|
||||
transformed_point = matrix @ spline.bezier_points[0].co * scale_factor
|
||||
points.append((transformed_point.x, transformed_point.y, transformed_point.z))
|
||||
|
||||
num_points = len(points)
|
||||
|
||||
flattened_points = []
|
||||
for point in points:
|
||||
flattened_points.extend(point)
|
||||
|
||||
knot_count = num_points + degree - 1
|
||||
knots = [0] * knot_count
|
||||
|
||||
for i in range(1, len(knots)):
|
||||
knots[i] = i // 3
|
||||
|
||||
length = spline.calc_length()
|
||||
|
||||
domain = Interval(start=0, end=length)
|
||||
display_value = bezier_to_speckle_polyline(
|
||||
matrix, spline, length, scale_factor, units
|
||||
)
|
||||
|
||||
curve = Curve(
|
||||
degree=degree,
|
||||
periodic=not spline.use_endpoint_u,
|
||||
rational=True,
|
||||
points=flattened_points,
|
||||
weights=[1] * num_points,
|
||||
knots=knots,
|
||||
closed=spline.use_cyclic_u,
|
||||
displayValue=display_value,
|
||||
units=units,
|
||||
bbox=None,
|
||||
)
|
||||
|
||||
curve.__dict__["_length"] = length
|
||||
curve.__dict__["_area"] = 0.0
|
||||
|
||||
curve["domain"] = domain
|
||||
|
||||
if name:
|
||||
curve["name"] = name
|
||||
|
||||
return curve
|
||||
|
||||
|
||||
def bezier_to_speckle_polyline(
|
||||
matrix: Matrix,
|
||||
spline,
|
||||
length: Optional[float] = None,
|
||||
scale_factor: float = 1.0,
|
||||
units: str = "m",
|
||||
) -> Optional[Polyline]:
|
||||
segments = len(spline.bezier_points)
|
||||
if segments < 2:
|
||||
return None
|
||||
|
||||
resolution = spline.resolution_u + 1
|
||||
points: List[float] = []
|
||||
|
||||
if not spline.use_cyclic_u:
|
||||
segments -= 1
|
||||
|
||||
for i in range(segments):
|
||||
inext = (i + 1) % len(spline.bezier_points)
|
||||
|
||||
knot1 = spline.bezier_points[i].co
|
||||
handle1 = spline.bezier_points[i].handle_right
|
||||
handle2 = spline.bezier_points[inext].handle_left
|
||||
knot2 = spline.bezier_points[inext].co
|
||||
|
||||
sampled_points = interpolate_bezier(knot1, handle1, handle2, knot2, resolution)
|
||||
for p in sampled_points:
|
||||
scaled_point = matrix @ p * scale_factor
|
||||
points.append(scaled_point.x)
|
||||
points.append(scaled_point.y)
|
||||
points.append(scaled_point.z)
|
||||
|
||||
length = length or spline.calc_length()
|
||||
|
||||
polyline = Polyline(value=points, units=units)
|
||||
|
||||
polyline["domain"] = {"start": 0, "end": length}
|
||||
polyline["closed"] = spline.use_cyclic_u
|
||||
|
||||
return polyline
|
||||
|
||||
|
||||
def nurbs_to_speckle(
|
||||
matrix: Matrix,
|
||||
spline,
|
||||
name: Optional[str] = None,
|
||||
scale_factor: float = 1.0,
|
||||
units: str = "m",
|
||||
) -> Curve:
|
||||
degree = spline.order_u - 1
|
||||
knots = make_knots(spline)
|
||||
|
||||
length = spline.calc_length()
|
||||
domain = Interval(start=0, end=length)
|
||||
|
||||
weights = [pt.weight for pt in spline.points]
|
||||
first_weight = weights[0] if weights else 1.0
|
||||
is_rational = any(abs(w - first_weight) > 1e-9 for w in weights)
|
||||
|
||||
points = []
|
||||
for pt in spline.points:
|
||||
transformed_point = matrix @ pt.co.xyz * scale_factor
|
||||
points.append((transformed_point.x, transformed_point.y, transformed_point.z))
|
||||
|
||||
flattened_points = []
|
||||
for point in points:
|
||||
flattened_points.extend(point)
|
||||
|
||||
if spline.use_cyclic_u:
|
||||
for i in range(0, degree * 3, 3):
|
||||
flattened_points.append(flattened_points[i + 0])
|
||||
flattened_points.append(flattened_points[i + 1])
|
||||
flattened_points.append(flattened_points[i + 2])
|
||||
|
||||
for i in range(0, degree):
|
||||
weights.append(weights[i])
|
||||
|
||||
resolution_multiplier = (
|
||||
4 if (spline.use_cyclic_u and spline.point_count_u <= 16) else 1
|
||||
)
|
||||
display_value = nurbs_to_speckle_polyline(
|
||||
matrix, spline, length, scale_factor, units, resolution_multiplier
|
||||
)
|
||||
|
||||
curve = Curve(
|
||||
degree=degree,
|
||||
periodic=not spline.use_endpoint_u,
|
||||
rational=is_rational,
|
||||
points=flattened_points,
|
||||
weights=weights,
|
||||
knots=knots,
|
||||
closed=spline.use_cyclic_u,
|
||||
displayValue=display_value,
|
||||
units=units,
|
||||
bbox=None,
|
||||
)
|
||||
|
||||
curve.__dict__["_length"] = length
|
||||
|
||||
curve["domain"] = domain
|
||||
|
||||
if name:
|
||||
curve["name"] = name
|
||||
|
||||
return curve
|
||||
|
||||
|
||||
def nurbs_to_speckle_polyline(
|
||||
matrix: Matrix,
|
||||
spline,
|
||||
length: Optional[float] = None,
|
||||
scale_factor: float = 1.0,
|
||||
units: str = "m",
|
||||
resolution_multiplier: int = 1,
|
||||
) -> Polyline:
|
||||
from mathutils import Vector
|
||||
|
||||
points: List[float] = []
|
||||
|
||||
resolution = spline.resolution_u * resolution_multiplier
|
||||
|
||||
sampled_points = nurb_make_curve(spline, resolution)
|
||||
|
||||
for i in range(0, len(sampled_points), 3):
|
||||
point_vector = Vector(
|
||||
(sampled_points[i], sampled_points[i + 1], sampled_points[i + 2])
|
||||
)
|
||||
transformed_point = matrix @ point_vector * scale_factor
|
||||
|
||||
points.append(transformed_point.x)
|
||||
points.append(transformed_point.y)
|
||||
points.append(transformed_point.z)
|
||||
|
||||
length = length or spline.calc_length()
|
||||
|
||||
polyline = Polyline(value=points, units=units)
|
||||
|
||||
polyline["domain"] = {"start": 0, "end": length}
|
||||
polyline["closed"] = spline.use_cyclic_u
|
||||
|
||||
# Set length property if needed
|
||||
if hasattr(polyline, "length") or hasattr(polyline, "_length"):
|
||||
polyline.__dict__["_length"] = length
|
||||
|
||||
# Set area property if needed
|
||||
if hasattr(polyline, "area") or hasattr(polyline, "_area"):
|
||||
polyline.__dict__["_area"] = 0
|
||||
|
||||
return polyline
|
||||
@@ -0,0 +1,257 @@
|
||||
from typing import Dict, List, Set
|
||||
import bpy
|
||||
from bpy.types import Material, Object
|
||||
from specklepy.objects.base import Base
|
||||
from specklepy.objects.other import RenderMaterial
|
||||
from specklepy.objects.proxies import RenderMaterialProxy
|
||||
from ..utils import to_argb_int
|
||||
|
||||
from .utils import get_submesh_id, get_unique_id
|
||||
|
||||
|
||||
def blender_material_to_speckle(material: Material) -> RenderMaterial:
|
||||
"""
|
||||
convert a Blender material to a Speckle RenderMaterial
|
||||
"""
|
||||
diffuse = -1 # default white
|
||||
opacity = 1.0
|
||||
emissive = -16777216 # default black
|
||||
metalness = 0.0
|
||||
roughness = 1.0
|
||||
|
||||
# extract material properties if using nodes
|
||||
if material.use_nodes and material.node_tree:
|
||||
output_node = None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "OUTPUT_MATERIAL":
|
||||
output_node = node
|
||||
break
|
||||
|
||||
# find the main shader node connected to output
|
||||
main_shader = None
|
||||
if output_node and output_node.inputs["Surface"].is_linked:
|
||||
main_shader = output_node.inputs["Surface"].links[0].from_node
|
||||
|
||||
# handle different shader types
|
||||
# we're supporting: principled, diffuse, emmision and glass - for now
|
||||
if main_shader:
|
||||
if main_shader.type == "BSDF_PRINCIPLED":
|
||||
diffuse, opacity, metalness, roughness, emissive = (
|
||||
_extract_principled_properties(main_shader)
|
||||
)
|
||||
|
||||
elif main_shader.type == "BSDF_DIFFUSE":
|
||||
color_input = main_shader.inputs.get("Color")
|
||||
if color_input:
|
||||
if color_input.is_linked:
|
||||
rgba = _get_color_from_connected_node(
|
||||
color_input.links[0].from_node
|
||||
)
|
||||
else:
|
||||
rgba = list(color_input.default_value)
|
||||
diffuse = to_argb_int(rgba)
|
||||
roughness = 1.0
|
||||
|
||||
elif main_shader.type == "EMISSION":
|
||||
color_input = main_shader.inputs.get("Color")
|
||||
strength_input = main_shader.inputs.get("Strength")
|
||||
if color_input and strength_input:
|
||||
if color_input.is_linked:
|
||||
rgba = _get_color_from_connected_node(
|
||||
color_input.links[0].from_node
|
||||
)
|
||||
else:
|
||||
rgba = list(color_input.default_value)
|
||||
|
||||
strength = (
|
||||
float(strength_input.default_value)
|
||||
if not strength_input.is_linked
|
||||
else 1.0
|
||||
)
|
||||
|
||||
if strength > 0:
|
||||
emission_rgba = [c * strength for c in rgba[:3]] + [rgba[3]]
|
||||
emission_rgba = [min(1.0, max(0.0, c)) for c in emission_rgba]
|
||||
emissive = to_argb_int(emission_rgba)
|
||||
diffuse = to_argb_int(rgba)
|
||||
|
||||
elif main_shader.type == "BSDF_GLASS":
|
||||
color_input = main_shader.inputs.get("Color")
|
||||
if color_input:
|
||||
if color_input.is_linked:
|
||||
rgba = _get_color_from_connected_node(
|
||||
color_input.links[0].from_node
|
||||
)
|
||||
else:
|
||||
rgba = list(color_input.default_value)
|
||||
diffuse = to_argb_int(rgba)
|
||||
roughness_input = main_shader.inputs.get("Roughness")
|
||||
if roughness_input:
|
||||
roughness = (
|
||||
float(roughness_input.default_value)
|
||||
if not roughness_input.is_linked
|
||||
else 0.0
|
||||
)
|
||||
opacity = 0.5
|
||||
|
||||
else:
|
||||
# fallback to legacy material properties
|
||||
if hasattr(material, "diffuse_color"):
|
||||
rgba = list(material.diffuse_color) + [1.0]
|
||||
diffuse = to_argb_int(rgba)
|
||||
|
||||
if hasattr(material, "metallic"):
|
||||
metalness = float(material.metallic)
|
||||
|
||||
if hasattr(material, "roughness"):
|
||||
roughness = float(material.roughness)
|
||||
|
||||
render_material = RenderMaterial(
|
||||
name=material.name,
|
||||
diffuse=diffuse,
|
||||
opacity=opacity,
|
||||
emissive=emissive,
|
||||
metalness=metalness,
|
||||
roughness=roughness,
|
||||
)
|
||||
|
||||
return render_material
|
||||
|
||||
|
||||
def _extract_principled_properties(principled_node):
|
||||
diffuse = -1
|
||||
opacity = 1.0
|
||||
metalness = 0.0
|
||||
roughness = 1.0
|
||||
emissive = -16777216
|
||||
|
||||
base_color_input = principled_node.inputs.get("Base Color")
|
||||
if base_color_input:
|
||||
if base_color_input.is_linked:
|
||||
rgba = _get_color_from_connected_node(base_color_input.links[0].from_node)
|
||||
else:
|
||||
rgba = list(base_color_input.default_value)
|
||||
diffuse = to_argb_int(rgba)
|
||||
|
||||
# Alpha/Opacity
|
||||
alpha_input = principled_node.inputs.get("Alpha")
|
||||
if alpha_input and not alpha_input.is_linked:
|
||||
opacity = float(alpha_input.default_value)
|
||||
|
||||
# Metallic
|
||||
metallic_input = principled_node.inputs.get("Metallic")
|
||||
if metallic_input and not metallic_input.is_linked:
|
||||
metalness = float(metallic_input.default_value)
|
||||
|
||||
# Roughness
|
||||
roughness_input = principled_node.inputs.get("Roughness")
|
||||
if roughness_input and not roughness_input.is_linked:
|
||||
roughness = float(roughness_input.default_value)
|
||||
|
||||
# Emission - try different possible input names for different versions
|
||||
emission_color_input = principled_node.inputs.get(
|
||||
"Emission Color"
|
||||
) or principled_node.inputs.get("Emission")
|
||||
|
||||
emission_strength_input = principled_node.inputs.get("Emission Strength")
|
||||
|
||||
if emission_color_input:
|
||||
if emission_color_input.is_linked:
|
||||
emission_rgba = _get_color_from_connected_node(
|
||||
emission_color_input.links[0].from_node
|
||||
)
|
||||
else:
|
||||
emission_rgba = list(emission_color_input.default_value)
|
||||
|
||||
emission_strength = 1.0
|
||||
if emission_strength_input and not emission_strength_input.is_linked:
|
||||
emission_strength = float(emission_strength_input.default_value)
|
||||
|
||||
if emission_strength > 0 and any(
|
||||
c > 0.01 for c in emission_rgba[:3]
|
||||
): # Check if color is not black
|
||||
final_emission_rgba = [c * emission_strength for c in emission_rgba[:3]] + [
|
||||
emission_rgba[3]
|
||||
]
|
||||
final_emission_rgba = [min(1.0, max(0.0, c)) for c in final_emission_rgba]
|
||||
emissive = to_argb_int(final_emission_rgba)
|
||||
|
||||
return diffuse, opacity, metalness, roughness, emissive
|
||||
|
||||
|
||||
def _get_color_from_connected_node(node):
|
||||
if node.type == "RGB":
|
||||
rgba = list(node.outputs["Color"].default_value)
|
||||
return rgba
|
||||
elif node.type == "VALTORGB":
|
||||
if node.color_ramp.elements:
|
||||
rgba = list(node.color_ramp.elements[0].color)
|
||||
return rgba
|
||||
elif hasattr(node, "color"):
|
||||
rgba = list(node.color) + [1.0]
|
||||
return rgba
|
||||
|
||||
# fallback to white
|
||||
return [1.0, 1.0, 1.0, 1.0]
|
||||
|
||||
|
||||
def collect_material_assignments(objects: List[Object]) -> Dict[str, Set[str]]:
|
||||
"""
|
||||
collect material assignments for objects, creating unique applicationIds
|
||||
for each material slot use a unique id
|
||||
"""
|
||||
material_assignments: Dict[str, Set[str]] = {}
|
||||
|
||||
for obj in objects:
|
||||
if not obj or not hasattr(obj, "data") or not obj.data:
|
||||
continue
|
||||
|
||||
# check if object has materials
|
||||
if hasattr(obj.data, "materials") and obj.data.materials:
|
||||
for material_index, material_slot in enumerate(obj.data.materials):
|
||||
if material_slot:
|
||||
material_name = material_slot.name
|
||||
|
||||
# set unique ID for submeshes
|
||||
application_id = get_submesh_id(obj, material_index)
|
||||
|
||||
if material_name not in material_assignments:
|
||||
material_assignments[material_name] = set()
|
||||
|
||||
material_assignments[material_name].add(application_id)
|
||||
|
||||
return material_assignments
|
||||
|
||||
|
||||
def create_render_material_proxies(objects: List[Object]) -> List[RenderMaterialProxy]:
|
||||
material_assignments = collect_material_assignments(objects)
|
||||
|
||||
if not material_assignments:
|
||||
return []
|
||||
|
||||
proxies = []
|
||||
|
||||
for material_name, object_ids in material_assignments.items():
|
||||
blender_material = bpy.data.materials.get(material_name)
|
||||
if not blender_material:
|
||||
continue
|
||||
|
||||
speckle_material = blender_material_to_speckle(blender_material)
|
||||
|
||||
proxy = RenderMaterialProxy(objects=list(object_ids), value=speckle_material)
|
||||
|
||||
proxy.applicationId = get_unique_id(blender_material)
|
||||
|
||||
proxies.append(proxy)
|
||||
|
||||
return proxies
|
||||
|
||||
|
||||
def add_render_material_proxies_to_base(base: Base, objects: List[Object]) -> None:
|
||||
"""
|
||||
add render material proxies to the base object.
|
||||
"""
|
||||
proxies = create_render_material_proxies(objects)
|
||||
|
||||
if proxies:
|
||||
base.renderMaterialProxies = proxies
|
||||
@@ -0,0 +1,132 @@
|
||||
from typing import Dict, List, cast
|
||||
|
||||
import bpy
|
||||
from bpy.types import Mesh as BMesh
|
||||
from bpy.types import MeshPolygon, Object
|
||||
from mathutils import Matrix as MMatrix
|
||||
from mathutils import Vector as MVector
|
||||
|
||||
from specklepy.objects.base import Base
|
||||
from specklepy.objects.geometry.mesh import Mesh
|
||||
from .utils import get_submesh_id
|
||||
|
||||
|
||||
def mesh_to_speckle(
|
||||
blender_object: Object, data: bpy.types.Mesh, units_scale: float, units: str
|
||||
) -> Base:
|
||||
"""
|
||||
convert a Blender mesh object
|
||||
"""
|
||||
meshes = mesh_to_speckle_meshes(blender_object, data, units_scale, units)
|
||||
return meshes
|
||||
|
||||
|
||||
def mesh_to_speckle_meshes(
|
||||
blender_object: Object, data: bpy.types.Mesh, units_scale: float, units: str
|
||||
) -> List[Mesh]:
|
||||
"""
|
||||
convert a Blender mesh to a list of Speckle meshes
|
||||
each face corner (loop) gets its own vertex
|
||||
"""
|
||||
assert isinstance(data, BMesh), "Data must be a Blender mesh"
|
||||
assert units_scale > 0, "Units scale must be positive"
|
||||
|
||||
submesh_data: Dict[int, List[MeshPolygon]] = {}
|
||||
for p in data.polygons:
|
||||
if p.material_index not in submesh_data:
|
||||
submesh_data[p.material_index] = []
|
||||
submesh_data[p.material_index].append(p)
|
||||
|
||||
transform = cast(MMatrix, blender_object.matrix_world)
|
||||
normal_transform = transform.to_3x3().inverted().transposed()
|
||||
|
||||
submeshes = []
|
||||
|
||||
# sort material indices to ensure consistent ordering
|
||||
for material_index in sorted(submesh_data.keys()):
|
||||
mesh_area = 0
|
||||
m_verts: List[float] = []
|
||||
m_faces: List[int] = []
|
||||
m_texcoords: List[float] = []
|
||||
m_normals: List[float] = []
|
||||
|
||||
vertex_counter = 0
|
||||
|
||||
for face in submesh_data[material_index]:
|
||||
mesh_area += face.area
|
||||
|
||||
loop_indices = face.loop_indices
|
||||
m_faces.append(len(loop_indices))
|
||||
|
||||
for loop_index in loop_indices:
|
||||
loop = data.loops[loop_index]
|
||||
|
||||
vertex = data.vertices[loop.vertex_index]
|
||||
transformed_vertex = transform @ vertex.co * units_scale
|
||||
|
||||
m_verts.extend(
|
||||
[transformed_vertex.x, transformed_vertex.y, transformed_vertex.z]
|
||||
)
|
||||
|
||||
# get and transform the loop normal
|
||||
# try to get split normal, fallback to face normal if not available
|
||||
try:
|
||||
if hasattr(loop, "normal") and len(loop.normal) > 0:
|
||||
# Use split normal from loop
|
||||
loop_normal = normal_transform @ loop.normal
|
||||
else:
|
||||
# Fallback to face normal
|
||||
loop_normal = normal_transform @ face.normal
|
||||
except: # noqa: E722
|
||||
# Final fallback: use face normal
|
||||
loop_normal = normal_transform @ face.normal
|
||||
|
||||
loop_normal.normalize()
|
||||
m_normals.extend([loop_normal.x, loop_normal.y, loop_normal.z])
|
||||
|
||||
# add UV coordinates if available
|
||||
if data.uv_layers.active:
|
||||
uv_data = data.uv_layers.active.data[loop_index]
|
||||
uv = cast(MVector, uv_data.uv)
|
||||
m_texcoords.extend([uv.x, uv.y])
|
||||
|
||||
m_faces.append(vertex_counter)
|
||||
vertex_counter += 1
|
||||
|
||||
speckle_mesh = Mesh(
|
||||
vertices=m_verts,
|
||||
faces=m_faces,
|
||||
colors=[],
|
||||
textureCoordinates=m_texcoords,
|
||||
vertexNormals=m_normals,
|
||||
units=units,
|
||||
)
|
||||
|
||||
if len(m_verts) > 0:
|
||||
speckle_mesh.area = mesh_area
|
||||
|
||||
speckle_mesh.applicationId = get_submesh_id(blender_object, material_index)
|
||||
|
||||
submeshes.append(speckle_mesh)
|
||||
|
||||
return submeshes
|
||||
|
||||
|
||||
def is_closed_mesh(faces: List[int]) -> bool:
|
||||
"""
|
||||
check if a mesh is closed by verifying that each edge is shared by exactly 2 faces.
|
||||
"""
|
||||
edge_counts = {}
|
||||
|
||||
i = 0
|
||||
while i < len(faces):
|
||||
vertex_count = faces[i]
|
||||
for j in range(vertex_count):
|
||||
v1 = faces[i + 1 + j]
|
||||
v2 = faces[i + 1 + ((j + 1) % vertex_count)]
|
||||
edge = tuple(sorted([v1, v2]))
|
||||
edge_counts[edge] = edge_counts.get(edge, 0) + 1
|
||||
|
||||
i += vertex_count + 1
|
||||
|
||||
return all(count == 2 for count in edge_counts.values())
|
||||
@@ -0,0 +1,17 @@
|
||||
from bpy.types import Object
|
||||
from specklepy.objects.geometry import Point
|
||||
|
||||
|
||||
def point_to_speckle(blender_object: Object, scale_factor: float = 1.0) -> Point:
|
||||
assert blender_object.type == "EMPTY", "Object must be an empty."
|
||||
|
||||
location = blender_object.location
|
||||
|
||||
speckle_point = Point(
|
||||
x=location.x * scale_factor,
|
||||
y=location.y * scale_factor,
|
||||
z=location.z * scale_factor,
|
||||
units="", # TODO: implement units in object level
|
||||
)
|
||||
|
||||
return speckle_point
|
||||
@@ -0,0 +1,85 @@
|
||||
from bpy.types import Object
|
||||
from typing import Optional
|
||||
from specklepy.objects.data_objects import BlenderObject
|
||||
from .curve_to_speckle import curve_to_speckle
|
||||
from .mesh_to_speckle import mesh_to_speckle_meshes
|
||||
from .utils import get_object_id, get_curve_element_id
|
||||
|
||||
|
||||
def convert_to_speckle(
|
||||
blender_object: Object,
|
||||
scale_factor: float = 1.0,
|
||||
units: str = "m",
|
||||
apply_modifiers: bool = True,
|
||||
) -> Optional[BlenderObject]:
|
||||
display_value = []
|
||||
properties = {}
|
||||
|
||||
if blender_object.type == "CURVE":
|
||||
# handle curve modifiers apply_modifiers is True
|
||||
if apply_modifiers and blender_object.modifiers:
|
||||
import bpy
|
||||
|
||||
# Convert curve with modifiers to mesh
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
evaluated_obj = blender_object.evaluated_get(depsgraph)
|
||||
evaluated_mesh = evaluated_obj.to_mesh()
|
||||
|
||||
if evaluated_mesh:
|
||||
meshes = mesh_to_speckle_meshes(
|
||||
blender_object, evaluated_mesh, scale_factor, units
|
||||
)
|
||||
blender_object.to_mesh_clear()
|
||||
if meshes:
|
||||
display_value = meshes
|
||||
else:
|
||||
# curve conversion without modifiers
|
||||
curve_result = curve_to_speckle(blender_object, scale_factor)
|
||||
if curve_result and hasattr(curve_result, "@elements"):
|
||||
display_value = curve_result["@elements"]
|
||||
for i, element in enumerate(display_value):
|
||||
if hasattr(element, "applicationId"):
|
||||
element.applicationId = get_curve_element_id(blender_object, i)
|
||||
elif curve_result:
|
||||
if hasattr(curve_result, "applicationId"):
|
||||
curve_result.applicationId = get_curve_element_id(blender_object, 0)
|
||||
display_value = [curve_result]
|
||||
|
||||
elif blender_object.type == "MESH":
|
||||
# get mesh data - apply modifiers if requested
|
||||
mesh_data = blender_object.data
|
||||
if apply_modifiers and blender_object.modifiers:
|
||||
import bpy
|
||||
|
||||
# use evaluated object to get mesh with modifiers applied
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
evaluated_obj = blender_object.evaluated_get(depsgraph)
|
||||
evaluated_mesh = evaluated_obj.to_mesh()
|
||||
mesh_data = evaluated_mesh
|
||||
|
||||
meshes = mesh_to_speckle_meshes(blender_object, mesh_data, scale_factor, units)
|
||||
|
||||
if (
|
||||
apply_modifiers
|
||||
and blender_object.modifiers
|
||||
and mesh_data != blender_object.data
|
||||
):
|
||||
blender_object.to_mesh_clear()
|
||||
|
||||
if meshes:
|
||||
display_value = meshes
|
||||
|
||||
if not display_value:
|
||||
return None
|
||||
|
||||
if not isinstance(display_value, list):
|
||||
display_value = [display_value]
|
||||
|
||||
return BlenderObject(
|
||||
name=blender_object.name,
|
||||
type=blender_object.type,
|
||||
displayValue=display_value,
|
||||
applicationId=get_object_id(blender_object),
|
||||
properties=properties,
|
||||
units=units,
|
||||
)
|
||||
@@ -0,0 +1,242 @@
|
||||
import bpy
|
||||
from bpy.types import ID, Object
|
||||
import math
|
||||
from typing import Tuple, Optional
|
||||
|
||||
OBJECT_NAME_SPECKLE_SEPARATOR = " -- "
|
||||
SPECKLE_ID_LENGTH = 32
|
||||
_QUICK_TEST_NAME_LENGTH = SPECKLE_ID_LENGTH + len(OBJECT_NAME_SPECKLE_SEPARATOR)
|
||||
|
||||
|
||||
def to_speckle_name(blender_object: bpy.types.ID) -> str:
|
||||
does_name_contain_id = (
|
||||
len(blender_object.name) > _QUICK_TEST_NAME_LENGTH
|
||||
and OBJECT_NAME_SPECKLE_SEPARATOR in blender_object.name
|
||||
)
|
||||
if does_name_contain_id:
|
||||
return blender_object.name.rsplit(OBJECT_NAME_SPECKLE_SEPARATOR, 1)[0]
|
||||
else:
|
||||
return blender_object.name
|
||||
|
||||
|
||||
"""
|
||||
Python implementation of Blender's NURBS curve generation for to Speckle conversion
|
||||
from: https://blender.stackexchange.com/a/34276
|
||||
based on https://projects.blender.org/blender/blender/src/branch/main/source/blender/blenkernel/intern/curve.cc (check old version)
|
||||
"""
|
||||
|
||||
|
||||
def macro_knotsu(nu: bpy.types.Spline) -> int:
|
||||
return nu.order_u + nu.point_count_u + (nu.order_u - 1 if nu.use_cyclic_u else 0)
|
||||
|
||||
|
||||
def macro_segmentsu(nu: bpy.types.Spline) -> int:
|
||||
return nu.point_count_u if nu.use_cyclic_u else nu.point_count_u - 1
|
||||
|
||||
|
||||
def make_knots(nu: bpy.types.Spline) -> list[float]:
|
||||
knots = [0.0] * macro_knotsu(nu)
|
||||
flag = nu.use_endpoint_u + (nu.use_bezier_u << 1)
|
||||
if nu.use_cyclic_u:
|
||||
calc_knots(knots, nu.point_count_u, nu.order_u, 0)
|
||||
else:
|
||||
calc_knots(knots, nu.point_count_u, nu.order_u, flag)
|
||||
return knots
|
||||
|
||||
|
||||
def calc_knots(knots: list[float], point_count: int, order: int, flag: int) -> None:
|
||||
pts_order = point_count + order
|
||||
if flag == 1: # CU_NURB_ENDPOINT
|
||||
k = 0.0
|
||||
for a in range(1, pts_order + 1):
|
||||
knots[a - 1] = k
|
||||
if a >= order and a <= point_count:
|
||||
k += 1.0
|
||||
elif flag == 2: # CU_NURB_BEZIER
|
||||
if order == 4:
|
||||
k = 0.34
|
||||
for a in range(pts_order):
|
||||
knots[a] = math.floor(k)
|
||||
k += 1.0 / 3.0
|
||||
elif order == 3:
|
||||
k = 0.6
|
||||
for a in range(pts_order):
|
||||
if a >= order and a <= point_count:
|
||||
k += 0.5
|
||||
knots[a] = math.floor(k)
|
||||
else:
|
||||
for a in range(1, len(knots) - 1):
|
||||
knots[a] = a - 1
|
||||
|
||||
knots[-1] = knots[-2]
|
||||
|
||||
|
||||
def basis_nurb(
|
||||
t: float,
|
||||
order: int,
|
||||
point_count: int,
|
||||
knots: list[float],
|
||||
basis: list[float],
|
||||
start: int,
|
||||
end: int,
|
||||
) -> Tuple[int, int]:
|
||||
i1 = i2 = 0
|
||||
orderpluspnts = order + point_count
|
||||
opp2 = orderpluspnts - 1
|
||||
|
||||
# this is for float inaccuracy
|
||||
if t < knots[0]:
|
||||
t = knots[0]
|
||||
elif t > knots[opp2]:
|
||||
t = knots[opp2]
|
||||
|
||||
# this part is order '1'
|
||||
o2 = order + 1
|
||||
for i in range(opp2):
|
||||
if knots[i] != knots[i + 1] and t >= knots[i] and t <= knots[i + 1]:
|
||||
basis[i] = 1.0
|
||||
i1 = i - o2
|
||||
if i1 < 0:
|
||||
i1 = 0
|
||||
i2 = i
|
||||
i += 1
|
||||
while i < opp2:
|
||||
basis[i] = 0.0
|
||||
i += 1
|
||||
break
|
||||
|
||||
else:
|
||||
basis[i] = 0.0
|
||||
|
||||
basis[i] = 0.0 # type: ignore
|
||||
|
||||
# this is order 2, 3, ...
|
||||
for j in range(2, order + 1):
|
||||
if i2 + j >= orderpluspnts:
|
||||
i2 = opp2 - j
|
||||
|
||||
for i in range(i1, i2 + 1):
|
||||
if basis[i] != 0.0:
|
||||
d = ((t - knots[i]) * basis[i]) / (knots[i + j - 1] - knots[i])
|
||||
else:
|
||||
d = 0.0
|
||||
|
||||
if basis[i + 1] != 0.0:
|
||||
e = ((knots[i + j] - t) * basis[i + 1]) / (knots[i + j] - knots[i + 1])
|
||||
else:
|
||||
e = 0.0
|
||||
|
||||
basis[i] = d + e
|
||||
|
||||
start = 1000
|
||||
end = 0
|
||||
|
||||
for i in range(i1, i2 + 1):
|
||||
if basis[i] > 0.0:
|
||||
end = i
|
||||
if start == 1000:
|
||||
start = i
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def nurb_make_curve(nu: bpy.types.Spline, resolu: int, stride: int = 3) -> list[float]:
|
||||
""" "BKE_nurb_makeCurve"""
|
||||
EPS = 1e-6
|
||||
coord_index = istart = iend = 0
|
||||
|
||||
coord_array = [0.0] * (3 * nu.resolution_u * macro_segmentsu(nu))
|
||||
sum_array = [0] * nu.point_count_u
|
||||
basisu = [0.0] * macro_knotsu(nu)
|
||||
knots = make_knots(nu)
|
||||
|
||||
resolu = resolu * macro_segmentsu(nu)
|
||||
ustart = knots[nu.order_u - 1]
|
||||
uend = (
|
||||
knots[nu.point_count_u + nu.order_u - 1]
|
||||
if nu.use_cyclic_u
|
||||
else knots[nu.point_count_u]
|
||||
)
|
||||
ustep = (uend - ustart) / (resolu - (0 if nu.use_cyclic_u else 1))
|
||||
cycl = nu.order_u - 1 if nu.use_cyclic_u else 0
|
||||
|
||||
u = ustart
|
||||
while resolu:
|
||||
resolu -= 1
|
||||
istart, iend = basis_nurb(
|
||||
u, nu.order_u, nu.point_count_u + cycl, knots, basisu, istart, iend
|
||||
)
|
||||
|
||||
# /* calc sum */
|
||||
sumdiv = 0.0
|
||||
sum_index = 0
|
||||
pt_index = istart - 1
|
||||
for i in range(istart, iend + 1):
|
||||
if i >= nu.point_count_u:
|
||||
pt_index = i - nu.point_count_u
|
||||
else:
|
||||
pt_index += 1
|
||||
|
||||
sum_array[sum_index] = basisu[i] * nu.points[pt_index].co[3] # type: ignore
|
||||
sumdiv += sum_array[sum_index]
|
||||
sum_index += 1
|
||||
|
||||
if (sumdiv != 0.0) and (sumdiv < 1.0 - EPS or sumdiv > 1.0 + EPS):
|
||||
sum_index = 0
|
||||
for i in range(istart, iend + 1):
|
||||
sum_array[sum_index] /= sumdiv # type: ignore
|
||||
sum_index += 1
|
||||
|
||||
coord_array[coord_index : coord_index + 3] = (0.0, 0.0, 0.0)
|
||||
|
||||
sum_index = 0
|
||||
pt_index = istart - 1
|
||||
for i in range(istart, iend + 1):
|
||||
if i >= nu.point_count_u:
|
||||
pt_index = i - nu.point_count_u
|
||||
else:
|
||||
pt_index += 1
|
||||
|
||||
if sum_array[sum_index] != 0.0:
|
||||
for j in range(3):
|
||||
coord_array[coord_index + j] += (
|
||||
sum_array[sum_index] * nu.points[pt_index].co[j]
|
||||
)
|
||||
sum_index += 1
|
||||
|
||||
coord_index += stride
|
||||
u += ustep
|
||||
|
||||
return coord_array
|
||||
|
||||
|
||||
def get_unique_id(native_object: ID, suffix: Optional[str] = None) -> str:
|
||||
base_id = f"{type(native_object).__name__}:{native_object.name_full}"
|
||||
|
||||
if suffix:
|
||||
return f"{base_id}:{suffix}"
|
||||
|
||||
return base_id
|
||||
|
||||
|
||||
def get_submesh_id(blender_object: Object, material_index: int) -> str:
|
||||
mesh_data = blender_object.data
|
||||
if not mesh_data:
|
||||
return f"Mesh:{blender_object.name_full}_mat{material_index}"
|
||||
|
||||
return f"Mesh:{mesh_data.name_full}_mat{material_index}"
|
||||
|
||||
|
||||
def get_curve_element_id(blender_object: Object, curve_index: int = 0) -> str:
|
||||
curve_data = blender_object.data
|
||||
if not curve_data:
|
||||
return f"Curve:{blender_object.name_full}_curve{curve_index}"
|
||||
|
||||
if curve_index == 0:
|
||||
return f"Curve:{curve_data.name_full}"
|
||||
|
||||
return f"Curve:{curve_data.name_full}_curve{curve_index}"
|
||||
|
||||
|
||||
def get_object_id(blender_object: Object) -> str:
|
||||
return get_unique_id(blender_object)
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Tuple, List, Optional
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
import bpy
|
||||
import mathutils
|
||||
from specklepy.objects import Base
|
||||
from specklepy.objects.graph_traversal.default_traversal import (
|
||||
create_default_traversal_function,
|
||||
)
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
|
||||
|
||||
def to_rgba(argb_int: int) -> Tuple[float, float, float, float]:
|
||||
@@ -59,13 +60,11 @@ def create_material_from_proxy(
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
if hasattr(render_material, "opacity"):
|
||||
opacity = float(render_material.opacity)
|
||||
if opacity < 1.0:
|
||||
material.blend_method = "BLEND"
|
||||
bsdf.inputs["Alpha"].default_value = opacity
|
||||
|
||||
|
||||
if hasattr(render_material, "metalness"):
|
||||
metalness = float(render_material.metalness)
|
||||
@@ -88,10 +87,15 @@ def create_material_from_proxy(
|
||||
1.0,
|
||||
)
|
||||
bsdf.inputs["Emission Strength"].default_value = 1.0
|
||||
|
||||
|
||||
# set viewport display color
|
||||
if hasattr(render_material, "diffuse") and hasattr(render_material, "opacity"):
|
||||
material.diffuse_color = (diffuse_rgba[0], diffuse_rgba[1], diffuse_rgba[2], opacity)
|
||||
material.diffuse_color = (
|
||||
diffuse_rgba[0],
|
||||
diffuse_rgba[1],
|
||||
diffuse_rgba[2],
|
||||
opacity,
|
||||
)
|
||||
|
||||
return material
|
||||
|
||||
@@ -114,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]:
|
||||
"""
|
||||
finds an object using traversal, checking both id and applicationId
|
||||
@@ -183,3 +206,21 @@ def find_object_by_id(root_object: Base, target_id: str) -> Optional[Base]:
|
||||
return None
|
||||
|
||||
return deep_search(root_object)
|
||||
|
||||
|
||||
def get_project_workspace_id(client: SpeckleClient, project_id: str) -> Optional[str]:
|
||||
workspace_id = None
|
||||
server_version = client.project.server_version or client.server.version()
|
||||
|
||||
# Local yarn builds of server will report a server version if "dev"
|
||||
# We'll assume that local builds are up-to-date with the latest features
|
||||
if server_version[0] == "dev":
|
||||
maj = 999
|
||||
min = 999
|
||||
else:
|
||||
maj = server_version[0]
|
||||
min = server_version[1]
|
||||
|
||||
if maj > 2 or (maj == 2 and min > 20):
|
||||
workspace_id = client.project.get(project_id).workspace_id
|
||||
return workspace_id
|
||||
|
||||
+28
-47
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Provides uniform and consistent path helpers for `specklepy`
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -55,9 +56,7 @@ def user_application_data_path() -> Path:
|
||||
if sys.platform.startswith("win"):
|
||||
app_data_path = os.getenv("APPDATA")
|
||||
if not app_data_path:
|
||||
raise Exception(
|
||||
"Cannot get appdata path from environment."
|
||||
)
|
||||
raise Exception("Cannot get appdata path from environment.")
|
||||
return Path(app_data_path)
|
||||
else:
|
||||
# try getting the standard XDG_DATA_HOME value
|
||||
@@ -68,9 +67,7 @@ def user_application_data_path() -> Path:
|
||||
else:
|
||||
return _ensure_folder_exists(Path.home(), ".config")
|
||||
except Exception as ex:
|
||||
raise Exception(
|
||||
"Failed to initialize user application data path.", ex
|
||||
)
|
||||
raise Exception("Failed to initialize user application data path.", ex)
|
||||
|
||||
|
||||
def user_speckle_folder_path() -> Path:
|
||||
@@ -90,19 +87,16 @@ def user_speckle_connector_installation_path(host_application: str) -> Path:
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
print("Starting module dependency installation")
|
||||
print(sys.executable)
|
||||
|
||||
PYTHON_PATH = sys.executable
|
||||
|
||||
|
||||
|
||||
def connector_installation_path(host_application: str) -> Path:
|
||||
connector_installation_path = user_speckle_connector_installation_path(host_application)
|
||||
connector_installation_path = user_speckle_connector_installation_path(
|
||||
host_application
|
||||
)
|
||||
connector_installation_path.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# set user modules path at beginning of paths for earlier hit
|
||||
@@ -113,7 +107,6 @@ def connector_installation_path(host_application: str) -> Path:
|
||||
return connector_installation_path
|
||||
|
||||
|
||||
|
||||
def is_pip_available() -> bool:
|
||||
try:
|
||||
import_module("pip") # noqa F401
|
||||
@@ -132,25 +125,9 @@ def ensure_pip() -> None:
|
||||
if completed_process.returncode == 0:
|
||||
print("Successfully installed pip")
|
||||
else:
|
||||
raise Exception(f"Failed to install pip, got {completed_process.returncode} return code")
|
||||
|
||||
|
||||
def is_uv_available() -> bool:
|
||||
try:
|
||||
import_module("uv") # noqa F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_uv() -> None:
|
||||
print("Installing uv... ")
|
||||
from subprocess import run
|
||||
completed_process = run([PYTHON_PATH, "-m", "pip", "install", "uv"])
|
||||
if completed_process.returncode == 0:
|
||||
print("Successfully installed uv")
|
||||
else:
|
||||
raise Exception(f"Failed to install uv, got {completed_process.returncode} return code")
|
||||
raise Exception(
|
||||
f"Failed to install pip, got {completed_process.returncode} return code"
|
||||
)
|
||||
|
||||
|
||||
def get_requirements_path() -> Path:
|
||||
@@ -169,12 +146,12 @@ def install_requirements(host_application: str) -> None:
|
||||
|
||||
def debugger_is_active() -> bool:
|
||||
"""Return if the debugger is currently active"""
|
||||
return hasattr(sys, 'gettrace') and sys.gettrace() is not None
|
||||
|
||||
return hasattr(sys, "gettrace") and sys.gettrace() is not None
|
||||
|
||||
requirements_path = get_requirements_path()
|
||||
|
||||
is_debug = debugger_is_active()
|
||||
|
||||
|
||||
if not is_debug and not requirements_path.exists():
|
||||
print("Skipped installing dependencies")
|
||||
return
|
||||
@@ -184,11 +161,15 @@ def install_requirements(host_application: str) -> None:
|
||||
[
|
||||
PYTHON_PATH,
|
||||
"-m",
|
||||
"uv",
|
||||
"pip",
|
||||
"-q",
|
||||
"--disable-pip-version-check",
|
||||
"install",
|
||||
"--system",
|
||||
"--target",
|
||||
"--prefer-binary",
|
||||
"--ignore-installed",
|
||||
"--no-compile",
|
||||
"--no-deps",
|
||||
"-t",
|
||||
str(path),
|
||||
"-r",
|
||||
str(requirements_path),
|
||||
@@ -198,10 +179,12 @@ def install_requirements(host_application: str) -> None:
|
||||
)
|
||||
|
||||
if completed_process.returncode != 0:
|
||||
m = f"Failed to install dependencies through uv, got {completed_process.returncode} return code"
|
||||
print(completed_process.stdout)
|
||||
print(completed_process.stderr)
|
||||
m = f"Failed to install dependencies through pip, got {completed_process.returncode} return code"
|
||||
print(m)
|
||||
raise Exception(m)
|
||||
|
||||
|
||||
print("Successfully installed dependencies")
|
||||
|
||||
if not is_debug:
|
||||
@@ -211,9 +194,6 @@ def install_requirements(host_application: str) -> None:
|
||||
def install_dependencies(host_application: str) -> None:
|
||||
if not is_pip_available():
|
||||
ensure_pip()
|
||||
|
||||
if not is_uv_available():
|
||||
ensure_uv()
|
||||
|
||||
install_requirements(host_application)
|
||||
|
||||
@@ -223,7 +203,7 @@ def _import_dependencies() -> None:
|
||||
# the code above doesn't work for now, it fails on importing graphql-core
|
||||
# despite that, the connector seams to be working as expected
|
||||
# But it would be nice to make this solution work
|
||||
# it would ensure that all dependencies are fully loaded
|
||||
# it would ensure that all dependencies are fully loaded
|
||||
# requirements = get_requirements_path().read_text()
|
||||
# reqs = [
|
||||
# req.split(" ; ")[0].split("==")[0].split("[")[0].replace("-", "_")
|
||||
@@ -234,6 +214,7 @@ def _import_dependencies() -> None:
|
||||
# print(req)
|
||||
# import_module("specklepy")
|
||||
|
||||
|
||||
def ensure_dependencies(host_application: str) -> None:
|
||||
try:
|
||||
install_dependencies(host_application)
|
||||
@@ -241,6 +222,6 @@ def ensure_dependencies(host_application: str) -> None:
|
||||
_import_dependencies()
|
||||
print("Successfully found dependencies")
|
||||
except ImportError:
|
||||
raise Exception(f"Cannot automatically ensure Speckle dependencies. Please try restarting the host application {host_application}!")
|
||||
|
||||
|
||||
raise Exception(
|
||||
f"Cannot automatically ensure Speckle dependencies. Please try restarting the host application {host_application}!"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e -o pipefail
|
||||
|
||||
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --all-extras
|
||||
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
|
||||
|
||||
+10
-5
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def patch_addon(simple_version: str):
|
||||
"""Patches the __init__.py bl_info version within the connector init file"""
|
||||
FILE_PATH = "bpy_speckle/__init__.py"
|
||||
@@ -9,13 +10,16 @@ def patch_addon(simple_version: str):
|
||||
with open(FILE_PATH, "r") as file:
|
||||
lines = file.readlines()
|
||||
|
||||
for (index, line) in enumerate(lines):
|
||||
for index, line in enumerate(lines):
|
||||
if '"version":' in line:
|
||||
lines[index] = f' "version": ({version[0]}, {version[1]}, {version[2]}),\n'
|
||||
lines[index] = (
|
||||
f' "version": ({version[0]}, {version[1]}, {version[2]}),\n'
|
||||
)
|
||||
|
||||
with open(FILE_PATH, "w") as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def patch_manifest(simple_version: str):
|
||||
"""Patches the connector version within the connector init file"""
|
||||
FILE_PATH = "bpy_speckle/blender_manifest.toml"
|
||||
@@ -24,15 +28,16 @@ def patch_manifest(simple_version: str):
|
||||
with open(FILE_PATH, "r") as file:
|
||||
lines = file.readlines()
|
||||
|
||||
for (index, line) in enumerate(lines):
|
||||
if line.startswith('version ='):
|
||||
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}",\n'
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("version ="):
|
||||
lines[index] = f'version = "{version[0]}.{version[1]}.{version[2]}"\n'
|
||||
print(f"Patched connector version number in {FILE_PATH}")
|
||||
break
|
||||
|
||||
with open(FILE_PATH, "w") as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def main():
|
||||
tag = sys.argv[1]
|
||||
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
|
||||
|
||||
+4
-4
@@ -5,12 +5,12 @@ description = "Next-Gen Speckle connector for Blender!"
|
||||
requires-python = ">=3.11.9, <4.0.0"
|
||||
license = "Apache-2.0"
|
||||
dependencies = [
|
||||
"specklepy==3.0.0a15",
|
||||
"specklepy>=3.2.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"fake-bpy-module-latest>=20240524,<20240525",
|
||||
"ruff>=0.4.4,<0.5",
|
||||
"fake-bpy-module-latest>=20260126",
|
||||
"ruff==0.14.14",
|
||||
"pre-commit>=4.0.1",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user