├── .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 | [](https://ci.appveyor.com/project/adam-urbanczyk/cq-editor/branch/master)
4 | [](https://codecov.io/gh/CadQuery/CQ-editor)
5 | [](https://dev.azure.com/cadquery/CQ-editor/_build/latest?definitionId=3&branchName=master)
6 | [](https://zenodo.org/badge/latestdoi/136604983)
7 |
8 | CadQuery GUI editor based on PyQT that supports Linux, Windows and Mac.
9 |
10 | 
11 | Additional screenshots are available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki#screenshots).
12 |
13 | ## Notable features
14 |
15 | * Automatic code reloading - you can use your favourite editor
16 | * OCCT based
17 | * Graphical debugger for CadQuery scripts
18 | * Step through script and watch how your model changes
19 | * CadQuery object stack inspector
20 | * Visual inspection of current workplane and selected items
21 | * Insight into evolution of the model
22 | * Export to various formats
23 | * STL
24 | * STEP
25 |
26 | ## Documentation
27 |
28 | Documentation is available in [the wiki](https://github.com/CadQuery/CQ-editor/wiki). Topics covered are the following.
29 |
30 | * [Installation](https://github.com/CadQuery/CQ-editor/wiki/Installation)
31 | * [Usage](https://github.com/CadQuery/CQ-editor/wiki/Usage)
32 | * [Configuration](https://github.com/CadQuery/CQ-editor/wiki/Configuration)
33 |
34 | ## Getting Help
35 |
36 | For general questions and discussion about CQ-editor, please create a [GitHub Discussion](https://github.com/CadQuery/CQ-editor/discussions).
37 |
38 | ## Reporting a Bug
39 |
40 | If you believe that you have found a bug in CQ-editor, please ensure the following.
41 |
42 | * You are not running a CQ-editor fork, as these are not always synchronized with the latest updates in this project.
43 | * You have searched the [issue tracker](https://github.com/CadQuery/CQ-editor/issues) to make sure that the bug is not already known.
44 |
45 | If you have already checked those things, please file a [new issue](https://github.com/CadQuery/CQ-editor/issues/new) with the following information.
46 |
47 | * Operating System (type, version, etc) - If running Linux, please include the distribution and the version.
48 | * How CQ-editor was installed.
49 | * Python version of your environment (unless you are running a pre-built package).
50 | * Steps to reproduce the bug.
51 |
--------------------------------------------------------------------------------
/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 |
132 |
--------------------------------------------------------------------------------
/icons/bottom_view.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
79 |
--------------------------------------------------------------------------------
/icons/front_view.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |
--------------------------------------------------------------------------------