Compare commits
60 Commits
main
..
alan/test-ci
| Author | SHA1 | Date | |
|---|---|---|---|
| c4812ab092 | |||
| e8306b68f6 | |||
| bc0afeb19e | |||
| 0a2809400e | |||
| d456e7b5ce | |||
| 6bcd182753 | |||
| 9995fcb24a | |||
| 354be78ce8 | |||
| 62cac5d74f | |||
| 532446f8ab | |||
| a7c0a142e7 | |||
| cfbe6b54fa | |||
| b10fdf3035 | |||
| de80273f32 | |||
| f7aeaa851f | |||
| 75c9f8ea3a | |||
| 205c7c5bc7 | |||
| 03ec7f23a2 | |||
| 7dfbd458f9 | |||
| 13fb88b130 | |||
| 3484213be9 | |||
| 6096c017ad | |||
| 63379c495c | |||
| 6fe8bb5f7c | |||
| 2eb52af055 | |||
| 5db67f496e | |||
| 05aa7f9dee | |||
| 453f222a67 | |||
| d4030296de | |||
| 83c1ea4005 | |||
| dc6147005c | |||
| 4ad4650379 | |||
| dbe503be8f | |||
| 24556105f6 | |||
| 5ddaaced6f | |||
| 7954b7fabf | |||
| d40ea1d0ed | |||
| cc3f63dc2b | |||
| f7bbd3b165 | |||
| 2add780d6f | |||
| 35d9ee6e39 | |||
| ef23ef698e | |||
| e806c896e3 | |||
| 0fff76e492 | |||
| cae1f3045b | |||
| 72b834a709 | |||
| 24d6deadf8 | |||
| 52cd86ee0b | |||
| afb3c546dd | |||
| 7ddba1d76e | |||
| 219fb7d20d | |||
| 1d38189ee1 | |||
| 6fa40cc161 | |||
| 7dd57ef29a | |||
| dafb48275d | |||
| 1cca4acd2b | |||
| dc5c539adc | |||
| 0af5b0895e | |||
| f0075185fb | |||
| b958f5b446 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"gitversion.tool": {
|
||||
"version": "6.1.0",
|
||||
"commands": [
|
||||
"dotnet-gitversion"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
name: build_powerbi
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "dev", "release/*", "alan/*"] # Continuous delivery on every long-lived branch
|
||||
tags: ["v3.*"] # Manual delivery on every 3.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: 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
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
workflow: GitFlow/v1
|
||||
next-version: 3.0.0
|
||||
branches:
|
||||
release:
|
||||
prevent-increment:
|
||||
when-current-commit-tagged: true
|
||||
@@ -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)
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# -*- 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
|
||||
|
||||
from qgis.core import Qgis
|
||||
|
||||
# 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("QGIS")
|
||||
|
||||
from speckle_qgis_v3 import SpeckleQGIS
|
||||
from specklepy.logging import metrics
|
||||
|
||||
version = (
|
||||
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
|
||||
.decode("utf-8")
|
||||
.split(".")[0]
|
||||
)
|
||||
metrics.set_host_app("qgis", version)
|
||||
return SpeckleQGIS(iface)
|
||||
|
||||
class EmptyClass:
|
||||
# https://docs.qgis.org/3.28/en/docs/pyqgis_developer_cookbook/plugins/plugins.html#mainplugin-py
|
||||
def __init__(self, iface):
|
||||
pass
|
||||
|
||||
def initGui(self):
|
||||
pass
|
||||
|
||||
def unload(self):
|
||||
pass
|
||||
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = "Stop";
|
||||
|
||||
dotnet run --project ci-build/build.csproj -- $args
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dotnet run --project ci-build/build.csproj -- "$@"
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 714 B |
@@ -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
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def patch_installer(tag):
|
||||
"""Patches the installer with the correct connector version and specklepy version"""
|
||||
iss_file = "speckle-sharp-ci-tools/qgis.iss"
|
||||
metadata = "metadata.txt"
|
||||
plugin_start_file = "speckle_qgis.py"
|
||||
|
||||
try:
|
||||
with open(iss_file, "r") as file:
|
||||
lines = file.readlines()
|
||||
new_lines = []
|
||||
for i, line in enumerate(lines):
|
||||
if "#define AppVersion " in line:
|
||||
line = f'#define AppVersion "{tag.split("-")[0]}"\n'
|
||||
if "#define AppInfoVersion " in line:
|
||||
line = f'#define AppInfoVersion "{tag}"\n'
|
||||
new_lines.append(line)
|
||||
with open(iss_file, "w") as file:
|
||||
file.writelines(new_lines)
|
||||
print(f"Patched installer with connector v{tag} ")
|
||||
file.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
with open(metadata, "r") as file:
|
||||
lines = file.readlines()
|
||||
new_lines = []
|
||||
for i, line in enumerate(lines):
|
||||
if "version=" in line:
|
||||
line = f"version={tag}\n" # .split("-")[0]
|
||||
if "experimental=" in line:
|
||||
if "-" in tag:
|
||||
line = f"experimental=True\n" # .split("-")[0]
|
||||
elif len(tag.split(".")) == 3 and tag != "0.0.99":
|
||||
line = f"experimental=False\n" # .split("-")[0]
|
||||
new_lines.append(line)
|
||||
with open(metadata, "w") as file:
|
||||
file.writelines(new_lines)
|
||||
print(f"Patched metadata v{tag} ")
|
||||
file.close()
|
||||
|
||||
with open(plugin_start_file, "r") as file:
|
||||
lines = file.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
if "self.version = " in line:
|
||||
lines[i] = (
|
||||
lines[i].split('"')[0]
|
||||
+ '"'
|
||||
+ tag.split("-")[0]
|
||||
+ '"'
|
||||
+ lines[i].split('"')[2]
|
||||
)
|
||||
break
|
||||
with open(plugin_start_file, "w") as file:
|
||||
file.writelines(lines)
|
||||
print(f"Patched GIS start file with connector v{tag} and specklepy ")
|
||||
file.close()
|
||||
|
||||
r"""
|
||||
def whlFileRename(fileName: str):
|
||||
with open(fileName, "r") as file:
|
||||
lines = file.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
if "-py3-none-any.whl" in line:
|
||||
p1 = line.split("-py3-none-any.whl")[0].split("-")[0]
|
||||
p2 = f'{tag.split("-")[0]}'
|
||||
p3 = line.split("-py3-none-any.whl")[1]
|
||||
lines[i] = p1+"-"+p2+"-py3-none-any.whl"+p3
|
||||
with open(fileName, "w") as file:
|
||||
file.writelines(lines)
|
||||
print(f"Patched toolbox_installer with connector v{tag} and specklepy ")
|
||||
file.close()
|
||||
|
||||
whlFileRename(conda_file)
|
||||
whlFileRename(toolbox_install_file)
|
||||
whlFileRename(toolbox_manual_install_file)
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
return
|
||||
|
||||
tag = sys.argv[1]
|
||||
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
|
||||
raise ValueError(f"Invalid tag provided: {tag}")
|
||||
|
||||
print(f"Patching version: {tag}")
|
||||
# patch_connector(tag.split("-")[0]) if I need to edit a connector file
|
||||
patch_installer(tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+55
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
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",
|
||||
"-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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
<b>/Users/username/Documents/Speckle/speckle_qgis_v3</b>
|
||||
<p>
|
||||
Your QGIS plugin directory is located at:<br>
|
||||
<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>
|
||||
©2011-2019 GeoApt LLC - geoapt.com
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
@@ -0,0 +1,51 @@
|
||||
annotated-types==0.7.0
|
||||
anyio==4.6.2.post1
|
||||
appdirs==1.4.4
|
||||
attrs==23.2.0
|
||||
backoff==2.2.1
|
||||
certifi==2024.8.30
|
||||
charset-normalizer==3.4.0
|
||||
click-plugins==1.1.1
|
||||
click==8.1.7
|
||||
cligj==0.7.2
|
||||
colorama==0.4.6
|
||||
deprecated==1.2.14
|
||||
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.5
|
||||
h11==0.14.0
|
||||
httpcore==1.0.6
|
||||
httpx==0.25.2
|
||||
idna==3.10
|
||||
importlib-metadata==8.5.0
|
||||
multidict==6.1.0
|
||||
numpy==1.26.4
|
||||
packaging==24.1
|
||||
pandas==2.2.3
|
||||
propcache==0.2.0
|
||||
pydantic-core==2.23.4
|
||||
pydantic==2.9.2
|
||||
pyproj==3.6.1
|
||||
pyshp==2.3.1
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2024.2
|
||||
requests-toolbelt==1.0.0
|
||||
requests==2.31.0
|
||||
scipy==1.13.1
|
||||
shapely==2.0.6
|
||||
six==1.16.0
|
||||
sniffio==1.3.1
|
||||
specklepy==2.21.3
|
||||
stringcase==1.2.0
|
||||
typing-extensions==4.12.2
|
||||
tzdata==2024.2
|
||||
ujson==5.10.0
|
||||
urllib3==2.2.1
|
||||
websockets==11.0.3
|
||||
wrapt==1.16.0
|
||||
yarl==1.17.1
|
||||
zipp==3.20.2
|
||||
@@ -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
|
||||
@@ -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
+1924
File diff suppressed because it is too large
Load Diff
@@ -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.0a1", 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
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/plugins/speckle-qgis-v3" >
|
||||
<file>icon.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -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
|
||||
@@ -0,0 +1,222 @@
|
||||
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 new_func():
|
||||
self.store.document_changed()
|
||||
# TODO
|
||||
# self.send_conversion_cache.clear_cache()
|
||||
|
||||
self.store.document_changed = new_func
|
||||
|
||||
def subscribe_to_qgis_events(self):
|
||||
# TODO
|
||||
return
|
||||
|
||||
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,217 @@
|
||||
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 PyQt5.QtGui import QColor
|
||||
from qgis.core import QgsLayerTreeGroup, QgsVectorLayer, QgsRasterLayer, QgsFeature
|
||||
|
||||
|
||||
class QgisDocumentStore(DocumentModelStore):
|
||||
def __init__(self):
|
||||
self.models = []
|
||||
self.is_document_init = False
|
||||
|
||||
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():
|
||||
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()
|
||||
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,21 @@
|
||||
from typing import Callable
|
||||
|
||||
from speckle.sdk.connectors_common.threading import ThreadContext
|
||||
|
||||
from qgis.core import Qgis
|
||||
|
||||
UNSUPPORTED_PROVIDERS = ["WFS", "wms", "wcs", "vectortile"]
|
||||
HOST_APP_FULL_VERSION = (
|
||||
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
|
||||
.decode("utf-8")
|
||||
.split("-")[0]
|
||||
)
|
||||
|
||||
|
||||
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,101 @@
|
||||
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,
|
||||
QgsPoint,
|
||||
)
|
||||
from osgeo import gdal
|
||||
|
||||
|
||||
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):
|
||||
return core_object.attributeMap()
|
||||
|
||||
elif isinstance(core_object, QgsRasterLayer):
|
||||
return {} # TODO
|
||||
|
||||
return {}
|
||||
@@ -0,0 +1,560 @@
|
||||
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 specklepy.objects.geometry.point import Point
|
||||
from specklepy.objects.geometry.polyline import Polyline
|
||||
from specklepy.objects.geometry.mesh import Mesh
|
||||
|
||||
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[Polyline]:
|
||||
|
||||
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_curves = []
|
||||
for part in target.parts():
|
||||
|
||||
all_curves.append(
|
||||
self._polyline_converter.convert(part.exteriorRing())[0]
|
||||
)
|
||||
|
||||
for i in range(part.numInteriorRings()):
|
||||
all_curves.append(
|
||||
self._polyline_converter.convert(part.interiorRing(i))[0]
|
||||
)
|
||||
return all_curves
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,194 @@
|
||||
# -*- 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.runSetup(self)
|
||||
self.connect_dockwidget_signals()
|
||||
self.connect_self_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
|
||||
|
||||
# all dockwidget subscribtions to child widget signals are handled in Dockwidget class,
|
||||
# because child widget are not persistent
|
||||
|
||||
def connect_self_signals(self):
|
||||
# signal to update UI, needs t be transferred to the main thread
|
||||
self.dockwidget.activity_start_signal.connect(
|
||||
self.dockwidget.add_activity_status
|
||||
)
|
||||
|
||||
def connect_connector_module_signals(self):
|
||||
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",
|
||||
):
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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": "3.0.099",
|
||||
"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(
|
||||
objectId=obj_id,
|
||||
modelId=send_info.model_id,
|
||||
projectId=send_info.project_id,
|
||||
message="Sent from QGIS v3",
|
||||
sourceApplication=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()
|
||||
@@ -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()
|
||||
@@ -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).workspaceId
|
||||
return workspace_id
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.updatedAt)}",
|
||||
]
|
||||
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.updatedAt)}",
|
||||
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")
|
||||
@@ -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[Project]:
|
||||
|
||||
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, projectId=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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,625 @@
|
||||
# -*- 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, plugin):
|
||||
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)
|
||||
|
||||
# create and add header widget
|
||||
self.header_widget = self._create_header(plugin)
|
||||
self.placeholder_widget.layout.addWidget(self.header_widget)
|
||||
|
||||
# cerate 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(plugin)
|
||||
|
||||
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, plugin):
|
||||
|
||||
# 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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,201 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os.path
|
||||
from typing import Optional
|
||||
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
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user