Compare commits

...

25 Commits

Author SHA1 Message Date
Dogukan Karatas add470699b Merge pull request #314 from specklesystems/dogukan/cnx-3036-blender
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
feat: add model and version permission check
2026-02-02 14:32:18 +01:00
Dogukan Karatas f5bcd805e8 Merge branch 'v3-dev' into dogukan/cnx-3036-blender 2026-02-02 14:30:38 +01:00
Dogukan Karatas cf4bb14240 Merge pull request #316 from specklesystems/dogukan/cnx-3003-blender
feat: add model ingestion support
2026-02-02 14:26:42 +01:00
Dogukan Karatas 1ee1650fef error messages implemented 2026-02-02 13:05:44 +01:00
Dogukan Karatas 70f5f672a6 Merge branch 'v3-dev' into dogukan/cnx-3003-blender 2026-02-02 09:57:46 +01:00
Dogukan Karatas b329ec8c97 Merge pull request #315 from specklesystems/dogukan/cnx-2996-blender
feat: remove personal projects
2026-02-02 09:57:24 +01:00
bimgeek b54cfe16e8 fix result version id 2026-01-30 21:54:17 +03:00
bimgeek 357859827c fix model ingestion inputs 2026-01-30 21:47:38 +03:00
Dogukan Karatas f35457dff8 pass file size 2026-01-30 17:11:06 +01:00
Dogukan Karatas f993c38ea9 selection dialog fix 2026-01-30 16:59:30 +01:00
Dogukan Karatas 624537cc5d implements model ingestion check 2026-01-30 12:33:16 +01:00
Dogukan Karatas ebb7f1b3bf formatted 2026-01-30 09:12:38 +01:00
Dogukan Karatas ac2a95d968 removes personal projects 2026-01-30 09:08:29 +01:00
Dogukan Karatas 2440c44f44 formatted 2026-01-29 17:21:43 +01:00
Dogukan Karatas 33dfa1229c can create version checks 2026-01-29 16:37:35 +01:00
Jedd Morgan ea61bd06b8 feat(receive): Call MarkVersionReceived (#312)
* Enable ruff pre-commit

* bump ruff

* Call `client.version.received`
2026-01-27 09:35:43 +00:00
Jedd Morgan e071aca299 Update readme with local dev instructions (#311) 2026-01-26 15:03:58 +00:00
Jedd Morgan 4a8a980034 chore(ci): enable pre-commit (#309)
* Enable ruff pre-commit

* bump ruff
2026-01-26 15:03:07 +00:00
Dogukan Karatas b05447dc30 Merge pull request #308 from specklesystems/dogukan/ssl-match-localhost
Release workflow / Build Zip (push) Has been cancelled
Release workflow / deploy-installers (push) Has been cancelled
fix: skip ssl use for http servers
2026-01-20 09:44:30 +01:00
Dogukan Karatas 7a36f9ec08 check url 2026-01-20 09:29:14 +01:00
Mucahit Bilal GOKER 80e3971706 Show update button in connector ui (#297)
* bump specklepy

* update button first pass

* clear timer on unregister

* remove unnecessary specklepy import handling

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2026-01-19 14:06:07 +03:00
Dogukan Karatas dc770b7a79 Merge pull request #307 from specklesystems/dogukan/cnx-1776-typeerror-bpy_prop_collection__contains__-expected-a-string
fix: handle null material name
2026-01-08 18:09:41 +01:00
Dogukan Karatas f8e7d391be handle none 2026-01-08 17:59:06 +01:00
Mucahit Bilal GOKER 3092ba3056 separate panel for model cards (#292)
Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2026-01-08 16:05:54 +03:00
Mucahit Bilal GOKER 9d10006116 bump specklepy (#306) 2026-01-06 12:41:32 +03:00
27 changed files with 1425 additions and 719 deletions
+1 -1
View File
@@ -14,4 +14,4 @@ workflows:
when:
false
jobs:
- build
- build
+6 -6
View File
@@ -19,13 +19,13 @@ jobs:
- name: Install the project
run: uv sync --all-extras --dev
# - uses: actions/cache@v3
# with:
# path: ~/.cache/pre-commit/
# key: ${{ hashFiles('.pre-commit-config.yaml') }}
- uses: actions/cache@v3
with:
path: ~/.cache/pre-commit/
key: ${{ hashFiles('.pre-commit-config.yaml') }}
# - name: Run pre-commit
# run: uv run pre-commit run --all-files
- name: Run pre-commit
run: uv run pre-commit run --all-files
- name: Minimize uv cache
run: uv cache prune --ci
+1 -1
View File
@@ -14,4 +14,4 @@ modules/
.tool-versions
requirements.txt
SEMVER
dui3/
dui3/
+21
View File
@@ -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
+36 -6
View File
@@ -39,16 +39,16 @@ Give Speckle a try in no time by:
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-docs.speckle.systems-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://docs.speckle.systems/connectors/blender) reference on almost any end-user and developer functionality
# 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.
@@ -62,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.
@@ -77,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.
@@ -88,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!
+27
View File
@@ -34,6 +34,8 @@ bl_info = {
# UI
from .connector.ui.main_panel import SPECKLE_PT_main_panel
from .connector.ui.update_panel import SPECKLE_PT_update_panel
from .connector.ui.model_cards_panel import SPECKLE_PT_model_cards_panel
from .connector.utils.account_manager import speckle_workspace
from .connector.ui.project_selection_dialog import (
SPECKLE_OT_project_selection_dialog,
@@ -80,6 +82,8 @@ from .connector.blender_operators.add_project_by_url import (
from .connector.blender_operators.create_project import SPECKLE_OT_create_project
from .connector.blender_operators.create_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,
@@ -105,6 +109,14 @@ from .connector.ui.account_selection_dialog import (
)
def delayed_version_check():
"""Timer function to check for updates after addon startup"""
try:
bpy.ops.speckle.version_check()
except Exception as e:
print(f"[Speckle] Failed to check for updates: {e}")
def invoke_window_manager_properties():
# Accounts
WindowManager.speckle_accounts = bpy.props.CollectionProperty(type=speckle_account)
@@ -139,11 +151,17 @@ def invoke_window_manager_properties():
)
# Objects
WindowManager.speckle_objects = bpy.props.CollectionProperty(type=speckle_object)
# Update checking
WindowManager.update_available = bpy.props.BoolProperty(default=False)
WindowManager.latest_version = bpy.props.StringProperty(default="")
WindowManager.update_url = bpy.props.StringProperty(default="")
# Classes to load
classes = (
SPECKLE_PT_update_panel,
SPECKLE_PT_main_panel,
SPECKLE_PT_model_cards_panel,
SPECKLE_OT_publish,
SPECKLE_OT_load,
SPECKLE_OT_project_selection_dialog,
@@ -171,6 +189,8 @@ classes = (
SPECKLE_OT_add_project_by_url,
SPECKLE_OT_create_project,
SPECKLE_OT_create_model,
SPECKLE_OT_version_check,
SPECKLE_OT_update_button,
speckle_account,
SPECKLE_UL_workspaces_list,
SPECKLE_OT_workspace_selection_dialog,
@@ -203,8 +223,15 @@ def register():
except Exception as e:
print(f"[Speckle] Failed to pre-warm client: {e}")
# Use a timer to delay the version check
bpy.app.timers.register(delayed_version_check, first_interval=2.0)
def unregister():
# Clear any pending timers to prevent duplicate calls
if bpy.app.timers.is_registered(delayed_version_check):
bpy.app.timers.unregister(delayed_version_check)
icons.unload_icons()
unregister_speckle_state() # Unregister SpeckleState
_client_cache.clear()
@@ -3,7 +3,7 @@ from bpy.types import Context, Event, UILayout
from specklepy.core.api.inputs import CreateModelInput
from typing import Tuple
from ..utils.account_manager import _client_cache
from ..utils.account_manager import _client_cache, can_create_model
class SPECKLE_OT_create_model(bpy.types.Operator):
@@ -11,11 +11,26 @@ class SPECKLE_OT_create_model(bpy.types.Operator):
bl_label = "Create Model"
bl_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"}
@@ -1,10 +1,9 @@
import bpy
from bpy.types import Context, Event, UILayout
from specklepy.core.api.inputs import ProjectCreateInput
from specklepy.core.api.inputs.project_inputs import WorkspaceProjectCreateInput
from specklepy.core.api.enums import ProjectVisibility
from typing import Tuple, Optional
from typing import Tuple
from ..utils.account_manager import _client_cache
@@ -25,9 +24,7 @@ class SPECKLE_OT_create_project(bpy.types.Operator):
project_id, project_name = create_project(
wm.selected_account_id,
self.project_name,
None
if wm.selected_workspace.id == "personal"
else wm.selected_workspace.id,
wm.selected_workspace.id,
)
wm.selected_project_id = project_id
wm.selected_project_name = project_name
@@ -54,30 +51,21 @@ def unregister() -> None:
def create_project(
account_id: str, project_name: str, workspace_id: Optional[str]
account_id: str, project_name: str, workspace_id: str
) -> Tuple[str, str]:
try:
# Get cached client
client = _client_cache.get_client(account_id)
if not client:
raise Exception(f"Could not get client for account: {account_id}")
if workspace_id:
project = client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
workspaceId=workspace_id,
)
)
else:
project = client.project.create(
input=ProjectCreateInput(
name=project_name,
description="",
visibility=ProjectVisibility("PUBLIC"),
)
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:
@@ -2,6 +2,7 @@ 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):
@@ -27,6 +28,14 @@ class SPECKLE_OT_publish_model_card(bpy.types.Operator):
self.model_card_id
)
# On-demand permission check
authorized, auth_message = can_create_version(
model_card.account_id, model_card.project_id, model_card.model_id
)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
# set wm
wm.selected_account_id = model_card.account_id
wm.selected_project_id = model_card.project_id
@@ -4,7 +4,7 @@ from bpy.types import Event
from typing import Set
from ..operations.publish_operation import publish_operation
from ..utils.account_manager import get_server_url_by_account_id
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
@@ -55,6 +55,11 @@ class SPECKLE_OT_publish(bpy.types.Operator):
self.report({"ERROR"}, "No model selected")
return {"CANCELLED"}
authorized, auth_message = can_create_version(account_id, project_id, model_id)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
objects_to_convert = []
for speckle_obj in wm.speckle_objects:
blender_obj = bpy.data.objects.get(speckle_obj.name)
@@ -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,29 +1,29 @@
from typing import Dict, Union
import bpy
from bpy.types import Context
from specklepy.transports.server import ServerTransport
from specklepy.core.api import operations
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.core.api import host_applications, operations
from specklepy.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.core.api import host_applications
from specklepy.objects.models.collections.collection import Collection as SCollection
from specklepy.transports.server import ServerTransport
from ..utils.get_ascendants import get_ascendants
from ..utils.account_manager import _client_cache
from ...converter.utils import (
find_object_by_id,
get_project_workspace_id,
build_object_id_map,
)
from ... import bl_info
from ...converter.to_native import (
convert_to_native,
render_material_proxy_to_native,
instance_definition_proxy_to_native,
find_instance_definitions,
instance_definition_proxy_to_native,
render_material_proxy_to_native,
)
from specklepy.logging import metrics
from ... import bl_info
from typing import Dict, Union
from ...converter.utils import (
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(
@@ -34,53 +34,50 @@ def load_operation(
"""
wm = context.window_manager
accountId: str = wm.selected_account_id # type: ignore
projectId: str = wm.selected_project_id # type: ignore
versionId: str = wm.selected_version_id # type: ignore
# get cached client
client = _client_cache.get_client(context.window_manager.selected_account_id)
client = _client_cache.get_client(accountId)
if not client:
print("No Speckle client found")
return {}
print(f"Using client for account: {context.window_manager.selected_account_id}")
print(f"Using client for account: {accountId}")
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
transport = ServerTransport(stream_id=projectId, client=client)
version = client.version.get(wm.selected_version_id, wm.selected_project_id)
version = client.version.get(versionId, projectId)
obj_id = version.referenced_object
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")
# Get account for metrics tracking
from specklepy.core.api.credentials import get_local_accounts
account = next(
(
acc
for acc in get_local_accounts()
if acc.id == context.window_manager.selected_account_id
),
None,
client.version.received(
MarkReceivedVersionInput(
version_id=version.id,
project_id=projectId,
source_application="blender",
)
)
if account:
metrics.track(
metrics.RECEIVE,
account,
{
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"sourceHostApp": host_applications.get_host_app_from_string(
version.source_application
).slug,
"isMultiplayer": version.author_user.id != account.userInfo.id,
"workspace_id": get_project_workspace_id(
client, wm.selected_project_id
),
},
)
metrics.track(
metrics.RECEIVE,
client.account,
{
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"sourceHostApp": host_applications.get_host_app_from_string(
version.source_application
).slug,
"isMultiplayer": version.author_user.id != client.account.userInfo.id,
"workspace_id": get_project_workspace_id(client, wm.selected_project_id),
},
)
# Build object ID map once
object_id_map = build_object_id_map(version_data)
@@ -8,6 +8,14 @@ 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 (
@@ -19,6 +27,108 @@ 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,
@@ -36,7 +146,13 @@ def publish_operation(
if not client:
return False, "No Speckle client found", None
transport = ServerTransport(stream_id=wm.selected_project_id, client=client)
project_id = wm.selected_project_id
model_id = wm.selected_model_id
# check ingestion support before sending data (fail fast on permission errors)
use_ingestion = _check_use_model_ingestion_send(client, project_id, model_id)
transport = ServerTransport(stream_id=project_id, client=client)
# build collection hierarchy and convert objects
root_collection = build_collection_hierarchy(
@@ -51,16 +167,14 @@ def publish_operation(
obj_id = operations.send(root_collection, [transport])
version_input = CreateVersionInput(
objectId=obj_id,
modelId=wm.selected_model_id,
projectId=wm.selected_project_id,
message=version_message,
sourceApplication="blender",
)
version = client.version.create(version_input)
version_id = version.id
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
@@ -80,9 +194,7 @@ def publish_operation(
"ui": "dui3",
"hostAppVersion": ".".join(map(str, bl_info["blender"])),
"core_version": ".".join(map(str, bl_info["version"])),
"workspace_id": get_project_workspace_id(
client, wm.selected_project_id
),
"workspace_id": get_project_workspace_id(client, project_id),
},
)
@@ -95,6 +207,9 @@ def publish_operation(
version_id,
)
except WorkspacePermissionException as e:
return False, f"Permission denied: {str(e)}", None
except Exception as e:
import traceback
@@ -126,8 +126,8 @@ def update_workspaces_list(context: Context) -> None:
active_workspace = get_active_workspace(wm.selected_account_id)
if active_workspace:
wm.selected_workspace.id = active_workspace["id"]
else:
wm.selected_workspace.id = "personal"
elif wm.speckle_workspaces:
wm.selected_workspace.id = wm.speckle_workspaces[0].id
print("Updated Workspaces List!")
-67
View File
@@ -104,70 +104,3 @@ class SPECKLE_PT_main_panel(bpy.types.Panel):
row = layout.row()
row.enabled = project_selected and model_selected and version_selected
row.operator("speckle.load", text="Load Model", icon="IMPORT")
layout.separator()
# group model cards by project name
project_groups = {}
for model_card in context.scene.speckle_state.model_cards:
project_name = (
model_card.project_name if model_card.project_name else "No Project"
)
if project_name not in project_groups:
project_groups[project_name] = []
project_groups[project_name].append(model_card)
for project_name, model_cards in project_groups.items():
project_box = layout.box()
project_row = project_box.row()
project_row.label(text=f"Project: {project_name}", icon="TRIA_RIGHT")
for model_card in model_cards:
box: UILayout = project_box.box()
row_1: UILayout = box.row()
row_2: UILayout = box.row()
if model_card.is_publish:
# Publish button in the model card
row_1.operator(
"speckle.model_card_publish", text="", icon="EXPORT"
).model_card_id = model_card.get_model_card_id()
# Selection filter button in the model card
row_2.operator(
"speckle.selection_filter_dialog",
text=f"Selection: {len(model_card.objects)} objects",
).model_card_id = model_card.get_model_card_id()
elif not model_card.is_publish:
# Load button in the model card
row_1.operator(
"speckle.model_card_load", text="", icon="IMPORT"
).model_card_id = model_card.get_model_card_id()
version_button_text = (
f"Latest: {model_card.version_id}"
if model_card.load_option == "LATEST"
else f"{model_card.version_id}"
)
row_2.operator(
"speckle.version_selection_dialog",
text=version_button_text,
).model_card_id = model_card.get_model_card_id()
# TODO: Get last updated time
else:
print({"ERROR"}, "Model card state unknown")
return
row_1.label(text=f"{model_card.model_name}")
# Select button in the model card
select_op = row_1.operator(
"speckle.select_objects",
text="",
icon_value=get_icon("object_highlight"),
)
select_op.model_card_id = model_card.get_model_card_id()
# Settings button in the model card
row_1.operator(
"speckle.model_card_settings", text="", icon="COLLAPSEMENU"
).model_card_id = model_card.get_model_card_id()
@@ -0,0 +1,89 @@
import bpy
from bpy.types import UILayout, Context
from .icons import get_icon
class SPECKLE_PT_model_cards_panel(bpy.types.Panel):
"""
Panel for displaying Speckle model cards.
"""
bl_label = "Model Cards"
bl_idname = "SPECKLE_PT_model_cards_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Speckle"
bl_order = 1
@classmethod
def poll(cls, context: Context) -> bool:
"""Only show panel when model cards exist"""
return bool(context.scene.speckle_state.model_cards)
def draw(self, context: Context) -> None:
layout: UILayout = self.layout
# group model cards by project name
project_groups = {}
for model_card in context.scene.speckle_state.model_cards:
project_name = (
model_card.project_name if model_card.project_name else "No Project"
)
if project_name not in project_groups:
project_groups[project_name] = []
project_groups[project_name].append(model_card)
for project_name, model_cards in project_groups.items():
project_box = layout.box()
project_row = project_box.row()
project_row.label(text=f"Project: {project_name}", icon="TRIA_RIGHT")
for model_card in model_cards:
box: UILayout = project_box.box()
row_1: UILayout = box.row()
row_2: UILayout = box.row()
if model_card.is_publish:
# Publish button in the model card
row_1.operator(
"speckle.model_card_publish", text="", icon="EXPORT"
).model_card_id = model_card.get_model_card_id()
# Selection filter button in the model card
row_2.operator(
"speckle.selection_filter_dialog",
text=f"Selection: {len(model_card.objects)} objects",
).model_card_id = model_card.get_model_card_id()
elif not model_card.is_publish:
# Load button in the model card
row_1.operator(
"speckle.model_card_load", text="", icon="IMPORT"
).model_card_id = model_card.get_model_card_id()
version_button_text = (
f"Latest: {model_card.version_id}"
if model_card.load_option == "LATEST"
else f"{model_card.version_id}"
)
row_2.operator(
"speckle.version_selection_dialog",
text=version_button_text,
).model_card_id = model_card.get_model_card_id()
# TODO: Get last updated time
else:
print({"ERROR"}, "Model card state unknown")
return
row_1.label(text=f"{model_card.model_name}")
# Select button in the model card
select_op = row_1.operator(
"speckle.select_objects",
text="",
icon_value=get_icon("object_highlight"),
)
select_op.model_card_id = model_card.get_model_card_id()
# Settings button in the model card
row_1.operator(
"speckle.model_card_settings", text="", icon="COLLAPSEMENU"
).model_card_id = model_card.get_model_card_id()
@@ -2,6 +2,8 @@ import bpy
from bpy.types import UILayout, Context, PropertyGroup, Event
from ..utils.model_manager import get_models_for_project
from ..utils.version_manager import get_latest_version
from ..utils.account_manager import can_create_model
from ..blender_operators.create_model import SPECKLE_OT_create_model
class SPECKLE_UL_models_list(bpy.types.UIList):
@@ -94,6 +96,11 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
def invoke(self, context: Context, event: Event) -> set[str]:
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:
@@ -104,7 +111,9 @@ class SPECKLE_OT_model_selection_dialog(bpy.types.Operator):
row = layout.row(align=True)
row.prop(self, "search_query", icon="VIEWZOOM", text="") # search bar
if wm.ui_mode != "LOAD":
row.operator("speckle.create_model", icon="ADD", text="")
sub = row.row(align=True)
sub.enabled = getattr(self, "_can_create_model", True)
sub.operator("speckle.create_model", icon="ADD", text="")
layout.template_list(
"SPECKLE_UL_models_list",
@@ -125,8 +125,13 @@ class SPECKLE_OT_project_selection_dialog(bpy.types.Operator):
wm.selected_workspace.id = active_workspace["id"]
wm.selected_workspace.name = active_workspace["name"]
else:
wm.selected_workspace.id = "personal"
wm.selected_workspace.name = "Personal Projects"
from .account_selection_dialog import update_workspaces_list
update_workspaces_list(context)
workspaces = list(wm.speckle_workspaces)
if workspaces:
wm.selected_workspace.id = workspaces[0].id
wm.selected_workspace.name = workspaces[0].name
# Fetch projects from server
projects: List[Tuple[str, str, str, str, bool]] = get_projects_for_account(
@@ -3,6 +3,7 @@ from typing import List
from bpy.types import Operator, Context, Object
from bpy.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):
@@ -45,6 +46,14 @@ class SPECKLE_OT_selection_filter_dialog(Operator):
update_model_card_objects(model_card, user_selection)
self.report({"INFO"}, "Selection updated")
# On-demand permission check before publishing
authorized, auth_message = can_create_version(
model_card.account_id, model_card.project_id, model_card.model_id
)
if not authorized:
self.report({"ERROR"}, auth_message)
return {"CANCELLED"}
# Call the publish operator
bpy.ops.speckle.model_card_publish(
model_card_id=self.model_card_id, version_message=self.version_message
+48
View File
@@ -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")
+49 -25
View File
@@ -1,6 +1,7 @@
import bpy
from specklepy.core.api.credentials import get_local_accounts
from typing import List, Tuple, Optional, Dict
from urllib.parse import urlparse
from specklepy.core.api.credentials import Account
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.wrapper import StreamWrapper
@@ -23,7 +24,9 @@ class SpeckleClientCache:
if not account:
raise ValueError(f"No account found for ID: {account_id}")
client = SpeckleClient(host=account.serverInfo.url)
url = account.serverInfo.url
use_ssl = urlparse(url).scheme.lower() != "http"
client = SpeckleClient(host=url, use_ssl=use_ssl)
client.authenticate_with_account(account)
self._clients[account_id] = client
return client
@@ -92,22 +95,20 @@ def get_workspaces(account_id: str) -> List[Tuple[str, str]]:
for ws in workspaces
if ws.creation_state is None or ws.creation_state.completed
]
personal_projects_text = "Personal Projects (Legacy)"
else:
workspace_list = []
personal_projects_text = "Personal Projects"
workspace_list.append(("personal", personal_projects_text))
if workspaces_enabled:
active_workspace = client.active_user.get_active_workspace()
default_workspace_id = (
active_workspace.id if active_workspace else "personal"
active_workspace.id
if active_workspace
else (workspaces[0].id if workspaces else None)
)
result = reorder_tuple(workspace_list, default_workspace_id)
if default_workspace_id:
result = reorder_tuple(workspace_list, default_workspace_id)
else:
result = workspace_list
else:
result = workspace_list
result = []
return result
except Exception as e:
@@ -145,7 +146,7 @@ def get_active_workspace(account_id: str) -> Optional[Dict[str, str]]:
active_workspace = client.active_user.get_active_workspace()
if active_workspace:
return {"id": active_workspace.id, "name": active_workspace.name}
return {"id": "personal", "name": "Personal Projects"}
return None
except Exception as e:
print(f"Error in get_active_workspace: {str(e)}")
_client_cache.clear()
@@ -261,16 +262,42 @@ 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:
@@ -286,15 +313,12 @@ def can_create_project_in_workspace(account_id: str, workspace_id: str) -> bool:
try:
client = _client_cache.get_client(account_id)
if workspace_id == "personal":
return client.active_user.can_create_personal_projects().authorized
else:
try:
workspace = client.workspace.get(workspace_id)
return workspace.permissions.can_create_project.authorized
except Exception as e:
print(f"Failed to get workspace: {str(e)}")
return False
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
@@ -30,9 +30,6 @@ def get_projects_for_account(
print(f"Error: Could not find account with ID: {account_id}")
return []
if workspace_id == "personal":
return _get_personal_projects_with_permissions(client, search)
try:
workspace_resource = WorkspaceResource(
account, client.url, client.httpclient, client.server.version()
@@ -95,43 +92,6 @@ def get_projects_for_account(
return []
def _get_personal_projects_with_permissions(
client: SpeckleClient, search: Optional[str] = None
) -> List[Tuple[str, str, str, str, bool]]:
"""
helper function to get personal projects with permissions using the old method
"""
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from .account_manager import can_load
filter = UserProjectsFilter(
search=search,
workspaceId=None,
personalOnly=True,
include_implicit_access=True,
)
projects = client.active_user.get_projects(limit=10, filter=filter).items
result = []
for project in projects:
can_load_permission, _ = can_load(client, project)
result.append(
(
strip_non_ascii(project.name),
format_role(getattr(project, "role", ""))
if hasattr(project, "role") and project.role
else "",
format_relative_time(project.updated_at),
project.id,
can_load_permission,
)
)
return result
def _get_projects_with_individual_permissions(
client: SpeckleClient,
workspace_id: str,
+23 -18
View File
@@ -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 = [
@@ -695,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)
@@ -714,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")
@@ -848,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")
@@ -1340,7 +1343,9 @@ def instance_definition_proxy_to_native(
if max_depth > 0: # Only process if max_depth allows
assert (
found_obj.definitionId in definition_collections
), f"Definition collection not found for nested instance {found_obj.definitionId}"
), (
f"Definition collection not found for nested instance {found_obj.definitionId}"
)
if instance_loading_mode == "LINKED_DUPLICATES":
blender_obj = instance_proxy_to_linked_duplicates(
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env pwsh
$ErrorActionPreference = "Stop"
uv pip compile pyproject.toml --output-file bpy_speckle/requirements.txt --generate-hashes
+4 -4
View File
@@ -5,12 +5,12 @@ description = "Next-Gen Speckle connector for Blender!"
requires-python = ">=3.11.9, <4.0.0"
license = "Apache-2.0"
dependencies = [
"specklepy>=3.0.4",
"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",
]
Generated
+795 -460
View File
File diff suppressed because it is too large Load Diff