22 Commits

Author SHA1 Message Date
KatKatKateryna 237381939f Prompt to login, if no accounts locally yet (#46)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* upgrade specklepy

* load UI without account

* call account widget, resize account search window
2025-05-16 21:27:19 +01:00
KatKatKateryna 6a8b2d9e28 Desktop services auth (#44)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* add accounts via desktop service

* remove widget on bckgr click

* correct Add new account btn behavior

* don't duplicate account cards

* refresh workspace list dropdown; add READY button

* filter out personal workspace projects from projects
2025-05-04 23:54:12 +01:00
KatKatKateryna 54241d427e send non-horizontal polygons as meshes (#39)
* send non-horizontal polygons as meshes

* merge mesh per feature

* fix

* substiture regions with meshes

* move to utils
2025-05-02 13:57:26 +01:00
KatKatKateryna 600c4460c1 Fix workspace access (#42)
* cleaned permissions

* remove Workspace field on project creation

* project creation in the correct workspace

* .
2025-05-01 23:13:13 +01:00
Jedd Morgan 8c49ff8ee0 refactor(ci): Update workflow to use new consolidated deployment workflow (#36)
* test1

* trigger

* Correct wild card

* is_public_release

* use upstream fork

* target main
2025-05-01 16:34:38 +02:00
KatKatKateryna 5bd67fa171 Add project search by selected Workspace (#41)
* add workspace selection menu, update specklepy

* styling

* add filter by workspace id

* customize workspace query (possibly includes non-writeable project by active user)

* filter roles
2025-05-01 16:28:08 +02:00
KatKatKateryna 0f5957cbd0 check for QtVatiant NULL for symbols lookup (#40) 2025-05-01 11:10:43 +01:00
KatKatKateryna 6cd0d47dae add RenderMaterials and update specklepy with requirements (#38)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-04-22 16:01:23 +01:00
KatKatKateryna 72e6566334 Send regions with Meshes for displayValue (#35)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* send Regions

* send displayValue meshes

* fix meshing

* fixed again

* specklepy update and typo
2025-03-29 02:00:57 +08:00
KatKatKateryna 19b87984e4 clear UI if task exited (#34) 2025-03-14 08:29:02 +08:00
KatKatKateryna c694cf57a9 release-triggering commit
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-03-06 10:42:55 +00:00
KatKatKateryna 2f54e90cdf Metrics version fix (#32)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* calculate metrics inside the plugin

* calculate and store metric vals in Utils
2025-02-28 20:30:46 +08:00
KatKatKateryna 65a36557be update specklepy dependency
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-02-26 16:47:15 +00:00
KatKatKateryna b635cbde8a add version and experimental tag (#31) 2025-02-27 00:35:21 +08:00
KatKatKateryna df710deefe rename hostapp for dependencies folder 2025-02-26 16:21:36 +00:00
KatKatKateryna 318daf7b48 rename workflow 2025-02-26 15:10:04 +00:00
KatKatKateryna 1f02c33c5b Create release.yml (#22)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
* Create release.yml

* return patch requirements

* First pass implementing QGIS github actions

* rename repo

* update specklepy

* version to semver; artifact retention; rename workflow

* echo semver

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2025-02-26 23:07:54 +08:00
Jedd Morgan aa66061925 Add CI workflow (#30)
* Create release.yml

* return patch requirements

* First pass implementing QGIS github actions

* Poetry export plugin

* Added poetry install

* rename repo

* Pinned CI python version to 3.11

* Added comment

* Added gitversion

* Add bash script

* Add bash script as executable

* change action to run sh

* build was gitignored

* Added dotnet tool manifest

* continue on error false

* dummy tag

* Revert "dummy tag"

This reverts commit b10fdf3035.

* fix version and workflow name (#25)

* Revert "dummy tag"

This reverts commit b10fdf3035.

* remove env

* trigger wrong workflow(just to check version)

* Revert "trigger wrong workflow(just to check version)"

This reverts commit 21958d40c7.

* updates

* Changed zip name to match slug

* don't need to setup .net on a machine that already has it

* rename zip instead

* restructed zip

* remove extra input

* add outputs

* rename outputs

---------

Co-authored-by: KatKatKateryna <89912278+KatKatKateryna@users.noreply.github.com>
Co-authored-by: KatKatKateryna <kateryna@speckle.systems>
2025-02-26 14:54:33 +00:00
KatKatKateryna 648fb31558 Serialize properties better (#29)
* convert properties to primitive types before serialization

* rename reserved 'id' property
2025-02-26 09:42:31 +08:00
KatKatKateryna 5e8d2ee3ca Clear widgets on_document_changed (#28)
* patch requirements

* clear UI on document create/read

* create start widget on document change
2025-02-26 01:41:49 +08:00
Mucahit Bilal GOKER d20ecd982c updatedAt -> updated_at (#26)
* updatedAt -> updated_at

* another one

* remaining props renamed

---------

Co-authored-by: KatKatKateryna <kateryna@speckle.systems>
2025-02-25 18:43:26 +08:00
KatKatKateryna d319efba87 install pre-releases (#27) 2025-02-25 18:35:54 +08:00
29 changed files with 1707 additions and 1015 deletions
+22 -12
View File
@@ -1,8 +1,8 @@
name: build_powerbi
name: build_qgis
on:
push:
branches: ["main", "dev", "release/*", "alan/*"] # Continuous delivery on every long-lived branch
tags: ["v3.*"] # Manual delivery on every 3.x tag
branches: ["main", "installer-test/**"] # Continuous delivery on every long-lived branch
tags: ["v3.*.*"] # Manual delivery on every v3.x tag
env:
ZipName: "qgis.zip"
@@ -11,7 +11,8 @@ jobs:
build-connector:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -40,6 +41,10 @@ jobs:
run: |
python plugin_utils/patch_requirements.py
- name: Update version in plugin metadata.txt
run: |
python plugin_utils/patch_version.py ${{ env.GitVersion_FullSemVer }}
- name: Install dependencies
run: |
pip install pb_tool==3.1.0 pyqt5==5.15.9
@@ -51,11 +56,11 @@ jobs:
- id: set-version
name: Set version to output
run: echo "version=${{ env.GitVersion_FullSemVer }}" >> "$GITHUB_OUTPUT"
run: echo "semver=${{ env.GitVersion_FullSemVer }}" >> "$GITHUB_OUTPUT"
- id: set-info-version
name: Set file version to output
run: echo "file_version=${{ env.GitVersion_AssemblySemVer}}" >> "$GITHUB_OUTPUT" # version will be retrieved from tag?
run: echo "file-version=${{ env.GitVersion_AssemblySemVer}}" >> "$GITHUB_OUTPUT" # version will be retrieved from tag?
- name: ⬆️ Upload artifacts
uses: actions/upload-artifact@v4
@@ -70,16 +75,21 @@ jobs:
runs-on: ubuntu-latest
needs: build-connector
env:
IS_TAG_BUILD: ${{ github.ref_type == 'tag' }}
IS_RELEASE_BRANCH: ${{ startsWith(github.ref_name, 'release/') || github.ref_name == 'main'}}
IS_PUBLIC_RELEASE: ${{ github.ref_type == 'tag' }}
steps:
- name: 🔫 Trigger Build QGIS
uses: ALEEF02/workflow-dispatch@v3.0.0
- name: 🔫 Trigger Build Installer(s)
uses: the-actions-org/workflow-dispatch@v4.0.0
with:
workflow: Build QGIS
workflow: Build Installers
repo: specklesystems/connector-installers
token: ${{ secrets.CONNECTORS_GH_TOKEN }}
inputs: '{ "run_id": "${{ github.run_id }}", "semver": "${{ needs.build-connector.outputs.semver }}", "file_version": "${{ needs.build-connector.outputs.file-version }}", "public_release": ${{ env.IS_TAG_BUILD }} }'
inputs: '{
"run_id": "${{ github.run_id }}",
"semver": "${{ needs.build-connector.outputs.semver }}",
"file_version": "${{ needs.build-connector.outputs.file-version }}",
"repo": "${{ github.repository }}",
"is_public_release": ${{ env.IS_PUBLIC_RELEASE }}
}'
ref: main
wait-for-completion: true
wait-for-completion-interval: 10s
+1 -21
View File
@@ -11,8 +11,6 @@ try:
from plugin_utils.installer import ensure_dependencies, startDebugger
from plugin_utils.panel_logging import logger
from qgis.core import Qgis
# noinspection PyPep8Naming
def classFactory(iface): # pylint: disable=invalid-name
"""Load SpeckleQGIS class from file SpeckleQGIS.
@@ -27,29 +25,11 @@ try:
# Ensure dependencies are installed in the machine
startDebugger()
ensure_dependencies("QGIS")
ensure_dependencies("QGISv3")
from speckle_qgis_v3 import SpeckleQGIS
from specklepy.logging import metrics
version = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split(".")[0]
)
metrics.set_host_app("qgis", version)
return SpeckleQGIS(iface)
class EmptyClass:
# https://docs.qgis.org/3.28/en/docs/pyqgis_developer_cookbook/plugins/plugins.html#mainplugin-py
def __init__(self, iface):
pass
def initGui(self):
pass
def unload(self):
pass
except ModuleNotFoundError:
pass
-97
View File
@@ -1,97 +0,0 @@
import re
import sys
def patch_installer(tag):
"""Patches the installer with the correct connector version and specklepy version"""
iss_file = "speckle-sharp-ci-tools/qgis.iss"
metadata = "metadata.txt"
plugin_start_file = "speckle_qgis.py"
try:
with open(iss_file, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "#define AppVersion " in line:
line = f'#define AppVersion "{tag.split("-")[0]}"\n'
if "#define AppInfoVersion " in line:
line = f'#define AppInfoVersion "{tag}"\n'
new_lines.append(line)
with open(iss_file, "w") as file:
file.writelines(new_lines)
print(f"Patched installer with connector v{tag} ")
file.close()
except:
pass
with open(metadata, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "version=" in line:
line = f"version={tag}\n" # .split("-")[0]
if "experimental=" in line:
if "-" in tag:
line = f"experimental=True\n" # .split("-")[0]
elif len(tag.split(".")) == 3 and tag != "0.0.99":
line = f"experimental=False\n" # .split("-")[0]
new_lines.append(line)
with open(metadata, "w") as file:
file.writelines(new_lines)
print(f"Patched metadata v{tag} ")
file.close()
with open(plugin_start_file, "r") as file:
lines = file.readlines()
for i, line in enumerate(lines):
if "self.version = " in line:
lines[i] = (
lines[i].split('"')[0]
+ '"'
+ tag.split("-")[0]
+ '"'
+ lines[i].split('"')[2]
)
break
with open(plugin_start_file, "w") as file:
file.writelines(lines)
print(f"Patched GIS start file with connector v{tag} and specklepy ")
file.close()
r"""
def whlFileRename(fileName: str):
with open(fileName, "r") as file:
lines = file.readlines()
for i, line in enumerate(lines):
if "-py3-none-any.whl" in line:
p1 = line.split("-py3-none-any.whl")[0].split("-")[0]
p2 = f'{tag.split("-")[0]}'
p3 = line.split("-py3-none-any.whl")[1]
lines[i] = p1+"-"+p2+"-py3-none-any.whl"+p3
with open(fileName, "w") as file:
file.writelines(lines)
print(f"Patched toolbox_installer with connector v{tag} and specklepy ")
file.close()
whlFileRename(conda_file)
whlFileRename(toolbox_install_file)
whlFileRename(toolbox_manual_install_file)
"""
def main():
if len(sys.argv) < 2:
return
tag = sys.argv[1]
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
raise ValueError(f"Invalid tag provided: {tag}")
print(f"Patching version: {tag}")
# patch_connector(tag.split("-")[0]) if I need to edit a connector file
patch_installer(tag)
if __name__ == "__main__":
main()
+1
View File
@@ -186,6 +186,7 @@ def install_requirements(host_application: str) -> None:
"-m",
"pip",
"install",
"--pre",
"-t",
str(path),
"-r",
+39
View File
@@ -0,0 +1,39 @@
import sys
def patch_installer(tag):
"""Patches the installer with the correct connector version and specklepy version"""
metadata = "metadata.txt"
with open(metadata, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "version=" in line:
line = f"version={tag}\n"
if "experimental=" in line:
if "-" in tag:
line = f"experimental=True\n"
elif len(tag.split(".")) == 3 and tag != "0.0.99" and "-" not in tag:
line = f"experimental=False\n" # .split("-")[0]
new_lines.append(line)
with open(metadata, "w") as file:
file.writelines(new_lines)
print(f"Patched metadata v{tag} ")
file.close()
def main():
if len(sys.argv) < 2:
return
tag = sys.argv[1]
print(f"Patching version: {tag}")
patch_installer(tag)
if __name__ == "__main__":
main()
+30 -30
View File
@@ -1,51 +1,51 @@
annotated-types==0.7.0
anyio==4.6.2.post1
anyio==4.9.0
appdirs==1.4.4
attrs==23.2.0
attrs==25.3.0
backoff==2.2.1
certifi==2024.8.30
charset-normalizer==3.4.0
certifi==2025.4.26
charset-normalizer==3.4.2
click-plugins==1.1.1
click==8.1.7
click==8.2.0
cligj==0.7.2
colorama==0.4.6
deprecated==1.2.14
deprecated==1.2.18
earcut==1.1.5
exceptiongroup==1.2.2
exceptiongroup==1.3.0
fiona==1.10.1
geopandas==0.13.2
geovoronoi==0.4.0
gql==3.5.0
graphql-core==3.2.5
h11==0.14.0
httpcore==1.0.6
httpx==0.25.2
gql==3.5.2
graphql-core==3.2.4
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.10
importlib-metadata==8.5.0
multidict==6.1.0
multidict==6.4.3
numpy==1.26.4
packaging==24.1
packaging==25.0
pandas==2.2.3
propcache==0.2.0
pydantic-core==2.23.4
pydantic==2.9.2
pyproj==3.6.1
propcache==0.3.1
pydantic-core==2.33.2
pydantic-settings==2.9.1
pydantic==2.11.4
pyproj==3.7.1
pyshp==2.3.1
python-dateutil==2.9.0.post0
pytz==2024.2
python-dotenv==1.1.0
pytz==2025.2
requests-toolbelt==1.0.0
requests==2.31.0
scipy==1.13.1
shapely==2.0.6
six==1.16.0
scipy==1.15.3
shapely==2.1.0
six==1.17.0
sniffio==1.3.1
specklepy==2.21.3
stringcase==1.2.0
typing-extensions==4.12.2
tzdata==2024.2
specklepy==3.0.0a15
typing-extensions==4.13.2
typing-inspection==0.4.0
tzdata==2025.2
ujson==5.10.0
urllib3==2.2.1
websockets==11.0.3
wrapt==1.16.0
yarl==1.17.1
zipp==3.20.2
wrapt==1.17.2
yarl==1.20.0
Generated
+758 -700
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,7 +17,7 @@ geopandas = "0.13.2"
geovoronoi = "0.4.0"
scipy = "^1.13.0"
earcut = "1.1.5"
specklepy = {version = "^3.0.0a1", allow-prereleases = true}
specklepy = {version = "^3.0.0a15", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
@@ -35,7 +35,7 @@ class QgisBasicConnectorBinding(IBasicConnectorBinding):
self.parent = bridge
self.commands = BasicConnectorBindingCommands(bridge)
self.store.document_changed = lambda: self.commands.notify_document_changed()
# self.store.document_changed = lambda: self.commands.notify_document_changed()
def get_source_app_name(self) -> str:
# TODO self.speckle_application.slug
@@ -127,13 +127,6 @@ class QgisSendBinding(ISendBinding, QObject, metaclass=MetaQObject):
self.commads = SendBindingUICommands(bridge)
self.subscribe_to_qgis_events()
def new_func():
self.store.document_changed()
# TODO
# self.send_conversion_cache.clear_cache()
self.store.document_changed = new_func
def subscribe_to_qgis_events(self):
# TODO
return
+40 -7
View File
@@ -5,15 +5,38 @@ from speckle.ui.models import DocumentModelStore
from specklepy.objects.models.collections.collection import Collection
from specklepy.objects.proxies import ColorProxy
from PyQt5.QtGui import QColor
from qgis.core import QgsLayerTreeGroup, QgsVectorLayer, QgsRasterLayer, QgsFeature
from PyQt5.QtCore import pyqtSignal, QObject, QTimer, QVariant
class QgisDocumentStore(DocumentModelStore):
def __init__(self):
class MetaQObject(type(QObject), type(DocumentModelStore)):
# avoiding TypeError: metaclass conflict: the metaclass of a derived class
# must be a (non-strict) subclass of the metaclasses of all its bases
pass
class QgisDocumentStore(DocumentModelStore, QObject, metaclass=MetaQObject):
document_changed_signal = pyqtSignal()
def __init__(self, iface):
QObject.__init__(self)
self.models = []
self.is_document_init = False
# connect to reading document from disk
iface.projectRead.connect(
lambda: QTimer.singleShot(0, self.on_document_changed)
)
# connect to creating new document
iface.newProjectCreated.connect(
lambda: QTimer.singleShot(0, self.on_document_changed)
)
def document_changed(self):
self.document_changed_signal.emit()
def on_project_closing(self):
return
@@ -95,7 +118,10 @@ class QgisLayerUnpacker:
if isinstance(layer, QgsVectorLayer):
layer_fields: Dict[str, Any] = {}
for field in layer.fields():
layer_fields[field.name()] = field.type()
# rename reserved property name 'id' (here and in PropertiesExtractor)
field_name = field.name() if field.name() != "id" else "ID"
layer_fields[field_name] = field.type()
collection["fields"] = layer_fields
collection["wkbType"] = layer.wkbType().name
@@ -183,8 +209,11 @@ class QgisColorUnpacker:
) -> Any:
feature_value_for_rendering = feature.attribute(self.stored_renderer_field)
category_index = renderer.categoryIndexForValue(feature_value_for_rendering)
value_symbol = renderer.categories()[category_index].symbol()
if not isinstance(feature_value_for_rendering, QVariant): # for QVariant.NULL
category_index = renderer.categoryIndexForValue(feature_value_for_rendering)
value_symbol = renderer.categories()[category_index].symbol()
else:
value_symbol = renderer.sourceSymbol()
if not value_symbol:
value_symbol = renderer.sourceSymbol()
@@ -197,7 +226,11 @@ class QgisColorUnpacker:
) -> Any:
feature_value_for_rendering = feature.attribute(self.stored_renderer_field)
value_symbol = renderer.symbolForValue(feature_value_for_rendering)
if not isinstance(feature_value_for_rendering, QVariant): # for QVariant.NULL
value_symbol = renderer.symbolForValue(feature_value_for_rendering)
else:
value_symbol = renderer.sourceSymbol()
if not value_symbol:
value_symbol = renderer.sourceSymbol()
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, List, Optional
from specklepy.objects.geometry import Region
from speckle.ui.bindings import SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
@@ -234,3 +235,35 @@ class QgisLayerUtils:
selected_object_ids=[layer.id or layer.name for layer in selected_layers],
summary=f"{len(selected_layers)} layers ({", ".join(object_types)})",
)
def confirm_features_type(self, converted_features: List["QgisObject"]) -> None:
# check if it's a Polygon layer and it has vertical data (needs to be converted to Meshes)
convert_regions_to_meshes = False
for feature in converted_features:
polygon_dataset = True
for display_region in feature.displayValue:
if not isinstance(display_region, Region):
polygon_dataset = False
break
else: # if Region
if getattr(
display_region, "3d", None
): # sufficient condition to convert all dataset features to meshes
convert_regions_to_meshes = True
break
if convert_regions_to_meshes or not polygon_dataset:
break
# modify list of features if needed
if convert_regions_to_meshes:
for i, feature in enumerate(converted_features):
display_meshes = []
# replace displayValue of Region list to Mesh list
for display_region in feature.displayValue: # region
display_meshes.extend(display_region.displayValue)
converted_features[i].displayValue = display_meshes
@@ -19,12 +19,14 @@ from speckle.ui.models import SendInfo
from specklepy.objects.base import Base
# from specklepy.objects.data import QgisObject
from specklepy.objects.geometry.mesh import Mesh
from specklepy.objects.geometry import Mesh
from specklepy.objects.models.collections.collection import Collection
from qgis.core import QgsProject, QgsVectorLayer, QgsRasterLayer
from speckle.host_apps.qgis.connectors.utils import UNSUPPORTED_PROVIDERS
from specklepy.objects.other import RenderMaterial
from specklepy.objects.proxies import RenderMaterialProxy
class QgisRootObjectBuilder(IRootObjectBuilder):
@@ -148,6 +150,17 @@ class QgisRootObjectBuilder(IRootObjectBuilder):
self.color_unpacker.color_proxy_cache.values()
)
# duplicate colors into render materials
root_collection[ProxyKeys().RENDER_MATERIAL] = [
RenderMaterialProxy(
objects=x.objects,
value=RenderMaterial(
applicationId=x.applicationId, name=x.applicationId, diffuse=x.value
),
)
for x in self.color_unpacker.color_proxy_cache.values()
]
return RootObjectBuilderResult(
root_object=root_collection,
conversion_results=results,
@@ -174,6 +187,9 @@ class QgisRootObjectBuilder(IRootObjectBuilder):
feature, get_speckle_app_id(feature, layer_app_id)
)
# for 3d polygons: replace Regions with Meshes
self.layer_utils.confirm_features_type(converted_features)
return converted_features
def convert_raster_feature(
@@ -50,7 +50,7 @@ class QgisConnectorModule(QObject):
self.iface = iface
self.bridge = bridge
self.thread_context = QgisThreadContext()
self.document_store = QgisDocumentStore()
self.document_store = QgisDocumentStore(iface)
self.basic_binding = QgisBasicConnectorBinding(self.document_store, bridge)
self.send_binding = QgisSendBinding(
bridge=bridge,
+39 -2
View File
@@ -1,15 +1,52 @@
import os
from typing import Callable
from pathlib import Path
from specklepy.logging import metrics
from speckle.sdk.connectors_common.threading import ThreadContext
from qgis.core import Qgis
from qgis.core import Qgis, QgsApplication
UNSUPPORTED_PROVIDERS = ["WFS", "wms", "wcs", "vectortile"]
HOST_APP_FULL_VERSION = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split("-")[0]
)
UNSUPPORTED_PROVIDERS = ["WFS", "wms", "wcs", "vectortile"]
def get_core_version():
metadata_path = os.path.join(
QgsApplication.qgisSettingsDirPath(),
"python",
"plugins",
"speckle-qgis-v3",
"metadata.txt",
)
core_version = "3.0.099-alpha"
with open(metadata_path, "r") as file:
for i, line in enumerate(file.readlines()):
if "version=" in line:
core_version = line.replace("version=", "").replace("\n", "")
break
file.close()
return core_version
CORE_VERSION = get_core_version()
def setup_metrics():
# set hostApp and hostAppVersion
version = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split(".")[0]
)
metrics.set_host_app("qgis", version)
class QgisThreadContext(ThreadContext):
@@ -14,9 +14,8 @@ from qgis.core import (
QgsRasterLayer,
QgsGeometry,
QgsCoordinateTransform,
QgsPoint,
)
from osgeo import gdal
from PyQt5 import QtCore
class DisplayValueExtractor:
@@ -93,7 +92,24 @@ class PropertiesExtractor:
def get_properties(self, core_object: Any) -> Dict[str, Any]:
if isinstance(core_object, QgsFeature):
return core_object.attributeMap()
# print(core_object.attributeMap()) # shortcut, but we need special treatment for certain data types, therefore using the loop below
properties = {}
for field in core_object.fields():
# rename reserved property name (here and in create_and_cache_layer_collection)
name: str = field.name() if field.name() != "id" else "ID"
value = core_object[field.name()]
# convert values unfamiliar to our Serializer to String or Null
if type(value) is QtCore.QVariant:
value = None
elif type(value) is QtCore.QDate or QtCore.QDateTime or QtCore.QTime:
value = str(value)
properties[name] = value
return properties
elif isinstance(core_object, QgsRasterLayer):
return {} # TODO
@@ -0,0 +1,102 @@
import math
from typing import List
import earcut.earcut
from specklepy.objects.geometry.mesh import Mesh
from specklepy.objects.geometry.polyline import Polyline
def generate_region_mesh(boundary: Polyline, inner_loops: List[Polyline], units: str):
"""Generate Speckle Mesh for a planar shape represented by boundary and inner loops."""
# Get a 'list of coordinate tuples' for boundary points
vertices3d_tuples: List[List[float]] = _flat_coords_to_tuples(boundary)
# Get a list of 'lists of coordinate tuples' for inner loops
loops3d_tuples_list: List[List[List[float]]] = []
for loop in inner_loops:
vertices3d_loop_tuples = _flat_coords_to_tuples(loop)
loops3d_tuples_list.append(vertices3d_loop_tuples)
# triangulate region
all_coords, triangles = _get_all_coords_and_triangles(
vertices3d_tuples, loops3d_tuples_list
)
# construct mesh
mesh: Mesh = _construct_mesh_from_triangles(all_coords, triangles, units)
return mesh
def _flat_coords_to_tuples(polyline: Polyline):
"""Reduce resolution of the given polyline (if vertices exceed max amount),
and return the list of vertices' coordinate tuples."""
max_points = 1000
coef = math.ceil(len(polyline.value) / (3 * max_points))
# Get a list of coordinate tuples for polyline points
points_count = int(len(polyline.value) / 3)
coordinates_tuples: List[List[float]] = [
(
polyline.value[i * coef * 3],
polyline.value[i * coef * 3 + 1],
polyline.value[i * coef * 3 + 2],
)
for i, _ in enumerate(polyline.value)
if i * coef < points_count
]
return coordinates_tuples
def _get_all_coords_and_triangles(
vertices3d_tuples: List[List[float]], loops3d_tuples: List[List[List[float]]]
):
"""Triangulate the shape given tuple lists of boundary and loops' coordinates.
Return full flat list of triangulated vertices and list of triangle tuples."""
data = earcut.earcut.flatten([vertices3d_tuples] + loops3d_tuples)
triangles_flat_list = earcut.earcut.earcut(data["vertices"], data["holes"], dim=3)
triangle_tuples = [
[
triangles_flat_list[3 * i],
triangles_flat_list[3 * i + 1],
triangles_flat_list[3 * i + 2],
]
for i, _ in enumerate(triangles_flat_list)
if i < len(triangles_flat_list) / 3
]
return data["vertices"], triangle_tuples
def _construct_mesh_from_triangles(all_coords, triangles, units) -> Mesh:
"""Construct Speckle Mesh given a flat list of coordinates and a list of triangles
(defined by tuples with vertices' indices)."""
total_vertices = 0
vertices = []
faces = []
for trg in triangles:
# make sure all faces are clockwise (facing down). Seems earcut already returns clockwise faces
vertices.extend(
all_coords[3 * trg[0] : 3 * trg[0] + 3]
+ all_coords[3 * trg[1] : 3 * trg[1] + 3]
+ all_coords[3 * trg[2] : 3 * trg[2] + 3]
)
faces.extend(
[
3,
total_vertices,
total_vertices + 1,
total_vertices + 2,
]
)
total_vertices += 3
return Mesh(vertices=vertices, faces=faces, units=units)
@@ -3,9 +3,9 @@ from typing import List
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from specklepy.objects.geometry.point import Point
from specklepy.objects.geometry.polyline import Polyline
from specklepy.objects.geometry.mesh import Mesh
from speckle.host_apps.qgis.converters.to_speckle.mesher import generate_region_mesh
from specklepy.objects.base import Base
from specklepy.objects.geometry import Mesh, Point, Polyline, Region
from qgis.core import (
QgsAbstractGeometry,
@@ -124,7 +124,7 @@ class PolygonToSpeckleConverter:
self._conversion_settings = conversion_settings
self._polyline_converter = polyline_converter
def convert(self, target: QgsAbstractGeometry) -> List[Polyline]:
def convert(self, target: QgsAbstractGeometry) -> List[Base]:
wkb_type = target.wkbType()
@@ -142,18 +142,39 @@ class PolygonToSpeckleConverter:
or wkb_type == QgsWkbTypes.CurvePolygonM
or wkb_type == QgsWkbTypes.CurvePolygonZM
):
all_curves = []
all_regions = []
all_z_values = []
for part in target.parts():
all_curves.append(
self._polyline_converter.convert(part.exteriorRing())[0]
boundary = self._polyline_converter.convert(part.exteriorRing())[0]
all_z_values.extend(
[x for i, x in enumerate(boundary.value) if (i + 1) % 3 == 0]
)
inner_loops = []
for i in range(part.numInteriorRings()):
all_curves.append(
inner_loops.append(
self._polyline_converter.convert(part.interiorRing(i))[0]
)
return all_curves
display_mesh: Mesh = generate_region_mesh(
boundary, inner_loops, self._conversion_settings.speckle_units
)
new_region = Region(
boundary=boundary,
innerLoops=inner_loops,
hasHatchPattern=False,
displayValue=[display_mesh],
units=self._conversion_settings.speckle_units,
)
# hacky way to indicate that all features in the dataset should be sent as Meshes instead of Regions
if len(list(set(all_z_values))) > 1:
new_region["3d"] = True
all_regions.append(new_region)
# return list of Meshes, if not horizontal Polygon
return all_regions
raise ValueError(f"Geometry of type '{type(target)}' cannot be converted")
+32 -23
View File
@@ -31,9 +31,9 @@ class SpeckleQGISv3Module:
self.dockwidget = SpeckleQGISv3Dialog(
bridge=self, basic_binding=self.connector_module.basic_binding
)
self.dockwidget.runSetup(self)
self.dockwidget.header_widget = self.dockwidget.create_header(self)
self.dockwidget.runSetup()
self.connect_dockwidget_signals()
self.connect_self_signals()
def instantiate_module_dependencies(self, iface):
@@ -59,16 +59,20 @@ class SpeckleQGISv3Module:
self.dockwidget.add_send_notification
) # Send a UI notification after Send operation
# all dockwidget subscribtions to child widget signals are handled in Dockwidget class,
# because child widget are not persistent
# refresh widgets if document change signal received
self.connector_module.document_store.document_changed_signal.connect(
self.dockwidget.refresh_ui
)
def connect_self_signals(self):
# signal to update UI, needs t be transferred to the main thread
# signal to update UI, needs to be transferred to the main thread
self.dockwidget.activity_start_signal.connect(
self.dockwidget.add_activity_status
)
# all dockwidget subscribtions to child widget signals are handled in Dockwidget class,
# because child widget are not persistent
def connect_connector_module_signals(self):
# create conversion settings and RootObjectBuilder
self.connector_module.send_binding.create_send_modules_signal.connect(
self._create_send_modules
)
@@ -92,24 +96,29 @@ class SpeckleQGISv3Module:
on_operation_progressed: "IProgress[CardProgress]",
ct: "CancellationToken",
):
# first, update UI status
self.dockwidget.activity_start_signal.emit(
model_card_id, "Converting and sending.."
)
print("_execute_send_operation, send_operation.execute:")
# execute and return send operation results
send_operation_result: SendOperationResult = (
self.connector_module.send_operation.execute(
objects, send_info, on_operation_progressed, ct
# wrap into exception handler, which will cancel task and UI progress, instead of giving ipression that task still loads
try:
# first, update UI status
self.dockwidget.activity_start_signal.emit(
model_card_id, "Converting and sending.."
)
)
self.connector_module.send_binding.commads.set_model_send_result(
model_card_id=model_card_id,
version_id=send_operation_result.root_obj_id,
send_conversion_results=send_operation_result.converted_references,
)
print("_execute_send_operation -> send_operation.execute:")
# execute and return send operation results
send_operation_result: SendOperationResult = (
self.connector_module.send_operation.execute(
objects, send_info, on_operation_progressed, ct
)
)
self.connector_module.send_binding.commads.set_model_send_result(
model_card_id=model_card_id,
version_id=send_operation_result.root_obj_id,
send_conversion_results=send_operation_result.converted_references,
)
except Exception as e:
# TODO: also show an error message
print(e)
self._cancel_operation(model_card_id)
def _cancel_operation(self, model_card_id: str):
+6 -6
View File
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Any, Dict, List
from speckle.host_apps.qgis.connectors.utils import HOST_APP_FULL_VERSION
from speckle.host_apps.qgis.connectors.utils import HOST_APP_FULL_VERSION, CORE_VERSION
from speckle.sdk.connectors_common.api import IClientFactory, IOperations
from speckle.sdk.connectors_common.builders import (
IRootObjectBuilder,
@@ -136,7 +136,7 @@ class SendOperation:
account,
{
"hostAppFullVersion": HOST_APP_FULL_VERSION,
"core_version": "3.0.099",
"core_version": CORE_VERSION,
"ui": "dui3",
"workspace_id": get_project_workspace_id(client, send_info.project_id),
},
@@ -153,11 +153,11 @@ class SendOperation:
_ = api_client.version.create(
CreateVersionInput(
objectId=obj_id,
modelId=send_info.model_id,
projectId=send_info.project_id,
object_id=obj_id,
model_id=send_info.model_id,
project_id=send_info.project_id,
message="Sent from QGIS v3",
sourceApplication=send_info.host_application,
source_application=send_info.host_application,
)
)
+1 -1
View File
@@ -16,5 +16,5 @@ def get_project_workspace_id(client: SpeckleClient, project_id: str) -> Optional
min = server_version[1]
if maj > 2 or (maj == 2 and min > 20):
workspace_id = client.project.get(project_id).workspaceId
workspace_id = client.project.get(project_id).workspace_id
return workspace_id
+50 -18
View File
@@ -9,6 +9,7 @@ from specklepy.core.api.models.current import (
ResourceCollection,
)
from specklepy.core.api.resources.current.project_resource import ProjectResource
from specklepy.core.api.resources.current.workspace_resource import Workspace
from speckle.ui.utils.utils import (
create_new_project_query,
create_new_model_query,
@@ -28,6 +29,7 @@ class UiSearchUtils(QObject):
cursor_projects: Any = None
cursor_models: Any = None
speckle_client: SpeckleClient = None
current_workspace: Optional[Workspace] = None
batch_size: int = None
add_selection_filter_signal = pyqtSignal(SenderModelCard)
add_models_search_signal = pyqtSignal(Project)
@@ -36,30 +38,25 @@ class UiSearchUtils(QObject):
new_model_widget_signal = pyqtSignal(str)
change_account_and_projects_signal = pyqtSignal()
refresh_models_signal = pyqtSignal()
open_add_new_account_widget_signal = pyqtSignal()
add_new_account_signal = pyqtSignal()
clear_project_search_bar_signal = pyqtSignal()
clear_model_search_bar_signal = pyqtSignal()
def __init__(self):
super().__init__()
accounts: List[Account] = get_accounts()
if len(accounts) == 0: # TODO handle no local accounts
raise SpeckleException(
"Add accounts via Speckle Desktop Manager in order to start"
)
self.speckle_client: SpeckleClient = get_authenticate_client_for_account(
accounts[0]
)
self.batch_size = QUERY_BATCH_SIZE
def get_accounts_content(self):
accounts: List[Account] = get_accounts()
if len(accounts) == 0: # TODO handle no local accounts
raise SpeckleException(
"Add accounts via Speckle Desktop Manager in order to start"
if len(accounts) > 0:
self.speckle_client: SpeckleClient = get_authenticate_client_for_account(
accounts[0]
)
def get_accounts_content(self) -> List[List[Any]]:
accounts: List[Account] = get_accounts()
content_list = [
[
partial(self._replace_projects_list_with_new_account, acc),
@@ -77,11 +74,13 @@ class UiSearchUtils(QObject):
self.change_account_and_projects_signal.emit()
def get_account_initials(self):
if self.speckle_client is None:
return "?"
name = self.speckle_client.account.userInfo.name
if isinstance(name, str) and len(name) > 0:
return name[0]
return "X"
return "?"
def create_new_project(self, name: str, workspace_id: Optional[str] = None):
create_new_project_query(self.speckle_client, name, workspace_id)
@@ -90,6 +89,9 @@ class UiSearchUtils(QObject):
create_new_model_query(self.speckle_client, project_id, model_name)
def get_new_projects_content(self, clear_cursor=False):
workspace_id: Optional[str] = (
self.current_workspace.id if self.current_workspace else None
)
if clear_cursor:
self.cursor_projects = None
@@ -97,9 +99,18 @@ class UiSearchUtils(QObject):
content_list: List[List] = []
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client, cursor=self.cursor_projects
speckle_client=self.speckle_client,
workspace_id=workspace_id,
cursor=self.cursor_projects,
)
)
# filter out projects without workspace (if None)
if self.current_workspace is None:
projects_resource_collection.items = [
x for x in projects_resource_collection.items if x.workspace_id is None
]
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
@@ -111,15 +122,27 @@ class UiSearchUtils(QObject):
def get_new_projects_content_with_name_condition(self, name_filter: str):
workspace_id: Optional[str] = (
self.current_workspace.id if self.current_workspace else None
)
self.cursor_projects = None
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client,
workspace_id=workspace_id,
cursor=self.cursor_projects,
filter_keyword=name_filter,
)
)
# filter out projects without workspace (if None)
if self.current_workspace is None:
projects_resource_collection.items = [
x for x in projects_resource_collection.items if x.workspace_id is None
]
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
@@ -137,12 +160,15 @@ class UiSearchUtils(QObject):
content_list: List[List] = []
for project in projects_batch:
role = "can edit" if project.role is None else project.role.split(":")[-1]
# make sure to pass the actual project, not a reference to a variable
project_content = [
partial(self._emit_function_add_models_signal, project),
project.name,
project.role.split(":")[-1],
f"updated {time_ago(project.updatedAt)}",
role,
f"updated {time_ago(project.updated_at)}",
]
content_list.append(project_content)
return content_list
@@ -205,7 +231,7 @@ class UiSearchUtils(QObject):
model_content = [
partial(self.add_selection_filter_widget, project, model),
model.name,
f"updated {time_ago(model.updatedAt)}",
f"updated {time_ago(model.updated_at)}",
project,
]
content_list.append(model_content)
@@ -231,6 +257,12 @@ class UiSearchUtils(QObject):
)
)
def get_workspaces(self) -> List[Workspace]:
if self.speckle_client is None:
return []
workspaces = self.speckle_client.active_user.get_workspaces().items
return workspaces
def get_version_search_widget_content(self, project: ProjectResource) -> List[List]:
"""Add search cards for models (only valid for Receive workflow)."""
+77 -16
View File
@@ -9,8 +9,10 @@ from specklepy.core.api.inputs.model_inputs import CreateModelInput
from specklepy.core.api.inputs.project_inputs import (
ProjectCreateInput,
ProjectModelsFilter,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import (
Model,
Project,
@@ -49,19 +51,51 @@ def get_authenticate_client_for_account(account: Account) -> SpeckleClient:
def get_projects_from_client(
speckle_client: SpeckleClient, cursor=None, filter_keyword: Optional[str] = None
speckle_client: SpeckleClient,
workspace_id: Optional[str],
cursor=None,
filter_keyword: Optional[str] = None,
) -> ResourceCollection[Project]:
results = []
# create search filters for user query and workspace query
project_user_filter = UserProjectsFilter(search="", workspaceId=workspace_id)
project_workspace_filter = WorksaceProjectsFilter(
search="", with_project_role_only=False
)
if speckle_client is not None:
# possible GraphQLException
results: ResourceCollection[Project] = speckle_client.active_user.get_projects(
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=(
UserProjectsFilter(search=filter_keyword) if filter_keyword else None
),
)
# for personal projects, use active_user query
if workspace_id is None:
if isinstance(filter_keyword, str):
project_user_filter.search = filter_keyword
# possible GraphQLException
results: ResourceCollection[Project] = (
speckle_client.active_user.get_projects(
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=project_user_filter,
)
)
# for workspace projects, use workspace query (active user.get_projects doesn't return projects created by others, even for admin role)
else:
if isinstance(filter_keyword, str):
project_workspace_filter.search = filter_keyword
# possible GraphQLException
results: ResourceCollection[Project] = (
speckle_client.workspace.get_projects(
workspace_id=workspace_id,
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=project_workspace_filter,
)
)
if not isinstance(results, ResourceCollection):
# TODO: handle
@@ -71,6 +105,20 @@ def get_projects_from_client(
# TODO add a warning
pass
results.items = [
item
for item in results.items
if (
(
item.role is None
and speckle_client.project.get_permissions(
item.id
).can_create_model.authorized
)
or (isinstance(item.role, str) and not item.role.endswith("viewer"))
) # "None" for "implicit" owner or viewer roles (if not explicitly invited)
]
return results
@@ -79,7 +127,7 @@ def get_models_from_client(
project: Project,
cursor=None,
filter_keyword: Optional[str] = None,
) -> ResourceCollection[Project]:
) -> ResourceCollection[Model]:
results = []
if speckle_client is not None:
@@ -130,12 +178,25 @@ def create_new_project_query(
result = None
if speckle_client is not None:
# possible GraphQLException
result: Project = speckle_client.project.create(
input=ProjectCreateInput(
name=project_name, description=None, visibility=None
if workspace_id:
# possible GraphQLException
result: Project = speckle_client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description=None,
visibility=None,
workspaceId=workspace_id,
)
)
else:
# possible GraphQLException
result: Project = speckle_client.project.create(
input=ProjectCreateInput(
name=project_name, description=None, visibility=None
)
)
)
if not isinstance(result, Project):
# TODO: handle
@@ -157,7 +218,7 @@ def create_new_model_query(
# possible GraphQLException
result: Project = speckle_client.model.create(
input=CreateModelInput(
name=model_name, description=None, projectId=project_id
name=model_name, description=None, project_id=project_id
)
)
+73 -11
View File
@@ -5,6 +5,7 @@ from speckle.sdk.connectors_common.operations import SendOperationResult
from speckle.ui.bindings import IBasicConnectorBinding, SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
from speckle.ui.widgets.widget_account_search import AccountSearchWidget
from speckle.ui.widgets.widget_add_account import AddAccountWidget
from speckle.ui.widgets.widget_model_card import ModelCardWidget
from speckle.ui.widgets.widget_model_cards_list import ModelCardsWidget
from speckle.ui.widgets.widget_model_search import ModelSearchWidget
@@ -55,6 +56,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
widget_project_search: ProjectSearchWidget = None
widget_model_search: ModelSearchWidget = None
widget_account_search: AccountSearchWidget = None
widget_account_add: AddAccountWidget = None
widget_new_project: NewProjectWidget = None
widget_new_model: NewModelWidget = None
widget_model_cards: ModelCardsWidget = None
@@ -80,7 +82,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.basic_binding = basic_binding
self.bridge = bridge
def runSetup(self, plugin):
def runSetup(self):
self.placeholder_widget = QWidget()
self.placeholder_widget.layout = QVBoxLayout(self.placeholder_widget)
self.placeholder_widget.layout.setContentsMargins(0, 0, 0, 0)
@@ -88,11 +90,10 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.placeholder_widget.setStyleSheet(f"{ZERO_MARGIN_PADDING}")
self.layout().addWidget(self.placeholder_widget)
# create and add header widget
self.header_widget = self._create_header(plugin)
# add header widget
self.placeholder_widget.layout.addWidget(self.header_widget)
# cerate and add main widget
# create and add main widget
self.main_widget = QWidget()
self.main_widget.layout = QStackedLayout(self.main_widget)
self.main_widget.layout.setStackingMode(QStackedLayout.StackAll)
@@ -101,9 +102,13 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.placeholder_widget.layout.addWidget(self.main_widget)
# add first widget to main
self._add_start_widget(plugin)
self._add_start_widget()
def _create_header(self, plugin):
def refresh_ui(self):
self._remove_all_widgets()
self._add_start_widget()
def create_header(self, plugin):
try:
header_widget = QWidget()
header_widget.setStyleSheet(f"{BACKGR_COLOR}{ZERO_MARGIN_PADDING}")
@@ -180,7 +185,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
except Exception as e:
print(e)
def _add_start_widget(self, plugin):
def _add_start_widget(self):
# document in QGIS is opened by default, we don't need as actually saved file to start working with data
document_open = True
@@ -218,6 +223,9 @@ class SpeckleQGISv3Dialog(QDockWidget):
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_account_add:
self._remove_widget_account_add()
if self.widget_new_project:
self._remove_widget_new_project()
@@ -225,7 +233,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
self._remove_widget_new_model()
if self.widget_selection_filter:
self.remove_widget_selection_filter()
self._remove_widget_selection_filter()
if self.widget_model_cards:
self._remove_widget_model_cards()
@@ -244,6 +252,9 @@ class SpeckleQGISv3Dialog(QDockWidget):
elif self.widget_account_search == widget:
self._remove_widget_account_search()
elif self.widget_account_add == widget:
self._remove_widget_account_add()
elif self.widget_new_project == widget:
self._remove_widget_new_project()
@@ -251,7 +262,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
self._remove_widget_new_model()
elif self.widget_selection_filter == widget:
self.remove_widget_selection_filter()
self._remove_widget_selection_filter()
def _remove_process_widgets(self):
if self.widget_project_search:
@@ -261,11 +272,14 @@ class SpeckleQGISv3Dialog(QDockWidget):
self._remove_widget_model_search()
if self.widget_selection_filter:
self.remove_widget_selection_filter()
self._remove_widget_selection_filter()
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_account_add:
self._remove_widget_account_add()
if self.widget_new_project:
self._remove_widget_new_project()
@@ -284,6 +298,10 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.widget_account_search.setParent(None)
self.widget_account_search = None
def _remove_widget_account_add(self):
self.widget_account_add.setParent(None)
self.widget_account_add = None
def _remove_widget_new_project(self):
self.widget_new_project.setParent(None)
self.widget_new_project = None
@@ -292,7 +310,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.widget_new_model.setParent(None)
self.widget_new_model = None
def remove_widget_selection_filter(self):
def _remove_widget_selection_filter(self):
self.widget_selection_filter.setParent(None)
self.widget_selection_filter = None
@@ -375,6 +393,10 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.main_widget.layout.addWidget(self.widget_project_search)
self.main_widget.layout.setCurrentWidget(self.widget_project_search)
# if no accounts are present, open Select Account widget
if self.widget_project_search.ui_search_content.speckle_client is None:
self._open_select_accounts_widget()
self.widget_project_search.ui_search_content.add_selection_filter_signal.connect(
self._create_selection_filter_widget
)
@@ -402,6 +424,16 @@ class SpeckleQGISv3Dialog(QDockWidget):
self._update_project_list
)
def _update_account_list(self):
# close AddAccount widget
# can be called from AddAccount widget
if self.widget_account_add:
self._remove_widget_account_add()
# refresh accounts in the AccountSearch widget
self.widget_account_search.refresh_accounts()
def _update_project_list(self):
# can be called from CreateAccount or NewProject widgets
@@ -410,6 +442,13 @@ class SpeckleQGISv3Dialog(QDockWidget):
if self.widget_new_project:
self._remove_widget_new_project()
# get list of workspaces
self.widget_project_search.workspaces = (
self.widget_project_search.ui_search_content.get_workspaces()
)
self.widget_project_search._fill_workspace_dropdown()
# refresh projects for the selected workspace
self.widget_project_search.refresh_projects()
def _update_model_list(self):
@@ -474,6 +513,29 @@ class SpeckleQGISv3Dialog(QDockWidget):
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_account_search)
# subscribe to select_account_signal signal
self.widget_account_search.ui_search_content.open_add_new_account_widget_signal.connect(
self._open_add_account_widget
)
# subscribe to add_new_account_signal signal
self.widget_account_search.ui_search_content.add_new_account_signal.connect(
self._update_account_list
)
def _open_add_account_widget(self):
if not self.widget_account_add:
self.widget_account_add = AddAccountWidget(
parent=self,
ui_search_content=self.widget_project_search.ui_search_content,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_account_add)
self.main_widget.layout.setCurrentWidget(self.widget_account_add)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_account_add)
def _open_select_models_widget(self, project):
if not self.widget_model_search:
+36 -7
View File
@@ -3,6 +3,13 @@ from speckle.ui.widgets.widget_cards_list_temporary import (
CardsListTemporaryWidget,
)
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
)
from PyQt5.QtWidgets import QPushButton, QSizePolicy
class AccountSearchWidget(CardsListTemporaryWidget):
@@ -13,25 +20,47 @@ class AccountSearchWidget(CardsListTemporaryWidget):
*,
parent=None,
label_text: str = "Select account",
ui_search_content: UiSearchUtils = None
ui_search_content: UiSearchUtils = None,
):
self.parent = parent
self.ui_search_content = ui_search_content
# customize load_more function
self._load_more = lambda: self._add_accounts(clear_cursor=False)
# initialize the inherited widget, passing the card content
super(AccountSearchWidget, self).__init__(
parent=parent, label_text=label_text, cards_content_list=[]
parent=parent,
label_text=label_text,
cards_content_list=[],
init_load_more_btn=False,
)
self.refresh_accounts()
self._add_accounts(clear_cursor=True)
button_create = self._create_add_button()
self.scroll_container.layout().addWidget(button_create)
def _add_accounts(self, clear_cursor=False):
def _create_add_button(self) -> QPushButton:
button_create = QPushButton("Add new account")
button_create.clicked.connect(
self.ui_search_content.open_add_new_account_widget_signal.emit
)
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
return button_create
def refresh_accounts(self, clear_cursor=False):
all_accounts = self.ui_search_content.get_accounts_content()
if len(all_accounts) == 0:
self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
else:
self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._remove_all_cards()
self._add_more_cards(
all_accounts, clear_cursor, self.ui_search_content.batch_size
)
+207
View File
@@ -0,0 +1,207 @@
from speckle.ui.utils.search_widget_utils import UiSearchUtils
from speckle.ui.widgets.background_widget import BackgroundWidget
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
BACKGR_COLOR_WHITE,
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
)
import webbrowser
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QPushButton,
QLabel,
QLineEdit,
QGraphicsDropShadowEffect,
)
class AddAccountWidget(QWidget):
ui_search_content: UiSearchUtils = None
_message_card: QWidget = (
None # needs to be here, so it can be called on resize event
)
server_url_widget: QLineEdit = None
def __init__(
self,
*,
parent=None,
label_text: str = "Add new account",
ui_search_content: UiSearchUtils = None,
):
super(AddAccountWidget, self).__init__(parent)
self.parent = parent
self.ui_search_content = ui_search_content
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
)
self._add_background()
self.layout = QStackedLayout()
self.layout.addWidget(self.background)
self._fill_message_card(label_text)
content = QWidget()
content.layout = QVBoxLayout(self)
content.layout.setContentsMargins(0, 0, 0, 0)
content.layout.setAlignment(Qt.AlignCenter)
content.layout.addWidget(self._message_card)
self.layout.addWidget(content)
def _create_widget_label(self, label_text: str, props: str = ""):
label = QLabel(label_text)
# for some reason, "margin-left" doesn't make any effect here
label.setStyleSheet(
"QLabel {"
+ f"{ZERO_MARGIN_PADDING}padding-left:{int(WIDGET_SIDE_BUFFER/2)};"
+ f"padding-top:{int(WIDGET_SIDE_BUFFER/4)}; margin-bottom:{int(WIDGET_SIDE_BUFFER/4)};"
+ f"text-align:left;{props}"
+ "}"
)
return label
def _create_text_widget(self, label_text: str, props: str = ""):
label = QLabel(label_text)
# for some reason, "margin-left" doesn't make any effect here
label.setStyleSheet(
"QLabel {"
+ f"{ZERO_MARGIN_PADDING}padding-left:5px;padding-right:5px;padding-bottom:5px;"
+ f"text-align:left;{props}"
+ "}"
)
return label
def _add_background(self):
self.background = BackgroundWidget(parent=self, transparent=False)
self.background.show()
def _add_drop_shadow(self, item=None):
if not item:
item = self
# create drop shadow effect
self._shadow_effect = QGraphicsDropShadowEffect()
self._shadow_effect.setOffset(2, 2)
self._shadow_effect.setBlurRadius(8)
self._shadow_effect.setColor(QColor.fromRgb(100, 100, 100, 150))
item.setGraphicsEffect(self._shadow_effect)
def _fill_message_card(self, label_text: str):
self._message_card = QWidget()
self._message_card.setAttribute(Qt.WA_StyledBackground, True)
self._message_card.setStyleSheet(
"QWidget {" + "border-radius: 10px;" + f"{BACKGR_COLOR_WHITE}" + "}"
)
boxLayout = QVBoxLayout(self._message_card)
label_main = self._create_widget_label(label_text)
boxLayout.addWidget(label_main)
# add text 1
label = self._create_text_widget("Server URL:")
boxLayout.addWidget(label)
# add text input 1
self.server_url_widget = QLineEdit("https://app.speckle.systems")
self.server_url_widget.setMaxLength(40)
self.server_url_widget.setStyleSheet(
"QLineEdit { "
+ f"{ZERO_MARGIN_PADDING}margin-left:{int(WIDGET_SIDE_BUFFER/6)};margin-right:{int(WIDGET_SIDE_BUFFER/6)};"
+ "border: 1px solid lightgrey; height: 30px; border-radius: 5px; "
+ "}"
)
boxLayout.addWidget(self.server_url_widget)
button_create = self._create_add_button()
boxLayout.addWidget(button_create)
self._add_drop_shadow(self._message_card)
def _create_add_button(self) -> QPushButton:
button_create = QPushButton("Create")
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
button_create.clicked.connect(self._add_account)
button_create.clicked.connect(lambda: self._create_ready_button(button_create))
return button_create
def _create_ready_button(self, button):
try:
button.clicked.disconnect(self._add_account)
button.clicked.disconnect(self._create_ready_button)
except:
pass # ignore if methods already disconnected
button.setText("READY!")
button.clicked.connect(self._exit_widget)
button.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
def _exit_widget(self):
# the next signal will trigger closing the widget and refreshing project list
self.ui_search_content.add_new_account_signal.emit()
def _add_account(self):
# create a new account, authenticate and write to DB
server_url: str = self.server_url_widget.text()
# Logic to handle sign in
api_url = "http://localhost:29364"
url = f"{api_url}/auth/add-account?serverUrl={server_url}"
webbrowser.open(url)
def resizeEvent(self, event=None):
QWidget.resizeEvent(self, event)
try:
self.background.resize(
self.parent.frameSize().width(),
self.parent.frameSize().height(),
)
self._message_card.setGeometry(
int(0.5 * WIDGET_SIDE_BUFFER),
int(
(self.parent.frameSize().height() - self._message_card.height()) / 2
),
self.parent.frameSize().width() - 1 * WIDGET_SIDE_BUFFER,
self._message_card.height(),
)
except RuntimeError as e:
# e.g. Widget was deleted
pass
@@ -17,9 +17,9 @@ from speckle.ui.widgets.widget_card_from_list import CardInListWidget
class CardsListTemporaryWidget(QWidget):
background: BackgroundWidget = None
scroll_area: QtWidgets.QScrollArea = None
cards_list_widget: QWidget = None # needed here to resize child elements
load_more_btn: QPushButton = None
scroll_area: QtWidgets.QScrollArea = None
scroll_container: QWidget = None # overall container, added after the label
@@ -29,9 +29,11 @@ class CardsListTemporaryWidget(QWidget):
parent=None,
label_text: str = "Label",
cards_content_list: List[List],
init_load_more_btn: bool = True,
):
super(CardsListTemporaryWidget, self).__init__(parent)
self.parent: "SpeckleQGISv3Dialog" = parent
self.init_load_more_btn = init_load_more_btn
# align with the parent widget size
self.resize(
@@ -82,7 +84,7 @@ class CardsListTemporaryWidget(QWidget):
return scroll_container
def _create_container(self):
def _create_container(self) -> QWidget:
scroll_container = QWidget()
scroll_container.setAttribute(QtCore.Qt.WA_StyledBackground, True)
@@ -114,8 +116,8 @@ class CardsListTemporaryWidget(QWidget):
self.scroll_area.setAlignment(Qt.AlignHCenter)
# create a widget inside scroll area
cards_list_widget = self._create_area_with_cards(cards_content_list)
self.scroll_area.setWidget(cards_list_widget)
self.cards_list_widget = self._create_area_with_cards(cards_content_list)
self.scroll_area.setWidget(self.cards_list_widget)
return self.scroll_area
@@ -155,22 +157,21 @@ class CardsListTemporaryWidget(QWidget):
def _create_area_with_cards(self, cards_content_list: List[List]) -> QWidget:
self.cards_list_widget = QWidget()
self.cards_list_widget.setStyleSheet(
"QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}"
)
_ = QVBoxLayout(self.cards_list_widget)
cards_list_widget = QWidget()
cards_list_widget.setStyleSheet("QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}")
_ = QVBoxLayout(cards_list_widget)
# in case the input argument was missing or None, don't create any cards
if isinstance(cards_content_list, list):
for content in cards_content_list:
project_card = CardInListWidget(content)
self.cards_list_widget.layout().addWidget(project_card)
cards_list_widget.layout().addWidget(project_card)
self._create_load_more_btn()
self.cards_list_widget.layout().addWidget(self.load_more_btn)
if self.init_load_more_btn:
self._create_load_more_btn()
cards_list_widget.layout().addWidget(self.load_more_btn)
return self.cards_list_widget
return cards_list_widget
def _add_more_cards(
self, new_cards_content_list: list, keep_scroll_on_top=False, batch_size=1
@@ -188,6 +189,7 @@ class CardsListTemporaryWidget(QWidget):
assigned_cards_list_widget = self._create_area_with_cards(existing_content)
self.scroll_area.setWidget(assigned_cards_list_widget)
self.cards_list_widget = assigned_cards_list_widget
# scroll down
if not keep_scroll_on_top:
@@ -195,9 +197,8 @@ class CardsListTemporaryWidget(QWidget):
vbar.setValue(vbar.maximum())
# style LoadMore buttom
if len(new_cards_content_list) < batch_size:
if self.load_more_btn and len(new_cards_content_list) < batch_size:
self._style_load_btn(active=False, text="No more items found")
return
def _remove_all_cards(self):
all_count = self.cards_list_widget.layout().count()
+12 -22
View File
@@ -28,7 +28,6 @@ class NewProjectWidget(QWidget):
None # needs to be here, so it can be called on resize event
)
project_name_widget: QLineEdit = None
workspace_widget: QLineEdit = None
def __init__(
self,
@@ -131,22 +130,6 @@ class NewProjectWidget(QWidget):
)
boxLayout.addWidget(self.project_name_widget)
# add text 2
label2 = self._create_text_widget("Workspaces:")
label2.setEnabled(False)
boxLayout.addWidget(label2)
# add text input 2
self.workspace_widget = QLineEdit()
self.workspace_widget.setStyleSheet(
"QLineEdit { "
+ f"{ZERO_MARGIN_PADDING}margin-left:{int(WIDGET_SIDE_BUFFER/6)};margin-right:{int(WIDGET_SIDE_BUFFER/6)};"
+ "border: 1px solid lightgrey; height: 30px; border-radius: 5px; "
+ "}"
)
self.workspace_widget.setEnabled(False)
boxLayout.addWidget(self.workspace_widget)
button_create = self._create_create_button()
boxLayout.addWidget(button_create)
@@ -154,20 +137,27 @@ class NewProjectWidget(QWidget):
def _create_create_button(self) -> QPushButton:
button_publish = QPushButton("Create")
button_publish.clicked.connect(self._create_project_and_exit_widget)
button_publish.setStyleSheet(
button_create = QPushButton("Create")
button_create.clicked.connect(self._create_project_and_exit_widget)
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
return button_publish
return button_create
def _create_project_and_exit_widget(self):
self.ui_search_content.create_new_project(self.project_name_widget.text(), None)
workspace_id = (
self.ui_search_content.current_workspace.id
if self.ui_search_content.current_workspace
else None
)
self.ui_search_content.create_new_project(
self.project_name_widget.text(), workspace_id
)
# the next signal will trigger closing the widget and refreshing project list
self.ui_search_content.change_account_and_projects_signal.emit()
+60 -4
View File
@@ -1,4 +1,4 @@
from typing import Optional
from typing import List, Optional
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
@@ -18,6 +18,8 @@ from PyQt5.QtWidgets import (
QWidget,
QLineEdit,
QPushButton,
QComboBox,
QSizePolicy,
)
@@ -26,6 +28,8 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
ui_search_content: UiSearchUtils = None
account_switch_btn: QPushButton = None
search_widget: QLineEdit = None
workspaces_dropdown: QComboBox = None
workspaces: List["Workspace"] = None
def __init__(
self,
@@ -39,6 +43,7 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
# get content for project cards
self.ui_search_content = UiSearchUtils()
self.workspaces = self.ui_search_content.get_workspaces()
# customize load_more function
self._load_more = lambda: self._add_projects(clear_cursor=False)
@@ -49,11 +54,23 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
label_text=label_text,
cards_content_list=[],
)
self._add_search_and_account_switch_line()
self._add_project_search_and_project_add_line()
self._add_workspace_search_and_account_switch_line()
self._add_projects(clear_cursor=True)
def _add_projects(self, clear_cursor=False, name_filter: Optional[str] = None):
if self.ui_search_content.speckle_client is None:
return
workspace_id = None # default to "Personal Projects"
index = self.workspaces_dropdown.currentIndex()
if index < len(self.workspaces):
workspace_id = self.workspaces[index].id
self.ui_search_content.current_workspace = self.workspaces[index]
else: # personal projects
self.ui_search_content.current_workspace = None
if name_filter is None:
# just get the projects in batches
new_project_cards: list = self.ui_search_content.get_new_projects_content(
@@ -75,15 +92,20 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
self.resizeEvent()
def refresh_projects(self, name_filter: Optional[str] = None):
if self.account_switch_btn:
self.account_switch_btn.setText(
self.ui_search_content.get_account_initials()
)
self._remove_all_cards()
self._add_projects(clear_cursor=True, name_filter=name_filter)
def clear_search_bar(self):
self.search_widget.setText("")
def _add_search_and_account_switch_line(self):
def _add_project_search_and_project_add_line(self):
# create a line widget
# create an empty widget
line = QWidget()
line.setStyleSheet(
"QWidget {"
@@ -104,12 +126,46 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
new_project_btn = self._create_new_project_btn()
layout_line.addWidget(new_project_btn)
self.scroll_container.layout().insertWidget(1, line)
def _add_workspace_search_and_account_switch_line(self):
# create an empty widget
line = QWidget()
line.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ f"margin-left:{int(WIDGET_SIDE_BUFFER/4)};margin-right:{int(WIDGET_SIDE_BUFFER/4)};text-align: left;"
+ "}"
)
layout_line = QHBoxLayout(line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 0, 0, 0)
# workspaces selection dropdown
self.workspaces_dropdown = QComboBox()
self.workspaces_dropdown.currentIndexChanged.connect(self.refresh_projects)
self._fill_workspace_dropdown()
layout_line.addWidget(self.workspaces_dropdown)
# Account switch buttom
self.account_switch_btn = self._create_account_switch_btn()
layout_line.addWidget(self.account_switch_btn)
self.scroll_container.layout().insertWidget(1, line)
def _fill_workspace_dropdown(self):
self.workspaces_dropdown.clear()
self.workspaces_dropdown.addItems([x.name for x in self.workspaces])
self.workspaces_dropdown.addItem("Personal Projects")
self.workspaces_dropdown.setStyleSheet(
"""QComboBox { background-color: white; border: 1px solid lightgrey; border-radius: 5px; color: black; height: 30px; padding: 0px 0px 0px 10px; }"""
)
self.workspaces_dropdown.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def _create_search_widget(self):
text_box = QLineEdit()
text_box.setMaxLength(20)
+3
View File
@@ -2,6 +2,7 @@
import os.path
from typing import Optional
from speckle.host_apps.qgis.connectors.utils import setup_metrics
from speckle.host_apps.qgis.qgis_module import SpeckleQGISv3Module
# Initialize Qt resources from file resources.py
@@ -30,6 +31,8 @@ class SpeckleQGIS(SpeckleQGISv3Module):
application at run time.
:type iface: QgsInterface
"""
setup_metrics()
super(SpeckleQGIS, self).__init__(iface)
# initialize plugin directory