├── .coveragerc ├── .github └── workflows │ └── lint.yml ├── .gitignore ├── LICENSE ├── README.md ├── appveyor.yml ├── azure-pipelines.yml ├── bundle.py ├── changes.md ├── collect_icons.py ├── conda ├── construct.yaml ├── meta.yaml ├── run.bat └── run.sh ├── cq_editor ├── __init__.py ├── __main__.py ├── _version.py ├── cq_utils.py ├── cqe_run.py ├── icons.py ├── icons_res.py ├── main_window.py ├── mixins.py ├── preferences.py ├── utils.py └── widgets │ ├── __init__.py │ ├── console.py │ ├── cq_object_inspector.py │ ├── debugger.py │ ├── editor.py │ ├── log.py │ ├── object_tree.py │ ├── occt_widget.py │ ├── traceback_viewer.py │ └── viewer.py ├── cqgui_env.yml ├── icons ├── back_view.svg ├── bottom_view.svg ├── cadquery_logo_dark.ico ├── cadquery_logo_dark.svg ├── front_view.svg ├── isometric_view.svg ├── left_side_view.svg ├── right_side_view.svg └── top_view.svg ├── pyinstaller.spec ├── pyinstaller ├── pyi_rth_fontconfig.py └── pyi_rth_occ.py ├── pyproject.toml ├── pytest.ini ├── run.py ├── runtests_locally.sh ├── screenshots ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── setup.py └── tests └── test_app.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | timid = True 3 | branch = True 4 | source = src 5 | 6 | [report] 7 | exclude_lines = 8 | if __name__ == .__main__.: 9 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CQ-editor 2 | 3 | [![Build status](https://ci.appveyor.com/api/projects/status/g98rs7la393mgy91/branch/master?svg=true)](https://ci.appveyor.com/project/adam-urbanczyk/cq-editor/branch/master) 4 | [![codecov](https://codecov.io/gh/CadQuery/CQ-editor/branch/master/graph/badge.svg)](https://codecov.io/gh/CadQuery/CQ-editor) 5 | [![Build Status](https://dev.azure.com/cadquery/CQ-editor/_apis/build/status/CadQuery.CQ-editor?branchName=master)](https://dev.azure.com/cadquery/CQ-editor/_build/latest?definitionId=3&branchName=master) 6 | [![DOI](https://zenodo.org/badge/136604983.svg)](https://zenodo.org/badge/latestdoi/136604983) 7 | 8 | CadQuery GUI editor based on PyQT that supports Linux, Windows and Mac. 9 | 10 | ![CQ-editor screenshot](https://github.com/CadQuery/CQ-editor/raw/master/screenshots/screenshot4.png) 11 | Additional screenshots are available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki#screenshots). 12 | 13 | ## Notable features 14 | 15 | * Automatic code reloading - you can use your favourite editor 16 | * OCCT based 17 | * Graphical debugger for CadQuery scripts 18 | * Step through script and watch how your model changes 19 | * CadQuery object stack inspector 20 | * Visual inspection of current workplane and selected items 21 | * Insight into evolution of the model 22 | * Export to various formats 23 | * STL 24 | * STEP 25 | 26 | ## Documentation 27 | 28 | Documentation is available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki). Topics covered are the following. 29 | 30 | * [Installation](https://github.com/CadQuery/CQ-editor/wiki/Installation) 31 | * [Usage](https://github.com/CadQuery/CQ-editor/wiki/Usage) 32 | * [Configuration](https://github.com/CadQuery/CQ-editor/wiki/Configuration) 33 | 34 | ## Getting Help 35 | 36 | For general questions and discussion about CQ-editor, please create a [GitHub Discussion](https://github.com/CadQuery/CQ-editor/discussions). 37 | 38 | ## Reporting a Bug 39 | 40 | If you believe that you have found a bug in CQ-editor, please ensure the following. 41 | 42 | * You are not running a CQ-editor fork, as these are not always synchronized with the latest updates in this project. 43 | * You have searched the [issue tracker](https://github.com/CadQuery/CQ-editor/issues) to make sure that the bug is not already known. 44 | 45 | If you have already checked those things, please file a [new issue](https://github.com/CadQuery/CQ-editor/issues/new) with the following information. 46 | 47 | * Operating System (type, version, etc) - If running Linux, please include the distribution and the version. 48 | * How CQ-editor was installed. 49 | * Python version of your environment (unless you are running a pre-built package). 50 | * Steps to reproduce the bug. 51 | -------------------------------------------------------------------------------- /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/latest/download/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/latest/download/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 | - mamba info 25 | - mamba env create --name cqgui -f cqgui_env.yml 26 | - sh: source activate cqgui 27 | - cmd: activate cqgui 28 | - mamba list 29 | - mamba install -y pytest pluggy pytest-qt 30 | - mamba 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 | -------------------------------------------------------------------------------- /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: CadQuery/conda-packages 17 | endpoint: CadQuery 18 | 19 | parameters: 20 | - name: minor 21 | type: object 22 | default: 23 | - 11 24 | 25 | stages: 26 | - stage: build_conda_package 27 | jobs: 28 | - ${{ each minor in parameters.minor }}: 29 | - template: conda-build.yml@templates 30 | parameters: 31 | name: Linux 32 | vmImage: 'ubuntu-latest' 33 | py_maj: 3 34 | py_min: ${{minor}} 35 | conda_bld: 3.21.6 36 | 37 | - stage: build_installers 38 | jobs: 39 | - template: constructor-build.yml@templates 40 | parameters: 41 | name: linux 42 | vmImage: 'ubuntu-latest' 43 | - template: constructor-build.yml@templates 44 | parameters: 45 | name: win 46 | vmImage: 'windows-latest' 47 | - template: constructor-build.yml@templates 48 | parameters: 49 | name: macos 50 | vmImage: 'macOS-latest' 51 | 52 | - stage: upload_installers 53 | jobs: 54 | - job: upload_to_github 55 | condition: ne(variables['Build.Reason'], 'PullRequest') 56 | pool: 57 | vmImage: ubuntu-latest 58 | steps: 59 | - download: current 60 | artifact: installer_ubuntu-latest 61 | - download: current 62 | artifact: installer_windows-latest 63 | - download: current 64 | artifact: installer_macOS-latest 65 | - bash: cp $(Pipeline.Workspace)/installer*/*.* . 66 | - task: GitHubRelease@1 67 | inputs: 68 | gitHubConnection: github.com_oauth 69 | assets: CQ-editor-*.* 70 | action: edit 71 | tag: nightly 72 | target: d8e247d15001bf785ef7498d922b4b5aa017a9c9 73 | addChangeLog: false 74 | assetUploadMode: replace 75 | isPreRelease: true 76 | 77 | # stage left for debugging, disabled by default 78 | - stage: verify 79 | condition: False 80 | jobs: 81 | - job: verify_linux 82 | pool: 83 | vmImage: ubuntu-latest 84 | steps: 85 | - download: current 86 | artifact: installer_ubuntu-latest 87 | - bash: cp $(Pipeline.Workspace)/installer*/*.* . 88 | - bash: sh ./CQ-editor-master-Linux-x86_64.sh -b -p dummy && cd dummy && ./run.sh 89 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.8 18 | - setuptools 19 | 20 | run: 21 | - python >=3.9 22 | - cadquery=master 23 | - ocp 24 | - logbook 25 | - pyqt=5.* 26 | - pyqtgraph 27 | - spyder >=5.5.6,<6 28 | - path 29 | - logbook 30 | - requests 31 | - qtconsole >=5.5.1,<5.6.0 32 | test: 33 | imports: 34 | - cq_editor 35 | 36 | about: 37 | summary: GUI for CadQuery 2 38 | -------------------------------------------------------------------------------- /conda/run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | start /B condabin\mamba.bat run -n base python Scripts\cq-editor-script.py 3 | -------------------------------------------------------------------------------- /conda/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec $(dirname $(realpath "$0"))/bin/cq-editor 3 | -------------------------------------------------------------------------------- /cq_editor/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | -------------------------------------------------------------------------------- /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 | win = MainWindow(filename=args.filename if args.filename else None) 22 | win.show() 23 | sys.exit(app.exec_()) 24 | 25 | 26 | if __name__ == "__main__": 27 | 28 | main() 29 | -------------------------------------------------------------------------------- /cq_editor/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.dev0" 2 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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": (("fa.file-o",), {}), 19 | "open": (("fa.folder-open-o",), {}), 20 | # borrowed from spider-ide 21 | "autoreload": [ 22 | ("fa.repeat", "fa.clock-o"), 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": (("fa.save",), {}), 31 | "save_as": ( 32 | ("fa.save", "fa.pencil"), 33 | { 34 | "options": [ 35 | { 36 | "scale_factor": 1, 37 | }, 38 | {"scale_factor": 0.8, "offset": (0.2, 0.2)}, 39 | ] 40 | }, 41 | ), 42 | "run": (("fa.play",), {}), 43 | "delete": (("fa.trash",), {}), 44 | "delete-many": ( 45 | ( 46 | "fa.trash", 47 | "fa.trash", 48 | ), 49 | { 50 | "options": [ 51 | {"scale_factor": 0.8, "offset": (0.2, 0.2), "color": "gray"}, 52 | {"scale_factor": 0.8}, 53 | ] 54 | }, 55 | ), 56 | "help": (("fa.life-ring",), {}), 57 | "about": (("fa.info",), {}), 58 | "preferences": (("fa.cogs",), {}), 59 | "inspect": ( 60 | ("fa.cubes", "fa.search"), 61 | {"options": [{"scale_factor": 0.8, "offset": (0, 0), "color": "gray"}, {}]}, 62 | ), 63 | "screenshot": (("fa.camera",), {}), 64 | "screenshot-save": ( 65 | ("fa.save", "fa.camera"), 66 | { 67 | "options": [ 68 | {"scale_factor": 0.8}, 69 | {"scale_factor": 0.8, "offset": (0.2, 0.2)}, 70 | ] 71 | }, 72 | ), 73 | "toggle-comment": (("fa.hashtag",), {}), 74 | } 75 | 76 | 77 | def icon(name): 78 | 79 | if name in _icons: 80 | return _icons[name] 81 | 82 | args, kwargs = _icons_specs[name] 83 | 84 | return qta.icon(*args, **kwargs) 85 | -------------------------------------------------------------------------------- /cq_editor/main_window.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PyQt5.QtCore import QObject, pyqtSignal 4 | from PyQt5.QtGui import QPalette, QColor 5 | from PyQt5.QtWidgets import ( 6 | QLabel, 7 | QMainWindow, 8 | QToolBar, 9 | QDockWidget, 10 | QAction, 11 | QApplication, 12 | QMenu, 13 | ) 14 | from logbook import Logger 15 | import cadquery as cq 16 | 17 | from .widgets.editor import Editor 18 | from .widgets.viewer import OCCViewer 19 | from .widgets.console import ConsoleWidget 20 | from .widgets.object_tree import ObjectTree 21 | from .widgets.traceback_viewer import TracebackPane 22 | from .widgets.debugger import Debugger, LocalsView 23 | from .widgets.cq_object_inspector import CQObjectInspector 24 | from .widgets.log import LogViewer 25 | 26 | from . import __version__ 27 | from .utils import ( 28 | dock, 29 | add_actions, 30 | open_url, 31 | about_dialog, 32 | check_gtihub_for_updates, 33 | confirm, 34 | ) 35 | from .mixins import MainMixin 36 | from .icons import icon 37 | from pyqtgraph.parametertree import Parameter 38 | from .preferences import PreferencesWidget 39 | 40 | 41 | class _PrintRedirectorSingleton(QObject): 42 | """This class monkey-patches `sys.stdout.write` to emit a signal. 43 | It is instanciated as `.main_window.PRINT_REDIRECTOR` and should not be instanciated again. 44 | """ 45 | 46 | sigStdoutWrite = pyqtSignal(str) 47 | 48 | def __init__(self): 49 | super().__init__() 50 | 51 | original_stdout_write = sys.stdout.write 52 | 53 | def new_stdout_write(text: str): 54 | self.sigStdoutWrite.emit(text) 55 | return original_stdout_write(text) 56 | 57 | sys.stdout.write = new_stdout_write 58 | 59 | 60 | PRINT_REDIRECTOR = _PrintRedirectorSingleton() 61 | 62 | 63 | class MainWindow(QMainWindow, MainMixin): 64 | 65 | name = "CQ-Editor" 66 | org = "CadQuery" 67 | 68 | preferences = Parameter.create( 69 | name="Preferences", 70 | children=[ 71 | { 72 | "name": "Light/Dark Theme", 73 | "type": "list", 74 | "value": "Light", 75 | "values": [ 76 | "Light", 77 | "Dark", 78 | ], 79 | }, 80 | ], 81 | ) 82 | 83 | def __init__(self, parent=None, filename=None): 84 | 85 | super(MainWindow, self).__init__(parent) 86 | MainMixin.__init__(self) 87 | 88 | self.setWindowIcon(icon("app")) 89 | 90 | # Windows workaround - makes the correct task bar icon show up. 91 | if sys.platform == "win32": 92 | import ctypes 93 | 94 | myappid = "cq-editor" # arbitrary string 95 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) 96 | 97 | self.viewer = OCCViewer(self) 98 | self.setCentralWidget(self.viewer.canvas) 99 | 100 | self.prepare_panes() 101 | self.registerComponent("viewer", self.viewer) 102 | self.prepare_toolbar() 103 | self.prepare_menubar() 104 | 105 | self.prepare_statusbar() 106 | self.prepare_actions() 107 | 108 | self.components["object_tree"].addLines() 109 | 110 | self.prepare_console() 111 | 112 | self.fill_dummy() 113 | 114 | self.setup_logging() 115 | 116 | # Allows us to react to the top-level settings for this window being changed 117 | self.preferences.sigTreeStateChanged.connect(self.preferencesChanged) 118 | 119 | self.restorePreferences() 120 | self.restoreWindow() 121 | 122 | # Let the user know when the file has been modified 123 | self.components["editor"].document().modificationChanged.connect( 124 | self.update_window_title 125 | ) 126 | 127 | if filename: 128 | self.components["editor"].load_from_file(filename) 129 | 130 | self.restoreComponentState() 131 | 132 | def preferencesChanged(self, param, changes): 133 | """ 134 | Triggered when the preferences for this window are changed. 135 | """ 136 | 137 | # Use the default light theme/palette 138 | if self.preferences["Light/Dark Theme"] == "Light": 139 | QApplication.instance().setStyleSheet("") 140 | QApplication.instance().setPalette(QApplication.style().standardPalette()) 141 | 142 | # The console theme needs to be changed separately 143 | self.components["console"].app_theme_changed("Light") 144 | # Use the dark theme/palette 145 | elif self.preferences["Light/Dark Theme"] == "Dark": 146 | QApplication.instance().setStyle("Fusion") 147 | 148 | # Now use a palette to switch to dark colors: 149 | white_color = QColor(255, 255, 255) 150 | black_color = QColor(0, 0, 0) 151 | red_color = QColor(255, 0, 0) 152 | palette = QPalette() 153 | palette.setColor(QPalette.Window, QColor(53, 53, 53)) 154 | palette.setColor(QPalette.WindowText, white_color) 155 | palette.setColor(QPalette.Base, QColor(25, 25, 25)) 156 | palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) 157 | palette.setColor(QPalette.ToolTipBase, black_color) 158 | palette.setColor(QPalette.ToolTipText, white_color) 159 | palette.setColor(QPalette.Text, white_color) 160 | palette.setColor(QPalette.Button, QColor(53, 53, 53)) 161 | palette.setColor(QPalette.ButtonText, white_color) 162 | palette.setColor(QPalette.BrightText, red_color) 163 | palette.setColor(QPalette.Link, QColor(42, 130, 218)) 164 | palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) 165 | palette.setColor(QPalette.HighlightedText, black_color) 166 | QApplication.instance().setPalette(palette) 167 | 168 | # The console theme needs to be changed separately 169 | self.components["console"].app_theme_changed("Dark") 170 | 171 | # We alter the color of the toolbar separately to avoid having separate dark theme icons 172 | p = self.toolbar.palette() 173 | if self.preferences["Light/Dark Theme"] == "Dark": 174 | p.setColor(QPalette.Background, QColor(120, 120, 120)) 175 | 176 | # TWeak the QMenu items palette for dark theme 177 | menu_palette = self.menuBar().palette() 178 | menu_palette.setColor(QPalette.Base, QColor(80, 80, 80)) 179 | for menu in self.menuBar().findChildren(QMenu): 180 | menu.setPalette(menu_palette) 181 | else: 182 | p.setColor(QPalette.Background, QColor(240, 240, 240)) 183 | 184 | # Revert the QMenu items palette for dark theme 185 | menu_palette = self.menuBar().palette() 186 | menu_palette.setColor(QPalette.Base, QColor(240, 240, 240)) 187 | for menu in self.menuBar().findChildren(QMenu): 188 | menu.setPalette(menu_palette) 189 | 190 | self.toolbar.setPalette(p) 191 | 192 | def closeEvent(self, event): 193 | 194 | self.saveWindow() 195 | self.savePreferences() 196 | self.saveComponentState() 197 | 198 | if self.components["editor"].document().isModified(): 199 | 200 | rv = confirm(self, "Confirm close", "Close without saving?") 201 | 202 | if rv: 203 | event.accept() 204 | super(MainWindow, self).closeEvent(event) 205 | else: 206 | event.ignore() 207 | else: 208 | super(MainWindow, self).closeEvent(event) 209 | 210 | def prepare_panes(self): 211 | 212 | self.registerComponent( 213 | "editor", 214 | Editor(self), 215 | lambda c: dock(c, "Editor", self, defaultArea="left"), 216 | ) 217 | 218 | self.registerComponent( 219 | "object_tree", 220 | ObjectTree(self), 221 | lambda c: dock(c, "Objects", self, defaultArea="right"), 222 | ) 223 | 224 | self.registerComponent( 225 | "traceback_viewer", 226 | TracebackPane(self), 227 | lambda c: dock(c, "Current traceback", self, defaultArea="bottom"), 228 | ) 229 | 230 | self.registerComponent("debugger", Debugger(self)) 231 | 232 | self.registerComponent( 233 | "console", 234 | ConsoleWidget(self), 235 | lambda c: dock(c, "Console", self, defaultArea="bottom"), 236 | ) 237 | 238 | self.registerComponent( 239 | "variables_viewer", 240 | LocalsView(self), 241 | lambda c: dock(c, "Variables", self, defaultArea="right"), 242 | ) 243 | 244 | self.registerComponent( 245 | "cq_object_inspector", 246 | CQObjectInspector(self), 247 | lambda c: dock(c, "CQ object inspector", self, defaultArea="right"), 248 | ) 249 | self.registerComponent( 250 | "log", 251 | LogViewer(self), 252 | lambda c: dock(c, "Log viewer", self, defaultArea="bottom"), 253 | ) 254 | 255 | for d in self.docks.values(): 256 | d.show() 257 | 258 | PRINT_REDIRECTOR.sigStdoutWrite.connect( 259 | lambda text: self.components["log"].append(text) 260 | ) 261 | 262 | def prepare_menubar(self): 263 | 264 | menu = self.menuBar() 265 | 266 | menu_file = menu.addMenu("&File") 267 | menu_edit = menu.addMenu("&Edit") 268 | menu_tools = menu.addMenu("&Tools") 269 | menu_run = menu.addMenu("&Run") 270 | menu_view = menu.addMenu("&View") 271 | menu_help = menu.addMenu("&Help") 272 | 273 | # per component menu elements 274 | menus = { 275 | "File": menu_file, 276 | "Edit": menu_edit, 277 | "Run": menu_run, 278 | "Tools": menu_tools, 279 | "View": menu_view, 280 | "Help": menu_help, 281 | } 282 | 283 | for comp in self.components.values(): 284 | self.prepare_menubar_component(menus, comp.menuActions()) 285 | 286 | # global menu elements 287 | menu_view.addSeparator() 288 | for d in self.findChildren(QDockWidget): 289 | menu_view.addAction(d.toggleViewAction()) 290 | 291 | menu_view.addSeparator() 292 | for t in self.findChildren(QToolBar): 293 | menu_view.addAction(t.toggleViewAction()) 294 | 295 | menu_edit.addAction( 296 | QAction( 297 | icon("toggle-comment"), 298 | "Toggle Comment", 299 | self, 300 | shortcut="ctrl+/", 301 | triggered=self.components["editor"].toggle_comment, 302 | ) 303 | ) 304 | menu_edit.addAction( 305 | QAction( 306 | icon("preferences"), 307 | "Preferences", 308 | self, 309 | triggered=self.edit_preferences, 310 | ) 311 | ) 312 | 313 | menu_help.addAction( 314 | QAction(icon("help"), "Documentation", self, triggered=self.documentation) 315 | ) 316 | 317 | menu_help.addAction( 318 | QAction("CQ documentation", self, triggered=self.cq_documentation) 319 | ) 320 | 321 | menu_help.addAction(QAction(icon("about"), "About", self, triggered=self.about)) 322 | 323 | menu_help.addAction( 324 | QAction( 325 | "Check for CadQuery updates", self, triggered=self.check_for_cq_updates 326 | ) 327 | ) 328 | 329 | def prepare_menubar_component(self, menus, comp_menu_dict): 330 | 331 | for name, action in comp_menu_dict.items(): 332 | menus[name].addActions(action) 333 | 334 | def prepare_toolbar(self): 335 | 336 | self.toolbar = QToolBar("Main toolbar", self, objectName="Main toolbar") 337 | 338 | for c in self.components.values(): 339 | add_actions(self.toolbar, c.toolbarActions()) 340 | 341 | self.addToolBar(self.toolbar) 342 | 343 | def prepare_statusbar(self): 344 | 345 | self.status_label = QLabel("", parent=self) 346 | self.statusBar().insertPermanentWidget(0, self.status_label) 347 | 348 | def prepare_actions(self): 349 | 350 | self.components["debugger"].sigRendered.connect( 351 | self.components["object_tree"].addObjects 352 | ) 353 | self.components["debugger"].sigTraceback.connect( 354 | self.components["traceback_viewer"].addTraceback 355 | ) 356 | self.components["debugger"].sigLocals.connect( 357 | self.components["variables_viewer"].update_frame 358 | ) 359 | self.components["debugger"].sigLocals.connect( 360 | self.components["console"].push_vars 361 | ) 362 | 363 | self.components["object_tree"].sigObjectsAdded[list].connect( 364 | self.components["viewer"].display_many 365 | ) 366 | self.components["object_tree"].sigObjectsAdded[list, bool].connect( 367 | self.components["viewer"].display_many 368 | ) 369 | self.components["object_tree"].sigItemChanged.connect( 370 | self.components["viewer"].update_item 371 | ) 372 | self.components["object_tree"].sigObjectsRemoved.connect( 373 | self.components["viewer"].remove_items 374 | ) 375 | self.components["object_tree"].sigCQObjectSelected.connect( 376 | self.components["cq_object_inspector"].setObject 377 | ) 378 | self.components["object_tree"].sigObjectPropertiesChanged.connect( 379 | self.components["viewer"].redraw 380 | ) 381 | self.components["object_tree"].sigAISObjectsSelected.connect( 382 | self.components["viewer"].set_selected 383 | ) 384 | 385 | self.components["viewer"].sigObjectSelected.connect( 386 | self.components["object_tree"].handleGraphicalSelection 387 | ) 388 | 389 | self.components["traceback_viewer"].sigHighlightLine.connect( 390 | self.components["editor"].go_to_line 391 | ) 392 | 393 | self.components["cq_object_inspector"].sigDisplayObjects.connect( 394 | self.components["viewer"].display_many 395 | ) 396 | self.components["cq_object_inspector"].sigRemoveObjects.connect( 397 | self.components["viewer"].remove_items 398 | ) 399 | self.components["cq_object_inspector"].sigShowPlane.connect( 400 | self.components["viewer"].toggle_grid 401 | ) 402 | self.components["cq_object_inspector"].sigShowPlane[bool, float].connect( 403 | self.components["viewer"].toggle_grid 404 | ) 405 | self.components["cq_object_inspector"].sigChangePlane.connect( 406 | self.components["viewer"].set_grid_orientation 407 | ) 408 | 409 | self.components["debugger"].sigLocalsChanged.connect( 410 | self.components["variables_viewer"].update_frame 411 | ) 412 | self.components["debugger"].sigLineChanged.connect( 413 | self.components["editor"].go_to_line 414 | ) 415 | self.components["debugger"].sigDebugging.connect( 416 | self.components["object_tree"].stashObjects 417 | ) 418 | self.components["debugger"].sigCQChanged.connect( 419 | self.components["object_tree"].addObjects 420 | ) 421 | self.components["debugger"].sigTraceback.connect( 422 | self.components["traceback_viewer"].addTraceback 423 | ) 424 | 425 | # trigger re-render when file is modified externally or saved 426 | self.components["editor"].triggerRerender.connect( 427 | self.components["debugger"].render 428 | ) 429 | self.components["editor"].sigFilenameChanged.connect( 430 | self.handle_filename_change 431 | ) 432 | 433 | def prepare_console(self): 434 | 435 | console = self.components["console"] 436 | obj_tree = self.components["object_tree"] 437 | 438 | # application related items 439 | console.push_vars({"self": self}) 440 | 441 | # CQ related items 442 | console.push_vars( 443 | { 444 | "show": obj_tree.addObject, 445 | "show_object": obj_tree.addObject, 446 | "rand_color": self.components["debugger"]._rand_color, 447 | "cq": cq, 448 | "log": Logger(self.name).info, 449 | } 450 | ) 451 | 452 | def fill_dummy(self): 453 | 454 | self.components["editor"].set_text( 455 | 'import cadquery as cq\nresult = cq.Workplane("XY" ).box(3, 3, 0.5).edges("|Z").fillet(0.125)\nshow_object(result)' 456 | ) 457 | 458 | def setup_logging(self): 459 | 460 | from logbook.compat import redirect_logging 461 | from logbook import INFO, Logger 462 | 463 | redirect_logging() 464 | self.components["log"].handler.level = INFO 465 | self.components["log"].handler.push_application() 466 | 467 | self._logger = Logger(self.name) 468 | 469 | def handle_exception(exc_type, exc_value, exc_traceback): 470 | 471 | if issubclass(exc_type, KeyboardInterrupt): 472 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 473 | return 474 | 475 | self._logger.error( 476 | "Uncaught exception occurred", 477 | exc_info=(exc_type, exc_value, exc_traceback), 478 | ) 479 | 480 | sys.excepthook = handle_exception 481 | 482 | def edit_preferences(self): 483 | 484 | prefs = PreferencesWidget(self, self.components) 485 | prefs.exec_() 486 | 487 | def about(self): 488 | 489 | about_dialog( 490 | self, 491 | f"About CQ-editor", 492 | f"PyQt GUI for CadQuery.\nVersion: {__version__}.\nSource Code: https://github.com/CadQuery/CQ-editor", 493 | ) 494 | 495 | def check_for_cq_updates(self): 496 | 497 | check_gtihub_for_updates(self, cq) 498 | 499 | def documentation(self): 500 | 501 | open_url("https://github.com/CadQuery") 502 | 503 | def cq_documentation(self): 504 | 505 | open_url("https://cadquery.readthedocs.io/en/latest/") 506 | 507 | def handle_filename_change(self, fname): 508 | 509 | new_title = fname if fname else "*" 510 | self.setWindowTitle(f"{self.name}: {new_title}") 511 | 512 | def update_window_title(self, modified): 513 | """ 514 | Allows updating the window title to show that the document has been modified. 515 | """ 516 | title = self.windowTitle().rstrip("*") 517 | if modified: 518 | title += "*" 519 | self.setWindowTitle(title) 520 | 521 | 522 | if __name__ == "__main__": 523 | 524 | pass 525 | -------------------------------------------------------------------------------- /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/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(["Spyder", "Monokai", "Zenburn"]) 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 | 95 | @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) 96 | def handleSelection(self, item, *args): 97 | 98 | if item: 99 | self.stacked.setCurrentWidget(item.widget) 100 | -------------------------------------------------------------------------------- /cq_editor/utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from pkg_resources import 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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/cq_editor/widgets/__init__.py -------------------------------------------------------------------------------- /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("delete"), "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/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 | -------------------------------------------------------------------------------- /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 spyder.utils.icon_manager 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 compile_code(self, cq_script, cq_script_path=None): 203 | 204 | try: 205 | module = ModuleType("__cq_main__") 206 | if cq_script_path: 207 | module.__dict__["__file__"] = cq_script_path 208 | cq_code = compile(cq_script, DUMMY_FILE, "exec") 209 | return cq_code, module 210 | except Exception: 211 | self.sigTraceback.emit(sys.exc_info(), cq_script) 212 | return None, None 213 | 214 | def _exec(self, code, locals_dict, globals_dict): 215 | 216 | with ExitStack() as stack: 217 | p = (self.get_current_script_path() or Path("")).absolute().dirname() 218 | 219 | if self.preferences["Add script dir to path"] and p.exists(): 220 | sys.path.insert(0, p) 221 | stack.callback(sys.path.remove, p) 222 | if self.preferences["Change working dir to script dir"] and p.exists(): 223 | stack.enter_context(p) 224 | if self.preferences["Reload imported modules"]: 225 | stack.enter_context(module_manager()) 226 | 227 | exec(code, locals_dict, globals_dict) 228 | 229 | @staticmethod 230 | def _rand_color(alpha=0.0, cfloat=False): 231 | # helper function to generate a random color dict 232 | # for CQ-editor's show_object function 233 | lower = 10 234 | upper = 100 # not too high to keep color brightness in check 235 | if cfloat: # for two output types depending on need 236 | return ( 237 | (rrr(lower, upper) / 255), 238 | (rrr(lower, upper) / 255), 239 | (rrr(lower, upper) / 255), 240 | alpha, 241 | ) 242 | return { 243 | "alpha": alpha, 244 | "color": ( 245 | rrr(lower, upper), 246 | rrr(lower, upper), 247 | rrr(lower, upper), 248 | ), 249 | } 250 | 251 | def _inject_locals(self, module): 252 | 253 | cq_objects = {} 254 | 255 | def _show_object(obj, name=None, options={}): 256 | 257 | if name: 258 | cq_objects.update({name: SimpleNamespace(shape=obj, options=options)}) 259 | else: 260 | # get locals of the enclosing scope 261 | d = currentframe().f_back.f_locals 262 | 263 | # try to find the name 264 | try: 265 | name = list(d.keys())[list(d.values()).index(obj)] 266 | except ValueError: 267 | # use id if not found 268 | name = str(id(obj)) 269 | 270 | cq_objects.update({name: SimpleNamespace(shape=obj, options=options)}) 271 | 272 | def _debug(obj, name=None): 273 | 274 | _show_object(obj, name, options=dict(color="red", alpha=0.2)) 275 | 276 | module.__dict__["show_object"] = _show_object 277 | module.__dict__["debug"] = _debug 278 | module.__dict__["rand_color"] = self._rand_color 279 | module.__dict__["log"] = lambda x: info(str(x)) 280 | module.__dict__["cq"] = cq 281 | 282 | return cq_objects, set(module.__dict__) - {"cq"} 283 | 284 | def _cleanup_locals(self, module, injected_names): 285 | 286 | for name in injected_names: 287 | module.__dict__.pop(name) 288 | 289 | @pyqtSlot(bool) 290 | def render(self): 291 | 292 | seed(59798267586177) 293 | if self.preferences["Reload CQ"]: 294 | reload_cq() 295 | 296 | cq_script = self.get_current_script() 297 | cq_script_path = self.get_current_script_path() 298 | cq_code, module = self.compile_code(cq_script, cq_script_path) 299 | 300 | if cq_code is None: 301 | return 302 | 303 | cq_objects, injected_names = self._inject_locals(module) 304 | 305 | try: 306 | self._exec(cq_code, module.__dict__, module.__dict__) 307 | 308 | # remove the special methods 309 | self._cleanup_locals(module, injected_names) 310 | 311 | # collect all CQ objects if no explicit show_object was called 312 | if len(cq_objects) == 0: 313 | cq_objects = find_cq_objects(module.__dict__) 314 | self.sigRendered.emit(cq_objects) 315 | self.sigTraceback.emit(None, cq_script) 316 | self.sigLocals.emit(module.__dict__) 317 | except Exception: 318 | exc_info = sys.exc_info() 319 | sys.last_traceback = exc_info[-1] 320 | self.sigTraceback.emit(exc_info, cq_script) 321 | 322 | @property 323 | def breakpoints(self): 324 | return [el[0] for el in self.get_breakpoints()] 325 | 326 | @pyqtSlot(bool) 327 | def debug(self, value): 328 | 329 | # used to stop the debugging session early 330 | self._stop_debugging = False 331 | 332 | if value: 333 | self.previous_trace = previous_trace = sys.gettrace() 334 | 335 | self.sigDebugging.emit(True) 336 | self.state = DbgState.STEP 337 | 338 | self.script = self.get_current_script() 339 | cq_script_path = self.get_current_script_path() 340 | code, module = self.compile_code(self.script, cq_script_path) 341 | 342 | if code is None: 343 | self.sigDebugging.emit(False) 344 | self._actions["Run"][1].setChecked(False) 345 | return 346 | 347 | cq_objects, injected_names = self._inject_locals(module) 348 | 349 | # clear possible traceback 350 | self.sigTraceback.emit(None, self.script) 351 | 352 | try: 353 | sys.settrace(self.trace_callback) 354 | exec(code, module.__dict__, module.__dict__) 355 | except BdbQuit: 356 | pass 357 | except Exception: 358 | exc_info = sys.exc_info() 359 | sys.last_traceback = exc_info[-1] 360 | self.sigTraceback.emit(exc_info, self.script) 361 | finally: 362 | sys.settrace(previous_trace) 363 | self.sigDebugging.emit(False) 364 | self._actions["Run"][1].setChecked(False) 365 | 366 | if len(cq_objects) == 0: 367 | cq_objects = find_cq_objects(module.__dict__) 368 | self.sigRendered.emit(cq_objects) 369 | 370 | self._cleanup_locals(module, injected_names) 371 | self.sigLocals.emit(module.__dict__) 372 | 373 | self._frames = [] 374 | self.inner_event_loop.exit(0) 375 | else: 376 | self._stop_debugging = True 377 | self.inner_event_loop.exit(0) 378 | 379 | def debug_cmd(self, state=DbgState.STEP): 380 | 381 | self.state = state 382 | self.inner_event_loop.exit(0) 383 | 384 | def trace_callback(self, frame, event, arg): 385 | 386 | filename = frame.f_code.co_filename 387 | 388 | if filename == DUMMY_FILE: 389 | if not self._frames: 390 | self._frames.append(frame) 391 | self.trace_local(frame, event, arg) 392 | return self.trace_callback 393 | 394 | else: 395 | return None 396 | 397 | def trace_local(self, frame, event, arg): 398 | 399 | lineno = frame.f_lineno 400 | 401 | if event in (DbgEevent.LINE,): 402 | if ( 403 | self.state in (DbgState.STEP, DbgState.STEP_IN) 404 | and frame is self._frames[-1] 405 | ) or (lineno in self.breakpoints): 406 | 407 | if lineno in self.breakpoints: 408 | self._frames.append(frame) 409 | 410 | self.sigLineChanged.emit(lineno) 411 | self.sigFrameChanged.emit(frame) 412 | self.sigLocalsChanged.emit(frame.f_locals) 413 | self.sigCQChanged.emit(find_cq_objects(frame.f_locals), True) 414 | 415 | self.inner_event_loop.exec_() 416 | 417 | elif event in (DbgEevent.RETURN): 418 | self.sigLocalsChanged.emit(frame.f_locals) 419 | self._frames.pop() 420 | 421 | elif event == DbgEevent.CALL: 422 | func_filename = frame.f_code.co_filename 423 | if self.state == DbgState.STEP_IN and func_filename == DUMMY_FILE: 424 | self.sigLineChanged.emit(lineno) 425 | self.sigFrameChanged.emit(frame) 426 | self.state = DbgState.STEP 427 | self._frames.append(frame) 428 | 429 | if self._stop_debugging: 430 | raise BdbQuit # stop debugging if requested 431 | 432 | 433 | @contextmanager 434 | def module_manager(): 435 | """unloads any modules loaded while the context manager is active""" 436 | loaded_modules = set(sys.modules.keys()) 437 | 438 | try: 439 | yield 440 | finally: 441 | new_modules = set(sys.modules.keys()) - loaded_modules 442 | for module_name in new_modules: 443 | del sys.modules[module_name] 444 | -------------------------------------------------------------------------------- /cq_editor/widgets/editor.py: -------------------------------------------------------------------------------- 1 | import os 2 | import spyder.utils.encoding 3 | from modulefinder import ModuleFinder 4 | 5 | from spyder.plugins.editor.widgets.codeeditor import CodeEditor 6 | from PyQt5.QtCore import pyqtSignal, QFileSystemWatcher, QTimer 7 | from PyQt5.QtWidgets import QAction, QFileDialog, QApplication 8 | from PyQt5.QtGui import QFontDatabase, QTextCursor 9 | from path import Path 10 | 11 | import sys 12 | 13 | from pyqtgraph.parametertree import Parameter 14 | 15 | from ..mixins import ComponentMixin 16 | from ..utils import get_save_filename, get_open_filename, confirm 17 | 18 | from ..icons import icon 19 | 20 | 21 | class Editor(CodeEditor, ComponentMixin): 22 | 23 | name = "Code Editor" 24 | 25 | # This signal is emitted whenever the currently-open file changes and 26 | # autoreload is enabled. 27 | triggerRerender = pyqtSignal(bool) 28 | sigFilenameChanged = pyqtSignal(str) 29 | 30 | preferences = Parameter.create( 31 | name="Preferences", 32 | children=[ 33 | {"name": "Font size", "type": "int", "value": 12}, 34 | {"name": "Autoreload", "type": "bool", "value": False}, 35 | {"name": "Autoreload delay", "type": "int", "value": 50}, 36 | { 37 | "name": "Autoreload: watch imported modules", 38 | "type": "bool", 39 | "value": False, 40 | }, 41 | {"name": "Line wrap", "type": "bool", "value": False}, 42 | { 43 | "name": "Color scheme", 44 | "type": "list", 45 | "values": ["Spyder", "Monokai", "Zenburn"], 46 | "value": "Spyder", 47 | }, 48 | {"name": "Maximum line length", "type": "int", "value": 88}, 49 | ], 50 | ) 51 | 52 | EXTENSIONS = "py" 53 | 54 | # Tracks whether or not the document was saved from the Spyder editor vs an external editor 55 | was_modified_by_self = False 56 | 57 | def __init__(self, parent=None): 58 | 59 | self._watched_file = None 60 | 61 | super(Editor, self).__init__(parent) 62 | ComponentMixin.__init__(self) 63 | 64 | self.setup_editor( 65 | linenumbers=True, 66 | markers=True, 67 | edge_line=self.preferences["Maximum line length"], 68 | tab_mode=False, 69 | show_blanks=True, 70 | font=QFontDatabase.systemFont(QFontDatabase.FixedFont), 71 | language="Python", 72 | filename="", 73 | ) 74 | 75 | self._actions = { 76 | "File": [ 77 | QAction( 78 | icon("new"), "New", self, shortcut="ctrl+N", triggered=self.new 79 | ), 80 | QAction( 81 | icon("open"), "Open", self, shortcut="ctrl+O", triggered=self.open 82 | ), 83 | QAction( 84 | icon("save"), "Save", self, shortcut="ctrl+S", triggered=self.save 85 | ), 86 | QAction( 87 | icon("save_as"), 88 | "Save as", 89 | self, 90 | shortcut="ctrl+shift+S", 91 | triggered=self.save_as, 92 | ), 93 | QAction( 94 | icon("autoreload"), 95 | "Automatic reload and preview", 96 | self, 97 | triggered=self.autoreload, 98 | checkable=True, 99 | checked=False, 100 | objectName="autoreload", 101 | ), 102 | ] 103 | } 104 | 105 | for a in self._actions.values(): 106 | self.addActions(a) 107 | 108 | self._fixContextMenu() 109 | 110 | # autoreload support 111 | self._file_watcher = QFileSystemWatcher(self) 112 | # we wait for 50ms after a file change for the file to be written completely 113 | self._file_watch_timer = QTimer(self) 114 | self._file_watch_timer.setInterval(self.preferences["Autoreload delay"]) 115 | self._file_watch_timer.setSingleShot(True) 116 | self._file_watcher.fileChanged.connect( 117 | lambda val: self._file_watch_timer.start() 118 | ) 119 | self._file_watch_timer.timeout.connect(self._file_changed) 120 | 121 | self.updatePreferences() 122 | 123 | def _fixContextMenu(self): 124 | 125 | menu = self.menu 126 | 127 | menu.removeAction(self.run_cell_action) 128 | menu.removeAction(self.run_cell_and_advance_action) 129 | menu.removeAction(self.run_selection_action) 130 | menu.removeAction(self.re_run_last_cell_action) 131 | 132 | def updatePreferences(self, *args): 133 | 134 | self.set_color_scheme(self.preferences["Color scheme"]) 135 | 136 | font = self.font() 137 | font.setPointSize(self.preferences["Font size"]) 138 | self.set_font(font) 139 | 140 | self.findChild(QAction, "autoreload").setChecked(self.preferences["Autoreload"]) 141 | 142 | self._file_watch_timer.setInterval(self.preferences["Autoreload delay"]) 143 | 144 | self.toggle_wrap_mode(self.preferences["Line wrap"]) 145 | 146 | # Update the edge line (maximum line length) 147 | self.edge_line.set_enabled(True) 148 | self.edge_line.set_columns(self.preferences["Maximum line length"]) 149 | 150 | self._clear_watched_paths() 151 | self._watch_paths() 152 | 153 | def confirm_discard(self): 154 | 155 | if self.modified: 156 | rv = confirm( 157 | self, 158 | "Please confirm", 159 | "Current document is not saved - do you want to continue?", 160 | ) 161 | else: 162 | rv = True 163 | 164 | return rv 165 | 166 | def new(self): 167 | 168 | if not self.confirm_discard(): 169 | return 170 | 171 | self.set_text("") 172 | self.filename = "" 173 | self.reset_modified() 174 | 175 | def open(self): 176 | 177 | if not self.confirm_discard(): 178 | return 179 | 180 | curr_dir = Path(self.filename).absolute().dirname() 181 | fname = get_open_filename(self.EXTENSIONS, curr_dir) 182 | if fname != "": 183 | self.load_from_file(fname) 184 | 185 | def load_from_file(self, fname): 186 | 187 | self.set_text_from_file(fname) 188 | self.filename = fname 189 | self.reset_modified() 190 | 191 | def save(self): 192 | """ 193 | Saves the current document to the current filename if it exists, otherwise it triggers a 194 | save-as dialog. 195 | """ 196 | 197 | if self._filename != "": 198 | with open(self._filename, "w", encoding="utf-8") as f: 199 | f.write(self.toPlainText()) 200 | 201 | # Let the editor and the rest of the app know that the file is no longer dirty 202 | self.reset_modified() 203 | 204 | self.was_modified_by_self = True 205 | 206 | else: 207 | self.save_as() 208 | 209 | def save_as(self): 210 | 211 | fname = get_save_filename(self.EXTENSIONS) 212 | if fname != "": 213 | with open(fname, "w", encoding="utf-8") as f: 214 | f.write(self.toPlainText()) 215 | self.filename = fname 216 | 217 | self.reset_modified() 218 | 219 | def toggle_comment(self): 220 | """ 221 | Allows us to mark the document as modified when the user toggles a comment. 222 | """ 223 | super(Editor, self).toggle_comment() 224 | self.document().setModified(True) 225 | 226 | def _update_filewatcher(self): 227 | if self._watched_file and ( 228 | self._watched_file != self.filename or not self.preferences["Autoreload"] 229 | ): 230 | self._clear_watched_paths() 231 | self._watched_file = None 232 | if ( 233 | self.preferences["Autoreload"] 234 | and self.filename 235 | and self.filename != self._watched_file 236 | ): 237 | self._watched_file = self._filename 238 | self._watch_paths() 239 | 240 | @property 241 | def filename(self): 242 | return self._filename 243 | 244 | @filename.setter 245 | def filename(self, fname): 246 | self._filename = fname 247 | self._update_filewatcher() 248 | self.sigFilenameChanged.emit(fname) 249 | 250 | def _clear_watched_paths(self): 251 | paths = self._file_watcher.files() 252 | if paths: 253 | self._file_watcher.removePaths(paths) 254 | 255 | def _watch_paths(self): 256 | if Path(self._filename).exists(): 257 | self._file_watcher.addPath(self._filename) 258 | if self.preferences["Autoreload: watch imported modules"]: 259 | module_paths = self.get_imported_module_paths(self._filename) 260 | if module_paths: 261 | self._file_watcher.addPaths(module_paths) 262 | 263 | # callback triggered by QFileSystemWatcher 264 | def _file_changed(self): 265 | # neovim writes a file by removing it first so must re-add each time 266 | self._watch_paths() 267 | 268 | # Save the current cursor position and selection 269 | cursor = self.textCursor() 270 | cursor_position = cursor.position() 271 | anchor_position = cursor.anchor() 272 | 273 | # Save the current scroll position 274 | vertical_scroll_pos = self.verticalScrollBar().value() 275 | horizontal_scroll_pos = self.horizontalScrollBar().value() 276 | 277 | # Block signals to avoid reset issues 278 | self.blockSignals(True) 279 | 280 | # Read the contents of the file into a string 281 | with open(self._filename, "r", encoding="utf-8") as f: 282 | file_contents = f.read() 283 | 284 | # Insert new text while preserving history 285 | cursor = self.textCursor() 286 | cursor.select(QTextCursor.Document) 287 | cursor.insertText(file_contents) 288 | 289 | # The editor will not always update after a text insertion, so we force it 290 | QApplication.processEvents() 291 | 292 | # Stop blocking signals 293 | self.blockSignals(False) 294 | 295 | self.document().setModified(True) 296 | 297 | # Undo has to be backed up one step to compensate for the text insertion 298 | if self.was_modified_by_self: 299 | self.document().undo() 300 | self.was_modified_by_self = False 301 | 302 | # Restore the cursor position and selection 303 | cursor.setPosition(anchor_position) 304 | cursor.setPosition(cursor_position, QTextCursor.KeepAnchor) 305 | self.setTextCursor(cursor) 306 | 307 | # Restore the scroll position 308 | self.verticalScrollBar().setValue(vertical_scroll_pos) 309 | self.horizontalScrollBar().setValue(horizontal_scroll_pos) 310 | 311 | # Reset the dirty state and trigger a 3D render 312 | self.reset_modified() 313 | self.triggerRerender.emit(True) 314 | 315 | # Turn autoreload on/off. 316 | def autoreload(self, enabled): 317 | self.preferences["Autoreload"] = enabled 318 | self._update_filewatcher() 319 | 320 | def reset_modified(self): 321 | 322 | self.document().setModified(False) 323 | 324 | @property 325 | def modified(self): 326 | 327 | return self.document().isModified() 328 | 329 | def saveComponentState(self, store): 330 | 331 | if self.filename != "": 332 | store.setValue(self.name + "/state", self.filename) 333 | 334 | def restoreComponentState(self, store): 335 | 336 | filename = store.value(self.name + "/state") 337 | 338 | if filename and self.filename == "": 339 | try: 340 | self.load_from_file(filename) 341 | except IOError: 342 | self._logger.warning(f"could not open {filename}") 343 | 344 | def get_imported_module_paths(self, module_path): 345 | 346 | finder = ModuleFinder([os.path.dirname(module_path)]) 347 | imported_modules = [] 348 | 349 | try: 350 | finder.run_script(module_path) 351 | except SyntaxError as err: 352 | self._logger.warning(f"Syntax error in {module_path}: {err}") 353 | except Exception as err: 354 | # The module finder has trouble when CadQuery is imported in the top level script and in 355 | # imported modules. The warning about it can be ignored. 356 | if "cadquery" not in finder.badmodules or ( 357 | "cadquery" in finder.badmodules and len(finder.badmodules) > 1 358 | ): 359 | self._logger.warning( 360 | f"Cannot determine imported modules in {module_path}: {type(err).__name__} {err}" 361 | ) 362 | else: 363 | for module_name, module in finder.modules.items(): 364 | if module_name != "__main__": 365 | path = getattr(module, "__file__", None) 366 | if path is not None and os.path.isfile(path): 367 | imported_modules.append(path) 368 | 369 | return imported_modules 370 | 371 | 372 | if __name__ == "__main__": 373 | 374 | from PyQt5.QtWidgets import QApplication 375 | 376 | app = QApplication(sys.argv) 377 | editor = Editor() 378 | editor.show() 379 | 380 | sys.exit(app.exec_()) 381 | -------------------------------------------------------------------------------- /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("delete"), "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 | -------------------------------------------------------------------------------- /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/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, QEvent 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.AIS import AIS_InteractiveContext, AIS_DisplayMode 13 | from OCP.Quantity import Quantity_Color 14 | 15 | 16 | ZOOM_STEP = 0.9 17 | 18 | 19 | class OCCTWidget(QWidget): 20 | 21 | sigObjectSelected = pyqtSignal(list) 22 | 23 | def __init__(self, parent=None): 24 | 25 | super(OCCTWidget, self).__init__(parent) 26 | 27 | self.setAttribute(Qt.WA_NativeWindow) 28 | self.setAttribute(Qt.WA_PaintOnScreen) 29 | self.setAttribute(Qt.WA_NoSystemBackground) 30 | 31 | self._initialized = False 32 | self._needs_update = False 33 | 34 | # OCCT secific things 35 | self.display_connection = Aspect_DisplayConnection() 36 | self.graphics_driver = OpenGl_GraphicDriver(self.display_connection) 37 | 38 | self.viewer = V3d_Viewer(self.graphics_driver) 39 | self.view = self.viewer.CreateView() 40 | self.context = AIS_InteractiveContext(self.viewer) 41 | 42 | # Trihedorn, lights, etc 43 | self.prepare_display() 44 | 45 | def prepare_display(self): 46 | 47 | view = self.view 48 | 49 | params = view.ChangeRenderingParams() 50 | params.NbMsaaSamples = 8 51 | params.IsAntialiasingEnabled = True 52 | 53 | view.TriedronDisplay( 54 | Aspect_TypeOfTriedronPosition.Aspect_TOTP_RIGHT_LOWER, Quantity_Color(), 0.1 55 | ) 56 | 57 | viewer = self.viewer 58 | 59 | viewer.SetDefaultLights() 60 | viewer.SetLightOn() 61 | 62 | ctx = self.context 63 | 64 | ctx.SetDisplayMode(AIS_DisplayMode.AIS_Shaded, True) 65 | ctx.DefaultDrawer().SetFaceBoundaryDraw(True) 66 | 67 | def wheelEvent(self, event): 68 | 69 | delta = event.angleDelta().y() 70 | factor = ZOOM_STEP if delta < 0 else 1 / ZOOM_STEP 71 | 72 | self.view.SetZoom(factor) 73 | 74 | def mousePressEvent(self, event): 75 | 76 | pos = event.pos() 77 | 78 | if event.button() == Qt.LeftButton: 79 | # Used to prevent drag selection of objects 80 | self.pending_select = True 81 | self.left_press = pos 82 | 83 | self.view.StartRotation(pos.x(), pos.y()) 84 | elif event.button() == Qt.RightButton: 85 | self.view.StartZoomAtPoint(pos.x(), pos.y()) 86 | 87 | self.old_pos = pos 88 | 89 | def mouseMoveEvent(self, event): 90 | 91 | pos = event.pos() 92 | x, y = pos.x(), pos.y() 93 | 94 | if event.buttons() == Qt.LeftButton: 95 | self.view.Rotation(x, y) 96 | 97 | # If the user moves the mouse at all, the selection will not happen 98 | if abs(x - self.left_press.x()) > 2 or abs(y - self.left_press.y()) > 2: 99 | self.pending_select = False 100 | 101 | elif event.buttons() == Qt.MiddleButton: 102 | self.view.Pan(x - self.old_pos.x(), self.old_pos.y() - y, theToStart=True) 103 | 104 | elif event.buttons() == Qt.RightButton: 105 | self.view.ZoomAtPoint(self.old_pos.x(), y, x, self.old_pos.y()) 106 | 107 | self.old_pos = pos 108 | 109 | def mouseReleaseEvent(self, event): 110 | 111 | if event.button() == Qt.LeftButton: 112 | pos = event.pos() 113 | x, y = pos.x(), pos.y() 114 | 115 | # Only make the selection if the user has not moved the mouse 116 | if self.pending_select: 117 | self.context.MoveTo(x, y, self.view, True) 118 | self._handle_selection() 119 | 120 | def _handle_selection(self): 121 | 122 | self.context.Select(True) 123 | self.context.InitSelected() 124 | 125 | selected = [] 126 | if self.context.HasSelectedShape(): 127 | selected.append(self.context.SelectedShape()) 128 | 129 | self.sigObjectSelected.emit(selected) 130 | 131 | def paintEngine(self): 132 | 133 | return None 134 | 135 | def paintEvent(self, event): 136 | 137 | if not self._initialized: 138 | self._initialize() 139 | else: 140 | self.view.Redraw() 141 | 142 | def showEvent(self, event): 143 | 144 | super(OCCTWidget, self).showEvent(event) 145 | 146 | def resizeEvent(self, event): 147 | 148 | super(OCCTWidget, self).resizeEvent(event) 149 | 150 | self.view.MustBeResized() 151 | 152 | def _initialize(self): 153 | 154 | wins = { 155 | "darwin": self._get_window_osx, 156 | "linux": self._get_window_linux, 157 | "win32": self._get_window_win, 158 | } 159 | 160 | self.view.SetWindow(wins.get(platform, self._get_window_linux)(self.winId())) 161 | 162 | self._initialized = True 163 | 164 | def _get_window_win(self, wid): 165 | 166 | from OCP.WNT import WNT_Window 167 | 168 | return WNT_Window(wid.ascapsule()) 169 | 170 | def _get_window_linux(self, wid): 171 | 172 | from OCP.Xw import Xw_Window 173 | 174 | return Xw_Window(self.display_connection, int(wid)) 175 | 176 | def _get_window_osx(self, wid): 177 | 178 | from OCP.Cocoa import Cocoa_Window 179 | 180 | return Cocoa_Window(wid.ascapsule()) 181 | -------------------------------------------------------------------------------- /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/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 | ) 90 | IMAGE_EXTENSIONS = "png" 91 | 92 | sigObjectSelected = pyqtSignal(list) 93 | 94 | def __init__(self, parent=None): 95 | 96 | super(OCCViewer, self).__init__(parent) 97 | ComponentMixin.__init__(self) 98 | 99 | self.canvas = OCCTWidget() 100 | self.canvas.sigObjectSelected.connect(self.handle_selection) 101 | 102 | self.create_actions(self) 103 | 104 | self.layout_ = layout( 105 | self, 106 | [ 107 | self.canvas, 108 | ], 109 | top_widget=self, 110 | margin=0, 111 | ) 112 | 113 | self.setup_default_drawer() 114 | self.updatePreferences() 115 | 116 | def setup_default_drawer(self): 117 | 118 | # set the default color and material 119 | material = Graphic3d_MaterialAspect(Graphic3d_NOM_JADE) 120 | 121 | shading_aspect = self.canvas.context.DefaultDrawer().ShadingAspect() 122 | shading_aspect.SetMaterial(material) 123 | shading_aspect.SetColor(DEFAULT_FACE_COLOR) 124 | 125 | # face edge lw 126 | line_aspect = self.canvas.context.DefaultDrawer().FaceBoundaryAspect() 127 | line_aspect.SetWidth(DEFAULT_EDGE_WIDTH) 128 | line_aspect.SetColor(DEFAULT_EDGE_COLOR) 129 | 130 | def updatePreferences(self, *args): 131 | 132 | color1 = to_occ_color(self.preferences["Background color"]) 133 | color2 = to_occ_color(self.preferences["Background color (aux)"]) 134 | 135 | if not self.preferences["Use gradient"]: 136 | color2 = color1 137 | self.canvas.view.SetBgGradientColors(color1, color2, theToUpdate=True) 138 | 139 | self.canvas.update() 140 | 141 | ctx = self.canvas.context 142 | ctx.SetDeviationCoefficient(self.preferences["Deviation"]) 143 | ctx.SetDeviationAngle(self.preferences["Angular deviation"]) 144 | 145 | v = self._get_view() 146 | camera = v.Camera() 147 | projection_type = self.preferences["Projection Type"] 148 | camera.SetProjectionType( 149 | getattr( 150 | Graphic3d_Camera, 151 | f"Projection_{projection_type}", 152 | Graphic3d_Camera.Projection_Orthographic, 153 | ) 154 | ) 155 | 156 | # onle relevant for stereo projection 157 | stereo_mode = self.preferences["Stereo Mode"] 158 | params = v.ChangeRenderingParams() 159 | params.StereoMode = getattr( 160 | Graphic3d_StereoMode, 161 | f"Graphic3d_StereoMode_{stereo_mode}", 162 | Graphic3d_StereoMode.Graphic3d_StereoMode_QuadBuffer, 163 | ) 164 | 165 | def create_actions(self, parent): 166 | 167 | self._actions = { 168 | "View": [ 169 | QAction( 170 | qta.icon("fa.arrows-alt"), 171 | "Fit (Shift+F1)", 172 | parent, 173 | shortcut="shift+F1", 174 | triggered=self.fit, 175 | ), 176 | QAction( 177 | QIcon(":/images/icons/isometric_view.svg"), 178 | "Iso (Shift+F2)", 179 | parent, 180 | shortcut="shift+F2", 181 | triggered=self.iso_view, 182 | ), 183 | QAction( 184 | QIcon(":/images/icons/top_view.svg"), 185 | "Top (Shift+F3)", 186 | parent, 187 | shortcut="shift+F3", 188 | triggered=self.top_view, 189 | ), 190 | QAction( 191 | QIcon(":/images/icons/bottom_view.svg"), 192 | "Bottom (Shift+F4)", 193 | parent, 194 | shortcut="shift+F4", 195 | triggered=self.bottom_view, 196 | ), 197 | QAction( 198 | QIcon(":/images/icons/front_view.svg"), 199 | "Front (Shift+F5)", 200 | parent, 201 | shortcut="shift+F5", 202 | triggered=self.front_view, 203 | ), 204 | QAction( 205 | QIcon(":/images/icons/back_view.svg"), 206 | "Back (Shift+F6)", 207 | parent, 208 | shortcut="shift+F6", 209 | triggered=self.back_view, 210 | ), 211 | QAction( 212 | QIcon(":/images/icons/left_side_view.svg"), 213 | "Left (Shift+F7)", 214 | parent, 215 | shortcut="shift+F7", 216 | triggered=self.left_view, 217 | ), 218 | QAction( 219 | QIcon(":/images/icons/right_side_view.svg"), 220 | "Right (Shift+F8)", 221 | parent, 222 | shortcut="shift+F8", 223 | triggered=self.right_view, 224 | ), 225 | QAction( 226 | qta.icon("fa.square-o"), 227 | "Wireframe (Shift+F9)", 228 | parent, 229 | shortcut="shift+F9", 230 | triggered=self.wireframe_view, 231 | ), 232 | QAction( 233 | qta.icon("fa.square"), 234 | "Shaded (Shift+F10)", 235 | parent, 236 | shortcut="shift+F10", 237 | triggered=self.shaded_view, 238 | ), 239 | ], 240 | "Tools": [ 241 | QAction( 242 | icon("screenshot"), 243 | "Screenshot", 244 | parent, 245 | triggered=self.save_screenshot, 246 | ) 247 | ], 248 | } 249 | 250 | def toolbarActions(self): 251 | 252 | return self._actions["View"] 253 | 254 | def clear(self): 255 | 256 | self.displayed_shapes = [] 257 | self.displayed_ais = [] 258 | self.canvas.context.EraseAll(True) 259 | context = self._get_context() 260 | context.PurgeDisplay() 261 | context.RemoveAll(True) 262 | 263 | def _display(self, shape): 264 | 265 | ais = make_AIS(shape) 266 | self.canvas.context.Display(shape, True) 267 | 268 | self.displayed_shapes.append(shape) 269 | self.displayed_ais.append(ais) 270 | 271 | # self.canvas._display.Repaint() 272 | 273 | @pyqtSlot(object) 274 | def display(self, ais): 275 | 276 | context = self._get_context() 277 | context.Display(ais, True) 278 | 279 | if self.preferences["Fit automatically"]: 280 | self.fit() 281 | 282 | @pyqtSlot(list) 283 | @pyqtSlot(list, bool) 284 | def display_many(self, ais_list, fit=None): 285 | context = self._get_context() 286 | for ais in ais_list: 287 | context.Display(ais, True) 288 | 289 | if self.preferences["Fit automatically"] and fit is None: 290 | self.fit() 291 | elif fit: 292 | self.fit() 293 | 294 | @pyqtSlot(QTreeWidgetItem, int) 295 | def update_item(self, item, col): 296 | 297 | ctx = self._get_context() 298 | if item.checkState(0): 299 | ctx.Display(item.ais, True) 300 | else: 301 | ctx.Erase(item.ais, True) 302 | 303 | @pyqtSlot(list) 304 | def remove_items(self, ais_items): 305 | 306 | ctx = self._get_context() 307 | for ais in ais_items: 308 | ctx.Erase(ais, True) 309 | 310 | @pyqtSlot() 311 | def redraw(self): 312 | 313 | self._get_viewer().Redraw() 314 | 315 | def fit(self): 316 | 317 | self.canvas.view.FitAll() 318 | 319 | def iso_view(self): 320 | 321 | v = self._get_view() 322 | v.SetProj(1, -1, 1) 323 | v.SetTwist(0) 324 | 325 | def bottom_view(self): 326 | 327 | v = self._get_view() 328 | v.SetProj(0, 0, -1) 329 | v.SetTwist(0) 330 | 331 | def top_view(self): 332 | 333 | v = self._get_view() 334 | v.SetProj(0, 0, 1) 335 | v.SetTwist(0) 336 | 337 | def front_view(self): 338 | 339 | v = self._get_view() 340 | v.SetProj(0, 1, 0) 341 | v.SetTwist(0) 342 | 343 | def back_view(self): 344 | 345 | v = self._get_view() 346 | v.SetProj(0, -1, 0) 347 | v.SetTwist(0) 348 | 349 | def left_view(self): 350 | 351 | v = self._get_view() 352 | v.SetProj(-1, 0, 0) 353 | v.SetTwist(0) 354 | 355 | def right_view(self): 356 | 357 | v = self._get_view() 358 | v.SetProj(1, 0, 0) 359 | v.SetTwist(0) 360 | 361 | def shaded_view(self): 362 | 363 | c = self._get_context() 364 | c.SetDisplayMode(AIS_Shaded, True) 365 | 366 | def wireframe_view(self): 367 | 368 | c = self._get_context() 369 | c.SetDisplayMode(AIS_WireFrame, True) 370 | 371 | def show_grid( 372 | self, step=1.0, size=10.0 + 1e-6, color1=(0.7, 0.7, 0.7), color2=(0, 0, 0) 373 | ): 374 | 375 | viewer = self._get_viewer() 376 | viewer.ActivateGrid(Aspect_GT_Rectangular, Aspect_GDM_Lines) 377 | viewer.SetRectangularGridGraphicValues(size, size, 0) 378 | viewer.SetRectangularGridValues(0, 0, step, step, 0) 379 | grid = viewer.Grid() 380 | grid.SetColors( 381 | Quantity_Color(*color1, TOC_RGB), Quantity_Color(*color2, TOC_RGB) 382 | ) 383 | 384 | def hide_grid(self): 385 | 386 | viewer = self._get_viewer() 387 | viewer.DeactivateGrid() 388 | 389 | @pyqtSlot(bool, float) 390 | @pyqtSlot(bool) 391 | def toggle_grid(self, value: bool, dim: float = 10.0): 392 | 393 | if value: 394 | self.show_grid(step=dim / 20, size=dim + 1e-9) 395 | else: 396 | self.hide_grid() 397 | 398 | @pyqtSlot(gp_Ax3) 399 | def set_grid_orientation(self, orientation: gp_Ax3): 400 | 401 | viewer = self._get_viewer() 402 | viewer.SetPrivilegedPlane(orientation) 403 | 404 | def show_axis(self, origin=(0, 0, 0), direction=(0, 0, 1)): 405 | 406 | ax_placement = Geom_Axis1Placement(gp_Ax1(gp_Pnt(*origin), gp_Dir(*direction))) 407 | ax = AIS_Axis(ax_placement) 408 | self._display_ais(ax) 409 | 410 | def save_screenshot(self): 411 | 412 | fname = get_save_filename(self.IMAGE_EXTENSIONS) 413 | if fname != "": 414 | self._get_view().Dump(fname) 415 | 416 | def _display_ais(self, ais): 417 | 418 | self._get_context().Display(ais) 419 | 420 | def _get_view(self): 421 | 422 | return self.canvas.view 423 | 424 | def _get_viewer(self): 425 | 426 | return self.canvas.viewer 427 | 428 | def _get_context(self): 429 | 430 | return self.canvas.context 431 | 432 | @pyqtSlot(list) 433 | def handle_selection(self, obj): 434 | 435 | self.sigObjectSelected.emit(obj) 436 | 437 | @pyqtSlot(list) 438 | def set_selected(self, ais): 439 | 440 | ctx = self._get_context() 441 | ctx.ClearSelected(False) 442 | 443 | for obj in ais: 444 | ctx.AddOrRemoveSelected(obj, False) 445 | 446 | self.redraw() 447 | 448 | 449 | if __name__ == "__main__": 450 | 451 | import sys 452 | from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox 453 | 454 | app = QApplication(sys.argv) 455 | viewer = OCCViewer() 456 | 457 | dlg = QDialog() 458 | dlg.setFixedHeight(400) 459 | dlg.setFixedWidth(600) 460 | 461 | layout(dlg, (viewer,), dlg) 462 | dlg.show() 463 | 464 | box = BRepPrimAPI_MakeBox(20, 20, 30) 465 | box_ais = AIS_ColoredShape(box.Shape()) 466 | viewer.display(box_ais) 467 | 468 | sys.exit(app.exec_()) 469 | -------------------------------------------------------------------------------- /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 | - spyder >=5.5.6,<6 10 | - path 11 | - logbook 12 | - requests 13 | - cadquery 14 | - qtconsole >=5.5.1,<5.6.0 15 | -------------------------------------------------------------------------------- /icons/back_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /icons/bottom_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /icons/cadquery_logo_dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/icons/cadquery_logo_dark.ico -------------------------------------------------------------------------------- /icons/cadquery_logo_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 65 | 68 | 72 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /icons/front_view.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "spyder>=5.5.6,<6", 12 | "path", 13 | "logbook", 14 | "requests", 15 | "qtconsole>=5.5.1,<5.6.0" 16 | ] 17 | requires-python = ">=3.9,<3.13" 18 | authors = [ 19 | { name="CadQuery Developers" } 20 | ] 21 | maintainers = [ 22 | { name="CadQuery Developers" } 23 | ] 24 | description = "CadQuery plugin to create a mesh of an assembly with corresponding data" 25 | readme = "README.md" 26 | license = {file = "LICENSE"} 27 | keywords = ["cadquery", "CAD", "engineering", "design"] 28 | classifiers = [ 29 | "Development Status :: 4 - Beta", 30 | "Programming Language :: Python" 31 | ] 32 | 33 | [project.scripts] 34 | CQ-editor = "cq_editor.cqe_run:main" 35 | 36 | [project.optional-dependencies] 37 | test = [ 38 | "pytest", 39 | "pluggy", 40 | "pytest-qt", 41 | "pytest-mock", 42 | "pytest-repeat", 43 | "pyvirtualdisplay" 44 | ] 45 | dev = [ 46 | "black", 47 | ] 48 | 49 | [project.urls] 50 | Repository = "https://github.com/CadQuery/CQ-editor.git" 51 | "Bug Tracker" = "https://github.com/CadQuery/CQ-editor/issues" 52 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | xvfb_args=-ac +extension GLX +render 3 | log_level=DEBUG -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /runtests_locally.sh: -------------------------------------------------------------------------------- 1 | python -m pytest --no-xvfb -s 2 | -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/screenshots/screenshot2.png -------------------------------------------------------------------------------- /screenshots/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/screenshots/screenshot3.png -------------------------------------------------------------------------------- /screenshots/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CadQuery/CQ-editor/7a1f152519cf6efc828a6f1e160d40b3cf409261/screenshots/screenshot4.png -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------