├── 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 | [](https://ci.appveyor.com/project/adam-urbanczyk/cq-editor/branch/master)
4 | [](https://codecov.io/gh/CadQuery/CQ-editor)
5 | [](https://dev.azure.com/cadquery/CQ-editor/_build/latest?definitionId=3&branchName=master)
6 | [](https://zenodo.org/badge/latestdoi/136604983)
7 |
8 | CadQuery GUI editor based on PyQT that supports Linux, Windows and Mac.
9 |
10 | 
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 |
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 |
132 |
--------------------------------------------------------------------------------
/icons/bottom_view.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
132 |
--------------------------------------------------------------------------------
/icons/front_view.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
132 |
--------------------------------------------------------------------------------
/icons/top_view.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
132 |
--------------------------------------------------------------------------------