├── cq_editor ├── widgets │ ├── __init__.py │ ├── log.py │ ├── traceback_viewer.py │ ├── console.py │ ├── cq_object_inspector.py │ ├── occt_widget.py │ ├── pyhighlight.py │ ├── object_tree.py │ ├── debugger.py │ └── viewer.py ├── _version.py ├── __init__.py ├── cqe_run.py ├── __main__.py ├── icons.py ├── mixins.py ├── preferences.py ├── utils.py └── cq_utils.py ├── runtests_locally.sh ├── conda ├── run.sh ├── run.bat ├── construct.yaml └── meta.yaml ├── pytest.ini ├── screenshots ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── icons ├── cadquery_logo_dark.ico ├── cadquery_logo_dark.svg ├── back_view.svg ├── bottom_view.svg ├── front_view.svg └── top_view.svg ├── pyinstaller ├── pyi_rth_fontconfig.py └── pyi_rth_occ.py ├── .coveragerc ├── cqgui_env.yml ├── run.py ├── .github └── workflows │ ├── lint.yml │ └── constructor.yml ├── collect_icons.py ├── bundle.py ├── setup.py ├── pyproject.toml ├── changes.md ├── .gitignore ├── pyinstaller.spec ├── appveyor.yml ├── README.md ├── azure-pipelines.yml └── LICENSE /cq_editor/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cq_editor/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.dev0" 2 | -------------------------------------------------------------------------------- /runtests_locally.sh: -------------------------------------------------------------------------------- 1 | python -m pytest --no-xvfb -s 2 | -------------------------------------------------------------------------------- /cq_editor/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | -------------------------------------------------------------------------------- /conda/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec $(dirname $(realpath "$0"))/bin/cq-editor 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | xvfb_args=-ac +extension GLX +render 3 | log_level=DEBUG -------------------------------------------------------------------------------- /conda/run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | start /B condabin\mamba.bat run -n base python Scripts\cq-editor-script.py 3 | -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/HEAD/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/HEAD/screenshots/screenshot2.png -------------------------------------------------------------------------------- /screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/HEAD/screenshots/screenshot3.png -------------------------------------------------------------------------------- /screenshots/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/HEAD/screenshots/screenshot4.png -------------------------------------------------------------------------------- /icons/cadquery_logo_dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/HEAD/icons/cadquery_logo_dark.ico -------------------------------------------------------------------------------- /pyinstaller/pyi_rth_fontconfig.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if sys.platform.startswith("linux"): 5 | os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf" 6 | os.environ["FONTCONFIG_PATH"] = "/etc/fonts/" 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | timid = True 3 | branch = True 4 | source = src 5 | omit = 6 | cq_editor/__main__.py 7 | cq_editor/widgets/pyhighlight.py 8 | 9 | [report] 10 | exclude_lines = 11 | if __name__ == .__main__.: 12 | -------------------------------------------------------------------------------- /cqgui_env.yml: -------------------------------------------------------------------------------- 1 | name: cq-occ-conda-test-py3 2 | channels: 3 | - CadQuery 4 | - conda-forge 5 | dependencies: 6 | - pyqt=5 7 | - pyqtgraph 8 | - python=3.10 9 | - qtawesome=1.4.0 10 | - path 11 | - logbook 12 | - requests 13 | - cadquery 14 | - qtconsole >=5.5.1,<5.7.0 15 | -------------------------------------------------------------------------------- /pyinstaller/pyi_rth_occ.py: -------------------------------------------------------------------------------- 1 | from os import environ as env 2 | 3 | env["CASROOT"] = "opencascade" 4 | 5 | env["CSF_ShadersDirectory"] = "opencascade/src/Shaders" 6 | env["CSF_UnitsLexicon"] = "opencascade/src/UnitsAPI/Lexi_Expr.dat" 7 | env["CSF_UnitsDefinition"] = "opencascade/src/UnitsAPI/Units.dat" 8 | -------------------------------------------------------------------------------- /cq_editor/cqe_run.py: -------------------------------------------------------------------------------- 1 | import os, sys, asyncio 2 | 3 | if "CASROOT" in os.environ: 4 | del os.environ["CASROOT"] 5 | 6 | if sys.platform == "win32": 7 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 8 | 9 | from cq_editor.__main__ import main 10 | 11 | 12 | if __name__ == "__main__": 13 | main() 14 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import os, sys, asyncio 2 | import faulthandler 3 | 4 | faulthandler.enable() 5 | 6 | if "CASROOT" in os.environ: 7 | del os.environ["CASROOT"] 8 | 9 | if sys.platform == "win32": 10 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 11 | 12 | from cq_editor.__main__ import main 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v5 11 | with: 12 | python-version: '3.11' 13 | - run: | 14 | python -m pip install --upgrade pip 15 | python -m pip install -e .[dev] 16 | black --diff --check . --exclude icons_res.py 17 | -------------------------------------------------------------------------------- /conda/construct.yaml: -------------------------------------------------------------------------------- 1 | name: CQ-editor 2 | company: Cadquery 3 | version: master 4 | icon_image: ../icons/cadquery_logo_dark.ico 5 | license_file: ../LICENSE 6 | register_python: False 7 | initialize_conda: False 8 | keep_pkgs: False 9 | 10 | channels: 11 | - conda-forge 12 | - cadquery 13 | 14 | specs: 15 | - cq-editor=master 16 | - cadquery=master 17 | - nomkl 18 | - mamba 19 | 20 | menu_packages: [] 21 | 22 | extra_files: 23 | - run.sh # [unix] 24 | - run.bat # [win] 25 | -------------------------------------------------------------------------------- /collect_icons.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | from subprocess import call 3 | from os import remove 4 | 5 | TEMPLATE = """ 6 | 7 | {} 8 | 9 | """ 10 | 11 | ITEM_TEMPLATE = "{}" 12 | 13 | QRC_OUT = "icons.qrc" 14 | RES_OUT = "src/icons_res.py" 15 | TOOL = "pyrcc5" 16 | 17 | items = [] 18 | 19 | for i in glob("icons/*.svg"): 20 | items.append(ITEM_TEMPLATE.format(i)) 21 | 22 | 23 | qrc_text = TEMPLATE.format("\n".join(items)) 24 | 25 | with open(QRC_OUT, "w") as f: 26 | f.write(qrc_text) 27 | 28 | call([TOOL, QRC_OUT, "-o", RES_OUT]) 29 | remove(QRC_OUT) 30 | -------------------------------------------------------------------------------- /bundle.py: -------------------------------------------------------------------------------- 1 | from sys import platform 2 | from path import Path 3 | from os import system 4 | from shutil import make_archive 5 | from cq_editor import __version__ as version 6 | 7 | out_p = Path("dist/CQ-editor") 8 | out_p.rmtree_p() 9 | 10 | build_p = Path("build") 11 | build_p.rmtree_p() 12 | 13 | system("pyinstaller pyinstaller.spec") 14 | 15 | if platform == "linux": 16 | with out_p: 17 | p = Path(".").glob("libpython*")[0] 18 | p.symlink(p.split(".so")[0] + ".so") 19 | 20 | make_archive(f"CQ-editor-{version}-linux64", "bztar", out_p / "..", "CQ-editor") 21 | 22 | elif platform == "win32": 23 | 24 | make_archive(f"CQ-editor-{version}-win64", "zip", out_p / "..", "CQ-editor") 25 | -------------------------------------------------------------------------------- /conda/meta.yaml: -------------------------------------------------------------------------------- 1 | package: 2 | name: cq-editor 3 | version: {{ environ.get('PACKAGE_VERSION') }} 4 | 5 | source: 6 | path: .. 7 | 8 | build: 9 | string: {{ GIT_DESCRIBE_TAG }}_{{ GIT_BUILD_STR }} 10 | noarch: python 11 | script: python setup.py install --single-version-externally-managed --record=record.txt 12 | entry_points: 13 | - cq-editor = cq_editor.__main__:main 14 | - CQ-editor = cq_editor.__main__:main 15 | requirements: 16 | build: 17 | - python >=3.10 18 | - setuptools 19 | 20 | run: 21 | - python >=3.10 22 | - cadquery=master 23 | - ocp 24 | - logbook 25 | - pyqt=5.* 26 | - pyqtgraph 27 | - qtawesome=1.4.0 28 | - path 29 | - logbook 30 | - requests 31 | - qtconsole >=5.5.1,<5.7.0 32 | test: 33 | imports: 34 | - cq_editor 35 | 36 | about: 37 | summary: GUI for CadQuery 2 38 | -------------------------------------------------------------------------------- /cq_editor/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | 4 | from PyQt5.QtWidgets import QApplication 5 | 6 | NAME = "CQ-editor" 7 | 8 | # need to initialize QApp here, otherewise svg icons do not work on windows 9 | app = QApplication(sys.argv, applicationName=NAME) 10 | 11 | from .main_window import MainWindow 12 | 13 | 14 | def main(): 15 | 16 | parser = argparse.ArgumentParser(description=NAME) 17 | parser.add_argument("filename", nargs="?", default=None) 18 | 19 | args = parser.parse_args(app.arguments()[1:]) 20 | 21 | # sys.exit(app.exec_()) 22 | 23 | try: 24 | win = MainWindow(filename=args.filename if args.filename else None) 25 | win.show() 26 | app.exec_() 27 | except Exception as e: 28 | import traceback 29 | 30 | traceback.print_exc() 31 | 32 | 33 | if __name__ == "__main__": 34 | 35 | main() 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os.path 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | def read(rel_path): 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | with codecs.open(os.path.join(here, rel_path), "r") as fp: 10 | return fp.read() 11 | 12 | 13 | def get_version(rel_path): 14 | for line in read(rel_path).splitlines(): 15 | if line.startswith("__version__"): 16 | delim = '"' if '"' in line else "'" 17 | return line.split(delim)[1] 18 | else: 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | 22 | setup( 23 | name="CQ-editor", 24 | version=get_version("cq_editor/_version.py"), 25 | packages=find_packages(), 26 | entry_points={ 27 | "gui_scripts": [ 28 | "cq-editor = cq_editor.__main__:main", 29 | "CQ-editor = cq_editor.__main__:main", 30 | ] 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "CQ-editor" 7 | version = "0.6.dev0" 8 | dependencies = [ 9 | "cadquery", 10 | "pyqtgraph", 11 | "qtawesome==1.4.0", 12 | "path", 13 | "logbook", 14 | "requests", 15 | "qtconsole>=5.5.1,<5.7.0", 16 | "packaging" 17 | ] 18 | requires-python = ">=3.10,<=3.14" 19 | authors = [ 20 | { name="CadQuery Developers" } 21 | ] 22 | maintainers = [ 23 | { name="CadQuery Developers" } 24 | ] 25 | description = "CadQuery plugin to create a mesh of an assembly with corresponding data" 26 | readme = "README.md" 27 | license = {file = "LICENSE"} 28 | keywords = ["cadquery", "CAD", "engineering", "design"] 29 | classifiers = [ 30 | "Development Status :: 4 - Beta", 31 | "Programming Language :: Python" 32 | ] 33 | 34 | [project.scripts] 35 | CQ-editor = "cq_editor.cqe_run:main" 36 | cq-editor = "cq_editor.cqe_run:main" 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "pytest", 41 | "pluggy", 42 | "pytest-qt", 43 | "pytest-mock", 44 | "pytest-repeat", 45 | "pyvirtualdisplay" 46 | ] 47 | dev = [ 48 | "black", 49 | ] 50 | 51 | [project.urls] 52 | Repository = "https://github.com/CadQuery/CQ-editor.git" 53 | "Bug Tracker" = "https://github.com/CadQuery/CQ-editor/issues" 54 | -------------------------------------------------------------------------------- /changes.md: -------------------------------------------------------------------------------- 1 | # Release 0.5.0 2 | 3 | * Implemented a dark theme and tweaked it to work better with icons (#490) 4 | * Fixed bug causing the cursor to jump when autoreload was enabled (#496) 5 | * Added a max line length indicator (#495) 6 | * Mentioned work-around for Linux-Wayland users in the readme (#497) 7 | * Fixed bug where dragging over an object while rotating would select the object (#498) 8 | * Changed alpha setting in `show_object` to be consistent with other CadQuery alpha settings (#499) 9 | * Added ability to clear the Log View and Console (#500) 10 | * Started preserving undo history across saves (#501) 11 | * Updated run.sh script to find the real path to the executable (#505) 12 | 13 | # Release 0.4.0 14 | 15 | * Updated version pins in order to fix some issues, including segfaults on Python 3.12 16 | * Changed to forcing UTF-8 when saving (#480) 17 | * Added `Toggle Comment` Edit menu item with a `Ctrl+/` hotkey (#481) 18 | * Fixed the case where long exceptions would force the window to expand (#481) 19 | * Add stdout redirect so that print statements could be used and passed to log viewer (#481) 20 | * Improvements in stdout redirect method (#483 and #485) 21 | * Fixed preferences drop downs not populating (#484) 22 | * Fixed double-render calls on saves in some cases (#486) 23 | * Added a lint check to CI and linted codebase (#487) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /pyinstaller.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | import sys, site, os 4 | from path import Path 5 | 6 | block_cipher = None 7 | 8 | spyder_data = Path(site.getsitepackages()[-1]) / 'spyder' 9 | parso_grammar = (Path(site.getsitepackages()[-1]) / 'parso/python').glob('grammar*') 10 | 11 | if sys.platform == 'linux': 12 | occt_dir = os.path.join(Path(sys.prefix), 'share', 'opencascade') 13 | ocp_path = (os.path.join(HOMEPATH, 'OCP.cpython-38-x86_64-linux-gnu.so'), '.') 14 | elif sys.platform == 'darwin': 15 | occt_dir = os.path.join(Path(sys.prefix), 'share', 'opencascade') 16 | ocp_path = (os.path.join(HOMEPATH, 'OCP.cpython-38-darwin.so'), '.') 17 | elif sys.platform == 'win32': 18 | occt_dir = os.path.join(Path(sys.prefix), 'Library', 'share', 'opencascade') 19 | ocp_path = (os.path.join(HOMEPATH, 'OCP.cp38-win_amd64.pyd'), '.') 20 | 21 | a = Analysis(['run.py'], 22 | pathex=['.'], 23 | binaries=[ocp_path], 24 | datas=[(spyder_data, 'spyder'), 25 | (occt_dir, 'opencascade')] + 26 | [(p, 'parso/python') for p in parso_grammar], 27 | hiddenimports=['ipykernel.datapub'], 28 | hookspath=[], 29 | runtime_hooks=['pyinstaller/pyi_rth_occ.py', 30 | 'pyinstaller/pyi_rth_fontconfig.py'], 31 | excludes=['_tkinter',], 32 | win_no_prefer_redirects=False, 33 | win_private_assemblies=False, 34 | cipher=block_cipher, 35 | noarchive=False) 36 | 37 | pyz = PYZ(a.pure, a.zipped_data, 38 | cipher=block_cipher) 39 | exe = EXE(pyz, 40 | a.scripts, 41 | [], 42 | exclude_binaries=True, 43 | name='CQ-editor', 44 | debug=False, 45 | bootloader_ignore_signals=False, 46 | strip=False, 47 | upx=True, 48 | console=True, 49 | icon='icons/cadquery_logo_dark.ico') 50 | 51 | exclude = ('libGL','libEGL','libbsd') 52 | a.binaries = TOC([x for x in a.binaries if not x[0].startswith(exclude)]) 53 | 54 | coll = COLLECT(exe, 55 | a.binaries, 56 | a.zipfiles, 57 | a.datas, 58 | strip=False, 59 | upx=True, 60 | name='CQ-editor') 61 | -------------------------------------------------------------------------------- /cq_editor/widgets/log.py: -------------------------------------------------------------------------------- 1 | import logbook as logging 2 | import re 3 | 4 | from PyQt5 import QtGui 5 | from PyQt5.QtCore import QObject, pyqtSignal 6 | from PyQt5.QtWidgets import QPlainTextEdit, QAction 7 | 8 | from ..mixins import ComponentMixin 9 | 10 | from ..icons import icon 11 | 12 | 13 | def strip_escape_sequences(input_string): 14 | # Regular expression pattern to match ANSI escape codes 15 | escape_pattern = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") 16 | 17 | # Use re.sub to replace escape codes with an empty string 18 | clean_string = re.sub(escape_pattern, "", input_string) 19 | 20 | return clean_string 21 | 22 | 23 | class _QtLogHandlerQObject(QObject): 24 | sigRecordEmit = pyqtSignal(str) 25 | 26 | 27 | class QtLogHandler(logging.Handler, logging.StringFormatterHandlerMixin): 28 | 29 | def __init__(self, log_widget, *args, **kwargs): 30 | 31 | super(QtLogHandler, self).__init__(*args, **kwargs) 32 | 33 | log_format_string = ( 34 | "[{record.time:%H:%M:%S%z}] {record.level_name}: {record.message}" 35 | ) 36 | 37 | logging.StringFormatterHandlerMixin.__init__(self, log_format_string) 38 | 39 | self._qobject = _QtLogHandlerQObject() 40 | self._qobject.sigRecordEmit.connect(log_widget.append) 41 | 42 | def emit(self, record): 43 | self._qobject.sigRecordEmit.emit(self.format(record) + "\n") 44 | 45 | 46 | class LogViewer(QPlainTextEdit, ComponentMixin): 47 | 48 | name = "Log viewer" 49 | 50 | def __init__(self, *args, **kwargs): 51 | 52 | super(LogViewer, self).__init__(*args, **kwargs) 53 | self._MAX_ROWS = 500 54 | 55 | self._actions = { 56 | "Run": [ 57 | QAction(icon("clear"), "Clear Log", self, triggered=self.clear), 58 | ] 59 | } 60 | 61 | self.setReadOnly(True) 62 | self.setMaximumBlockCount(self._MAX_ROWS) 63 | self.setLineWrapMode(QPlainTextEdit.NoWrap) 64 | 65 | self.handler = QtLogHandler(self) 66 | 67 | def append(self, msg): 68 | """Append text to the panel with ANSI escape sequences stipped.""" 69 | self.moveCursor(QtGui.QTextCursor.End) 70 | self.insertPlainText(strip_escape_sequences(msg)) 71 | 72 | def clear_log(self): 73 | """ 74 | Clear the log content. 75 | """ 76 | self.clear() 77 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | shallow_clone: false 2 | 3 | image: 4 | - Ubuntu2204 5 | - Visual Studio 2019 6 | 7 | environment: 8 | matrix: 9 | - PYTEST_QT_API: pyqt5 10 | CODECOV_TOKEN: 11 | secure: ZggK9wgDeFdTp0pu0MEV+SY4i/i1Ls0xrEC2MxSQOQ0JQV+TkpzJJzI4au7L8TpD 12 | MINICONDA_DIRNAME: C:\FreshMiniconda 13 | 14 | install: 15 | - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then sudo apt update; sudo apt -y --force-yes install libglu1-mesa xvfb libgl1-mesa-dri mesa-common-dev libglu1-mesa-dev; fi 16 | - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/download/25.3.1-0/Miniforge3-Linux-x86_64.sh; fi 17 | - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE == "macOS"* ]]; then curl -fsSL -o miniconda.sh https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Darwin-x86_64.sh; fi 18 | - sh: bash miniconda.sh -b -p $HOME/miniconda 19 | - sh: source $HOME/miniconda/bin/activate 20 | - cmd: curl -fsSL -o miniconda.exe https://github.com/conda-forge/miniforge/releases/download/24.11.3-2/Miniforge3-Windows-x86_64.exe 21 | - cmd: miniconda.exe /S /InstallationType=JustMe /D=%MINICONDA_DIRNAME% 22 | - cmd: set "PATH=%MINICONDA_DIRNAME%;%MINICONDA_DIRNAME%\\Scripts;%PATH%" 23 | - cmd: activate 24 | - conda info 25 | - conda env create -y --name cqgui -f cqgui_env.yml 26 | - sh: source activate cqgui 27 | - cmd: activate cqgui 28 | - conda list 29 | - conda install -y pytest pluggy pytest-qt 30 | - conda install -y pytest-mock pytest-cov pytest-repeat codecov pyvirtualdisplay 31 | 32 | build: false 33 | 34 | before_test: 35 | - sh: ulimit -c unlimited -S 36 | - sh: sudo rm -f /cores/core.* 37 | 38 | test_script: 39 | - sh: export PYTHONPATH=$(pwd) 40 | - cmd: set PYTHONPATH=%cd% 41 | - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE != "macOS"* ]]; then xvfb-run -s '-screen 0 1920x1080x24 +iglx' pytest -v --cov=cq_editor; fi 42 | - sh: if [[ $APPVEYOR_BUILD_WORKER_IMAGE == "macOS"* ]]; then pytest -v --cov=cq_editor; fi 43 | - cmd: pytest -v --cov=cq_editor 44 | 45 | on_success: 46 | - codecov 47 | 48 | #on_failure: 49 | # - qtdiag 50 | # - ls /cores/core.* 51 | # - lldb --core `ls /cores/core.*` --batch --one-line "bt" 52 | 53 | on_finish: 54 | # - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) 55 | # - sh: export APPVEYOR_SSH_BLOCK=true 56 | # - sh: curl -sflL 'https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-ssh.sh' | bash -e - 57 | -------------------------------------------------------------------------------- /cq_editor/icons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Fri May 25 14:47:10 2018 5 | 6 | @author: adam 7 | """ 8 | 9 | from PyQt5.QtGui import QIcon 10 | 11 | from . import icons_res 12 | 13 | _icons = {"app": QIcon(":/images/icons/cadquery_logo_dark.svg")} 14 | 15 | import qtawesome as qta 16 | 17 | _icons_specs = { 18 | "new": (("fa5.file",), {}), 19 | "open": (("fa5.folder-open",), {}), 20 | # borrowed from spider-ide 21 | "autoreload": [ 22 | ("fa5s.redo-alt", "fa5.clock"), 23 | { 24 | "options": [ 25 | {"scale_factor": 0.75, "offset": (-0.1, -0.1)}, 26 | {"scale_factor": 0.5, "offset": (0.25, 0.25)}, 27 | ] 28 | }, 29 | ], 30 | "save": (("fa5.save",), {}), 31 | "save_as": ( 32 | ("fa5.save", "fa5s.pencil-alt"), 33 | { 34 | "options": [ 35 | { 36 | "scale_factor": 1, 37 | }, 38 | {"scale_factor": 0.8, "offset": (0.2, 0.2)}, 39 | ] 40 | }, 41 | ), 42 | "run": (("fa5s.play",), {}), 43 | "debug": (("fa5s.bug",), {}), 44 | "delete": (("fa5s.trash",), {}), 45 | "delete-many": ( 46 | ( 47 | "fa5s.trash", 48 | "fa5s.trash", 49 | ), 50 | { 51 | "options": [ 52 | {"scale_factor": 0.8, "offset": (0.2, 0.2), "color": "gray"}, 53 | {"scale_factor": 0.8}, 54 | ] 55 | }, 56 | ), 57 | "help": (("fa5s.life-ring",), {}), 58 | "about": (("fa5s.info",), {}), 59 | "preferences": (("fa5s.cogs",), {}), 60 | "inspect": ( 61 | ("fa5s.cubes", "fa5s.search"), 62 | {"options": [{"scale_factor": 0.8, "offset": (0, 0), "color": "gray"}, {}]}, 63 | ), 64 | "screenshot": (("fa5s.camera",), {}), 65 | "screenshot-save": ( 66 | ("fa5.save", "fa5s.camera"), 67 | { 68 | "options": [ 69 | {"scale_factor": 0.8}, 70 | {"scale_factor": 0.8, "offset": (0.2, 0.2)}, 71 | ] 72 | }, 73 | ), 74 | "toggle-comment": (("fa5s.hashtag",), {}), 75 | "search": (("fa5s.search",), {}), 76 | "arrow-step-over": (("fa5s.step-forward",), {}), 77 | "arrow-step-in": (("fa5s.angle-down",), {}), 78 | "arrow-continue": (("fa5s.arrow-right",), {}), 79 | "clear": (("fa5s.eraser",), {}), 80 | "clear-2": (("fa5s.broom",), {}), 81 | } 82 | 83 | 84 | def icon(name): 85 | 86 | if name in _icons: 87 | return _icons[name] 88 | 89 | args, kwargs = _icons_specs[name] 90 | 91 | return qta.icon(*args, **kwargs) 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CQ-editor 2 | 3 | [![Build status](https://ci.appveyor.com/api/projects/status/g98rs7la393mgy91/branch/master?svg=true)](https://ci.appveyor.com/project/adam-urbanczyk/cq-editor/branch/master) 4 | [![codecov](https://codecov.io/gh/CadQuery/CQ-editor/branch/master/graph/badge.svg)](https://codecov.io/gh/CadQuery/CQ-editor) 5 | [![Build Status](https://dev.azure.com/cadquery/CQ-editor/_apis/build/status/CadQuery.CQ-editor?branchName=master)](https://dev.azure.com/cadquery/CQ-editor/_build/latest?definitionId=3&branchName=master) 6 | [![DOI](https://zenodo.org/badge/136604983.svg)](https://zenodo.org/badge/latestdoi/136604983) 7 | 8 | CadQuery GUI editor based on PyQT that supports Linux, Windows and Mac. 9 | 10 | ![CQ-editor screenshot](https://github.com/CadQuery/CQ-editor/raw/master/screenshots/screenshot4.png) 11 | Additional screenshots are available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki#screenshots). 12 | 13 | ## Notable features 14 | 15 | * Automatic code reloading - you can use your favourite editor 16 | * OCCT based 17 | * Graphical debugger for CadQuery scripts 18 | * Step through script and watch how your model changes 19 | * CadQuery object stack inspector 20 | * Visual inspection of current workplane and selected items 21 | * Insight into evolution of the model 22 | * Export to various formats 23 | * STL 24 | * STEP 25 | 26 | ## Documentation 27 | 28 | Documentation is available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki). Topics covered are the following. 29 | 30 | * [Installation](https://github.com/CadQuery/CQ-editor/wiki/Installation) 31 | * [Usage](https://github.com/CadQuery/CQ-editor/wiki/Usage) 32 | * [Configuration](https://github.com/CadQuery/CQ-editor/wiki/Configuration) 33 | 34 | ## Getting Help 35 | 36 | For general questions and discussion about CQ-editor, please create a [GitHub Discussion](https://github.com/CadQuery/CQ-editor/discussions). 37 | 38 | ## Reporting a Bug 39 | 40 | If you believe that you have found a bug in CQ-editor, please ensure the following. 41 | 42 | * You are not running a CQ-editor fork, as these are not always synchronized with the latest updates in this project. 43 | * You have searched the [issue tracker](https://github.com/CadQuery/CQ-editor/issues) to make sure that the bug is not already known. 44 | 45 | If you have already checked those things, please file a [new issue](https://github.com/CadQuery/CQ-editor/issues/new) with the following information. 46 | 47 | * Operating System (type, version, etc) - If running Linux, please include the distribution and the version. 48 | * How CQ-editor was installed. 49 | * Python version of your environment (unless you are running a pre-built package). 50 | * Steps to reproduce the bug. 51 | -------------------------------------------------------------------------------- /.github/workflows/constructor.yml: -------------------------------------------------------------------------------- 1 | # Set this workflow up to run on pushes to the main branch 2 | name: Constructor 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | constructor-linux: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Conda 16 | uses: conda-incubator/setup-miniconda@v3 17 | with: 18 | miniconda-version: "latest" 19 | activate-environment: constructor 20 | 21 | - name: Create Conda environment 22 | shell: bash -el {0} 23 | run: | 24 | conda install -c conda-forge constructor conda-libmamba-solver 25 | mkdir out && cd out && constructor ../conda 26 | 27 | - name: Upload Constructor output 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: CQ-editor-master-Linux-x86_64 31 | path: out/*.* 32 | constructor-windows: 33 | runs-on: windows-latest 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | 38 | - name: Set up Conda 39 | uses: conda-incubator/setup-miniconda@v3 40 | with: 41 | miniconda-version: "latest" 42 | activate-environment: constructor 43 | 44 | - name: Create Conda environment 45 | shell: bash -el {0} 46 | run: | 47 | conda install -c conda-forge constructor conda-libmamba-solver 48 | mkdir out && cd out && constructor ../conda 49 | 50 | - name: Upload Constructor output 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: CQ-editor-master-Windows-x86_64 54 | path: out/*.* 55 | constructor-macos-arm64: 56 | runs-on: macos-14 57 | steps: 58 | - name: Checkout repository 59 | uses: actions/checkout@v4 60 | 61 | - name: Set up Conda 62 | uses: conda-incubator/setup-miniconda@v3 63 | with: 64 | miniconda-version: "latest" 65 | activate-environment: constructor 66 | 67 | - name: Create Conda environment 68 | shell: bash -el {0} 69 | run: | 70 | conda install -c conda-forge constructor conda-libmamba-solver 71 | mkdir out && cd out && constructor ../conda 72 | 73 | - name: Upload Constructor output 74 | uses: actions/upload-artifact@v4 75 | with: 76 | name: CQ-editor-master-MacOSX-ARM64 77 | path: out/*.* 78 | constructor-macos-x86_64: 79 | runs-on: macos-15-intel 80 | steps: 81 | - name: Checkout repository 82 | uses: actions/checkout@v4 83 | 84 | - name: Set up Conda 85 | uses: conda-incubator/setup-miniconda@v3 86 | with: 87 | miniconda-version: "latest" 88 | activate-environment: constructor 89 | 90 | - name: Create Conda environment 91 | shell: bash -el {0} 92 | run: | 93 | conda install -c conda-forge constructor conda-libmamba-solver 94 | mkdir out && cd out && constructor ../conda 95 | 96 | - name: Upload Constructor output 97 | uses: actions/upload-artifact@v4 98 | with: 99 | name: CQ-editor-master-MacOSX-x86_64 100 | path: out/*.* 101 | -------------------------------------------------------------------------------- /cq_editor/mixins.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Created on Wed May 23 22:02:30 2018 5 | 6 | @author: adam 7 | """ 8 | 9 | from functools import reduce 10 | from operator import add 11 | from logbook import Logger 12 | 13 | from PyQt5.QtCore import pyqtSlot, QSettings 14 | 15 | 16 | class MainMixin(object): 17 | 18 | name = "Main" 19 | org = "Unknown" 20 | 21 | components = {} 22 | docks = {} 23 | preferences = None 24 | 25 | def __init__(self): 26 | 27 | self.settings = QSettings(self.org, self.name) 28 | 29 | def registerComponent(self, name, component, dock=None): 30 | 31 | self.components[name] = component 32 | 33 | if dock: 34 | self.docks[name] = dock(component) 35 | 36 | def saveWindow(self): 37 | 38 | self.settings.setValue("geometry", self.saveGeometry()) 39 | self.settings.setValue("windowState", self.saveState()) 40 | 41 | def restoreWindow(self): 42 | 43 | if self.settings.value("geometry"): 44 | self.restoreGeometry(self.settings.value("geometry")) 45 | if self.settings.value("windowState"): 46 | self.restoreState(self.settings.value("windowState")) 47 | 48 | def savePreferences(self): 49 | 50 | settings = self.settings 51 | 52 | if self.preferences: 53 | settings.setValue("General", self.preferences.saveState()) 54 | 55 | for comp in (c for c in self.components.values() if c.preferences): 56 | settings.setValue(comp.name, comp.preferences.saveState()) 57 | 58 | def restorePreferences(self): 59 | 60 | settings = self.settings 61 | 62 | if self.preferences and settings.value("General"): 63 | self.preferences.restoreState( 64 | settings.value("General"), removeChildren=False 65 | ) 66 | 67 | for comp in (c for c in self.components.values() if c.preferences): 68 | if settings.value(comp.name): 69 | comp.preferences.restoreState( 70 | settings.value(comp.name), removeChildren=False 71 | ) 72 | 73 | def saveComponentState(self): 74 | 75 | settings = self.settings 76 | 77 | for comp in self.components.values(): 78 | comp.saveComponentState(settings) 79 | 80 | def restoreComponentState(self): 81 | 82 | settings = self.settings 83 | 84 | for comp in self.components.values(): 85 | comp.restoreComponentState(settings) 86 | 87 | 88 | class ComponentMixin(object): 89 | 90 | name = "Component" 91 | preferences = None 92 | 93 | _actions = {} 94 | 95 | def __init__(self): 96 | 97 | if self.preferences: 98 | self.preferences.sigTreeStateChanged.connect(self.updatePreferences) 99 | 100 | self._logger = Logger(self.name) 101 | 102 | def menuActions(self): 103 | 104 | return self._actions 105 | 106 | def toolbarActions(self): 107 | 108 | if len(self._actions) > 0: 109 | return reduce(add, [a for a in self._actions.values()]) 110 | else: 111 | return [] 112 | 113 | @pyqtSlot(object, object) 114 | def updatePreferences(self, *args): 115 | 116 | pass 117 | 118 | def saveComponentState(self, store): 119 | 120 | pass 121 | 122 | def restoreComponentState(self, store): 123 | 124 | pass 125 | -------------------------------------------------------------------------------- /cq_editor/widgets/traceback_viewer.py: -------------------------------------------------------------------------------- 1 | from traceback import extract_tb, format_exception_only 2 | from itertools import dropwhile 3 | 4 | from PyQt5.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QAction, QLabel 5 | from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal 6 | from PyQt5.QtGui import QFontMetrics 7 | 8 | from ..mixins import ComponentMixin 9 | from ..utils import layout 10 | 11 | 12 | class TracebackTree(QTreeWidget): 13 | 14 | name = "Traceback Viewer" 15 | 16 | def __init__(self, parent): 17 | 18 | super(TracebackTree, self).__init__(parent) 19 | self.setHeaderHidden(False) 20 | self.setItemsExpandable(False) 21 | self.setRootIsDecorated(False) 22 | self.setContextMenuPolicy(Qt.ActionsContextMenu) 23 | 24 | self.setColumnCount(3) 25 | self.setHeaderLabels(["File", "Line", "Code"]) 26 | 27 | self.root = self.invisibleRootItem() 28 | 29 | 30 | class TracebackPane(QWidget, ComponentMixin): 31 | 32 | sigHighlightLine = pyqtSignal(int) 33 | 34 | def __init__(self, parent): 35 | 36 | super(TracebackPane, self).__init__(parent) 37 | 38 | self.tree = TracebackTree(self) 39 | self.current_exception = QLabel(self) 40 | self.current_exception.setStyleSheet("QLabel {color : red; }") 41 | 42 | layout(self, (self.current_exception, self.tree), self) 43 | 44 | self.tree.currentItemChanged.connect(self.handleSelection) 45 | 46 | def truncate_text(self, text, max_length=100): 47 | """ 48 | Used to prevent the label from expanding the window width off the screen. 49 | """ 50 | metrics = QFontMetrics(self.current_exception.font()) 51 | elided_text = metrics.elidedText( 52 | text, Qt.ElideRight, self.current_exception.width() - 75 53 | ) 54 | 55 | return elided_text 56 | 57 | @pyqtSlot(object, str) 58 | def addTraceback(self, exc_info, code): 59 | 60 | self.tree.clear() 61 | 62 | if exc_info: 63 | t, exc, tb = exc_info 64 | 65 | root = self.tree.root 66 | code = code.splitlines() 67 | 68 | for el in dropwhile( 69 | lambda el: "string>" not in el.filename, extract_tb(tb) 70 | ): 71 | # workaround of the traceback module 72 | if el.line == "": 73 | line = code[el.lineno - 1].strip() 74 | else: 75 | line = el.line 76 | 77 | root.addChild(QTreeWidgetItem([el.filename, str(el.lineno), line])) 78 | 79 | exc_name = t.__name__ 80 | exc_msg = str(exc) 81 | exc_msg = exc_msg.replace("<", "<").replace(">", ">") # replace <> 82 | 83 | truncated_msg = self.truncate_text(exc_msg) 84 | self.current_exception.setText( 85 | "{}: {}".format(exc_name, truncated_msg) 86 | ) 87 | self.current_exception.setToolTip(exc_msg) 88 | 89 | # handle the special case of a SyntaxError 90 | if t is SyntaxError: 91 | root.addChild( 92 | QTreeWidgetItem( 93 | [ 94 | exc.filename, 95 | str(exc.lineno), 96 | exc.text.strip() if exc.text else "", 97 | ] 98 | ) 99 | ) 100 | else: 101 | self.current_exception.setText("") 102 | self.current_exception.setToolTip("") 103 | 104 | @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) 105 | def handleSelection(self, item, *args): 106 | 107 | if item: 108 | f, line = item.data(0, 0), int(item.data(1, 0)) 109 | 110 | if "" in f: 111 | self.sigHighlightLine.emit(line) 112 | -------------------------------------------------------------------------------- /cq_editor/preferences.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QStackedWidget, QDialog 2 | from PyQt5.QtCore import pyqtSlot, Qt 3 | 4 | from pyqtgraph.parametertree import ParameterTree 5 | 6 | from .utils import splitter, layout 7 | 8 | 9 | class PreferencesTreeItem(QTreeWidgetItem): 10 | 11 | def __init__( 12 | self, 13 | name, 14 | widget, 15 | ): 16 | 17 | super(PreferencesTreeItem, self).__init__(name) 18 | self.widget = widget 19 | 20 | 21 | class PreferencesWidget(QDialog): 22 | 23 | def __init__(self, parent, components): 24 | 25 | super(PreferencesWidget, self).__init__( 26 | parent, 27 | Qt.Window | Qt.CustomizeWindowHint | Qt.WindowCloseButtonHint, 28 | windowTitle="Preferences", 29 | ) 30 | 31 | self.stacked = QStackedWidget(self) 32 | self.preferences_tree = QTreeWidget( 33 | self, 34 | headerHidden=True, 35 | itemsExpandable=False, 36 | rootIsDecorated=False, 37 | columnCount=1, 38 | ) 39 | 40 | self.root = self.preferences_tree.invisibleRootItem() 41 | 42 | self.add("General", parent) 43 | 44 | for v in parent.components.values(): 45 | self.add(v.name, v) 46 | 47 | self.splitter = splitter((self.preferences_tree, self.stacked), (2, 5)) 48 | layout(self, (self.splitter,), self) 49 | 50 | self.preferences_tree.currentItemChanged.connect(self.handleSelection) 51 | 52 | def add(self, name, component): 53 | 54 | if component.preferences: 55 | widget = ParameterTree() 56 | widget.setHeaderHidden(True) 57 | widget.setParameters(component.preferences, showTop=False) 58 | self.root.addChild(PreferencesTreeItem((name,), widget)) 59 | 60 | self.stacked.addWidget(widget) 61 | 62 | # PyQtGraph is not setting items in drop down lists properly, so we do it manually 63 | for child in component.preferences.children(): 64 | # Fill the editor color scheme drop down list 65 | if child.name() == "Color scheme": 66 | child.setLimits(["Light", "Dark"]) 67 | # Fill the camera projection type 68 | elif child.name() == "Projection Type": 69 | child.setLimits( 70 | [ 71 | "Orthographic", 72 | "Perspective", 73 | "Stereo", 74 | "MonoLeftEye", 75 | "MonoRightEye", 76 | ] 77 | ) 78 | # Fill the stereo mode, or lack thereof 79 | elif child.name() == "Stereo Mode": 80 | child.setLimits( 81 | [ 82 | "QuadBuffer", 83 | "Anaglyph", 84 | "RowInterlaced", 85 | "ColumnInterlaced", 86 | "ChessBoard", 87 | "SideBySide", 88 | "OverUnder", 89 | ] 90 | ) 91 | # Fill the light/dark theme in the general settings 92 | elif child.name() == "Light/Dark Theme": 93 | child.setLimits(["Light", "Dark"]) 94 | # Fill the orbit method 95 | elif child.name() == "Orbit Method": 96 | child.setLimits( 97 | [ 98 | "Turntable", 99 | "Trackball", 100 | ] 101 | ) 102 | 103 | @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) 104 | def handleSelection(self, item, *args): 105 | 106 | if item: 107 | self.stacked.setCurrentWidget(item.widget) 108 | -------------------------------------------------------------------------------- /icons/cadquery_logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 65 | 68 | 72 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /cq_editor/widgets/console.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QApplication, QAction 2 | from PyQt5.QtCore import pyqtSlot 3 | 4 | from qtconsole.rich_jupyter_widget import RichJupyterWidget 5 | from qtconsole.inprocess import QtInProcessKernelManager 6 | 7 | from ..mixins import ComponentMixin 8 | 9 | from ..icons import icon 10 | 11 | 12 | class ConsoleWidget(RichJupyterWidget, ComponentMixin): 13 | 14 | name = "Console" 15 | 16 | def __init__(self, customBanner=None, namespace=dict(), *args, **kwargs): 17 | super(ConsoleWidget, self).__init__(*args, **kwargs) 18 | 19 | # if not customBanner is None: 20 | # self.banner = customBanner 21 | 22 | self._actions = { 23 | "Run": [ 24 | QAction( 25 | icon("clear-2"), "Clear Console", self, triggered=self.reset_console 26 | ), 27 | ] 28 | } 29 | self.font_size = 6 30 | self.style_sheet = """ 47 | """ 48 | self.syntax_style = "zenburn" 49 | 50 | self.kernel_manager = kernel_manager = QtInProcessKernelManager() 51 | kernel_manager.start_kernel(show_banner=False) 52 | kernel_manager.kernel.gui = "qt" 53 | kernel_manager.kernel.shell.banner1 = "" 54 | 55 | self.kernel_client = kernel_client = self._kernel_manager.client() 56 | kernel_client.start_channels() 57 | 58 | def stop(): 59 | kernel_client.stop_channels() 60 | kernel_manager.shutdown_kernel() 61 | QApplication.instance().exit() 62 | 63 | self.exit_requested.connect(stop) 64 | 65 | self.clear() 66 | 67 | self.push_vars(namespace) 68 | 69 | @pyqtSlot(dict) 70 | def push_vars(self, variableDict): 71 | """ 72 | Given a dictionary containing name / value pairs, push those variables 73 | to the Jupyter console widget 74 | """ 75 | self.kernel_manager.kernel.shell.push(variableDict) 76 | 77 | def clear(self): 78 | """ 79 | Clears the terminal 80 | """ 81 | self._control.clear() 82 | 83 | def reset_console(self): 84 | """ 85 | Resets the terminal, which clears it back to a single prompt. 86 | """ 87 | self.reset(clear=True) 88 | 89 | def print_text(self, text): 90 | """ 91 | Prints some plain text to the console 92 | """ 93 | self._append_plain_text(text) 94 | 95 | def execute_command(self, command): 96 | """ 97 | Execute a command in the frame of the console widget 98 | """ 99 | self._execute(command, False) 100 | 101 | def _banner_default(self): 102 | 103 | return "" 104 | 105 | def app_theme_changed(self, theme): 106 | """ 107 | Allows this console to be changed to match the light or dark theme of the rest of the app. 108 | """ 109 | 110 | if theme == "Dark": 111 | self.set_default_style("linux") 112 | else: 113 | self.set_default_style("lightbg") 114 | 115 | 116 | if __name__ == "__main__": 117 | 118 | import sys 119 | 120 | app = QApplication(sys.argv) 121 | 122 | console = ConsoleWidget(customBanner="IPython console test") 123 | console.show() 124 | 125 | sys.exit(app.exec_()) 126 | -------------------------------------------------------------------------------- /cq_editor/utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from packaging.version import parse as parse_version 4 | 5 | from PyQt5 import QtCore, QtWidgets 6 | from PyQt5.QtGui import QDesktopServices 7 | from PyQt5.QtCore import QUrl 8 | from PyQt5.QtWidgets import QFileDialog, QMessageBox 9 | 10 | DOCK_POSITIONS = { 11 | "right": QtCore.Qt.RightDockWidgetArea, 12 | "left": QtCore.Qt.LeftDockWidgetArea, 13 | "top": QtCore.Qt.TopDockWidgetArea, 14 | "bottom": QtCore.Qt.BottomDockWidgetArea, 15 | } 16 | 17 | 18 | def layout( 19 | parent, 20 | items, 21 | top_widget=None, 22 | layout_type=QtWidgets.QVBoxLayout, 23 | margin=2, 24 | spacing=0, 25 | ): 26 | 27 | if not top_widget: 28 | top_widget = QtWidgets.QWidget(parent) 29 | top_widget_was_none = True 30 | else: 31 | top_widget_was_none = False 32 | layout = layout_type(top_widget) 33 | top_widget.setLayout(layout) 34 | 35 | for item in items: 36 | layout.addWidget(item) 37 | 38 | layout.setSpacing(spacing) 39 | layout.setContentsMargins(margin, margin, margin, margin) 40 | 41 | if top_widget_was_none: 42 | return top_widget 43 | else: 44 | return layout 45 | 46 | 47 | def splitter(items, stretch_factors=None, orientation=QtCore.Qt.Horizontal): 48 | 49 | sp = QtWidgets.QSplitter(orientation) 50 | 51 | for item in items: 52 | sp.addWidget(item) 53 | 54 | if stretch_factors: 55 | for i, s in enumerate(stretch_factors): 56 | sp.setStretchFactor(i, s) 57 | 58 | return sp 59 | 60 | 61 | def dock( 62 | widget, 63 | title, 64 | parent, 65 | allowedAreas=QtCore.Qt.AllDockWidgetAreas, 66 | defaultArea="right", 67 | name=None, 68 | icon=None, 69 | ): 70 | 71 | dock = QtWidgets.QDockWidget(title, parent, objectName=title) 72 | 73 | if name: 74 | dock.setObjectName(name) 75 | if icon: 76 | dock.toggleViewAction().setIcon(icon) 77 | 78 | dock.setAllowedAreas(allowedAreas) 79 | dock.setWidget(widget) 80 | action = dock.toggleViewAction() 81 | action.setText(title) 82 | 83 | dock.setFeatures( 84 | QtWidgets.QDockWidget.DockWidgetFeatures( 85 | QtWidgets.QDockWidget.AllDockWidgetFeatures 86 | ) 87 | ) 88 | 89 | parent.addDockWidget(DOCK_POSITIONS[defaultArea], dock) 90 | 91 | return dock 92 | 93 | 94 | def add_actions(menu, actions): 95 | 96 | if len(actions) > 0: 97 | menu.addActions(actions) 98 | menu.addSeparator() 99 | 100 | 101 | def open_url(url): 102 | 103 | QDesktopServices.openUrl(QUrl(url)) 104 | 105 | 106 | def about_dialog(parent, title, text): 107 | 108 | QtWidgets.QMessageBox.about(parent, title, text) 109 | 110 | 111 | def get_save_filename(suffix): 112 | 113 | rv, _ = QFileDialog.getSaveFileName(filter="*.{}".format(suffix)) 114 | if rv != "" and not rv.endswith(suffix): 115 | rv += "." + suffix 116 | 117 | return rv 118 | 119 | 120 | def get_open_filename(suffix, curr_dir): 121 | 122 | rv, _ = QFileDialog.getOpenFileName( 123 | directory=curr_dir, filter="*.{}".format(suffix) 124 | ) 125 | if rv != "" and not rv.endswith(suffix): 126 | rv += "." + suffix 127 | 128 | return rv 129 | 130 | 131 | def check_gtihub_for_updates( 132 | parent, mod, github_org="cadquery", github_proj="cadquery" 133 | ): 134 | 135 | url = f"https://api.github.com/repos/{github_org}/{github_proj}/releases" 136 | resp = requests.get(url).json() 137 | 138 | newer = [ 139 | el["tag_name"] 140 | for el in resp 141 | if not el["draft"] 142 | and parse_version(el["tag_name"]) > parse_version(mod.__version__) 143 | ] 144 | 145 | if newer: 146 | title = "Updates available" 147 | text = ( 148 | f"There are newer versions of {github_proj} " 149 | f"available on github:\n" + "\n".join(newer) 150 | ) 151 | 152 | else: 153 | title = "No updates available" 154 | text = f"You are already using the latest version of {github_proj}" 155 | 156 | QtWidgets.QMessageBox.about(parent, title, text) 157 | 158 | 159 | def confirm(parent, title, msg): 160 | 161 | rv = QMessageBox.question(parent, title, msg, QMessageBox.Yes, QMessageBox.No) 162 | 163 | return True if rv == QMessageBox.Yes else False 164 | -------------------------------------------------------------------------------- /cq_editor/widgets/cq_object_inspector.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAction 2 | from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal 3 | 4 | from OCP.AIS import AIS_ColoredShape 5 | from OCP.gp import gp_Ax3 6 | 7 | from cadquery import Vector 8 | 9 | from ..mixins import ComponentMixin 10 | from ..icons import icon 11 | 12 | 13 | class CQChildItem(QTreeWidgetItem): 14 | 15 | def __init__(self, cq_item, **kwargs): 16 | 17 | super(CQChildItem, self).__init__( 18 | [type(cq_item).__name__, str(cq_item)], **kwargs 19 | ) 20 | 21 | self.cq_item = cq_item 22 | 23 | 24 | class CQStackItem(QTreeWidgetItem): 25 | 26 | def __init__(self, name, workplane=None, **kwargs): 27 | 28 | super(CQStackItem, self).__init__([name, ""], **kwargs) 29 | 30 | self.workplane = workplane 31 | 32 | 33 | class CQObjectInspector(QTreeWidget, ComponentMixin): 34 | 35 | name = "CQ Object Inspector" 36 | 37 | sigRemoveObjects = pyqtSignal(list) 38 | sigDisplayObjects = pyqtSignal(list, bool) 39 | sigShowPlane = pyqtSignal([bool], [bool, float]) 40 | sigChangePlane = pyqtSignal(gp_Ax3) 41 | 42 | def __init__(self, parent): 43 | 44 | super(CQObjectInspector, self).__init__(parent) 45 | self.setHeaderHidden(False) 46 | self.setRootIsDecorated(True) 47 | self.setContextMenuPolicy(Qt.ActionsContextMenu) 48 | self.setColumnCount(2) 49 | self.setHeaderLabels(["Type", "Value"]) 50 | 51 | self.root = self.invisibleRootItem() 52 | self.inspected_items = [] 53 | 54 | self._toolbar_actions = [ 55 | QAction( 56 | icon("inspect"), 57 | "Inspect CQ object", 58 | self, 59 | toggled=self.inspect, 60 | checkable=True, 61 | ) 62 | ] 63 | 64 | self.addActions(self._toolbar_actions) 65 | 66 | def menuActions(self): 67 | 68 | return {"Tools": self._toolbar_actions} 69 | 70 | def toolbarActions(self): 71 | 72 | return self._toolbar_actions 73 | 74 | @pyqtSlot(bool) 75 | def inspect(self, value): 76 | 77 | if value: 78 | self.itemSelectionChanged.connect(self.handleSelection) 79 | self.itemSelectionChanged.emit() 80 | else: 81 | self.itemSelectionChanged.disconnect(self.handleSelection) 82 | self.sigRemoveObjects.emit(self.inspected_items) 83 | self.sigShowPlane.emit(False) 84 | 85 | @pyqtSlot() 86 | def handleSelection(self): 87 | 88 | inspected_items = self.inspected_items 89 | self.sigRemoveObjects.emit(inspected_items) 90 | inspected_items.clear() 91 | 92 | items = self.selectedItems() 93 | if len(items) == 0: 94 | return 95 | 96 | item = items[-1] 97 | if type(item) is CQStackItem: 98 | cq_plane = item.workplane.plane 99 | dim = item.workplane.largestDimension() 100 | plane = gp_Ax3( 101 | cq_plane.origin.toPnt(), cq_plane.zDir.toDir(), cq_plane.xDir.toDir() 102 | ) 103 | self.sigChangePlane.emit(plane) 104 | self.sigShowPlane[bool, float].emit(True, dim) 105 | 106 | for child in (item.child(i) for i in range(item.childCount())): 107 | obj = child.cq_item 108 | if hasattr(obj, "wrapped") and type(obj) != Vector: 109 | ais = AIS_ColoredShape(obj.wrapped) 110 | inspected_items.append(ais) 111 | 112 | else: 113 | self.sigShowPlane.emit(False) 114 | obj = item.cq_item 115 | if hasattr(obj, "wrapped") and type(obj) != Vector: 116 | ais = AIS_ColoredShape(obj.wrapped) 117 | inspected_items.append(ais) 118 | 119 | self.sigDisplayObjects.emit(inspected_items, False) 120 | 121 | @pyqtSlot(object) 122 | def setObject(self, cq_obj): 123 | 124 | self.root.takeChildren() 125 | 126 | # iterate through parent objects if they exist 127 | while getattr(cq_obj, "parent", None): 128 | current_frame = CQStackItem(str(cq_obj.plane.origin), workplane=cq_obj) 129 | self.root.addChild(current_frame) 130 | 131 | for obj in cq_obj.objects: 132 | current_frame.addChild(CQChildItem(obj)) 133 | 134 | cq_obj = cq_obj.parent 135 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | - master 5 | - refs/tags/* 6 | exclude: 7 | - refs/tags/nightly 8 | 9 | pr: 10 | - master 11 | 12 | # resources: 13 | # repositories: 14 | # - repository: templates 15 | # type: github 16 | # name: jmwright/conda-packages 17 | # endpoint: CadQuery 18 | 19 | parameters: 20 | - name: minor 21 | type: object 22 | default: 23 | - 11 24 | - name: location 25 | type: string 26 | default: 'conda' 27 | 28 | stages: 29 | - stage: build_conda_package 30 | jobs: 31 | - ${{ each minor in parameters.minor }}: 32 | - job: Linux_3_${{ minor }} 33 | timeoutInMinutes: 360 34 | 35 | pool: 36 | vmImage: ubuntu-latest 37 | 38 | steps: 39 | 40 | # this step is needed for OCC to find fonts 41 | - bash: | 42 | sudo apt-get -q -y install gsfonts xfonts-utils && \ 43 | sudo mkfontscale /usr/share/fonts/type1/gsfonts/ && \ 44 | sudo mkfontdir /usr/share/fonts/type1/gsfonts/ 45 | condition: eq( variables['Agent.OS'], 'Linux' ) 46 | displayName: 'Help OCC find fonts' 47 | 48 | # Ubunut install opengl items 49 | - ${{ if contains('ubuntu-latest', 'Ubuntu') }}: 50 | - bash: | 51 | sudo apt-get update && \ 52 | sudo apt-get -q -y install libglu1-mesa-dev freeglut3-dev mesa-common-dev 53 | displayName: 'Install OpenGL headers' 54 | 55 | # install conda for mac 56 | - bash: brew install miniforge && ls /usr/local/Caskroom/miniforge/base/bin 57 | displayName: 'MacOS install miniforge' 58 | condition: eq( variables['Agent.OS'], 'Darwin' ) 59 | 60 | #activate conda 61 | - bash: echo "##vso[task.prependpath]/usr/local/Caskroom/miniforge/base/bin" 62 | condition: eq( variables['Agent.OS'], 'Darwin' ) 63 | displayName: 'Add conda to PATH - OSX' 64 | 65 | - bash: echo "##vso[task.prependpath]$CONDA/bin" 66 | condition: eq( variables['Agent.OS'], 'Linux' ) 67 | displayName: 'Add conda to PATH - Linux' 68 | 69 | - powershell: Write-Host "##vso[task.prependpath]$env:CONDA\Scripts" 70 | condition: eq( variables['Agent.OS'], 'Windows_NT' ) 71 | displayName: 'Add conda to PATH - Windows' 72 | 73 | - powershell: Write-Host "##vso[task.prependpath]$env:CONDA\Library\bin" 74 | condition: eq( variables['Agent.OS'], 'Windows_NT' ) 75 | displayName: 'Add condabin to PATH - Windows' 76 | 77 | # install mamba 78 | - bash: | 79 | conda config --set anaconda_upload yes --set always_yes yes --set solver libmamba && \ 80 | conda config --add channels conda-forge && \ 81 | conda install -c conda-forge -q mamba micromamba conda-devenv libsolv conda-libmamba-solver && \ 82 | env && \ 83 | conda info && \ 84 | conda list 85 | displayName: 'Install mamba, boa; report config and info' 86 | 87 | - bash: conda create --yes --quiet --name build_env -c conda-forge conda-build boa conda-verify libarchive python=3.12 anaconda-client 88 | displayName: Create Anaconda environment 89 | 90 | - bash: | 91 | cd ${{ parameters.location }} && \ 92 | conda run -n build_env conda mambabuild -c conda-forge -c cadquery --output-folder . . && \ 93 | full_path=$(conda run -n build_env conda build --output -c conda-forge -c cadquery --output-folder . . ) && \ 94 | echo $full_path && \ 95 | base_name=$(basename $full_path) && \ 96 | split_name=($(IFS=-; tmp=($base_name); echo ${tmp[@]})) && \ 97 | package_name=${split_name[@]: 0 : ${#split_name[@]} - 2} && \ 98 | package_name=${package_name/ /-} && \ 99 | version_name=${split_name[-2]} && \ 100 | echo "Removing $base_name" && \ 101 | conda run -n build_env anaconda -v -t $TOKEN remove --force "cadquery/$package_name/$version_name" && \ 102 | echo "Uploading $full_path" && \ 103 | conda run -n build_env anaconda -v -t $TOKEN upload -u cadquery --force $full_path && \ 104 | cd .. 105 | displayName: 'Run conda build' 106 | failOnStderr: false 107 | condition: ne(variables['Build.Reason'], 'PullRequest') 108 | env: 109 | PYTHON_VERSION: 3.${{ minor }} 110 | PACKAGE_VERSION: $(Build.SourceBranchName) 111 | TOKEN: $(anaconda.TOKEN) 112 | 113 | - bash: | 114 | cd ${{ parameters.location }} && \ 115 | conda run -n build_env conda mambabuild -c conda-forge -c cadquery . && \ 116 | cd .. 117 | displayName: 'Run conda build without upload' 118 | failOnStderr: false 119 | condition: eq(variables['Build.Reason'], 'PullRequest') 120 | env: 121 | PYTHON_VERSION: 3.${{ minor }} 122 | PACKAGE_VERSION: $(Build.SourceBranchName) 123 | TOKEN: $(anaconda.TOKEN) 124 | -------------------------------------------------------------------------------- /cq_editor/cq_utils.py: -------------------------------------------------------------------------------- 1 | import cadquery as cq 2 | from cadquery.occ_impl.assembly import toCAF 3 | 4 | from typing import List, Union 5 | from importlib import reload 6 | from types import SimpleNamespace 7 | 8 | from OCP.XCAFPrs import XCAFPrs_AISObject 9 | from OCP.TopoDS import TopoDS_Shape 10 | from OCP.AIS import AIS_InteractiveObject, AIS_Shape 11 | from OCP.Quantity import ( 12 | Quantity_TOC_RGB as TOC_RGB, 13 | Quantity_Color, 14 | Quantity_NOC_GOLD as GOLD, 15 | ) 16 | from OCP.Graphic3d import Graphic3d_NOM_JADE, Graphic3d_MaterialAspect 17 | 18 | from PyQt5.QtGui import QColor 19 | 20 | DEFAULT_FACE_COLOR = Quantity_Color(GOLD) 21 | DEFAULT_MATERIAL = Graphic3d_MaterialAspect(Graphic3d_NOM_JADE) 22 | 23 | 24 | def is_cq_obj(obj): 25 | 26 | from cadquery import Workplane, Shape, Assembly, Sketch 27 | 28 | return isinstance(obj, (Workplane, Shape, Assembly, Sketch)) 29 | 30 | 31 | def find_cq_objects(results: dict): 32 | 33 | return { 34 | k: SimpleNamespace(shape=v, options={}) 35 | for k, v in results.items() 36 | if is_cq_obj(v) 37 | } 38 | 39 | 40 | def to_compound( 41 | obj: Union[cq.Workplane, List[cq.Workplane], cq.Shape, List[cq.Shape], cq.Sketch], 42 | ): 43 | 44 | vals = [] 45 | 46 | if isinstance(obj, cq.Workplane): 47 | vals.extend(obj.vals()) 48 | elif isinstance(obj, cq.Shape): 49 | vals.append(obj) 50 | elif isinstance(obj, list) and isinstance(obj[0], cq.Workplane): 51 | for o in obj: 52 | vals.extend(o.vals()) 53 | elif isinstance(obj, list) and isinstance(obj[0], cq.Shape): 54 | vals.extend(obj) 55 | elif isinstance(obj, TopoDS_Shape): 56 | vals.append(cq.Shape.cast(obj)) 57 | elif isinstance(obj, list) and isinstance(obj[0], TopoDS_Shape): 58 | vals.extend(cq.Shape.cast(o) for o in obj) 59 | elif isinstance(obj, cq.Sketch): 60 | if obj._faces: 61 | vals.append(obj._faces) 62 | else: 63 | vals.extend(obj._edges) 64 | else: 65 | raise ValueError(f"Invalid type {type(obj)}") 66 | 67 | return cq.Compound.makeCompound(vals) 68 | 69 | 70 | def to_workplane(obj: cq.Shape): 71 | 72 | rv = cq.Workplane("XY") 73 | rv.objects = [ 74 | obj, 75 | ] 76 | 77 | return rv 78 | 79 | 80 | def make_AIS( 81 | obj: Union[ 82 | cq.Workplane, 83 | List[cq.Workplane], 84 | cq.Shape, 85 | List[cq.Shape], 86 | cq.Assembly, 87 | AIS_InteractiveObject, 88 | ], 89 | options={}, 90 | ): 91 | 92 | shape = None 93 | 94 | if isinstance(obj, cq.Assembly): 95 | label, shape = toCAF(obj) 96 | ais = XCAFPrs_AISObject(label) 97 | elif isinstance(obj, AIS_InteractiveObject): 98 | ais = obj 99 | else: 100 | shape = to_compound(obj) 101 | ais = AIS_Shape(shape.wrapped) 102 | 103 | set_material(ais, DEFAULT_MATERIAL) 104 | set_color(ais, DEFAULT_FACE_COLOR) 105 | 106 | if "alpha" in options: 107 | set_transparency(ais, options["alpha"]) 108 | if "color" in options: 109 | set_color(ais, to_occ_color(options["color"])) 110 | if "rgba" in options: 111 | r, g, b, a = options["rgba"] 112 | set_color(ais, to_occ_color((r, g, b))) 113 | set_transparency(ais, a) 114 | 115 | return ais, shape 116 | 117 | 118 | def export( 119 | obj: Union[cq.Workplane, List[cq.Workplane]], type: str, file, precision=1e-1 120 | ): 121 | 122 | comp = to_compound(obj) 123 | 124 | if type == "stl": 125 | comp.exportStl(file, tolerance=precision) 126 | elif type == "step": 127 | comp.exportStep(file) 128 | elif type == "brep": 129 | comp.exportBrep(file) 130 | 131 | 132 | def to_occ_color(color) -> Quantity_Color: 133 | 134 | if not isinstance(color, QColor): 135 | if isinstance(color, tuple): 136 | if isinstance(color[0], int): 137 | color = QColor(*color) 138 | elif isinstance(color[0], float): 139 | color = QColor.fromRgbF(*color) 140 | else: 141 | raise ValueError("Unknown color format") 142 | else: 143 | color = QColor(color) 144 | 145 | return Quantity_Color(color.redF(), color.greenF(), color.blueF(), TOC_RGB) 146 | 147 | 148 | def get_occ_color(obj: Union[AIS_InteractiveObject, Quantity_Color]) -> QColor: 149 | 150 | if isinstance(obj, AIS_InteractiveObject): 151 | color = Quantity_Color() 152 | obj.Color(color) 153 | else: 154 | color = obj 155 | 156 | return QColor.fromRgbF(color.Red(), color.Green(), color.Blue()) 157 | 158 | 159 | def set_color(ais: AIS_Shape, color: Quantity_Color) -> AIS_Shape: 160 | 161 | drawer = ais.Attributes() 162 | drawer.SetupOwnShadingAspect() 163 | drawer.ShadingAspect().SetColor(color) 164 | 165 | return ais 166 | 167 | 168 | def set_material(ais: AIS_Shape, material: Graphic3d_MaterialAspect) -> AIS_Shape: 169 | 170 | drawer = ais.Attributes() 171 | drawer.SetupOwnShadingAspect() 172 | drawer.ShadingAspect().SetMaterial(material) 173 | 174 | return ais 175 | 176 | 177 | def set_transparency(ais: AIS_Shape, alpha: float) -> AIS_Shape: 178 | 179 | drawer = ais.Attributes() 180 | drawer.SetupOwnShadingAspect() 181 | drawer.ShadingAspect().SetTransparency(1.0 - alpha) 182 | 183 | return ais 184 | 185 | 186 | def reload_cq(): 187 | 188 | # NB: order of reloads is important 189 | reload(cq.types) 190 | reload(cq.occ_impl.geom) 191 | reload(cq.occ_impl.shapes) 192 | reload(cq.occ_impl.shapes) 193 | reload(cq.occ_impl.importers.dxf) 194 | reload(cq.occ_impl.importers) 195 | reload(cq.occ_impl.solver) 196 | reload(cq.occ_impl.assembly) 197 | reload(cq.occ_impl.sketch_solver) 198 | reload(cq.hull) 199 | reload(cq.selectors) 200 | reload(cq.sketch) 201 | reload(cq.occ_impl.exporters.svg) 202 | reload(cq.cq) 203 | reload(cq.occ_impl.exporters.dxf) 204 | reload(cq.occ_impl.exporters.amf) 205 | reload(cq.occ_impl.exporters.json) 206 | # reload(cq.occ_impl.exporters.assembly) 207 | reload(cq.occ_impl.exporters) 208 | reload(cq.assembly) 209 | reload(cq) 210 | 211 | 212 | def is_obj_empty(obj: Union[cq.Workplane, cq.Shape]) -> bool: 213 | 214 | rv = False 215 | 216 | if isinstance(obj, cq.Workplane): 217 | rv = True if isinstance(obj.val(), cq.Vector) else False 218 | 219 | return rv 220 | -------------------------------------------------------------------------------- /cq_editor/widgets/occt_widget.py: -------------------------------------------------------------------------------- 1 | from sys import platform 2 | 3 | 4 | from PyQt5.QtWidgets import QWidget, QApplication 5 | from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QPoint 6 | 7 | import OCP 8 | 9 | from OCP.Aspect import Aspect_DisplayConnection, Aspect_TypeOfTriedronPosition 10 | from OCP.OpenGl import OpenGl_GraphicDriver 11 | from OCP.V3d import V3d_Viewer 12 | from OCP.gp import gp_Trsf, gp_Ax1, gp_Dir 13 | from OCP.AIS import AIS_InteractiveContext, AIS_DisplayMode 14 | from OCP.Quantity import Quantity_Color 15 | 16 | 17 | ZOOM_STEP = 0.9 18 | 19 | 20 | class OCCTWidget(QWidget): 21 | 22 | sigObjectSelected = pyqtSignal(list) 23 | 24 | def __init__(self, parent=None): 25 | 26 | super(OCCTWidget, self).__init__(parent) 27 | 28 | self.setAttribute(Qt.WA_NativeWindow) 29 | self.setAttribute(Qt.WA_PaintOnScreen) 30 | self.setAttribute(Qt.WA_NoSystemBackground) 31 | 32 | self._initialized = False 33 | self._needs_update = False 34 | self._previous_pos = QPoint( 35 | 0, 0 # Keeps track of where the previous mouse position 36 | ) 37 | self._rotate_step = ( 38 | 0.008 # Controls the speed of rotation with the turntable orbit method 39 | ) 40 | 41 | # Orbit method settings 42 | self._orbit_method = "Turntable" 43 | 44 | # OCCT secific things 45 | self.display_connection = Aspect_DisplayConnection() 46 | self.graphics_driver = OpenGl_GraphicDriver(self.display_connection) 47 | 48 | self.viewer = V3d_Viewer(self.graphics_driver) 49 | self.view = self.viewer.CreateView() 50 | self.context = AIS_InteractiveContext(self.viewer) 51 | 52 | # Trihedorn, lights, etc 53 | self.prepare_display() 54 | 55 | def prepare_display(self): 56 | 57 | view = self.view 58 | 59 | params = view.ChangeRenderingParams() 60 | params.NbMsaaSamples = 8 61 | params.IsAntialiasingEnabled = True 62 | 63 | view.TriedronDisplay( 64 | Aspect_TypeOfTriedronPosition.Aspect_TOTP_RIGHT_LOWER, Quantity_Color(), 0.1 65 | ) 66 | 67 | viewer = self.viewer 68 | 69 | viewer.SetDefaultLights() 70 | viewer.SetLightOn() 71 | 72 | ctx = self.context 73 | 74 | ctx.SetDisplayMode(AIS_DisplayMode.AIS_Shaded, True) 75 | ctx.DefaultDrawer().SetFaceBoundaryDraw(True) 76 | 77 | def set_orbit_method(self, method): 78 | """ 79 | Set the orbit method for the OCCT view. 80 | """ 81 | 82 | # Keep track of which orbit method is used 83 | if method == "Turntable": 84 | self._orbit_method = "Turntable" 85 | self.view.SetUp(0, 0, 1) 86 | elif method == "Trackball": 87 | self._orbit_method = "Trackball" 88 | else: 89 | raise ValueError(f"Unknown orbit method: {method}") 90 | 91 | def wheelEvent(self, event): 92 | 93 | delta = event.angleDelta().y() 94 | factor = ZOOM_STEP if delta < 0 else 1 / ZOOM_STEP 95 | 96 | self.view.SetZoom(factor) 97 | 98 | def mousePressEvent(self, event): 99 | 100 | pos = event.pos() 101 | 102 | if event.button() == Qt.LeftButton: 103 | # Used to prevent drag selection of objects 104 | self.pending_select = True 105 | self.left_press = pos 106 | 107 | # We only start the rotation if the orbit method is set to Trackball 108 | if self._orbit_method == "Trackball": 109 | self.view.StartRotation(pos.x(), pos.y()) 110 | elif event.button() == Qt.RightButton: 111 | self.view.StartZoomAtPoint(pos.x(), pos.y()) 112 | 113 | self._previous_pos = pos 114 | 115 | def mouseMoveEvent(self, event): 116 | 117 | pos = event.pos() 118 | x, y = pos.x(), pos.y() 119 | 120 | # Check for mouse drag rotation 121 | if event.buttons() == Qt.LeftButton: 122 | # Set the rotation differently based on the orbit method 123 | if self._orbit_method == "Trackball": 124 | self.view.Rotation(x, y) 125 | elif self._orbit_method == "Turntable": 126 | # Control the turntable rotation manually 127 | delta_x, delta_y = ( 128 | x - self._previous_pos.x(), 129 | y - self._previous_pos.y(), 130 | ) 131 | cam = self.view.Camera() 132 | z_rotation = gp_Trsf() 133 | z_rotation.SetRotation( 134 | gp_Ax1(cam.Center(), gp_Dir(0, 0, 1)), -delta_x * self._rotate_step 135 | ) 136 | cam.Transform(z_rotation) 137 | self.view.Rotate(0, -delta_y * self._rotate_step, 0) 138 | 139 | # If the user moves the mouse at all, the selection will not happen 140 | if abs(x - self.left_press.x()) > 2 or abs(y - self.left_press.y()) > 2: 141 | self.pending_select = False 142 | 143 | elif event.buttons() == Qt.MiddleButton: 144 | self.view.Pan( 145 | x - self._previous_pos.x(), self._previous_pos.y() - y, theToStart=True 146 | ) 147 | 148 | elif event.buttons() == Qt.RightButton: 149 | self.view.ZoomAtPoint(self._previous_pos.x(), y, x, self._previous_pos.y()) 150 | 151 | self._previous_pos = pos 152 | 153 | def mouseReleaseEvent(self, event): 154 | 155 | if event.button() == Qt.LeftButton: 156 | pos = event.pos() 157 | x, y = pos.x(), pos.y() 158 | 159 | # Only make the selection if the user has not moved the mouse 160 | if self.pending_select: 161 | self.context.MoveTo(x, y, self.view, True) 162 | self._handle_selection() 163 | 164 | def _handle_selection(self): 165 | 166 | self.context.Select(True) 167 | self.context.InitSelected() 168 | 169 | selected = [] 170 | if self.context.HasSelectedShape(): 171 | selected.append(self.context.SelectedShape()) 172 | 173 | self.sigObjectSelected.emit(selected) 174 | 175 | def paintEngine(self): 176 | 177 | return None 178 | 179 | def paintEvent(self, event): 180 | 181 | if not self._initialized: 182 | self._initialize() 183 | else: 184 | self.view.Redraw() 185 | 186 | def showEvent(self, event): 187 | 188 | super(OCCTWidget, self).showEvent(event) 189 | 190 | def resizeEvent(self, event): 191 | 192 | super(OCCTWidget, self).resizeEvent(event) 193 | 194 | self.view.MustBeResized() 195 | 196 | def _initialize(self): 197 | 198 | wins = { 199 | "darwin": self._get_window_osx, 200 | "linux": self._get_window_linux, 201 | "win32": self._get_window_win, 202 | } 203 | 204 | self.view.SetWindow(wins.get(platform, self._get_window_linux)(self.winId())) 205 | 206 | self._initialized = True 207 | 208 | def _get_window_win(self, wid): 209 | 210 | from OCP.WNT import WNT_Window 211 | 212 | return WNT_Window(wid.ascapsule()) 213 | 214 | def _get_window_linux(self, wid): 215 | 216 | from OCP.Xw import Xw_Window 217 | 218 | return Xw_Window(self.display_connection, int(wid)) 219 | 220 | def _get_window_osx(self, wid): 221 | 222 | from OCP.Cocoa import Cocoa_Window 223 | 224 | return Cocoa_Window(wid.ascapsule()) 225 | -------------------------------------------------------------------------------- /cq_editor/widgets/pyhighlight.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QRegExp 2 | from PyQt5.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter 3 | 4 | 5 | def format(color, style=""): 6 | """ 7 | Return a QTextCharFormat with the given attributes. 8 | """ 9 | _color = QColor() 10 | _color.setNamedColor(color) 11 | 12 | _format = QTextCharFormat() 13 | _format.setForeground(_color) 14 | if "bold" in style: 15 | _format.setFontWeight(QFont.Bold) 16 | if "italic" in style: 17 | _format.setFontItalic(True) 18 | 19 | return _format 20 | 21 | 22 | # Syntax styles that can be shared by all languages 23 | STYLES = { 24 | "keyword": format("blue"), 25 | "operator": format("gray"), 26 | "brace": format("darkGray"), 27 | "defclass": format("gray", "bold"), 28 | "string": format("orange"), 29 | "string2": format("darkMagenta"), 30 | "comment": format("darkGreen", "italic"), 31 | "self": format("blue", "italic"), 32 | "numbers": format("magenta"), 33 | } 34 | 35 | 36 | class PythonHighlighter(QSyntaxHighlighter): 37 | """ 38 | Syntax highlighter for the Python language. 39 | """ 40 | 41 | # Python keywords 42 | keywords = [ 43 | "and", 44 | "assert", 45 | "break", 46 | "class", 47 | "continue", 48 | "def", 49 | "del", 50 | "elif", 51 | "else", 52 | "except", 53 | "exec", 54 | "finally", 55 | "for", 56 | "from", 57 | "global", 58 | "if", 59 | "import", 60 | "in", 61 | "is", 62 | "lambda", 63 | "not", 64 | "or", 65 | "pass", 66 | "print", 67 | "raise", 68 | "return", 69 | "try", 70 | "while", 71 | "yield", 72 | "None", 73 | "True", 74 | "False", 75 | ] 76 | 77 | # Python operators 78 | operators = [ 79 | "=", 80 | # Comparison 81 | "==", 82 | "!=", 83 | "<", 84 | "<=", 85 | ">", 86 | ">=", 87 | # Arithmetic 88 | r"\+", 89 | "-", 90 | r"\*", 91 | "/", 92 | "//", 93 | r"\%", 94 | r"\*\*", 95 | # In-place 96 | r"\+=", 97 | "-=", 98 | r"\*=", 99 | "/=", 100 | r"\%=", 101 | # Bitwise 102 | r"\^", 103 | r"\|", 104 | r"\&", 105 | r"\~", 106 | ">>", 107 | "<<", 108 | ] 109 | 110 | # Python braces 111 | braces = [ 112 | r"\{", 113 | r"\}", 114 | r"\(", 115 | r"\)", 116 | r"\[", 117 | r"\]", 118 | ] 119 | 120 | def __init__(self, parent=None): 121 | super(PythonHighlighter, self).__init__(parent) 122 | 123 | # Multi-line strings (expression, flag, style) 124 | self.tri_single = (QRegExp("'''"), 1, STYLES["string2"]) 125 | self.tri_double = (QRegExp('"""'), 2, STYLES["string2"]) 126 | 127 | rules = [] 128 | 129 | # Keyword, operator, and brace rules 130 | rules += [ 131 | (r"\b%s\b" % w, 0, STYLES["keyword"]) for w in PythonHighlighter.keywords 132 | ] 133 | rules += [ 134 | (r"%s" % o, 0, STYLES["operator"]) for o in PythonHighlighter.operators 135 | ] 136 | rules += [(r"%s" % b, 0, STYLES["brace"]) for b in PythonHighlighter.braces] 137 | 138 | # All other rules 139 | rules += [ 140 | # 'self' 141 | (r"\bself\b", 0, STYLES["self"]), 142 | # 'def' followed by an identifier 143 | (r"\bdef\b\s*(\w+)", 1, STYLES["defclass"]), 144 | # 'class' followed by an identifier 145 | (r"\bclass\b\s*(\w+)", 1, STYLES["defclass"]), 146 | # Numeric literals 147 | (r"\b[+-]?[0-9]+[lL]?\b", 0, STYLES["numbers"]), 148 | (r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", 0, STYLES["numbers"]), 149 | (r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", 0, STYLES["numbers"]), 150 | # Double-quoted string, possibly containing escape sequences 151 | (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES["string"]), 152 | # Single-quoted string, possibly containing escape sequences 153 | (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES["string"]), 154 | # From '#' until a newline 155 | (r"#[^\n]*", 0, STYLES["comment"]), 156 | ] 157 | 158 | # Build a QRegExp for each pattern 159 | self.rules = [(QRegExp(pat), index, fmt) for (pat, index, fmt) in rules] 160 | 161 | def highlightBlock(self, text): 162 | """ 163 | Apply syntax highlighting to the given block of text. 164 | """ 165 | self.tripleQuoutesWithinStrings = [] 166 | # Do other syntax formatting 167 | for expression, nth, format in self.rules: 168 | index = expression.indexIn(text, 0) 169 | if index >= 0: 170 | # if there is a string we check 171 | # if there are some triple quotes within the string 172 | # they will be ignored if they are matched again 173 | if expression.pattern() in [ 174 | r'"[^"\\]*(\\.[^"\\]*)*"', 175 | r"'[^'\\]*(\\.[^'\\]*)*'", 176 | ]: 177 | innerIndex = self.tri_single[0].indexIn(text, index + 1) 178 | if innerIndex == -1: 179 | innerIndex = self.tri_double[0].indexIn(text, index + 1) 180 | 181 | if innerIndex != -1: 182 | tripleQuoteIndexes = range(innerIndex, innerIndex + 3) 183 | self.tripleQuoutesWithinStrings.extend(tripleQuoteIndexes) 184 | 185 | while index >= 0: 186 | # skipping triple quotes within strings 187 | if index in self.tripleQuoutesWithinStrings: 188 | index += 1 189 | expression.indexIn(text, index) 190 | continue 191 | 192 | # We actually want the index of the nth match 193 | index = expression.pos(nth) 194 | length = len(expression.cap(nth)) 195 | self.setFormat(index, length, format) 196 | index = expression.indexIn(text, index + length) 197 | 198 | self.setCurrentBlockState(0) 199 | 200 | # Do multi-line strings 201 | in_multiline = self.match_multiline(text, *self.tri_single) 202 | if not in_multiline: 203 | in_multiline = self.match_multiline(text, *self.tri_double) 204 | 205 | def match_multiline(self, text, delimiter, in_state, style): 206 | """ 207 | Do highlighting of multi-line strings. ``delimiter`` should be a 208 | ``QRegExp`` for triple-single-quotes or triple-double-quotes, and 209 | ``in_state`` should be a unique integer to represent the corresponding 210 | state changes when inside those strings. Returns True if we're still 211 | inside a multi-line string when this function is finished. 212 | """ 213 | # If inside triple-single quotes, start at 0 214 | if self.previousBlockState() == in_state: 215 | start = 0 216 | add = 0 217 | # Otherwise, look for the delimiter on this line 218 | else: 219 | start = delimiter.indexIn(text) 220 | # skipping triple quotes within strings 221 | if start in self.tripleQuoutesWithinStrings: 222 | return False 223 | # Move past this match 224 | add = delimiter.matchedLength() 225 | 226 | # As long as there's a delimiter match on this line... 227 | while start >= 0: 228 | # Look for the ending delimiter 229 | end = delimiter.indexIn(text, start + add) 230 | # Ending delimiter on this line? 231 | if end >= add: 232 | length = end - start + add + delimiter.matchedLength() 233 | self.setCurrentBlockState(0) 234 | # No; multi-line string 235 | else: 236 | self.setCurrentBlockState(in_state) 237 | length = len(text) - start + add 238 | # Apply formatting 239 | self.setFormat(start, length, style) 240 | # Look for the next match 241 | start = delimiter.indexIn(text, start + length) 242 | 243 | # Return True if still inside a multi-line string, False otherwise 244 | if self.currentBlockState() == in_state: 245 | return True 246 | else: 247 | return False 248 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /cq_editor/widgets/object_tree.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QTreeWidget, 3 | QTreeWidgetItem, 4 | QAction, 5 | QMenu, 6 | QWidget, 7 | QAbstractItemView, 8 | ) 9 | from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal 10 | 11 | from pyqtgraph.parametertree import Parameter, ParameterTree 12 | 13 | from OCP.AIS import AIS_Line 14 | from OCP.Geom import Geom_Line 15 | from OCP.gp import gp_Dir, gp_Pnt, gp_Ax1 16 | 17 | from ..mixins import ComponentMixin 18 | from ..icons import icon 19 | from ..cq_utils import ( 20 | make_AIS, 21 | export, 22 | to_occ_color, 23 | is_obj_empty, 24 | get_occ_color, 25 | set_color, 26 | ) 27 | from .viewer import DEFAULT_FACE_COLOR 28 | from ..utils import splitter, layout, get_save_filename 29 | 30 | 31 | class TopTreeItem(QTreeWidgetItem): 32 | 33 | def __init__(self, *args, **kwargs): 34 | 35 | super(TopTreeItem, self).__init__(*args, **kwargs) 36 | 37 | 38 | class ObjectTreeItem(QTreeWidgetItem): 39 | 40 | props = [ 41 | {"name": "Name", "type": "str", "value": "", "readonly": True}, 42 | # {"name": "Color", "type": "color", "value": "#f4a824"}, 43 | # {"name": "Alpha", "type": "float", "value": 0, "limits": (0, 1), "step": 1e-1}, 44 | {"name": "Visible", "type": "bool", "value": True}, 45 | ] 46 | 47 | def __init__( 48 | self, 49 | name, 50 | ais=None, 51 | shape=None, 52 | shape_display=None, 53 | sig=None, 54 | alpha=0.0, 55 | color="#f4a824", 56 | **kwargs, 57 | ): 58 | 59 | super(ObjectTreeItem, self).__init__([name], **kwargs) 60 | self.setFlags(self.flags() | Qt.ItemIsUserCheckable) 61 | self.setCheckState(0, Qt.Checked) 62 | 63 | self.ais = ais 64 | self.shape = shape 65 | self.shape_display = shape_display 66 | self.sig = sig 67 | 68 | self.properties = Parameter.create(name="Properties", children=self.props) 69 | 70 | self.properties["Name"] = name 71 | # Alpha and Color from this panel fight with the options in show_object and so they are 72 | # disabled for now until a better solution is found 73 | # self.properties["Alpha"] = ais.Transparency() 74 | # self.properties["Color"] = ( 75 | # get_occ_color(ais) 76 | # if ais and ais.HasColor() 77 | # else get_occ_color(DEFAULT_FACE_COLOR) 78 | # ) 79 | self.properties.sigTreeStateChanged.connect(self.propertiesChanged) 80 | 81 | def propertiesChanged(self, properties, changed): 82 | 83 | changed_prop = changed[0][0] 84 | 85 | self.setData(0, 0, self.properties["Name"]) 86 | 87 | # if changed_prop.name() == "Alpha": 88 | # self.ais.SetTransparency(self.properties["Alpha"]) 89 | 90 | # if changed_prop.name() == "Color": 91 | # set_color(self.ais, to_occ_color(self.properties["Color"])) 92 | 93 | # self.ais.Redisplay() 94 | 95 | if self.properties["Visible"]: 96 | self.setCheckState(0, Qt.Checked) 97 | else: 98 | self.setCheckState(0, Qt.Unchecked) 99 | 100 | if self.sig: 101 | self.sig.emit() 102 | 103 | 104 | class CQRootItem(TopTreeItem): 105 | 106 | def __init__(self, *args, **kwargs): 107 | 108 | super(CQRootItem, self).__init__(["CQ models"], *args, **kwargs) 109 | 110 | 111 | class HelpersRootItem(TopTreeItem): 112 | 113 | def __init__(self, *args, **kwargs): 114 | 115 | super(HelpersRootItem, self).__init__(["Helpers"], *args, **kwargs) 116 | 117 | 118 | class ObjectTree(QWidget, ComponentMixin): 119 | 120 | name = "Object Tree" 121 | _stash = [] 122 | 123 | preferences = Parameter.create( 124 | name="Preferences", 125 | children=[ 126 | {"name": "Preserve properties on reload", "type": "bool", "value": False}, 127 | {"name": "Clear all before each run", "type": "bool", "value": True}, 128 | {"name": "STL precision", "type": "float", "value": 0.1}, 129 | ], 130 | ) 131 | 132 | sigObjectsAdded = pyqtSignal([list], [list, bool]) 133 | sigObjectsRemoved = pyqtSignal(list) 134 | sigCQObjectSelected = pyqtSignal(object) 135 | sigAISObjectsSelected = pyqtSignal(list) 136 | sigItemChanged = pyqtSignal(QTreeWidgetItem, int) 137 | sigObjectPropertiesChanged = pyqtSignal() 138 | 139 | def __init__(self, parent): 140 | 141 | super(ObjectTree, self).__init__(parent) 142 | 143 | self.tree = tree = QTreeWidget( 144 | self, selectionMode=QAbstractItemView.ExtendedSelection 145 | ) 146 | self.properties_editor = ParameterTree(self) 147 | 148 | tree.setHeaderHidden(True) 149 | tree.setItemsExpandable(False) 150 | tree.setRootIsDecorated(False) 151 | tree.setContextMenuPolicy(Qt.ActionsContextMenu) 152 | 153 | # forward itemChanged singal 154 | tree.itemChanged.connect(lambda item, col: self.sigItemChanged.emit(item, col)) 155 | # handle visibility changes form tree 156 | tree.itemChanged.connect(self.handleChecked) 157 | 158 | self.CQ = CQRootItem() 159 | self.Helpers = HelpersRootItem() 160 | 161 | root = tree.invisibleRootItem() 162 | root.addChild(self.CQ) 163 | root.addChild(self.Helpers) 164 | 165 | tree.expandToDepth(1) 166 | 167 | self._export_STL_action = QAction( 168 | "Export as STL", 169 | self, 170 | enabled=False, 171 | triggered=lambda: self.export("stl", self.preferences["STL precision"]), 172 | ) 173 | 174 | self._export_STEP_action = QAction( 175 | "Export as STEP", self, enabled=False, triggered=lambda: self.export("step") 176 | ) 177 | 178 | self._clear_current_action = QAction( 179 | icon("delete"), 180 | "Clear current", 181 | self, 182 | enabled=False, 183 | triggered=self.removeSelected, 184 | ) 185 | 186 | self._toolbar_actions = [ 187 | QAction( 188 | icon("delete-many"), "Clear all", self, triggered=self.removeObjects 189 | ), 190 | self._clear_current_action, 191 | ] 192 | 193 | self.prepareMenu() 194 | 195 | tree.itemSelectionChanged.connect(self.handleSelection) 196 | tree.customContextMenuRequested.connect(self.showMenu) 197 | 198 | self.prepareLayout() 199 | 200 | def prepareMenu(self): 201 | 202 | self.tree.setContextMenuPolicy(Qt.CustomContextMenu) 203 | 204 | self._context_menu = QMenu(self) 205 | self._context_menu.addActions(self._toolbar_actions) 206 | self._context_menu.addActions( 207 | (self._export_STL_action, self._export_STEP_action) 208 | ) 209 | 210 | def prepareLayout(self): 211 | 212 | self._splitter = splitter( 213 | (self.tree, self.properties_editor), 214 | stretch_factors=(2, 1), 215 | orientation=Qt.Vertical, 216 | ) 217 | layout(self, (self._splitter,), top_widget=self) 218 | 219 | self._splitter.show() 220 | 221 | def showMenu(self, position): 222 | 223 | self._context_menu.exec_(self.tree.viewport().mapToGlobal(position)) 224 | 225 | def menuActions(self): 226 | 227 | return {"Tools": [self._export_STL_action, self._export_STEP_action]} 228 | 229 | def toolbarActions(self): 230 | 231 | return self._toolbar_actions 232 | 233 | def addLines(self): 234 | 235 | origin = (0, 0, 0) 236 | ais_list = [] 237 | 238 | for name, color, direction in zip( 239 | ("X", "Y", "Z"), 240 | ("red", "lawngreen", "blue"), 241 | ((1, 0, 0), (0, 1, 0), (0, 0, 1)), 242 | ): 243 | line_placement = Geom_Line(gp_Ax1(gp_Pnt(*origin), gp_Dir(*direction))) 244 | line = AIS_Line(line_placement) 245 | line.SetColor(to_occ_color(color)) 246 | 247 | self.Helpers.addChild(ObjectTreeItem(name, ais=line)) 248 | 249 | ais_list.append(line) 250 | 251 | self.sigObjectsAdded.emit(ais_list) 252 | 253 | def _current_properties(self): 254 | 255 | current_params = {} 256 | for i in range(self.CQ.childCount()): 257 | child = self.CQ.child(i) 258 | current_params[child.properties["Name"]] = child.properties 259 | 260 | return current_params 261 | 262 | def _restore_properties(self, obj, properties): 263 | 264 | for p in properties[obj.properties["Name"]]: 265 | obj.properties[p.name()] = p.value() 266 | 267 | @pyqtSlot(dict, bool) 268 | @pyqtSlot(dict) 269 | def addObjects(self, objects, clean=False, root=None): 270 | 271 | if root is None: 272 | root = self.CQ 273 | 274 | request_fit_view = True if root.childCount() == 0 else False 275 | preserve_props = self.preferences["Preserve properties on reload"] 276 | 277 | if preserve_props: 278 | current_props = self._current_properties() 279 | 280 | if clean or self.preferences["Clear all before each run"]: 281 | self.removeObjects() 282 | 283 | ais_list = [] 284 | 285 | # remove empty objects 286 | objects_f = {k: v for k, v in objects.items() if not is_obj_empty(v.shape)} 287 | 288 | for name, obj in objects_f.items(): 289 | ais, shape_display = make_AIS(obj.shape, obj.options) 290 | 291 | child = ObjectTreeItem( 292 | name, 293 | shape=obj.shape, 294 | shape_display=shape_display, 295 | ais=ais, 296 | sig=self.sigObjectPropertiesChanged, 297 | ) 298 | 299 | if preserve_props and name in current_props: 300 | self._restore_properties(child, current_props) 301 | 302 | if child.properties["Visible"]: 303 | ais_list.append(ais) 304 | 305 | root.addChild(child) 306 | 307 | if request_fit_view: 308 | self.sigObjectsAdded[list, bool].emit(ais_list, True) 309 | else: 310 | self.sigObjectsAdded[list].emit(ais_list) 311 | 312 | @pyqtSlot(object, str, object) 313 | def addObject(self, obj, name="", options=None): 314 | 315 | if options is None: 316 | options = {} 317 | 318 | root = self.CQ 319 | 320 | ais, shape_display = make_AIS(obj, options) 321 | 322 | root.addChild( 323 | ObjectTreeItem( 324 | name, 325 | shape=obj, 326 | shape_display=shape_display, 327 | ais=ais, 328 | sig=self.sigObjectPropertiesChanged, 329 | ) 330 | ) 331 | 332 | self.sigObjectsAdded.emit([ais]) 333 | 334 | @pyqtSlot(list) 335 | @pyqtSlot() 336 | def removeObjects(self, objects=None): 337 | 338 | if objects: 339 | removed_items_ais = [self.CQ.takeChild(i).ais for i in objects] 340 | else: 341 | removed_items_ais = [ch.ais for ch in self.CQ.takeChildren()] 342 | 343 | self.sigObjectsRemoved.emit(removed_items_ais) 344 | 345 | @pyqtSlot(bool) 346 | def stashObjects(self, action: bool): 347 | 348 | if action: 349 | self._stash = self.CQ.takeChildren() 350 | removed_items_ais = [ch.ais for ch in self._stash] 351 | self.sigObjectsRemoved.emit(removed_items_ais) 352 | else: 353 | self.removeObjects() 354 | self.CQ.addChildren(self._stash) 355 | ais_list = [el.ais for el in self._stash] 356 | self.sigObjectsAdded.emit(ais_list) 357 | 358 | @pyqtSlot() 359 | def removeSelected(self): 360 | 361 | ixs = self.tree.selectedIndexes() 362 | rows = [ix.row() for ix in ixs] 363 | 364 | self.removeObjects(rows) 365 | 366 | def export(self, export_type, precision=None): 367 | 368 | items = self.tree.selectedItems() 369 | 370 | # if CQ models is selected get all children 371 | if [item for item in items if item is self.CQ]: 372 | CQ = self.CQ 373 | shapes = [CQ.child(i).shape for i in range(CQ.childCount())] 374 | # otherwise collect all selected children of CQ 375 | else: 376 | shapes = [item.shape for item in items if item.parent() is self.CQ] 377 | 378 | fname = get_save_filename(export_type) 379 | if fname != "": 380 | export(shapes, export_type, fname, precision) 381 | 382 | @pyqtSlot() 383 | def handleSelection(self): 384 | 385 | items = self.tree.selectedItems() 386 | if len(items) == 0: 387 | self._export_STL_action.setEnabled(False) 388 | self._export_STEP_action.setEnabled(False) 389 | return 390 | 391 | # emit list of all selected ais objects (might be empty) 392 | ais_objects = [item.ais for item in items if item.parent() is self.CQ] 393 | self.sigAISObjectsSelected.emit(ais_objects) 394 | 395 | # handle context menu and emit last selected CQ object (if present) 396 | item = items[-1] 397 | if item.parent() is self.CQ: 398 | self._export_STL_action.setEnabled(True) 399 | self._export_STEP_action.setEnabled(True) 400 | self._clear_current_action.setEnabled(True) 401 | self.sigCQObjectSelected.emit(item.shape) 402 | self.properties_editor.setParameters(item.properties, showTop=False) 403 | self.properties_editor.setEnabled(True) 404 | elif item is self.CQ and item.childCount() > 0: 405 | self._export_STL_action.setEnabled(True) 406 | self._export_STEP_action.setEnabled(True) 407 | else: 408 | self._export_STL_action.setEnabled(False) 409 | self._export_STEP_action.setEnabled(False) 410 | self._clear_current_action.setEnabled(False) 411 | self.properties_editor.setEnabled(False) 412 | self.properties_editor.clear() 413 | 414 | @pyqtSlot(list) 415 | def handleGraphicalSelection(self, shapes): 416 | 417 | self.tree.clearSelection() 418 | 419 | CQ = self.CQ 420 | for i in range(CQ.childCount()): 421 | item = CQ.child(i) 422 | for shape in shapes: 423 | if item.ais.Shape().IsEqual(shape): 424 | item.setSelected(True) 425 | 426 | @pyqtSlot(QTreeWidgetItem, int) 427 | def handleChecked(self, item, col): 428 | 429 | if type(item) is ObjectTreeItem: 430 | if item.checkState(0): 431 | item.properties["Visible"] = True 432 | else: 433 | item.properties["Visible"] = False 434 | -------------------------------------------------------------------------------- /cq_editor/widgets/debugger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import ExitStack, contextmanager 3 | from enum import Enum, auto 4 | from types import SimpleNamespace, FrameType, ModuleType 5 | from typing import List 6 | from bdb import BdbQuit 7 | from inspect import currentframe 8 | 9 | import cadquery as cq 10 | from PyQt5 import QtCore 11 | from PyQt5.QtCore import ( 12 | Qt, 13 | QObject, 14 | pyqtSlot, 15 | pyqtSignal, 16 | QEventLoop, 17 | QAbstractTableModel, 18 | ) 19 | from PyQt5.QtWidgets import QAction, QTableView 20 | 21 | from logbook import info 22 | from path import Path 23 | from pyqtgraph.parametertree import Parameter 24 | from ..icons import icon 25 | from random import randrange as rrr, seed 26 | 27 | from ..cq_utils import find_cq_objects, reload_cq 28 | from ..mixins import ComponentMixin 29 | 30 | DUMMY_FILE = "" 31 | 32 | 33 | class DbgState(Enum): 34 | 35 | STEP = auto() 36 | CONT = auto() 37 | STEP_IN = auto() 38 | RETURN = auto() 39 | 40 | 41 | class DbgEevent(object): 42 | 43 | LINE = "line" 44 | CALL = "call" 45 | RETURN = "return" 46 | 47 | 48 | class LocalsModel(QAbstractTableModel): 49 | 50 | HEADER = ("Name", "Type", "Value") 51 | 52 | def __init__(self, parent): 53 | 54 | super(LocalsModel, self).__init__(parent) 55 | self.frame = None 56 | 57 | def update_frame(self, frame): 58 | 59 | self.frame = [ 60 | (k, type(v).__name__, str(v)) 61 | for k, v in frame.items() 62 | if not k.startswith("_") 63 | ] 64 | 65 | def rowCount(self, parent=QtCore.QModelIndex()): 66 | 67 | if self.frame: 68 | return len(self.frame) 69 | else: 70 | return 0 71 | 72 | def columnCount(self, parent=QtCore.QModelIndex()): 73 | 74 | return 3 75 | 76 | def headerData(self, section, orientation, role=Qt.DisplayRole): 77 | if role == Qt.DisplayRole and orientation == Qt.Horizontal: 78 | return self.HEADER[section] 79 | return QAbstractTableModel.headerData(self, section, orientation, role) 80 | 81 | def data(self, index, role): 82 | if role == QtCore.Qt.DisplayRole: 83 | i = index.row() 84 | j = index.column() 85 | return self.frame[i][j] 86 | else: 87 | return QtCore.QVariant() 88 | 89 | 90 | class LocalsView(QTableView, ComponentMixin): 91 | 92 | name = "Variables" 93 | 94 | def __init__(self, parent): 95 | 96 | super(LocalsView, self).__init__(parent) 97 | ComponentMixin.__init__(self) 98 | 99 | header = self.horizontalHeader() 100 | header.setStretchLastSection(True) 101 | 102 | vheader = self.verticalHeader() 103 | vheader.setVisible(False) 104 | 105 | @pyqtSlot(dict) 106 | def update_frame(self, frame): 107 | 108 | model = LocalsModel(self) 109 | model.update_frame(frame) 110 | 111 | self.setModel(model) 112 | 113 | 114 | class Debugger(QObject, ComponentMixin): 115 | 116 | name = "Debugger" 117 | 118 | preferences = Parameter.create( 119 | name="Preferences", 120 | children=[ 121 | {"name": "Reload CQ", "type": "bool", "value": False}, 122 | {"name": "Add script dir to path", "type": "bool", "value": True}, 123 | {"name": "Change working dir to script dir", "type": "bool", "value": True}, 124 | {"name": "Reload imported modules", "type": "bool", "value": True}, 125 | ], 126 | ) 127 | 128 | sigRendered = pyqtSignal(dict) 129 | sigLocals = pyqtSignal(dict) 130 | sigTraceback = pyqtSignal(object, str) 131 | 132 | sigFrameChanged = pyqtSignal(object) 133 | sigLineChanged = pyqtSignal(int) 134 | sigLocalsChanged = pyqtSignal(dict) 135 | sigCQChanged = pyqtSignal(dict, bool) 136 | sigDebugging = pyqtSignal(bool) 137 | 138 | _frames: List[FrameType] 139 | _stop_debugging: bool 140 | 141 | def __init__(self, parent): 142 | 143 | super(Debugger, self).__init__(parent) 144 | ComponentMixin.__init__(self) 145 | 146 | self.inner_event_loop = QEventLoop(self) 147 | 148 | self._actions = { 149 | "Run": [ 150 | QAction( 151 | icon("run"), "Render", self, shortcut="F5", triggered=self.render 152 | ), 153 | QAction( 154 | icon("debug"), 155 | "Debug", 156 | self, 157 | checkable=True, 158 | shortcut="ctrl+F5", 159 | triggered=self.debug, 160 | ), 161 | QAction( 162 | icon("arrow-step-over"), 163 | "Step", 164 | self, 165 | shortcut="ctrl+F10", 166 | triggered=lambda: self.debug_cmd(DbgState.STEP), 167 | ), 168 | QAction( 169 | icon("arrow-step-in"), 170 | "Step in", 171 | self, 172 | shortcut="ctrl+F11", 173 | triggered=lambda: self.debug_cmd(DbgState.STEP_IN), 174 | ), 175 | QAction( 176 | icon("arrow-continue"), 177 | "Continue", 178 | self, 179 | shortcut="ctrl+F12", 180 | triggered=lambda: self.debug_cmd(DbgState.CONT), 181 | ), 182 | ] 183 | } 184 | 185 | self._frames = [] 186 | self._stop_debugging = False 187 | 188 | def get_current_script(self): 189 | 190 | return self.parent().components["editor"].get_text_with_eol() 191 | 192 | def get_current_script_path(self): 193 | 194 | filename = self.parent().components["editor"].filename 195 | if filename: 196 | return Path(filename).absolute() 197 | 198 | def get_breakpoints(self): 199 | 200 | return self.parent().components["editor"].debugger.get_breakpoints() 201 | 202 | def set_breakpoints(self, breakpoints): 203 | return self.parent().components["editor"].debugger.set_breakpoints(breakpoints) 204 | 205 | def compile_code(self, cq_script, cq_script_path=None): 206 | 207 | try: 208 | module = ModuleType("__cq_main__") 209 | if cq_script_path: 210 | module.__dict__["__file__"] = cq_script_path 211 | cq_code = compile(cq_script, DUMMY_FILE, "exec") 212 | return cq_code, module 213 | except Exception: 214 | self.sigTraceback.emit(sys.exc_info(), cq_script) 215 | return None, None 216 | 217 | def _exec(self, code, locals_dict, globals_dict): 218 | 219 | with ExitStack() as stack: 220 | p = (self.get_current_script_path() or Path("")).absolute().dirname() 221 | 222 | if self.preferences["Add script dir to path"] and p.exists(): 223 | sys.path.insert(0, p) 224 | stack.callback(sys.path.remove, p) 225 | if self.preferences["Change working dir to script dir"] and p.exists(): 226 | stack.enter_context(p) 227 | if self.preferences["Reload imported modules"]: 228 | stack.enter_context(module_manager()) 229 | 230 | exec(code, locals_dict, globals_dict) 231 | 232 | @staticmethod 233 | def _rand_color(alpha=0.0, cfloat=False): 234 | # helper function to generate a random color dict 235 | # for CQ-editor's show_object function 236 | lower = 10 237 | upper = 100 # not too high to keep color brightness in check 238 | if cfloat: # for two output types depending on need 239 | return ( 240 | (rrr(lower, upper) / 255), 241 | (rrr(lower, upper) / 255), 242 | (rrr(lower, upper) / 255), 243 | alpha, 244 | ) 245 | return { 246 | "alpha": alpha, 247 | "color": ( 248 | rrr(lower, upper), 249 | rrr(lower, upper), 250 | rrr(lower, upper), 251 | ), 252 | } 253 | 254 | def _inject_locals(self, module): 255 | 256 | cq_objects = {} 257 | 258 | def _show_object(obj, name=None, options={}): 259 | 260 | if name: 261 | cq_objects.update({name: SimpleNamespace(shape=obj, options=options)}) 262 | else: 263 | # get locals of the enclosing scope 264 | d = currentframe().f_back.f_locals 265 | 266 | # try to find the name 267 | try: 268 | name = list(d.keys())[list(d.values()).index(obj)] 269 | except ValueError: 270 | # use id if not found 271 | name = str(id(obj)) 272 | 273 | cq_objects.update({name: SimpleNamespace(shape=obj, options=options)}) 274 | 275 | def _debug(obj, name=None): 276 | 277 | _show_object(obj, name, options=dict(color="red", alpha=0.2)) 278 | 279 | module.__dict__["show_object"] = _show_object 280 | module.__dict__["debug"] = _debug 281 | module.__dict__["rand_color"] = self._rand_color 282 | module.__dict__["log"] = lambda x: info(str(x)) 283 | module.__dict__["cq"] = cq 284 | 285 | return cq_objects, set(module.__dict__) - {"cq"} 286 | 287 | def _cleanup_locals(self, module, injected_names): 288 | 289 | for name in injected_names: 290 | module.__dict__.pop(name) 291 | 292 | @pyqtSlot(bool) 293 | def render(self): 294 | 295 | seed(59798267586177) 296 | if self.preferences["Reload CQ"]: 297 | reload_cq() 298 | 299 | cq_script = self.get_current_script() 300 | cq_script_path = self.get_current_script_path() 301 | cq_code, module = self.compile_code(cq_script, cq_script_path) 302 | 303 | if cq_code is None: 304 | return 305 | 306 | cq_objects, injected_names = self._inject_locals(module) 307 | 308 | try: 309 | self._exec(cq_code, module.__dict__, module.__dict__) 310 | 311 | # remove the special methods 312 | self._cleanup_locals(module, injected_names) 313 | 314 | # collect all CQ objects if no explicit show_object was called 315 | if len(cq_objects) == 0: 316 | cq_objects = find_cq_objects(module.__dict__) 317 | self.sigRendered.emit(cq_objects) 318 | self.sigTraceback.emit(None, cq_script) 319 | self.sigLocals.emit(module.__dict__) 320 | except Exception: 321 | exc_info = sys.exc_info() 322 | sys.last_traceback = exc_info[-1] 323 | self.sigTraceback.emit(exc_info, cq_script) 324 | 325 | @property 326 | def breakpoints(self): 327 | return [el[0] for el in self.get_breakpoints()] 328 | 329 | @pyqtSlot(bool) 330 | def debug(self, value): 331 | 332 | # used to stop the debugging session early 333 | self._stop_debugging = False 334 | 335 | if value: 336 | self.previous_trace = previous_trace = sys.gettrace() 337 | 338 | self.sigDebugging.emit(True) 339 | self.state = DbgState.STEP 340 | 341 | self.script = self.get_current_script() 342 | cq_script_path = self.get_current_script_path() 343 | code, module = self.compile_code(self.script, cq_script_path) 344 | 345 | if code is None: 346 | self.sigDebugging.emit(False) 347 | self._actions["Run"][1].setChecked(False) 348 | return 349 | 350 | cq_objects, injected_names = self._inject_locals(module) 351 | 352 | # clear possible traceback 353 | self.sigTraceback.emit(None, self.script) 354 | 355 | try: 356 | sys.settrace(self.trace_callback) 357 | exec(code, module.__dict__, module.__dict__) 358 | except BdbQuit: 359 | pass 360 | except Exception: 361 | exc_info = sys.exc_info() 362 | sys.last_traceback = exc_info[-1] 363 | self.sigTraceback.emit(exc_info, self.script) 364 | finally: 365 | sys.settrace(previous_trace) 366 | self.sigDebugging.emit(False) 367 | self._actions["Run"][1].setChecked(False) 368 | 369 | if len(cq_objects) == 0: 370 | cq_objects = find_cq_objects(module.__dict__) 371 | self.sigRendered.emit(cq_objects) 372 | 373 | self._cleanup_locals(module, injected_names) 374 | self.sigLocals.emit(module.__dict__) 375 | 376 | self._frames = [] 377 | self.inner_event_loop.exit(0) 378 | else: 379 | self._stop_debugging = True 380 | self.inner_event_loop.exit(0) 381 | 382 | def debug_cmd(self, state=DbgState.STEP): 383 | 384 | self.state = state 385 | self.inner_event_loop.exit(0) 386 | 387 | def trace_callback(self, frame, event, arg): 388 | 389 | filename = frame.f_code.co_filename 390 | 391 | if filename == DUMMY_FILE: 392 | if not self._frames: 393 | self._frames.append(frame) 394 | self.trace_local(frame, event, arg) 395 | return self.trace_callback 396 | 397 | else: 398 | return None 399 | 400 | def trace_local(self, frame, event, arg): 401 | 402 | lineno = frame.f_lineno 403 | 404 | if event in (DbgEevent.LINE,): 405 | if ( 406 | self.state in (DbgState.STEP, DbgState.STEP_IN) 407 | and frame is self._frames[-1] 408 | ) or (lineno in self.get_breakpoints()): 409 | 410 | if lineno in self.get_breakpoints(): 411 | self._frames.append(frame) 412 | 413 | self.sigLineChanged.emit(lineno) 414 | self.sigFrameChanged.emit(frame) 415 | self.sigLocalsChanged.emit(frame.f_locals) 416 | self.sigCQChanged.emit(find_cq_objects(frame.f_locals), True) 417 | 418 | self.inner_event_loop.exec_() 419 | 420 | elif event in (DbgEevent.RETURN): 421 | self.sigLocalsChanged.emit(frame.f_locals) 422 | self._frames.pop() 423 | 424 | elif event == DbgEevent.CALL: 425 | func_filename = frame.f_code.co_filename 426 | if self.state == DbgState.STEP_IN and func_filename == DUMMY_FILE: 427 | self.sigLineChanged.emit(lineno) 428 | self.sigFrameChanged.emit(frame) 429 | self.state = DbgState.STEP 430 | self._frames.append(frame) 431 | 432 | if self._stop_debugging: 433 | raise BdbQuit # stop debugging if requested 434 | 435 | 436 | @contextmanager 437 | def module_manager(): 438 | """unloads any modules loaded while the context manager is active""" 439 | loaded_modules = set(sys.modules.keys()) 440 | 441 | try: 442 | yield 443 | finally: 444 | new_modules = set(sys.modules.keys()) - loaded_modules 445 | for module_name in new_modules: 446 | del sys.modules[module_name] 447 | -------------------------------------------------------------------------------- /cq_editor/widgets/viewer.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QWidget, QDialog, QTreeWidgetItem, QApplication, QAction 2 | 3 | from PyQt5.QtCore import pyqtSlot, pyqtSignal 4 | from PyQt5.QtGui import QIcon 5 | 6 | from OCP.Graphic3d import ( 7 | Graphic3d_Camera, 8 | Graphic3d_StereoMode, 9 | Graphic3d_NOM_JADE, 10 | Graphic3d_MaterialAspect, 11 | ) 12 | from OCP.AIS import AIS_Shaded, AIS_WireFrame, AIS_ColoredShape, AIS_Axis 13 | from OCP.Aspect import Aspect_GDM_Lines, Aspect_GT_Rectangular 14 | from OCP.Quantity import ( 15 | Quantity_NOC_BLACK as BLACK, 16 | Quantity_TOC_RGB as TOC_RGB, 17 | Quantity_Color, 18 | ) 19 | from OCP.Geom import Geom_Axis1Placement 20 | from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt, gp_Ax1 21 | 22 | from ..utils import layout, get_save_filename 23 | from ..mixins import ComponentMixin 24 | from ..icons import icon 25 | from ..cq_utils import to_occ_color, make_AIS, DEFAULT_FACE_COLOR 26 | 27 | from .occt_widget import OCCTWidget 28 | 29 | from pyqtgraph.parametertree import Parameter 30 | import qtawesome as qta 31 | 32 | 33 | DEFAULT_EDGE_COLOR = Quantity_Color(BLACK) 34 | DEFAULT_EDGE_WIDTH = 2 35 | 36 | 37 | class OCCViewer(QWidget, ComponentMixin): 38 | 39 | name = "3D Viewer" 40 | 41 | preferences = Parameter.create( 42 | name="Pref", 43 | children=[ 44 | {"name": "Fit automatically", "type": "bool", "value": True}, 45 | {"name": "Use gradient", "type": "bool", "value": False}, 46 | {"name": "Background color", "type": "color", "value": (95, 95, 95)}, 47 | {"name": "Background color (aux)", "type": "color", "value": (30, 30, 30)}, 48 | { 49 | "name": "Deviation", 50 | "type": "float", 51 | "value": 1e-5, 52 | "dec": True, 53 | "step": 1, 54 | }, 55 | { 56 | "name": "Angular deviation", 57 | "type": "float", 58 | "value": 0.1, 59 | "dec": True, 60 | "step": 1, 61 | }, 62 | { 63 | "name": "Projection Type", 64 | "type": "list", 65 | "value": "Orthographic", 66 | "values": [ 67 | "Orthographic", 68 | "Perspective", 69 | "Stereo", 70 | "MonoLeftEye", 71 | "MonoRightEye", 72 | ], 73 | }, 74 | { 75 | "name": "Stereo Mode", 76 | "type": "list", 77 | "value": "QuadBuffer", 78 | "values": [ 79 | "QuadBuffer", 80 | "Anaglyph", 81 | "RowInterlaced", 82 | "ColumnInterlaced", 83 | "ChessBoard", 84 | "SideBySide", 85 | "OverUnder", 86 | ], 87 | }, 88 | { 89 | "name": "Orbit Method", 90 | "type": "list", 91 | "value": "Turntable", 92 | "values": [ 93 | "Turntable", 94 | "Trackball", 95 | ], 96 | }, 97 | ], 98 | ) 99 | IMAGE_EXTENSIONS = "png" 100 | 101 | sigObjectSelected = pyqtSignal(list) 102 | 103 | def __init__(self, parent=None): 104 | 105 | super(OCCViewer, self).__init__(parent) 106 | ComponentMixin.__init__(self) 107 | 108 | self.canvas = OCCTWidget() 109 | self.canvas.sigObjectSelected.connect(self.handle_selection) 110 | 111 | self.create_actions(self) 112 | 113 | self.layout_ = layout( 114 | self, 115 | [ 116 | self.canvas, 117 | ], 118 | top_widget=self, 119 | margin=0, 120 | ) 121 | 122 | self.setup_default_drawer() 123 | self.updatePreferences() 124 | 125 | def setup_default_drawer(self): 126 | 127 | # set the default color and material 128 | material = Graphic3d_MaterialAspect(Graphic3d_NOM_JADE) 129 | 130 | shading_aspect = self.canvas.context.DefaultDrawer().ShadingAspect() 131 | shading_aspect.SetMaterial(material) 132 | shading_aspect.SetColor(DEFAULT_FACE_COLOR) 133 | 134 | # face edge lw 135 | line_aspect = self.canvas.context.DefaultDrawer().FaceBoundaryAspect() 136 | line_aspect.SetWidth(DEFAULT_EDGE_WIDTH) 137 | line_aspect.SetColor(DEFAULT_EDGE_COLOR) 138 | 139 | def updatePreferences(self, *args): 140 | 141 | color1 = to_occ_color(self.preferences["Background color"]) 142 | color2 = to_occ_color(self.preferences["Background color (aux)"]) 143 | 144 | if not self.preferences["Use gradient"]: 145 | color2 = color1 146 | self.canvas.view.SetBgGradientColors(color1, color2, theToUpdate=True) 147 | 148 | # Set the orbit method 149 | orbit_method = self.preferences["Orbit Method"] 150 | if not orbit_method: 151 | orbit_method = "Trackball" 152 | self.canvas.set_orbit_method(orbit_method) 153 | 154 | self.canvas.update() 155 | 156 | ctx = self.canvas.context 157 | ctx.SetDeviationCoefficient(self.preferences["Deviation"]) 158 | ctx.SetDeviationAngle(self.preferences["Angular deviation"]) 159 | 160 | v = self._get_view() 161 | camera = v.Camera() 162 | projection_type = self.preferences["Projection Type"] 163 | camera.SetProjectionType( 164 | getattr( 165 | Graphic3d_Camera, 166 | f"Projection_{projection_type}", 167 | Graphic3d_Camera.Projection_Orthographic, 168 | ) 169 | ) 170 | 171 | # onle relevant for stereo projection 172 | stereo_mode = self.preferences["Stereo Mode"] 173 | params = v.ChangeRenderingParams() 174 | params.StereoMode = getattr( 175 | Graphic3d_StereoMode, 176 | f"Graphic3d_StereoMode_{stereo_mode}", 177 | Graphic3d_StereoMode.Graphic3d_StereoMode_QuadBuffer, 178 | ) 179 | 180 | def create_actions(self, parent): 181 | 182 | self._actions = { 183 | "View": [ 184 | QAction( 185 | qta.icon("fa6s.maximize"), 186 | "Fit (Shift+F1)", 187 | parent, 188 | shortcut="shift+F1", 189 | triggered=self.fit, 190 | ), 191 | QAction( 192 | QIcon(":/images/icons/isometric_view.svg"), 193 | "Iso (Shift+F2)", 194 | parent, 195 | shortcut="shift+F2", 196 | triggered=self.iso_view, 197 | ), 198 | QAction( 199 | QIcon(":/images/icons/top_view.svg"), 200 | "Top (Shift+F3)", 201 | parent, 202 | shortcut="shift+F3", 203 | triggered=self.top_view, 204 | ), 205 | QAction( 206 | QIcon(":/images/icons/bottom_view.svg"), 207 | "Bottom (Shift+F4)", 208 | parent, 209 | shortcut="shift+F4", 210 | triggered=self.bottom_view, 211 | ), 212 | QAction( 213 | QIcon(":/images/icons/front_view.svg"), 214 | "Front (Shift+F5)", 215 | parent, 216 | shortcut="shift+F5", 217 | triggered=self.front_view, 218 | ), 219 | QAction( 220 | QIcon(":/images/icons/back_view.svg"), 221 | "Back (Shift+F6)", 222 | parent, 223 | shortcut="shift+F6", 224 | triggered=self.back_view, 225 | ), 226 | QAction( 227 | QIcon(":/images/icons/left_side_view.svg"), 228 | "Left (Shift+F7)", 229 | parent, 230 | shortcut="shift+F7", 231 | triggered=self.left_view, 232 | ), 233 | QAction( 234 | QIcon(":/images/icons/right_side_view.svg"), 235 | "Right (Shift+F8)", 236 | parent, 237 | shortcut="shift+F8", 238 | triggered=self.right_view, 239 | ), 240 | QAction( 241 | qta.icon("fa5.stop-circle"), 242 | "Wireframe (Shift+F9)", 243 | parent, 244 | shortcut="shift+F9", 245 | triggered=self.wireframe_view, 246 | ), 247 | QAction( 248 | qta.icon("fa5.square"), 249 | "Shaded (Shift+F10)", 250 | parent, 251 | shortcut="shift+F10", 252 | triggered=self.shaded_view, 253 | ), 254 | ], 255 | "Tools": [ 256 | QAction( 257 | qta.icon("fa5s.camera"), 258 | "Screenshot", 259 | parent, 260 | triggered=self.save_screenshot, 261 | ) 262 | ], 263 | } 264 | 265 | def toolbarActions(self): 266 | 267 | return self._actions["View"] 268 | 269 | def clear(self): 270 | 271 | self.displayed_shapes = [] 272 | self.displayed_ais = [] 273 | self.canvas.context.EraseAll(True) 274 | context = self._get_context() 275 | context.PurgeDisplay() 276 | context.RemoveAll(True) 277 | 278 | def _display(self, shape): 279 | 280 | ais = make_AIS(shape) 281 | self.canvas.context.Display(shape, True) 282 | 283 | self.displayed_shapes.append(shape) 284 | self.displayed_ais.append(ais) 285 | 286 | # self.canvas._display.Repaint() 287 | 288 | @pyqtSlot(object) 289 | def display(self, ais): 290 | 291 | context = self._get_context() 292 | context.Display(ais, True) 293 | 294 | if self.preferences["Fit automatically"]: 295 | self.fit() 296 | 297 | @pyqtSlot(list) 298 | @pyqtSlot(list, bool) 299 | def display_many(self, ais_list, fit=None): 300 | context = self._get_context() 301 | for ais in ais_list: 302 | context.Display(ais, True) 303 | 304 | if self.preferences["Fit automatically"] and fit is None: 305 | self.fit() 306 | elif fit: 307 | self.fit() 308 | 309 | @pyqtSlot(QTreeWidgetItem, int) 310 | def update_item(self, item, col): 311 | 312 | ctx = self._get_context() 313 | if item.checkState(0): 314 | ctx.Display(item.ais, True) 315 | else: 316 | ctx.Erase(item.ais, True) 317 | 318 | @pyqtSlot(list) 319 | def remove_items(self, ais_items): 320 | 321 | ctx = self._get_context() 322 | for ais in ais_items: 323 | ctx.Erase(ais, True) 324 | 325 | @pyqtSlot() 326 | def redraw(self): 327 | 328 | self._get_viewer().Redraw() 329 | 330 | def fit(self): 331 | 332 | self.canvas.view.FitAll() 333 | 334 | def iso_view(self): 335 | 336 | v = self._get_view() 337 | v.SetProj(1, -1, 1) 338 | v.SetTwist(0) 339 | 340 | def bottom_view(self): 341 | 342 | v = self._get_view() 343 | v.SetProj(0, 0, -1) 344 | v.SetTwist(0) 345 | 346 | def top_view(self): 347 | 348 | v = self._get_view() 349 | v.SetProj(0, 0, 1) 350 | v.SetTwist(0) 351 | 352 | def front_view(self): 353 | 354 | v = self._get_view() 355 | v.SetProj(0, 1, 0) 356 | v.SetTwist(0) 357 | 358 | def back_view(self): 359 | 360 | v = self._get_view() 361 | v.SetProj(0, -1, 0) 362 | v.SetTwist(0) 363 | 364 | def left_view(self): 365 | 366 | v = self._get_view() 367 | v.SetProj(-1, 0, 0) 368 | v.SetTwist(0) 369 | 370 | def right_view(self): 371 | 372 | v = self._get_view() 373 | v.SetProj(1, 0, 0) 374 | v.SetTwist(0) 375 | 376 | def shaded_view(self): 377 | 378 | c = self._get_context() 379 | c.SetDisplayMode(AIS_Shaded, True) 380 | 381 | def wireframe_view(self): 382 | 383 | c = self._get_context() 384 | c.SetDisplayMode(AIS_WireFrame, True) 385 | 386 | def show_grid( 387 | self, step=1.0, size=10.0 + 1e-6, color1=(0.7, 0.7, 0.7), color2=(0, 0, 0) 388 | ): 389 | 390 | viewer = self._get_viewer() 391 | viewer.ActivateGrid(Aspect_GT_Rectangular, Aspect_GDM_Lines) 392 | viewer.SetRectangularGridGraphicValues(size, size, 0) 393 | viewer.SetRectangularGridValues(0, 0, step, step, 0) 394 | grid = viewer.Grid() 395 | grid.SetColors( 396 | Quantity_Color(*color1, TOC_RGB), Quantity_Color(*color2, TOC_RGB) 397 | ) 398 | 399 | def hide_grid(self): 400 | 401 | viewer = self._get_viewer() 402 | viewer.DeactivateGrid() 403 | 404 | @pyqtSlot(bool, float) 405 | @pyqtSlot(bool) 406 | def toggle_grid(self, value: bool, dim: float = 10.0): 407 | 408 | if value: 409 | self.show_grid(step=dim / 20, size=dim + 1e-9) 410 | else: 411 | self.hide_grid() 412 | 413 | @pyqtSlot(gp_Ax3) 414 | def set_grid_orientation(self, orientation: gp_Ax3): 415 | 416 | viewer = self._get_viewer() 417 | viewer.SetPrivilegedPlane(orientation) 418 | 419 | def show_axis(self, origin=(0, 0, 0), direction=(0, 0, 1)): 420 | 421 | ax_placement = Geom_Axis1Placement(gp_Ax1(gp_Pnt(*origin), gp_Dir(*direction))) 422 | ax = AIS_Axis(ax_placement) 423 | self._display_ais(ax) 424 | 425 | def save_screenshot(self): 426 | 427 | fname = get_save_filename(self.IMAGE_EXTENSIONS) 428 | if fname != "": 429 | self._get_view().Dump(fname) 430 | 431 | def _display_ais(self, ais): 432 | 433 | self._get_context().Display(ais) 434 | 435 | def _get_view(self): 436 | 437 | return self.canvas.view 438 | 439 | def _get_viewer(self): 440 | 441 | return self.canvas.viewer 442 | 443 | def _get_context(self): 444 | 445 | return self.canvas.context 446 | 447 | @pyqtSlot(list) 448 | def handle_selection(self, obj): 449 | 450 | self.sigObjectSelected.emit(obj) 451 | 452 | @pyqtSlot(list) 453 | def set_selected(self, ais): 454 | 455 | ctx = self._get_context() 456 | ctx.ClearSelected(False) 457 | 458 | for obj in ais: 459 | ctx.AddOrRemoveSelected(obj, False) 460 | 461 | self.redraw() 462 | 463 | 464 | if __name__ == "__main__": 465 | 466 | import sys 467 | from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox 468 | 469 | app = QApplication(sys.argv) 470 | viewer = OCCViewer() 471 | 472 | dlg = QDialog() 473 | dlg.setFixedHeight(400) 474 | dlg.setFixedWidth(600) 475 | 476 | layout(dlg, (viewer,), dlg) 477 | dlg.show() 478 | 479 | box = BRepPrimAPI_MakeBox(20, 20, 30) 480 | box_ais = AIS_ColoredShape(box.Shape()) 481 | viewer.display(box_ais) 482 | 483 | sys.exit(app.exec_()) 484 | -------------------------------------------------------------------------------- /icons/back_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /icons/bottom_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /icons/front_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /icons/top_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | --------------------------------------------------------------------------------