Compare commits

...

26 Commits

Author SHA1 Message Date
Jedd Morgan 2f4c403229 Added speckleifc 2025-07-10 19:20:13 +01:00
Jedd Morgan f5e024c8ce perf(serializer): Avoid unnecessary serialization of detached objects (#431)
* Avoid unnecessary serialization of detached objects

* camel case variable namings
2025-06-16 16:24:41 +01:00
Dogukan Karatas 3bcdf723b0 feat (api): projects with permissions (#430)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* adds project with permissions

* removes the project resource with permissions

* fix the tests
2025-06-06 16:07:48 +02:00
Jedd Morgan adc1105b3a Forward secret to publish job (#428)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-06-02 17:00:00 +01:00
Gergő Jedlicska fa9877b6da Gergo/ci upgrade (#427)
* feat(ci): refactor ci jobs to remove duplication

* chore(ci): just some comment fix
2025-06-02 16:45:41 +02:00
Gergő Jedlicska 2929e2f93b Merge pull request #426 from specklesystems/v3-dev
V3 mainline
2025-06-02 15:10:27 +01:00
Gergő Jedlicska 6636950705 Merge branch 'main' of github.com:specklesystems/specklepy into v3-dev 2025-06-02 12:52:31 +02:00
Gergő Jedlicska 79c0106f57 Merge pull request #425 from specklesystems/gergo/fix_wheel_build
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
fix: specify what packages to include in the wheel
2025-05-29 14:33:31 +02:00
Gergő Jedlicska f4d73ff1ae fix: specify what packages to include in the wheel 2025-05-29 14:31:39 +02:00
Gergő Jedlicska 7ea719141f Merge pull request #424 from specklesystems/gergo/objectResultsWithApplicationIds
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
feat(automate): attach application id-s to automate result cases
2025-05-28 10:43:10 +02:00
Gergő Jedlicska a47f568f69 chore: comment cleanup 2025-05-27 15:34:59 +02:00
Gergő Jedlicska b174802451 fix(automate): remove last ref to object_id 2025-05-27 14:30:19 +02:00
Gergő Jedlicska 87a7e7482d Merge branch 'v3-dev' of github.com:specklesystems/specklepy into gergo/objectResultsWithApplicationIds 2025-05-22 20:36:38 +02:00
Gergő Jedlicska e888339dda feat(automate): attach application id-s to automate result cases 2025-05-22 20:35:35 +02:00
Dogukan Karatas 3417557405 feat: BlenderObject (#423)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* adds blenderobject

* exports the classes

* tests added
2025-05-21 18:18:36 +02:00
Jedd Morgan 8aba21de01 Fix(v2): Fix Workspace Visibility enum for Project queries (#422)
* V2 workspaces updated

* Update hooks

* Updated docker file

* Pre-commit passing

* Skipped failing test

* commented out test

* Fixed tests
2025-05-19 11:52:47 +02:00
Gergő Jedlicska 4ce61f4e89 feat: add WORKSPACE visibility for projects (#421)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat: add WORKSPACE visibility for projects

* tests: projects are now private by default, follow that in tests
2025-05-15 14:35:54 +02:00
Dogukan Karatas 6d6e1e7650 adds can_load and can_publish (#420)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-05-08 12:32:47 +02:00
KatKatKateryna 95de5cbb30 Introducing Text class (#419)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* add text class and tests

* formatting

* fix default values

* comments

* comment

* sort imports

* import alignments

* compare properties, not Base objects

* revert irrelevant changes

* tests

* use correct fixture

* fix tests property
2025-05-06 10:12:29 +01:00
KatKatKateryna 5f56818d63 remove print statement (#418) 2025-05-05 19:03:33 +01:00
Jedd Morgan 825097e1a6 Oops (#417)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-05-01 22:04:46 +02:00
Jedd Morgan d3ab26240a fix(ap): fix mistake in workspace get response handling (#416)
* Corrected broken workspace query

* And one more!

* Fixed mistake in workspace get
2025-05-01 19:57:44 +00:00
Jedd Morgan ce6be1a98e fic(api): Fix mistake in workspace queries (#415)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Corrected broken workspace query

* And one more!
2025-05-01 07:06:33 +00:00
Jedd Morgan 213e73dfdd Corrected broken workspace query (#414)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-04-30 17:10:17 +00:00
Jedd Morgan 15129df7ce More tweaks (#413)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* More tweaks

* WIP on v3-dev

* Add creation state

* format
2025-04-30 18:16:17 +02:00
Gergő Jedlicska 12b9602577 Merge pull request #397 from specklesystems/gergo/nostringcase
chore: remove stringcase as a dependency
2025-03-27 15:27:06 +01:00
40 changed files with 2866 additions and 1166 deletions
+8 -4
View File
@@ -1,12 +1,16 @@
name: "Specklepy test and build"
name: "Specklepy test"
on:
workflow_call:
secrets:
CODECOV_TOKEN:
required: true
pull_request:
branches:
- "v3-dev"
- "main"
jobs:
build-and-test:
name: build-and-test
test:
name: test
runs-on: ubuntu-latest
strategy:
matrix:
+7 -50
View File
@@ -2,63 +2,20 @@ name: "Publish Python Package"
on:
push:
branches:
- "v3-dev"
- "main"
tags:
- "3.*.*"
jobs:
build-and-test:
name: continuous-integration
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
steps:
- uses: actions/checkout@v4
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: "uv.lock"
- 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') }}
- name: Run pre-commit
run: uv run pre-commit run --all-files
- name: Run Speckle Server
run: docker compose up -d
# - name: Run tests
# run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
# - uses: codecov/codecov-action@v5
# if: matrix.python-version == 3.13
# with:
# fail_ci_if_error: true # optional (default = false)
# files: ./reports/test-results.xml # optional
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Minimize uv cache
run: uv cache prune --ci
test:
uses: "./.github/workflows/pr.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
publish-package:
name: "Build and Publish Python Package"
runs-on: ubuntu-latest
needs: build-and-test
needs: test
# set the environment based on what triggered the workflow
environment:
@@ -79,7 +36,7 @@ jobs:
- name: "Build package artifacts"
run: uv build
# Logic for TestPyPI (on v3-dev branch push)
# Logic for TestPyPI (on main branch push)
- name: "Publish to TestPyPI"
if: ${{ github.ref_type == 'branch' }}
run: uv publish --index test
+9
View File
@@ -18,6 +18,11 @@ dependencies = [
"ujson>=5.10.0",
]
[project.optional-dependencies]
speckleifc = [
"ifcopenshell>=0.8.2",
]
[dependency-groups]
dev = [
"commitizen>=4.1.0",
@@ -47,6 +52,10 @@ build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.wheel]
only-include = ["src"]
sources = ["src"]
[tool.hatch.version.raw-options]
local_scheme = "no-local-version"
+84 -35
View File
@@ -1,5 +1,3 @@
# ignoring "line too long" check from linter
# ruff: noqa: E501
"""This module provides an abstraction layer above the Speckle Automate runtime."""
import time
@@ -75,7 +73,7 @@ class AutomationContext:
speckle_client.authenticate_with_token(speckle_token)
if not speckle_client.account:
msg = (
f"Could not autenticate to {automation_run_data.speckle_server_url}",
f"Could not authenticate to {automation_run_data.speckle_server_url}",
"with the provided token",
)
raise ValueError(msg)
@@ -109,18 +107,24 @@ class AutomationContext:
)
except SpeckleException as err:
raise ValueError(
f"""\
Could not receive specified version.
Is your environment configured correctly?
project_id: {self.automation_run_data.project_id}
model_id: {self.automation_run_data.triggers[0].payload.model_id}
version_id: {self.automation_run_data.triggers[0].payload.version_id}
"""
f"""Could not receive specified version.
Is your environment configured correctly?
project_id: {self.automation_run_data.project_id}
model_id: {self.automation_run_data.triggers[0].payload.model_id}
version_id: {self.automation_run_data.triggers[0].payload.version_id}
"""
) from err
if not version.referenced_object:
raise Exception(
"This version is past the version history limit,",
" cannot execute an automation on it",
)
base = operations.receive(
version.referenced_object, self._server_transport, self._memory_transport
)
# self._closure_tree = base["__closure"]
print(
f"It took {self.elapsed():.2f} seconds to receive",
f" the speckle version {version_id}",
@@ -242,7 +246,7 @@ class AutomationContext:
)
if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]:
object_results = {
"version": 1,
"version": 2,
"values": {
"objectResults": self._automation_result.model_dump(by_alias=True)[
"objectResults"
@@ -332,26 +336,24 @@ class AutomationContext:
def attach_error_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new error case to the run results.
If the error cause has already created an error case,
the error will be extended with a new case refering to the causing objects.
Args:
error_tag (str): A short tag for the error type.
causing_object_ids (str[]): A list of object_id-s that are causing the error
error_messagge (Optional[str]): Optional error message.
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the error case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.ERROR,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -360,16 +362,25 @@ class AutomationContext:
def attach_warning_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new warning case to the run results."""
"""Add a new warning case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the warning case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.WARNING,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -378,16 +389,25 @@ class AutomationContext:
def attach_success_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new success case to the run results."""
"""Add a new success case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the success case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.SUCCESS,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -396,16 +416,25 @@ class AutomationContext:
def attach_info_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new info case to the run results."""
"""Add a new info case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the info case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.INFO,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -415,19 +444,39 @@ class AutomationContext:
self,
level: ObjectResultLevel,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
if isinstance(object_ids, list):
if len(object_ids) < 1:
"""Add a new result case to the run results.
Args:
level: Result level.
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the info case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
if isinstance(affected_objects, list):
if len(affected_objects) < 1:
raise ValueError(
f"Need atleast one object_id to report a(n) {level.value.upper()}"
f"Need atleast one object to report a(n) {level.value.upper()}"
)
id_list = object_ids
object_list = affected_objects
else:
id_list = [object_ids]
object_list = [affected_objects]
ids: Dict[str, Optional[str]] = {}
for o in object_list:
# validate that the Base.id is not None. If its a None, throw an Exception
if not o.id:
raise Exception(
f"You can only attach {level} results to objects with an id."
)
ids[o.id] = o.applicationId
print(
f"Created new {level.value.upper()}"
f" category: {category} caused by: {message}"
@@ -436,7 +485,7 @@ class AutomationContext:
ResultCase(
category=category,
level=level,
object_ids=id_list,
object_app_ids=ids,
message=message,
metadata=metadata,
visual_overrides=visual_overrides,
+1 -1
View File
@@ -80,7 +80,7 @@ class ResultCase(AutomateBase):
category: str
level: ObjectResultLevel
object_ids: List[str]
object_app_ids: Dict[str, Optional[str]]
message: Optional[str]
metadata: Optional[Dict[str, Any]]
visual_overrides: Optional[Dict[str, Any]]
View File
+126
View File
@@ -0,0 +1,126 @@
import json
import time
import traceback
from argparse import ArgumentParser
from os import getenv
from speckleifc.ifc_geometry_processing import open_ifc
from speckleifc.importer import ImportJob
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account, get_accounts_for_server
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models.current import Version
from specklepy.core.api.operations import send
from specklepy.transports.server import ServerTransport
def cmd_line_import() -> None:
parser = ArgumentParser(
prog="speckleifc",
description="imports a file",
)
parser.add_argument("file_path")
parser.add_argument("output_path")
parser.add_argument("project_id")
parser.add_argument("version_message")
parser.add_argument("model_id")
# parser.add_argument("model_name")
# parser.add_argument("region_name")
args = parser.parse_args()
TOKEN = getenv("USER_TOKEN")
assert TOKEN is not None
SERVER_URL = getenv("SPECKLE_SERVER_URL") or "http://127.0.0.1:3000"
account = Account.from_token(TOKEN, SERVER_URL)
try:
version = open_and_convert_file(
args.file_path,
args.project_id,
args.version_message,
args.model_id,
account,
)
with open(args.output_path, "w") as f:
json.dump({"success": True, "commitId": version.id}, f)
except Exception as e:
error_msg = f"IFC Importer failed with exception:\n{traceback.format_exc()}"
print(error_msg)
# Write error result
with open(args.output_path, "w") as f:
json.dump({"success": False, "error": str(e)}, f)
def manual_import() -> None:
PROJECT_ID = "f3a42bdf24"
MODEL_ID = "0e23cfdea3"
SERVER_URL = "app.speckle.systems"
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\60mins.ifc"
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\hillside_house_meters.ifc"
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\GRAPHISOFT_Archicad_Sample_Project-S-Office_v1.0_AC25.ifc" # noqa: E501
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\GRAPHISOFT_Archicad_Sample_Project-S-Office_v1.0_AC25.ifc" # noqa: E501
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\OSS-MJL-B1-ZZ-M3-ME-00002.ifc[P26].ifc" # noqa: E501
FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\AC20-FZK-Haus.ifc" # noqa: E501
# FILE_PATH = "C:\\Users\\Jedd\\Desktop\\ifc\\ENZ-TPL-27-02-Zones.ifc" # noqa: E501
# FILE_PATH = (
# "C:\\Users\\Jedd\\Desktop\\ifc\\22-329-X-CVP-XX-XX-M3-A-3001-G-1 Bedroom.ifc" # noqa: E501
# )
account = get_accounts_for_server(SERVER_URL)[0]
open_and_convert_file(FILE_PATH, PROJECT_ID, None, MODEL_ID, account)
def open_and_convert_file(
file_path: str,
project_id: str,
version_message: str | None,
model_id: str,
account: Account,
) -> Version:
start = time.time()
very_start = start
ifc_file = open_ifc(file_path)
import_job = ImportJob(ifc_file)
data = import_job.convert()
print(f"File conversion complete after {(time.time() - start) * 1000}ms")
start = time.time()
remote_transport = ServerTransport(project_id, account=account)
root_id = send(data, transports=[remote_transport], use_default_cache=False)
print(f"Sending to speckle complete after: {(time.time() - start) * 1000}ms")
start = time.time()
server_url = account.serverInfo.url
assert server_url
client = SpeckleClient(host=server_url, use_ssl=server_url.startswith("https"))
client.authenticate_with_account(account)
create_version = CreateVersionInput(
object_id=root_id,
model_id=model_id,
project_id=project_id,
message=version_message,
source_application="IFC",
)
version = client.version.create(create_version)
end = time.time()
print(f"Version committed after: {(end - start) * 1000}ms")
print(f"Total time (to commit): {(end - very_start) * 1000}ms")
del ifc_file
return version
if __name__ == "__main__":
start = time.time()
# cmd_line_import()
manual_import()
print(f"Total time (including cleanup): {(time.time() - start) * 1000}ms")
@@ -0,0 +1,28 @@
from typing import cast
from ifcopenshell.entity_instance import entity_instance
from speckleifc.property_extraction import extract_properties
from specklepy.objects.base import Base
from specklepy.objects.data_objects import DataObject
def data_object_to_speckle(
display_value: list[Base],
step_element: entity_instance,
children: list[Base],
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or guid)
data_object = DataObject(
applicationId=guid,
properties=extract_properties(step_element),
name=name or guid,
displayValue=display_value,
)
data_object["@elements"] = children
data_object["ifcType"] = step_element.is_a()
return data_object
@@ -0,0 +1,130 @@
from collections import defaultdict
from collections.abc import Sequence
from typing import cast
from ifcopenshell.ifcopenshell_wrapper import (
Triangulation,
TriangulationElement,
colour,
style,
)
from speckleifc.render_material_proxy_manager import RenderMaterialProxyManager
from specklepy.objects.base import Base
from specklepy.objects.geometry import Mesh
from specklepy.objects.other import RenderMaterial
def geometry_to_speckle(
shape: TriangulationElement, render_material_manager: RenderMaterialProxyManager
) -> list[Base]:
geometry = cast(Triangulation, shape.geometry)
materials = cast(Sequence[style], geometry.materials)
MESH_COUNT = max(len(materials), 1)
material_ids = cast(Sequence[int], geometry.material_ids)
faces = cast(Sequence[int], geometry.faces)
verts = cast(Sequence[float], geometry.verts)
normals = cast(Sequence[float], geometry.normals)
FACE_COUNT = len(material_ids)
if len(faces) != FACE_COUNT * 3:
# Not really expected, but occasionally some meshes fail to triangulate
return []
mapped_meshes = _pre_alloc_mesh_lists(shape, material_ids, MESH_COUNT)
for i, mesh in enumerate(mapped_meshes):
material = _material_to_speckle(materials[i])
render_material_manager.add_mesh_material_mapping(material, mesh)
mapped_faces_pointers = [0] * MESH_COUNT
mapped_vertices_pointers = [0] * MESH_COUNT
mapped_index_counters = [0] * MESH_COUNT
i = 0
face_index = 0
while i < FACE_COUNT:
mesh_index = material_ids[i]
mesh: Mesh = mapped_meshes[mesh_index]
face_ptr = mapped_faces_pointers[mesh_index]
vert_ptr = mapped_vertices_pointers[mesh_index]
# Add triangle
mesh.faces[face_ptr] = 3
for j in range(3):
# Add vert
mesh.faces[face_ptr + 1 + j] = mapped_index_counters[mesh_index] + j
vert_index = faces[face_index + j] * 3
mapped_vert_offset = vert_ptr + (j * 3)
mesh.vertices[mapped_vert_offset] = verts[vert_index]
mesh.vertices[mapped_vert_offset + 1] = verts[vert_index + 1]
mesh.vertices[mapped_vert_offset + 2] = verts[vert_index + 2]
mesh.vertexNormals[mapped_vert_offset] = normals[vert_index]
mesh.vertexNormals[mapped_vert_offset + 1] = normals[vert_index + 1]
mesh.vertexNormals[mapped_vert_offset + 2] = normals[vert_index + 2]
i += 1
face_index += 3 # number of items in the faces list we just jumped over
mapped_index_counters[mesh_index] += (
3 # number of verts we just added to the mesh.vertices i.e. the next index
)
mapped_faces_pointers[mesh_index] += (
4 # number of item's we've just added to the mesh.faces list
)
mapped_vertices_pointers[mesh_index] += (
9 # number of item's we've just added to the mesh.vertices list
)
return mapped_meshes # type: ignore
def _material_to_speckle(material: style) -> RenderMaterial:
return RenderMaterial(
applicationId=material.calc_hash(),
name=material.name,
diffuse=_color_to_argb(material.diffuse),
opacity=1 - material.transparency if material.has_transparency() else 1,
)
def _color_to_argb(colour: colour) -> int:
# Clamp values to [0, 1] and convert to 0255
a_int = 255
r_int = max(0, min(255, int(round(colour.r() * 255))))
g_int = max(0, min(255, int(round(colour.g() * 255))))
b_int = max(0, min(255, int(round(colour.b() * 255))))
return (a_int << 24) | (r_int << 16) | (g_int << 8) | b_int
def _pre_alloc_mesh_lists(
shape: TriangulationElement, material_ids: Sequence[int], MESH_COUNT: int
) -> list[Mesh]:
"""
This is a performance optimisation to pre-size the lists
since we're expecting potential hundreds of thousands of verts in a single model
This is very much in the hot path, so worth the extra bit of convoluted logic
"""
appId = cast(str, shape.guid)
material_face_counts = defaultdict(int)
for mat_id in material_ids:
material_face_counts[mat_id] += 1
meshes = []
for mat_id in range(MESH_COUNT):
face_count = material_face_counts.get(mat_id, 0)
mesh = Mesh(
units="m",
vertices=[-1] * (face_count * 9),
vertexNormals=[-1] * (face_count * 9),
faces=[-1] * (face_count * 4), # 1 marker + 3 vertex indices
applicationId=f"{appId}_mat{mat_id}",
)
meshes.append(mesh)
return meshes
@@ -0,0 +1,24 @@
from typing import cast
from ifcopenshell.entity_instance import entity_instance
from specklepy.objects.base import Base
from specklepy.objects.models.collections.collection import Collection
def project_to_speckle(
step_element: entity_instance, children: list[Base]
) -> Collection:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or step_element.LongName or guid)
project = Collection(applicationId=guid, name=name, elements=children)
project["ifcType"] = step_element.is_a()
project["description"] = step_element.Description
project["objectType"] = step_element.ObjectType
project["longName"] = step_element.LongName
project["phase"] = step_element.Phase
return project
return project
@@ -0,0 +1,42 @@
from typing import cast
from ifcopenshell.entity_instance import entity_instance
from speckleifc.property_extraction import extract_properties
from specklepy.objects.base import Base
from specklepy.objects.data_objects import DataObject
from specklepy.objects.models.collections.collection import Collection
def spatial_element_to_speckle(
display_value: list[Base],
step_element: entity_instance,
relational_children: list[Base],
) -> Collection:
direct_geometry = _convert_as_data_object(display_value, step_element)
all_children = [direct_geometry] + relational_children
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or step_element.LongName or guid)
data_object = Collection(applicationId=guid, name=name, elements=all_children)
data_object["ifcType"] = step_element.is_a()
return data_object
def _convert_as_data_object(
display_value: list[Base], step_element: entity_instance
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or step_element.LongName or guid)
data_object = DataObject(
applicationId=guid,
properties=extract_properties(step_element),
name=name,
displayValue=display_value,
)
data_object["ifcType"] = step_element.is_a()
return data_object
+44
View File
@@ -0,0 +1,44 @@
import multiprocessing
from ifcopenshell import file, ifcopenshell_wrapper, open, sqlite
from ifcopenshell.geom import iterator, settings
from specklepy.logging.exceptions import SpeckleException
def _create_iterator_settings() -> settings:
ifc_settings = settings()
# triangles for now, speckle does support n-gons, but may be less performant
ifc_settings.set("triangulation-type", ifcopenshell_wrapper.TRIANGLE_MESH)
# no need to weld verts
ifc_settings.set("weld-vertices", False)
# Speckle meshes are all in world coords
ifc_settings.set("use-world-coords", True)
# Tiny performance improvement,
ifc_settings.set("no-wire-intersection-check", True)
# IfcOpenshell defaults to 0.001mm here, which leads to very dense meshes.
# lowering the mesh quality a bit here leads to meshes
# that are still much higher quality than webifc
# We still need to experiment with the affect on memory usage
# It may be desirable to lower this further, and increase the angular deflection
# to compensate. This would allow large meshes to be lower quality,
# while keeping small meshes relatively similar.
ifc_settings.set("mesher-linear-deflection", 0.2)
return ifc_settings
def open_ifc(file_path: str) -> file:
ifc_file = open(file_path)
if isinstance(ifc_file, file):
return ifc_file
else:
raise SpeckleException(f"file at {file_path} is not a compatible ifc file type")
def create_geometry_iterator(ifc_file: file | sqlite) -> iterator:
return iterator(_create_iterator_settings(), ifc_file, multiprocessing.cpu_count())
return iterator(_create_iterator_settings(), ifc_file, multiprocessing.cpu_count())
+31
View File
@@ -0,0 +1,31 @@
from collections.abc import Generator, Iterable
from itertools import chain
from typing import cast
from ifcopenshell.entity_instance import entity_instance
def get_children(step_element: entity_instance) -> Generator[entity_instance]:
yield from chain(
get_spatial_children(step_element), get_aggregate_children(step_element)
)
def get_spatial_children(step_element: entity_instance) -> Generator[entity_instance]:
spatial_relations = cast(
Iterable[entity_instance] | None,
getattr(step_element, "ContainsElements", None),
)
if spatial_relations is not None:
for relation in spatial_relations:
yield from cast(Iterable[entity_instance], relation.RelatedElements)
def get_aggregate_children(step_element: entity_instance) -> Generator[entity_instance]:
aggregate_relations = cast(
Iterable[entity_instance] | None,
getattr(step_element, "IsDecomposedBy", None),
)
if aggregate_relations is not None:
for relation in aggregate_relations:
yield from cast(Iterable[entity_instance], relation.RelatedObjects)
+101
View File
@@ -0,0 +1,101 @@
import time
from typing import cast
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.geom import file
from ifcopenshell.ifcopenshell_wrapper import TriangulationElement
from speckleifc.converter.data_object_converter import data_object_to_speckle
from speckleifc.converter.geometry_converter import geometry_to_speckle
from speckleifc.converter.project_converter import project_to_speckle
from speckleifc.converter.spatial_element_converter import spatial_element_to_speckle
from speckleifc.ifc_geometry_processing import create_geometry_iterator
from speckleifc.ifc_openshell_helpers import get_children
from speckleifc.render_material_proxy_manager import RenderMaterialProxyManager
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects import Base
class ImportJob:
def __init__(self, ifc_file: file):
self._ifc_file = ifc_file
self.cached_display_values: dict[int, list[Base]] = {}
self._render_material_manager = RenderMaterialProxyManager()
self.geometries_count = 0
self.geometries_used = 0
def convert_element(self, step_element: entity_instance) -> Base:
children = self._convert_children(step_element)
display_value = self.cached_display_values.get(step_element.id(), [])
if display_value is not None:
self.geometries_used += 1
if step_element.is_a("IfcProject"):
return project_to_speckle(step_element, children)
elif step_element.is_a("IfcSpatialStructureElement"):
return spatial_element_to_speckle(display_value, step_element, children)
else:
return data_object_to_speckle(display_value, step_element, children)
def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [
self.convert_element(i)
for i in get_children(step_element)
if self._should_convert(i)
]
@staticmethod
def _should_convert(step_element: entity_instance) -> bool:
# We only consider IfcRoot objects convertible
# This is the super class for root level entities that have a GUID...
# This will ignore some types like IfcGridAxis
s = step_element.is_a("IfcRoot")
if not s:
print(
f"Skipping #{step_element.id()} because it's type ({step_element.is_a()}) it not an IfcRoot" # noqa: E501
)
return s
def convert(self) -> Base:
start = time.time()
self.pre_process_geometry()
print(f"Geometry conversion complete after {(time.time() - start) * 1000}ms")
print(f"Created {self.geometries_count} geometries")
start = time.time()
root = self._convert_project_tree()
print(f"Object tree conversion complete after {(time.time() - start) * 1000}ms")
print(f"Used {self.geometries_used} geometries")
return root
def pre_process_geometry(self) -> None:
iterator = create_geometry_iterator(self._ifc_file)
if not iterator.initialize():
raise SpeckleException(
"geometry iterator failed to initialize for the given file"
)
self.geometries_count = 0
while True:
shape = cast(TriangulationElement, iterator.get())
self.geometries_count += 1
id = cast(int, shape.id)
display_value = geometry_to_speckle(shape, self._render_material_manager)
self.cached_display_values[id] = display_value
if not iterator.next():
break
def _convert_project_tree(self) -> Base:
projects = self._ifc_file.by_type("IfcProject", False)
if len(projects) != 1:
raise SpeckleException("Expected exactly one IfcProject in file")
project = projects[0]
tree = self.convert_element(project)
tree["renderMaterialProxies"] = list(
self._render_material_manager.render_material_proxies.values()
)
return tree
+87
View File
@@ -0,0 +1,87 @@
from typing import Any
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.util.element import get_type
def extract_properties(element: entity_instance) -> dict[str, object]:
properties: dict[str, object] = {
"Attributes": get_attributes(element),
"Property Sets": _get_ifc_object_properties(element),
}
if (ifc_type := get_type(element)) is not None:
properties["Element Type Property Sets"] = _get_ifc_element_type_properties(
ifc_type,
)
return properties
def get_attributes(element: entity_instance) -> dict[str, object]:
return element.get_info(True, False, scalar_only=True)
def _get_ifc_element_type_properties(element: entity_instance) -> dict[str, object]:
result: dict[str, object] = {}
for definition in element.HasPropertySets or []:
if not definition.is_a("IfcPropertySet"):
continue
result[definition.Name] = _get_properties(definition.HasProperties)
return result
def _get_ifc_object_properties(element: entity_instance) -> dict[str, object]:
result: dict[str, object] = {}
for rel in getattr(element, "IsDefinedBy", []):
if not rel.is_a("IfcRelDefinesByProperties"):
continue
definition: entity_instance = rel.RelatingPropertyDefinition
if not definition.is_a("IfcPropertySet"):
continue
set_name = definition.Name
properties = _get_properties(definition.HasProperties)
if properties:
result[set_name] = properties
return result
def _get_properties(properties: entity_instance) -> dict[str, Any]:
"""
There already exists a canonical way to get properties
`ifcopenshell.util.element.get_properties` but it's very verbose
and we don't want to bloat our selves with supporting complex property types
This is a slimmed down version, only supporting a couple of property types
"""
result: dict[str, Any] = {}
for prop in properties:
name = prop.Name
if prop.is_a("IfcPropertySingleValue"):
val = prop.NominalValue
if val is not None:
result[name] = val.wrappedValue if hasattr(val, "wrappedValue") else val
elif prop.is_a("IfcPropertyListValue"):
values = getattr(prop, "ListValues", None)
if values:
result[name] = [
v.wrappedValue if hasattr(v, "wrappedValue") else v for v in values
]
elif prop.is_a("IfcPropertyEnumeratedValue"):
values = getattr(prop, "EnumerationValues", None)
if values:
result[name] = [
v.wrappedValue if hasattr(v, "wrappedValue") else v for v in values
]
# elif prop.is_a("IfcPropertyTableValue"):
# properties[name] = #not sure if we want to support these...
return result
@@ -0,0 +1,28 @@
from specklepy.objects.geometry import Mesh
from specklepy.objects.other import RenderMaterial
from specklepy.objects.proxies import RenderMaterialProxy
class RenderMaterialProxyManager:
def __init__(self):
self._render_material_proxies: dict[str, RenderMaterialProxy] = {}
@property
def render_material_proxies(self):
return self._render_material_proxies
def add_mesh_material_mapping(
self, render_material: RenderMaterial, mesh: Mesh
) -> None:
material_id = render_material.applicationId
assert material_id is not None
mesh_id = mesh.applicationId
assert mesh_id is not None
proxy = self._render_material_proxies.get(material_id, None)
if proxy is not None:
proxy.objects.append(mesh_id)
else:
self._render_material_proxies[material_id] = RenderMaterialProxy(
objects=[mesh_id], value=render_material
)
@@ -11,7 +11,11 @@ from specklepy.core.api.models import (
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.models.current import (
PermissionCheckResult,
ProjectWithPermissions,
Workspace,
)
from specklepy.core.api.resources import ActiveUserResource as CoreResource
from specklepy.logging import metrics
@@ -51,6 +55,22 @@ class ActiveUserResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Active User Get Projects"})
return super().get_projects(limit=limit, cursor=cursor, filter=filter)
def get_projects_with_permissions(
self,
*,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
metrics.track(
metrics.SDK,
self.account,
{"name": "Active User Get Projects With Permissions"},
)
return super().get_projects_with_permissions(
limit=limit, cursor=cursor, filter=filter
)
def get_project_invites(self) -> List[PendingStreamCollaborator]:
metrics.track(
metrics.SDK, self.account, {"name": "Active User Get Project Invites"}
@@ -7,7 +7,11 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
from specklepy.core.api.models import (
Project,
ProjectWithModels,
ProjectWithTeam,
)
from specklepy.core.api.models.current import ProjectPermissionChecks
from specklepy.core.api.resources import ProjectResource as CoreResource
from specklepy.logging import metrics
@@ -1,7 +1,12 @@
from typing import Optional
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import Project, ResourceCollection, Workspace
from specklepy.core.api.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
Workspace,
)
from specklepy.core.api.resources import WorkspaceResource as CoreResource
from specklepy.logging import metrics
@@ -30,3 +35,19 @@ class WorkspaceResource(CoreResource):
) -> ResourceCollection[Project]:
metrics.track(metrics.SDK, self.account, {"name": "Workspace Get Projects"})
return super().get_projects(workspace_id, limit, cursor, filter)
def get_projects_with_permissions(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
metrics.track(
metrics.SDK,
self.account,
{"name": "Workspace Get Projects With Permissions"},
)
return super().get_projects_with_permissions(
workspace_id, limit, cursor, filter
)
+3
View File
@@ -2,9 +2,12 @@ from enum import Enum
class ProjectVisibility(str, Enum):
"""Supported project visibility types"""
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
UNLISTED = "UNLISTED"
WORKSPACE = "WORKSPACE"
class UserProjectsUpdatedMessageType(str, Enum):
+3 -2
View File
@@ -13,8 +13,9 @@ class UserUpdateInput(GraphQLBaseModel):
class UserProjectsFilter(GraphQLBaseModel):
search: Optional[str] = None
only_with_roles: Optional[Sequence[str]] = None
workspaceId: Optional[str] = None
personalOnly: Optional[bool] = None
workspace_id: Optional[str] = None
personal_only: Optional[bool] = None
include_implicit_access: Optional[bool] = None
class UserWorkspacesFilter(GraphQLBaseModel):
@@ -8,6 +8,7 @@ from specklepy.core.api.models.current import (
ProjectCollaborator,
ProjectCommentCollection,
ProjectWithModels,
ProjectWithPermissions,
ProjectWithTeam,
ResourceCollection,
ServerConfiguration,
@@ -39,6 +40,7 @@ __all__ = [
"ModelWithVersions",
"Project",
"ProjectWithModels",
"ProjectWithPermissions",
"ProjectWithTeam",
"ProjectCommentCollection",
"UserSearchResultCollection",
+11
View File
@@ -155,6 +155,8 @@ class ModelWithVersions(Model):
class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_load: "PermissionCheckResult"
can_publish: "PermissionCheckResult"
class Project(GraphQLBaseModel):
@@ -174,6 +176,10 @@ class ProjectWithModels(Project):
models: ResourceCollection[Model]
class ProjectWithPermissions(Project):
permissions: ProjectPermissionChecks
class ProjectWithTeam(Project):
invited_team: List[PendingStreamCollaborator]
team: List[ProjectCollaborator]
@@ -203,6 +209,10 @@ class WorkspacePermissionChecks(GraphQLBaseModel):
can_create_project: PermissionCheckResult
class WorkspaceCreationState(GraphQLBaseModel):
completed: bool
class Workspace(GraphQLBaseModel):
id: str
name: str
@@ -213,4 +223,5 @@ class Workspace(GraphQLBaseModel):
updated_at: datetime
read_only: bool
description: Optional[str]
creation_state: Optional[WorkspaceCreationState]
permissions: WorkspacePermissionChecks
@@ -13,7 +13,11 @@ from specklepy.core.api.models import (
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.models.current import (
PermissionCheckResult,
ProjectWithPermissions,
Workspace,
)
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
from specklepy.logging.exceptions import GraphQLException
@@ -252,6 +256,10 @@ class ActiveUserResource(ResourceBase):
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
@@ -306,6 +314,10 @@ class ActiveUserResource(ResourceBase):
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
@@ -330,3 +342,84 @@ class ActiveUserResource(ResourceBase):
)
return response.data.data
def get_projects_with_permissions(
self,
*,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
"""
Gets the currently active user's projects with their permissions.
This is useful for checking what actions can be performed on each project.
"""
QUERY = gql(
"""
query User($limit : Int!, $cursor: String, $filter: UserProjectsFilter) {
data:activeUser {
data:projects(limit: $limit, cursor: $cursor, filter: $filter) {
totalCount
cursor
items {
id
name
description
visibility
allowPublicComments
role
createdAt
updatedAt
sourceApps
workspaceId
permissions {
canCreateModel {
code
authorized
message
}
canDelete {
code
authorized
message
}
canLoad {
code
authorized
message
}
canPublish {
code
authorized
message
}
}
}
}
}
}
"""
)
variables = {
"limit": limit,
"cursor": cursor,
"filter": filter.model_dump(warnings="error", by_alias=True)
if filter
else None,
}
response = self.make_request_and_parse_response(
DataResponse[
Optional[DataResponse[ResourceCollection[ProjectWithPermissions]]]
],
QUERY,
variables,
)
if response.data is None:
raise GraphQLException(
"GraphQL response indicated that the ActiveUser could not be found"
)
return response.data.data
@@ -9,7 +9,11 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
from specklepy.core.api.models import (
Project,
ProjectWithModels,
ProjectWithTeam,
)
from specklepy.core.api.models.current import ProjectPermissionChecks
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
@@ -67,13 +71,21 @@ class ProjectResource(ResourceBase):
authorized
code
message
payload
}
canDelete {
authorized
code
message
payload
}
canLoad {
authorized
code
message
}
canPublish {
authorized
code
message
}
}
}
@@ -3,7 +3,12 @@ from typing import Optional
from gql import gql
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import Project, ResourceCollection, Workspace
from specklepy.core.api.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
Workspace,
)
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
@@ -36,6 +41,10 @@ class WorkspaceResource(ResourceBase):
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
@@ -53,8 +62,8 @@ class WorkspaceResource(ResourceBase):
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[Workspace]], QUERY, variables
).data.data
DataResponse[Workspace], QUERY, variables
).data
def get_projects(
self,
@@ -100,3 +109,72 @@ class WorkspaceResource(ResourceBase):
return self.make_request_and_parse_response(
DataResponse[DataResponse[ResourceCollection[Project]]], QUERY, variables
).data.data
def get_projects_with_permissions(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
QUERY = gql(
"""
query Workspace($workspaceId: String!, $limit: Int!, $cursor: String, $filter: WorkspaceProjectsFilter) {
data:workspace(id: $workspaceId) {
data:projects(limit: $limit, cursor: $cursor, filter: $filter) {
cursor
items {
allowPublicComments
createdAt
description
id
name
role
sourceApps
updatedAt
visibility
workspaceId
permissions {
canCreateModel {
code
authorized
message
}
canDelete {
code
authorized
message
}
canLoad {
code
authorized
message
}
canPublish {
code
authorized
message
}
}
}
totalCount
}
}
}
""" # noqa: E501
)
variables = {
"workspaceId": workspace_id,
"limit": limit,
"cursor": cursor,
"filter": filter.model_dump(warnings="error", by_alias=True)
if filter
else None,
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[ResourceCollection[ProjectWithPermissions]]],
QUERY,
variables,
).data.data
+2 -6
View File
@@ -1,7 +1,3 @@
from .data_objects import Base, DataObject, QgisObject
from .data_objects import Base, DataObject, QgisObject, BlenderObject # noqa: I001
__all__ = [
"Base",
"DataObject",
"QgisObject",
]
__all__ = ["Base", "DataObject", "QgisObject", "BlenderObject"]
@@ -0,0 +1,8 @@
from .text import AlignmentHorizontal, AlignmentVertical, Text
# re-export them at the geometry package level
__all__ = [
"Text",
"AlignmentHorizontal",
"AlignmentVertical",
]
+54
View File
@@ -0,0 +1,54 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from specklepy.objects.base import Base
from specklepy.objects.geometry import Plane, Point
from specklepy.objects.interfaces import IHasUnits
class AlignmentHorizontal(Enum):
Left = 0
Center = 1
Right = 2
class AlignmentVertical(Enum):
Top = 0
Center = 1
Bottom = 2
@dataclass(kw_only=True)
class Text(Base, IHasUnits, speckle_type="Objects.Annotation.Text"):
"""
Text class for representation in the viewer.
Units will be 'Units.None' if the text size is defined in pixels.
"""
value: str # Plain text, without formatting
origin: Point # Relation to the text is defined by AlignmentH and AlignmentV
height: float # Font height in linear units or pixels (if Units.None)
alignmentH: AlignmentHorizontal = field(
default_factory=lambda: AlignmentHorizontal.Left
)
alignmentV: AlignmentVertical = field(default_factory=lambda: AlignmentVertical.Top)
plane: Optional[Plane] = field(
default_factory=lambda: None
) # None if the text object orientation follows camera view
maxWidth: Optional[float] = field(
default_factory=lambda: None
) # Maximum width of the text field. None, if don't split into lines
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}("
f"value: {self.value}, "
f"origin: {self.origin}, "
f"height: {self.height}, "
f"alignmentH: {self.alignmentH}, "
f"alignmentV: {self.alignmentV}, "
f"plane: {self.plane}, "
f"maxWidth: {self.maxWidth}, "
f"units: {self.units})"
)
+27 -1
View File
@@ -3,7 +3,12 @@ from typing import Dict, List
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects.base import Base
from specklepy.objects.interfaces import IDataObject, IGisObject, IHasUnits
from specklepy.objects.interfaces import (
IBlenderObject,
IDataObject,
IGisObject,
IHasUnits,
)
@dataclass(kw_only=True)
@@ -79,3 +84,24 @@ class QgisObject(
raise SpeckleException(
f"'type' value should be string, received {type(value)}"
)
@dataclass(kw_only=True)
class BlenderObject(
DataObject, IBlenderObject, IHasUnits, speckle_type="Objects.Data.BlenderObject"
):
type: str
_type: str = field(repr=False, init=False)
@property
def type(self) -> str:
return self._type
@type.setter
def type(self, value: str):
if isinstance(value, str):
self._type = value
else:
raise SpeckleException(
f"'type' value should be string, received {type(value)}"
)
-1
View File
@@ -47,7 +47,6 @@ class Region(
@property
def displayValue(self) -> List[Mesh]:
print(self._displayValue)
return self._displayValue
@displayValue.setter
@@ -199,8 +199,9 @@ class BaseObjectSerializer:
# write detached or root objects to transports
if detached and self.write_transports:
serialized_data = ujson.dumps(object_builder)
for t in self.write_transports:
t.save_object(id=obj_id, serialized_object=ujson.dumps(object_builder))
t.save_object(id=obj_id, serialized_object=serialized_data)
del self.lineage[-1]
@@ -0,0 +1,85 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from specklepy.core.api.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
)
@pytest.mark.run()
class TestActiveUserResourcePermissions:
@pytest.fixture()
def test_project(self, client: SpeckleClient) -> Project:
project = client.project.create(
ProjectCreateInput(
name="test project for active user permissions",
description="test description",
visibility=None,
)
)
return project
def test_active_user_get_projects_with_permissions(
self, client: SpeckleClient, test_project: Project
):
result = client.active_user.get_projects_with_permissions()
assert isinstance(result, ResourceCollection)
assert len(result.items) >= 1
test_project_with_permissions = None
for project in result.items:
if project.id == test_project.id:
test_project_with_permissions = project
break
assert test_project_with_permissions is not None
assert isinstance(test_project_with_permissions, ProjectWithPermissions)
assert hasattr(test_project_with_permissions, "permissions")
assert test_project_with_permissions.permissions is not None
assert test_project_with_permissions.id == test_project.id
assert test_project_with_permissions.name == test_project.name
permissions = test_project_with_permissions.permissions
assert hasattr(permissions, "can_create_model")
assert hasattr(permissions, "can_delete")
assert hasattr(permissions, "can_load")
assert hasattr(permissions, "can_publish")
assert permissions.can_create_model.authorized is True
assert permissions.can_delete.authorized is True
assert permissions.can_load.authorized is True
assert permissions.can_publish.authorized is True
def test_active_user_get_projects_with_permissions_with_filter(
self, client: SpeckleClient, test_project: Project
):
"""test getting active user's projects with permissions using a filter."""
filter = UserProjectsFilter(search=test_project.name)
result = client.active_user.get_projects_with_permissions(filter=filter)
assert isinstance(result, ResourceCollection)
assert len(result.items) >= 1
assert result.total_count >= 1
project_with_permissions = result.items[0]
assert isinstance(project_with_permissions, ProjectWithPermissions)
assert project_with_permissions.id == test_project.id
assert hasattr(project_with_permissions, "permissions")
assert project_with_permissions.permissions is not None
def test_active_user_projects_with_permissions_method_exists(
self, client: SpeckleClient
):
"""test that the method exists and is callable on active user resource."""
assert hasattr(client.active_user, "get_projects_with_permissions")
method = client.active_user.get_projects_with_permissions
assert callable(method)
@@ -3,6 +3,7 @@ from typing import Optional
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.project_inputs import (
ProjectCreateInput,
ProjectInviteCreateInput,
@@ -22,7 +23,9 @@ class TestProjectInviteResource:
@pytest.fixture
def project(self, client: SpeckleClient):
return client.project.create(
ProjectCreateInput(name="test", description=None, visibility=None)
ProjectCreateInput(
name="test", description=None, visibility=ProjectVisibility.PUBLIC
)
)
@pytest.fixture
@@ -51,8 +51,8 @@ class TestProjectResource:
assert result.name == name
assert result.description == (description or "")
# we've disabled creation of public projects for now, they fall back to unlisted
if visibility == ProjectVisibility.PUBLIC:
assert result.visibility == ProjectVisibility.UNLISTED
if visibility == ProjectVisibility.UNLISTED:
assert result.visibility == ProjectVisibility.PUBLIC
else:
assert result.visibility == visibility
@@ -78,7 +78,7 @@ class TestProjectResource:
def test_project_update(self, client: SpeckleClient, test_project: Project):
new_name = "MY new name"
new_description = "MY new desc"
new_visibility = ProjectVisibility.PUBLIC
new_visibility = ProjectVisibility.UNLISTED
update_data = ProjectUpdateInput(
id=test_project.id,
@@ -94,8 +94,8 @@ class TestProjectResource:
assert updated_project.name == new_name
assert updated_project.description == new_description
# we've disabled creation of public projects for now, they fall back to unlisted
if new_visibility == ProjectVisibility.PUBLIC:
assert updated_project.visibility == ProjectVisibility.UNLISTED
if new_visibility == ProjectVisibility.UNLISTED:
assert updated_project.visibility == ProjectVisibility.PUBLIC
else:
assert updated_project.visibility == new_visibility
@@ -0,0 +1,19 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.logging.exceptions import GraphQLException
@pytest.mark.run()
class TestWorkspaceResourcePermissions:
def test_get_projects_with_permissions(self, client: SpeckleClient):
with pytest.raises(GraphQLException):
client.workspace.get_projects_with_permissions("not a real id")
def test_get_projects_with_permissions_method_exists(self, client: SpeckleClient):
"""
test that the method exists with the correct signature.
"""
assert hasattr(client.workspace, "get_projects_with_permissions")
method = client.workspace.get_projects_with_permissions
assert callable(method)
+252
View File
@@ -0,0 +1,252 @@
import pytest
from specklepy.core.api.operations import deserialize, serialize
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects.base import Base
from specklepy.objects.data_objects import BlenderObject, DataObject, QgisObject
from specklepy.objects.interfaces import (
IBlenderObject,
IDataObject,
IGisObject,
IHasUnits,
)
from specklepy.objects.models.units import Units
def test_data_object_creation():
display_value = [Base()]
data_obj = DataObject(
name="Test Data Object",
properties={"key1": "value1", "key2": 2},
displayValue=display_value,
)
assert data_obj.name == "Test Data Object"
assert data_obj.properties == {"key1": "value1", "key2": 2}
assert data_obj.displayValue == display_value
assert data_obj.speckle_type == "Objects.Data.DataObject"
def test_inheritance_relationships():
data_obj = DataObject(
name="Test Data Object",
properties={"key": "value"},
displayValue=[Base()],
)
assert isinstance(data_obj, DataObject)
assert isinstance(data_obj, Base)
assert isinstance(data_obj, IDataObject)
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key": "value"},
displayValue=[Base()],
type="Feature",
units=Units.m,
)
assert isinstance(qgis_obj, QgisObject)
assert isinstance(qgis_obj, DataObject)
assert isinstance(qgis_obj, Base)
assert isinstance(qgis_obj, IDataObject)
assert isinstance(qgis_obj, IGisObject)
assert isinstance(qgis_obj, IHasUnits)
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key": "value"},
displayValue=[Base()],
type="Mesh",
units=Units.m,
)
assert isinstance(blender_obj, BlenderObject)
assert isinstance(blender_obj, DataObject)
assert isinstance(blender_obj, Base)
assert isinstance(blender_obj, IDataObject)
assert isinstance(blender_obj, IBlenderObject)
assert isinstance(blender_obj, IHasUnits)
def test_data_object_invalid_types():
data_obj = DataObject(
name="Test Object",
properties={"key": "value"},
displayValue=[Base()],
)
class ComplexObject:
def __str__(self):
raise ValueError("Can't convert to string")
complex_obj = ComplexObject()
with pytest.raises((ValueError, SpeckleException)):
data_obj.name = complex_obj # should be string
with pytest.raises(SpeckleException):
data_obj.properties = [1, 2, 3] # should be dict, not list
with pytest.raises(SpeckleException):
data_obj.displayValue = {"key": "value"} # should be list, not dict
def test_data_object_serialization():
display_value = [Base()]
data_obj = DataObject(
name="Test Data Object",
properties={"key1": "value1", "key2": 2},
displayValue=display_value,
)
serialized = serialize(data_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, DataObject)
assert deserialized.name == data_obj.name
assert deserialized.properties == data_obj.properties
assert len(deserialized.displayValue) == len(data_obj.displayValue)
assert deserialized.speckle_type == data_obj.speckle_type
def test_qgis_object_creation():
display_value = [Base()]
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Feature",
units=Units.m,
)
assert qgis_obj.name == "Test QGIS Object"
assert qgis_obj.properties == {"key1": "value1"}
assert qgis_obj.displayValue == display_value
assert qgis_obj.type == "Feature"
assert qgis_obj.units == Units.m.value
assert "Objects.Data.QgisObject" in qgis_obj.speckle_type
def test_qgis_object_serialization():
display_value = [Base()]
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Feature",
units=Units.m,
)
serialized = serialize(qgis_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, QgisObject)
assert deserialized.name == qgis_obj.name
assert deserialized.properties == qgis_obj.properties
assert len(deserialized.displayValue) == len(qgis_obj.displayValue)
assert deserialized.type == qgis_obj.type
assert deserialized.units == qgis_obj.units
assert "Objects.Data.QgisObject" in deserialized.speckle_type
def test_blender_object_creation():
display_value = [Base()]
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Mesh",
units=Units.m,
)
assert blender_obj.name == "Test Blender Object"
assert blender_obj.properties == {"key1": "value1"}
assert blender_obj.displayValue == display_value
assert blender_obj.type == "Mesh"
assert blender_obj.units == Units.m.value
assert "Objects.Data.BlenderObject" in blender_obj.speckle_type
def test_blender_object_invalid_types():
blender_obj = BlenderObject(
name="Test Object",
properties={"key": "value"},
displayValue=[Base()],
type="Mesh",
units=Units.m,
)
class ComplexObject:
def __str__(self):
raise ValueError("Can't convert to string")
complex_obj = ComplexObject()
with pytest.raises((ValueError, SpeckleException)):
blender_obj.type = complex_obj # should be string
def test_blender_object_serialization():
display_value = [Base()]
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Mesh",
units=Units.m,
)
serialized = serialize(blender_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, BlenderObject)
assert deserialized.name == blender_obj.name
assert deserialized.properties == blender_obj.properties
assert len(deserialized.displayValue) == len(blender_obj.displayValue)
assert deserialized.type == blender_obj.type
assert deserialized.units == blender_obj.units
assert "Objects.Data.BlenderObject" in deserialized.speckle_type
def test_data_object_property_modification():
data_obj = DataObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
)
data_obj.name = "Updated Name"
data_obj.properties = {"updated": "property"}
new_display_value = [Base(), Base()]
data_obj.displayValue = new_display_value
assert data_obj.name == "Updated Name"
assert data_obj.properties == {"updated": "property"}
assert data_obj.displayValue == new_display_value
def test_qgis_object_property_modification():
"""Test modification of QgisObject properties after creation."""
qgis_obj = QgisObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
type="OriginalType",
units=Units.m,
)
qgis_obj.type = "UpdatedType"
assert qgis_obj.type == "UpdatedType"
def test_blender_object_property_modification():
blender_obj = BlenderObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
type="OriginalType",
units=Units.m,
)
blender_obj.type = "UpdatedType"
assert blender_obj.type == "UpdatedType"
+100
View File
@@ -0,0 +1,100 @@
import pytest
from specklepy.core.api.operations import deserialize, serialize
from specklepy.objects.annotation import AlignmentHorizontal, AlignmentVertical, Text
from specklepy.objects.geometry import Plane, Point, Vector
from specklepy.objects.models.units import Units
@pytest.fixture
def sample_point() -> Point:
return Point(x=0.0, y=0.0, z=0.0, units=Units.m)
@pytest.fixture
def sample_plane(sample_point: Point) -> Plane:
normal = Vector(x=0.0, y=0.0, z=1.0, units=Units.m)
xdir = Vector(x=1.0, y=0.0, z=0.0, units=Units.m)
ydir = Vector(x=0.0, y=1.0, z=0.0, units=Units.m)
return Plane(
origin=sample_point, normal=normal, xdir=xdir, ydir=ydir, units=Units.m
)
@pytest.fixture
def sample_text(sample_point: Point) -> Text:
return Text(value="text", origin=sample_point, height=0.5, units=Units.m)
@pytest.fixture
def sample_text_all_properties(sample_point: Point, sample_plane: Plane) -> Text:
return Text(
value="text",
origin=sample_point,
height=0.5,
alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center,
plane=sample_plane,
maxWidth=20,
units=Units.m,
)
def test_text_creation_minimal(sample_point: Point):
text_value = "text"
text_obj = Text(value=text_value, origin=sample_point, height=0.5, units=Units.m)
assert text_obj.value == text_value
assert text_obj.origin == sample_point
assert text_obj.height == 0.5
assert text_obj.alignmentH == AlignmentHorizontal.Left
assert text_obj.alignmentV == AlignmentVertical.Top
assert text_obj.plane is None
assert text_obj.maxWidth is None
assert text_obj.units == Units.m.value
def test_text_creation_extended(sample_point: Point, sample_plane: Plane):
text_value = "text"
max_width = 20
text_obj = Text(
value=text_value,
origin=sample_point,
height=0.5,
alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center,
plane=sample_plane,
maxWidth=max_width,
units=Units.m,
)
assert text_obj.value == text_value
assert text_obj.origin == sample_point
assert text_obj.height == 0.5
assert text_obj.alignmentH == AlignmentHorizontal.Center
assert text_obj.alignmentV == AlignmentVertical.Center
assert text_obj.plane == sample_plane
assert text_obj.maxWidth == max_width
assert text_obj.units == Units.m.value
def test_point_serialization(sample_text_all_properties: Text):
serialized = serialize(sample_text_all_properties)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Text)
assert deserialized.value == sample_text_all_properties.value
assert deserialized.origin.x == sample_text_all_properties.origin.x
assert deserialized.origin.y == sample_text_all_properties.origin.y
assert deserialized.origin.z == sample_text_all_properties.origin.z
assert deserialized.height == sample_text_all_properties.height
assert deserialized.alignmentH == sample_text_all_properties.alignmentH
assert deserialized.alignmentV == sample_text_all_properties.alignmentV
assert deserialized.plane.origin.x == sample_text_all_properties.plane.origin.x
assert deserialized.plane.origin.y == sample_text_all_properties.plane.origin.y
assert deserialized.plane.origin.z == sample_text_all_properties.plane.origin.z
assert deserialized.plane.normal.x == sample_text_all_properties.plane.normal.x
assert deserialized.plane.normal.y == sample_text_all_properties.plane.normal.y
assert deserialized.plane.normal.z == sample_text_all_properties.plane.normal.z
assert deserialized.maxWidth == sample_text_all_properties.maxWidth
assert deserialized.units == sample_text_all_properties.units
Generated
+1301 -1049
View File
File diff suppressed because it is too large Load Diff