44 Commits

Author SHA1 Message Date
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
KatKatKateryna d4030296de add poetry (#24) 2025-02-19 20:25:07 +08:00
KatKatKateryna dc6147005c Update operations.py (#23) 2025-02-19 00:31:44 +08:00
KatKatKateryna 4ad4650379 add send metrics (#21)
* add send metrics

* rename stuff

* align app versioning
2025-02-18 20:23:12 +08:00
KatKatKateryna 24556105f6 Installing libraries, aligning menu bar and general cleanup (#20)
* don't try to send layers with unsupported data providers

* innocent cleanup

* remove pip and old installer

* risky cleanup

* import library after plugin load

* proper label with clickable btns

* experimental: to uncover title bar

* Fix layout with unblocked header

* all layouts fixed

* removing extras

* update specklepy

* menu logo fixed

* fixed icon and metadata
2025-02-18 07:16:07 +08:00
KatKatKateryna 5ddaaced6f UI search bar (#19)
* query to create new model

* refresh model list

* send data immediately

* typo

* clear search bar if new project/model is created
2025-02-10 17:55:38 +08:00
KatKatKateryna 7954b7fabf model creation placeholder 2025-02-07 14:01:00 +00:00
KatKatKateryna d40ea1d0ed fully working project add 2025-02-07 12:59:28 +00:00
KatKatKateryna cc3f63dc2b add project button and query 2025-02-07 12:27:23 +00:00
KatKatKateryna f7bbd3b165 models search bar 2025-02-07 11:19:19 +00:00
KatKatKateryna 2add780d6f project search 2025-02-07 01:09:54 +00:00
KatKatKateryna 35d9ee6e39 Cancellation (#18)
* separate card notification into a separate widget

* customize card notification

* update card status from the beginning of send operation

* update status

* overlay Cancel btn on top of notification window

* added Cancel trigger, need to ensure to remove all tasks

* pass model card id to the task description

* cancellation manager

* cancellation request triggered

* actually cancel Task

* cancel operation even after send

* clean task start and cancel

* cleaning

* clean Task exit
2025-02-07 03:06:06 +08:00
KatKatKateryna ef23ef698e rename background widget 2025-01-29 22:40:10 +00:00
KatKatKateryna e806c896e3 Creating ThreadContext for Send operation (#17)
* inject thread context

* fix functions

* successful sync to main thread

* create thread context with QgsTask; move Build and Send operation to the task

* warning placeholder
2025-01-30 06:27:55 +08:00
KatKatKateryna 0fff76e492 Send correct class name (#16)
* delete print

* add data object

* remove Closed attribute

* fix name
2025-01-28 02:46:14 +08:00
KatKatKateryna cae1f3045b Send QgisDataObject class (#15)
* delete print

* add data object

* remove Closed attribute
2025-01-24 09:51:58 +08:00
KatKatKateryna 72b834a709 Color unpacker (#14)
* supported renderers

* accurate colors except categorized

* categories send
2025-01-23 10:05:59 +08:00
KatKatKateryna 24d6deadf8 Switch accounts (#13)
* add btn with name

* dialog with accounts

* account change

* remove print
2025-01-21 09:28:08 +08:00
KatKatKateryna 52cd86ee0b Send notification bar (#12)
* add persistent send notification

* placeholder for signals to add

* show and hide notification

* signal sent for notification

* card shows up correctly

* line shows up correctly
2025-01-21 07:27:04 +08:00
KatKatKateryna afb3c546dd Geometry converters (#11)
* point converter wip

* transform before sending

* proper transforms before passing to displayValExtractor

* convert polylines

* add multicurves

* send polygons

* explicit geometry type

* sending rasters

* color fix
2025-01-16 08:26:07 +08:00
KatKatKateryna 7ddba1d76e geometry converters placeholders (#10) 2025-01-14 09:26:29 +08:00
KatKatKateryna 219fb7d20d post-merge mess 2025-01-14 00:58:33 +00:00
KatKatKateryna 1d38189ee1 Properties extractor (#9)
* properties extractor
2025-01-14 08:54:00 +08:00
KatKatKateryna 6fa40cc161 Handle nested layers (#8)
* layer utils moved out of UI

* rename module

* layer selection moved to one place

* pass group layers to Selection Filter

* remove duplicate layers

* sending groups

* unpack layers

* wrap layers correctly

* don't use full project path

* send layers with structure
2025-01-14 08:50:48 +08:00
KatKatKateryna 7dd57ef29a Top converter (#7)
* placeholders

* reorg structure

* top level converter placeholder

* typo & placeholders for display & properties extractor

* fix load button

* minor things

* simplify the card creation functions

* clear cursor on request

* clear project cursor too

* call add_cards always from one place

* lock scroll

* style Load button automatically

* don't call model search twice

* correct label handling when deleting cards

* top level builder properly injected

* display and properties value extractor
2025-01-10 08:08:39 +08:00
KatKatKateryna dafb48275d Restructure UI signals (#6)
* remove widget on background click through signals

* remove model cards widget through signal

* create model search directly from dockwidget

* remove redundancies

* remove all references to parent widget methods

* mark internal functions for ProjectsSearchWidget

* internalize functions

* internalized functions in dockwidget
2025-01-09 06:35:10 +08:00
KatKatKateryna 1cca4acd2b SelectionFilter widget called from the mode card (#5)
* activate Selection from the model card

* clean send
2025-01-08 08:15:04 +08:00
KatKatKateryna dc5c539adc Selection filter with subscription to Selection change (#4)
* initialize send operation from the right place

* comment

* selection filter works on send

* selection connected to selection widget

* trigger on layer selection

* reflect selection info on the card

* reduce redundant functions, minimize send operation and injections
2025-01-08 06:53:44 +08:00
KatKatKateryna 0af5b0895e rearrange modules and prepare RootObjectBuilder (#3)
* remove legacy stuff

* don't close model cards widget by accident

* decoupled modules

* refactor to separate modules

* move conversion settings

* inject conversion settings right before Send

* add layer utils
2025-01-07 11:26:36 +08:00
KatKatKateryna f0075185fb Send operation (#2)
* server helpers

* better signals handling

* send "Send" signal all the way to the plugin (only works for the 1st button)

* fix the signals

* sending mock object (from first card only)

* send signals from all cards

* open in web

* all working except resize
2024-12-25 08:57:58 +08:00
KatKatKateryna b958f5b446 Send workflow layout & bindings (#1)
* working base with merged ui repo

* include project search

* widgets

* working cards

* remove .pyc files

* again

* add gitignore

* nice hovers

* interactive background working

* beautiful search

* working widgets

* optimized

* project query

* all working

* models show up but only from the last stream

* fixed models

* cleaner

* model search moved

* add models

* UI models and bindings

* rearrangement

* moved folder

* cleanup

* rearrange

* rename1

* rename2

* cleanup

* rename1

* rename2

* hide old ui

* bindings added

* rename

* still works

* works

* works except resize

* project scroll working

* scroll down

* load button fixed

* emit sender card to dockwidget

* models cards added

* model cards recognized

* resize

* publish works

* cards created in the right order

* fix order

* styling

* perfect model card

* visuals

* remove widget btn

* publish button to the bottom

* add and remove model cards faster

* remove old code

* stretch cards
2024-12-24 20:11:00 +08:00
92 changed files with 10784 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"gitversion.tool": {
"version": "6.1.0",
"commands": [
"dotnet-gitversion"
],
"rollForward": false
}
}
}
+97
View File
@@ -0,0 +1,97 @@
name: build_qgis
on:
push:
branches: ["main", "dev", "release/*", "alan/*"] # Continuous delivery on every long-lived branch
tags: ["v3.*"] # Manual delivery on every v3.x tag
env:
ZipName: "qgis.zip"
jobs:
build-connector:
runs-on: ubuntu-latest
outputs:
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # fetch the whole repo history
- name: ⚒️ Run GitVersion
run: ./build.sh build-server-version
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11" # pb_tool depends on distutils which was deprecated in 3.10 and removed in 3.12
- name: Setup Poetry Env
run: |
pip install poetry
poetry self add poetry-plugin-export
poetry install
- name: Export requirements.txt
run: |
poetry export --without-hashes --without-urls --output plugin_utils/requirements.txt
- name: Clean requirements.txt
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
- name: ZIP plugin
run: |
pb_tool zip
mv "zip_build/speckle-qgis-v3.zip" ${{env.ZipName}}
- id: set-version
name: Set version to 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?
- name: ⬆️ Upload artifacts
uses: actions/upload-artifact@v4
with:
name: output-${{ env.GitVersion_FullSemVer }}
path: ${{env.ZipName}}
if-no-files-found: error
retention-days: 1
compression-level: 0 # no compression
deploy-installers:
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'}}
steps:
- name: 🔫 Trigger Build QGIS
uses: ALEEF02/workflow-dispatch@v3.0.0
with:
workflow: Build QGIS
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 }} }'
ref: main
wait-for-completion: true
wait-for-completion-interval: 10s
wait-for-completion-timeout: 10m
display-workflow-run-url: true
display-workflow-run-url-interval: 10s
- uses: geekyeggo/delete-artifact@v5
with:
name: output-*
+155
View File
@@ -0,0 +1,155 @@
./idea/
/speckle_qgis_dialog_base.ui.autosave
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Jetbrains stuff:
.idea
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# other
scratch.py
settings.json
**/.DS_Store
zip_build
.qt_for_python
speckle-sharp-ci-tools*
zip-build*
*.dbf
*.shp
*.shx
# C#
**/bin/*
**/obj/*
_ReSharper.SharpCompress/
bin/
*.suo
*.user
TestArchives/Scratch/
TestArchives/Scratch2/
TestResults/
*.nupkg
packages/*/
project.lock.json
tests/TestArchives/Scratch
.vs
tools
.vscode
.idea/
.DS_Store
*.snupkg
coverage.xml
*.received.*
*.log
/typings/*
*.csv
*/PyQt-UI*
plugin_utils/requirements.txt
specklepy
+6
View File
@@ -0,0 +1,6 @@
workflow: GitFlow/v1
next-version: 3.0.0
branches:
release:
prevent-increment:
when-current-commit-tagged: true
+226
View File
@@ -0,0 +1,226 @@
#################################################
# Edit the following to match your sources lists
#################################################
#Add iso code for any locales you want to support here (space separated)
# default is no locales
# LOCALES = af
LOCALES =
# If locales are enabled, set the name of the lrelease binary on your system. If
# you have trouble compiling the translations, you may have to specify the full path to
# lrelease
#LRELEASE = lrelease
#LRELEASE = lrelease-qt4
# translation
SOURCES = \
__init__.py \
speckle_qgis_v3.py
PLUGINNAME = speckle-qgis-v3
PY_FILES = \
__init__.py \
speckle_qgis_v3.py
UI_FILES = speckle_qgis_dialog_base.ui
EXTRAS = metadata.txt icon.png
EXTRA_DIRS =
COMPILED_RESOURCE_FILES = resources.py
PEP8EXCLUDE=pydev,resources.py,conf.py,third_party,ui
# QGISDIR points to the location where your plugin should be installed.
# This varies by platform, relative to your HOME directory:
# * Linux:
# .local/share/QGIS/QGIS3/profiles/default/python/plugins/
# * Mac OS X:
# Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins
# * Windows:
# AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins'
QGISDIR=/Users/username/Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins
#################################################
# Normally you would not need to edit below here
#################################################
HELP = help/build/html
PLUGIN_UPLOAD = $(c)/plugin_upload.py
RESOURCE_SRC=$(shell grep '^ *<file' resources.qrc | sed 's@</file>@@g;s/.*>//g' | tr '\n' ' ')
.PHONY: default
default:
@echo While you can use make to build and deploy your plugin, pb_tool
@echo is a much better solution.
@echo A Python script, pb_tool provides platform independent management of
@echo your plugins and runs anywhere.
@echo You can install pb_tool using: pip install pb_tool
@echo See https://g-sherman.github.io/plugin_build_tool/ for info.
compile: $(COMPILED_RESOURCE_FILES)
%.py : %.qrc $(RESOURCES_SRC)
pyrcc5 -o $*.py $<
%.qm : %.ts
$(LRELEASE) $<
test: compile transcompile
@echo
@echo "----------------------"
@echo "Regression Test Suite"
@echo "----------------------"
@# Preceding dash means that make will continue in case of errors
@-export PYTHONPATH=`pwd`:$(PYTHONPATH); \
export QGIS_DEBUG=0; \
export QGIS_LOG_FILE=/dev/null; \
nosetests -v --with-id --with-coverage --cover-package=. \
3>&1 1>&2 2>&3 3>&- || true
@echo "----------------------"
@echo "If you get a 'no module named qgis.core error, try sourcing"
@echo "the helper script we have provided first then run make test."
@echo "e.g. source run-env-linux.sh <path to qgis install>; make test"
@echo "----------------------"
deploy: compile doc transcompile
@echo
@echo "------------------------------------------"
@echo "Deploying plugin to your .qgis2 directory."
@echo "------------------------------------------"
# The deploy target only works on unix like operating system where
# the Python plugin directory is located at:
# $HOME/$(QGISDIR)/python/plugins
mkdir -p $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vf $(PY_FILES) $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vf $(UI_FILES) $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vf $(COMPILED_RESOURCE_FILES) $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vf $(EXTRAS) $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vfr i18n $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
cp -vfr $(HELP) $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)/help
# Copy extra directories if any
(foreach EXTRA_DIR,(EXTRA_DIRS), cp -R (EXTRA_DIR) (HOME)/(QGISDIR)/python/plugins/(PLUGINNAME)/;)
# The dclean target removes compiled python files from plugin directory
# also deletes any .git entry
dclean:
@echo
@echo "-----------------------------------"
@echo "Removing any compiled python files."
@echo "-----------------------------------"
find $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME) -iname "*.pyc" -delete
find $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME) -iname "*.gif" -delete
find $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME) -iname ".git" -prune -exec rm -Rf {} \;
derase:
@echo
@echo "-------------------------"
@echo "Removing deployed plugin."
@echo "-------------------------"
rm -Rf $(HOME)/$(QGISDIR)/python/plugins/$(PLUGINNAME)
zip: deploy dclean
@echo
@echo "---------------------------"
@echo "Creating plugin zip bundle."
@echo "---------------------------"
# The zip target deploys the plugin and creates a zip file with the deployed
# content. You can then upload the zip file on http://plugins.qgis.org
rm -f $(PLUGINNAME).zip
cd $(HOME)/$(QGISDIR)/python/plugins; zip -9r $(CURDIR)/$(PLUGINNAME).zip $(PLUGINNAME)
package: compile
# Create a zip package of the plugin named $(PLUGINNAME).zip.
# This requires use of git (your plugin development directory must be a
# git repository).
# To use, pass a valid commit or tag as follows:
# make package VERSION=Version_0.3.2
@echo
@echo "------------------------------------"
@echo "Exporting plugin to zip package. "
@echo "------------------------------------"
rm -f $(PLUGINNAME).zip
git archive --prefix=$(PLUGINNAME)/ -o $(PLUGINNAME).zip $(VERSION)
echo "Created package: $(PLUGINNAME).zip"
upload: zip
@echo
@echo "-------------------------------------"
@echo "Uploading plugin to QGIS Plugin repo."
@echo "-------------------------------------"
$(PLUGIN_UPLOAD) $(PLUGINNAME).zip
transup:
@echo
@echo "------------------------------------------------"
@echo "Updating translation files with any new strings."
@echo "------------------------------------------------"
@chmod +x scripts/update-strings.sh
@scripts/update-strings.sh $(LOCALES)
transcompile:
@echo
@echo "----------------------------------------"
@echo "Compiled translation files to .qm files."
@echo "----------------------------------------"
@chmod +x scripts/compile-strings.sh
@scripts/compile-strings.sh $(LRELEASE) $(LOCALES)
transclean:
@echo
@echo "------------------------------------"
@echo "Removing compiled translation files."
@echo "------------------------------------"
rm -f i18n/*.qm
clean:
@echo
@echo "------------------------------------"
@echo "Removing uic and rcc generated files"
@echo "------------------------------------"
rm $(COMPILED_UI_FILES) $(COMPILED_RESOURCE_FILES)
doc:
@echo
@echo "------------------------------------"
@echo "Building documentation using sphinx."
@echo "------------------------------------"
cd help; make html
pylint:
@echo
@echo "-----------------"
@echo "Pylint violations"
@echo "-----------------"
@pylint --reports=n --rcfile=pylintrc . || true
@echo
@echo "----------------------"
@echo "If you get a 'no module named qgis.core' error, try sourcing"
@echo "the helper script we have provided first then run make pylint."
@echo "e.g. source run-env-linux.sh <path to qgis install>; make pylint"
@echo "----------------------"
# Run pep8 style checking
#http://pypi.python.org/pypi/pep8
pep8:
@echo
@echo "-----------"
@echo "PEP8 issues"
@echo "-----------"
@pep8 --repeat --ignore=E203,E121,E122,E123,E124,E125,E126,E127,E128 --exclude $(PEP8EXCLUDE) . || true
@echo "-----------"
@echo "Ignored in PEP8 check:"
@echo $(PEP8EXCLUDE)
+35
View File
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
import os
import sys
path = os.path.dirname(os.path.abspath(__file__))
if path not in sys.path:
sys.path.insert(0, path)
try:
from plugin_utils.installer import ensure_dependencies, startDebugger
from plugin_utils.panel_logging import logger
# noinspection PyPep8Naming
def classFactory(iface): # pylint: disable=invalid-name
"""Load SpeckleQGIS class from file SpeckleQGIS.
:param iface: A QGIS interface instance.
:type iface: QgsInterface
"""
# Set qgisInterface to enable log_to_user notifications
logger.qgisInterface = iface
iface.pluginToolBar().setVisible(True)
# Ensure dependencies are installed in the machine
startDebugger()
ensure_dependencies("QGISv3")
from speckle_qgis_v3 import SpeckleQGIS
return SpeckleQGIS(iface)
except ModuleNotFoundError:
pass
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop";
dotnet run --project ci-build/build.csproj -- $args
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
dotnet run --project ci-build/build.csproj -- "$@"
+25
View File
@@ -0,0 +1,25 @@
using static Bullseye.Targets;
using static SimpleExec.Command;
const string RESTORE_TOOLS = "restore-tools";
const string BUILD_SERVER_VERSION = "build-server-version";
Target(
RESTORE_TOOLS,
() =>
{
Run("dotnet", "tool restore");
}
);
Target(
BUILD_SERVER_VERSION,
DependsOn(RESTORE_TOOLS),
() =>
{
Run("dotnet", "tool run dotnet-gitversion /output json /output buildserver");
}
);
await RunTargetsAndExitAsync(args).ConfigureAwait(true);
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bullseye" Version="5.0.0" />
<PackageReference Include="Glob" Version="1.1.9"/>
<PackageReference Include="Microsoft.Build" Version="17.10.4"/>
<PackageReference Include="SimpleExec" Version="12.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.github\workflows\release.yml">
<Link>workflows\release.yml</Link>
</Content>
</ItemGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"Bullseye": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "bqyt+m17ym+5aN45C5oZRAjuLDt8jKiCm/ys1XfymIXSkrTFwvI/QsbY3ucPSHDz7SF7uON7B57kXFv5H2k1ew=="
},
"Glob": {
"type": "Direct",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.Build": {
"type": "Direct",
"requested": "[17.10.4, )",
"resolved": "17.10.4",
"contentHash": "ZmGA8vhVXFzC4oo48ybQKlEybVKd0Ntfdr+Enqrn5ES1R6e/krIK9hLk0W33xuT0/G6QYd3YdhJZh+Xle717Ag==",
"dependencies": {
"Microsoft.Build.Framework": "17.10.4",
"Microsoft.NET.StringTools": "17.10.4",
"System.Collections.Immutable": "8.0.0",
"System.Configuration.ConfigurationManager": "8.0.0",
"System.Reflection.Metadata": "8.0.0",
"System.Reflection.MetadataLoadContext": "8.0.0",
"System.Security.Principal.Windows": "5.0.0",
"System.Threading.Tasks.Dataflow": "8.0.0"
}
},
"SimpleExec": {
"type": "Direct",
"requested": "[12.0.0, )",
"resolved": "12.0.0",
"contentHash": "ptxlWtxC8vM6Y6e3h9ZTxBBkOWnWrm/Sa1HT+2i1xcXY3Hx2hmKDZP5RShPf8Xr9D+ivlrXNy57ktzyH8kyt+Q=="
},
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "17.10.4",
"contentHash": "4qXCwNOXBR1dyCzuks9SwTwFJQO/xmf2wcMislotDWJu7MN/r3xDNoU8Ae5QmKIHPaLG1xmfDkYS7qBVzxmeKw=="
},
"Microsoft.NET.StringTools": {
"type": "Transitive",
"resolved": "17.10.4",
"contentHash": "wyABaqY+IHCMMSTQmcc3Ca6vbmg5BaEPgicnEgpll+4xyWZWlkQqUwafweUd9VAhBb4jqplMl6voUHQ6yfdUcg=="
},
"System.Collections.Immutable": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg=="
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JlYi9XVvIREURRUlGMr1F6vOFLk7YSY4p1vHo4kX3tQ0AGrjqlRWHDi66ImHhy6qwXBG3BJ6Y1QlYQ+Qz6Xgww==",
"dependencies": {
"System.Diagnostics.EventLog": "8.0.0",
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==",
"dependencies": {
"System.Collections.Immutable": "8.0.0"
}
},
"System.Reflection.MetadataLoadContext": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "SZxrQ4sQYnIcdwiO3G/lHZopbPYQ2lW0ioT4JezgccWUrKaKbHLJbAGZaDfkYjWcta1pWssAo3MOXLsR0ie4tQ==",
"dependencies": {
"System.Collections.Immutable": "8.0.0",
"System.Reflection.Metadata": "8.0.0"
}
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg=="
},
"System.Security.Principal.Windows": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA=="
},
"System.Threading.Tasks.Dataflow": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7V0I8tPa9V7UxMx/+7DIwkhls5ouaEMQx6l/GwGm1Y8kJQ61On9B/PxCXFLbgu5/C47g0BP2CUYs+nMv1+Oaqw=="
}
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

+70
View File
@@ -0,0 +1,70 @@
# This file contains metadata for your plugin.
# This file should be included when you package your plugin.# Mandatory items:
[general]
name=Speckle_v3
qgisMinimumVersion=3.34.6
qgisMaximumVersion=3.43.0
description=Speckle 2.0 Connector for QGIS
version=3.0.0
author=Speckle Systems
email=connectors@speckle.systems
about=
The Speckle QGIS plugin allows you send and receive data from multiple sources to/from several layers in your project, and store their geometry (as well as their contained metadata), in a Speckle server.
Don't know what Speckle is? You're not alone! Find out more at https://speckle.systems
You can start exploring by checking out this commit generated in QGIS from open web data sources (OSM, Google Satellite Tiles and Mapzen Terrain Tiles): https://app.speckle.systems/projects/5feae56049/models/1a95ec93ec
Requirements:
- Speckle Manager: You can download the installer here -> https://speckle.guide/user/manager
- An account in a Speckle server. If you don't have one, feel free to use our public server https://app.speckle.systems/
- An account added in the Accounts section of the Speckle Manager
- Windows and Mac compatible
If the requirements are not fullfilled, the plugin will load but will not have any functionality enabled.
If you're having issues with this plugin, you can always reach us at https://speckle.community
Plugin is using the external libraries such as pyshp, scipy and specklepy, which will be installed automatically to a custom folder.
Data types currently not supported for sending:
- Layers depending on the server connection (WMS, WFC, WCS etc.)
- Scenes
- Mesh Vector layers
- Pointclouds
tracker=http://github.com/specklesystems/speckle-qgis/issues
repository=http://github.com/specklesystems/speckle-qgis
# End of mandatory metadata
# Recommended items:
hasProcessingProvider=no
# Uncomment the following line and add your changelog:
# changelog=
# Tags are comma separated with spaces allowed
tags=python, speckle, interoperability, collaboration, 3d, bim, cad, online, server, web, cloud
homepage=http://speckle.systems
category=Web
icon=icon.png
experimental=False
# deprecated flag (applies to the whole plugin, not just a single version)
deprecated=False
# Since QGIS 3.8, a comma separated list of plugins to be installed
# (or upgraded) can be specified.
# Check the documentation for more information.
# plugin_dependencies=
# If the plugin can run on QGIS Server.
server=False
+55
View File
@@ -0,0 +1,55 @@
# You can install pb_tool using:
# pip install http://geoapt.net/files/pb_tool.zip
#
# Consider doing your development (and install of pb_tool) in a virtualenv.
#
# For details on setting up and using pb_tool, see:
# http://g-sherman.github.io/plugin_build_tool/
#
# Issues and pull requests here:
# https://github.com/g-sherman/plugin_build_tool:
#
# Sane defaults for your plugin generated by the Plugin Builder are
# already set below.
#
# As you add Python source files and UI files to your plugin, add
# them to the appropriate [files] section below.
[plugin]
# Name of the plugin. This is the name of the directory that will
# be created in .qgis2/python/plugins
name: speckle-qgis-v3
# Full path to where you want your plugin directory copied. If empty,
# the QGIS default path will be used. Don't include the plugin name in
# the path.
plugin_path:
[files]
# Python files that should be deployed with the plugin
python_files: __init__.py speckle_qgis_v3.py resources.py
# The main dialog file that is loaded (not compiled)
main_dialog:
# Other ui files for dialogs you create (these will be compiled)
compiled_ui_files:
# Resource file(s) that will be compiled
resource_files: resources.qrc
# Other files required for the plugin
extras: metadata.txt icon.png README.md LICENSE
# Other directories to be deployed with the plugin.
# These must be subdirectories under the plugin directory
extra_dirs: speckle/ plugin_utils/
# ISO code(s) for any locales (translations), separated by spaces.
# Corresponding .ts files must exist in the i18n directory
locales:
View File
+274
View File
@@ -0,0 +1,274 @@
"""
Provides uniform and consistent path helpers for `specklepy`
"""
import os
import sys
from pathlib import Path
from typing import Optional
from importlib import import_module, invalidate_caches
import pkg_resources
from subprocess import run
import shutil
from plugin_utils.utils import get_qgis_python_path
_user_data_env_var = "SPECKLE_USERDATA_PATH"
_debug = False
_vs_code_directory = os.path.expanduser(
"~\.vscode\extensions\ms-python.python-2023.20.0\pythonFiles\lib\python"
)
def _path() -> Optional[Path]:
"""Read the user data path override setting."""
path_override = os.environ.get(_user_data_env_var)
if path_override:
return Path(path_override)
return None
_application_name = "Speckle"
def override_application_name(application_name: str) -> None:
"""Override the global Speckle application name."""
global _application_name
_application_name = application_name
def override_application_data_path(path: Optional[str]) -> None:
"""
Override the global Speckle application data path.
If the value of path is `None` the environment variable gets deleted.
"""
if path:
os.environ[_user_data_env_var] = path
else:
os.environ.pop(_user_data_env_var, None)
def _ensure_folder_exists(base_path: Path, folder_name: str) -> Path:
path = base_path.joinpath(folder_name)
path.mkdir(exist_ok=True, parents=True)
return path
def user_application_data_path() -> Path:
"""Get the platform specific user configuration folder path"""
path_override = _path()
if path_override:
return path_override
try:
if sys.platform.startswith("win"):
app_data_path = os.getenv("APPDATA")
if not app_data_path:
raise Exception("Cannot get appdata path from environment.")
return Path(app_data_path)
else:
# try getting the standard XDG_DATA_HOME value
# as that is used as an override
app_data_path = os.getenv("XDG_DATA_HOME")
if app_data_path:
return Path(app_data_path)
else:
return _ensure_folder_exists(Path.home(), ".config")
except Exception as ex:
raise Exception("Failed to initialize user application data path.", ex)
def user_speckle_folder_path() -> Path:
"""Get the folder where the user's Speckle data should be stored."""
return _ensure_folder_exists(user_application_data_path(), _application_name)
def user_speckle_connector_installation_path(host_application: str) -> Path:
"""
Gets a connector specific installation folder.
In this folder we can put our connector installation and all python packages.
"""
return _ensure_folder_exists(
_ensure_folder_exists(user_speckle_folder_path(), "connector_installations"),
host_application,
)
print("Starting module dependency installation")
print(sys.executable)
PYTHON_PATH = get_qgis_python_path()
def connector_installation_path(host_application: str) -> Path:
connector_installation_path = user_speckle_connector_installation_path(
host_application
)
connector_installation_path.mkdir(exist_ok=True, parents=True)
# set user modules path at beginning of paths for earlier hit
if sys.path[0] != connector_installation_path:
sys.path.insert(0, str(connector_installation_path))
print(f"Using connector installation path {connector_installation_path}")
return connector_installation_path
def is_pip_available() -> bool:
try:
import_module("pip") # noqa F401
return True
except ImportError:
return False
def ensure_pip() -> None:
print("Installing pip... ")
print(PYTHON_PATH)
completed_process = run([PYTHON_PATH, "-m", "ensurepip"])
if completed_process.returncode == 0:
print("Successfully installed pip")
else:
raise Exception(
f"Failed to install pip, got {completed_process.returncode} return code"
)
def get_requirements_path() -> Path:
# we assume that a requirements.txt exists next to the __init__.py file
if sys.platform.lower().startswith("darwin"):
path = Path(Path(__file__).parent, "requirements_mac.txt")
path = Path(Path(__file__).parent, "requirements.txt")
assert path.exists(), f"path not found {path}"
return path
def _dependencies_installed(requirements: str, path: str) -> bool:
for d in pkg_resources.find_distributions(path):
entry = f"{d.key}=={d.version}"
if entry in requirements:
requirements = requirements.replace(entry, "")
requirements = requirements.replace(" ", "").replace(";", "").replace(",", "")
if len(requirements) > 0:
return False
print("Dependencies already installed")
return True
def install_requirements(host_application: str) -> None:
# set up addons/modules under the user
# script path. Here we'll install the
# dependencies
requirements = get_requirements_path().read_text().replace("\n", "")
path = str(connector_installation_path(host_application))
print(f"Installing debugpy to {path}")
if _dependencies_installed(requirements, path):
return
try:
shutil.rmtree(path)
except PermissionError as e:
raise Exception("Restart QGIS for changes to take effect")
print(f"Installing Speckle dependencies to {path}")
from subprocess import run
completed_process = run(
[
PYTHON_PATH,
"-m",
"pip",
"install",
"--pre",
"-t",
str(path),
"-r",
str(get_requirements_path()),
],
capture_output=True,
)
if completed_process.returncode != 0:
m = f"Failed to install dependenices through pip, got {completed_process.returncode} as return code. Full log: {completed_process}"
print(m)
print(completed_process.stdout)
print(completed_process.stderr)
raise Exception(m)
def install_dependencies(host_application: str) -> None:
if not is_pip_available():
ensure_pip()
install_requirements(host_application)
def _import_dependencies() -> None:
import_module("specklepy")
# the code above doesn't work for now, it fails on importing graphql-core
# despite that, the connector seams to be working as expected
# But it would be nice to make this solution work
# it would ensure that all dependencies are fully loaded
# requirements = get_requirements_path().read_text()
# reqs = [
# req.split(" ; ")[0].split("==")[0].split("[")[0].replace("-", "_")
# for req in requirements.split("\n")
# if req and not req.startswith(" ")
# ]
# for req in reqs:
# print(req)
# import_module("specklepy")
def ensure_dependencies(host_application: str) -> None:
try:
install_dependencies(host_application)
invalidate_caches()
# _import_dependencies()
print("Successfully found dependencies")
except ImportError:
raise Exception(
f"Cannot automatically ensure Speckle dependencies. Please try restarting the host application {host_application}!"
)
def startDebugger() -> None:
if _debug is True:
try:
import debugpy
except:
# path = str(connector_installation_path(host_application))
completed_process = run(
[
PYTHON_PATH,
"-m",
"pip",
"install",
"debugpy==1.8.0",
],
capture_output=True,
)
if completed_process.returncode != 0:
m = f"Failed to install debugpy through pip. Disable debug mode or install debugpy manually. Full log: {completed_process}"
raise Exception(completed_process)
# debugger: https://gist.github.com/giohappy/8a30f14678aa7e446f9b694c632d7089
if _debug is True:
import debugpy
sys.path.append(_vs_code_directory)
debugpy.configure(python=PYTHON_PATH) # shutil.which("python"))
debugpy.listen(("localhost", 5678))
debugpy.wait_for_client()
# path = str(connector_installation_path("QGIS"))
# print(path)
+125
View File
@@ -0,0 +1,125 @@
"""Logging Utility Module for Speckle QGIS"""
import webbrowser
def display_and_log(
msg: str,
func=None, # name of the function from where logging was called
level: int = 2,
dockwidget=None,
url="",
blue=False,
report=False,
):
# display in Speckle UI: TODO
# log_to_user(msg, func, level, dockwidget, url, blue, report)
# display in QGIS message panel
logger.writeToLog(msg.replace("\n", ". ") + " " + url, level, func)
class Logging:
"""Holds utility methods for logging messages to QGIS"""
qgisInterface = None
def __init__(self, iface) -> None:
self.qgisInterface = iface
def log(self, message: str, level: int = 0):
"""Logs a specific message to the Speckle messages panel."""
try:
from qgis.core import Qgis, QgsMessageLog
if level == 0:
level = Qgis.Info
elif level == 1:
level = Qgis.Warning
elif level == 2:
level = Qgis.Critical
# return
QgsMessageLog.logMessage(message, "Speckle", level=level)
except ImportError or ModuleNotFoundError as e:
print(e)
def btnClicked(url):
try:
if url == "":
return
webbrowser.open(url, new=0, autoraise=True)
except Exception as e:
pass
def logToUserWithAction(
self,
message: str,
action_text: str,
url: str = "",
level: int = 0,
duration: int = 120,
):
self.log(message, level)
if not self.qgisInterface:
return
try:
from qgis.core import Qgis
from qgis.PyQt.QtWidgets import QPushButton
if level == 0:
level = Qgis.Info
elif level == 1:
level = Qgis.Warning
elif level == 2:
level = Qgis.Critical
widget = self.qgisInterface.messageBar().createMessage("Speckle", message)
button = QPushButton(widget)
button.setText(action_text)
button.pressed.connect(lambda: self.btnClicked(url))
widget.layout().addWidget(button)
self.qgisInterface.messageBar().pushWidget(widget, level, duration)
except ImportError:
pass
def logToUserPanel(
self,
message: str,
level: int = 0,
duration: int = 20,
func=None,
plugin=None,
):
"""Logs a specific message to the user in QGIS"""
self.log(message, level)
if not self.qgisInterface:
return
try:
from qgis.core import Qgis
if level == 0:
level = Qgis.Info
if level == 1:
level = Qgis.Warning
if level == 2:
level = Qgis.Critical
if self.qgisInterface:
self.qgisInterface.messageBar().pushMessage(
"Speckle", message, level=level, duration=duration
)
except ImportError:
pass
def writeToLog(self, msg: str = "", level: int = 2, func=None, plugin=None):
msg = str(msg)
if func is not None and func != "None":
msg += "::" + str(func)
self.log(msg, level)
logger = Logging(None)
+24
View File
@@ -0,0 +1,24 @@
def main():
"""Removes Python version and OS from Requirements.txt"""
req_file = "plugin_utils/requirements.txt"
with open(req_file, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
new_line = line.split(";")[0].replace(" ", "")
if "[" in new_line and "]" in new_line:
new_line = new_line.split("[")[0] + new_line.split("]")[1]
if i < len(lines) - 1:
new_line += "\n"
new_lines.append(new_line)
with open(req_file, "w") as file:
file.writelines(new_lines)
print("Requirements file overwritten")
file.close()
if __name__ == "__main__":
main()
+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()
+42
View File
@@ -0,0 +1,42 @@
<html>
<body>
<h3>Plugin Builder Results</h3>
Congratulations! You just built a plugin for QGIS!<br/><br />
<div id='help' style='font-size:.9em;'>
Your plugin <b>Speckle (Beta)</b> was created in:<br>
&nbsp;&nbsp;<b>/Users/username/Documents/Speckle/speckle_qgis_v3</b>
<p>
Your QGIS plugin directory is located at:<br>
&nbsp;&nbsp;<b>/Users/username/Library/Application Support/QGIS/QGIS3/profiles/default/python/plugins</b>
<p>
<h3>What's Next</h3>
<ol>
<li>If resources.py is not present in your plugin directory, compile the resources file using pyrcc5 (simply use <b>pb_tool</b> or <b>make</b> if you have automake)
<li>Optionally, test the generated sources using <b>make test</b> (or run tests from your IDE)
<li>Copy the entire directory containing your new plugin to the QGIS plugin directory (see Notes below)
<li>Test the plugin by enabling it in the QGIS plugin manager
<li>Customize it by editing the implementation file <b>speckle_qgis_v3.py</b>
<li>Create your own custom icon, replacing the default <b>icon.png</b>
<li>Modify your user interface by opening <b>speckle_qgis_dialog_base.ui</b> in Qt Designer
</ol>
Notes:
<ul>
<li>You can use <b>pb_tool</b> to compile, deploy, and manage your plugin. Tweak the <i>pb_tool.cfg</i> file included with your plugin as you add files. Install <b>pb_tool</b> using
<i>pip</i> or <i>easy_install</i>. See <b>http://loc8.cc/pb_tool</b> for more information.
<li>You can also use the <b>Makefile</b> to compile and deploy when you
make changes. This requires GNU make (gmake). The Makefile is ready to use, however you
will have to edit it to add addional Python source files, dialogs, and translations.
</ul>
</div>
<div style='font-size:.9em;'>
<p>
For information on writing PyQGIS code, see <b>http://loc8.cc/pyqgis_resources</b> for a list of resources.
</p>
</div>
<p>
&copy;2011-2019 GeoApt LLC - geoapt.com
</p>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python
# coding=utf-8
"""This script uploads a plugin package to the plugin repository.
Authors: A. Pasotti, V. Picavet
git sha : $TemplateVCSFormat
"""
import sys
import getpass
import xmlrpc.client
from optparse import OptionParser
from future.standard_library import install_aliases
install_aliases()
# Configuration
PROTOCOL = "https"
SERVER = "plugins.qgis.org"
PORT = "443"
ENDPOINT = "/plugins/RPC2/"
VERBOSE = False
def main(parameters, arguments):
"""Main entry point.
:param parameters: Command line parameters.
:param arguments: Command line arguments.
"""
address = "{protocol}://{username}:{password}@{server}:{port}{endpoint}".format(
protocol=PROTOCOL,
username=parameters.username,
password=parameters.password,
server=parameters.server,
port=parameters.port,
endpoint=ENDPOINT,
)
print("Connecting to: %s" % hide_password(address))
server = xmlrpc.client.ServerProxy(address, verbose=VERBOSE)
try:
with open(arguments[0], "rb") as handle:
plugin_id, version_id = server.plugin.upload(
xmlrpc.client.Binary(handle.read())
)
print("Plugin ID: %s" % plugin_id)
print("Version ID: %s" % version_id)
except xmlrpc.client.ProtocolError as err:
print("A protocol error occurred")
print("URL: %s" % hide_password(err.url, 0))
print("HTTP/HTTPS headers: %s" % err.headers)
print("Error code: %d" % err.errcode)
print("Error message: %s" % err.errmsg)
sys.exit(1)
except xmlrpc.client.Fault as err:
print("A fault occurred")
print("Fault code: %d" % err.faultCode)
print("Fault string: %s" % err.faultString)
sys.exit(1)
def hide_password(url, start=6):
"""Returns the http url with password part replaced with '*'.
:param url: URL to upload the plugin to.
:type url: str
:param start: Position of start of password.
:type start: int
"""
start_position = url.find(":", start) + 1
end_position = url.find("@")
return "%s%s%s" % (
url[:start_position],
"*" * (end_position - start_position),
url[end_position:],
)
if __name__ == "__main__":
parser = OptionParser(usage="%prog [options] plugin.zip")
parser.add_option(
"-w",
"--password",
dest="password",
help="Password for plugin site",
metavar="******",
)
parser.add_option(
"-u",
"--username",
dest="username",
help="Username of plugin site",
metavar="user",
)
parser.add_option(
"-p", "--port", dest="port", help="Server port to connect to", metavar="80"
)
parser.add_option(
"-s",
"--server",
dest="server",
help="Specify server name",
metavar="plugins.qgis.org",
)
options, args = parser.parse_args()
if len(args) != 1:
print("Please specify zip file.\n")
parser.print_help()
sys.exit(1)
if not options.server:
options.server = SERVER
if not options.port:
options.port = PORT
if not options.username:
# interactive mode
username = getpass.getuser()
print("Please enter user name [%s] :" % username, end=" ")
res = input()
if res != "":
options.username = res
else:
options.username = username
if not options.password:
# interactive mode
options.password = getpass.getpass()
main(options, args)
+51
View File
@@ -0,0 +1,51 @@
annotated-types==0.7.0
anyio==4.8.0
appdirs==1.4.4
attrs==25.1.0
backoff==2.2.1
certifi==2025.1.31
charset-normalizer==3.4.1
click-plugins==1.1.1
click==8.1.8
cligj==0.7.2
colorama==0.4.6
deprecated==1.2.18
earcut==1.1.5
exceptiongroup==1.2.2
fiona==1.10.1
geopandas==0.13.2
geovoronoi==0.4.0
gql==3.5.0
graphql-core==3.2.6
h11==0.14.0
httpcore==1.0.7
httpx==0.28.1
idna==3.10
multidict==6.1.0
numpy==1.26.4
packaging==24.2
pandas==2.2.3
propcache==0.2.1
pydantic-core==2.27.2
pydantic-settings==2.7.1
pydantic==2.10.6
pyproj==3.7.1
pyshp==2.3.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
pytz==2025.1
requests-toolbelt==1.0.0
requests==2.31.0
scipy==1.15.2
shapely==2.0.7
six==1.17.0
sniffio==1.3.1
specklepy==3.0.0a2
stringcase==1.2.0
typing-extensions==4.12.2
tzdata==2025.1
ujson==5.10.0
urllib3==2.2.1
websockets==11.0.3
wrapt==1.17.2
yarl==1.18.3
+9
View File
@@ -0,0 +1,9 @@
geopandas==0.8.1
geovoronoi==0.4.0
pyproj==3.2.0
pyshp==2.3.1
python-dateutil==2.8.0
specklepy==2.21.3
earcut==1.1.5
numpy==1.22.4
pandas==1.3.3
+15
View File
@@ -0,0 +1,15 @@
import sys
import os
MESSAGE_CATEGORY = "Speckle"
def get_qgis_python_path():
if sys.platform.startswith("linux"):
return sys.executable
pythonExec = os.path.dirname(sys.executable)
if sys.platform == "win32":
pythonExec += "\\python3"
else:
pythonExec += "/bin/python3"
return pythonExec
Generated
+1954
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[tool.poetry]
name = "speckle-qgis-v3"
version = "0.1.0"
description = "poetry for speckle-qgis-v3"
authors = ["Your Name <you@example.com>"]
license = "Apache License 2.0"
readme = "README.md"
[tool.poetry.dependencies]
python = ">=3.10, <4.0"
numpy = "1.26.4"
pyshp = "2.3.1"
requests = "2.31.0"
requests-toolbelt = "1.0.0"
urllib3 = "2.2.1"
geopandas = "0.13.2"
geovoronoi = "0.4.0"
scipy = "^1.13.0"
earcut = "1.1.5"
specklepy = {version = "^3.0.0a3", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
pytest-cov = "^3.0.0"
devtools = "^0.12.2"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.15.2)
#
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore
qt_resource_data = b"\
\x00\x00\x03\x07\
\x89\
\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\
\x00\x00\x20\x00\x00\x00\x20\x08\x06\x00\x00\x00\x73\x7a\x7a\xf4\
\x00\x00\x01\x82\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\
\x36\x31\x39\x36\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xbf\
\x4b\x42\x51\x14\xc7\x3f\x6a\xa1\x95\x61\x50\x43\x43\x83\x44\x35\
\x69\x94\x41\xd4\x12\xa4\x44\x05\x11\x62\x06\x59\x2d\xfa\xf2\x47\
\xa0\xf6\x78\x4f\x09\x69\x0d\x5a\x83\x82\xa8\xa5\x5f\x43\xfd\x05\
\xb5\x06\xcd\x41\x50\x14\x41\x34\x35\x34\x17\xb5\x94\xbc\xce\x53\
\x41\x89\x3c\x97\x73\xcf\xe7\x7e\xef\x3d\x87\x7b\xcf\x05\x6b\x38\
\xad\x64\xf4\x86\x01\xc8\x64\x73\x5a\x68\xd2\xef\x5e\x88\x2c\xba\
\xed\xaf\x34\x61\xc3\xc1\x28\xf6\xa8\xa2\xab\xe3\xc1\xe0\x0c\x75\
\xed\xeb\x01\x8b\x19\xef\xbc\x66\xad\xfa\xe7\xfe\xb5\x96\x95\xb8\
\xae\x80\xc5\x21\x3c\xa6\xa8\x5a\x4e\x78\x4a\x78\x66\x3d\xa7\x9a\
\xbc\x2b\xdc\xa1\xa4\xa2\x2b\xc2\xe7\xc2\x1e\x4d\x2e\x28\x7c\x6f\
\xea\xb1\x32\xbf\x99\x9c\x2c\xf3\x8f\xc9\x5a\x38\x14\x00\x6b\x9b\
\xb0\x3b\x59\xc3\xb1\x1a\x56\x52\x5a\x46\x58\x5e\x4e\x4f\x26\x9d\
\x57\x2a\xf7\x31\x5f\xe2\x8c\x67\xe7\xe7\x24\x76\x8b\x77\xa1\x13\
\x62\x12\x3f\x6e\xa6\x99\x20\xc0\x30\x83\xd2\x97\x61\x19\x5e\x7c\
\xf4\xcb\x8a\x3a\xf9\x03\xa5\xfc\x59\xd6\x24\x57\x91\x59\xa5\x80\
\xc6\x2a\x49\x52\xe4\xf0\x88\x9a\x97\xea\x71\x89\x09\xd1\xe3\x32\
\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\xf2\x95\xab\x3b\xfd\xd0\xf8\
\x62\x18\x1f\xbd\x60\xdf\x81\xe2\xb6\x61\x7c\x1f\x1b\x46\xf1\x04\
\x6c\xcf\x70\x95\xad\xe6\xaf\x1d\xc1\xc8\xa7\xe8\xdb\x55\xad\xe7\
\x10\x5c\x9b\x70\x71\x5d\xd5\x62\x7b\x70\xb9\x05\x9d\x4f\x6a\x54\
\x8b\x96\x24\x9b\xb8\x35\x91\x80\xf7\x33\x68\x8d\x40\xfb\x2d\x34\
\x2f\x95\x7b\x56\xd9\xe7\xf4\x11\xc2\x1b\xf2\x55\x37\xb0\x7f\x00\
\x7d\x72\xde\xb5\xfc\x0b\x2c\x30\x67\xcb\x84\x72\xd0\x9f\x00\x00\
\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\
\x9a\x9c\x18\x00\x00\x01\x2b\x49\x44\x41\x54\x58\x85\xed\x96\xbb\
\x4a\x03\x41\x14\x40\x4f\x8c\x81\x90\x5d\x36\x08\x3e\x1a\x1f\x20\
\xc1\x46\xa2\xa6\xb0\x4a\x63\x91\x46\xd0\x26\xbf\xe1\x1f\x68\x23\
\x42\x3a\x2b\x6b\x5b\xbf\xc1\xc6\x56\xb4\x10\x83\xc8\x22\xa8\xa8\
\x85\x8d\x11\x0c\x31\x66\xf3\xd8\x71\xaf\x85\x2b\x88\x30\x64\xdd\
\x42\x05\xe7\xc0\x65\xe6\x32\x77\xee\x3d\x30\xcd\x80\xc1\xf0\xdf\
\x49\x44\x29\xda\x38\x10\x0b\x70\x80\x6c\xb8\x3a\x11\xf2\x24\xb0\
\x56\x29\x25\xae\x63\x09\x14\x57\xdd\x95\xf9\xf2\xd4\xe6\xd0\x84\
\xb5\x10\x36\x8b\x43\x0d\x58\xae\x94\x12\xa7\xba\x82\x01\xdd\x81\
\xf7\xd4\x73\x8e\x76\xaf\x72\xb5\xcb\x86\x1b\x71\x98\x88\x48\x2b\
\x10\x79\x50\x81\xdc\x74\x95\xb8\x2f\x3d\xb9\x7f\x6c\xc9\xf6\xec\
\x8e\xca\xeb\x2e\x0d\xf6\x69\x9a\x3d\xd9\xbb\x9d\x19\x2f\x4f\xef\
\x07\xa3\x76\xc6\xf3\x49\xb6\x7d\x49\x7a\x3d\x52\x9e\x22\xdd\xf6\
\x25\xdd\x51\x64\x3a\x3e\x76\xf7\x15\x1b\xb0\xc2\xf8\x4a\x1e\x38\
\x8f\x23\x00\x90\x3e\xbe\xe8\x0e\xd7\x1b\x99\xc5\x08\xb5\xdf\x46\
\xfb\x04\x3f\x85\x11\x30\x02\x46\xc0\x08\x18\x81\x3f\x2d\xe0\x02\
\xcd\x5f\x13\xa8\x1e\x16\xce\x80\x25\xde\x3f\x15\x71\x51\x40\x1d\
\xf0\x74\x05\x7d\xbf\x64\x85\x62\x35\x77\x37\x37\xb9\xde\x1c\x71\
\xc6\x80\xe7\x30\x1a\x9f\xf6\xda\x5c\x6d\xa5\xb4\x83\x0d\x06\xc3\
\x07\x6f\x57\xf4\x64\xb1\x20\xbd\x58\x5c\x00\x00\x00\x00\x49\x45\
\x4e\x44\xae\x42\x60\x82\
"
qt_resource_name = b"\
\x00\x07\
\x07\x3b\xe0\xb3\
\x00\x70\
\x00\x6c\x00\x75\x00\x67\x00\x69\x00\x6e\x00\x73\
\x00\x0c\
\x0d\xfb\x0a\x43\
\x00\x73\
\x00\x70\x00\x65\x00\x63\x00\x6b\x00\x6c\x00\x65\x00\x5f\x00\x71\x00\x67\x00\x69\x00\x73\
\x00\x08\
\x0a\x61\x5a\xa7\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\
"
qt_resource_struct_v1 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
\x00\x00\x00\x32\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
"
qt_resource_struct_v2 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x32\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x7b\x10\xd3\x1c\x5e\
"
qt_version = [int(v) for v in QtCore.qVersion().split('.')]
if qt_version < [5, 8, 0]:
rcc_version = 1
qt_resource_struct = qt_resource_struct_v1
else:
rcc_version = 2
qt_resource_struct = qt_resource_struct_v2
def qInitResources():
QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
def qCleanupResources():
QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
qInitResources()
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/plugins/speckle-qgis-v3" >
<file>icon.png</file>
</qresource>
</RCC>
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "ci-build\build.csproj", "{7DF908B3-503D-51C2-3675-626DC2895B77}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7DF908B3-503D-51C2-3675-626DC2895B77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DF908B3-503D-51C2-3675-626DC2895B77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DF908B3-503D-51C2-3675-626DC2895B77}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DF908B3-503D-51C2-3675-626DC2895B77}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {91042CB4-A71D-4F55-8534-D9A7A1760347}
EndGlobalSection
EndGlobal
View File
View File
View File
@@ -0,0 +1,215 @@
from typing import Any, Dict, List, Optional
from speckle.host_apps.qgis.connectors.layer_utils import LayerStorage, QgisLayerUtils
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from speckle.host_apps.qgis.converters.utils import CRSoffsetRotation
from speckle.sdk.connectors_common.cancellation import (
CancellationManager,
)
from speckle.ui.bindings import (
BasicConnectorBindingCommands,
IBasicConnectorBinding,
ISelectionBinding,
ISendBinding,
SelectionInfo,
SendBindingUICommands,
)
from speckle.ui.models import (
DocumentInfo,
DocumentModelStore,
ISendFilter,
ModelCard,
SenderModelCard,
)
from qgis.core import QgsProject
from PyQt5.QtCore import pyqtSignal, QObject, QTimer
class QgisBasicConnectorBinding(IBasicConnectorBinding):
store: DocumentModelStore
speckle_application: Any # TODO
def __init__(self, store, bridge):
self.store = store
self.name = "baseBinding"
self.parent = bridge
self.commands = BasicConnectorBindingCommands(bridge)
# self.store.document_changed = lambda: self.commands.notify_document_changed()
def get_source_app_name(self) -> str:
# TODO self.speckle_application.slug
return ""
def get_source_app_version(self) -> str:
return ""
def get_connector_version(self) -> str:
return ""
def get_document_info(self) -> Optional[DocumentInfo]:
# TODO
doc_path = "doc_path"
doc_name = "doc_name"
doc_id = "doc_id"
return DocumentInfo(doc_path, doc_name, doc_id)
def get_document_state(self) -> DocumentModelStore:
return self.store
def add_model(self, model_card: ModelCard) -> None:
self.store.add_model(model_card=model_card)
def update_model(self, model_card: ModelCard) -> None:
self.store.update_model(model_card=model_card)
def remove_model(self, model_card: ModelCard) -> None:
self.store.remove_model(model_card=model_card)
def highlight_model(self, model_card_id: str) -> None:
return
def highlight_objects(self, ids: str) -> None:
return
class MetaQObject(type(QObject), type(ISendBinding)):
# 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 QgisSendBinding(ISendBinding, QObject, metaclass=MetaQObject):
name: str = "sendBinding"
commands: SendBindingUICommands
parent: "SpeckleQGISv3Module"
store: DocumentModelStore
_service_provider: "IServiceProvider"
_send_filters: List[ISendFilter]
cancellation_manager: CancellationManager
_send_conversion_cache: "ISendConversionCache"
_operation_progress_manager: "IOperationProgressManager"
_logger: "ILogger[QGISSendBinding]"
_top_level_exception_handler: "ITopLevelExceptionHandler"
_qgis_conversion_settings: QgisConversionSettings
changed_objects_ids: Dict[str, bytes]
subscribed_layers: List[Any]
create_send_modules_signal = pyqtSignal(QgsProject, CRSoffsetRotation, list)
send_operation_execute_signal = pyqtSignal(str, list, object, object, object)
def __init__(
self,
bridge: "SpeckleQGISv3Module",
store: DocumentModelStore,
_service_provider: "IServiceProvider",
_send_filters: List[ISendFilter],
cancellation_manager: CancellationManager,
send_conversion_cache: "ISendConversionCache",
_operation_progress_manager: "IOperationProgressManager",
_logger: "ILogger[QGISSendBinding]",
_top_level_exception_handler: "ITopLevelExceptionHandler",
_qgis_conversion_settings: QgisConversionSettings,
):
QObject.__init__(self)
self.store = store
self._service_provider = _service_provider
self._send_filters = _send_filters
self.cancellation_manager = cancellation_manager
self.send_conversion_cache = send_conversion_cache
self._operation_progress_manager = _operation_progress_manager
self._logger = _logger
self._top_level_exception_handler = _top_level_exception_handler
self._qgis_conversion_settings = _qgis_conversion_settings
self.bridge = bridge
self.commads = SendBindingUICommands(bridge)
self.subscribe_to_qgis_events()
def subscribe_to_qgis_events(self):
# TODO
return
def get_send_filters(self):
return self._send_filters
def get_send_settings(self):
return []
def send(self, model_card_id: str) -> None:
print("____1 BINDINGS_SEND: first called operation from the main module")
model_card: SenderModelCard = self.store.get_model_by_id(model_card_id)
if not isinstance(model_card, SenderModelCard):
raise Exception("Model card is not a sender model card")
if model_card.send_filter is None:
raise ValueError("SendFilter is None")
# get layers
layers: List[LayerStorage] = (
self.bridge.connector_module.layer_utils.get_layers_from_model_card_content(
model_card
)
)
# set conversion settings by sending signal to the main module
qgis_project = QgsProject.instance()
crs_offset_rotation = CRSoffsetRotation(qgis_project.crs(), 0, 0, 0)
self.create_send_modules_signal.emit(qgis_project, crs_offset_rotation, layers)
# cancellation token
cancellation_token = self.cancellation_manager.init_cancellation_token_source(
f"speckle_{model_card_id}"
)
self.send_operation_execute_signal.emit(
model_card_id,
layers,
model_card.get_send_info("QGIS"),
None,
cancellation_token,
)
def cancel_send(self, model_card_id):
return super().cancel_send(model_card_id)
class QgisSelectionBinding(ISelectionBinding, QObject, metaclass=MetaQObject):
layer_utils: QgisLayerUtils
name: str
bridge: "SpeckleQGISv3Module"
selection_changed_signal = pyqtSignal(SelectionInfo)
def __init__(self, iface, bridge=None, layer_utils=None):
# iface variable cannot be derived from "connector_module" yet, because the binding itself is
# being initialized during connector_module initialization. But it can be called in other methods
# via "self.bridge.connector_module.iface"
QObject.__init__(self)
self.name = "selectionBinding"
self.bridge = bridge
self.layer_utils = layer_utils
# subscribe to selection change
# use QTimer to handle the event AFTER the user UI selection event is fully processed
# otherwise, on event trigger, the UI still has the pre-event layer selection active
# layerTreeView().currentLayerChanged doesn't cover GroupSelected event though
iface.layerTreeView().currentLayerChanged.connect(
lambda: QTimer.singleShot(0, self.on_selection_changed)
)
def on_selection_changed(self) -> None:
selection_info: SelectionInfo = self.get_selection()
# instead of parent.send(set_selection event)
self.selection_changed_signal.emit(selection_info)
def get_selection(self) -> SelectionInfo:
return (
self.bridge.connector_module.layer_utils.get_currently_selected_layers_info()
)
@@ -0,0 +1,29 @@
from functools import singledispatch
from qgis.core import QgsLayerTreeGroup, QgsVectorLayer, QgsRasterLayer, QgsFeature
@singledispatch
def get_speckle_app_id(data):
raise NotImplementedError(
f"Cannot get application id from data of type {type(data)}"
)
@get_speckle_app_id.register
def _(data: QgsLayerTreeGroup):
return data.name()
@get_speckle_app_id.register
def _(data: QgsVectorLayer):
return data.id()
@get_speckle_app_id.register
def _(data: QgsRasterLayer):
return f"{data.id()}_0"
@get_speckle_app_id.register
def _(data: QgsFeature, layer_app_id: str):
return f"{layer_app_id}_{data.id()}"
@@ -0,0 +1,14 @@
from typing import Dict, List, Optional
from speckle.ui.models import ISendFilter
class QgisSelectionFilter(ISendFilter):
def __init__(self, selected_object_ids=None):
self.id: str = "selection"
self.name: str = "Selection"
self.is_default: bool = True
self.selected_object_ids: List[str] = selected_object_ids or []
def refresh_object_ids(self):
return self.selected_object_ids
@@ -0,0 +1,243 @@
from typing import Any, Dict, List, Optional
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.host_apps.qgis.connectors.layer_utils import LayerStorage
from speckle.ui.models import DocumentModelStore
from specklepy.objects.models.collections.collection import Collection
from specklepy.objects.proxies import ColorProxy
from qgis.core import QgsLayerTreeGroup, QgsVectorLayer, QgsRasterLayer, QgsFeature
from PyQt5.QtCore import pyqtSignal, QObject, QTimer
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
def on_project_saving(self):
return
def host_app_save_state(self, state):
# return super().host_app_save_state(state)
# TODO: replace model cards written in QGIS project
return
def load_state(self):
# return super().load_state()
# TODO: get the model cards written into the document
return
class QgisLayerUnpacker:
collection_cache: Dict[str, Collection]
def __init__(self):
self.collection_cache = {}
def unpack_selection(
self,
qgis_layers: List[LayerStorage],
parent_collection: Collection,
objects: Optional[List[Any]] = None,
):
if objects is None:
objects = []
for layer_storage in qgis_layers:
layer = layer_storage.layer
if isinstance(layer, QgsLayerTreeGroup):
group_collection: Collection = self.create_and_cache_layer_collection(
layer=layer, is_layer_group=True
)
parent_collection.elements.append(group_collection)
# pass all sub-layers through unpacking
sub_layers = [
(
LayerStorage(name=lyr.name(), id=None, layer=lyr)
if isinstance(lyr, QgsLayerTreeGroup)
else LayerStorage(
name=lyr.layer().name(),
id=lyr.layer().id(),
layer=lyr.layer(),
)
)
for lyr in layer.children()
]
self.unpack_selection(sub_layers, group_collection, objects)
else: # QgsVectorLayer, QgsRasterLayer
if layer not in objects:
collection: Collection = self.create_and_cache_layer_collection(
layer
)
parent_collection.elements.append(collection)
objects.append(layer)
return objects
def create_and_cache_layer_collection(
self,
layer: QgsLayerTreeGroup | QgsVectorLayer | QgsRasterLayer,
is_layer_group: bool = False,
):
layer_app_id = get_speckle_app_id(layer)
collection: Collection = Collection(
name=layer.name(), applicationId=layer_app_id
)
collection["type"] = type(layer)
if isinstance(layer, QgsVectorLayer):
layer_fields: Dict[str, Any] = {}
for field in layer.fields():
# 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
elif isinstance(layer, QgsRasterLayer):
pass
if not is_layer_group:
self.collection_cache[layer_app_id] = collection
return collection
class QgisColorUnpacker:
color_proxy_cache: Dict[int, ColorProxy]
stored_renderer_field: str = None
stored_renderer: Optional["QgsFeatureRenderer"] = None
stored_color: Optional[int] = None
def __init__(self):
self.color_proxy_cache = {}
def store_renderer_and_fields(self, vector_layer: QgsVectorLayer) -> None:
# clear stored values
self.stored_renderer_field = None
self.stored_color = None
self.stored_renderer = None
renderer = vector_layer.renderer()
if not renderer: # e.g. for Tables
return
renderer_type = renderer.type()
if renderer_type == "singleSymbol":
self.stored_renderer = renderer
elif renderer_type in ["categorizedSymbol", "graduatedSymbol"]:
self.stored_renderer = renderer
# field name or expression string, needs to be double-checked when used to get field value
self.stored_renderer_field = self.stored_renderer.classAttribute()
def process_vector_layer_color(
self, feature: QgsFeature, feature_app_id: str
) -> None:
"""Processes a feature color from a vector layer by the stored renderer,
and stores the feature's id and color proxy to the color_proxy_cache."""
if not self.stored_renderer:
return
if self.stored_color:
self.add_object_id_to_color_proxy_cache(feature_app_id, self.stored_color)
return
color_rgba: int = None
renderer_type = self.stored_renderer.type()
if renderer_type == "singleSymbol":
color_rgba = self.stored_renderer.symbol().color().rgba()
elif renderer_type == "categorizedSymbol":
color_rgba = self.get_feature_color_by_categorized_renderer(
self.stored_renderer, feature
)
elif renderer_type == "graduatedSymbol":
color_rgba = self.get_feature_color_by_graduated_values_renderer(
self.stored_renderer, feature
)
if not color_rgba:
return
# argb: int = self.color_list_to_int(color_rgba)
self.add_object_id_to_color_proxy_cache(feature_app_id, color_rgba)
if renderer_type == "singleSymbol":
self.stored_color = color_rgba
def get_feature_color_by_categorized_renderer(
self, renderer: Any, feature: QgsFeature
) -> 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 value_symbol:
value_symbol = renderer.sourceSymbol()
color = value_symbol.color().rgba()
return color
def get_feature_color_by_graduated_values_renderer(
self, renderer: Any, feature: QgsFeature
) -> Any:
feature_value_for_rendering = feature.attribute(self.stored_renderer_field)
value_symbol = renderer.symbolForValue(feature_value_for_rendering)
if not value_symbol:
value_symbol = renderer.sourceSymbol()
color = value_symbol.color().rgba()
return color
def add_object_id_to_color_proxy_cache(self, object_id: str, argb: int):
existing_color_proxy: ColorProxy = self.color_proxy_cache.get(argb)
if existing_color_proxy:
existing_color_proxy.objects.append(object_id)
else:
new_color_proxy = ColorProxy(
name=str(argb), value=argb, objects=[object_id], applicationId=str(argb)
)
self.color_proxy_cache[argb] = new_color_proxy
@@ -0,0 +1,236 @@
from dataclasses import dataclass
from typing import Any, List, Optional
from speckle.ui.bindings import SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
from qgis.core import (
QgsProject,
QgsLayerTreeNode,
QgsLayerTreeGroup,
QgsLayerTreeLayer,
QgsVectorLayer,
QgsRasterLayer,
)
@dataclass
class LayerStorage:
"""This class is implemented to store different types of layers, which, depending
on the API, might or might not have direct calls to get their ID.
Name is used as a backup for missing ID.
"""
name: str
id: Optional[str] # None for QgsLayerTreeGroup
layer: QgsLayerTreeGroup | QgsVectorLayer | QgsRasterLayer
class QgisLayerUtils:
iface: Any
def __init__(self, iface: Any):
self.iface = iface
def get_all_layers(self, project) -> List[Any]:
return []
def unpack_layers(self, layers_to_unpack) -> List[Any]:
return []
def get_layers_in_order(
self, qgis_project, layers_to_search: List[LayerStorage]
) -> List[LayerStorage]:
# get all layer tree
root_node = qgis_project.layerTreeRoot()
original_layers_to_search: List[Any] = [lyr.layer for lyr in layers_to_search]
ordered_top_level_layers: List[LayerStorage] = []
# get only selected layers, EXCLUDING any child layers (if it's a group)
# will modify both input lists
self.traverse_and_select_group(
root_node, original_layers_to_search, ordered_top_level_layers
)
return ordered_top_level_layers
def get_selection_filter_summary_from_ids(self, card_content: ModelCard) -> str:
print("___get_selection_filter_summary_from_ids")
if isinstance(card_content, SenderModelCard):
layers: List[LayerStorage] = self.get_layers_from_model_card_content(
card_content
)
selection_info: SelectionInfo = self.get_selection_info_from_layers(layers)
return selection_info.summary
else:
return "0 layers"
def get_layers_from_model_card_content(
self, card_content: ModelCard
) -> List[LayerStorage]:
layer_ids_and_group_names: List[str] = (
card_content.send_filter.refresh_object_ids()
)
root = QgsProject.instance().layerTreeRoot()
# get groups
# for group in root.findGroups() get layer;
# then extract actual .layer() from the found QgsLayerTreeLayer
all_groups: List[LayerStorage] = self.traverse_nodes(
nodes=root.findGroups(), return_layers=False
)
groups: List[LayerStorage] = [
LayerStorage(name=group.name, id=None, layer=group.layer)
for group in all_groups
if group.name in layer_ids_and_group_names
]
layers: List[Any] = [
LayerStorage(
name=root.findLayer(l_id).layer().name(),
id=root.findLayer(l_id).layer().id(),
layer=root.findLayer(l_id).layer(),
)
for l_id in layer_ids_and_group_names
if root.findLayer(l_id) is not None
]
all_layers = groups + layers
if len(all_layers) != len(layer_ids_and_group_names):
pass
# TODO: raise Warning about missing layers. Likely due to document opening/change, or deleted layers
return self.filter_out_duplicate_layers(all_layers)
def filter_out_duplicate_layers(
self, layers: List[LayerStorage]
) -> List[LayerStorage]:
filtered_out_layers = []
for layer_storage in layers:
if layer_storage not in filtered_out_layers:
filtered_out_layers.append(layer_storage)
return filtered_out_layers
def traverse_and_select_group(
self, node: Any, list_to_clear, list_to_add: List[LayerStorage]
) -> bool:
layer_found = False
if isinstance(node, QgsLayerTreeLayer):
if node.layer() in list_to_clear:
list_to_clear.remove(node.layer())
list_to_add.append(
LayerStorage(
name=node.layer().name(),
id=node.layer().id(),
layer=node.layer(),
)
)
layer_found = True
elif isinstance(node, QgsLayerTreeGroup):
if node in list_to_clear:
list_to_clear.remove(node)
list_to_add.append(LayerStorage(name=node.name(), id=None, layer=node))
layer_found = True
# if the layer was a group, and it wasn't a part of selection: traverse further
if not layer_found and isinstance(node, QgsLayerTreeGroup):
children = node.children()
for child_node in children:
# will modify both input lists
self.traverse_and_select_group(child_node, list_to_clear, list_to_add)
def traverse_nodes(
self, nodes: QgsLayerTreeNode, return_layers=True, return_groups=True
) -> List[LayerStorage]:
all_layers = []
for node in nodes:
if isinstance(node, QgsLayerTreeLayer):
if return_layers:
all_layers.append(
LayerStorage(
name=node.layer().name(),
id=node.layer().id(),
layer=node.layer(),
)
)
elif isinstance(node, QgsLayerTreeGroup):
all_layers.extend(
self.traverse_group(node, return_layers, return_groups)
)
return all_layers
def traverse_group(
self, group: QgsLayerTreeGroup, return_layers=True, return_groups=True
) -> List[LayerStorage]:
all_layers = []
if return_groups:
all_layers.append(
LayerStorage(
name=group.name(),
id=None,
layer=group,
)
)
children = group.children()
for child in children:
if isinstance(child, QgsLayerTreeLayer):
if return_layers:
all_layers.append(
LayerStorage(
name=child.layer().name(),
id=child.layer().id(),
layer=child.layer(),
)
)
elif isinstance(child, QgsLayerTreeGroup):
all_layers.extend(
self.traverse_group(child, return_layers, return_groups)
)
return all_layers
def get_currently_selected_layers(self) -> List[LayerStorage]:
# get groups
selected_nodes = self.iface.layerTreeView().selectedNodes() # QgsLayerTreeGroup
groups_content_layers: List[LayerStorage] = self.traverse_nodes(selected_nodes)
return self.filter_out_duplicate_layers(groups_content_layers)
def get_currently_selected_layers_info(self) -> SelectionInfo:
return self.get_selection_info_from_layers(self.get_currently_selected_layers())
def get_selection_info_from_layers(
self, selected_layers: List[LayerStorage]
) -> SelectionInfo:
# possible inputs are coming from:
# - get_currently_selected_layers() = self.iface.layerTreeView().selectedLayers(): returns QgsVectorLayer, QgsRasterLayer
# - get_layers_from_model_card_content(): List[Any] = [root.findLayer(l_id).layer() for l_id in layer_ids]: returns QgsVectorLayer, QgsRasterLayer
object_types = list(
set(
[
str(type(layer.layer)).split(".")[-1].split("'")[0].split(">")[0]
for layer in selected_layers
]
)
)
return SelectionInfo(
selected_object_ids=[layer.id or layer.name for layer in selected_layers],
summary=f"{len(selected_layers)} layers ({", ".join(object_types)})",
)
@@ -0,0 +1,187 @@
from typing import Any, Dict, List
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.sdk.connectors_common.builders import (
IRootObjectBuilder,
RootObjectBuilderResult,
)
from speckle.host_apps.qgis.connectors.host_app import (
QgisColorUnpacker,
QgisLayerUnpacker,
)
from speckle.host_apps.qgis.connectors.layer_utils import LayerStorage, QgisLayerUtils
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from speckle.sdk.connectors_common.cancellation import CancellationToken
from speckle.sdk.connectors_common.conversion import SendConversionResult
from speckle.sdk.connectors_common.operations import ProxyKeys
from speckle.sdk.converters_common.converters_common import IRootToSpeckleConverter
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.models.collections.collection import Collection
from qgis.core import QgsProject, QgsVectorLayer, QgsRasterLayer
from speckle.host_apps.qgis.connectors.utils import UNSUPPORTED_PROVIDERS
class QgisRootObjectBuilder(IRootObjectBuilder):
root_to_speckle_converter: IRootToSpeckleConverter
send_conversion_cache: "ISendConversionCache"
layer_unpacker: QgisLayerUnpacker
color_unpacker: QgisColorUnpacker
converter_settings: QgisConversionSettings
layer_utils: QgisLayerUtils
logger: "ILogger[QgisRootObjectBuilder]"
activity_factory: "ISdkActivityFactory"
def __init__(
self,
root_to_speckle_converter: IRootToSpeckleConverter,
send_conversion_cache: "ISendConversionCache",
layer_unpacker: QgisLayerUnpacker,
color_unpacker: QgisColorUnpacker,
converter_settings: QgisConversionSettings,
layer_utils: QgisLayerUtils,
logger: "ILogger[QgisRootObjectBuilder]",
activity_factory: "ISdkActivityFactory",
):
self.root_to_speckle_converter = root_to_speckle_converter
self.send_conversion_cache = send_conversion_cache
self.layer_unpacker = layer_unpacker
self.color_unpacker = color_unpacker
self.converter_settings = converter_settings
self.layer_utils = layer_utils
self.logger = logger
self.activity_factory = activity_factory
def build(
self,
layers_flat: List[LayerStorage],
send_info: SendInfo,
on_operation_progressed: Any,
ct: CancellationToken = None,
) -> RootObjectBuilderResult:
# TODO
print("____BUILD")
print(len(layers_flat))
qgis_project = QgsProject.instance()
root_collection: Collection = Collection(
name=qgis_project.fileName().split("/")[-1], elements=[]
)
qgis_project_crs = qgis_project.crs()
crs: Dict[str, Any] = {
"description": qgis_project_crs.description(),
"unit": qgis_project_crs.mapUnits().name,
"authid": qgis_project_crs.authid(),
"wkt": qgis_project_crs.toWkt(),
}
root_collection["units"] = self.converter_settings.speckle_units
root_collection["crs"] = crs
root_collection["version"] = 3
# TODO: wrap into activityFactory
layers_ordered: List[LayerStorage] = self.layer_utils.get_layers_in_order(
qgis_project, layers_flat
)
# will modify root_collection and return objects as flat list of Qgs Vector or Raster layers
# will pre-populate Collections with Type, Fields and WkbType
unpacked_layers_to_convert: List[Any] = self.layer_unpacker.unpack_selection(
qgis_layers=layers_ordered, parent_collection=root_collection
)
# here will be iteration loop through layers and their features
results: List[SendConversionResult] = []
for lyr in unpacked_layers_to_convert:
ct.throw_if_cancellation_requested()
layer_app_id: str = get_speckle_app_id(lyr)
layer_collection = self.layer_unpacker.collection_cache[layer_app_id]
status = "SUCCESS"
# verify that the data provider is supported
data_provider_type = lyr.providerType()
if data_provider_type in UNSUPPORTED_PROVIDERS:
status = "ERROR"
print(f"Unsupported layer data provider: {data_provider_type}")
elif isinstance(lyr, QgsVectorLayer):
# TODO handle layers that failed to convert
# right now, the entire layer will fail if 1 feature fails
try:
converted_features: List[Base] = self.convert_vector_features(
lyr, layer_app_id, ct
)
layer_collection.elements.extend(converted_features)
except ValueError as e:
status = "ERROR"
print(e)
elif isinstance(lyr, QgsRasterLayer):
try:
converted_raster = self.convert_raster_feature(lyr, layer_app_id)
layer_collection.elements.append(converted_raster)
except ValueError as e:
status = "ERROR"
print(e)
result_1 = SendConversionResult(
status=status,
source_id=layer_app_id,
source_type=type(lyr),
result=layer_collection,
)
results.append(result_1)
root_collection[ProxyKeys().COLOR] = list(
self.color_unpacker.color_proxy_cache.values()
)
return RootObjectBuilderResult(
root_object=root_collection,
conversion_results=results,
)
def convert_vector_features(
self, vector_layer: QgsVectorLayer, layer_app_id: str, ct: CancellationToken
) -> List[Base]:
converted_features: List[Base] = []
self.color_unpacker.store_renderer_and_fields(vector_layer)
for i, feature in enumerate(vector_layer.getFeatures()):
# trigger after every 100 features
if i % 100 == 0:
ct.throw_if_cancellation_requested()
converted_feature: "QgisObject" = self.root_to_speckle_converter.convert(
{"target": feature, "layer_application_id": layer_app_id}
)
converted_features.append(converted_feature)
self.color_unpacker.process_vector_layer_color(
feature, get_speckle_app_id(feature, layer_app_id)
)
return converted_features
def convert_raster_feature(
self, raster_layer: QgsRasterLayer, layer_app_id: str
) -> Mesh:
converted_raster: "QgisObject" = self.root_to_speckle_converter.convert(
{"target": raster_layer, "layer_application_id": layer_app_id}
)
return converted_raster
@@ -0,0 +1,105 @@
from speckle.host_apps.qgis.connectors.utils import QgisThreadContext
from speckle.host_apps.qgis.converters.to_speckle.top_level import (
CoreObjectsBaseToSpeckleTopLevelConverter,
)
from speckle.sdk.connectors_common.api import ClientFactory
from speckle.sdk.connectors_common.cancellation import CancellationManager
from speckle.sdk.connectors_common.credentials import AccountManager
from speckle.sdk.connectors_common.operations import (
AccountService,
SendOperation,
)
from speckle.host_apps.qgis.connectors.bindings import (
QgisBasicConnectorBinding,
QgisSelectionBinding,
QgisSendBinding,
)
from speckle.host_apps.qgis.connectors.host_app import (
QgisColorUnpacker,
QgisDocumentStore,
QgisLayerUnpacker,
)
from speckle.host_apps.qgis.connectors.operations import (
QgisRootObjectBuilder,
)
from speckle.host_apps.qgis.connectors.layer_utils import QgisLayerUtils
from PyQt5.QtCore import QObject
class QgisConnectorModule(QObject):
bridge: "SpeckleQGISv3Module"
thread_context: QgisThreadContext
document_store: QgisDocumentStore
basic_binding: QgisBasicConnectorBinding
send_binding: QgisSendBinding
layer_utils: QgisLayerUtils
selection_binding: QgisSelectionBinding
root_obj_builder: QgisRootObjectBuilder
account_service: AccountService
send_operation: SendOperation
layer_unpacker: QgisLayerUnpacker
color_unpacker: QgisColorUnpacker
iface = None # will be assigned on plugin init
def __init__(self, bridge, iface):
super().__init__()
self.iface = iface
self.bridge = bridge
self.thread_context = QgisThreadContext()
self.document_store = QgisDocumentStore(iface)
self.basic_binding = QgisBasicConnectorBinding(self.document_store, bridge)
self.send_binding = QgisSendBinding(
bridge=bridge,
store=self.document_store,
_service_provider=None,
_send_filters=[],
cancellation_manager=CancellationManager(),
send_conversion_cache=None,
_operation_progress_manager=None,
_logger=None,
_top_level_exception_handler=None,
_qgis_conversion_settings=None,
)
self.layer_utils = QgisLayerUtils(iface=iface)
self.selection_binding = QgisSelectionBinding(
iface=self.iface, bridge=self.bridge, layer_utils=self.layer_utils
)
self.layer_unpacker = QgisLayerUnpacker()
self.color_unpacker = QgisColorUnpacker()
self.root_obj_builder = None
account_manager = AccountManager()
self.account_service = AccountService(account_manager)
self.send_operation = None
def create_root_builder_send_operation(self, converter_module):
# create modules for Send operation that require conversion_settings
self.root_obj_builder = QgisRootObjectBuilder(
root_to_speckle_converter=CoreObjectsBaseToSpeckleTopLevelConverter(
display_value_extractor=converter_module.display_value_extractor,
properties_extractor=converter_module.properties_extractor,
conversion_settings=converter_module.conversion_settings,
),
send_conversion_cache=None,
layer_unpacker=self.layer_unpacker,
color_unpacker=self.color_unpacker,
converter_settings=converter_module.conversion_settings,
layer_utils=self.layer_utils,
logger=None,
activity_factory=None,
)
client_factory = ClientFactory()
self.send_operation: SendOperation = SendOperation(
root_object_builder=self.root_obj_builder,
send_conversion_cache=None,
account_service=self.account_service,
send_progress=None,
operations=None,
client_factory=client_factory,
activity_factory=None,
)
@@ -0,0 +1,58 @@
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, QgsApplication
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):
def worker_to_main_async(self, action: Callable):
raise NotImplementedError()
def main_to_worker_async(self, action: Callable):
raise NotImplementedError()
@@ -0,0 +1,43 @@
from typing import List
from speckle.host_apps.qgis.connectors.layer_utils import LayerStorage
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from qgis.core import QgsProject
from speckle.host_apps.qgis.converters.to_speckle.helpers import (
DisplayValueExtractor,
PropertiesExtractor,
)
from speckle.host_apps.qgis.converters.utils import (
CRSoffsetRotation,
)
class QgisConverterModule:
display_value_extractor: DisplayValueExtractor
properties_extractor: PropertiesExtractor
conversion_settings: QgisConversionSettings
def __init__(
self,
):
self.display_value_extractor = (
None # will be assigned on operation DisplayValueExtractor()
)
self.properties_extractor = PropertiesExtractor()
self.conversion_settings = None # will be assigned on operation
def create_and_save_conversion_settings(
self,
qgis_project: QgsProject,
crs_offset_rotation: CRSoffsetRotation,
layers: List[LayerStorage],
):
self.conversion_settings = QgisConversionSettings(
project=qgis_project,
active_crs_offset_rotation=crs_offset_rotation,
layers=layers,
)
self.display_value_extractor = DisplayValueExtractor(self.conversion_settings)
return self.conversion_settings
@@ -0,0 +1,57 @@
from dataclasses import dataclass
from typing import Dict, List
from qgis.core import (
QgsProject,
QgsVectorLayer,
QgsRasterLayer,
QgsCoordinateTransform,
QgsCoordinateTransformContext,
)
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.host_apps.qgis.connectors.layer_utils import LayerStorage
from speckle.host_apps.qgis.converters.utils import (
CRSoffsetRotation,
IHostToSpeckleUnitConverter,
QgisToSpeckleUnitConverter,
)
@dataclass
class QgisConversionSettings:
project: QgsProject
active_crs_offset_rotation: CRSoffsetRotation
layers_send_transforms: Dict[str, QgsCoordinateTransformContext]
unit_converter: IHostToSpeckleUnitConverter[str]
speckle_units: str
def __init__(
self,
project: QgsProject,
active_crs_offset_rotation: CRSoffsetRotation,
layers: List[LayerStorage],
# unit_converter: IHostToSpeckleUnitConverter[Qgis.DistanceUnit],
):
self.project = project
self.active_crs_offset_rotation = active_crs_offset_rotation
self.layers_send_transforms = {}
self.unit_converter = QgisToSpeckleUnitConverter()
self.speckle_units = self.unit_converter.convert_or_throw(
self.project.distanceUnits()
)
# create QgsCoordinateTransform for each layer
transform_context: QgsCoordinateTransformContext = project.transformContext()
for lyr in layers:
source_layer = lyr.layer
if isinstance(source_layer, QgsVectorLayer) or isinstance(
source_layer, QgsRasterLayer
):
transformation = QgsCoordinateTransform(
source_layer.crs(), # crs_from
active_crs_offset_rotation.crs, # crs_to
transform_context,
)
self.layers_send_transforms[get_speckle_app_id(source_layer)] = (
transformation
)
@@ -0,0 +1,117 @@
from typing import Any, Dict, List
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from speckle.host_apps.qgis.converters.to_speckle.raw import (
PointToSpeckleConverter,
PolylineToSpeckleConverter,
PolygonToSpeckleConverter,
RasterToSpeckleConverter,
)
from specklepy.objects.base import Base
from qgis.core import (
QgsFeature,
QgsRasterLayer,
QgsGeometry,
QgsCoordinateTransform,
)
from PyQt5 import QtCore
class DisplayValueExtractor:
_conversion_settings: QgisConversionSettings
_point_converter: PointToSpeckleConverter
_polyline_converter: PolylineToSpeckleConverter
_polygon_converter: PolygonToSpeckleConverter
_raster_converter: RasterToSpeckleConverter
def __init__(self, conversion_settings):
self._conversion_settings = conversion_settings
self._point_converter = PointToSpeckleConverter(conversion_settings)
self._polyline_converter = PolylineToSpeckleConverter(
conversion_settings, self._point_converter
)
self._polygon_converter = PolygonToSpeckleConverter(
conversion_settings, self._polyline_converter
)
self._raster_converter = RasterToSpeckleConverter(
conversion_settings, self._point_converter
)
def get_display_value(
self, core_object: QgsFeature | QgsRasterLayer, layer_app_id: str
) -> List[Base]:
if isinstance(core_object, QgsFeature):
return self._get_feature_geometries(core_object, layer_app_id)
elif isinstance(core_object, QgsRasterLayer):
return self._get_raster_geometry(core_object)
raise NotImplementedError(
f"Cannot extract displayValue from object of type '{type(core_object)}'"
)
def _get_layer_transformation(self, layer_app_id: str) -> QgsCoordinateTransform:
return self._conversion_settings.layers_send_transforms[layer_app_id]
def _get_feature_geometries(self, feature: QgsFeature, layer_app_id: str):
geometry: QgsGeometry = feature.geometry()
geometry_type = geometry.type().value
# Point: 0, Line: 1, Polygon: 2, Unknown: 3, Null: 4
abstract_geometry = geometry.get()
if geometry_type in [0, 1, 2]:
# reproject geometry: needs to be done here, while we have a layer reference
transformation = self._get_layer_transformation(layer_app_id)
abstract_geometry.transform(transformation)
if geometry_type == 0:
return self._point_converter.convert(abstract_geometry)
if geometry_type == 1:
return self._polyline_converter.convert(abstract_geometry)
if geometry_type == 2:
return self._polygon_converter.convert(abstract_geometry)
elif geometry_type == 3: # no-geometry table feature
return []
raise ValueError(f"Unsupported geometry type: '{geometry.type().name}'")
def _get_raster_geometry(self, raster: QgsRasterLayer):
# transformation will be done already in converter
return [self._raster_converter.convert(raster)]
class PropertiesExtractor:
def get_properties(self, core_object: Any) -> Dict[str, Any]:
if isinstance(core_object, QgsFeature):
# 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
return {}
@@ -0,0 +1,103 @@
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))
print(coef)
# 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)
@@ -0,0 +1,573 @@
import math
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 speckle.host_apps.qgis.converters.to_speckle.mesher import generate_region_mesh
from specklepy.objects.geometry import Mesh, Point, Polyline, Region
from qgis.core import (
QgsAbstractGeometry,
QgsWkbTypes,
QgsPoint,
QgsRasterLayer,
QgsRasterBandStats,
)
from osgeo import gdal
class PointToSpeckleConverter:
_conversion_settings: QgisConversionSettings
def __init__(
self,
conversion_settings,
):
self._conversion_settings = conversion_settings
def convert(self, target: QgsAbstractGeometry) -> List[Point]:
result_geometry = [self.convert_point_to_speckle(pt) for pt in target.parts()]
return result_geometry
def convert_point_to_speckle(self, point: QgsPoint) -> Point:
speckle_point = Point(
x=point.x(),
y=point.y(),
z=0 if math.isnan(point.z()) else point.z(),
units=self._conversion_settings.speckle_units,
)
return speckle_point
class PolylineToSpeckleConverter:
_conversion_settings: QgisConversionSettings
_point_converter: PointToSpeckleConverter
def __init__(
self,
conversion_settings: QgisConversionSettings,
point_converter: PointToSpeckleConverter,
):
self._conversion_settings = conversion_settings
self._point_converter = point_converter
def convert(self, target: QgsAbstractGeometry) -> List[Polyline]:
wkb_type = target.wkbType()
# WkbTypes: https://qgis.org/pyqgis/master/core/Qgis.html#qgis.core.Qgis.WkbType
if (
wkb_type == QgsWkbTypes.LineString
or wkb_type == QgsWkbTypes.LineString25D
or wkb_type == QgsWkbTypes.LineStringZ
or wkb_type == QgsWkbTypes.LineStringM
or wkb_type == QgsWkbTypes.LineStringZM
or wkb_type == QgsWkbTypes.MultiLineString
or wkb_type == QgsWkbTypes.MultiLineString25D
or wkb_type == QgsWkbTypes.MultiLineStringZ
or wkb_type == QgsWkbTypes.MultiLineStringM
or wkb_type == QgsWkbTypes.MultiLineStringZM
or wkb_type == QgsWkbTypes.MultiCurve
or wkb_type == QgsWkbTypes.MultiCurveZ
or wkb_type == QgsWkbTypes.MultiCurveM
or wkb_type == QgsWkbTypes.MultiCurveZM
):
return [self._convert_linestring(part) for part in target.parts()]
if (
wkb_type == QgsWkbTypes.CircularString
or wkb_type == QgsWkbTypes.CircularStringZ
or wkb_type == QgsWkbTypes.CircularStringM
or wkb_type == QgsWkbTypes.CircularStringZM
or wkb_type == QgsWkbTypes.CompoundCurve
or wkb_type == QgsWkbTypes.CompoundCurveZ
or wkb_type == QgsWkbTypes.CompoundCurveM
or wkb_type == QgsWkbTypes.CompoundCurveZM
):
return [self._convert_circularstring(part) for part in target.parts()]
raise ValueError(f"Geometry of type '{wkb_type.name}' cannot be converted")
def _convert_linestring(self, linestring) -> Polyline:
speckle_points: List[Point] = [
self._point_converter.convert_point_to_speckle(pt)
for pt in linestring.points()
]
coord_list = [item for pt in speckle_points for item in [pt.x, pt.y, pt.z]]
if linestring.isClosed() and len(coord_list) >= 3:
coord_list.extend(coord_list[:3])
return Polyline(
value=coord_list,
units=self._conversion_settings.speckle_units,
)
def _convert_circularstring(self, circularstring) -> Polyline:
new_linestring = circularstring.clone().curveToLine()
return self._convert_linestring(new_linestring)
class PolygonToSpeckleConverter:
_conversion_settings: QgisConversionSettings
_polyline_converter: PolylineToSpeckleConverter
def __init__(
self,
conversion_settings: QgisConversionSettings,
polyline_converter: PolylineToSpeckleConverter,
):
self._conversion_settings = conversion_settings
self._polyline_converter = polyline_converter
def convert(self, target: QgsAbstractGeometry) -> List[Region]:
wkb_type = target.wkbType()
if (
wkb_type == QgsWkbTypes.Polygon
or wkb_type == QgsWkbTypes.PolygonZ
or wkb_type == QgsWkbTypes.PolygonM
or wkb_type == QgsWkbTypes.PolygonZM
or wkb_type == QgsWkbTypes.MultiPolygon
or wkb_type == QgsWkbTypes.MultiPolygonZ
or wkb_type == QgsWkbTypes.MultiPolygonM
or wkb_type == QgsWkbTypes.MultiPolygonZM
or wkb_type == QgsWkbTypes.CurvePolygon
or wkb_type == QgsWkbTypes.CurvePolygonZ
or wkb_type == QgsWkbTypes.CurvePolygonM
or wkb_type == QgsWkbTypes.CurvePolygonZM
):
all_regions = []
for part in target.parts():
boundary = self._polyline_converter.convert(part.exteriorRing())[0]
inner_loops = []
for i in range(part.numInteriorRings()):
inner_loops.append(
self._polyline_converter.convert(part.interiorRing(i))[0]
)
display_mesh: Mesh = generate_region_mesh(
boundary, inner_loops, self._conversion_settings.speckle_units
)
all_regions.append(
Region(
boundary=boundary,
innerLoops=inner_loops,
hasHatchPattern=False,
displayValue=[display_mesh],
units=self._conversion_settings.speckle_units,
)
)
return all_regions
raise ValueError(f"Geometry of type '{type(target)}' cannot be converted")
class RasterToSpeckleConverter:
_conversion_settings: QgisConversionSettings
_point_converter: PointToSpeckleConverter
def __init__(
self,
conversion_settings: QgisConversionSettings,
point_converter: PointToSpeckleConverter,
):
self._conversion_settings = conversion_settings
self._point_converter = point_converter
def convert(self, target) -> Mesh:
# get raster basic info
ds = gdal.Open(target.source(), gdal.GA_ReadOnly)
dimension_x = target.width()
dimension_y = target.height()
cells_number = dimension_x * dimension_y
# Geotransforms documentation: https://gdal.org/en/stable/tutorials/geotransforms_tut.html
min_x, resolution_pixel_x, _, min_y, _, resolution_pixel_y = (
ds.GetGeoTransform()[:6]
)
max_x: float = min_x + resolution_pixel_x * dimension_x
max_y: float = min_y + resolution_pixel_y * dimension_y
# reproject raster corner points, taken clockwise
layer_app_id = get_speckle_app_id(target)
transformation = self._conversion_settings.layers_send_transforms[layer_app_id]
corner_pts = []
for coord in [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]:
point = QgsPoint(*coord)
point.transform(transformation)
corner_pts.append(point)
# get updated raster resolution (after reprojecting)
scale_x = (corner_pts[1].x() - corner_pts[0].x()) / (max_x - min_x)
scale_y = (corner_pts[2].y() - corner_pts[1].y()) / (max_y - min_y)
resolution_pixel_x *= scale_x
resolution_pixel_y *= scale_y
faces_list = self._get_faces_list(cells_number)
vertices_list = self._get_vertices_list(
corner_pts, dimension_x, dimension_y, resolution_pixel_x, resolution_pixel_y
)
# get bands data
renderer_type = target.renderer().type()
colors_list = self._get_colors_list(target, ds, renderer_type, cells_number)
return Mesh(
vertices=vertices_list,
faces=faces_list,
colors=colors_list,
units=self._conversion_settings.speckle_units,
)
def _get_faces_list(self, cells_number: int) -> List[int]:
# get faces
nested_faces_list = [
(4, 4 * i, 4 * i + 1, 4 * i + 2, 4 * i + 3) for i in range(cells_number)
]
faces_list = [item for sublist in nested_faces_list for item in sublist]
return faces_list
def _get_vertices_list(
self,
corner_pts: List[QgsPoint],
dimension_x: int,
dimension_y: int,
resolution_pixel_x: int,
resolution_pixel_y: int,
) -> List[float]:
cells_number = dimension_x * dimension_y
# get vertices
correction_pixel_x = (corner_pts[3].x() - corner_pts[0].x()) / dimension_x
correction_pixel_y = (corner_pts[1].y() - corner_pts[0].y()) / dimension_y
nested_vertices_list = [
(
# list of coordinates for 4 vertices of the face, counterclockwise
# i % dimension_x = current column index (x-dimension)
corner_pts[0].x()
+ (resolution_pixel_x + correction_pixel_x) * (i % dimension_x),
# math.floor(ind/sizeX) = current row index (y-dimension)
corner_pts[0].y()
+ (resolution_pixel_y + correction_pixel_y)
* math.floor(i / dimension_x),
0,
# vertex #2:
corner_pts[0].x()
+ (resolution_pixel_x + correction_pixel_x) * (i % dimension_x),
corner_pts[0].y()
+ (resolution_pixel_y + correction_pixel_y)
* math.floor(1 + i / dimension_x),
0,
# vertex #3:
corner_pts[0].x()
+ (resolution_pixel_x + correction_pixel_x) * (1 + i % dimension_x),
corner_pts[0].y()
+ (resolution_pixel_y + correction_pixel_y)
* math.floor(1 + i / dimension_x),
0,
# vertex #4:
corner_pts[0].x()
+ (resolution_pixel_x + correction_pixel_x) * (1 + i % dimension_x),
corner_pts[0].y()
+ (resolution_pixel_y + correction_pixel_y)
* math.floor(i / dimension_x),
0,
)
for i in range(cells_number)
]
vertices_list = [item for sublist in nested_vertices_list for item in sublist]
return vertices_list
def _get_colors_list(
self, target: QgsRasterLayer, ds, renderer_type: str, cells_number: int
) -> List[int]:
if renderer_type == "multibandcolor":
return self._get_colors_multiband_renderer(target, ds, cells_number)
if renderer_type == "paletted":
return self._get_colors_paletted_renderer(target, ds, cells_number)
if renderer_type == "singlebandpseudocolor":
return self._get_colors_singlebandpseudocolor_renderer(
target, ds, cells_number
)
# if other or 'singlebandgray'
return self._get_colors_singleband_renderer(target, ds, cells_number)
def _get_renderer_band_values(self, target, ds, band_index=None):
# if index not provided, use default renderer band
if band_index is None:
band_index = target.renderer().band()
if band_index < 1: # not a valid index
raise IndexError("Band index invalid")
rb = ds.GetRasterBand(band_index)
band_values = [
item for sublist in rb.ReadAsArray().tolist() for item in sublist
]
return rb, band_values
def _get_min_max_nodata_from_band(self, target: QgsRasterLayer, band_index: int):
if band_index < 1: # not a valid index
raise IndexError("Band index invalid")
band_min = (
target.dataProvider()
.bandStatistics(band_index, QgsRasterBandStats.All)
.minimumValue
)
band_max = (
target.dataProvider()
.bandStatistics(band_index, QgsRasterBandStats.All)
.maximumValue
)
vals_range = band_max - band_min
return band_min, band_max, vals_range
def _get_colors_singlebandpseudocolor_renderer(
self, target: QgsRasterLayer, ds, cells_number: int
) -> List[int]:
# get band values
rb, band_values = self._get_renderer_band_values(target, ds)
no_data_val = rb.GetNoDataValue()
# get renderer classes
renderer_classes = target.renderer().legendSymbologyItems()
class_rgbs = [
renderer_classes[class_ind][1].getRgb()
for class_ind in range(len(renderer_classes))
]
list_colors = []
for val in band_values:
for class_ind in range(len(renderer_classes)):
current_class = renderer_classes[class_ind]
if class_ind < len(renderer_classes) - 1: # if not last class
next_class = renderer_classes[class_ind + 1]
if val >= float(current_class[0]) and val < float(next_class[0]):
rgb = class_rgbs[class_ind]
color = (255 << 24) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
break
elif class_ind == len(renderer_classes) - 1: # last class
if val >= float(current_class[0]):
rgb = class_rgbs[class_ind]
color = (255 << 24) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
break
else: # if last class, but value still not found
color = (0 << 24) | (0 << 16) | (0 << 8) | 0
break
# assign black transparent color if no data
if val == no_data_val or val is None:
color = (0 << 24) | (0 << 16) | (0 << 8) | 0
# add color value after looping through classes/categories
list_colors.extend([color, color, color, color])
return list_colors
def _get_colors_paletted_renderer(
self, target: QgsRasterLayer, ds, cells_number: int
) -> List[int]:
# get band values
rb, band_values = self._get_renderer_band_values(target, ds)
no_data_val = rb.GetNoDataValue()
# get renderer classes
renderer_classes = target.renderer().classes()
class_rgbs = [
renderer_classes[class_ind].color.getRgb()
for class_ind in range(len(renderer_classes))
]
list_colors = []
# iterate through each value
for val in band_values:
# iterate through each renderer class
for class_ind in range(len(renderer_classes)):
current_class = renderer_classes[class_ind]
if class_ind < len(renderer_classes) - 1: # if not last class
next_class = renderer_classes[class_ind + 1]
if val >= float(current_class.value) and val < float(
next_class.value
):
rgb = class_rgbs[class_ind]
color = (255 << 24) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
break
elif class_ind == len(renderer_classes) - 1: # last class
if val >= float(current_class.value):
rgb = class_rgbs[class_ind]
color = (255 << 24) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
break
else: # if last class, but value still not found
color = (0 << 24) | (0 << 16) | (0 << 8) | 0
break
# assign black transparent color if no data
if val == no_data_val or val is None:
color = (0 << 24) | (0 << 16) | (0 << 8) | 0
list_colors.extend([color, color, color, color])
return list_colors
def _get_colors_multiband_renderer(
self, target: QgsRasterLayer, ds, cells_number: int
) -> List[int]:
band_count = target.bandCount()
# assign correct values to R,G,B channels, where available
for band_index in range(1, band_count + 1):
# note: raster stats can be messed up and are not reliable (e.g. Min is larger than Max)
band_min, band_max, _ = self._get_min_max_nodata_from_band(
target, band_index
)
rb, band_values = self._get_renderer_band_values(target, ds, band_index)
band_no_data = rb.GetNoDataValue()
# pre-populate the lists for each color
vals_red = [0 for _ in range(cells_number)]
vals_green = [0 for _ in range(cells_number)]
vals_blue = [0 for _ in range(cells_number)]
vals_alpha = None
val_min_red = val_min_green = val_min_blue = val_min_alpha = 0
vals_range_red = vals_range_green = vals_range_blue = vals_range_alpha = 0
val_na_red = val_na_green = val_na_blue = val_na_alpha = None
# if statements are not exclusive, as QGIS allows to assugn 1 band to several color channels
if band_index == int(target.renderer().redBand()):
vals_red = band_values
vals_range_red = band_max - band_min
val_min_red = band_min
val_na_red = band_no_data
if band_index == int(target.renderer().greenBand()):
vals_green = band_values
vals_range_green = band_max - band_min
val_min_green = band_min
val_na_green = band_no_data
if band_index == int(target.renderer().blueBand()):
vals_blue = band_values
vals_range_blue = band_max - band_min
val_min_blue = band_min
val_na_blue = band_no_data
if band_index == int(target.renderer().alphaBand()):
vals_alpha = band_values
vals_range_alpha = band_max - band_min
val_min_alpha = band_min
val_na_alpha = band_no_data
list_colors = [
(
(
(
255 << 24
if vals_alpha is None or vals_range_alpha == 0
else int(
255 * (vals_alpha[ind] - val_min_alpha) / vals_range_alpha
)
<< 24
)
| (
(
int(255 * (vals_red[ind] - val_min_red) / vals_range_red)
<< 16
)
if vals_range_red != 0
else int(vals_red[ind]) << 16
)
| (
int(255 * (vals_green[ind] - val_min_green) / vals_range_green)
<< 8
if vals_range_green != 0
else int(vals_green[ind]) << 8
)
| (
int(255 * (vals_blue[ind] - val_min_blue) / vals_range_blue)
if vals_range_blue != 0
else int(vals_blue[ind])
)
)
if (
vals_red[ind] != val_na_red
and vals_green[ind] != val_na_green
and vals_blue[ind] != val_na_blue
)
else (0 << 24) | (0 << 16) | (0 << 8) | 0
)
for ind in range(cells_number)
for _ in range(4)
]
return list_colors
def _get_colors_singleband_renderer(self, target, ds, cells_number) -> List[int]:
# get band values
band_index = 1
rb, band_values = self._get_renderer_band_values(target, ds, band_index)
no_data_band_val = rb.GetNoDataValue()
band_min, _, vals_range = self._get_min_max_nodata_from_band(target, band_index)
# get alpha band values
alpha_band_index = target.renderer().alphaBand()
if alpha_band_index >= 1: # valid index
alpha_min, _, alpha_range = self._get_min_max_nodata_from_band(
target, alpha_band_index
)
_, alpha_band_values = self._get_renderer_band_values(
target, ds, alpha_band_index
)
else:
alpha_range = None
alpha_band_values = None
list_colors: List[int] = [
(
(
255 << 24
if alpha_range is None or alpha_band_values is None
else int(255 * (alpha_band_values[ind] - alpha_min) / alpha_range)
<< 24
)
| (int(255 * (band_values[ind] - band_min) / vals_range) << 16)
| (int(255 * (band_values[ind] - band_min) / vals_range) << 8)
| int(255 * (band_values[ind] - band_min) / vals_range)
if band_values[ind] != no_data_band_val
else (0 << 24) | (0 << 16) | (0 << 8) | 0
)
for ind in range(cells_number)
for _ in range(4)
]
return list_colors
@@ -0,0 +1,72 @@
from typing import Any, Dict, List
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from speckle.host_apps.qgis.converters.to_speckle.helpers import (
DisplayValueExtractor,
PropertiesExtractor,
)
from speckle.sdk.converters_common.converters_common import IRootToSpeckleConverter
from speckle.sdk.converters_common.objects import IToSpeckleTopLevelConverter
from specklepy.objects.base import Base
from specklepy.objects.data_objects import QgisObject
from qgis.core import QgsFeature, QgsRasterLayer
class CoreObjectsBaseToSpeckleTopLevelConverter(
IToSpeckleTopLevelConverter, IRootToSpeckleConverter
):
_display_value_extractor: DisplayValueExtractor
_properties_extractor: PropertiesExtractor
_conversion_settings: QgisConversionSettings
def __init__(
self,
display_value_extractor,
properties_extractor,
conversion_settings,
):
self._display_value_extractor = display_value_extractor
self._properties_extractor = properties_extractor
self._conversion_settings = conversion_settings
def convert(self, target_dict: Dict[str, Any]) -> "QgisObject":
target: QgsFeature | QgsRasterLayer = target_dict["target"]
layer_app_id = target_dict["layer_application_id"]
object_type: str = type(target).__name__
# get displayValue
display: List[Base] = self._display_value_extractor.get_display_value(
target, layer_app_id
)
# get properties
properties: Dict[str, Any] = self._properties_extractor.get_properties(target)
# get applicationId
application_id = ""
if isinstance(target, QgsFeature):
application_id = get_speckle_app_id(target, layer_app_id)
elif isinstance(target, QgsRasterLayer):
application_id = get_speckle_app_id(target)
else:
raise NotImplementedError(
f"Conversion of objects of type '{object_type}' is not supported"
)
result: QgisObject = QgisObject(
name=object_type,
type=object_type,
displayValue=display,
properties=properties,
units=self._conversion_settings.speckle_units,
applicationId=application_id,
)
return result
@@ -0,0 +1,62 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict, Generic, TypeVar
from specklepy.objects.geometry import Point
from specklepy.objects.models.units import Units, get_scale_factor
from qgis.core import Qgis
T = TypeVar("T")
@dataclass
class CRSoffsetRotation:
crs: "QgsCoordinateReferenceSystem"
lat_offset: float
lon_offset: float
true_north_radians: float
def point_scale(self, point: Point, from_unit: str, to_unit: str) -> Point:
scale_factor = get_scale_factor(from_unit, to_unit)
return Point.from_coords(
x=point.x * scale_factor,
y=point.y * scale_factor,
z=point.z * scale_factor,
units=to_unit,
)
class IHostToSpeckleUnitConverter(ABC, Generic[T]):
@abstractmethod
def convert_or_throw(self, host_unit: T) -> Units:
raise NotImplementedError()
class QgisToSpeckleUnitConverter(IHostToSpeckleUnitConverter[Qgis.DistanceUnit]):
def s_unit_mapping(self) -> Dict[int, str]:
units_dict: Dict[int, str] = {
Qgis.DistanceUnit.DistanceMeters: Units.m,
Qgis.DistanceUnit.DistanceKilometers: Units.km,
Qgis.DistanceUnit.DistanceFeet: Units.feet,
# Qgis.DistanceUnit.DistanceNauticalMiles: ?,
Qgis.DistanceUnit.DistanceYards: Units.yards,
Qgis.DistanceUnit.DistanceMiles: Units.miles,
# Qgis.DistanceUnit.DistanceDegrees: ?,
Qgis.DistanceUnit.DistanceCentimeters: Units.cm,
Qgis.DistanceUnit.DistanceMillimeters: Units.mm,
Qgis.DistanceUnit.Inches: Units.inches,
# Qgis.DistanceUnit.DistanceUnknownUnit: Units.none,
}
return units_dict
def convert_or_throw(self, host_unit):
if host_unit == Qgis.DistanceUnit.DistanceDegrees:
return Units.m
try:
return self.s_unit_mapping()[host_unit]
except KeyError:
# TODO define exception type
raise Exception(f"The unit system {host_unit} is not supported")
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
from typing import Any, List
from plugin_utils.panel_logging import display_and_log
from speckle.host_apps.qgis.connectors.qgis_connector_module import (
QgisConnectorModule,
)
from speckle.host_apps.qgis.converters.qgis_converter_module import (
QgisConverterModule,
)
from speckle.sdk.connectors_common.operations import SendOperationResult
from speckle.ui.models import ModelCard, SendInfo
from speckle.ui.widgets.dockwidget_main import SpeckleQGISv3Dialog
import webbrowser
class SpeckleQGISv3Module:
"""Speckle Connector Plugin for QGIS"""
connector_module: QgisConnectorModule
converter_module: QgisConverterModule
def __init__(self, iface):
self.instantiate_module_dependencies(iface)
def create_dockwidget(self):
self.dockwidget = SpeckleQGISv3Dialog(
bridge=self, basic_binding=self.connector_module.basic_binding
)
self.dockwidget.header_widget = self.dockwidget.create_header(self)
self.dockwidget.runSetup()
self.connect_dockwidget_signals()
def instantiate_module_dependencies(self, iface):
self.converter_module = QgisConverterModule()
self.connector_module = QgisConnectorModule(bridge=self, iface=iface)
self.connect_connector_module_signals()
self.connect_converter_module_signals()
def connect_dockwidget_signals(self):
self.dockwidget.send_model_signal.connect(self._send_model)
self.dockwidget.add_model_signal.connect(self.add_model_card_to_store)
self.dockwidget.remove_model_signal.connect(self.remove_model_card_from_store)
self.dockwidget.cancel_operation_signal.connect(self._cancel_operation)
# moved here from "connect_connector_module_signals", because it's
# calling dockwidget and should only be accessed after dockwidget is created
self.connector_module.selection_binding.selection_changed_signal.connect(
self.dockwidget.handle_change_selection_info
)
self.connector_module.send_binding.commads.bridge_send_signal.connect(
self.dockwidget.add_send_notification
) # Send a UI notification after Send operation
# refresh widgets if document change signal received
self.connector_module.document_store.document_changed_signal.connect(
self.dockwidget.refresh_ui
)
# 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
)
# move operation to worker thread
self.connector_module.send_binding.send_operation_execute_signal.connect(
lambda model_card_id, obj, send_info, progress, ct: self.connector_module.thread_context.run_on_thread_async(
lambda: self._execute_send_operation(
model_card_id, obj, send_info, progress, ct
),
model_card_id,
False,
)
)
def _execute_send_operation(
self,
model_card_id: str,
objects: List[Any],
send_info: SendInfo,
on_operation_progressed: "IProgress[CardProgress]",
ct: "CancellationToken",
):
# 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.."
)
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):
# 1. cancel operations
# This will mark CalcellationTokenSource as Canceled. The actual operation will only be cancelled
# whenever "throw_if_cancellation_requested" is called
self.connector_module.send_binding.cancellation_manager.cancel_operation(
f"speckle_{model_card_id}"
)
# unnecessary, we are using our own CalcellationTokenSource instead of QgsTask
# might need to be revised later for more "native" implementation
r"""
print(QgsApplication.taskManager().tasks())
for task in QgsApplication.taskManager().tasks():
if task.description() == f"speckle_{model_card_id}":
task.cancel() # this will mark the task as Cancelled
"""
# 2. hide notification line
model_card_widget = self.dockwidget.widget_model_cards._find_card_widget(
model_card_id
)
model_card_widget.hide_notification_line()
def _create_send_modules(self, *args):
# create conversion settings
self.converter_module.create_and_save_conversion_settings(*args)
# create root object builder with conversion settings
self.connector_module.create_root_builder_send_operation(self.converter_module)
def connect_converter_module_signals(self):
return
def add_model_card_to_store(self, model_card: ModelCard):
self.connector_module.document_store.add_model(model_card=model_card)
def remove_model_card_from_store(self, model_card: ModelCard):
self.connector_module.document_store.remove_model(model_card=model_card)
def _send_model(self, model_card: ModelCard):
# receiving signal from UI and passing it to SendBinding
# this part of the operation will only get a model card, layers and conversion settings,
# and send a signal to execute Build and Send
# first, update UI status through the main thread
self.dockwidget.activity_start_signal.emit(
model_card.model_card_id, "Preparing to send.."
)
self.connector_module.send_binding.send(model_card_id=model_card.model_card_id)
def verify_dependencies(self):
import urllib3
import requests
# if the standard QGIS libraries are used
if (urllib3.__version__ == "1.25.11" and requests.__version__ == "2.24.0") or (
urllib3.__version__.startswith("1.24.")
and requests.__version__.startswith("2.23.")
):
display_and_log(
"Dependencies versioning error.", # \nClick here for details.",
url="dependencies_error",
level=2,
dockwidget=self.dockwidget,
)
raise ImportError(
f"Incompatible versions of dependencies: 'urllib3=={urllib3.__version__}' and 'requests=={requests.__version__}'"
)
def reloadUI(self):
return
def openUrl(self, url: str = ""):
if url is not None and url != "":
webbrowser.open(url, new=0, autoraise=True)
View File
+38
View File
@@ -0,0 +1,38 @@
from abc import ABC, abstractmethod
from typing import Any, Optional
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from specklepy.core.api import operations
from specklepy.objects.base import Base
from specklepy.transports.server.server import ServerTransport
class IClientFactory(ABC):
@abstractmethod
def create(self, account: Account) -> SpeckleClient:
raise NotImplementedError()
class IOperations(ABC):
@abstractmethod
def send(
self,
url: str,
project_id: str,
auth_token: Optional[str],
value: Base,
on_progress_action: Any = None,
cancellation_token: Any = None,
):
raise NotImplementedError()
# not in C#
class ClientFactory(IClientFactory):
def create(slef, account) -> SpeckleClient:
speckle_client = SpeckleClient(
account.serverInfo.url, account.serverInfo.url.startswith("https")
)
speckle_client.authenticate_with_account(account)
return speckle_client
+24
View File
@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from speckle.sdk.connectors_common.cancellation import CancellationToken
from speckle.sdk.connectors_common.conversion import SendConversionResult
from specklepy.objects.base import Base
from typing import Any, List
@dataclass
class RootObjectBuilderResult:
root_object: Base
conversion_results: List[SendConversionResult]
class IRootObjectBuilder(ABC):
@abstractmethod
def build(
objects: List[Any],
send_info: str,
on_operation_progressed: "IProgress[CardProgress]",
ct: CancellationToken,
) -> RootObjectBuilderResult:
"""Placeholder for connector to define."""
raise NotImplementedError()
@@ -0,0 +1,70 @@
from typing import Dict
class CancellationToken:
_source: "CancellationTokenSource"
def __init__(self, source: "CancellationTokenSource"):
self._source = source
@property
def is_cancellation_requested(self) -> bool:
return self._source.is_cancellation_requested
def throw_if_cancellation_requested(self):
if self.is_cancellation_requested:
raise Exception("Operation was cancelled.")
class CancellationTokenSource:
token: CancellationToken
is_cancellation_requested: bool = False
def __init__(self):
self.token = CancellationToken(self)
def cancel(self):
self.is_cancellation_requested = True
def dispose(self):
pass
class CancellationManager:
_operations_in_progress: Dict[str, CancellationTokenSource]
number_of_operations: int
def __init__(self):
self._operations_in_progress = {}
self.number_of_operations = len(self._operations_in_progress)
def get_token(self, id: str) -> CancellationToken:
return self._operations_in_progress[id].token
def is_exist(self, id: str) -> bool:
return True if self._operations_in_progress.get(id) else False
def cancel_all_operations(self):
for operation_value in self._operations_in_progress.values():
operation_value.cancel()
operation_value.dispose()
self._operations_in_progress.clear()
def init_cancellation_token_source(self, id: str) -> CancellationToken:
if self.is_exist(id):
self.cancel_operation(id)
cts = CancellationTokenSource()
self._operations_in_progress[id] = cts
return cts.token
def cancel_operation(self, id: str):
cts = self._operations_in_progress.get(id)
if cts:
cts.cancel()
cts.dispose()
self._operations_in_progress.pop(id)
def is_cancellation_requested(self, id: str) -> bool:
return self._operations_in_progress[id].is_cancellation_requested
@@ -0,0 +1,60 @@
from abc import ABC
from dataclasses import dataclass
import traceback
from specklepy.objects.base import Base
from typing import Optional
@dataclass
class ErrorWrapper:
message: str
stack_trace: str
class ConversionResult(ABC):
status: "Status"
source_id: str
source_type: str
result_id: Optional[str]
result_type: Optional[str]
error: Optional["ErrorWrapper"]
def __init__(
self, *, status, source_id, source_type, result_id, result_type, error
):
self.status = status
self.source_id = source_id
self.source_type = source_type
self.result_id = result_id
self.result_type = result_type
self.error = error
@staticmethod
def format_error(exception: Optional[Exception] = None) -> ErrorWrapper | None:
if exception is None:
return None
return ErrorWrapper(
message=str(exception),
stack_trace=f"{exception}\n{traceback.format_exc()}",
)
class SendConversionResult(ConversionResult):
def __init__(
self,
*,
status: str,
source_id: str,
source_type: str,
result: Optional[Base] = None,
exception: Optional[Exception] = None,
):
super().__init__(
status=status,
source_id=source_id,
source_type=source_type,
result_id=result.id if result is not None else None,
result_type=result.speckle_type if result is not None else None,
error=self.format_error(exception),
)
@@ -0,0 +1,156 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from specklepy.core.api.credentials import Account, UserInfo, get_local_accounts
class IAccountManager(ABC):
@abstractmethod
def get_server_info(
self, server: str, cancellation_token: "CancellationToken" = None
):
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_user_info(
self, token: str, server: str, cancellation_token: "CancellationToken" = None
) -> UserInfo:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_default_server_url(self) -> str:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_account(self, id: str) -> Account:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def upgrade_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_accounts(self, server_url: Optional[str] = None) -> List[Account]:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_default_account(self) -> Optional[Account]:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def update_accounts(self, ct: "CancellationToken" = None) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def remove_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def change_default_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_local_identifier_for_account(self, account: Account) -> str | None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def validate(self, account: Account) -> UserInfo:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def get_accounts_for_local_identifier(
self, local_identifier: str
) -> Account | None:
"""Placeholder for connector to define."""
raise NotImplementedError
@abstractmethod
def add_account(self, server: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
# not in C#, but needs to be instantiated for use in connector
class AccountManager(IAccountManager):
def get_server_info(
self, server: str, cancellation_token: "CancellationToken" = None
):
"""Placeholder for connector to define."""
raise NotImplementedError
def get_user_info(
self, token: str, server: str, cancellation_token: "CancellationToken" = None
) -> UserInfo:
"""Placeholder for connector to define."""
raise NotImplementedError
def get_default_server_url(self) -> str:
"""Placeholder for connector to define."""
raise NotImplementedError
def get_account(self, id: str) -> Account:
accounts: List[Account] = get_local_accounts()
for acc in accounts:
if acc.id == id:
return acc
raise Exception(f"Account with id '{id}' not found")
def upgrade_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
def get_accounts(self, server_url: Optional[str] = None) -> List[Account]:
accounts: List[Account] = get_local_accounts()
if len(accounts) == 0:
# TODO a warning here
return []
return accounts
def get_default_account(self) -> Optional[Account]:
"""Placeholder for connector to define."""
raise NotImplementedError
def update_accounts(self, ct: "CancellationToken" = None) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
def remove_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
def change_default_account(self, id: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
def get_local_identifier_for_account(self, account: Account) -> str | None:
"""Placeholder for connector to define."""
raise NotImplementedError
def validate(self, account: Account) -> UserInfo:
"""Placeholder for connector to define."""
raise NotImplementedError
def get_accounts_for_local_identifier(
self, local_identifier: str
) -> Account | None:
"""Placeholder for connector to define."""
raise NotImplementedError
def add_account(self, server: str) -> None:
"""Placeholder for connector to define."""
raise NotImplementedError
+175
View File
@@ -0,0 +1,175 @@
from dataclasses import dataclass
from typing import Any, Dict, List
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,
RootObjectBuilderResult,
)
from speckle.sdk.connectors_common.cancellation import CancellationToken
from speckle.sdk.connectors_common.credentials import IAccountManager
from speckle.sdk.utils import get_project_workspace_id
from speckle.ui.models import SendInfo
from specklepy.core.api import operations
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.logging import metrics
from specklepy.objects.base import Base
from specklepy.transports.server.server import ServerTransport
class AccountService:
account_manager: IAccountManager
def __init__(self, account_manager: IAccountManager):
self.account_manager = account_manager
def get_account_with_server_url_fallback(self, account_id: str, server_url: str):
try:
return self.account_manager.get_account(account_id)
except:
# TODO define exception type
accounts: List[Account] = self.account_manager.get_accounts(server_url)
try:
return accounts[0]
except IndexError:
# TODO define exception type
raise Exception(
message=f"No any account found that matches with server {server_url}"
)
@dataclass
class SendOperationResult:
root_obj_id: str
converted_references: Dict[str, str] # ["Id", "ObjectReference"]
class SendOperation:
root_object_builder: IRootObjectBuilder
send_conversion_cache: "ISendConversionCache"
account_service: AccountService
send_progress: "ISendProgress"
operations: IOperations
client_factory: IClientFactory
activity_factory: "IActivityFactory"
def __init__(
self,
root_object_builder: IRootObjectBuilder,
send_conversion_cache: "ISendConversionCache",
account_service: AccountService,
send_progress: "ISendProgress",
operations: IOperations,
client_factory: IClientFactory,
activity_factory: "IActivityFactory",
):
self.root_object_builder = root_object_builder
self.send_conversion_cache = send_conversion_cache
self.account_service = account_service
self.send_progress = send_progress
self.operations = operations
self.client_factory = client_factory
self.activity_factory = activity_factory
def execute(
self,
objects: List[Any],
send_info: SendInfo,
on_operation_progressed: "IProgress[CardProgress]",
ct: CancellationToken,
) -> SendOperationResult:
ct.throw_if_cancellation_requested()
build_result: RootObjectBuilderResult = self.root_object_builder.build(
objects, send_info, on_operation_progressed, ct
)
build_result.root_object["version"] = 3
ct.throw_if_cancellation_requested()
obj_id_and_converted_refs = self.send(
build_result.root_object, send_info, on_operation_progressed, ct
)
return SendOperationResult(
obj_id_and_converted_refs[0],
build_result.conversion_results,
)
def send(
self,
commit_object: Base,
send_info: SendInfo,
on_operation_progressed: "IProgress[CardProgress]" = None,
ct: "CancellationToken" = None,
):
ct.throw_if_cancellation_requested()
# on_operation_progressed.report(CardProgress(status="Uploading...",progress=None))
account: Account = self.account_service.get_account_with_server_url_fallback(
account_id=send_info.account_id, server_url=send_info.server_url
)
r"""
obj_id_and_converted_refs = self.operations.send(
send_info.server_url,
send_info.project_id,
account.token,
commit_object,
on_operation_progressed,
ct,
)
"""
client = self.client_factory.create(account=account)
transport = ServerTransport(
client=client,
account=account,
stream_id=send_info.project_id,
)
metrics.track(
metrics.SEND,
account,
{
"hostAppFullVersion": HOST_APP_FULL_VERSION,
"core_version": CORE_VERSION,
"ui": "dui3",
"workspace_id": get_project_workspace_id(client, send_info.project_id),
},
)
obj_id = operations.send(base=commit_object, transports=[transport])
# store cache
# ct.ThrowIfCancellationRequested()
# on_operation_progressed.report(CardProgress(status="Linking version to model...",progress=None))
# create a version in the project
api_client: SpeckleClient = self.client_factory.create(account)
ct.throw_if_cancellation_requested()
_ = api_client.version.create(
CreateVersionInput(
object_id=obj_id,
model_id=send_info.model_id,
project_id=send_info.project_id,
message="Sent from QGIS v3",
source_application=send_info.host_application,
)
)
return (obj_id, {})
@dataclass
class ProxyKeys:
COLOR = "colorProxies"
RENDER_MATERIAL = "renderMaterialProxies"
INSTANCE_DEFINITION = "instanceDefinitionProxies"
GROUP = "groupProxies"
PARAMETER_DEFINITIONS = "parameterDefinitions"
PROPERTYSET_DEFINITIONS = "propertySetDefinitions"
@@ -0,0 +1,85 @@
from abc import ABC, abstractmethod
import threading
from typing import Callable
from qgis.core import QgsTask, QgsApplication
from PyQt5.QtCore import QObject
class QgisSpeckleTask(QgsTask):
action: Callable
def __init__(
self,
thread_context: "ThreadContext",
action: Callable,
model_card_id: str,
):
super().__init__(f"speckle_{model_card_id}", QgsTask.CanCancel)
self.exception = None
self.action = action
self.thread_context = thread_context
def run(self):
try:
self.action()
except Exception as e:
# ignore unhandled or cancellation exceptions
pass
return True
def finished(self, result):
return
class MetaQObject(type(QObject), type(ABC)):
# 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 ThreadContext(ABC, QObject, metaclass=MetaQObject):
@staticmethod
def is_main_thread() -> bool:
if threading.current_thread() is threading.main_thread():
return True
return False
def run_on_thread_async(
self, action: Callable, model_card_id: str, use_main: bool = False
):
if use_main:
if self.is_main_thread():
return action()
else:
# we don't send operations to main thread for now
raise NotImplementedError()
else:
if self.is_main_thread():
# QgsApplication.taskManager().cancelAll()
task = QgisSpeckleTask(
self,
action=action,
model_card_id=model_card_id,
)
task.taskTerminated.connect(lambda: self.task_terminated(task))
QgsApplication.taskManager().addTask(task)
else:
return action()
def task_terminated(self, task: QgsTask):
# need to find a way to end Task without it emmiting failed signal to Qgis UI
pass
@abstractmethod
def worker_to_main_async(self, action: Callable):
raise NotImplementedError()
@abstractmethod
def main_to_worker_async(self, action: Callable):
raise NotImplementedError()
@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
from specklepy.objects.base import Base
class IRootToSpeckleConverter(ABC):
@abstractmethod
def convert(self, target: object) -> Base:
raise NotImplementedError()
+9
View File
@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
from specklepy.objects.base import Base
class IToSpeckleTopLevelConverter(ABC):
@abstractmethod
def convert(target: object) -> Base:
raise NotImplementedError()
View File
+20
View File
@@ -0,0 +1,20 @@
from typing import Optional
from specklepy.core.api.client import SpeckleClient
def get_project_workspace_id(client: SpeckleClient, project_id: str) -> Optional[str]:
workspace_id = None
server_version = client.project.server_version or client.server.version()
# Local yarn builds of server will report a server version of "dev"
# We'll assume that local builds are up-to-date with the latest features
if server_version[0] == "dev":
maj = 999
min = 999
else:
maj = server_version[0]
min = server_version[1]
if maj > 2 or (maj == 2 and min > 20):
workspace_id = client.project.get(project_id).workspace_id
return workspace_id
View File
+218
View File
@@ -0,0 +1,218 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any, Callable, Dict, List, Optional
from speckle.sdk.connectors_common.operations import SendOperationResult
from speckle.ui.models import (
DocumentInfo,
DocumentModelStore,
ICardSetting,
ISendFilter,
ModelCard,
)
from PyQt5.QtCore import pyqtSignal, QObject
class IBinding(ABC):
name: str
parent: "IBrowserBridge" # type not declared yet
class IBrowserBridge(ABC):
frontend_bound_name: str
@abstractmethod
def associate_with_bindings(self, bindings: IBinding) -> None:
raise NotImplementedError()
@abstractmethod
def get_bindings_method_names(self) -> List[str]:
raise NotImplementedError()
@abstractmethod
def run_methods(self, method_name: str, request_id: str, args: str) -> None:
raise NotImplementedError()
@abstractmethod
def run_on_main_thread_async(self, action: Callable) -> None:
raise NotImplementedError()
@abstractmethod
def send(event_name: str, cancellation_token: Any, **kwargs) -> None:
raise NotImplementedError()
@abstractmethod
def top_level_exception_handler(self) -> "ITopLevelExceptionHandler":
raise NotImplementedError()
class BasicConnectorBindingCommands:
NOTIFY_DOCUMENT_CHANGED_EVENT_NAME: str = "documentChanged"
SET_MODEL_ERROR_UI_COMMAND_NAME: str = "setModelError"
SET_GLOBAL_NOTIFICATION: str = "setGlobalNotification"
bridge: IBrowserBridge
def __init__(self, bridge: IBrowserBridge = None):
self.bridge = bridge
def basic_connector_bindings_commands(self, bridge: IBrowserBridge):
self.bridge = bridge
def notify_document_changed(self):
# TODO send event to Bridge (IBrowserBridge)
return
def set_global_notification(
self, type: Any, title: str, message: str, autoClose: bool = True
):
# TODO send notification through Bridge
return
def set_model_error(self, model_card_id: str, error: Exception):
# TODO send error through Bridge
return
class IBasicConnectorBinding(IBinding):
commands: BasicConnectorBindingCommands
@abstractmethod
def get_source_app_name() -> str:
raise NotImplementedError()
@abstractmethod
def get_source_app_version() -> str:
raise NotImplementedError()
@abstractmethod
def get_connector_version() -> str:
raise NotImplementedError()
@abstractmethod
def get_document_info() -> Optional[DocumentInfo]:
raise NotImplementedError()
@abstractmethod
def get_document_state() -> DocumentModelStore:
raise NotImplementedError()
@abstractmethod
def add_model(model_card: ModelCard) -> None:
return
@abstractmethod
def update_model(model_card: ModelCard) -> None:
return
@abstractmethod
def remove_model(model_card: ModelCard) -> None:
return
@abstractmethod
def highlight_model(model_card_id: str) -> None:
return
@abstractmethod
def highlight_objects(ids: str) -> None:
return
# not a data class, so the variables can be accessed directly
class BasicConnectorBindingEvents:
DISPLAY_TOAST_NOTIFICATION: str = "DisplayToastNotification"
DOCUMENT_CHANGED: str = "documentChanged"
class ToastNotificationType(Enum):
SUCCESS = auto()
WARNING = auto()
DANGER = auto()
INFO = auto()
class SendBindingUICommands(BasicConnectorBindingCommands, QObject):
REFRESH_SEND_FILTERS_UI_COMMAND_NAME: str = "refreshSendFilters"
SET_MODELS_EXPIRED_UI_COMMAND_NAME: str = "setModelsExpired"
SET_MODEL_SEND_RESULT_UI_COMMAND_NAME: str = "setModelSendResult"
SET_ID_MAP_COMMAND_NAME: str = "setIdMap"
bridge_send_signal = pyqtSignal(str, str, str, list)
def __init__(self, bridge: IBrowserBridge):
# initialize parent classes separately
BasicConnectorBindingCommands.__init__(self, bridge=bridge)
QObject.__init__(self)
def refresh_send_filter(self) -> None:
# TODO
# bridge.send(REFRESH_SEND_FILTERS_UI_COMMAND_NAME)
raise NotImplementedError()
def set_models_expired(self, expired_models_ids: List[str]) -> None:
# TODO
# bridge.send(SET_MODELS_EXPIRED_UI_COMMAND_NAME, expired_models_ids)
raise NotImplementedError()
def set_filter_object_ids(
self,
model_card_id: str,
id_map: Dict[str, str],
new_selected_object_ids: List[str],
):
# TODO
# bridge.send(SET_ID_MAP_COMMAND_NAME, model_card_id, id_map, new_selected_object_ids)
raise NotImplementedError()
def set_model_send_result(
self,
model_card_id: str,
version_id: str,
send_conversion_results: List[SendOperationResult],
) -> None:
# need to pass the operation back to main thread, therefore, emitting a signal instead of directly calling a command
self.bridge_send_signal.emit(
self.SET_MODEL_SEND_RESULT_UI_COMMAND_NAME,
model_card_id,
version_id,
send_conversion_results,
)
class ISendBinding(IBinding, ABC):
commads: SendBindingUICommands
@abstractmethod
def get_send_filters(self) -> List[ISendFilter]:
raise NotImplementedError()
@abstractmethod
def get_send_settings() -> List[ICardSetting]:
raise NotImplementedError()
@abstractmethod
def send(self, model_card_id: str) -> None:
raise NotImplementedError()
@abstractmethod
def cancel_send(self, model_card_id: str) -> None:
raise NotImplementedError()
@dataclass
class SelectionInfo:
selected_object_ids: List[str]
summary: str
class ISelectionBinding(IBinding):
@abstractmethod
def get_selection(self) -> SelectionInfo:
raise NotImplementedError()
class SelectionBindingEvents:
SET_SELECTION = "setSelection"
+178
View File
@@ -0,0 +1,178 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
@dataclass
class SendInfo:
# TODO
account_id: str
server_url: str
project_id: str
model_id: str
host_application: str
class ISendFilter(ABC):
id: str
name: str
summary: Optional[str]
is_default: bool
selected_object_ids: List[str]
id_map: Optional[Dict[str, str]]
@abstractmethod
def refresh_object_ids(self) -> List[str]:
raise NotImplementedError()
class ICardSetting(ABC):
id: Optional[str]
title: Optional[str]
type: Optional[str]
value: Any
enum: Optional[List[str]]
class CardSetting(ICardSetting):
# TODO
id: Optional[str]
class ModelCard(ABC):
model_card_id: Optional[str] = None
model_id: Optional[str] = None
project_id: Optional[str] = None
workspace_id: Optional[str] = None
account_id: Optional[str] = None
server_url: Optional[str] = None
settings: Optional[List[CardSetting]] = None
class SenderModelCard(ModelCard):
send_filter: Optional[ISendFilter] = None
def __init__(
self,
model_card_id=None,
model_id=None,
project_id=None,
workspace_id=None,
account_id=None,
server_url=None,
settings=None,
send_filter=None,
):
self.model_card_id = model_card_id
self.model_id = model_id
self.project_id = project_id
self.workspace_id = workspace_id
self.account_id = account_id
self.server_url = server_url
self.settings = settings
self.send_filter = send_filter
def get_send_info(self, host_application: str) -> Optional[SendInfo]:
return SendInfo(
self.account_id,
self.server_url,
self.project_id,
self.model_id,
host_application,
)
@dataclass
class DocumentInfo:
location: str
name: str
id: str
class DocumentModelStore(ABC):
models: List[ModelCard]
is_document_init: bool
def __init__(self, saved_model_cards: str):
self.models = []
self.is_document_init = False
def document_changed(self):
"""Placeholder for connector to define."""
return
def get_model_by_id(self, id: str) -> ModelCard:
try:
return next(x for x in self.models if x.model_card_id == id)
except StopIteration:
raise Exception("Model card not found.")
def add_model(self, model_card: ModelCard) -> None:
print(f"Adding model: {model_card.model_card_id}")
self.models.append(model_card)
self.save_state()
def clear_and_save(self) -> None:
self.models.clear()
self.save_state()
def update_model(self, model_card: ModelCard) -> None:
try:
index: int = next(
i
for i, x in enumerate(self.models)
if x.model_card_id == model_card.model_card_id
)
self.models[index] = model_card
self.save_state()
except StopIteration:
raise Exception("Model card not found to update.")
def remove_model(self, model_card: ModelCard) -> None:
try:
index: int = next(
i
for i, x in enumerate(self.models)
if x.model_card_id == model_card.model_card_id
)
self.models.pop(index)
self.save_state()
except StopIteration:
raise Exception("Model card not found to update.")
def on_document_changed(self) -> None:
self.document_changed()
return
def get_senders(self) -> List[SenderModelCard]:
return [x for x in self.models if isinstance(x, SenderModelCard)]
def serialize(self) -> str:
# TODO
return
def deserialize(self, models: str) -> List[ModelCard]:
# TODO
return
def save_state(self) -> None:
state = self.serialize()
self.host_app_save_state(state)
return
@abstractmethod
def host_app_save_state(self, state: str) -> None:
return
@abstractmethod
def load_state(self) -> None:
return
def load_from_string(self, models: Optional[str]) -> None:
self.models.clear()
if not models:
return
self.models.extend(self.deserialize(models))
View File
@@ -0,0 +1,58 @@
from typing import Any, List, Tuple
from speckle.ui.models import ModelCard
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from speckle.ui.utils.utils import (
# clear_models_cursor,
# clear_projects_cursor,
get_model_by_id_from_client,
get_project_by_id_from_client,
get_accounts,
get_authenticate_client_for_account,
)
from PyQt5.QtCore import QObject
class UiModelCardsUtils(QObject):
def __init__(self):
super().__init__()
def get_client_from_model_card(self, model_card: ModelCard) -> SpeckleClient:
account = None
accounts: List[Account] = get_accounts()
for acc in accounts:
if acc.id == model_card.account_id:
account = acc
break
if account is None:
# TODO
return
speckle_client: SpeckleClient = get_authenticate_client_for_account(account)
return speckle_client
def get_project_by_id_from_client(self, model_card: ModelCard) -> List[List]:
project_id: str = model_card.project_id
speckle_client: SpeckleClient = self.get_client_from_model_card(model_card)
if speckle_client is None:
return
return get_project_by_id_from_client(
speckle_client=speckle_client, project_id=project_id
)
def get_model_by_id_from_client(self, model_card: ModelCard) -> List[List]:
project_id: str = model_card.project_id
speckle_client: SpeckleClient = self.get_client_from_model_card(model_card)
if speckle_client is None:
return
return get_model_by_id_from_client(
speckle_client=speckle_client,
project_id=project_id,
model_id=model_card.model_id,
)
+237
View File
@@ -0,0 +1,237 @@
from functools import partial
from typing import Any, List, Optional
from speckle.ui.models import SenderModelCard
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from specklepy.core.api.models.current import (
Model,
Project,
ResourceCollection,
)
from specklepy.core.api.resources.current.project_resource import ProjectResource
from speckle.ui.utils.utils import (
create_new_project_query,
create_new_model_query,
get_accounts,
get_authenticate_client_for_account,
get_models_from_client,
get_projects_from_client,
time_ago,
QUERY_BATCH_SIZE,
)
from PyQt5.QtCore import QObject, pyqtSignal
from specklepy.logging.exceptions import SpeckleException
class UiSearchUtils(QObject):
cursor_projects: Any = None
cursor_models: Any = None
speckle_client: SpeckleClient = None
batch_size: int = None
add_selection_filter_signal = pyqtSignal(SenderModelCard)
add_models_search_signal = pyqtSignal(Project)
select_account_signal = pyqtSignal()
new_project_widget_signal = pyqtSignal()
new_model_widget_signal = pyqtSignal(str)
change_account_and_projects_signal = pyqtSignal()
refresh_models_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"
)
content_list = [
[
partial(self._replace_projects_list_with_new_account, acc),
acc.serverInfo.name,
acc.serverInfo.url,
]
for acc in accounts
]
return content_list
def _replace_projects_list_with_new_account(self, account: Account):
self.speckle_client: SpeckleClient = get_authenticate_client_for_account(
account
)
self.change_account_and_projects_signal.emit()
def get_account_initials(self):
name = self.speckle_client.account.userInfo.name
if isinstance(name, str) and len(name) > 0:
return name[0]
return "X"
def create_new_project(self, name: str, workspace_id: Optional[str] = None):
create_new_project_query(self.speckle_client, name, workspace_id)
def create_new_model(self, project_id: str, model_name: str):
create_new_model_query(self.speckle_client, project_id, model_name)
def get_new_projects_content(self, clear_cursor=False):
if clear_cursor:
self.cursor_projects = None
content_list: List[List] = []
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client, cursor=self.cursor_projects
)
)
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
projects_resource_collection
)
)
return content_list
def get_new_projects_content_with_name_condition(self, name_filter: str):
self.cursor_projects = None
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client,
cursor=self.cursor_projects,
filter_keyword=name_filter,
)
)
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
projects_resource_collection
)
)
return content_list
def _create_project_content_list_from_resource_collection(
self, projects_resource_collection: ResourceCollection[Project]
):
projects_batch: List[Project] = projects_resource_collection.items
content_list: List[List] = []
for project in projects_batch:
# 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.updated_at)}",
]
content_list.append(project_content)
return content_list
def _emit_function_add_models_signal(self, project: Project):
# emitting the signal that will trigger creation of ModelSearch widget,
# using the ModelCard content generated from the passed function
self.add_models_search_signal.emit(project)
def get_new_models_content(
self, project: Project, clear_cursor=False
) -> List[List]:
if clear_cursor:
self.cursor_models = None
models_resource_collection: ResourceCollection[Model] = get_models_from_client(
self.speckle_client, project, self.cursor_models
)
self.cursor_models = models_resource_collection.cursor
content_list: List[List] = (
self._create_model_content_list_from_resource_collection(
models_resource_collection, project
)
)
return content_list
def get_new_models_content_with_name_condition(
self, project: Project, name_filter: str
) -> List[List]:
self.cursor_models = None
models_resource_collection: ResourceCollection[Model] = get_models_from_client(
speckle_client=self.speckle_client,
project=project,
cursor=self.cursor_models,
filter_keyword=name_filter,
)
self.cursor_models = models_resource_collection.cursor
content_list: List[List] = (
self._create_model_content_list_from_resource_collection(
models_resource_collection, project
)
)
return content_list
def _create_model_content_list_from_resource_collection(
self, models_resource_collection: ResourceCollection[Model], project: Project
):
models_first: List[Model] = models_resource_collection.items
content_list: List[List] = []
for model in models_first:
# if a receive workflow: get_version_search_widget_content(...)
model_content = [
partial(self.add_selection_filter_widget, project, model),
model.name,
f"updated {time_ago(model.updated_at)}",
project,
]
content_list.append(model_content)
return content_list
def add_selection_filter_widget(self, project: Project, model: Model):
# leave "search widgets" area and send signal to the main dockwidget
# dockwidget will kill the search widgets and display a modelCards widget
server_url = self.speckle_client.account.serverInfo.url
self.add_selection_filter_signal.emit(
SenderModelCard(
model_card_id=f"Send_{server_url}_{project.id}_{model.id}",
model_id=model.id,
project_id=project.id,
workspace_id=None,
account_id=self.speckle_client.account.id,
server_url=server_url,
settings=None,
send_filter=None,
)
)
def get_version_search_widget_content(self, project: ProjectResource) -> List[List]:
"""Add search cards for models (only valid for Receive workflow)."""
raise NotImplementedError("Receive workflow is not implemented")
+227
View File
@@ -0,0 +1,227 @@
from datetime import datetime, timezone
from typing import List, Optional
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import (
Account,
get_local_accounts,
)
from specklepy.core.api.inputs.model_inputs import CreateModelInput
from specklepy.core.api.inputs.project_inputs import (
ProjectCreateInput,
ProjectModelsFilter,
)
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from specklepy.core.api.models.current import (
Model,
Project,
ProjectWithModels,
ResourceCollection,
)
QUERY_BATCH_SIZE = 10
def get_accounts() -> List[Account]:
"""Get all local user accounts and return the reordered list,
where the default account is first."""
accounts: List[Account] = get_local_accounts()
if len(accounts) == 0:
# TODO a warning here
return []
sorted_accounts = accounts.copy()
for account in accounts:
if account.isDefault:
sorted_accounts.remove(account)
sorted_accounts.insert(0, account)
break
return sorted_accounts
def get_authenticate_client_for_account(account: Account) -> SpeckleClient:
speckle_client = SpeckleClient(
account.serverInfo.url, account.serverInfo.url.startswith("https")
)
speckle_client.authenticate_with_account(account)
return speckle_client
def get_projects_from_client(
speckle_client: SpeckleClient, cursor=None, filter_keyword: Optional[str] = None
) -> ResourceCollection[Project]:
results = []
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
),
)
if not isinstance(results, ResourceCollection):
# TODO: handle
pass
else:
# TODO add a warning
pass
return results
def get_models_from_client(
speckle_client: SpeckleClient,
project: Project,
cursor=None,
filter_keyword: Optional[str] = None,
) -> ResourceCollection[Model]:
results = []
if speckle_client is not None:
# possible GraphQLException
results: ProjectWithModels = speckle_client.project.get_with_models(
project_id=project.id,
models_limit=100 if filter_keyword else QUERY_BATCH_SIZE,
models_cursor=cursor,
models_filter=(
ProjectModelsFilter(search=filter_keyword) if filter_keyword else None
),
).models
if not isinstance(results, ResourceCollection):
# TODO: handle
pass
else:
# TODO add a warning
pass
return results
def get_project_by_id_from_client(
speckle_client: SpeckleClient, project_id: str
) -> Project:
result = None
if speckle_client is not None:
# possible GraphQLException
result: Project = speckle_client.project.get(project_id=project_id)
if not isinstance(result, Project):
# TODO: handle
pass
else:
# TODO add a warning
pass
return result
def create_new_project_query(
speckle_client: SpeckleClient, project_name: str, workspace_id: str
) -> Project:
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 not isinstance(result, Project):
# TODO: handle
pass
else:
# TODO add a warning
pass
return result
def create_new_model_query(
speckle_client: SpeckleClient, project_id: str, model_name: str
) -> Project:
result = None
if speckle_client is not None:
# possible GraphQLException
result: Project = speckle_client.model.create(
input=CreateModelInput(
name=model_name, description=None, project_id=project_id
)
)
if not isinstance(result, Project):
# TODO: handle
pass
else:
# TODO add a warning
pass
return result
def get_model_by_id_from_client(
speckle_client: SpeckleClient, project_id: str, model_id: str
) -> Model:
result = None
if speckle_client is not None:
# possible GraphQLException
result: Model = speckle_client.model.get(
project_id=project_id, model_id=model_id
)
if not isinstance(result, Model):
# TODO: handle
pass
else:
# TODO add a warning
pass
return result
# def clear_models_cursor():
# # TODO: clear cursor from ContextStack
# pass
def time_ago(timestamp: datetime) -> str:
now = datetime.now(timezone.utc)
diff = now - timestamp
seconds = diff.total_seconds()
minutes = seconds / 60
hours = minutes / 60
days = hours / 24
weeks = days / 7
months = days / 30
years = days / 365
if seconds < 60:
return f"{int(seconds)} second{'s' if int(seconds) != 1 else ''} ago"
elif minutes < 60:
return f"{int(minutes)} minute{'s' if int(minutes) != 1 else ''} ago"
elif hours < 24:
return f"{int(hours)} hour{'s' if int(hours) != 1 else ''} ago"
elif days < 7:
return f"{int(days)} day{'s' if int(days) != 1 else ''} ago"
elif weeks < 4:
return f"{int(weeks)} week{'s' if int(weeks) != 1 else ''} ago"
elif months < 12:
return f"{int(months)} month{'s' if int(months) != 1 else ''} ago"
else:
return f"{int(years)} year{'s' if int(years) != 1 else ''} ago"
+51
View File
@@ -0,0 +1,51 @@
from PyQt5 import QtCore
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR_DARK_GREY_SEMI,
BACKGR_COLOR_TRANSPARENT,
)
class BackgroundWidget(QWidget):
ignore_close_on_click = False
remove_current_widget_signal = pyqtSignal(QWidget)
def __init__(
self,
parent=None,
transparent=False,
background_color=None,
ignore_close_on_click=False,
):
super(BackgroundWidget, self).__init__(parent)
self.parent = parent
self.ignore_close_on_click = ignore_close_on_click
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
if not transparent: # first widget
self.setStyleSheet(f"{BACKGR_COLOR_DARK_GREY_SEMI}")
else: # more overlaying widgets
self.setStyleSheet(f"{BACKGR_COLOR_TRANSPARENT}")
# if custom color, overwrite
if isinstance(background_color, str):
self.setStyleSheet(f"{background_color}")
def mouseReleaseEvent(self, event):
if self.ignore_close_on_click:
# don't close the widget on MouseClick outside the background
return
self.setGeometry(0, 0, 0, 0)
self.remove_current_widget_signal.emit(self.parent)
def show(self):
self.setGeometry(
0,
0,
self.parent.parent.frameSize().width(),
self.parent.parent.frameSize().height(),
) # top left corner x, y, width, height
+628
View File
@@ -0,0 +1,628 @@
# -*- coding: utf-8 -*-
from typing import List
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_model_card import ModelCardWidget
from speckle.ui.widgets.widget_model_cards_list import ModelCardsWidget
from speckle.ui.widgets.widget_model_search import ModelSearchWidget
from speckle.ui.widgets.widget_new_model import NewModelWidget
from speckle.ui.widgets.widget_new_project import NewProjectWidget
from speckle.ui.widgets.widget_no_document import NoDocumentWidget
from speckle.ui.widgets.widget_no_model_cards import NoModelCardsWidget
from speckle.ui.widgets.widget_project_search import ProjectSearchWidget
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR_LIGHT_GREY2,
ICON_LOGO,
BACKGR_COLOR,
LABEL_HEIGHT,
ZERO_MARGIN_PADDING,
)
from PyQt5.QtGui import QIcon, QPixmap, QCursor
from PyQt5.QtWidgets import (
QDockWidget,
QHBoxLayout,
QVBoxLayout,
QStackedLayout,
QWidget,
QPushButton,
QSpacerItem,
QSizePolicy,
)
from PyQt5 import QtCore
from PyQt5.QtCore import pyqtSignal
from speckle.ui.widgets.widget_selection_filter import SelectionFilterWidget
class SpeckleQGISv3Dialog(QDockWidget):
"""Dockwidget (UI module) handles all Speckle UI events, including
receiving and responding to the signals from child widgets.
SpeckleModule is set as .bridge, so we have access to all other Speckle modules."""
bridge: "QgisConnectorModule"
basic_binding: IBasicConnectorBinding
placeholder_widget: QWidget = None
header_widget: QWidget = None
main_widget: QWidget = None
widget_no_document: NoDocumentWidget = None
widget_no_model_cards: NoModelCardsWidget = None
widget_project_search: ProjectSearchWidget = None
widget_model_search: ModelSearchWidget = None
widget_account_search: AccountSearchWidget = None
widget_new_project: NewProjectWidget = None
widget_new_model: NewModelWidget = None
widget_model_cards: ModelCardsWidget = None
widget_selection_filter: SelectionFilterWidget = None
close_plugin_signal = pyqtSignal()
send_model_signal = pyqtSignal(object)
cancel_operation_signal = pyqtSignal(str)
add_model_signal = pyqtSignal(ModelCard)
remove_model_signal = pyqtSignal(ModelCard)
activity_start_signal = pyqtSignal(str, str)
def __init__(self, bridge=None, basic_binding: IBasicConnectorBinding = None):
"""Constructor."""
super(SpeckleQGISv3Dialog, self).__init__()
# Set up the user interface from Designer through FORM_CLASS.
# After self.setupUi() you can access any designer object by doing
# self.<objectname>, and you can use autoconnect slots - see
# http://qt-project.org/doc/qt-4.8/designer-using-a-ui-file.html
# #widgets-and-dialogs-with-auto-connect
# self.setupUi(self)
self.basic_binding = basic_binding
self.bridge = bridge
def runSetup(self):
self.placeholder_widget = QWidget()
self.placeholder_widget.layout = QVBoxLayout(self.placeholder_widget)
self.placeholder_widget.layout.setContentsMargins(0, 0, 0, 0)
self.placeholder_widget.setStyleSheet(f"{ZERO_MARGIN_PADDING}")
self.layout().addWidget(self.placeholder_widget)
# add header widget
self.placeholder_widget.layout.addWidget(self.header_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)
self.main_widget.layout.setContentsMargins(0, 0, 0, 0)
self.main_widget.setStyleSheet(f"{ZERO_MARGIN_PADDING}")
self.placeholder_widget.layout.addWidget(self.main_widget)
# add first widget to main
self._add_start_widget()
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}")
header_widget.layout = QHBoxLayout(header_widget)
header_widget.layout.setContentsMargins(0, 0, 10, 0)
header_widget.layout.setAlignment(QtCore.Qt.AlignVCenter)
exitIcon = QPixmap(ICON_LOGO)
exitActIcon = QIcon(exitIcon)
# create a label
text_label = QPushButton("Speckle (Beta) for QGIS")
text_label.setStyleSheet(
"border: 0px;"
"color: white;"
f"{BACKGR_COLOR}"
"padding-left: 20px;"
"font-size: 15px;"
"text-align: left;"
)
text_label.setIcon(exitActIcon)
text_label.setIconSize(QtCore.QSize(300, 93))
text_label.setMinimumSize(QtCore.QSize(100, 40))
version = ""
try:
if isinstance(plugin.version, str):
version = str(plugin.version)
except:
pass
version_label = QPushButton(version)
version_label.setStyleSheet(
"border: 0px;"
"color: white;"
f"{BACKGR_COLOR}"
"padding-left: 0px;"
"margin-left: 0px;"
"font-size: 10px;"
"height: 30px;"
"text-align: left;"
)
header_widget.layout.addWidget(text_label) # , alignment=Qt.AlignCenter)
header_widget.layout.addWidget(version_label)
self.labelWidget = text_label
self.labelWidget.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.labelWidget.clicked.connect(self._on_click_logo)
# Add a spacer item to push the next button to the right
spacer = QSpacerItem(10, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
header_widget.layout.addItem(spacer)
# close button
close_btn = QPushButton("x")
close_btn.clicked.connect(self.close_plugin_signal.emit)
close_btn.setStyleSheet(
"QPushButton {"
+ f"color:rgba(255,255,255,1); border-radius: 0px;{ZERO_MARGIN_PADDING}font-size: 12px;"
+ "background-color: rgba(240,240,240,0); height:20px;text-align: center; "
+ "} QPushButton:hover { "
+ "color:rgba(155,155,155,1);"
+ " }"
)
close_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
header_widget.layout.addWidget(close_btn)
self.setWindowTitle("Speckle (Beta)")
return header_widget
except Exception as e:
print(e)
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
if not document_open:
no_document_widget = NoDocumentWidget(parent=self)
self.main_widget.layout.addWidget(no_document_widget)
self.widget_no_document = no_document_widget
self.main_widget.layout.setCurrentWidget(self.widget_no_document)
else:
no_model_cards_widget = NoModelCardsWidget(parent=self)
self.main_widget.layout.addWidget(no_model_cards_widget)
self.widget_no_model_cards = no_model_cards_widget
self.main_widget.layout.setCurrentWidget(self.widget_no_model_cards)
self.widget_no_model_cards.add_projects_search_signal.connect(
self._open_select_projects_widget
)
def _remove_all_widgets(self):
if self.widget_no_document:
self.widget_no_document.setParent(None)
self.widget_no_document = None
if self.widget_no_model_cards:
self.widget_no_model_cards.setParent(None)
self.widget_no_model_cards = None
if self.widget_project_search:
self._remove_widget_project_search()
if self.widget_model_search:
self._remove_widget_model_search()
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_new_project:
self._remove_widget_new_project()
if self.widget_new_model:
self._remove_widget_new_model()
if self.widget_selection_filter:
self._remove_widget_selection_filter()
if self.widget_model_cards:
self._remove_widget_model_cards()
def _remove_current_widget(self, widget):
if self.widget_project_search == widget:
self._remove_widget_project_search()
elif self.widget_model_search == widget:
self._remove_widget_model_search()
elif self.widget_model_cards == widget:
self._remove_widget_model_cards()
elif self.widget_account_search == widget:
self._remove_widget_account_search()
elif self.widget_new_project == widget:
self._remove_widget_new_project()
elif self.widget_new_model == widget:
self._remove_widget_new_model()
elif self.widget_selection_filter == widget:
self._remove_widget_selection_filter()
def _remove_process_widgets(self):
if self.widget_project_search:
self._remove_widget_project_search()
if self.widget_model_search:
self._remove_widget_model_search()
if self.widget_selection_filter:
self._remove_widget_selection_filter()
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_new_project:
self._remove_widget_new_project()
if self.widget_new_model:
self._remove_widget_new_model()
def _remove_widget_project_search(self):
self.widget_project_search.setParent(None)
self.widget_project_search = None
def _remove_widget_model_search(self):
self.widget_model_search.setParent(None)
self.widget_model_search = None
def _remove_widget_account_search(self):
self.widget_account_search.setParent(None)
self.widget_account_search = None
def _remove_widget_new_project(self):
self.widget_new_project.setParent(None)
self.widget_new_project = None
def _remove_widget_new_model(self):
self.widget_new_model.setParent(None)
self.widget_new_model = None
def _remove_widget_selection_filter(self):
self.widget_selection_filter.setParent(None)
self.widget_selection_filter = None
def _remove_widget_model_cards(self):
self.widget_model_cards.setParent(None)
self.widget_model_cards = None
def _create_or_add_model_cards_widget(self, model_card: ModelCard):
self._remove_process_widgets()
if not self.widget_model_cards:
self.widget_model_cards = ModelCardsWidget(parent=self)
# TODO
# right now the cards are emitting too many signals on single click
# subscribe to all Remove Card events from all future ModelCards
self.widget_model_cards.remove_model_signal.connect(
lambda model_card=model_card: self.remove_model_signal.emit(model_card)
)
# subscribe to all Send events from all future ModelCards
self.widget_model_cards.send_model_signal.connect(
lambda model_card=model_card: self.send_model_signal.emit(model_card)
)
# subscribe to all Cancel events from all future ModelCards
self.widget_model_cards.cancel_operation_signal.connect(
self.cancel_operation_signal.emit
)
# subscribe to calling SelectionWidget from existing ModelCard
self.widget_model_cards.add_selection_filter_signal.connect(
self._create_selection_filter_widget
)
# subscribe to PUBLISH button to open project search
self.widget_model_cards.add_projects_search_signal.connect(
self._open_select_projects_widget
)
# subscribe to signal to remove the entire widget
self.widget_model_cards.remove_model_cards_widget_signal.connect(
self._remove_widget_model_cards
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_model_cards)
self.main_widget.layout.setCurrentWidget(self.widget_model_cards)
# actually add a new widget
self._add_new_model_card_widget(model_card)
# send data immediately
self.send_model_signal.emit(model_card)
def _add_new_model_card_widget(self, model_card: ModelCard):
model_card_widget = self.widget_model_cards.add_new_card(model_card)
# emit signal, for the card that was just added (because we subscribed after creating a widget)
self.add_model_signal.emit(model_card)
# add correct Selection text
self._assign_filter_summary_to_model_card_widget(model_card_widget)
def _assign_filter_summary_to_model_card_widget(
self, model_card_widget: ModelCardWidget
):
filter_summary: str = (
self.bridge.connector_module.layer_utils.get_selection_filter_summary_from_ids(
model_card_widget.card_content
)
)
model_card_widget.change_selection_text(filter_summary)
def _subscribe_to_close_on_background_click(self, widget):
"""Receive signal from background click, calling to close the widget."""
widget.background.remove_current_widget_signal.connect(
self._remove_current_widget
)
def _open_select_projects_widget(self):
if not self.widget_project_search:
self.widget_project_search = ProjectSearchWidget(parent=self)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_project_search)
self.main_widget.layout.setCurrentWidget(self.widget_project_search)
self.widget_project_search.ui_search_content.add_selection_filter_signal.connect(
self._create_selection_filter_widget
)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_project_search)
# subscribe to add_models_search_widget signal
self.widget_project_search.ui_search_content.add_models_search_signal.connect(
self._open_select_models_widget
)
# subscribe to new_project_widget_signal signal
self.widget_project_search.ui_search_content.new_project_widget_signal.connect(
self._open_new_project_widget
)
# subscribe to select_account_signal signal
self.widget_project_search.ui_search_content.select_account_signal.connect(
self._open_select_accounts_widget
)
# subscribe to change_account_signal signal
self.widget_project_search.ui_search_content.change_account_and_projects_signal.connect(
self._update_project_list
)
def _update_project_list(self):
# can be called from CreateAccount or NewProject widgets
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_new_project:
self._remove_widget_new_project()
self.widget_project_search.refresh_projects()
def _update_model_list(self):
# can be called from NewModelWidget
if self.widget_new_model:
self._remove_widget_new_model()
self.widget_model_search.refresh_models()
def _open_new_project_widget(self):
if not self.widget_new_project:
self.widget_new_project = NewProjectWidget(
parent=self,
ui_search_content=self.widget_project_search.ui_search_content,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_new_project)
self.main_widget.layout.setCurrentWidget(self.widget_new_project)
# connect clear_project_search_bar_signal. Called when New project is created
self.widget_new_project.ui_search_content.clear_project_search_bar_signal.connect(
self.widget_project_search.clear_search_bar
)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_new_project)
def _open_new_model_widget(self, project_id: str):
if not self.widget_new_model:
self.widget_new_model = NewModelWidget(
parent=self,
project_id=project_id,
ui_search_content=self.widget_project_search.ui_search_content,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_new_model)
self.main_widget.layout.setCurrentWidget(self.widget_new_model)
# connect clear_model_search_bar_signal. Called when New model is created
self.widget_new_model.ui_search_content.clear_model_search_bar_signal.connect(
self.widget_model_search.clear_search_bar
)
# subscribe to NewModelCreated event
self.widget_new_model.ui_search_content.refresh_models_signal.connect(
self._update_model_list
)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_new_model)
def _open_select_accounts_widget(self):
if not self.widget_account_search:
self.widget_account_search = AccountSearchWidget(
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_search)
self.main_widget.layout.setCurrentWidget(self.widget_account_search)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_account_search)
def _open_select_models_widget(self, project):
if not self.widget_model_search:
self.widget_model_search = ModelSearchWidget(
parent=self,
project=project,
ui_search_content=self.widget_project_search.ui_search_content,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_model_search)
self.main_widget.layout.setCurrentWidget(self.widget_model_search)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_model_search)
# subscribe to new_model_widget_signal signal
self.widget_model_search.ui_search_content.new_model_widget_signal.connect(
self._open_new_model_widget
)
def _create_selection_filter_widget(self, model_card: SenderModelCard):
# prevent repeated widget initialization
if not self.widget_selection_filter:
# get current user selection
# TODO should be updated on change, without a call
selection_info: SelectionInfo = (
self.bridge.connector_module.selection_binding.get_selection()
)
self.widget_selection_filter = SelectionFilterWidget(
parent=self,
model_card=model_card,
label_text="3/3 Select objects",
selection_info=selection_info,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_selection_filter)
self.main_widget.layout.setCurrentWidget(self.widget_selection_filter)
self.widget_selection_filter.add_model_card_signal.connect(
self._create_or_add_model_cards_widget
)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_selection_filter)
def handle_change_selection_info(self, *args):
if self.widget_selection_filter:
self.widget_selection_filter.change_selection_info(*args)
def add_activity_status(self, model_card_id: str, main_text: str):
model_card_widget = self.widget_model_cards._find_card_widget(model_card_id)
# enable Dismiss button for occasional situation, when the progress is stuck
model_card_widget.show_notification_line(main_text, True, False, True)
def add_send_notification(
self,
command: str,
model_card_id: str,
version_id: str,
send_conversion_results: List[SendOperationResult],
):
model_card_widget = self.widget_model_cards._find_card_widget(model_card_id)
model_card_widget.show_notification_line("Version created!", True, True, False)
def resizeEvent(self, event):
# handle resize of child elements
if self.header_widget:
self.header_widget.resize(
self.frameSize().width(),
LABEL_HEIGHT,
)
if self.placeholder_widget:
self.placeholder_widget.resize(
self.frameSize().width(),
self.frameSize().height(),
)
if self.main_widget:
self.main_widget.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_no_document:
self.widget_no_document.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_no_model_cards:
self.widget_no_model_cards.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_project_search:
self.widget_project_search.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_model_search:
self.widget_model_search.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_model_cards:
self.widget_model_cards.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_selection_filter:
self.widget_selection_filter.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_new_project:
self.widget_new_project.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_new_model:
self.widget_new_model.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
if self.widget_account_search:
self.widget_account_search.resize(
self.frameSize().width(),
self.frameSize().height() - LABEL_HEIGHT,
)
QDockWidget.resizeEvent(self, event)
def closeEvent(self, event):
self.close_plugin_signal.emit()
event.accept()
def _on_click_logo(self):
import webbrowser
url = "https://speckle.systems/"
webbrowser.open(url, new=0, autoraise=True)
@@ -0,0 +1,101 @@
import os
# widget utils
LABEL_HEIGHT = 35
WIDGET_SIDE_BUFFER = 40
ZERO_MARGIN_PADDING = "padding:0px; margin:0px;"
FULL_HEIGHT_WIDTH = "width:100%; height:100%"
# colors
COLOR_HIGHLIGHT = (210, 210, 210, 1)
SPECKLE_COLOR = "rgba(59, 130, 246, 1)"
SPECKLE_COLOR_LIGHT = (69, 140, 255, 1)
ICON_LOGO = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "logo-slab-white@0.5x.png"
)
ERROR_COLOR = (255, 150, 150, 1)
ERROR_COLOR_LIGHT = (255, 210, 210, 1)
COLOR = f"color: {SPECKLE_COLOR};"
BACKGR_COLOR_TRANSPARENT = f"background-color: rgba(0,0,0,0);"
BACKGR_COLOR_SUCCESS_SEND = f"background-color: rgba(225,235,255,1);"
BACKGR_COLOR_SEMI_TRANSPARENT = "background-color: rgba(250,250,250,80);"
BACKGR_COLOR_HIGHLIGHT = f"background-color: rgba{str(COLOR_HIGHLIGHT)};"
BACKGR_COLOR = f"background-color: {SPECKLE_COLOR};"
BACKGR_COLOR_LIGHT = f"background-color: rgba{str(SPECKLE_COLOR_LIGHT)};"
BACKGR_COLOR_WHITE = f"background-color: rgba(250,250,250,1);"
BACKGR_COLOR_GREY = f"background-color: rgba(220,220,220,1);"
BACKGR_COLOR_LIGHT_GREY2 = f"background-color: rgba(230,230,230,255);"
BACKGR_COLOR_LIGHT_GREY = f"background-color: rgba(240,240,240,1);"
BACKGR_COLOR_DARK_GREY_SEMI = f"background-color: rgba(120,120,120,150);"
BACKGR_ERROR_COLOR = f"background-color: rgba{str(ERROR_COLOR)};"
BACKGR_ERROR_COLOR_LIGHT = f"background-color: rgba{str(ERROR_COLOR_LIGHT)};"
NEW_GREY = BACKGR_COLOR_GREY.replace("1);", "0.2);")
NEW_GREY_HIGHLIGHT = BACKGR_COLOR_HIGHLIGHT.replace("1);", "0.3);")
# images
ICON_SEARCH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "magnify.png"
)
ICON_OPEN_WEB = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "open-in-new.png"
)
ICON_REPORT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "chart-line.png"
)
ICON_DELETE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "delete.png"
)
ICON_DELETE_BLUE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "delete-blue.png"
)
ICON_SEND = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-send.png"
)
ICON_RECEIVE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-receive.png"
)
ICON_SEND_BLACK = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-send-black.png"
)
ICON_RECEIVE_BLACK = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-receive-black.png"
)
ICON_SEND_BLUE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-send-blue.png"
)
ICON_RECEIVE_BLUE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "cube-receive-blue.png"
)
ICON_XXL = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "size-xxl.png"
)
ICON_RASTER = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "legend_raster.png"
)
ICON_POLYGON = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "legend_polygon.png"
)
ICON_LINE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "legend_line.png"
)
ICON_POINT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "legend_point.png"
)
ICON_GENERIC = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "legend_generic.png"
)
ICON_PIN_ACTIVE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "pin.png"
)
ICON_PIN_DISABLED = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "assets", "pin-outline.png"
)
+82
View File
@@ -0,0 +1,82 @@
from speckle.ui.widgets.utils.utils import splitTextIntoLines
def log_to_user(
msg: str, func=None, level: int = 2, plugin=None, url="", blue=False, report=False
):
msg = str(msg)
dockwidget = plugin
try:
if (
url == "" and blue is False
) or level == 2: # only for info messages or anything with error
msg = addLevelSymbol(msg, level)
if func is not None:
msg += "::" + str(func)
if dockwidget is None:
return
new_msg = splitTextIntoLines(msg)
# TODO: emit signal to dockwidget, create and display Log widget
except Exception as e:
print(e)
def addLevelSymbol(msg: str, level: int):
if level == 0:
msg = "🛈 " + msg
if level == 1:
msg = "⚠️ " + msg
if level == 2:
msg = "" + msg
return msg
def displayUserMsg(msg: str, func=None, level: int = 2):
try:
window = createWindow(msg, func, level)
window.exec_()
except Exception as e:
print(e)
def createWindow(msg_old: str, func=None, level: int = 2):
# print("Create window")
from PyQt5.QtWidgets import QMessageBox
from PyQt5 import QtCore
window = None
try:
# https://www.techwithtim.net/tutorials/pyqt5-tutorial/messageboxes/
window = QMessageBox()
msg = ""
if len(msg_old) > 80:
for line in msg_old.split("\n"):
line = splitTextIntoLines(line)
msg += line + "\n"
else:
msg = msg_old
window.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint)
if level == 0:
window.setWindowTitle("Info (Speckle)")
window.setIcon(QMessageBox.Icon.Information)
if level == 1:
window.setWindowTitle("Warning (Speckle)")
window.setIcon(QMessageBox.Icon.Warning)
elif level == 2:
window.setWindowTitle("Error (Speckle)")
window.setIcon(QMessageBox.Icon.Critical)
window.setFixedWidth(200)
# window.setTextFormat(QtCore.Qt.RichText)
if func is not None:
window.setText(str(msg + "\n" + str(func)))
else:
window.setText(str(msg))
# print(window)
except Exception as e:
print(e)
return window
+53
View File
@@ -0,0 +1,53 @@
from textwrap import wrap
import webbrowser
from speckle.ui.models import ModelCard
from PyQt5.QtWidgets import QPushButton
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR_TRANSPARENT,
ZERO_MARGIN_PADDING,
)
def splitTextIntoLines(text: str = "", number: int = 40) -> str:
msg = ""
try:
if len(text) > number:
try:
for i, text_part in enumerate(text.split("\n")):
lines = wrap(text_part, number)
for k, x in enumerate(lines):
msg += x
if k != len(lines) - 1:
msg += "\n"
if i != len(text.split("\n")) - 1:
msg += "\n"
except Exception as e:
print(e)
else:
msg = text
except Exception as e:
print(e)
# print(text)
return msg
def open_in_web(model_card: ModelCard):
url = f"{model_card.server_url}/projects/{model_card.project_id}/models/{model_card.model_id}"
webbrowser.open(url, new=0, autoraise=True)
def create_text_for_widget(content: str, color: str = "black", other_props=""):
# add label text (in a shape of QPushButton for easier styling)
text = QPushButton(content)
# reiterating callback, because QPushButton clicks are not propageted to the parent widget
text.setStyleSheet(
"QPushButton {"
+ f"color:{color};border-radius: 7px;{ZERO_MARGIN_PADDING}"
+ f" {BACKGR_COLOR_TRANSPARENT} height: 20px;text-align: left;{other_props}"
+ "}"
)
return text
@@ -0,0 +1,40 @@
from speckle.ui.utils.search_widget_utils import UiSearchUtils
from speckle.ui.widgets.widget_cards_list_temporary import (
CardsListTemporaryWidget,
)
class AccountSearchWidget(CardsListTemporaryWidget):
ui_search_content: UiSearchUtils = None
def __init__(
self,
*,
parent=None,
label_text: str = "Select account",
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=[]
)
self._add_accounts(clear_cursor=True)
def _add_accounts(self, clear_cursor=False):
all_accounts = self.ui_search_content.get_accounts_content()
self._add_more_cards(
all_accounts, clear_cursor, self.ui_search_content.batch_size
)
# adjust size of new widget:
self.resizeEvent()
@@ -0,0 +1,86 @@
from typing import List
from PyQt5 import QtCore
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QPushButton,
)
from speckle.ui.widgets.utils.global_resources import (
ZERO_MARGIN_PADDING,
SPECKLE_COLOR,
BACKGR_COLOR_TRANSPARENT,
BACKGR_COLOR_LIGHT_GREY,
BACKGR_COLOR_GREY,
)
class CardInListWidget(QWidget):
card_content = None
callback = None
def __init__(self, card_content: List):
super(CardInListWidget, self).__init__(None)
self.card_content = card_content
self.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
self.setStyleSheet(
"QWidget {"
+ "border-radius:5px; margin-bottom:3px; min-height:50px;"
+ f"{ZERO_MARGIN_PADDING} {BACKGR_COLOR_LIGHT_GREY}"
+ "} QWidget:hover { "
+ f"{BACKGR_COLOR_GREY}"
+ "}"
)
layout = QVBoxLayout(self)
# add callback function
self.callback = card_content[0]
# add content
layout.addWidget(self.add_main_text(card_content[1]))
for content in card_content[2:]:
if isinstance(content, str):
layout.addWidget(self.add_text_line(content))
def add_main_text(self, content: str):
# add label text (in a shape of QPushButton for easier styling)
main_text = QPushButton(content)
# reiterating callback, because QPushButton clicks are not propageted to the parent widget
main_text.clicked.connect(self.callback)
main_text.setStyleSheet(
"QPushButton {color:black;border-radius: 7px;"
+ f"{ZERO_MARGIN_PADDING} {BACKGR_COLOR_TRANSPARENT} min-height: 15px;text-align: left;"
+ "} QPushButton:hover { "
+ f"color:rgba{SPECKLE_COLOR};"
+ " }"
)
return main_text
def add_text_line(self, content: str):
# add text line (in a shape of QPushButton for easier styling)
text_line = QPushButton(content)
# reiterating callback, because QPushButton clicks are not propageted to the parent widget
text_line.clicked.connect(self.callback)
text_line.setStyleSheet(
"QPushButton {color:grey;border-radius: 7px;"
+ f"{ZERO_MARGIN_PADDING} {BACKGR_COLOR_TRANSPARENT} min-height: 10px;text-align: left;"
+ " }"
)
return text_line
def mouseReleaseEvent(self, event):
if self.callback:
self.callback()
@@ -0,0 +1,232 @@
from typing import List
from PyQt5 import QtCore
from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QVBoxLayout, QWidget, QStackedLayout, QLabel, QPushButton
from speckle.ui.widgets.utils.global_resources import (
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
BACKGR_COLOR_WHITE,
BACKGR_COLOR_LIGHT_GREY2,
)
from speckle.ui.widgets.background_widget import BackgroundWidget
from speckle.ui.widgets.widget_card_from_list import CardInListWidget
class CardsListTemporaryWidget(QWidget):
background: BackgroundWidget = 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
def __init__(
self,
*,
parent=None,
label_text: str = "Label",
cards_content_list: List[List],
):
super(CardsListTemporaryWidget, self).__init__(parent)
self.parent: "SpeckleQGISv3Dialog" = parent
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
) # top left corner x, y, width, height
self._add_background()
self.layout = QStackedLayout()
self.layout.addWidget(self.background)
self.scroll_container = self._create_cards_selection_widget(
label_text, cards_content_list
)
content = QWidget()
content.layout = QVBoxLayout(self)
content.layout.setContentsMargins(
WIDGET_SIDE_BUFFER,
WIDGET_SIDE_BUFFER,
WIDGET_SIDE_BUFFER,
WIDGET_SIDE_BUFFER,
)
content.layout.setAlignment(Qt.AlignCenter)
content.layout.addWidget(self.scroll_container)
self.layout.addWidget(content)
def _add_background(self):
self.background = BackgroundWidget(parent=self, transparent=False)
self.background.show()
def _create_cards_selection_widget(
self, label_text: str, cards_content_list: List[List]
) -> QWidget:
# create a container
scroll_container = self._create_container()
# create scroll area with this widget
label = self._create_widget_label(label_text)
scroll_area = self._create_scroll_area(cards_content_list)
# add label and scroll area to the container
scroll_container.layout().addWidget(label)
scroll_container.layout().addWidget(scroll_area)
return scroll_container
def _create_container(self):
scroll_container = QWidget()
scroll_container.setAttribute(QtCore.Qt.WA_StyledBackground, True)
scroll_container.setStyleSheet(
"QWidget {"
f"{ZERO_MARGIN_PADDING}" + f"border-radius:5px; {BACKGR_COLOR_WHITE}" + "}"
)
scroll_container_layout = QVBoxLayout(scroll_container)
scroll_container_layout.setAlignment(Qt.AlignHCenter)
return scroll_container
def _create_widget_label(self, label_text: 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)}; padding-top:{int(WIDGET_SIDE_BUFFER/4)}; margin-bottom:{int(WIDGET_SIDE_BUFFER/4)}; text-align:left;"
+ "}"
)
return label
def _create_scroll_area(self, cards_content_list: List[List] = None):
self.scroll_area = QtWidgets.QScrollArea()
self.scroll_area.setStyleSheet("QScrollArea {" + f"{ZERO_MARGIN_PADDING}" + "}")
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)
return self.scroll_area
def _load_more(self):
"""Overwride in the inheriting widgets."""
return
def _create_load_more_btn(self):
load_more_btn = QPushButton()
load_more_btn.clicked.connect(lambda: self._load_more())
self.load_more_btn = load_more_btn
self._style_load_btn()
def _style_load_btn(self, active: bool = True, text="Load more"):
if active:
self.load_more_btn.setStyleSheet(
"QWidget {"
+ f"color:black;border-width:1px;border-color:rgba(100,100,100,1);border-radius: 5px;margin-top:0px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR_WHITE}"
+ "} QWidget:hover { "
+ f"{BACKGR_COLOR_LIGHT_GREY2};"
+ " }"
)
self.load_more_btn.setText(text)
self.load_more_btn.setEnabled(True)
else:
self.load_more_btn.setStyleSheet(
"QWidget {"
+ f"color:grey;border-width:1px;border-color:rgba(100,100,100,1);border-radius: 5px;margin-top:0px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR_WHITE}"
+ "} QWidget:hover { "
+ f"{BACKGR_COLOR_LIGHT_GREY2};"
+ " }"
)
self.load_more_btn.setText(text)
self.load_more_btn.setEnabled(False)
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)
# 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)
self._create_load_more_btn()
self.cards_list_widget.layout().addWidget(self.load_more_btn)
return self.cards_list_widget
def _add_more_cards(
self, new_cards_content_list: list, keep_scroll_on_top=False, batch_size=1
):
self.cards_list_widget.setParent(None)
existing_content = []
for i in range(self.cards_list_widget.layout().count()):
widget = self.cards_list_widget.layout().itemAt(i).widget()
if isinstance(widget, CardInListWidget):
existing_content.append(widget.card_content)
existing_content.extend(new_cards_content_list)
assigned_cards_list_widget = self._create_area_with_cards(existing_content)
self.scroll_area.setWidget(assigned_cards_list_widget)
# scroll down
if not keep_scroll_on_top:
vbar = self.scroll_area.verticalScrollBar()
vbar.setValue(vbar.maximum())
# style LoadMore buttom
if 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()
for i in range(all_count):
# remove items by reversed index
widget = self.cards_list_widget.layout().itemAt(all_count - i - 1).widget()
widget.setParent(None)
def resizeEvent(self, event=None):
QtWidgets.QWidget.resizeEvent(self, event)
try:
self.background.resize(
self.parent.frameSize().width(),
self.parent.frameSize().height(),
)
self.cards_list_widget.resize(
self.parent.frameSize().width() - 3 * WIDGET_SIDE_BUFFER,
self.cards_list_widget.height(),
)
except RuntimeError as e:
# e.g. Widget was deleted
pass
def installEventFilter(self):
"""Overwriting native behavior of passing click (and other) events
to parent widgets. This is needed, so that only click on background
itself (and not widgets on top of it) would close the widget.
"""
return
def mouseReleaseEvent(self, event):
return
+258
View File
@@ -0,0 +1,258 @@
from PyQt5 import QtCore
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QCursor
from PyQt5.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QWidget,
QPushButton,
QGraphicsDropShadowEffect,
QSpacerItem,
QSizePolicy,
QStackedLayout,
)
from speckle.ui.models import ModelCard, SenderModelCard
from speckle.ui.utils.model_cards_widget_utils import UiModelCardsUtils
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
ZERO_MARGIN_PADDING,
BACKGR_COLOR_WHITE,
BACKGR_COLOR_TRANSPARENT,
SPECKLE_COLOR,
BACKGR_COLOR_LIGHT_GREY,
BACKGR_COLOR_LIGHT_GREY2,
)
from speckle.ui.widgets.utils.utils import create_text_for_widget, open_in_web
from speckle.ui.widgets.widget_model_card_notification import (
ModelCardNotificationWidget,
)
from specklepy.core.api.models.current import Model
class ModelCardWidget(QWidget):
card_content: ModelCard = None
send_model_btn: QPushButton = None
send_model_signal = pyqtSignal(SenderModelCard)
cancel_operation_signal = pyqtSignal(str)
remove_self_card_signal = pyqtSignal(ModelCard)
shadow_effect = None
close_btn: QPushButton = None
open_web_btn: QPushButton = None
ui_model_card_utils: UiModelCardsUtils = None
summary_text: str = "No objects are selected"
selection_filter_text: QPushButton
notification_line: QWidget
main_content: QWidget
# for the use in parent widget - to keep track if signals are already connected and not connect to btns twice
connected: bool = False
add_selection_filter_signal = pyqtSignal(SenderModelCard)
def __init__(
self, parent=None, ui_model_card_utils=None, card_content: ModelCard = None
):
super(ModelCardWidget, self).__init__(None)
self.parent = parent
self.ui_model_card_utils = ui_model_card_utils
self.card_content = card_content
self.layout = QStackedLayout(self)
self.layout.setStackingMode(QStackedLayout.StackAll)
self.add_drop_shadow()
# add to layout
content = QWidget()
content.setAttribute(QtCore.Qt.WA_StyledBackground, True)
content.setStyleSheet(
"QWidget {"
+ f"border-radius:5px;{ZERO_MARGIN_PADDING}"
+ "margin-bottom:3px;"
+ f"{BACKGR_COLOR_WHITE}"
+ "}"
)
content.layout = QVBoxLayout(content)
content.layout.setAlignment(Qt.AlignTop)
content.layout.setContentsMargins(
0,
10,
0,
0,
)
# create areas in the card
top_section = self._create_card_header()
bottom_section = self._create_send_filter_line()
content.layout.addWidget(top_section)
content.layout.addWidget(bottom_section)
self.main_content = content
self.layout.addWidget(self.main_content)
# placeholder for notification bar to create on demand later
self.notification_line = None
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 _create_send_filter_line(self):
line = QWidget()
layout_line = QHBoxLayout(line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 0, 10, 15)
line.setStyleSheet(
"QWidget {"
+ f"color:white;border-radius: 5px;{ZERO_MARGIN_PADDING}"
+ f"text-align: left;{BACKGR_COLOR_TRANSPARENT}"
+ "}"
)
clickable_text = create_text_for_widget("Selection: ", color=SPECKLE_COLOR)
clickable_text.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
clickable_text.clicked.connect(
lambda: self.add_selection_filter_signal.emit(self.card_content)
)
layout_line.addWidget(clickable_text)
self.selection_filter_text = create_text_for_widget(
self.summary_text, color="rgba(130,130,130,1)"
)
layout_line.addWidget(self.selection_filter_text)
return line
def hide_notification_line(self):
self.layout.setCurrentWidget(self.main_content)
self.layout.removeWidget(self.notification_line)
self.notification_line = None
def show_notification_line(
self, main_text: str, btn_dismiss: bool, btn_view_web: bool, btn_cancel: bool
):
if self.notification_line:
self.hide_notification_line()
self.notification_line = ModelCardNotificationWidget(
self.card_content, main_text, btn_dismiss, btn_view_web, btn_cancel, self
)
# connect buttons from the new notification widget
if btn_dismiss:
self.notification_line.dismiss_btn.clicked.connect(
lambda: self.hide_notification_line()
)
if btn_cancel:
self.notification_line.cancel_operation_signal_no_card.connect(
lambda: self.cancel_operation_signal.emit(
self.card_content.model_card_id
)
)
self.layout.addWidget(self.notification_line)
# put notification widget on top
self.layout.setCurrentWidget(self.notification_line)
self.notification_line.resize(
self.frameSize().width(), self.frameSize().height()
)
def change_selection_text(self, selection_text: str):
# function accessed from the parent dockwidget
# change text on the widget
self.selection_filter_text.setText(selection_text)
def _create_card_header(self):
top_line = QWidget()
layout_top_line = QHBoxLayout(top_line)
layout_top_line.setAlignment(Qt.AlignLeft)
layout_top_line.setContentsMargins(10, 0, 10, 0)
top_line.setStyleSheet(
"QWidget {"
+ f"color:white;{ZERO_MARGIN_PADDING}"
+ f"text-align: left;{BACKGR_COLOR_TRANSPARENT}"
+ "}"
)
if isinstance(self.card_content, SenderModelCard):
layout_top_line.addWidget(self._add_send_btn())
self.send_model_btn.clicked.connect(
lambda: self.send_model_signal.emit(self.card_content)
)
model: Model = self.ui_model_card_utils.get_model_by_id_from_client(
self.card_content
)
layout_top_line.addWidget(
create_text_for_widget(
model.name, other_props="font-size: 14px;font-weight: bold;"
)
)
# Add a spacer item to push the next button to the right
spacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
layout_top_line.addItem(spacer)
# Add the new button on the right side
layout_top_line.addWidget(self._add_open_web_button())
layout_top_line.addWidget(self._add_close_button())
return top_line
def _add_open_web_button(self):
open_web_btn = QPushButton("")
open_web_btn.clicked.connect(lambda: open_in_web(self.card_content))
open_web_btn.setStyleSheet(
"QPushButton {"
+ f"color:rgba(130,130,130,1); border-radius: 10px;{ZERO_MARGIN_PADDING}font-size: 24px;max-width:20px;"
+ f"{BACKGR_COLOR_LIGHT_GREY} height:20px;text-align: center; "
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT_GREY2};"
+ " }"
)
open_web_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.open_web_btn = open_web_btn
return open_web_btn
def _add_close_button(self):
close_btn = QPushButton(" x ")
close_btn.clicked.connect(
lambda: self.remove_self_card_signal.emit(self.card_content)
)
close_btn.setStyleSheet(
"QPushButton {"
+ f"color:rgba(130,130,130,1); border-radius: 10px;{ZERO_MARGIN_PADDING}font-size: 18px;"
+ f"{BACKGR_COLOR_LIGHT_GREY} height:20px;text-align: center; "
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT_GREY2};"
+ " }"
)
close_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.close_btn = close_btn
return close_btn
def _add_send_btn(self):
button_publish = QPushButton("")
button_publish.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 10px;{ZERO_MARGIN_PADDING}font-size: 24px;font-weight: bold;"
+ f"{BACKGR_COLOR} height:20px;text-align: center; padding: 0px 10px;"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
button_publish.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.send_model_btn = button_publish
return button_publish
@@ -0,0 +1,163 @@
from PyQt5 import QtCore
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import (
QVBoxLayout,
QHBoxLayout,
QWidget,
QPushButton,
QSpacerItem,
QSizePolicy,
)
from speckle.ui.models import ModelCard
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
BACKGR_COLOR_SEMI_TRANSPARENT,
BACKGR_COLOR_SUCCESS_SEND,
ZERO_MARGIN_PADDING,
BACKGR_COLOR_TRANSPARENT,
SPECKLE_COLOR,
)
from speckle.ui.widgets.utils.utils import create_text_for_widget, open_in_web
class ModelCardNotificationWidget(QWidget):
dismiss_btn: QPushButton = None
card_content: ModelCard
cancel_operation_signal_no_card = pyqtSignal()
def __init__(
self,
card_content: ModelCard,
main_text: str,
btn_dismiss: bool,
btn_view_web: bool,
btn_cancel: bool,
parent=None,
):
super(ModelCardNotificationWidget, self).__init__(parent)
self.card_content = card_content
self.main_text = main_text
self.btn_dismiss = btn_dismiss
self.btn_view_web = btn_view_web
self.btn_cancel = btn_cancel
# create a container that will be added to the main Stacked layout
# make it semi-transparent
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
self.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ f"text-align: left;{BACKGR_COLOR_SEMI_TRANSPARENT}"
+ "}"
)
self.layout = QVBoxLayout(self)
self.layout.setAlignment(Qt.AlignBottom)
self.layout.setContentsMargins(0, 0, 0, 0)
# Add cancel buttom
if self.btn_cancel:
top_line = QWidget()
top_line.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ "text-align: left;}"
)
layout_line = QHBoxLayout(top_line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 5, 10, 5)
cancel_btn = self.create_cancel_btn()
layout_line.addWidget(cancel_btn)
self.layout.addWidget(top_line)
bottom_line = self.create_notification_line()
# Add line widget to the container
self.layout.addWidget(bottom_line)
def create_notification_line(self):
# create a line widget
line = QWidget()
line.setAttribute(QtCore.Qt.WA_StyledBackground, True)
line.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ f"text-align: left;{BACKGR_COLOR_SUCCESS_SEND}"
+ "}"
)
layout_line = QHBoxLayout(line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 5, 10, 5)
# add main text
main_text = self.create_main_text(self.main_text)
layout_line.addWidget(main_text)
# Add a spacer item to push the next button to the right
spacer = self.create_horizontal_spacer()
layout_line.addItem(spacer)
# Add dismiss buttom
if self.btn_dismiss:
dismiss_btn = self.create_dismiss_btn()
layout_line.addWidget(dismiss_btn)
self.dismiss_btn = dismiss_btn
# Add view in Web buttom
if self.btn_view_web:
view_web = self.create_web_view_btn()
layout_line.addWidget(view_web)
return line
def create_main_text(self, text: str):
return create_text_for_widget(text, color=SPECKLE_COLOR)
def create_horizontal_spacer(self):
return QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
def create_web_view_btn(self):
view_web = QPushButton("View")
view_web.clicked.connect(lambda: open_in_web(self.card_content))
view_web.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 5px;{ZERO_MARGIN_PADDING}"
+ f"{BACKGR_COLOR} height:15px; text-align: center; padding: 0px 10px;"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
view_web.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
return view_web
def create_dismiss_btn(self):
dismiss_btn = QPushButton("Dismiss")
dismiss_btn.setStyleSheet(
"QPushButton {"
+ f"color:{SPECKLE_COLOR}; {ZERO_MARGIN_PADDING}"
+ f"{BACKGR_COLOR_TRANSPARENT} height:15px;text-align: center; "
+ " }"
)
dismiss_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
return dismiss_btn
def create_cancel_btn(self):
cancel_btn = QPushButton("")
cancel_btn.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 10px;{ZERO_MARGIN_PADDING}font-size: 24px;font-weight: bold;"
+ f"{BACKGR_COLOR} height:20px;text-align: center; padding: 0px 10px;"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
cancel_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
cancel_btn.clicked.connect(self.cancel_operation_signal_no_card.emit)
return cancel_btn
@@ -0,0 +1,343 @@
from typing import Any, List
from PyQt5 import QtCore
from PyQt5.QtGui import QCursor
from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QLabel,
QPushButton,
)
from speckle.ui.models import ModelCard, SenderModelCard
from speckle.ui.utils.model_cards_widget_utils import UiModelCardsUtils
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
BACKGR_COLOR_LIGHT_GREY,
BACKGR_COLOR_LIGHT_GREY2,
)
from speckle.ui.widgets.background_widget import BackgroundWidget
from speckle.ui.widgets.widget_model_card import ModelCardWidget
from specklepy.core.api.models.current import Project
class ModelCardsWidget(QWidget):
ui_model_card_utils: UiModelCardsUtils = None
background: BackgroundWidget = None
cards_list_widget: QWidget = None # needed here to resize child elements
scroll_area: QtWidgets.QScrollArea = None
global_publish_btn: QPushButton = None
add_projects_search_signal = pyqtSignal()
remove_model_cards_widget_signal = pyqtSignal()
remove_model_signal = pyqtSignal(ModelCard)
send_model_signal = pyqtSignal(SenderModelCard)
cancel_operation_signal = pyqtSignal(str)
add_selection_filter_signal = pyqtSignal(SenderModelCard)
child_cards: List[ModelCardWidget]
def __init__(
self,
*,
parent=None,
):
super(ModelCardsWidget, self).__init__(parent=parent)
self.parent: Any = parent
self.ui_model_card_utils = UiModelCardsUtils()
self.child_cards: List[ModelCardWidget] = []
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
) # top left corner x, y, width, height
self._add_background()
self.layout = QStackedLayout()
self.layout.addWidget(self.background)
self._create_search_button() # will be added later to the child widget
##########################
self.scroll_container = self._create_cards_selection_widget()
content = QWidget()
content.layout = QVBoxLayout(self)
content.layout.setContentsMargins(0, 0, 0, 0)
content.layout.setAlignment(Qt.AlignCenter)
content.layout.addWidget(self.scroll_container)
content.setStyleSheet("QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}")
##########################
# add both overlapping elements to widget
self.layout.addWidget(content)
def _create_search_button(self) -> QPushButton:
button_publish = QPushButton("Publish")
button_publish.clicked.connect(lambda: self.add_projects_search_signal.emit())
button_publish.setStyleSheet(
"QWidget {"
+ f"color:white;border-radius: 7px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QWidget:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
button_publish.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
self.global_publish_btn = button_publish
return button_publish
def _add_background(self):
# overwrite function for custom style
self.background = BackgroundWidget(
parent=self,
transparent=False,
background_color=BACKGR_COLOR_LIGHT_GREY2,
ignore_close_on_click=True,
)
self.background.show()
def _create_cards_selection_widget(self) -> QWidget:
# create a container
scroll_container = self._create_container()
# create scroll area with this widget
scroll_area = self._create_scroll_area()
# add label and scroll area to the container
scroll_container.layout().addWidget(scroll_area)
scroll_container.layout().addWidget(self.global_publish_btn)
return scroll_container
def _create_container(self):
scroll_container = QWidget()
scroll_container.setAttribute(QtCore.Qt.WA_StyledBackground, True)
scroll_container.setStyleSheet(
"QWidget {"
f"{ZERO_MARGIN_PADDING}{BACKGR_COLOR_LIGHT_GREY}"
+ "border-radius:0px;" # border-color:rgba(220,220,220,1);border-width:1px;border-style:solid;"
+ "}"
)
scroll_container_layout = QVBoxLayout(scroll_container)
scroll_container_layout.setAlignment(Qt.AlignHCenter)
return scroll_container
def _create_widget_label(self, label_text: str):
label = QLabel(label_text)
# for some reason, "margin-left" doesn't make any effect here
label.setStyleSheet(
"QLabel {font-size: 14px;color:rgba(130,130,130,1);"
+ f"{ZERO_MARGIN_PADDING}padding-left:{int(WIDGET_SIDE_BUFFER/6)}; padding-top:{int(WIDGET_SIDE_BUFFER/4)}; text-align:left;"
+ "}"
)
return label
def _create_scroll_area(self):
self.scroll_area = QtWidgets.QScrollArea()
self.scroll_area.setStyleSheet(
"QScrollArea {"
+ f"{ZERO_MARGIN_PADDING}margin-bottom:45px;" # space fot Publish btn
+ "}"
)
self.scroll_area.setAlignment(Qt.AlignHCenter)
# create a widget inside scroll area
self._create_area_with_cards()
self.scroll_area.setWidget(self.cards_list_widget)
return self.scroll_area
def _create_area_with_cards(self) -> QWidget:
cards_list_widget = QWidget()
cards_list_widget.setStyleSheet("QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}")
_ = QVBoxLayout(cards_list_widget)
self.cards_list_widget = cards_list_widget
def _modify_area_with_cards(self, widgets_list: List[Any]) -> QWidget:
cards_list_widget = QWidget()
cards_list_widget.setStyleSheet("QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}")
_ = QVBoxLayout(cards_list_widget)
self.child_cards.clear()
# in case the input argument was missing or None, don't create any cards
if isinstance(widgets_list, list):
for widget in widgets_list:
cards_list_widget.layout().addWidget(widget)
if isinstance(widget, ModelCardWidget):
self.child_cards.append(widget)
# if widget is not connected yet
if widget.connected is False:
widget.send_model_signal.connect(self.send_model_signal.emit)
widget.cancel_operation_signal.connect(
self.cancel_operation_signal.emit
)
widget.add_selection_filter_signal.connect(
self.add_selection_filter_signal.emit
)
widget.connected = True
self.cards_list_widget = cards_list_widget
return self.cards_list_widget
def add_new_card(self, new_card: ModelCard) -> ModelCardWidget:
all_widgets = []
new_card_widget = None
for i in range(self.cards_list_widget.layout().count()):
widget = self.cards_list_widget.layout().itemAt(i).widget()
if isinstance(widget, ModelCardWidget):
# check if it's the same project, to group together
if (
widget.card_content.server_url == new_card.server_url
and widget.card_content.project_id == new_card.project_id
):
# if not the same model, add the old one and then the new one
if widget.card_content.model_id != new_card.model_id:
all_widgets.append(widget)
if new_card_widget is None:
new_card_widget = self._create_model_card_widget(new_card)
all_widgets.append(new_card_widget)
else:
all_widgets.append(widget)
else: # labels
all_widgets.append(widget)
# if the new card widget was not yet created (injcted into a list with existing project cards),
# create a new project label and a new widget
if new_card_widget is None:
project: Project = self.ui_model_card_utils.get_project_by_id_from_client(
new_card
)
label = self._create_widget_label(project.name)
all_widgets.append(label)
new_card_widget = self._create_model_card_widget(new_card)
all_widgets.append(new_card_widget)
assigned_cards_list_widget = self._modify_area_with_cards(all_widgets)
self.scroll_area.setWidget(assigned_cards_list_widget)
# adjust size of new widget:
self.resizeEvent()
return new_card_widget
def _create_model_card_widget(self, new_card: ModelCard) -> ModelCardWidget:
new_card_widget = ModelCardWidget(self, self.ui_model_card_utils, new_card)
new_card_widget.remove_self_card_signal.connect(self._remove_card)
return new_card_widget
def _remove_card(self, new_card: ModelCard):
# signal to remove ModelCard info from DocumentStore (handled by main module)
self.remove_model_signal.emit(new_card)
project_groups: List[dict] = []
for i in range(self.cards_list_widget.layout().count()):
widget = self.cards_list_widget.layout().itemAt(i).widget()
if not isinstance(widget, ModelCardWidget):
# labels, will always come first before the card widgets
# indicates start of a new project group
# check if the previous group if empty
self._check_for_empty_group(project_groups)
# start a new project group, add label and a placeholder for cards
project_groups.append({"label": widget, "cards": []})
else:
# confirm it's NOT the card that needs to be removed
if not (
widget.card_content.server_url == new_card.server_url
and widget.card_content.project_id == new_card.project_id
and widget.card_content.model_id == new_card.model_id
):
project_groups[-1]["cards"].append(widget)
# check if last group if empty
self._check_for_empty_group(project_groups)
# if no cards left, remove widget completely
all_widgets = [
item for gr in project_groups for item in [gr["label"]] + gr["cards"]
]
if len(all_widgets) == 0:
self.remove_model_cards_widget_signal.emit()
return
assigned_cards_list_widget = self._modify_area_with_cards(all_widgets)
self.scroll_area.setWidget(assigned_cards_list_widget)
# adjust size of new widget:
self.resizeEvent()
def _find_card_widget(self, model_card_id: str):
for i in range(self.cards_list_widget.layout().count()):
widget = self.cards_list_widget.layout().itemAt(i).widget()
if isinstance(widget, ModelCardWidget):
if widget.card_content.model_card_id == model_card_id:
return widget
raise ValueError(f"Model Card with id '{model_card_id}' not found")
def _check_for_empty_group(self, project_groups):
# check if the last project group only contains a label (no cards), then delete it
if len(project_groups) > 0 and len(project_groups[-1]["cards"]) == 0:
project_groups.pop()
def resizeEvent(self, event=None):
QtWidgets.QWidget.resizeEvent(self, event)
try:
self.background.resize(
self.parent.frameSize().width(),
self.parent.frameSize().height(),
)
self.cards_list_widget.resize(
self.parent.frameSize().width() - int(0.5 * WIDGET_SIDE_BUFFER),
self.cards_list_widget.height(),
)
except RuntimeError as e:
# e.g. Widget was deleted
pass
def installEventFilter(self):
"""Overwriting native behavior of passing click (and other) events
to parent widgets. This is needed, so that only click on background
itself would close the widget.
"""
return
def mouseReleaseEvent(self, event):
return
+140
View File
@@ -0,0 +1,140 @@
from typing import Optional
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,
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
)
from speckle.ui.widgets.widget_cards_list_temporary import (
CardsListTemporaryWidget,
)
from specklepy.core.api.models.current import Project
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import (
QHBoxLayout,
QWidget,
QLineEdit,
QPushButton,
)
class ModelSearchWidget(CardsListTemporaryWidget):
ui_search_content: UiSearchUtils = None
_project: Project = None
search_widget: QLineEdit = None
def __init__(
self,
*,
parent=None,
project=None,
label_text: str = "2/3 Select model",
ui_search_content: UiSearchUtils = None,
):
self.parent = parent
self._project = project
self.ui_search_content = ui_search_content
# customize load_more function
self._load_more = lambda: self._add_models(clear_cursor=False)
# initialize the inherited widget, passing the card content
super(ModelSearchWidget, self).__init__(
parent=parent, label_text=label_text, cards_content_list=[]
)
self._add_search_and_account_switch_line()
self._add_models(clear_cursor=True)
def _add_background(self):
# overwrite function to make background transparent
self.background = BackgroundWidget(parent=self, transparent=True)
self.background.show()
def _add_models(self, clear_cursor=False, name_filter: Optional[str] = None):
new_models_cards = (
self.ui_search_content.get_new_models_content_with_name_condition(
project=self._project,
name_filter=name_filter,
)
)
self._add_more_cards(
new_models_cards, clear_cursor, self.ui_search_content.batch_size
)
# adjust size of new widget:
self.resizeEvent()
def clear_search_bar(self):
self.search_widget.setText("")
def refresh_models(self, name_filter: Optional[str] = None):
self._remove_all_cards()
self._add_models(clear_cursor=True, name_filter=name_filter)
def _add_search_and_account_switch_line(self):
# create a line widget
line = QWidget()
line.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ f"margin-left:{int(WIDGET_SIDE_BUFFER/4)};text-align: left;"
+ "}"
)
layout_line = QHBoxLayout(line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 0, 0, 0)
# model search field
search_widget = self._create_search_widget()
layout_line.addWidget(search_widget)
self.search_widget = search_widget
# New model buttom
new_model_btn = self._create_new_model_btn()
layout_line.addWidget(new_model_btn)
self.scroll_container.layout().insertWidget(1, line)
def _create_search_widget(self):
text_box = QLineEdit()
text_box.setMaxLength(20)
text_box.setStyleSheet(
"""QLineEdit { background-color: white; border: 1px solid lightgrey; border-radius: 5px; color: black; height: 30px }"""
)
text_box.textChanged.connect(lambda input_text: self.refresh_models(input_text))
return text_box
def _create_new_model_btn(self):
new_item_btn = QPushButton("+")
new_item_btn.clicked.connect(
lambda: self.ui_search_content.new_model_widget_signal.emit(
self._project.id
)
)
new_item_btn.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 5px;{ZERO_MARGIN_PADDING}"
+ f"{BACKGR_COLOR} height:30px; text-align: center; padding: 0px 10px;font-size:14px"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
new_item_btn.setFixedHeight(30)
new_item_btn.setFixedWidth(30)
new_item_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
return new_item_btn
+185
View File
@@ -0,0 +1,185 @@
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,
)
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QPushButton,
QLabel,
QLineEdit,
QGraphicsDropShadowEffect,
)
class NewModelWidget(QWidget):
ui_search_content: UiSearchUtils = None
project_id: str
_message_card: QWidget = (
None # needs to be here, so it can be called on resize event
)
model_name_widget: QLineEdit = None
workspace_widget: QLineEdit = None
def __init__(
self,
*,
parent=None,
project_id: str = None,
label_text: str = "Create new model",
ui_search_content: UiSearchUtils = None,
):
super(NewModelWidget, self).__init__(parent)
self.parent = parent
self.project_id = project_id
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
label = self._create_text_widget("Model name:")
boxLayout.addWidget(label)
# add text input
self.model_name_widget = QLineEdit()
self.model_name_widget.setMaxLength(40)
self.model_name_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.model_name_widget)
button_create = self._create_create_button()
boxLayout.addWidget(button_create)
self._add_drop_shadow(self._message_card)
def _create_create_button(self) -> QPushButton:
button_publish = QPushButton("Create")
button_publish.clicked.connect(self._create_model_and_exit_widget)
button_publish.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
def _create_model_and_exit_widget(self):
self.ui_search_content.create_new_model(
self.project_id, self.model_name_widget.text()
)
# the next signal will trigger closing the widget and refreshing model list
self.ui_search_content.refresh_models_signal.emit()
# clear text search bar
self.ui_search_content.clear_model_search_bar_signal.emit()
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
+196
View File
@@ -0,0 +1,196 @@
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,
)
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QPushButton,
QLabel,
QLineEdit,
QGraphicsDropShadowEffect,
)
class NewProjectWidget(QWidget):
ui_search_content: UiSearchUtils = None
_message_card: 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,
*,
parent=None,
label_text: str = "Create new project",
ui_search_content: UiSearchUtils = None,
):
super(NewProjectWidget, 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("Project name:")
boxLayout.addWidget(label)
# add text input 1
self.project_name_widget = QLineEdit()
self.project_name_widget.setMaxLength(40)
self.project_name_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.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)
self._add_drop_shadow(self._message_card)
def _create_create_button(self) -> QPushButton:
button_publish = QPushButton("Create")
button_publish.clicked.connect(self._create_project_and_exit_widget)
button_publish.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
def _create_project_and_exit_widget(self):
self.ui_search_content.create_new_project(self.project_name_widget.text(), None)
# the next signal will trigger closing the widget and refreshing project list
self.ui_search_content.change_account_and_projects_signal.emit()
# clear text search bar
self.ui_search_content.clear_project_search_bar_signal.emit()
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
+50
View File
@@ -0,0 +1,50 @@
from PyQt5 import QtCore
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QVBoxLayout, QWidget, QLabel
from speckle.ui.widgets.utils.global_resources import (
WIDGET_SIDE_BUFFER,
BACKGR_COLOR_WHITE,
BACKGR_COLOR_LIGHT_GREY2,
)
class NoDocumentWidget(QWidget):
_message_card: QWidget
def __init__(self, parent=None):
super(NoDocumentWidget, self).__init__(parent)
self.parent: "SpeckleQGISv3Dialog" = parent
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignVCenter)
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
self.setStyleSheet(f"{BACKGR_COLOR_LIGHT_GREY2}")
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
) # top left corner x, y, width, height
self._message_card = QWidget()
self._message_card.setStyleSheet(
"QWidget {"
+ f"border-radius: 10px;padding: 20px;margin:{int(0.5 * WIDGET_SIDE_BUFFER)};height: 40px;{BACKGR_COLOR_WHITE}"
+ "}"
)
self.fill_message_card()
self.layout.addWidget(self._message_card)
def fill_message_card(self):
boxLayout = QVBoxLayout(self._message_card)
# add text
label = QLabel("No active document")
label.setStyleSheet(
"QLabel {padding: 5px;padding-top: 20px;padding-bottom: 20px;height: 20px;text-align: left;}"
)
boxLayout.addWidget(label)
@@ -0,0 +1,93 @@
from PyQt5 import QtCore
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QPushButton,
QLabel,
QGraphicsDropShadowEffect,
)
from speckle.ui.widgets.utils.global_resources import (
WIDGET_SIDE_BUFFER,
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
BACKGR_COLOR_LIGHT_GREY2,
BACKGR_COLOR_WHITE,
)
class NoModelCardsWidget(QWidget):
add_projects_search_signal = pyqtSignal()
_message_card: QWidget
_shadow_effect = None
def __init__(self, parent=None):
super(NoModelCardsWidget, self).__init__(parent)
self.parent: "SpeckleQGISv3Dialog" = parent
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setAlignment(Qt.AlignVCenter)
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
self.setStyleSheet(f"{BACKGR_COLOR_LIGHT_GREY2}")
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
)
self._message_card = QWidget()
self._message_card.setStyleSheet(
"QWidget {"
+ f"border-radius: 10px;padding: 20px;margin:{int(0.5 * WIDGET_SIDE_BUFFER)};height: 100px;{BACKGR_COLOR_WHITE}"
+ "}"
)
self._fill_message_card()
self._add_drop_shadow(self._message_card)
self.layout.addWidget(self._message_card)
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):
boxLayout = QVBoxLayout(self._message_card)
# add text
label = QLabel(
"There are no Speckle models being published or loaded in this file yet."
)
label.setStyleSheet(
"QLabel {margin-bottom:10px;padding: 5px;height: 20px;text-align: left;}"
)
boxLayout.addWidget(label)
# add publish / load buttons
button_publish = self._create_search_button()
boxLayout.addWidget(button_publish)
def _create_search_button(self) -> QPushButton:
button_publish = QPushButton("Publish")
button_publish.clicked.connect(lambda: self.add_projects_search_signal.emit())
button_publish.setStyleSheet(
"QWidget {"
+ f"color:white;border-radius: 7px;margin-top:0px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QWidget:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
return button_publish
+167
View File
@@ -0,0 +1,167 @@
from typing import Optional
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
)
from speckle.ui.widgets.widget_cards_list_temporary import (
CardsListTemporaryWidget,
)
from speckle.ui.utils.search_widget_utils import UiSearchUtils
from PyQt5 import QtCore
from PyQt5.QtCore import Qt, QObject
from PyQt5.QtGui import QCursor
from PyQt5.QtWidgets import (
QHBoxLayout,
QWidget,
QLineEdit,
QPushButton,
)
class ProjectSearchWidget(CardsListTemporaryWidget):
ui_search_content: UiSearchUtils = None
account_switch_btn: QPushButton = None
search_widget: QLineEdit = None
def __init__(
self,
*,
parent=None,
label_text: str = "1/3 Select project",
):
# initialize QObject to be able to use pyQt signals before initializing parent class
QObject.__init__(self)
self.parent = parent
# get content for project cards
self.ui_search_content = UiSearchUtils()
# customize load_more function
self._load_more = lambda: self._add_projects(clear_cursor=False)
# initialize the inherited widget, passing the card content
super(ProjectSearchWidget, self).__init__(
parent=parent,
label_text=label_text,
cards_content_list=[],
)
self._add_search_and_account_switch_line()
self._add_projects(clear_cursor=True)
def _add_projects(self, clear_cursor=False, name_filter: Optional[str] = None):
if name_filter is None:
# just get the projects in batches
new_project_cards: list = self.ui_search_content.get_new_projects_content(
clear_cursor=clear_cursor
)
else:
# get the projects that match the name condition
new_project_cards: list = (
self.ui_search_content.get_new_projects_content_with_name_condition(
name_filter=name_filter
)
)
self._add_more_cards(
new_project_cards, clear_cursor, self.ui_search_content.batch_size
)
# adjust size of new widget:
self.resizeEvent()
def refresh_projects(self, name_filter: Optional[str] = None):
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):
# create a line 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)
# project search field
search_widget = self._create_search_widget()
layout_line.addWidget(search_widget)
self.search_widget = search_widget
# New project buttom
new_project_btn = self._create_new_project_btn()
layout_line.addWidget(new_project_btn)
# 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 _create_search_widget(self):
text_box = QLineEdit()
text_box.setMaxLength(20)
text_box.setStyleSheet(
"""QLineEdit { background-color: white; border: 1px solid lightgrey; border-radius: 5px; color: black; height: 30px }"""
)
text_box.textChanged.connect(
lambda input_text: self.refresh_projects(input_text)
)
return text_box
def _create_new_project_btn(self):
new_item_btn = QPushButton("+")
new_item_btn.clicked.connect(
lambda: self.ui_search_content.new_project_widget_signal.emit()
)
new_item_btn.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 5px;{ZERO_MARGIN_PADDING}"
+ f"{BACKGR_COLOR} height:30px; text-align: center; padding: 0px 10px;font-size:14px"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
new_item_btn.setFixedHeight(30)
new_item_btn.setFixedWidth(30)
new_item_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
return new_item_btn
def _create_account_switch_btn(self):
# Account switch buttom
account_switch_btn = QPushButton(" ")
account_switch_btn.clicked.connect(
lambda: self.ui_search_content.select_account_signal.emit()
)
account_switch_btn.setStyleSheet(
"QPushButton {"
+ f"color:white; border-radius: 15px;{ZERO_MARGIN_PADDING}"
+ f"{BACKGR_COLOR} height:30px; text-align: center; padding: 0px 10px;font-size:14px"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
account_switch_btn.setFixedHeight(30)
account_switch_btn.setFixedWidth(30)
account_switch_btn.setText(self.ui_search_content.get_account_initials())
account_switch_btn.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
return account_switch_btn
@@ -0,0 +1,190 @@
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QPushButton,
QLabel,
QGraphicsDropShadowEffect,
)
from speckle.host_apps.qgis.connectors.filters import QgisSelectionFilter
from speckle.ui.bindings import SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
from speckle.ui.widgets.background_widget import BackgroundWidget
from speckle.ui.widgets.utils.global_resources import (
WIDGET_SIDE_BUFFER,
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
BACKGR_COLOR_WHITE,
ZERO_MARGIN_PADDING,
)
class SelectionFilterWidget(QWidget):
add_model_card_signal = pyqtSignal(ModelCard)
background: BackgroundWidget = None
_message_card: QWidget
_model_card: SenderModelCard = None
_selection_info: SelectionInfo
_selection_info_label: QLabel
_shadow_effect = None
def __init__(
self,
parent=None,
model_card: SenderModelCard = None,
label_text: str = "3/3 Select objects",
selection_info: SelectionInfo = None,
):
super(SelectionFilterWidget, self).__init__(parent)
self.parent = parent
self._selection_info = selection_info
# update model card selection filter
selection_filter = QgisSelectionFilter(selection_info.selected_object_ids)
model_card.send_filter = selection_filter
self._model_card = model_card
# 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
label = self._create_text_widget("Selection:")
boxLayout.addWidget(label)
# TODO: replace later with responsive item (to SelectionFilter)
self._selection_info_label: QLabel = self._create_text_widget(
(
"No layers selected, go ahead and select some!"
if not self._selection_info
else self._selection_info.summary
),
"color: blue;",
)
boxLayout.addWidget(self._selection_info_label)
# add publish / load buttons
button_publish = self._create_publish_button()
boxLayout.addWidget(button_publish)
self._add_drop_shadow(self._message_card)
def _create_publish_button(self) -> QPushButton:
button_publish = QPushButton("Publish")
button_publish.clicked.connect(
lambda: self.add_model_card_signal.emit(self._model_card)
)
button_publish.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
def change_selection_info(self, selection_info: SelectionInfo):
# function accessed from the parent dockwidget
# change text on the widget
self._selection_info_label.setText(selection_info.summary)
# change selection info that will be passed to ModelCard
selection_filter = QgisSelectionFilter(selection_info.selected_object_ids)
self._model_card.send_filter = selection_filter
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(1.5 * WIDGET_SIDE_BUFFER),
int(
(self.parent.frameSize().height() - self._message_card.height()) / 2
),
self.parent.frameSize().width() - 3 * WIDGET_SIDE_BUFFER,
self._message_card.height(),
)
except RuntimeError as e:
# e.g. Widget was deleted
pass
+204
View File
@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
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
from resources import *
from qgis.core import Qgis
from qgis.PyQt.QtCore import QCoreApplication, QSettings, Qt, QTranslator
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QDockWidget
SPECKLE_COLOR = (59, 130, 246)
SPECKLE_COLOR_LIGHT = (69, 140, 255)
class SpeckleQGIS(SpeckleQGISv3Module):
"""Speckle Connector Plugin for QGIS"""
dockwidget: Optional["QDockWidget"]
gis_version: str
def __init__(self, iface):
"""Constructor.
:param iface: An interface instance that will be passed to this class
which provides the hook by which you can manipulate the QGIS
application at run time.
:type iface: QgsInterface
"""
setup_metrics()
super(SpeckleQGIS, self).__init__(iface)
# initialize plugin directory
self.plugin_dir = os.path.dirname(__file__)
# initialize locale
locale = QSettings().value("locale/userLocale")[0:2]
locale_path = os.path.join(
self.plugin_dir, "i18n", "SpeckleQGIS_{}.qm".format(locale)
)
if os.path.exists(locale_path):
self.translator = QTranslator()
self.translator.load(locale_path)
QCoreApplication.installTranslator(self.translator)
# Declare instance attributes
self.actions = []
self.menu = self.tr("&Speckle (Beta)")
# Check if plugin was started the first time in current QGIS session
# Must be set in initGui() to survive plugin reloads
self.pluginIsActive = False
self.dockwidget = None
self.gis_version = Qgis.QGIS_VERSION.encode(
"iso-8859-1", errors="ignore"
).decode("utf-8")
self.iface = iface
def tr(self, message: str):
"""Get the translation for a string using Qt translation API.
We implement this ourselves since we do not inherit QObject.
:param message: String for translation.
:type message: str, QString
:returns: Translated version of message.
:rtype: QString
"""
# noinspection PyTypeChecker,PyArgumentList,PyCallByClass
return QCoreApplication.translate("Speckle (Beta)", message)
def add_action(
self,
icon_path: str,
text,
callback,
enabled_flag=True,
add_to_menu=True,
add_to_toolbar=True,
status_tip=None,
whats_this=None,
parent=None,
):
"""Add a toolbar icon to the toolbar.
:param icon_path: Path to the icon for this action. Can be a resource
path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
:type icon_path: str
:param text: Text that should be shown in menu items for this action.
:type text: str
:param callback: Function to be called when the action is triggered.
:type callback: function
:param enabled_flag: A flag indicating if the action should be enabled
by default. Defaults to True.
:type enabled_flag: bool
:param add_to_menu: Flag indicating whether the action should also
be added to the menu. Defaults to True.
:type add_to_menu: bool
:param add_to_toolbar: Flag indicating whether the action should also
be added to the toolbar. Defaults to True.
:type add_to_toolbar: bool
:param status_tip: Optional text to show in a popup when mouse pointer
hovers over the action.
:type status_tip: str
:param parent: Parent widget for the new action. Defaults None.
:type parent: QWidget
:param whats_this: Optional text to show in the status bar when the
mouse pointer hovers over the action.
:returns: The action that was created. Note that the action is also
added to self.actions list.
:rtype: QAction
"""
icon = QIcon(icon_path)
action = QAction(icon, text, parent)
action.triggered.connect(callback)
action.setEnabled(enabled_flag)
if status_tip is not None:
action.setStatusTip(status_tip)
if whats_this is not None:
action.setWhatsThis(whats_this)
if add_to_toolbar:
# Adds plugin icon to Plugins toolbar
self.iface.addToolBarIcon(action)
if add_to_menu:
self.iface.addPluginToWebMenu(self.menu, action)
self.actions.append(action)
return action
def initGui(self):
"""Create the menu entries and toolbar icons inside the QGIS GUI."""
path = os.path.dirname(os.path.abspath(__file__))
icon_path = f"{path}/icon.png"
self.add_action(
icon_path,
text=self.tr("Speckle Beta"), # special characters/brackets are ignored
callback=self.run,
parent=self.iface.mainWindow(),
)
def onClosePlugin(self):
"""Cleanup necessary items here when plugin dockwidget is closed"""
# disconnects
if self.dockwidget:
self.dockwidget.close_plugin_signal.disconnect(self.onClosePlugin)
self.pluginIsActive = False
self.dockwidget.close()
def unload(self):
"""Removes the plugin menu item and icon from QGIS GUI."""
for action in self.actions:
self.iface.removePluginWebMenu(self.tr("&SpeckleQGIS"), action)
self.iface.removeToolBarIcon(action)
def run(self):
"""Run method that performs all the real work"""
# Create the dialog with elements (after translation) and keep reference
# Only create GUI ONCE in callback, so that it will only load when the plugin is started
if self.pluginIsActive:
self.reloadUI()
else:
print("Plugin inactive, launch")
self.pluginIsActive = True
if self.dockwidget is None:
self.create_dockwidget()
# show the dockwidget
self.iface.addDockWidget(Qt.RightDockWidgetArea, self.dockwidget)
# show the dockwidget
self.iface.addDockWidget(Qt.RightDockWidgetArea, self.dockwidget)
self.verify_dependencies()
# connect to close click event in dockwidget
self.dockwidget.close_plugin_signal.connect(self.onClosePlugin)
def reloadUI(self):
return