├── .gitattributes ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── README.md ├── assets ├── example_screenshot.png └── text_example.png ├── examples ├── README.md ├── m3d_viewer_example.py ├── primitive_viewer_example.py ├── text_render_example.py └── viewer_example.py ├── pyproject.toml ├── pytest.ini ├── src └── pythonopenscad │ ├── LICENSE │ ├── __init__.py │ ├── base.py │ ├── dask_anno.py │ ├── m3dapi.py │ ├── modifier.py │ ├── posc_main.py │ ├── text_render.py │ ├── text_render5.py │ ├── text_utils.py │ └── viewer │ ├── axes.py │ ├── basic_models.py │ ├── bbox.py │ ├── bbox_render.py │ ├── glctxt.py │ ├── model.py │ ├── shader.py │ ├── viewer.py │ └── viewer_base.py ├── tests ├── base_m3dapi_test.py ├── base_test.py ├── base_viewer_test.py ├── m3dapi_test.py ├── test_text_render.py ├── test_text_utils.py ├── test_triangulate_3d_face.py └── test_viewer.py └── uv.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Source files 2 | # ============ 3 | *.pxd text diff=python 4 | *.py text diff=python 5 | *.py3 text diff=python 6 | *.pyw text diff=python 7 | *.pyx text diff=python 8 | *.pyz text diff=python 9 | *.pyi text diff=python 10 | 11 | # Binary files 12 | # ============ 13 | *.db binary 14 | *.p binary 15 | *.pkl binary 16 | *.pickle binary 17 | *.pyc binary export-ignore 18 | *.pyo binary export-ignore 19 | *.pyd binary 20 | *.png binary 21 | *.jpg binary 22 | *.jpeg binary 23 | *.gif binary 24 | *.bmp binary 25 | *.tiff binary 26 | *.tif binary 27 | *.webp binary 28 | 29 | #* text eol=lf 30 | 31 | # Note: .db, .p, and .pkl files are associated 32 | # with the python modules ``pickle``, ``dbm.*``, 33 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 34 | # (among others). 35 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package to PyPI when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | release-build: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.x" 28 | 29 | - name: Build release distributions 30 | run: | 31 | # NOTE: put your own distribution build steps here. 32 | python -m pip install build 33 | python -m build 34 | 35 | - name: Upload distributions 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | 41 | pypi-publish: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - release-build 45 | permissions: 46 | # IMPORTANT: this permission is mandatory for trusted publishing 47 | id-token: write 48 | 49 | # Dedicated environments with protections for publishing are strongly recommended. 50 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 51 | environment: 52 | name: pypi 53 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 54 | url: https://pypi.org/p/pythonopenscad 55 | # 56 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 57 | # ALTERNATIVE: exactly, uncomment the following line instead: 58 | # url: https://pypi.org/project/pythonopenscad/${{ github.event.release.name }} 59 | 60 | steps: 61 | - name: Retrieve release distributions 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: release-dists 65 | path: dist/ 66 | 67 | - name: Publish release distributions to PyPI 68 | uses: pypa/gh-action-pypi-publish@release/v1 69 | with: 70 | packages-dir: dist/ 71 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # BAK files 141 | *.BAK 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PythonOpenScad (POSC) # 2 | 3 | # Introduction 4 | 5 | PythonOpenScad is a Python library for generating 3D models, primarily targeting [OpenSCAD](https://www.openscad.org/) scripts but now also supporting direct mesh generation via the [manifold3d](https://github.com/elalish/manifold) library. 6 | 7 | The primary client for PythonOpenScad is [anchorSCAD](https://github.com/owebeeone/anchorscad), which is a library for generating models from Python code with much easier metaphors for building complex models (holes vs difference, composite shapes, 8 | multi-material and multi-part models, path builders, and a whole lot more). PythonOpenScad aims to provide a robust API for both script generation and direct mesh manipulation. 9 | 10 | # Installation 11 | 12 | You can install PythonOpenScad using pip: 13 | 14 | ```bash 15 | pip install pythonopenscad 16 | ``` 17 | 18 | Note: PythonOpenScad requires Python 3.10 or later. 19 | 20 | # Getting Started 21 | 22 | 1. Install OpenSCAD from [openscad.org](https://openscad.org/) (Optional, if only using the pythonopenscad Manifold3D backend) 23 | 2. Install PythonOpenScad using pip: `pip install pythonopenscad` 24 | 3. Create your first model: 25 | 26 | ```python 27 | from pythonopenscad.posc_main import posc_main, PoscModel 28 | from pythonopenscad import PoscBase, Cube, translate, Sphere, Color 29 | from pythonopenscad.m3dapi import M3dRenderer 30 | 31 | # Create a simple model 32 | def make_model() -> PoscBase: 33 | return Sphere(r=10) - Color("red")( 34 | Cube([10, 10, 10]).translate([0, 2, 0]) 35 | ) - Color("cyan")(Cube([2, 2, 20])) 36 | 37 | model = make_model() 38 | # Save to OpenSCAD file 39 | model.write('my_model.scad') 40 | 41 | # Render to STL 42 | rc = model.renderObj(M3dRenderer()) 43 | rc.write_solid_stl("mystl.stl") 44 | 45 | # Or, view the result in a 3D viewer. 46 | posc_main([make_model]) 47 | ``` 48 | 49 | ![PythonOpenSCAD example snapshot](assets/example_screenshot.png) 50 | 51 | Note posc_main() takes a list of model generator functions with the expectation 52 | that your source code has functions for various models allowing for easier 53 | selection. 54 | 55 | # Examples 56 | 57 | The Python code below generates a 3D solid model of text saying 'Hello world!'. This demonstrates the [OpenPyScad](https://github.com/taxpon/openpyscad) style API. In fact, apart from the import line and conversion to string in print, this code should execute as expected using [OpenPyScad](https://github.com/taxpon/openpyscad). 58 | 59 | ```python 60 | from pythonopenscad import Text 61 | 62 | print( 63 | Text('Hello world!', size=15).linear_extrude(height=2) 64 | .translate([-60, 0, 0])) 65 | ``` 66 | 67 | However, as an alternative, [SolidPython](https://github.com/SolidCode/SolidPython) style is also supported, like this. 68 | 69 | ```python 70 | from pythonopenscad import text, linear_extrude, translate 71 | 72 | print( 73 | translate(v=[-60, 0, 0]) ( 74 | linear_extrude(height=2) ( 75 | text(text='Hello world!', size=15) 76 | ), 77 | ) 78 | ) 79 | ``` 80 | 81 | The generated OpenScad code in both cases above looks like the SolidPython style code with some interesting differences, note the braces ({}) which encapsulates the list of objects that the transforms apply to. 82 | 83 | ``` 84 | // Generated OpenScad code 85 | translate(v=[-60.0, 0.0, 0.0]) { 86 | linear_extrude(height=2.0) { 87 | text(text="Hello world!", size=15.0); 88 | } 89 | } 90 | ``` 91 | 92 | Note that the OpenScad script above is all using floating point numbers. This is because PythonOpenScad converts all parameters to their corresponding expected type. 93 | 94 | If you paste this code into OpenScad you get this: 95 | 96 | ![OpenScad example](assets/text_example.png) 97 | 98 | # Modules (OpenSCAD functions) and Lazy Unions 99 | 100 | Lazy union is the implicit union for the top level node. This is an experimental feature in OpenSCAD to render multi-component models to a 3mf file. PythonOpenScad supports this feature with `LazyUnion` but it needs to be the top level node. Also, in order 101 | to reduce duplication of code, the `Module` class will replace a tree with a module call and place the module code at a lower level in the script. 102 | 103 | ```python 104 | from pythonopenscad import LazyUnion, Module, Text, Translate 105 | 106 | thing1 = Text("Hello world!", size=15).linear_extrude(height=2).translate([-60, 20, 0]) 107 | thing2 = Text("Hello world 2!", size=15).linear_extrude(height=2).translate([-60, 0, 0]) 108 | print( 109 | LazyUnion()( 110 | Module("my_module")(thing1), 111 | Module("my_module")(thing2), 112 | Translate(v=[0, -40, 0])(Module("my_module")(thing1)), 113 | ) 114 | ) 115 | ``` 116 | 117 | Module names are generated by comparing the node (deep compare) so even if the module names are the same, a different module name will be generated for each distinct graph. 118 | 119 | This below is the generated OpenSCAD code. Notice that the module name for thing2 is `my_module_1` and the module name for thing1 is `my_module` even though they are created in the same name. Name collisions are resolved by appending an underscore and a number to the module name. : 120 | 121 | ``` 122 | // Start: lazy_union 123 | my_module(); 124 | my_module_1(); 125 | translate(v=[0.0, -40.0, 0.0]) { 126 | my_module(); 127 | } 128 | // End: lazy_union 129 | 130 | // Modules. 131 | 132 | module my_module() { 133 | translate(v=[-60.0, 20.0, 0.0]) { 134 | linear_extrude(height=2.0) { 135 | text(text="Hello world!", size=15.0); 136 | } 137 | } 138 | } // end module my_module 139 | 140 | module my_module_1() { 141 | translate(v=[-60.0, 0.0, 0.0]) { 142 | linear_extrude(height=2.0) { 143 | text(text="Hello world 2!", size=15.0); 144 | } 145 | } 146 | } // end module my_module_1 147 | ``` 148 | 149 | Notably, the module functionality is used by AnchorScad for managing multi material and multi part models. Each part/material pair will result in a module. When assembling a model, AnchorScad will combine these modules to respect the material and part priorities which can result in significant code reduction by removing duplicate code, not to mention the readability improvements. 150 | 151 | # Features 152 | 153 | The best things come for free. You're free to use your favorite Python IDE and get all the goodness of a full IDE experience. What doesn't come for free but is very useful is listed below: 154 | 155 | * All POSC constructors are type-checked. No generated scripts with junk inside them that's hard to trace, and full debugger support comes for free. 156 | 157 | * Supports both [OpenPyScad](https://github.com/taxpon/openpyscad) and [SolidPython](https://github.com/SolidCode/SolidPython) APIs for generating OpenScad code. Some differences exist between them in how the final model looks. 158 | 159 | * **New:** Direct mesh generation using the [manifold3d](https://github.com/elalish/manifold) backend, allowing export to formats like STL without requiring OpenSCAD. 160 | * Note: The manifold3d backend currently does not implement `minkowski()`, `import()`, or `surface()`. 161 | 162 | * **New:** Includes a simple viewer and a `posc_main` helper function to quickly view models from your scripts (see `pythonopenscad/posc_main.py` and `pythonopenscad/examples/primitive_viewer_example.py`). 163 | 164 | * Flexible code dump API to make it easy to add new functionality if desired. 165 | 166 | * POSC PyDoc strings have URLs to all the implemented primitives. 167 | 168 | * Best of all, it does nothing else. Consider it a communication layer to OpenScad. Other functionality should be built as separate libraries. 169 | 170 | ## POSC Compatibility with the [OpenPyScad](https://github.com/taxpon/openpyscad) and [SolidPython](https://github.com/SolidCode/SolidPython) APIs 171 | 172 | Each POSC object contains member functions for all the OpenScad transformations. (Note: these functions are simply wrapper functions over the transformation class constructors.) This API style is more traditional for solid modeling APIs. However, the POSC implementation gives no preference between either style, and objects created with one API can be mixed and matched with objects created using the other API. All the [OpenPyScad](https://github.com/taxpon/openpyscad) equivalent classes have capitalized names, while the [SolidPython](https://github.com/SolidCode/SolidPython) classes have lowercase names (the classes are different but they can be compared for equality). For example: 173 | 174 | ``` 175 | >>> from pythonopenscad import Text, text 176 | >>> Text() == text() 177 | True 178 | >>> Text('a') == text() 179 | False 180 | ``` 181 | 182 | [OpenPyScad](https://github.com/taxpon/openpyscad)'s modifier interface is not implemented but a different PythonOpenScad specific API accomplishes the same function. Modifiers are flags. In PythonOpenScad There are 4 flags, DISABLE, SHOW_ONLY, DEBUG and TRANSPARENT. They can be added and removed with the add_modifier, remove_modifier and has_modifiers functions. 183 | 184 | # License 185 | 186 | PythonOpenSCAD is available under the terms of the [GNU LESSER GENERAL PUBLIC LICENSE](https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html#SEC1). 187 | 188 | Copyright (C) 2025 Gianni Mariani 189 | 190 | [PythonOpenScad](https://github.com/owebeeone/pythonopenscad) is free software; 191 | you can redistribute it and/or modify it under the terms of the GNU Lesser General Public 192 | License as published by the Free Software Foundation; either 193 | version 2.1 of the License, or (at your option) any later version. 194 | 195 | This library is distributed in the hope that it will be useful, 196 | but WITHOUT ANY WARRANTY; without even the implied warranty of 197 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 198 | Lesser General Public License for more details. 199 | 200 | You should have received a copy of the GNU Lesser General Public 201 | License along with this library; if not, write to the Free Software 202 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 203 | 204 | ## Why Yet Another OpenScad Script Generator? 205 | 206 | I mainly wanted more functionality that was not being offered, and it didn't seem OpenPyScad (my preferred style) was pulling changes very quickly. (As luck would have it, my small pull request was published about the same time I got PythonOpenScad working to a sufficiently stable state.) I really wanted type checking/conversion and more comprehensive PyDoc documentation. 207 | 208 | Apart from that, it seems that using Python to produce 3D solid models using OpenScad is a prestigious line of work with a long and glorious tradition. 209 | 210 | Here are some: 211 | 212 | [https://github.com/SolidCode/SolidPython](https://github.com/SolidCode/SolidPython) active 213 | 214 | [https://github.com/taxpon/openpyscad](https://github.com/taxpon/openpyscad) (kind of active) 215 | 216 | [https://github.com/SquirrelCZE/pycad/](https://github.com/SquirrelCZE/pycad/) (gone) 217 | 218 | [https://github.com/vishnubob/pyscad](https://github.com/vishnubob/pyscad) (2016) 219 | 220 | [https://github.com/bjbsquared/SolidPy](https://github.com/bjbsquared/SolidPy) (2012) 221 | 222 | [https://github.com/acrobotic/py2scad](https://github.com/acrobotic/py2scad) (2015) 223 | 224 | [https://github.com/TheZoq2/py-scad](https://github.com/TheZoq2/py-scad) (2015) 225 | 226 | [https://github.com/defnull/pyscad](https://github.com/defnull/pyscad) (2014) 227 | 228 | It also seems like lots of dead projects but a popular theme nonetheless. 229 | 230 | Given there are 2 active projects the big difference seems to be the API. [SolidPython](https://github.com/SolidCode/SolidPython) seems to mimic OpenScad like syntax (e,g, translate(v)cube()) while [OpenPyScad](https://github.com/taxpon/openpyscad) employs a more common syntax (e.g. cube().translate()). 231 | 232 | [SolidPython](https://github.com/SolidCode/SolidPython) appears to be much more active than [OpenPyScad](https://github.com/taxpon/openpyscad) and contains a number of interesting enhancements with the inclusion of "holes". This can be positive or negative, I think negative. Personally I'd prefer another totally separate API layer that has much richer support and distances itself from the OpenScad api entirely. 233 | 234 | So why did I write PythonOpenScad? I really don't like the OpenScad syntax and I wanted a bit more error checking and flexibility with the supported data types. [OpenPyScad](https://github.com/taxpon/openpyscad) could be a whole lot better and it seems like it needs a bit of a rewrite. It still supports Python 2 (and 3) but I wanted to move on. 235 | 236 | PythonOpenScad provides a layer to generate OpenScad scripts or directly manipulate 3D meshes using Manifold3D. I will only entertain features that are specific to supporting OpenScad compatibility or Manifold3D integration in PythonOpenScad. PythonOpenScad supports both the [SolidPython](https://github.com/SolidCode/SolidPython) and [OpenPyScad](https://github.com/taxpon/openpyscad) solid modelling API styles. 237 | 238 | * Parameters are checked or converted and will raise exceptions if parameters are incompatible. 239 | 240 | * OpenScad defaults are applied so getting attribute values will result in actual values. 241 | 242 | * Documentation links to OpenScad reference docs can be found from help(object). 243 | 244 | * $fn/$fa/$fs is supported everywhere it actually does something even though the docs don't say that they do. 245 | 246 | * repr(object) works and produces python code. similarly str(object) produces OpenScad code. 247 | 248 | PythonOpenScad code aims to provide a robust interface for solid modeling in Python, whether targeting OpenSCAD or direct mesh generation. 249 | 250 | I am building another solid modelling tool, [AnchorScad](https://github.com/owebeeone/anchorscad) which allows building libraries of geometric solid models that will hopefully be a much easier way to build complex models. This is a layer on top of other CSG modules that hopefully will have an independent relationship with OpenScad. 251 | 252 | 253 | # Contributing 254 | 255 | Contributions are welcome! Please feel free to submit a Pull Request. 256 | 257 | Please make sure to update tests as appropriate. 258 | -------------------------------------------------------------------------------- /assets/example_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owebeeone/pythonopenscad/392504ebe54431d8b803b451e231aed0af48e795/assets/example_screenshot.png -------------------------------------------------------------------------------- /assets/text_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owebeeone/pythonopenscad/392504ebe54431d8b803b451e231aed0af48e795/assets/text_example.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # PyOpenSCAD Examples 2 | 3 | This directory contains example scripts demonstrating how to use various features of the PyOpenSCAD library. 4 | 5 | ## Viewer Examples 6 | 7 | ### Basic Viewer Example 8 | 9 | `viewer_example.py` demonstrates how to create and display 3D models using the PyOpenSCAD viewer. It creates several basic shapes and renders them in an interactive 3D viewer. 10 | 11 | To run: 12 | ``` 13 | python viewer_example.py 14 | ``` 15 | 16 | ### M3D Renderer Integration Example 17 | 18 | `m3d_viewer_example.py` shows how to integrate the viewer with the M3dRenderer class to visualize Manifold3D objects. It demonstrates creating complex shapes using CSG operations and then viewing them in 3D. 19 | 20 | To run: 21 | ``` 22 | python m3d_viewer_example.py 23 | ``` 24 | 25 | ## Requirements 26 | 27 | The viewer examples require additional dependencies: 28 | - PyOpenGL 29 | - PyOpenGL-accelerate 30 | - PyGLM 31 | - manifold3d (for M3D examples) 32 | 33 | Install these dependencies with: 34 | ``` 35 | pip install PyOpenGL PyOpenGL-accelerate PyGLM manifold3d 36 | ``` 37 | 38 | ## Controls 39 | 40 | The 3D viewer supports the following controls: 41 | - Left mouse button drag: Rotate camera 42 | - Mouse wheel: Zoom in/out 43 | - R key: Reset view 44 | - ESC key: Close viewer -------------------------------------------------------------------------------- /examples/m3d_viewer_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example of integrating the PyOpenSCAD viewer with M3dRenderer. 4 | This example shows how to visualize manifold3d objects using the viewer. 5 | """ 6 | 7 | import numpy as np 8 | import sys 9 | import os 10 | 11 | # Add the parent directory to the path to import pythonopenscad 12 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 13 | 14 | try: 15 | import manifold3d as m3d 16 | from pythonopenscad.m3dapi import M3dRenderer 17 | from pythonopenscad.viewer.viewer import Model, Viewer 18 | except ImportError as e: 19 | print(f"Failed to import required modules: {e}") 20 | print("Make sure manifold3d, PyOpenGL, and PyGLM are installed.") 21 | print("Try: pip install manifold3d PyOpenGL PyOpenGL-accelerate PyGLM") 22 | sys.exit(1) 23 | 24 | def manifold_to_model(manifold) -> Model: 25 | """Convert a manifold3d Manifold to a viewer Model.""" 26 | # Get the mesh from the manifold 27 | mesh = manifold.to_mesh() 28 | 29 | # Extract vertex positions and triangle indices 30 | positions = mesh.vert_properties 31 | triangles = mesh.tri_verts 32 | 33 | tri_indices = triangles.reshape(-1) 34 | 35 | # Flatten triangles and use to index positions 36 | vertex_data = positions[tri_indices] 37 | 38 | # Flatten the vertex data to 1D 39 | flattened_vertex_data = vertex_data.reshape(-1) 40 | 41 | # Create a model from the vertex data 42 | return Model(flattened_vertex_data) 43 | 44 | 45 | def create_m3d_example_models(): 46 | """Create example 3D models using M3dRenderer.""" 47 | renderer = M3dRenderer() 48 | 49 | # Create a cube 50 | cube = renderer._cube(size=(1.0, 1.0, 1.0), center=True) 51 | cube_manifold = cube.get_solid_manifold().translate([-2.0, -2.0, 0.0]) 52 | 53 | # Create a sphere 54 | sphere = renderer._color_renderer("green")._sphere(radius=0.8, fn=32) 55 | sphere_manifold = sphere.get_solid_manifold().translate([2.0, -2.0, 0.0]) 56 | 57 | # Create a cylinder 58 | cylinder = renderer._color_renderer("deepskyblue")._cylinder(h=1.5, r_base=0.5, r_top=0.5, fn=32, center=True) 59 | cylinder_manifold = cylinder.get_solid_manifold().translate([-2.0, 2.0, 0.0]) 60 | 61 | # Create a complex model using CSG operations 62 | # Create a union of cube and sphere 63 | cube3 = renderer._color_renderer("darkkhaki")._cube(size=1.2, center=True) 64 | cube_sphere = renderer._union([cube3, sphere]).get_solid_manifold().translate([2.0, 2.0, 0.0]) 65 | 66 | # Create a difference of cylinder from a cube 67 | cube2 = renderer._color_renderer("darkviolet")._cube(size=(1.5, 1.5, 1.5), center=True) 68 | cylinder2 = renderer._color_renderer("peru")._cylinder(h=3.0, r_base=0.4, r_top=0.4, fn=32, center=True) 69 | cube_with_hole = renderer._difference([cube2, cylinder2]).get_solid_manifold().translate([0.0, 0.0, 0.0]) 70 | 71 | # Convert to viewer models with different colors 72 | models = [ 73 | manifold_to_model(cube_manifold), # Red cube 74 | manifold_to_model(sphere_manifold), # Green sphere 75 | manifold_to_model(cylinder_manifold), # Blue cylinder 76 | manifold_to_model(cube_sphere), # Yellow cube+sphere 77 | manifold_to_model(cube_with_hole), # Cyan cube with hole 78 | ] 79 | 80 | return models 81 | 82 | def apply_translations(models, positions): 83 | """Apply translations to vertex data of models.""" 84 | for i, (model, pos) in enumerate(zip(models, positions)): 85 | # Get the raw vertex data 86 | data = model.data 87 | 88 | # Apply translation to positions (every 10 values, first 3 values) 89 | for j in range(0, len(data), model.stride): 90 | data[j] += pos[0] 91 | data[j+1] += pos[1] 92 | data[j+2] += pos[2] 93 | 94 | # Update the model's data 95 | model.data = data 96 | 97 | # Recompute the bounding box 98 | model._compute_bounding_box() 99 | 100 | def main(): 101 | # Create M3d models and get their positions 102 | models= create_m3d_example_models() 103 | 104 | # Apply translations to position the models 105 | #apply_translations(models, positions) 106 | 107 | # Create a viewer with all models 108 | viewer = Viewer(models, title="PyOpenSCAD M3D Viewer Example") 109 | 110 | print("Viewer controls:") 111 | print(viewer.VIEWER_HELP_TEXT) 112 | 113 | # Start the main loop 114 | viewer.run() 115 | 116 | if __name__ == "__main__": 117 | main() -------------------------------------------------------------------------------- /examples/primitive_viewer_example.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pythonopenscad import M3dRenderer 3 | import pythonopenscad as posc 4 | import sys 5 | import manifold3d as m3d 6 | from dataclasses import dataclass, field 7 | 8 | from pythonopenscad.modifier import DEBUG, DISABLE, SHOW_ONLY, PoscRendererBase 9 | 10 | try: 11 | from pythonopenscad.m3dapi import M3dRenderer 12 | from pythonopenscad.viewer.viewer import Model, Viewer 13 | except ImportError as e: 14 | print(f"Failed to import required modules: {e}") 15 | print("Make sure manifold3d, PyOpenGL, and PyGLM are installed.") 16 | print("Try: pip install manifold3d PyOpenGL PyOpenGL-accelerate PyGLM") 17 | sys.exit(1) 18 | 19 | 20 | @dataclass 21 | class PrimitiveCreatorBase: 22 | contexts: list[posc.RenderContext] = field(default_factory=list) 23 | 24 | def render(self, obj: PoscRendererBase): 25 | context = obj.renderObj(M3dRenderer()) 26 | self.contexts.append(context) 27 | return context 28 | 29 | def get_solid_manifolds(self) -> list[m3d.Manifold]: 30 | return [context.get_solid_manifold() for context in self.contexts] 31 | 32 | def get_shell_manifolds(self) -> list[m3d.Manifold]: 33 | return [context.get_shell_manifold() for context in self.contexts] 34 | 35 | def get_solid_model(self) -> list[Model]: 36 | return [Model.from_manifold(mfd) for mfd in self.get_solid_manifolds()] 37 | 38 | def get_shell_model(self) -> list[Model]: 39 | return [Model.from_manifold(mfd, has_alpha_lt1=True) for mfd in self.get_shell_manifolds()] 40 | 41 | 42 | @dataclass 43 | class PrimitiveCreator(PrimitiveCreatorBase): 44 | 45 | def create_primitive_models(self): 46 | """Create example 3D models using various OpenSCAD primitives and operations.""" 47 | 48 | spherefn = 32 49 | 50 | sphere6 = posc.Translate([6, 0, 0])(posc.Color("sienna")(posc.Sphere(r=6, _fn=spherefn ))) 51 | sphere3 = posc.Translate([-2, 0, 0])(posc.Color("orchid")(posc.Sphere(r=3, _fn=spherefn))) 52 | hull3d = posc.Translate([0, -14, 0])( 53 | posc.Color("sienna")(posc.Union()(posc.Hull()(sphere6, sphere3))) 54 | ) 55 | self.render(hull3d) 56 | 57 | circle6 = posc.Translate([6, 0, 0])(posc.Circle(r=6)) 58 | circle3 = posc.Translate([-2, 0, 0])(posc.Circle(r=3)) 59 | hull2d = posc.Hull()(circle6, circle3) 60 | hull_extrusion = posc.Translate([0, 10, 0])(posc.Linear_Extrude(height=3.0)(hull2d)) 61 | self.render(hull_extrusion) 62 | 63 | text = posc.Text( 64 | "Hello, World!", 65 | size=3, 66 | font="Arial", 67 | halign="center", 68 | valign="center", 69 | spacing=1.0, 70 | direction="ltr", 71 | language="en", 72 | script="latin", 73 | ) 74 | 75 | text_extrusion = posc.Color("darkseagreen")( 76 | posc.Translate([-8.0, 0.0, 4.5])( 77 | posc.Rotate([0, 0, 90])(posc.Linear_Extrude(height=3.0)(text)) 78 | ) 79 | ) 80 | self.render(text_extrusion) 81 | 82 | # Create a sphere 83 | sphere = posc.Translate([-2.0, -2.0, 0.0])( 84 | posc.Color("darkolivegreen")(posc.Sphere(r=2.8, _fn=spherefn)) 85 | ) 86 | self.render(sphere) 87 | 88 | # Create a sphere 89 | cube = posc.Translate([2.0, -2.0, 0.0])(posc.Color("orchid")(posc.Cube([1.0, 1.0, 2.0]))) 90 | self.render(cube) 91 | 92 | # Create a cylinder 93 | cylinder = posc.translate([-6.0, -2.0, 0.0])( 94 | posc.Color("peachpuff")(posc.Cylinder(h=1.5, r1=0.5, r2=1.5, _fn=32, center=False)) 95 | ) 96 | self.render(cylinder) 97 | 98 | sphere2 = posc.Color("darkolivegreen")(posc.Sphere(r=1, _fn=spherefn)) 99 | cube2 = posc.Color("orchid")(posc.Cube(1.5, center=True)) 100 | difference = posc.Difference()(cube2, sphere2).translate([6.0, 0.0, 0.0]) 101 | difference = posc.Rotate([0, 0, 45])(posc.Rotate([45, 45, 0])(difference)) 102 | ctxt = self.render(difference) 103 | difference_manifold = ctxt.get_solid_manifold() 104 | 105 | intersection = posc.Intersection()(cube2, sphere2).translate([6.0, -2.0, 0.0]) 106 | self.render(intersection) 107 | 108 | union = posc.Union()(cube2, sphere2).translate([6.0, -4.0, 0.0]) 109 | self.render(union) 110 | 111 | linear_extrusion = posc.Color("darkseagreen")( 112 | posc.Translate([0.0, 0.0, 4.5])( 113 | posc.Linear_Extrude(height=3.0, twist=45, slices=16, scale=(2.5, 0.5))( 114 | posc.Translate([0.0, 0.0, 0.0])(posc.Square([1.0, 1.0])) 115 | ) 116 | ) 117 | ) 118 | self.render(linear_extrusion) 119 | 120 | rotate_extrusion = posc.Color("darkseagreen")( 121 | posc.Translate([-3.0, 0.0, 4.5])( 122 | posc.Rotate_Extrude(angle=360, _fn=32)( 123 | posc.Translate([1.0, 0.0, 0.0])(posc.Circle(r=0.5, _fn=16)) 124 | ) 125 | ) 126 | ) 127 | self.render(rotate_extrusion) 128 | 129 | # Create a polygon as a triangle with an inner triangle hole. 130 | polygon = posc.Polygon( 131 | [[2, 0], [0, 2], [-2, 0], [1, 0.5], [0, 1], [-1, 0.5]], 132 | paths=[[0, 1, 2], [3, 5, 4]], 133 | convexity=2, 134 | ) 135 | polygon_extrusion = posc.Color("darkseagreen")( 136 | posc.Translate([-6.0, 0.0, 4.5])(posc.Linear_Extrude(height=3.0)(polygon)) 137 | ) 138 | self.render(polygon_extrusion) 139 | 140 | projection = posc.Projection()(difference) 141 | projection_extrusion = posc.Color("darkseagreen")( 142 | posc.Translate([16.0, 0.0, 4.5])(posc.Linear_Extrude(height=1.0)(projection)) 143 | ) 144 | self.render(projection_extrusion) 145 | 146 | xmin, ymin, zmin, xmax, ymax, zmax = difference_manifold.bounding_box() 147 | cut = posc.Projection(cut=True)(posc.Translate([0, 0, -(zmin + zmax) / 2])(difference)) 148 | cut_extrusion = posc.Color("darkseagreen")( 149 | posc.Translate([16.0, 5.0, 4.5])(posc.Linear_Extrude(height=1.0)(cut)) 150 | ) 151 | self.render(cut_extrusion) 152 | 153 | offset = posc.Offset(r=0.5)(cut) 154 | offset_extrusion = posc.Color("brown")( 155 | posc.Translate([16.0, 10.0, 4.5])(posc.Linear_Extrude(height=1.0)(offset)) 156 | ) 157 | self.render(offset_extrusion) 158 | 159 | filled_text = posc.Fill()( 160 | posc.Text( 161 | "A", 162 | size=3, 163 | font="Arial", 164 | halign="center", 165 | valign="center", 166 | ) 167 | ) 168 | filled_text_extrusion = posc.Color("royalblue")( 169 | posc.Translate([16.0, 15.0, 4.5])(posc.Linear_Extrude(height=1.0)(filled_text)) 170 | ) 171 | self.render(filled_text_extrusion) 172 | 173 | union_of_solids = posc.Translate([0, -40, 0])( 174 | hull_extrusion, 175 | hull3d.add_modifier(DEBUG), 176 | text_extrusion.transparent(), 177 | sphere, 178 | cube, 179 | cylinder, 180 | difference, 181 | intersection, 182 | union, 183 | linear_extrusion, 184 | rotate_extrusion, 185 | polygon_extrusion, 186 | projection_extrusion, 187 | cut_extrusion, 188 | offset_extrusion, 189 | filled_text_extrusion, 190 | ) 191 | 192 | self.render(union_of_solids) 193 | 194 | return self.get_solid_model() + self.get_shell_model() 195 | 196 | 197 | def main(): 198 | #Viewer._init_glut() 199 | 200 | # Create the models 201 | models = PrimitiveCreator().create_primitive_models() 202 | 203 | # Create and run the viewer 204 | viewer = Viewer(models) 205 | 206 | print(Viewer.VIEWER_HELP_TEXT) 207 | print(f"Total triangles={viewer.num_triangles()}") 208 | viewer.run() 209 | 210 | 211 | if __name__ == "__main__": 212 | main() 213 | -------------------------------------------------------------------------------- /examples/text_render_example.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import logging 4 | from pythonopenscad.text_render import render_text, get_fonts_list, get_available_fonts, extentsof 5 | log = logging.getLogger(__name__) 6 | 7 | if __name__ == "__main__": 8 | import matplotlib.pyplot as plt 9 | 10 | if len(sys.argv) > 1 and sys.argv[1] == "--list-fonts": 11 | print("Available Fonts:") 12 | fonts = get_fonts_list() 13 | for font in fonts: 14 | print(f" {font}") 15 | sys.exit(0) 16 | if len(sys.argv) > 1 and sys.argv[1] == "--list-fonts-by-family": 17 | print("Available Font Families:") 18 | fonts = get_available_fonts() 19 | for family, styles in sorted(fonts.items()): 20 | print(f"{family}: {', '.join(sorted(styles))}") 21 | sys.exit(0) 22 | 23 | font_to_use = "Arial" # Default if no arg 24 | font_to_use = "Times New Roman" 25 | font_to_use = "Tahoma" 26 | if len(sys.argv) > 1 and not sys.argv[1].startswith("--"): 27 | font_to_use = sys.argv[1] 28 | log.info(f"Using font: '{font_to_use}'") 29 | 30 | print("Generating text...") 31 | mixed_text = "Hello! مرحباً שלום" # Mix English, Arabic, Hebrew 32 | print(f"Testing text: {mixed_text}") 33 | 34 | try: 35 | # Regular text first 36 | log.info("Generating LTR example...") 37 | points1, contours1 = render_text( 38 | "Test!", size=25, font=font_to_use, halign="center", valign="center", fn=None 39 | ) 40 | 41 | # Bidirectional text with LTR base direction 42 | log.info("Generating Bidi LTR example...") 43 | points2, contours2 = render_text( 44 | mixed_text, 45 | size=25, 46 | font=font_to_use, 47 | halign="center", 48 | valign="center", 49 | fn=None, 50 | base_direction="ltr", 51 | script="latin", 52 | ) # Script hint might need adjustment 53 | 54 | # Bidirectional text with RTL base direction 55 | log.info("Generating Bidi RTL example...") 56 | points3, contours3 = render_text( 57 | mixed_text, 58 | size=25, 59 | font=font_to_use, 60 | halign="center", 61 | valign="center", 62 | fn=None, 63 | base_direction="rtl", 64 | script="arabic", 65 | ) # Script hint might need adjustment 66 | 67 | except Exception as e: 68 | log.error(f"Error during text generation: {e}", exc_info=True) 69 | print("\nTry running with --list-fonts to see available fonts.") 70 | sys.exit(1) 71 | 72 | print(f"Generated {len(contours1)} polygons, {len(points1)} points for 'Test!'") 73 | print(f"Generated {len(contours2)} polygons, {len(points2)} points for bidi text (LTR base)") 74 | print(f"Generated {len(contours3)} polygons, {len(points3)} points for bidi text (RTL base)") 75 | 76 | # --- Plotting --- 77 | fig, axes = plt.subplots(3, 1, figsize=(10, 15), sharex=False) # Don't share X 78 | 79 | # Function to determine polygon winding order 80 | def is_clockwise(points): 81 | """Determine if polygon has clockwise winding order using shoelace formula.""" 82 | # Return True for clockwise (positive area), False for counterclockwise (negative area) 83 | if len(points) < 3: 84 | return False 85 | area = 0 86 | for i in range(len(points) - 1): 87 | area += (points[i+1][0] - points[i][0]) * (points[i+1][1] + points[i][1]) 88 | return area > 0 89 | 90 | def plot_polygons(ax, title, points, contours, color): 91 | if points is None or points.shape[0] == 0 or not contours: 92 | ax.text(0.5, 0.5, "No polygons generated", ha="center", va="center") 93 | ax.set_title(f"{title} (No polygons)") 94 | return 95 | for contour_indices in contours: 96 | # Ensure indices are valid 97 | valid_indices = contour_indices[contour_indices < points.shape[0]] 98 | if len(valid_indices) > 1: 99 | contour_points = points[valid_indices] 100 | clockwise = is_clockwise(contour_points) 101 | fill_color = 'pink' if clockwise else color 102 | patch = plt.Polygon( 103 | contour_points, closed=True, facecolor=fill_color, edgecolor="black", alpha=0.6 104 | ) 105 | ax.add_patch(patch) 106 | else: 107 | log.warning(f"Skipping contour with invalid indices: {contour_indices}") 108 | 109 | ax.set_aspect("equal", adjustable="box") 110 | min_coord, max_coord = extentsof(points) 111 | ax.set_xlim(min_coord[0] - 5, max_coord[0] + 5) 112 | ax.set_ylim(min_coord[1] - 5, max_coord[1] + 5) 113 | ax.set_title(title) 114 | ax.grid(True) 115 | 116 | plot_polygons(axes[0], f"Regular Text: 'Test!'", points1, contours1, "blue") 117 | plot_polygons( 118 | axes[1], f"Bidirectional Text (LTR base): '{mixed_text}'", points2, contours2, "green" 119 | ) 120 | plot_polygons( 121 | axes[2], f"Bidirectional Text (RTL base): '{mixed_text}'", points3, contours3, "red" 122 | ) 123 | 124 | plt.tight_layout() 125 | plt.savefig("text_render_bidi_test_hb.png", dpi=150) 126 | print("Figure saved to 'text_render_bidi_test_hb.png'") 127 | try: 128 | plt.show() 129 | except Exception: 130 | print("Could not display plot - but image was saved to file") 131 | -------------------------------------------------------------------------------- /examples/viewer_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example usage of the PyOpenSCAD viewer module. 4 | This example creates several 3D models and displays them in the viewer. 5 | """ 6 | 7 | import numpy as np 8 | import sys 9 | import os 10 | 11 | # Add the parent directory to the path to import pythonopenscad 12 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 13 | 14 | try: 15 | from pythonopenscad.viewer.viewer import Model, Viewer 16 | except ImportError: 17 | print("Failed to import viewer module. Make sure PyOpenGL and PyGLM are installed.") 18 | print("Try: pip install PyOpenGL PyOpenGL-accelerate PyGLM") 19 | sys.exit(1) 20 | 21 | def create_cube_data(size=1.0, color=(0.0, 0.7, 0.2, 1.0)): 22 | """Create vertex data for a cube.""" 23 | s = size / 2 24 | vertices = [ 25 | # Front face 26 | [-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s], 27 | # Back face 28 | [-s, -s, -s], [s, -s, -s], [s, s, -s], [-s, s, -s], 29 | ] 30 | 31 | # Define the 6 face normals 32 | normals = [ 33 | [0, 0, 1], # front 34 | [0, 0, -1], # back 35 | [0, -1, 0], # bottom 36 | [0, 1, 0], # top 37 | [-1, 0, 0], # left 38 | [1, 0, 0] # right 39 | ] 40 | 41 | # Define the faces using indices 42 | faces = [ 43 | [0, 1, 2, 3], # front 44 | [4, 7, 6, 5], # back 45 | [0, 4, 5, 1], # bottom 46 | [3, 2, 6, 7], # top 47 | [0, 3, 7, 4], # left 48 | [1, 5, 6, 2] # right 49 | ] 50 | 51 | # Create vertex data for each face 52 | vertex_data = [] 53 | for face_idx, face in enumerate(faces): 54 | normal = normals[face_idx] 55 | 56 | # Create two triangles for each face 57 | tri1 = [face[0], face[1], face[2]] 58 | tri2 = [face[0], face[2], face[3]] 59 | 60 | for tri in [tri1, tri2]: 61 | for vertex_idx in tri: 62 | # position 63 | vertex_data.extend(vertices[vertex_idx]) 64 | # color 65 | vertex_data.extend(color) 66 | # normal 67 | vertex_data.extend(normal) 68 | 69 | return np.array(vertex_data, dtype=np.float32) 70 | 71 | def create_sphere_data(radius=1.0, color=(0.2, 0.2, 0.8, 1.0), segments=20): 72 | """Create vertex data for a sphere using UV-sphere construction.""" 73 | vertex_data = [] 74 | 75 | # Generate the vertices 76 | for i in range(segments + 1): 77 | theta = i * np.pi / segments 78 | sin_theta = np.sin(theta) 79 | cos_theta = np.cos(theta) 80 | 81 | for j in range(segments * 2): 82 | phi = j * 2 * np.pi / (segments * 2) 83 | sin_phi = np.sin(phi) 84 | cos_phi = np.cos(phi) 85 | 86 | # Position 87 | x = radius * sin_theta * cos_phi 88 | y = radius * sin_theta * sin_phi 89 | z = radius * cos_theta 90 | 91 | # Normal (normalized position for a sphere) 92 | nx = sin_theta * cos_phi 93 | ny = sin_theta * sin_phi 94 | nz = cos_theta 95 | 96 | # Add this vertex 97 | vertex_data.extend([x, y, z]) 98 | vertex_data.extend(color) 99 | vertex_data.extend([nx, ny, nz]) 100 | 101 | # Generate the triangles 102 | indices = [] 103 | for i in range(segments): 104 | for j in range(segments * 2): 105 | next_j = (j + 1) % (segments * 2) 106 | 107 | # Get the indices of the four vertices of a quad 108 | p1 = i * (segments * 2 + 1) + j 109 | p2 = i * (segments * 2 + 1) + next_j 110 | p3 = (i + 1) * (segments * 2 + 1) + next_j 111 | p4 = (i + 1) * (segments * 2 + 1) + j 112 | 113 | # Two triangles make a quad - with correct winding order 114 | # (counter-clockwise when viewed from outside) 115 | indices.extend([p2, p1, p3]) # First triangle 116 | indices.extend([p3, p1, p4]) # Second triangle 117 | 118 | # Create vertex data from indices 119 | indexed_vertex_data = [] 120 | vertices_per_point = 10 # 3 position + 4 color + 3 normal 121 | 122 | for idx in indices: 123 | start = idx * vertices_per_point 124 | end = start + vertices_per_point 125 | indexed_vertex_data.extend(vertex_data[start:end]) 126 | 127 | return np.array(indexed_vertex_data, dtype=np.float32) 128 | 129 | def create_torus_data(major_radius=1.0, minor_radius=0.3, color=(0.8, 0.2, 0.2, 1.0), segments=20): 130 | """Create vertex data for a torus.""" 131 | vertex_data = [] 132 | 133 | # Generate the vertices 134 | for i in range(segments): 135 | theta = i * 2 * np.pi / segments 136 | sin_theta = np.sin(theta) 137 | cos_theta = np.cos(theta) 138 | 139 | for j in range(segments): 140 | phi = j * 2 * np.pi / segments 141 | sin_phi = np.sin(phi) 142 | cos_phi = np.cos(phi) 143 | 144 | # Position 145 | x = (major_radius + minor_radius * cos_phi) * cos_theta 146 | y = (major_radius + minor_radius * cos_phi) * sin_theta 147 | z = minor_radius * sin_phi 148 | 149 | # Normal - pointing outward from the torus surface 150 | nx = cos_phi * cos_theta 151 | ny = cos_phi * sin_theta 152 | nz = sin_phi 153 | 154 | # Add this vertex 155 | vertex_data.extend([x, y, z]) 156 | vertex_data.extend(color) 157 | vertex_data.extend([nx, ny, nz]) 158 | 159 | # Generate the triangles 160 | indices = [] 161 | for i in range(segments): 162 | next_i = (i + 1) % segments 163 | for j in range(segments): 164 | next_j = (j + 1) % segments 165 | 166 | # Get the indices of the four vertices of a quad 167 | p1 = i * segments + j 168 | p2 = i * segments + next_j 169 | p3 = next_i * segments + next_j 170 | p4 = next_i * segments + j 171 | 172 | # Two triangles make a quad - with correct winding order 173 | # (counter-clockwise when viewed from outside) 174 | indices.extend([p2, p1, p3]) # First triangle 175 | indices.extend([p3, p1, p4]) # Second triangle 176 | 177 | # Create vertex data from indices 178 | indexed_vertex_data = [] 179 | vertices_per_point = 10 # 3 position + 4 color + 3 normal 180 | 181 | for idx in indices: 182 | start = idx * vertices_per_point 183 | end = start + vertices_per_point 184 | indexed_vertex_data.extend(vertex_data[start:end]) 185 | 186 | return np.array(indexed_vertex_data, dtype=np.float32) 187 | 188 | def main(): 189 | 190 | # Create vertex data for our models 191 | cube_data = create_cube_data(size=1.0, color=(0.0, 0.7, 0.2, 1.0)) 192 | sphere_data = create_sphere_data(radius=0.8, color=(0.2, 0.2, 0.8, 1.0)) 193 | torus_data = create_torus_data(major_radius=1.5, minor_radius=0.3, color=(0.8, 0.2, 0.2, 1.0)) 194 | 195 | # Create models 196 | cube = Model(cube_data) 197 | sphere = Model(sphere_data) 198 | torus = Model(torus_data) 199 | 200 | # Position the models 201 | # (In a more complex example, you'd use transformation matrices) 202 | 203 | # Create a viewer with all models 204 | viewer = Viewer([cube, sphere, torus], title="PyOpenSCAD Viewer Example") 205 | 206 | print("Viewer controls:") 207 | print(viewer.VIEWER_HELP_TEXT) 208 | 209 | # Start the main loop 210 | viewer.run() 211 | 212 | if __name__ == "__main__": 213 | main() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling", 4 | ] 5 | build-backend = "hatchling.build" 6 | 7 | [project] 8 | name = "pythonopenscad" 9 | version = "2.2.4" 10 | authors = [ 11 | { name = "Gianni Mariani", email = "gianni@mariani.ws" }, 12 | ] 13 | description = "Yet Another Python OpenSCAD API. This is a thin wrapper for generating OpenSCAD 3D model scripts." 14 | readme = "README.md" 15 | requires-python = ">=3.10" 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", 19 | "Operating System :: OS Independent", 20 | ] 21 | dependencies = [ 22 | "distributed>=2025.1.0", 23 | "numpy>=2.2.1", 24 | "manifold3d>=3.1.0", 25 | "mapbox-earcut>=1.0.3", 26 | "mapbox>=0.18.1", 27 | "frozendict>=2.3.0", 28 | "fonttools>=4.57.0", 29 | "pillow>=11.0.0", 30 | "uharfbuzz>=0.48.0", 31 | "datatrees>=0.2.1", 32 | "numpy-stl>=3.2.0", 33 | "pyopengl>=3.1.9", 34 | "pyglm>=2.8.0", 35 | "hershey-fonts>=2.1.0", 36 | ] 37 | 38 | [project.urls] 39 | Homepage = "https://github.com/owebeeone/pythonopenscad" 40 | "Bug Tracker" = "https://github.com/owebeeone/pythonopenscad/issues" 41 | 42 | [tool.hatch.envs.test] 43 | dependencies = [ 44 | "pytest>=7.0.0", 45 | "pytest-cov>=4.0.0", 46 | ] 47 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = *_test.py -------------------------------------------------------------------------------- /src/pythonopenscad/LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 2.1, February 1999 3 | 4 | Copyright (C) 1991, 1999 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | [This is the first released version of the Lesser GPL. It also counts 10 | as the successor of the GNU Library Public License, version 2, hence 11 | the version number 2.1.] 12 | 13 | Preamble 14 | 15 | The licenses for most software are designed to take away your 16 | freedom to share and change it. By contrast, the GNU General Public 17 | Licenses are intended to guarantee your freedom to share and change 18 | free software--to make sure the software is free for all its users. 19 | 20 | This license, the Lesser General Public License, applies to some 21 | specially designated software packages--typically libraries--of the 22 | Free Software Foundation and other authors who decide to use it. You 23 | can use it too, but we suggest you first think carefully about whether 24 | this license or the ordinary General Public License is the better 25 | strategy to use in any particular case, based on the explanations below. 26 | 27 | When we speak of free software, we are referring to freedom of use, 28 | not price. Our General Public Licenses are designed to make sure that 29 | you have the freedom to distribute copies of free software (and charge 30 | for this service if you wish); that you receive source code or can get 31 | it if you want it; that you can change the software and use pieces of 32 | it in new free programs; and that you are informed that you can do 33 | these things. 34 | 35 | To protect your rights, we need to make restrictions that forbid 36 | distributors to deny you these rights or to ask you to surrender these 37 | rights. These restrictions translate to certain responsibilities for 38 | you if you distribute copies of the library or if you modify it. 39 | 40 | For example, if you distribute copies of the library, whether gratis 41 | or for a fee, you must give the recipients all the rights that we gave 42 | you. You must make sure that they, too, receive or can get the source 43 | code. If you link other code with the library, you must provide 44 | complete object files to the recipients, so that they can relink them 45 | with the library after making changes to the library and recompiling 46 | it. And you must show them these terms so they know their rights. 47 | 48 | We protect your rights with a two-step method: (1) we copyright the 49 | library, and (2) we offer you this license, which gives you legal 50 | permission to copy, distribute and/or modify the library. 51 | 52 | To protect each distributor, we want to make it very clear that 53 | there is no warranty for the free library. Also, if the library is 54 | modified by someone else and passed on, the recipients should know 55 | that what they have is not the original version, so that the original 56 | author's reputation will not be affected by problems that might be 57 | introduced by others. 58 | 59 | Finally, software patents pose a constant threat to the existence of 60 | any free program. We wish to make sure that a company cannot 61 | effectively restrict the users of a free program by obtaining a 62 | restrictive license from a patent holder. Therefore, we insist that 63 | any patent license obtained for a version of the library must be 64 | consistent with the full freedom of use specified in this license. 65 | 66 | Most GNU software, including some libraries, is covered by the 67 | ordinary GNU General Public License. This license, the GNU Lesser 68 | General Public License, applies to certain designated libraries, and 69 | is quite different from the ordinary General Public License. We use 70 | this license for certain libraries in order to permit linking those 71 | libraries into non-free programs. 72 | 73 | When a program is linked with a library, whether statically or using 74 | a shared library, the combination of the two is legally speaking a 75 | combined work, a derivative of the original library. The ordinary 76 | General Public License therefore permits such linking only if the 77 | entire combination fits its criteria of freedom. The Lesser General 78 | Public License permits more lax criteria for linking other code with 79 | the library. 80 | 81 | We call this license the "Lesser" General Public License because it 82 | does Less to protect the user's freedom than the ordinary General 83 | Public License. It also provides other free software developers Less 84 | of an advantage over competing non-free programs. These disadvantages 85 | are the reason we use the ordinary General Public License for many 86 | libraries. However, the Lesser license provides advantages in certain 87 | special circumstances. 88 | 89 | For example, on rare occasions, there may be a special need to 90 | encourage the widest possible use of a certain library, so that it becomes 91 | a de-facto standard. To achieve this, non-free programs must be 92 | allowed to use the library. A more frequent case is that a free 93 | library does the same job as widely used non-free libraries. In this 94 | case, there is little to gain by limiting the free library to free 95 | software only, so we use the Lesser General Public License. 96 | 97 | In other cases, permission to use a particular library in non-free 98 | programs enables a greater number of people to use a large body of 99 | free software. For example, permission to use the GNU C Library in 100 | non-free programs enables many more people to use the whole GNU 101 | operating system, as well as its variant, the GNU/Linux operating 102 | system. 103 | 104 | Although the Lesser General Public License is Less protective of the 105 | users' freedom, it does ensure that the user of a program that is 106 | linked with the Library has the freedom and the wherewithal to run 107 | that program using a modified version of the Library. 108 | 109 | The precise terms and conditions for copying, distribution and 110 | modification follow. Pay close attention to the difference between a 111 | "work based on the library" and a "work that uses the library". The 112 | former contains code derived from the library, whereas the latter must 113 | be combined with the library in order to run. 114 | 115 | GNU LESSER GENERAL PUBLIC LICENSE 116 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 117 | 118 | 0. This License Agreement applies to any software library or other 119 | program which contains a notice placed by the copyright holder or 120 | other authorized party saying it may be distributed under the terms of 121 | this Lesser General Public License (also called "this License"). 122 | Each licensee is addressed as "you". 123 | 124 | A "library" means a collection of software functions and/or data 125 | prepared so as to be conveniently linked with application programs 126 | (which use some of those functions and data) to form executables. 127 | 128 | The "Library", below, refers to any such software library or work 129 | which has been distributed under these terms. A "work based on the 130 | Library" means either the Library or any derivative work under 131 | copyright law: that is to say, a work containing the Library or a 132 | portion of it, either verbatim or with modifications and/or translated 133 | straightforwardly into another language. (Hereinafter, translation is 134 | included without limitation in the term "modification".) 135 | 136 | "Source code" for a work means the preferred form of the work for 137 | making modifications to it. For a library, complete source code means 138 | all the source code for all modules it contains, plus any associated 139 | interface definition files, plus the scripts used to control compilation 140 | and installation of the library. 141 | 142 | Activities other than copying, distribution and modification are not 143 | covered by this License; they are outside its scope. The act of 144 | running a program using the Library is not restricted, and output from 145 | such a program is covered only if its contents constitute a work based 146 | on the Library (independent of the use of the Library in a tool for 147 | writing it). Whether that is true depends on what the Library does 148 | and what the program that uses the Library does. 149 | 150 | 1. You may copy and distribute verbatim copies of the Library's 151 | complete source code as you receive it, in any medium, provided that 152 | you conspicuously and appropriately publish on each copy an 153 | appropriate copyright notice and disclaimer of warranty; keep intact 154 | all the notices that refer to this License and to the absence of any 155 | warranty; and distribute a copy of this License along with the 156 | Library. 157 | 158 | You may charge a fee for the physical act of transferring a copy, 159 | and you may at your option offer warranty protection in exchange for a 160 | fee. 161 | 162 | 2. You may modify your copy or copies of the Library or any portion 163 | of it, thus forming a work based on the Library, and copy and 164 | distribute such modifications or work under the terms of Section 1 165 | above, provided that you also meet all of these conditions: 166 | 167 | a) The modified work must itself be a software library. 168 | 169 | b) You must cause the files modified to carry prominent notices 170 | stating that you changed the files and the date of any change. 171 | 172 | c) You must cause the whole of the work to be licensed at no 173 | charge to all third parties under the terms of this License. 174 | 175 | d) If a facility in the modified Library refers to a function or a 176 | table of data to be supplied by an application program that uses 177 | the facility, other than as an argument passed when the facility 178 | is invoked, then you must make a good faith effort to ensure that, 179 | in the event an application does not supply such function or 180 | table, the facility still operates, and performs whatever part of 181 | its purpose remains meaningful. 182 | 183 | (For example, a function in a library to compute square roots has 184 | a purpose that is entirely well-defined independent of the 185 | application. Therefore, Subsection 2d requires that any 186 | application-supplied function or table used by this function must 187 | be optional: if the application does not supply it, the square 188 | root function must still compute square roots.) 189 | 190 | These requirements apply to the modified work as a whole. If 191 | identifiable sections of that work are not derived from the Library, 192 | and can be reasonably considered independent and separate works in 193 | themselves, then this License, and its terms, do not apply to those 194 | sections when you distribute them as separate works. But when you 195 | distribute the same sections as part of a whole which is a work based 196 | on the Library, the distribution of the whole must be on the terms of 197 | this License, whose permissions for other licensees extend to the 198 | entire whole, and thus to each and every part regardless of who wrote 199 | it. 200 | 201 | Thus, it is not the intent of this section to claim rights or contest 202 | your rights to work written entirely by you; rather, the intent is to 203 | exercise the right to control the distribution of derivative or 204 | collective works based on the Library. 205 | 206 | In addition, mere aggregation of another work not based on the Library 207 | with the Library (or with a work based on the Library) on a volume of 208 | a storage or distribution medium does not bring the other work under 209 | the scope of this License. 210 | 211 | 3. You may opt to apply the terms of the ordinary GNU General Public 212 | License instead of this License to a given copy of the Library. To do 213 | this, you must alter all the notices that refer to this License, so 214 | that they refer to the ordinary GNU General Public License, version 2, 215 | instead of to this License. (If a newer version than version 2 of the 216 | ordinary GNU General Public License has appeared, then you can specify 217 | that version instead if you wish.) Do not make any other change in 218 | these notices. 219 | 220 | Once this change is made in a given copy, it is irreversible for 221 | that copy, so the ordinary GNU General Public License applies to all 222 | subsequent copies and derivative works made from that copy. 223 | 224 | This option is useful when you wish to copy part of the code of 225 | the Library into a program that is not a library. 226 | 227 | 4. You may copy and distribute the Library (or a portion or 228 | derivative of it, under Section 2) in object code or executable form 229 | under the terms of Sections 1 and 2 above provided that you accompany 230 | it with the complete corresponding machine-readable source code, which 231 | must be distributed under the terms of Sections 1 and 2 above on a 232 | medium customarily used for software interchange. 233 | 234 | If distribution of object code is made by offering access to copy 235 | from a designated place, then offering equivalent access to copy the 236 | source code from the same place satisfies the requirement to 237 | distribute the source code, even though third parties are not 238 | compelled to copy the source along with the object code. 239 | 240 | 5. A program that contains no derivative of any portion of the 241 | Library, but is designed to work with the Library by being compiled or 242 | linked with it, is called a "work that uses the Library". Such a 243 | work, in isolation, is not a derivative work of the Library, and 244 | therefore falls outside the scope of this License. 245 | 246 | However, linking a "work that uses the Library" with the Library 247 | creates an executable that is a derivative of the Library (because it 248 | contains portions of the Library), rather than a "work that uses the 249 | library". The executable is therefore covered by this License. 250 | Section 6 states terms for distribution of such executables. 251 | 252 | When a "work that uses the Library" uses material from a header file 253 | that is part of the Library, the object code for the work may be a 254 | derivative work of the Library even though the source code is not. 255 | Whether this is true is especially significant if the work can be 256 | linked without the Library, or if the work is itself a library. The 257 | threshold for this to be true is not precisely defined by law. 258 | 259 | If such an object file uses only numerical parameters, data 260 | structure layouts and accessors, and small macros and small inline 261 | functions (ten lines or less in length), then the use of the object 262 | file is unrestricted, regardless of whether it is legally a derivative 263 | work. (Executables containing this object code plus portions of the 264 | Library will still fall under Section 6.) 265 | 266 | Otherwise, if the work is a derivative of the Library, you may 267 | distribute the object code for the work under the terms of Section 6. 268 | Any executables containing that work also fall under Section 6, 269 | whether or not they are linked directly with the Library itself. 270 | 271 | 6. As an exception to the Sections above, you may also combine or 272 | link a "work that uses the Library" with the Library to produce a 273 | work containing portions of the Library, and distribute that work 274 | under terms of your choice, provided that the terms permit 275 | modification of the work for the customer's own use and reverse 276 | engineering for debugging such modifications. 277 | 278 | You must give prominent notice with each copy of the work that the 279 | Library is used in it and that the Library and its use are covered by 280 | this License. You must supply a copy of this License. If the work 281 | during execution displays copyright notices, you must include the 282 | copyright notice for the Library among them, as well as a reference 283 | directing the user to the copy of this License. Also, you must do one 284 | of these things: 285 | 286 | a) Accompany the work with the complete corresponding 287 | machine-readable source code for the Library including whatever 288 | changes were used in the work (which must be distributed under 289 | Sections 1 and 2 above); and, if the work is an executable linked 290 | with the Library, with the complete machine-readable "work that 291 | uses the Library", as object code and/or source code, so that the 292 | user can modify the Library and then relink to produce a modified 293 | executable containing the modified Library. (It is understood 294 | that the user who changes the contents of definitions files in the 295 | Library will not necessarily be able to recompile the application 296 | to use the modified definitions.) 297 | 298 | b) Use a suitable shared library mechanism for linking with the 299 | Library. A suitable mechanism is one that (1) uses at run time a 300 | copy of the library already present on the user's computer system, 301 | rather than copying library functions into the executable, and (2) 302 | will operate properly with a modified version of the library, if 303 | the user installs one, as long as the modified version is 304 | interface-compatible with the version that the work was made with. 305 | 306 | c) Accompany the work with a written offer, valid for at 307 | least three years, to give the same user the materials 308 | specified in Subsection 6a, above, for a charge no more 309 | than the cost of performing this distribution. 310 | 311 | d) If distribution of the work is made by offering access to copy 312 | from a designated place, offer equivalent access to copy the above 313 | specified materials from the same place. 314 | 315 | e) Verify that the user has already received a copy of these 316 | materials or that you have already sent this user a copy. 317 | 318 | For an executable, the required form of the "work that uses the 319 | Library" must include any data and utility programs needed for 320 | reproducing the executable from it. However, as a special exception, 321 | the materials to be distributed need not include anything that is 322 | normally distributed (in either source or binary form) with the major 323 | components (compiler, kernel, and so on) of the operating system on 324 | which the executable runs, unless that component itself accompanies 325 | the executable. 326 | 327 | It may happen that this requirement contradicts the license 328 | restrictions of other proprietary libraries that do not normally 329 | accompany the operating system. Such a contradiction means you cannot 330 | use both them and the Library together in an executable that you 331 | distribute. 332 | 333 | 7. You may place library facilities that are a work based on the 334 | Library side-by-side in a single library together with other library 335 | facilities not covered by this License, and distribute such a combined 336 | library, provided that the separate distribution of the work based on 337 | the Library and of the other library facilities is otherwise 338 | permitted, and provided that you do these two things: 339 | 340 | a) Accompany the combined library with a copy of the same work 341 | based on the Library, uncombined with any other library 342 | facilities. This must be distributed under the terms of the 343 | Sections above. 344 | 345 | b) Give prominent notice with the combined library of the fact 346 | that part of it is a work based on the Library, and explaining 347 | where to find the accompanying uncombined form of the same work. 348 | 349 | 8. You may not copy, modify, sublicense, link with, or distribute 350 | the Library except as expressly provided under this License. Any 351 | attempt otherwise to copy, modify, sublicense, link with, or 352 | distribute the Library is void, and will automatically terminate your 353 | rights under this License. However, parties who have received copies, 354 | or rights, from you under this License will not have their licenses 355 | terminated so long as such parties remain in full compliance. 356 | 357 | 9. You are not required to accept this License, since you have not 358 | signed it. However, nothing else grants you permission to modify or 359 | distribute the Library or its derivative works. These actions are 360 | prohibited by law if you do not accept this License. Therefore, by 361 | modifying or distributing the Library (or any work based on the 362 | Library), you indicate your acceptance of this License to do so, and 363 | all its terms and conditions for copying, distributing or modifying 364 | the Library or works based on it. 365 | 366 | 10. Each time you redistribute the Library (or any work based on the 367 | Library), the recipient automatically receives a license from the 368 | original licensor to copy, distribute, link with or modify the Library 369 | subject to these terms and conditions. You may not impose any further 370 | restrictions on the recipients' exercise of the rights granted herein. 371 | You are not responsible for enforcing compliance by third parties with 372 | this License. 373 | 374 | 11. If, as a consequence of a court judgment or allegation of patent 375 | infringement or for any other reason (not limited to patent issues), 376 | conditions are imposed on you (whether by court order, agreement or 377 | otherwise) that contradict the conditions of this License, they do not 378 | excuse you from the conditions of this License. If you cannot 379 | distribute so as to satisfy simultaneously your obligations under this 380 | License and any other pertinent obligations, then as a consequence you 381 | may not distribute the Library at all. For example, if a patent 382 | license would not permit royalty-free redistribution of the Library by 383 | all those who receive copies directly or indirectly through you, then 384 | the only way you could satisfy both it and this License would be to 385 | refrain entirely from distribution of the Library. 386 | 387 | If any portion of this section is held invalid or unenforceable under any 388 | particular circumstance, the balance of the section is intended to apply, 389 | and the section as a whole is intended to apply in other circumstances. 390 | 391 | It is not the purpose of this section to induce you to infringe any 392 | patents or other property right claims or to contest validity of any 393 | such claims; this section has the sole purpose of protecting the 394 | integrity of the free software distribution system which is 395 | implemented by public license practices. Many people have made 396 | generous contributions to the wide range of software distributed 397 | through that system in reliance on consistent application of that 398 | system; it is up to the author/donor to decide if he or she is willing 399 | to distribute software through any other system and a licensee cannot 400 | impose that choice. 401 | 402 | This section is intended to make thoroughly clear what is believed to 403 | be a consequence of the rest of this License. 404 | 405 | 12. If the distribution and/or use of the Library is restricted in 406 | certain countries either by patents or by copyrighted interfaces, the 407 | original copyright holder who places the Library under this License may add 408 | an explicit geographical distribution limitation excluding those countries, 409 | so that distribution is permitted only in or among countries not thus 410 | excluded. In such case, this License incorporates the limitation as if 411 | written in the body of this License. 412 | 413 | 13. The Free Software Foundation may publish revised and/or new 414 | versions of the Lesser General Public License from time to time. 415 | Such new versions will be similar in spirit to the present version, 416 | but may differ in detail to address new problems or concerns. 417 | 418 | Each version is given a distinguishing version number. If the Library 419 | specifies a version number of this License which applies to it and 420 | "any later version", you have the option of following the terms and 421 | conditions either of that version or of any later version published by 422 | the Free Software Foundation. If the Library does not specify a 423 | license version number, you may choose any version ever published by 424 | the Free Software Foundation. 425 | 426 | 14. If you wish to incorporate parts of the Library into other free 427 | programs whose distribution conditions are incompatible with these, 428 | write to the author to ask for permission. For software which is 429 | copyrighted by the Free Software Foundation, write to the Free 430 | Software Foundation; we sometimes make exceptions for this. Our 431 | decision will be guided by the two goals of preserving the free status 432 | of all derivatives of our free software and of promoting the sharing 433 | and reuse of software generally. 434 | 435 | NO WARRANTY 436 | 437 | 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO 438 | WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 439 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 440 | OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY 441 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 442 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 443 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 444 | LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME 445 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 446 | 447 | 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 448 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 449 | AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU 450 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 451 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 452 | LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 453 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 454 | FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF 455 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 456 | DAMAGES. 457 | 458 | END OF TERMS AND CONDITIONS 459 | 460 | How to Apply These Terms to Your New Libraries 461 | 462 | If you develop a new library, and you want it to be of the greatest 463 | possible use to the public, we recommend making it free software that 464 | everyone can redistribute and change. You can do so by permitting 465 | redistribution under these terms (or, alternatively, under the terms of the 466 | ordinary General Public License). 467 | 468 | To apply these terms, attach the following notices to the library. It is 469 | safest to attach them to the start of each source file to most effectively 470 | convey the exclusion of warranty; and each file should have at least the 471 | "copyright" line and a pointer to where the full notice is found. 472 | 473 | 474 | Copyright (C) 475 | 476 | This library is free software; you can redistribute it and/or 477 | modify it under the terms of the GNU Lesser General Public 478 | License as published by the Free Software Foundation; either 479 | version 2.1 of the License, or (at your option) any later version. 480 | 481 | This library is distributed in the hope that it will be useful, 482 | but WITHOUT ANY WARRANTY; without even the implied warranty of 483 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 484 | Lesser General Public License for more details. 485 | 486 | You should have received a copy of the GNU Lesser General Public 487 | License along with this library; if not, write to the Free Software 488 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 489 | 490 | Also add information on how to contact you by electronic and paper mail. 491 | 492 | You should also get your employer (if you work as a programmer) or your 493 | school, if any, to sign a "copyright disclaimer" for the library, if 494 | necessary. Here is a sample; alter the names: 495 | 496 | Yoyodyne, Inc., hereby disclaims all copyright interest in the 497 | library `Frob' (a library for tweaking knobs) written by James Random Hacker. 498 | 499 | , 1 April 1990 500 | Ty Coon, President of Vice 501 | 502 | That's all there is to it! 503 | -------------------------------------------------------------------------------- /src/pythonopenscad/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | Arg, 3 | Circle, 4 | CodeDumper, 5 | CodeDumperForPython, 6 | Color, 7 | ConversionException, 8 | Cube, 9 | Cylinder, 10 | Difference, 11 | DuplicateNamingOfArgs, 12 | Fill, 13 | Hull, 14 | Import, 15 | IndentLevelStackEmpty, 16 | InitializerNotAllowed, 17 | Intersection, 18 | InvalidIndentLevel, 19 | InvalidValue, 20 | InvalidValueForBool, 21 | InvalidValueForStr, 22 | LazyUnion, 23 | Linear_Extrude, 24 | Minkowski, 25 | Mirror, 26 | Module, 27 | Multmatrix, 28 | OSC_FALSE, 29 | OSC_TRUE, 30 | Offset, 31 | OpenScadApiSpecifier, 32 | OscKeyword, 33 | ParameterDefinedMoreThanOnce, 34 | ParameterNotDefined, 35 | Polygon, 36 | Polyhedron, 37 | PoscBase, 38 | PoscBaseException, 39 | PoscParentBase, 40 | Projection, 41 | RequiredParameterNotProvided, 42 | Render, 43 | Resize, 44 | Rotate, 45 | Rotate_Extrude, 46 | Scale, 47 | Sphere, 48 | Square, 49 | Surface, 50 | Text, 51 | TooManyParameters, 52 | Translate, 53 | Union, 54 | VECTOR2_FLOAT, 55 | VECTOR2_FLOAT_DEFAULT_1, 56 | VECTOR3OR4_FLOAT, 57 | VECTOR3_BOOL, 58 | VECTOR3_FLOAT, 59 | VECTOR3_FLOAT_DEFAULT_1, 60 | VECTOR4_FLOAT, 61 | apply_posc_attributes, 62 | bool_strict, 63 | circle, 64 | color, 65 | copy, 66 | cube, 67 | cylinder, 68 | difference, 69 | hull, 70 | intersection, 71 | lazy_union, 72 | linear_extrude, 73 | list_of, 74 | minkowski, 75 | mirror, 76 | module, 77 | multmatrix, 78 | of_set, 79 | offset, 80 | one_of, 81 | polygon, 82 | polyhedron, 83 | projection, 84 | render, 85 | resize, 86 | rotate, 87 | rotate_extrude, 88 | scale, 89 | sphere, 90 | square, 91 | str_strict, 92 | surface, 93 | text, 94 | translate, 95 | union 96 | ) 97 | 98 | from pythonopenscad.modifier import ( 99 | OscModifier, 100 | DISABLE, 101 | SHOW_ONLY, 102 | DEBUG, 103 | TRANSPARENT, 104 | BASE_MODIFIERS_SET, 105 | BASE_MODIFIERS, 106 | InvalidModifier, 107 | PoscModifiers, 108 | PoscRendererBase, 109 | ) 110 | 111 | from pythonopenscad.m3dapi import ( 112 | M3dRenderer, 113 | RenderContext, 114 | RenderContextManifold, 115 | RenderContextCrossSection, 116 | Mode 117 | ) 118 | 119 | # Try to import the viewer module, but don't fail if OpenGL is not available 120 | try: 121 | from pythonopenscad.viewer.viewer import ( 122 | BoundingBox, 123 | Model, 124 | Viewer, 125 | create_viewer_with_models 126 | ) 127 | HAS_VIEWER = True 128 | except ImportError: 129 | HAS_VIEWER = False 130 | 131 | __all__ = [ 132 | "Arg", 133 | "BASE_MODIFIERS", 134 | "BASE_MODIFIERS_SET", 135 | "Circle", 136 | "CodeDumper", 137 | "CodeDumperForPython", 138 | "Color", 139 | "ConversionException", 140 | "Cube", 141 | "Cylinder", 142 | "DEBUG", 143 | "DISABLE", 144 | "Difference", 145 | "DuplicateNamingOfArgs", 146 | "Fill", 147 | "Hull", 148 | "Import", 149 | "IndentLevelStackEmpty", 150 | "InitializerNotAllowed", 151 | "Intersection", 152 | "InvalidIndentLevel", 153 | "InvalidModifier", 154 | "InvalidValue", 155 | "InvalidValueForBool", 156 | "InvalidValueForStr", 157 | "LazyUnion", 158 | "Linear_Extrude", 159 | "Minkowski", 160 | "Mirror", 161 | "Module", 162 | "Multmatrix", 163 | "OSC_FALSE", 164 | "OSC_TRUE", 165 | "Offset", 166 | "OpenScadApiSpecifier", 167 | "OscKeyword", 168 | "OscModifier", 169 | "ParameterDefinedMoreThanOnce", 170 | "ParameterNotDefined", 171 | "Polygon", 172 | "Polyhedron", 173 | "PoscBase", 174 | "PoscBaseException", 175 | "PoscModifiers", 176 | "PoscParentBase", 177 | "PoscRendererBase", 178 | "Projection", 179 | "RequiredParameterNotProvided", 180 | "Render", 181 | "Resize", 182 | "Rotate", 183 | "Rotate_Extrude", 184 | "SHOW_ONLY", 185 | "Scale", 186 | "Sphere", 187 | "Square", 188 | "Surface", 189 | "TRANSPARENT", 190 | "Text", 191 | "TooManyParameters", 192 | "Translate", 193 | "Union", 194 | "VECTOR2_FLOAT", 195 | "VECTOR2_FLOAT_DEFAULT_1", 196 | "VECTOR3OR4_FLOAT", 197 | "VECTOR3_BOOL", 198 | "VECTOR3_FLOAT", 199 | "VECTOR3_FLOAT_DEFAULT_1", 200 | "VECTOR4_FLOAT", 201 | "apply_posc_attributes", 202 | "bool_strict", 203 | "circle", 204 | "color", 205 | "copy", 206 | "cube", 207 | "cylinder", 208 | "difference", 209 | "hull", 210 | "intersection", 211 | "lazy_union", 212 | "linear_extrude", 213 | "list_of", 214 | "minkowski", 215 | "mirror", 216 | "module", 217 | "multmatrix", 218 | "of_set", 219 | "offset", 220 | "one_of", 221 | "polygon", 222 | "polyhedron", 223 | "projection", 224 | "render", 225 | "resize", 226 | "rotate", 227 | "rotate_extrude", 228 | "scale", 229 | "sphere", 230 | "square", 231 | "str_strict", 232 | "surface", 233 | "text", 234 | "translate", 235 | "union", 236 | "M3dRenderer", 237 | "RenderContext", 238 | "RenderContextManifold", 239 | "RenderContextCrossSection", 240 | "Mode" 241 | ] 242 | 243 | # Add viewer classes if available 244 | if HAS_VIEWER: 245 | __all__.extend([ 246 | "BoundingBox", 247 | "Model", 248 | "Viewer", 249 | "create_viewer_with_models" 250 | ]) 251 | -------------------------------------------------------------------------------- /src/pythonopenscad/dask_anno.py: -------------------------------------------------------------------------------- 1 | from dask.delayed import Delayed, delayed as dask_delayed 2 | from typing import TypeVar, Callable, ParamSpec, Generic 3 | 4 | P = ParamSpec('P') # For parameters 5 | R = TypeVar('R') # For return type 6 | 7 | class TypedDelayed(Delayed, Generic[R]): 8 | """A type wrapper for Dask Delayed that preserves type annotation. 9 | This class cannot be instantiated - it's only used for type hints. 10 | """ 11 | 12 | def __init__(self) -> None: 13 | """This class cannot be instantiated""" 14 | raise NotImplementedError("TypedDelayed is a type hint and cannot be instantiated") 15 | 16 | def compute(self, **kwargs) -> R: 17 | """Type hint for compute() that preserves return type""" 18 | raise NotImplementedError("Only used for type annotations") 19 | 20 | 21 | def delayed(func: Callable[P, R], name=None, pure=None, nout=None, traverse=True, **kwargs) \ 22 | -> Callable[P, TypedDelayed[R]]: 23 | """Decorator that returns a type-aware delayed function, replaces dask.delayed.""" 24 | return dask_delayed(func, name=name, pure=pure, nout=nout, traverse=traverse, **kwargs) 25 | 26 | -------------------------------------------------------------------------------- /src/pythonopenscad/modifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | OpenScad modifiers. 3 | """ 4 | 5 | import math 6 | from dataclasses import dataclass, field 7 | from typing import Any 8 | 9 | 10 | @dataclass(frozen=True, repr=False) 11 | class OscModifier(object): 12 | """Defines an OpenScad modifier 13 | 14 | see: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Modifier_Characters 15 | """ 16 | modifier: str = field(compare=True) 17 | name: str = field(compare=False) 18 | 19 | def __repr__(self): 20 | return self.name 21 | 22 | 23 | DISABLE = OscModifier('*', 'DISABLE') # Ignore this subtree 24 | SHOW_ONLY = OscModifier('!', 'SHOW_ONLY') # Ignore the rest of the tree 25 | DEBUG = OscModifier('#', 'DEBUG') # Highlight the object 26 | TRANSPARENT = OscModifier('%', 'TRANSPARENT') # Background modifier 27 | BASE_MODIFIERS = (DISABLE, SHOW_ONLY, DEBUG, TRANSPARENT) 28 | BASE_MODIFIERS_SET = set(BASE_MODIFIERS) 29 | 30 | 31 | # Exceptions for dealing with argument checking. 32 | class PoscBaseException(Exception): 33 | """Base exception functionality""" 34 | 35 | 36 | class InvalidModifier(PoscBaseException): 37 | """Attempting to add or remove an unknown modifier.""" 38 | 39 | class NotParentException(PoscBaseException): 40 | """Attempting to get children of a non-parent.""" 41 | 42 | 43 | class PoscMetadataBase(object): 44 | """Provides medatadata properties. i.e. optional application specific 45 | properties. The metabase_name is printed in comments in the output file 46 | while the descriptor is used to store application specific data.""" 47 | 48 | def getMetadataName(self) -> str: 49 | if not hasattr(self, '_metabase_name'): 50 | return '' 51 | return self._metabase_name 52 | 53 | def setMetadataName(self, value: str): 54 | self._metabase_name = value 55 | return self 56 | 57 | def getDescriptor(self) -> Any: 58 | if not hasattr(self, '_metabase_descriptor'): 59 | return None 60 | return self._metabase_descriptor 61 | 62 | def setDescriptor(self, value: Any): 63 | self._metabase_descriptor = value 64 | return self 65 | 66 | 67 | class PoscModifiers(PoscMetadataBase): 68 | """Functions to add/remove OpenScad modifiers. 69 | 70 | The add_modifier and remove_modifier functions can be chained as they return self. 71 | 72 | e.g. 73 | Cylinder() - Cube().add_modifier(SHOW_ONLY, DEBUG).color('#f00') 74 | 75 | Will create a red 1x1x1 cube with the ! and # OpenScad modifiers. The SHOW_ONLY 76 | modifier will cause the cylinder to not be displayed. 77 | difference() { 78 | cylinder(h=1.0, r=1.0, center=false); 79 | !#cube(size=[1.0, 1.0, 1.0]); 80 | } 81 | 82 | This API is specified to PythonOpenScad. OpenPyScad and SolidPython use different 83 | APIs for this feature. 84 | """ 85 | 86 | def check_is_valid_modifier(self, *modifiers): 87 | if set(modifiers) - BASE_MODIFIERS_SET: 88 | raise InvalidModifier( 89 | '"%r" is not a valid modifier. Muse be one of %r' % (modifiers, BASE_MODIFIERS) 90 | ) 91 | 92 | def add_modifier(self, modifier, *args): 93 | """Adds one of the model modifiers like DISABLE, SHOW_ONLY, DEBUG or TRANSPARENT. 94 | Args: 95 | modifer, *args: The modifier/a being added. Checked for validity. 96 | """ 97 | self.check_is_valid_modifier(modifier, *args) 98 | if not hasattr(self, '_osc_modifier'): 99 | self._osc_modifier = set((modifier,)) 100 | self._osc_modifier.update(args + (modifier,)) 101 | return self 102 | 103 | def remove_modifier(self, modifier, *args): 104 | """Removes a modifiers, one of DISABLE, SHOW_ONLY, DEBUG or TRANSPARENT. 105 | Args: 106 | modifer, *args: The modifier/s being removed. Checked for validity. 107 | """ 108 | self.check_is_valid_modifier(modifier, *args) 109 | if not hasattr(self, '_osc_modifier'): 110 | return 111 | self._osc_modifier.difference_update(args + (modifier,)) 112 | return self 113 | 114 | def has_modifier(self, modifier): 115 | """Checks for presence of a modifier, one of DISABLE, SHOW_ONLY, DEBUG or TRANSPARENT. 116 | Args: 117 | modifer: The modifier being inspected. Checked for validity. 118 | """ 119 | self.check_is_valid_modifier(modifier) 120 | if not hasattr(self, '_osc_modifier'): 121 | return False 122 | return modifier in self._osc_modifier 123 | 124 | def get_modifiers(self): 125 | """Returns the current set of modifiers as an OpenScad equivalent modifier string""" 126 | if not hasattr(self, '_osc_modifier'): 127 | return '' 128 | # Maintains order of modifiers. 129 | return ''.join(i.modifier for i in BASE_MODIFIERS if i in self._osc_modifier) 130 | 131 | def get_modifiers_repr(self): 132 | """Returns the repr() equivalent of the current set or None if none are set.""" 133 | if not hasattr(self, '_osc_modifier'): 134 | return None 135 | if self._osc_modifier: 136 | return repr(self._osc_modifier) 137 | return None 138 | 139 | # Deprecated. 140 | def transparent(self): 141 | self.add_modifier(TRANSPARENT) 142 | return self 143 | 144 | 145 | class UidGen: 146 | """Basic id generator class""" 147 | curid: int = 1 148 | 149 | def genid(self): 150 | self.curid += 1 151 | return str(self.curid) 152 | _UIDGENNY = UidGen() 153 | 154 | @dataclass 155 | class PoscRendererBase(PoscModifiers): 156 | """Base class for renderer interfaces.""" 157 | 158 | @property 159 | def uid(self) -> str: 160 | if not hasattr(self, "_uid"): 161 | self._uid = _UIDGENNY.genid() 162 | return self._uid 163 | 164 | def children(self) -> list["PoscRendererBase"]: 165 | # This should be implemented in PoscParentBase. Illegal to call on 166 | # non-parent types. 167 | raise NotParentException("get_children is not implemented") 168 | 169 | def renderObj(self, renderer: "RendererBase") -> "RenderContextBase": 170 | # This should be implemented in each of the leaf classes. 171 | raise NotImplementedError("renderObj is not implemented") 172 | 173 | def can_have_children(self) -> bool: 174 | """This is a childless node, always returns False.""" 175 | return False 176 | 177 | class RenderContextBase: 178 | """Base class for render context interfaces.""" 179 | 180 | class RendererBase: 181 | """Base class for renderer interfaces.""" 182 | 183 | def renderChildren(self, posc_obj: PoscRendererBase) -> list[RenderContextBase]: 184 | # This should be implemented in each of the renderer classes. 185 | return [child.renderObj(self) for child in posc_obj.children()] 186 | 187 | 188 | 189 | def get_fragments_from_fn_fa_fs(r: float, fn: int | None, fa: float | None, fs: float | None) -> int: 190 | # From openscad/src/utils/calc.cpp 191 | # int Calc::get_fragments_from_r(double r, double fn, double fs, double fa) 192 | # { 193 | # // FIXME: It would be better to refuse to create an object. Let's do more strict error handling 194 | # // in future versions of OpenSCAD 195 | # if (r < GRID_FINE || std::isinf(fn) || std::isnan(fn)) return 3; 196 | # if (fn > 0.0) return static_cast(fn >= 3 ? fn : 3); 197 | # return static_cast(ceil(fmax(fmin(360.0 / fa, r * 2 * M_PI / fs), 5))); 198 | # } 199 | GRID_FINE = 0.00000095367431640625 200 | if r < GRID_FINE or fn is not None and (math.isinf(fn) or math.isnan(fn)): 201 | return 3 202 | if fn is not None and fn > 0: 203 | return max(fn, 3) 204 | if fa is None: 205 | fa = 1 206 | if fs is None: 207 | fs = 1 208 | return max(5, math.ceil(max(min(360.0 / fa, r * 2 * math.pi / fs), 5))) -------------------------------------------------------------------------------- /src/pythonopenscad/posc_main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import os 4 | import inspect 5 | from typing import List, Union, Callable, Optional, Tuple 6 | import manifold3d as m3d 7 | 8 | # Assume these imports are valid within the project structure 9 | from datatrees import datatree, Node, dtfield 10 | import pythonopenscad as posc 11 | from pythonopenscad.m3dapi import M3dRenderer, RenderContextManifold, manifold_to_stl 12 | from pythonopenscad.viewer.viewer import Viewer, Model 13 | from pythonopenscad.modifier import PoscRendererBase 14 | 15 | 16 | 17 | @datatree 18 | class PoscModel: 19 | """Wraps a PoscBase object, providing lazy rendering and access to results.""" 20 | item: posc.PoscBase | Callable[[], posc.PoscBase] 21 | name: str 22 | _render_context: RenderContextManifold | None = dtfield(default=None, init=False) 23 | _posc_obj: posc.PoscBase | None = dtfield(default=None, init=False) 24 | _solid_manifold: m3d.Manifold | None = dtfield(default=None, init=False) 25 | _shell_manifold: m3d.Manifold | None = dtfield(default=None, init=False) 26 | 27 | def __post_init__(self): 28 | if isinstance(self.item, PoscRendererBase): 29 | self._posc_obj = self.item 30 | 31 | def get_posc_obj(self) -> posc.PoscBase: 32 | """Retrieves or computes the PoscBase object.""" 33 | if self._posc_obj is None: 34 | self._posc_obj = self.item() 35 | return self._posc_obj 36 | 37 | def render(self) -> RenderContextManifold: 38 | """Renders the PoscBase object using M3dRenderer if not already cached.""" 39 | if self._render_context is None: 40 | posc_obj = self.get_posc_obj() 41 | try: 42 | self._render_context = posc_obj.renderObj(M3dRenderer()) 43 | except Exception as e: 44 | print(f"Error rendering object '{self.name}': {e}", file=sys.stderr) 45 | raise 46 | return self._render_context 47 | 48 | def get_solid_manifold(self) -> m3d.Manifold: 49 | """Returns the solid manifold from the render context.""" 50 | context = self.render() 51 | self._solid_manifold = context.get_solid_manifold() 52 | return self._solid_manifold 53 | 54 | def get_shell_manifold(self) -> m3d.Manifold | None: 55 | """Returns the shell manifold from the render context.""" 56 | context = self.render() 57 | self._shell_manifold = context.get_shell_manifold() 58 | return self._shell_manifold 59 | 60 | def get_viewer_models(self, include_shells: bool = False) -> list[Model]: 61 | """Returns a viewer Model for the solid manifold.""" 62 | manifold = self.get_solid_manifold() 63 | if not include_shells: 64 | return [Model.from_manifold(manifold)] 65 | else: 66 | shell_manifold = self.get_shell_manifold() 67 | return [Model.from_manifold(manifold), 68 | Model.from_manifold(shell_manifold, has_alpha_lt1=True)] 69 | 70 | 71 | def parse_color(color_str: str) -> Optional[Tuple[float, float, float, float]]: 72 | """Parses a color string (e.g., "1.0,0.5,0.0") into RGBA tuple.""" 73 | try: 74 | parts = [float(p.strip()) for p in color_str.split(',')] 75 | if len(parts) == 3: 76 | return (*parts, 1.0) # Add alpha if only RGB provided 77 | elif len(parts) == 4: 78 | return parts 79 | else: 80 | raise ValueError("Color must have 3 (RGB) or 4 (RGBA) components.") 81 | except Exception as e: 82 | print(f"Error parsing color '{color_str}': {e}", file=sys.stderr) 83 | return None 84 | 85 | def add_bool_arg(parser, name, help_text, default=False): 86 | parser.add_argument( 87 | f"--{name}", 88 | action="store_true", 89 | help=help_text 90 | ) 91 | 92 | parser.add_argument( 93 | f"--no-{name}", 94 | action="store_false", 95 | dest=name, 96 | help=f"Disable: {help_text}" 97 | ) 98 | parser.set_defaults(**{name: default}) 99 | 100 | @datatree 101 | class PoscMainRunner: 102 | """Parses arguments and runs actions for viewing/exporting Posc models.""" 103 | items: list[Callable[[], posc.PoscBase] | posc.PoscBase] 104 | script_path: str 105 | _args: argparse.Namespace | None = dtfield(default=None, init=False) 106 | posc_models: List[PoscModel] = dtfield(default_factory=list, init=False) 107 | parser: argparse.ArgumentParser | None = dtfield( 108 | self_default=lambda s: s._make_parser(), init=False) 109 | output_base: str | None = dtfield(default=None, init=False) 110 | 111 | @property 112 | def args(self) -> argparse.Namespace: 113 | if self._args is None: 114 | self.parse_args() 115 | return self._args 116 | 117 | def _make_parser(self) -> argparse.ArgumentParser: 118 | parser = argparse.ArgumentParser(description="View or export PythonOpenSCAD objects.") 119 | 120 | # --- Input/Output Control --- 121 | parser.add_argument( 122 | "--output-base", 123 | type=str, 124 | default=None, 125 | help="Base name for output files (STL, PNG). Defaults to the name of the calling script." 126 | ) 127 | add_bool_arg(parser, "solids", "Only process solid geometry (ignore shells/debug geometry).", default=False) 128 | 129 | # --- Actions --- 130 | add_bool_arg(parser, "view", "View the models in the OpenGL viewer.", default=True) 131 | add_bool_arg(parser, "stl", "Export solid models to STL files.", default=False) 132 | add_bool_arg(parser, "png", "Save a PNG image using the viewer settings (requires viewer modification for non-interactive use).", default=False) 133 | add_bool_arg(parser, "scad", "Export to SCAD file (Not implemented).", default=False) 134 | add_bool_arg(parser, "wireframe", "Start viewer in wireframe mode.", default=False) 135 | add_bool_arg(parser, "backface-culling", "Disable backface culling in the viewer.", default=True) 136 | add_bool_arg(parser, "bounding-box-mode", "Initial bounding box mode (0=off, 1=wireframe, 2=solid).", default=False) 137 | add_bool_arg(parser, "zbuffer-occlusion", "Disable Z-buffer occlusion for wireframes.", default=True) 138 | add_bool_arg(parser, "coalesce", "Model coalescing (may impact transparency rendering).", default=True) 139 | 140 | # --- Viewer Options --- 141 | parser.add_argument("--width", type=int, default=800, help="Viewer window width.") 142 | parser.add_argument("--height", type=int, default=600, help="Viewer window height.") 143 | parser.add_argument("--title", type=str, default=None, help="Viewer window title.") 144 | parser.add_argument( 145 | "--projection", 146 | type=str, 147 | choices=['perspective', 'orthographic'], 148 | default='perspective', 149 | help="Initial viewer projection mode." 150 | ) 151 | parser.add_argument( 152 | "--bg-color", 153 | type=str, 154 | default="0.98,0.98,0.85,1.0", # Default from Viewer 155 | help="Viewer background color as comma-separated RGBA floats (e.g., '0.1,0.1,0.1,1.0')." 156 | ) 157 | 158 | return parser 159 | 160 | def parse_args(self): 161 | 162 | self._args = self.parser.parse_args() 163 | 164 | def check_args(self): 165 | # Determine default output base name 166 | if self.args.output_base is None: 167 | self.args.output_base = os.path.splitext(os.path.basename(self.script_path))[0] 168 | 169 | # Set default title if not provided 170 | if self.args.title is None: 171 | self.args.title = f"Posc View: {os.path.basename(self.script_path)}" 172 | 173 | # Parse background color 174 | self.args.parsed_bg_color = parse_color(self.args.bg_color) 175 | if self.args.parsed_bg_color is None: 176 | # Fallback to default if parsing fails 177 | self.args.parsed_bg_color = parse_color("0.98,0.98,0.85,1.0") 178 | 179 | 180 | def _prepare_models(self): 181 | """Instantiate PoscModel objects from the input items.""" 182 | self.posc_models = [] 183 | for i, item in enumerate(self.items): 184 | # Try to get a name from the item if possible (e.g., function name) 185 | name = f"item_{i}" 186 | 187 | self.posc_models.append(PoscModel(item, name=name)) 188 | 189 | 190 | def run(self): 191 | self.check_args() 192 | self._prepare_models() 193 | 194 | if not self.posc_models: 195 | print("No models were generated or provided.", file=sys.stderr) 196 | return 197 | 198 | actions_requested = self.args.view or self.args.stl or self.args.png or self.args.scad 199 | if not actions_requested: 200 | print("No action specified. Use --view, --stl, --png etc.", file=sys.stderr) 201 | return 202 | 203 | # --- SCAD Action --- 204 | if self.args.scad: 205 | lazy_union = posc.LazyUnion()(*[model.get_posc_obj() for model in self.posc_models]) 206 | with open(f"{self.args.output_base}.scad", "w") as f: 207 | lazy_union.dump(f) 208 | 209 | # --- STL Action --- 210 | if self.args.stl: 211 | exported_count = 0 212 | for i, model in enumerate(self.posc_models): 213 | solid_manifold = model.get_solid_manifold() 214 | # TODO: Handle shell manifolds. 215 | if solid_manifold: 216 | suffix = f"_{i}" if len(self.posc_models) > 1 else "" 217 | filename = f"{self.args.output_base}{suffix}.stl" 218 | manifold_to_stl(solid_manifold, filename=filename, update_normals=False) 219 | print(f"Exported STL: {filename}") 220 | exported_count += 1 221 | 222 | if exported_count == 0: 223 | print("Warning: No solid geometry found to export to STL.", file=sys.stderr) 224 | 225 | 226 | # --- Viewer / PNG Actions --- 227 | if self.args.view or self.args.png: 228 | 229 | viewer_models: List[Model] = [] 230 | for model in self.posc_models: 231 | viewer_models.extend(model.get_viewer_models(include_shells=not self.args.solids)) 232 | 233 | if not viewer_models: 234 | print("Warning: No viewable geometry was generated.", file=sys.stderr) 235 | # Don't proceed if there's nothing to view/save 236 | if not self.args.stl: # Only exit if STL wasn't the primary goal 237 | return 238 | else: # If STL was done, but nothing to view, that's okay 239 | print("STL export completed, but no models to view/save as PNG.") 240 | 241 | 242 | if self.args.view or self.args.png: 243 | viewer: Viewer = Viewer( 244 | models=viewer_models, 245 | width=self.args.width, 246 | height=self.args.height, 247 | title=self.args.title, 248 | background_color=self.args.parsed_bg_color, 249 | projection_mode=self.args.projection, 250 | wireframe_mode=self.args.wireframe, 251 | backface_culling=self.args.backface_culling, 252 | bounding_box_mode=self.args.bounding_box_mode, 253 | zbuffer_occlusion=self.args.zbuffer_occlusion, 254 | use_coalesced_models=self.args.coalesce 255 | ) 256 | 257 | if self.args.png: 258 | filename = f"{self.args.output_base}.png" 259 | viewer.offscreen_render(filename) 260 | print(f"Saved PNG: {filename}") 261 | 262 | if self.args.view: 263 | print(Viewer.VIEWER_HELP_TEXT) 264 | print(f"triangle count = {viewer.num_triangles()}") 265 | viewer.run() # Enters GLUT main loop 266 | 267 | def posc_main(items: List[Union[Callable[[], posc.PoscBase], posc.PoscBase]]): 268 | """ 269 | Main entry point for processing PythonOpenSCAD objects via command line. 270 | 271 | Args: 272 | items: A list containing PoscBase objects or lambda functions 273 | that return PoscBase objects. 274 | """ 275 | # Get the file path of the script that called posc_main 276 | try: 277 | calling_frame = inspect.stack()[1] 278 | script_path = calling_frame.filename 279 | except IndexError: 280 | script_path = "unknown_script.py" # Fallback if stack inspection fails 281 | 282 | runner = PoscMainRunner(items, script_path) 283 | runner.run() 284 | 285 | 286 | # Example usage (would typically be in a user's script): 287 | if __name__ == "__main__": 288 | 289 | if len(sys.argv) <= 1: 290 | # Simulate command line arguments for testing 291 | # In real use, these come from the actual command line 292 | sys.argv = [ 293 | sys.argv[0], # Script name 294 | "--view", # Action: View the models 295 | "--scad", # Action: Export SCAD 296 | "--stl", # Action: Export STL 297 | "--png", # Action: Save PNG 298 | "--no-wireframe", # Viewer option 299 | "--bg-color", "1,1,1,1.0", # Viewer option 300 | "--projection", "orthographic", # Viewer option 301 | # "--output-base", "my_test_output", # Output name override 302 | ] 303 | 304 | print(f"Running example with simulated args: {' '.join(sys.argv[1:])}") 305 | 306 | # Define some example objects/lambdas 307 | def create_sphere(): 308 | return posc.Sphere(r=5).add_modifier(posc.DEBUG) 309 | 310 | my_cube = posc.Cube(10) 311 | 312 | posc_main([my_cube, create_sphere]) 313 | print("Example finished.") 314 | -------------------------------------------------------------------------------- /src/pythonopenscad/text_utils.py: -------------------------------------------------------------------------------- 1 | from datatrees.datatrees import datatree, dtfield 2 | import anchorscad_lib.linear as l 3 | import numpy as np 4 | import logging 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | def extentsof(p: np.ndarray) -> np.ndarray: 10 | return np.array((p.min(axis=0), p.max(axis=0))) 11 | 12 | def to_gvector(np_array): 13 | if len(np_array) == 2: 14 | return l.GVector([np_array[0], np_array[1], 0, 1]) 15 | else: 16 | return l.GVector(np_array) 17 | 18 | 19 | EPSILON = 1e-6 20 | 21 | @datatree(frozen=True) 22 | class CubicSpline: 23 | """Cubic spline evaluator, extents and inflection point finder.""" 24 | 25 | p: object = dtfield(doc="The control points for the spline, shape (4, N).") 26 | dimensions: int = dtfield( 27 | self_default=lambda s: np.asarray(s.p).shape[1], # Get dim from shape 28 | init=True, 29 | doc="The number of dimensions in the spline.", 30 | ) 31 | coefs: np.ndarray=dtfield(init=False) 32 | 33 | COEFFICIENTS = np.array([ 34 | [-1.0, 3, -3, 1], 35 | [3, -6, 3, 0], 36 | [-3, 3, 0, 0], 37 | [1, 0, 0, 0], 38 | ]) # Shape (4, 4) 39 | 40 | # @staticmethod # For some reason this breaks on Raspberry Pi OS. 41 | def _dcoeffs_builder(dims): 42 | # ... (keep as before) ... 43 | zero_order_derivative_coeffs = np.array([[1.0] * dims, [1] * dims, [1] * dims, [1] * dims]) 44 | derivative_coeffs = np.array([[3.0] * dims, [2] * dims, [1] * dims, [0] * dims]) 45 | second_derivative = np.array([[6] * dims, [2] * dims, [0] * dims, [0] * dims]) 46 | return (zero_order_derivative_coeffs, derivative_coeffs, second_derivative) 47 | 48 | DERIVATIVE_COEFFS = tuple(( 49 | _dcoeffs_builder(1), 50 | _dcoeffs_builder(2), 51 | _dcoeffs_builder(3), 52 | )) 53 | 54 | def _dcoeffs(self, deivative_order): 55 | # ... (keep as before) ... 56 | if 1 <= self.dimensions <= len(self.DERIVATIVE_COEFFS): 57 | return self.DERIVATIVE_COEFFS[self.dimensions - 1][deivative_order] 58 | else: 59 | log.warning( 60 | f"Unsupported dimension {self.dimensions} for derivative coeffs, using dim 2" 61 | ) 62 | return self.DERIVATIVE_COEFFS[1][deivative_order] # Default to 2D 63 | 64 | def __post_init__(self): 65 | # Ensure p is a numpy array (should be (4, dims)) 66 | p_arr = np.asarray(self.p, dtype=float) 67 | if p_arr.shape[0] != 4 or p_arr.ndim != 2: 68 | raise ValueError( 69 | f"CubicSpline control points 'p' must have shape (4, dims), got {p_arr.shape}" 70 | ) 71 | object.__setattr__(self, "p", p_arr) 72 | # Calculate coefficients: (4, 4) @ (4, dims) -> (4, dims) 73 | object.__setattr__(self, "coefs", np.matmul(self.COEFFICIENTS, self.p)) 74 | 75 | def _make_ta3(self, t): 76 | """ 77 | Create properly shaped array of t powers for vectorized evaluation. 78 | Creates appropriate arrays for matrix multiplication. 79 | """ 80 | t_arr = np.asarray(t) 81 | 82 | if t_arr.ndim == 0: # Single t value 83 | # Create powers [t³, t², t, 1] - shape (4,) 84 | t2 = t_arr * t_arr 85 | t3 = t2 * t_arr 86 | return np.array([t3, t2, t_arr, 1.0]) 87 | else: # Array of t values 88 | # Create powers with shape (4, len(t)) 89 | t2 = t_arr * t_arr 90 | t3 = t2 * t_arr 91 | t_powers = np.vstack([t3, t2, t_arr, np.ones_like(t_arr)]) 92 | return t_powers # Shape (4, N) 93 | 94 | def _make_ta2(self, t): 95 | # t2 = t * t 96 | # # Correct usage: Create column vector and tile horizontally 97 | # t_powers = np.array([[t2], [t], [1], [0]]) # Shape (4, 1) 98 | # ta = np.tile(t_powers, (1, self.dimensions)) # Shape (4, dims) 99 | # return ta 100 | 101 | """ 102 | Create properly shaped array of t powers for vectorized evaluation. 103 | Creates appropriate arrays for matrix multiplication. 104 | """ 105 | t_arr = np.asarray(t) 106 | 107 | if t_arr.ndim == 0: # Single t value 108 | # Create powers [t², t, 1, 0] - shape (4,) 109 | t2 = t * t 110 | return np.array([t2, t_arr, 1.0, 0]) 111 | else: # Array of t values 112 | # Create powers with shape (4, len(t)) 113 | t2 = t_arr * t_arr 114 | t_powers = np.vstack([t2, t_arr, np.ones_like(t_arr), np.zeros_like(t_arr)]) 115 | return t_powers # Shape (4, N) 116 | 117 | # --- evaluate (Iterative version as requested by user) --- 118 | def evaluate(self, t): 119 | """ 120 | Evaluates the spline at one or more t values. 121 | 122 | Args: 123 | t: Scalar or array of t values where to evaluate the spline 124 | 125 | Returns: 126 | For scalar t: array of shape (dimensions,) with the point coordinates 127 | For array t: array of shape (len(t), dimensions) with point coordinates 128 | """ 129 | t_arr = np.asarray(t) 130 | 131 | # Get powers with shape (4, N) 132 | powers = self._make_ta3(t_arr) 133 | 134 | # Matrix multiply coefficients (4, dims).T with powers (4, N) 135 | # Result shape: (dims, N) 136 | result = np.matmul(self.coefs.T, powers) 137 | 138 | # Transpose to get shape (N, dims) as expected 139 | return result.T 140 | 141 | # --- Keep find_roots, curve_maxima_minima_t, curve_inflexion_t --- 142 | @classmethod 143 | def find_roots(cls, a, b, c, *, t_range: tuple[float, float] = (0.0, 1.0)): 144 | # ... (keep as before, using np.isclose maybe) ... 145 | if np.isclose(a, 0): 146 | if np.isclose(b, 0): 147 | return () 148 | t = -c / b 149 | return (t,) if t_range[0] - EPSILON <= t <= t_range[1] + EPSILON else () 150 | b2_4ac = b * b - 4 * a * c 151 | if b2_4ac < 0 and not np.isclose(b2_4ac, 0): 152 | return () 153 | elif b2_4ac < 0: 154 | b2_4ac = 0 155 | sqrt_b2_4ac = np.sqrt(b2_4ac) 156 | two_a = 2 * a 157 | if np.isclose(two_a, 0): # Avoid division by zero if a is extremely small 158 | return () 159 | values = ((-b + sqrt_b2_4ac) / two_a, (-b - sqrt_b2_4ac) / two_a) 160 | return tuple(t for t in values if t_range[0] - EPSILON <= t <= t_range[1] + EPSILON) 161 | 162 | def curve_maxima_minima_t(self, t_range: tuple[float, float] = (0.0, 1.0)): 163 | d_coefs_scaled = self.coefs * self._dcoeffs(1) # Shape (4, dims) 164 | # Derivative coeffs are 3A, 2B, C (rows 0, 1, 2) 165 | return dict( 166 | (i, self.find_roots(*(d_coefs_scaled[0:3, i]), t_range=t_range)) 167 | for i in range(self.dimensions) 168 | ) 169 | 170 | def curve_inflexion_t(self, t_range: tuple[float, float] = (0.0, 1.0)): 171 | d2_coefs_scaled = self.coefs * self._dcoeffs(2) # Shape (4, dims) 172 | # Second derivative coeffs are 6A, 2B (rows 0, 1) 173 | # Solve 6At + 2B = 0 -> find_roots(6A, 2B) 174 | return dict( 175 | ( 176 | i, 177 | QuadraticSpline.find_roots(*(d2_coefs_scaled[0:2, i]), t_range=t_range), 178 | ) # Use linear root finder 179 | for i in range(self.dimensions) 180 | ) 181 | 182 | def derivative(self, t): 183 | t_arr = np.asarray(t) 184 | 185 | # Get powers with shape (4, N) 186 | powers = self._make_ta2(t_arr) 187 | 188 | # Matrix multiply coefficients (4, dims).T with powers (4, N) 189 | # Result shape: (dims, N) 190 | coefs = np.multiply(self.coefs, self._dcoeffs(1)) 191 | result = np.matmul(coefs.T, powers) 192 | 193 | # Transpose to get shape (N, dims) as expected 194 | return -result.T 195 | 196 | def normal2d(self, t, dims=[0, 1]): 197 | t_arr = np.asarray(t) 198 | if t_arr.ndim == 0: 199 | d = self.derivative(t_arr) 200 | if d.shape[0] < 2: 201 | return np.array([0.0, 0.0]) 202 | vr = np.array([d[dims[1]], -d[dims[0]]]) 203 | mag = np.linalg.norm(vr) 204 | return vr / mag if mag > EPSILON else np.array([0.0, 0.0]) 205 | else: 206 | # Vectorized implementation 207 | d = self.derivative(t_arr) # shape (N, dims) 208 | 209 | # Check if we have enough dimensions 210 | if d.shape[1] < 2: 211 | return np.zeros((len(t_arr), 2)) 212 | 213 | # Create normals array: swap and negate to get perpendicular vector 214 | vr = np.column_stack([d[:, dims[1]], -d[:, dims[0]]]) # shape (N, 2) 215 | 216 | # Calculate magnitudes 217 | mag = np.linalg.norm(vr, axis=1) # shape (N,) 218 | 219 | # Create result array 220 | result = np.zeros_like(vr) # shape (N, 2) 221 | 222 | # Only normalize where magnitude is significant 223 | mask = mag > EPSILON 224 | if np.any(mask): 225 | # Properly reshape mag for broadcasting 226 | result[mask] = vr[mask] / mag[mask, np.newaxis] 227 | 228 | return result 229 | 230 | def extremes(self): 231 | roots = self.curve_maxima_minima_t() 232 | t_values = {0.0, 1.0} 233 | for v in roots.values(): 234 | t_values.update(v) 235 | valid_t_values = sorted([t for t in t_values if 0.0 - EPSILON <= t <= 1.0 + EPSILON]) 236 | clamped_t_values = np.clip(valid_t_values, 0.0, 1.0) 237 | if not clamped_t_values.size: 238 | return np.empty((0, self.dimensions)) 239 | # Use iterative evaluate 240 | return np.array([self.evaluate(t) for t in clamped_t_values]) 241 | 242 | def extents(self): 243 | extr = self.extremes() 244 | return extentsof(extr) 245 | 246 | def transform(self, m: l.GMatrix) -> 'CubicSpline': 247 | '''Returns a new spline transformed by the matrix m.''' 248 | new_p = list((m * to_gvector(p)).A[0:self.dimensions] for p in self.p) 249 | return CubicSpline(np.array(new_p), self.dimensions) 250 | 251 | def azimuth_t(self, angle: float | l.Angle=0, t_end: bool=False, 252 | t_range: tuple[float, float]=(0.0, 1.0)) -> tuple[float, ...]: 253 | '''Returns the list of t where the tangent is at the given angle from the beginning of the 254 | given t_range. The angle is in degrees or Angle.''' 255 | 256 | angle = l.angle(angle) 257 | 258 | start_slope = self.normal2d(t_range[1 if t_end else 0]) 259 | start_rot: l.GMatrix = l.rotZ(sinr_cosr=(start_slope[1], -start_slope[0])) 260 | 261 | qs: CubicSpline = self.transform(l.rotZ(angle.inv()) * start_rot) 262 | 263 | roots = qs.curve_maxima_minima_t(t_range) 264 | 265 | return sorted(roots[0]) 266 | 267 | 268 | @datatree(frozen=True) 269 | class QuadraticSpline: 270 | """Quadratic spline evaluator, extents and inflection point finder.""" 271 | 272 | p: object = dtfield(doc="The control points for the spline, shape (3, N).") 273 | dimensions: int = dtfield( 274 | self_default=lambda s: np.asarray(s.p).shape[1], # Get dim from shape 275 | init=True, 276 | doc="The number of dimensions in the spline.", 277 | ) 278 | coefs: np.ndarray=dtfield(init=False) 279 | 280 | COEFFICIENTS = np.array([[1.0, -2, 1], [-2.0, 2, 0], [1.0, 0, 0]]) # Shape (3, 3) 281 | 282 | # @staticmethod # For some reason this breaks on Raspberry Pi OS. 283 | def _dcoeffs_builder(dims): 284 | # ... (keep as before) ... 285 | zero_order_derivative_coeffs = np.array([[1.0] * dims, [1] * dims, [1] * dims]) 286 | derivative_coeffs = np.array([[2] * dims, [1] * dims, [0] * dims]) 287 | second_derivative = np.array([[2] * dims, [0] * dims, [0] * dims]) 288 | return (zero_order_derivative_coeffs, derivative_coeffs, second_derivative) 289 | 290 | DERIVATIVE_COEFFS = tuple(( 291 | _dcoeffs_builder(1), 292 | _dcoeffs_builder(2), 293 | _dcoeffs_builder(3), 294 | )) 295 | 296 | def _dcoeffs(self, deivative_order): 297 | # ... (keep as before) ... 298 | if 1 <= self.dimensions <= len(self.DERIVATIVE_COEFFS): 299 | return self.DERIVATIVE_COEFFS[self.dimensions - 1][deivative_order] 300 | else: 301 | log.warning( 302 | f"Unsupported dimension {self.dimensions} for derivative coeffs, using dim 2" 303 | ) 304 | return self.DERIVATIVE_COEFFS[1][deivative_order] # Default to 2D 305 | 306 | def __post_init__(self): 307 | # Ensure p is a numpy array (should be (3, dims)) 308 | p_arr = np.asarray(self.p, dtype=float) 309 | if p_arr.shape[0] != 3 or p_arr.ndim != 2: 310 | raise ValueError( 311 | f"QuadraticSpline control points 'p' must have shape (3, dims), got {p_arr.shape}" 312 | ) 313 | object.__setattr__(self, "p", p_arr) 314 | # Calculate coefficients: (3, 3) @ (3, dims) -> (3, dims) 315 | object.__setattr__(self, "coefs", np.matmul(self.COEFFICIENTS, self.p)) 316 | 317 | def _qmake_ta2(self, t): 318 | """ 319 | Create properly shaped array of t powers for vectorized evaluation. 320 | Creates appropriate arrays for matrix multiplication. 321 | """ 322 | t_arr = np.asarray(t) 323 | 324 | if t_arr.ndim == 0: # Single t value 325 | # Create powers [t², t, 1] - shape (3,) 326 | return np.array([t_arr**2, t_arr, 1.0]) 327 | else: # Array of t values 328 | # Create powers with shape (3, len(t)) 329 | t_powers = np.vstack([t_arr**2, t_arr, np.ones_like(t_arr)]) 330 | return t_powers # Shape (3, N) 331 | 332 | def _qmake_ta1(self, t): 333 | # Correct usage: Create column vector and tile horizontally 334 | t_powers = np.array([[t], [1], [0]]) # Shape (3, 1) 335 | ta = np.tile(t_powers, (1, self.dimensions)) # Shape (3, dims) 336 | return ta 337 | 338 | def evaluate(self, t): 339 | """ 340 | Evaluates the spline at one or more t values. 341 | 342 | Args: 343 | t: Scalar or array of t values where to evaluate the spline 344 | 345 | Returns: 346 | For scalar t: array of shape (dimensions,) with the point coordinates 347 | For array t: array of shape (len(t), dimensions) with point coordinates 348 | """ 349 | t_arr = np.asarray(t) 350 | 351 | if t_arr.ndim == 0: # Single scalar t 352 | # Get powers [t², t, 1] - shape (3,) 353 | powers = self._qmake_ta2(t_arr) 354 | 355 | # Matrix multiply coefficients (3, dims) with powers (3,) 356 | # Result shape: (dims,) 357 | return np.dot(self.coefs.T, powers) 358 | else: # Multiple t values 359 | # Get powers [t²_1...t²_n, t_1...t_n, 1...1] - shape (3, N) 360 | powers = self._qmake_ta2(t_arr) 361 | 362 | # Matrix multiply coefficients (3, dims) with powers (3, N) 363 | # Result shape: (dims, N) 364 | result = np.matmul(self.coefs.T, powers) 365 | 366 | # Transpose to get shape (N, dims) as expected 367 | return result.T 368 | 369 | @classmethod 370 | def find_roots(cls, a, b, *, t_range: tuple[float, float] = (0.0, 1.0)): 371 | if np.isclose(a, 0): 372 | return () 373 | t = -b / a 374 | return (t,) if t_range[0] - EPSILON <= t <= t_range[1] + EPSILON else () 375 | 376 | def curve_maxima_minima_t(self, t_range: tuple[float, float] = (0.0, 1.0)): 377 | d_coefs_scaled = self.coefs * self._dcoeffs(1) # Shape (3, dims) 378 | # Derivative coeffs are 2A, B (rows 0, 1) 379 | return dict( 380 | (i, self.find_roots(*(d_coefs_scaled[0:2, i]), t_range=t_range)) 381 | for i in range(self.dimensions) 382 | ) 383 | 384 | def curve_inflexion_t(self, t_range: tuple[float, float] = (0.0, 1.0)): 385 | return dict((i, ()) for i in range(self.dimensions)) # No inflection points 386 | 387 | def derivative(self, t): 388 | """ 389 | Calculates the derivative of the spline at one or more t values. 390 | 391 | Args: 392 | t: Scalar or array of t values 393 | 394 | Returns: 395 | For scalar t: array of shape (dimensions,) with the derivatives 396 | For array t: array of shape (len(t), dimensions) with derivatives 397 | """ 398 | t_arr = np.asarray(t) 399 | 400 | # Coefs are [A, B, C] for each dimension (shape (3, dims)) 401 | A = self.coefs[0] # Shape (dims,) 402 | B = self.coefs[1] # Shape (dims,) 403 | 404 | # Derivative coefficients: [2A, B, 0] 405 | deriv_poly_coefs = np.vstack([2 * A, B, np.zeros_like(A)]).T # Shape (dims, 3) 406 | 407 | if t_arr.ndim == 0: # Scalar t 408 | # For a single t, create powers [t, 1, 0] - shape (3,) 409 | t_powers = np.array([t_arr, 1.0, 0.0]) 410 | 411 | # Matrix multiply: (dims, 3) @ (3,) -> (dims,) 412 | return np.dot(deriv_poly_coefs, t_powers) 413 | else: # Array of t values 414 | # For multiple t values, create powers shape (3, N) 415 | t_powers = np.vstack([t_arr, np.ones_like(t_arr), np.zeros_like(t_arr)]) 416 | 417 | # Matrix multiply: (dims, 3) @ (3, N) -> (dims, N) 418 | result = np.matmul(deriv_poly_coefs, t_powers) 419 | 420 | # Transpose to shape (N, dims) 421 | return result.T 422 | 423 | def normal2d(self, t, dims=[0, 1]): 424 | t_arr = np.asarray(t) 425 | if t_arr.ndim == 0: 426 | d = self.derivative(t_arr) 427 | if d.shape[0] < 2: 428 | return np.array([0.0, 0.0]) 429 | vr = np.array([d[dims[1]], -d[dims[0]]]) 430 | mag = np.linalg.norm(vr) 431 | return vr / mag if mag > EPSILON else np.array([0.0, 0.0]) 432 | else: 433 | # Vectorized implementation 434 | d = self.derivative(t_arr) # shape (N, dims) 435 | 436 | # Check if we have enough dimensions 437 | if d.shape[1] < 2: 438 | return np.zeros((len(t_arr), 2)) 439 | 440 | # Create normals array: swap and negate to get perpendicular vector 441 | vr = np.column_stack([d[:, dims[1]], -d[:, dims[0]]]) # shape (N, 2) 442 | 443 | # Calculate magnitudes 444 | mag = np.linalg.norm(vr, axis=1) # shape (N,) 445 | 446 | # Create result array 447 | result = np.zeros_like(vr) # shape (N, 2) 448 | 449 | # Only normalize where magnitude is significant 450 | mask = mag > EPSILON 451 | if np.any(mask): 452 | # Properly reshape mag for broadcasting 453 | result[mask] = vr[mask] / mag[mask, np.newaxis] 454 | 455 | return result 456 | 457 | def extremes(self): 458 | roots = self.curve_maxima_minima_t() 459 | t_values = {0.0, 1.0} 460 | for v in roots.values(): 461 | t_values.update(v) 462 | valid_t_values = sorted([t for t in t_values if 0.0 - EPSILON <= t <= 1.0 + EPSILON]) 463 | clamped_t_values = np.clip(valid_t_values, 0.0, 1.0) 464 | if not clamped_t_values.size: 465 | return np.empty((0, self.dimensions)) 466 | return np.array([self.evaluate(t) for t in clamped_t_values]) 467 | 468 | def extents(self): 469 | extr = self.extremes() 470 | return extentsof(extr) 471 | 472 | 473 | def transform(self, m: l.GMatrix) -> 'QuadraticSpline': 474 | '''Returns a new spline transformed by the matrix m.''' 475 | new_p = list((m * to_gvector(p)).A[0:self.dimensions] for p in self.p) 476 | return QuadraticSpline(np.array(new_p), self.dimensions) 477 | 478 | def azimuth_t(self, angle: float | l.Angle=0, t_end: bool=False, 479 | t_range: tuple[float, float]=(0.0, 1.0)) -> tuple[float, ...]: 480 | '''Returns the list of t where the tangent is at the given angle from the beginning of the 481 | given t_range. The angle is in degrees or Angle.''' 482 | 483 | angle = l.angle(angle) 484 | 485 | start_slope = self.normal2d(t_range[1 if t_end else 0]) 486 | start_rot: l.GMatrix = l.rotZ(sinr_cosr=(-start_slope[1], start_slope[0])) 487 | 488 | qs: QuadraticSpline = self.transform(angle.inv().rotZ * start_rot) 489 | 490 | roots = qs.curve_maxima_minima_t(t_range) 491 | 492 | return sorted(roots[0]) 493 | 494 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/axes.py: -------------------------------------------------------------------------------- 1 | from datatrees import datatree, dtfield, Node 2 | from typing import ClassVar, List, Tuple 3 | import numpy as np 4 | 5 | import OpenGL.GL as gl 6 | import OpenGL.GLU as glu 7 | 8 | from HersheyFonts import HersheyFonts 9 | 10 | from pythonopenscad.viewer.viewer_base import ViewerBase 11 | from pythonopenscad.viewer.glctxt import PYOPENGL_VERBOSE 12 | 13 | import anchorscad_lib.linear as l 14 | 15 | 16 | @datatree 17 | class ScreenContext: 18 | """Context for the screen.""" 19 | 20 | ws_width_per_px: float 21 | scene_diagonal_px: float 22 | 23 | def __post_init__(self): 24 | self.ws_width_per_px = float(self.ws_width_per_px) 25 | self.scene_diagonal_px = float(self.scene_diagonal_px) 26 | 27 | def get_scene_diagonal_ws(self): 28 | return self.scene_diagonal_px * self.ws_width_per_px 29 | 30 | @datatree 31 | class AxesRenderer: 32 | """Renders X, Y, Z axes using immediate mode.""" 33 | 34 | show_graduation_ticks: bool = True 35 | show_graduation_values: bool = True 36 | factor: float = 1 # Length of axes relative to scene diagonal 37 | color: Tuple[float, float, float] = (0.0, 0.0, 0.0) # Color for the axes lines 38 | line_width_px: float = 1.5 # Line width for the axes 39 | grad_line_width_px: float = 1.3 # Line width for the graduations 40 | stipple_factor: int = 4 # Scaling factor for the dash pattern negtive of axes. 41 | negative_stipple_pattern: int = 0xAAAA # For negative side of axes. 42 | use_stipple: bool = True # Enable stipple by default for visual distinction of negative axes 43 | 44 | grad_tick_size_px: list[float] = (10, 20, 25) # Size of the graduations in pixels 45 | grad_size_px_min: float = 9 # Min space between graduations 46 | grad_colors: tuple[tuple[float, float, float], 47 | tuple[float, float, float], 48 | tuple[float, float, float]] = ((1, 0, 0), (0, 0.5, 0), (0, 0, 1)) 49 | 50 | font_name: str = None # None means pick the default font. 51 | font_size_px: float = 30 52 | text_margin_px: float = 10 53 | 54 | _font: HersheyFonts = dtfield(default_factory=HersheyFonts) 55 | 56 | def __post_init__(self): 57 | if self.show_graduation_values: 58 | self._font.load_default_font(self.font_name) 59 | 60 | AXES: ClassVar[List[np.ndarray]] = [ 61 | np.array([1.0, 0.0, 0.0]), 62 | np.array([0.0, 1.0, 0.0]), 63 | np.array([0.0, 0.0, 1.0]), 64 | ] 65 | 66 | GRAD_DIR: ClassVar[List[np.ndarray]] = [ 67 | np.array([0.0, 1.0, 0.0]), 68 | np.array([-1.0, 0.0, 0.0]), 69 | np.array([-1.0, 0.0, 0.0]), 70 | ] 71 | 72 | def get_ws_width_per_px(self, viewer: ViewerBase): 73 | # Get how wide one pixel is in world space. 74 | 75 | if hasattr(viewer, 'custom_glu_project'): # Indicates PoscGLWidget or similar 76 | modelview_mat = viewer.get_view_mat() 77 | projection_mat = viewer.get_projection_mat() 78 | viewport = viewer.get_viewport() 79 | 80 | try: 81 | origin_sx, origin_sy, origin_sz = viewer.custom_glu_project(0.0, 0.0, 0.0, modelview_mat, projection_mat, viewport) 82 | world_at_origin_screen = np.array(viewer.custom_glu_unproject(origin_sx, origin_sy, origin_sz, modelview_mat, projection_mat, viewport)) 83 | world_at_origin_plus_1px_x_screen = np.array(viewer.custom_glu_unproject(origin_sx + 1.0, origin_sy, origin_sz, modelview_mat, projection_mat, viewport)) 84 | 85 | delta_world = world_at_origin_plus_1px_x_screen - world_at_origin_screen 86 | model_1px_len = np.sqrt(np.dot(delta_world, delta_world)) 87 | 88 | if model_1px_len == 0.0: return 0.001 89 | return model_1px_len 90 | except ValueError as ve: 91 | if PYOPENGL_VERBOSE: print(f"AxesRenderer.get_ws_width_per_px (custom path): ValueError: {ve}") 92 | return 0.01 93 | except Exception as e: 94 | if PYOPENGL_VERBOSE: print(f"AxesRenderer.get_ws_width_per_px (custom path): Exception: {e}") 95 | return 0.01 96 | else: # Fallback for original Viewer (GLUT-based) 97 | try: 98 | origin_screen_coords = np.array(glu.gluProject(0.0, 0.0, 0.0)) 99 | world_at_origin_screen = np.array(glu.gluUnProject(origin_screen_coords[0], origin_screen_coords[1], origin_screen_coords[2])) 100 | world_at_origin_plus_1px_x_screen = np.array(glu.gluUnProject(origin_screen_coords[0] + 1.0, origin_screen_coords[1], origin_screen_coords[2])) 101 | 102 | delta_world = world_at_origin_plus_1px_x_screen - world_at_origin_screen 103 | model_1px_len = np.sqrt(np.dot(delta_world, delta_world)) 104 | 105 | if model_1px_len == 0.0: return 0.001 106 | return model_1px_len 107 | except ValueError as ve: 108 | if PYOPENGL_VERBOSE: print(f"AxesRenderer.get_ws_width_per_px (GLU path): ValueError: {ve}") 109 | return 0.01 110 | except Exception as e: 111 | if PYOPENGL_VERBOSE: print(f"AxesRenderer.get_ws_width_per_px (GLU path): Exception: {e}") 112 | return 0.01 113 | 114 | 115 | def get_scene_diagonal_px(self, viewer: ViewerBase): 116 | win_xy = np.asarray(viewer.get_current_window_dims()) 117 | return np.sqrt(np.dot(win_xy, win_xy)) 118 | 119 | def compute_screen_context(self, viewer: ViewerBase): 120 | """Compute the screen context.""" 121 | return ScreenContext( 122 | ws_width_per_px=self.get_ws_width_per_px(viewer), 123 | scene_diagonal_px=self.get_scene_diagonal_px(viewer), 124 | ) 125 | 126 | def draw(self, viewer: ViewerBase): 127 | """Draw the axes lines.""" 128 | screen_context = self.compute_screen_context(viewer) 129 | self.draw_axes(viewer, screen_context) 130 | if self.show_graduation_ticks: 131 | self.draw_graduations(viewer, screen_context) 132 | 133 | def draw_text(self, text: str, max_allowed_width: float, transform: np.ndarray): 134 | try: 135 | if not text or not self.show_graduation_values: 136 | return 137 | 138 | lines_list = np.array(list(self._font.lines_for_text(text))) 139 | if len(lines_list) == 0: 140 | return 141 | # This should be an nx2x2 array. 142 | # Get the min max of all the points. 143 | min_point = np.min(lines_list, axis=(0, 1)) 144 | max_point = np.max(lines_list, axis=(0, 1)) 145 | 146 | width_with_minus = (min_point[0] + max_point[0]) 147 | width = width_with_minus 148 | if width > max_allowed_width: 149 | return 150 | offset = 0 151 | 152 | # Negative numbers should be centrerd on the digits. 153 | if text[0] == '-': 154 | min_point_m = np.min(lines_list[1:], axis=(0, 1)) 155 | max_point_m = np.max(lines_list[1:], axis=(0, 1)) 156 | width = (min_point_m[0] + max_point_m[0]) 157 | offset = width_with_minus - width 158 | 159 | gl.glPushMatrix() 160 | gl.glMultMatrixf(transform) 161 | gl.glTranslate(-width_with_minus / 2 + offset / 2, -max_point[1], 0) 162 | 163 | gl.glBegin(gl.GL_LINES) 164 | 165 | for a, b in lines_list: 166 | gl.glVertex2f(*a) 167 | gl.glVertex2f(*b) 168 | gl.glEnd() 169 | 170 | gl.glPopMatrix() 171 | except Exception as e: 172 | if PYOPENGL_VERBOSE: 173 | print(f"Failed to render text {e}") 174 | 175 | def format_for_scale(self, pos: float) -> str: 176 | """Format a floating point number to display on the axis. 177 | 178 | Rules: 179 | * If the number approx a whole number it should be printed without 180 | the decimal point or trailing zeros. 181 | * We don't render really small numbers (abs value < 0.01), except for 0 itself. 182 | * We limit to 3 significant digits. 183 | 184 | """ 185 | if np.abs(pos) < 0.01: 186 | return "" 187 | 188 | # Rule 1: Check if the number is approximately a whole number. 189 | # Use np.isclose for robust floating-point comparison. 190 | # Using default tolerances of np.isclose should be fine here. 191 | rounded_pos = round(pos) 192 | if np.isclose(pos, rounded_pos, atol=0.01): 193 | # Format as an integer string 194 | return str(int(rounded_pos)) 195 | 196 | # Rule 3: Limit to 3 significant digits for other numbers. 197 | # Use the 'g' format specifier for significant digits. 198 | return "{:.3g}".format(pos) 199 | 200 | def max_allowed_text_width_in_grad_multiples(self, ngrads_from_origin) -> float: 201 | if ngrads_from_origin % 100 == 0: 202 | return 80 203 | if ngrads_from_origin % 10 == 0: 204 | return 9 205 | if ngrads_from_origin % 5 == 0: 206 | return 5 207 | return 0.9 208 | 209 | def draw_graduations(self, viewer: ViewerBase, screen_context: ScreenContext): 210 | # Calculate minimum world-space distance required between ticks 211 | # to maintain grad_size_px_min pixel separation on screen. 212 | min_size_ws = screen_context.ws_width_per_px * self.grad_size_px_min 213 | 214 | text_margin_ws = self.text_margin_px * screen_context.ws_width_per_px 215 | 216 | # Set the font size to the appropriate width. 217 | if self.show_graduation_values: 218 | self._font.normalize_rendering( 219 | self.font_size_px * screen_context.ws_width_per_px) 220 | 221 | # Calculate corresponding world-space tick heights for constant pixel size. 222 | tick_heights_ws = [ 223 | screen_context.ws_width_per_px * grad_size for grad_size in self.grad_tick_size_px] 224 | 225 | # Find the exponents needed for base-10 and base-5 spacing candidates. 226 | min_ws_exp_base10 = float(np.ceil(np.log10(min_size_ws))) 227 | min_ws_exp_base5 = float(np.ceil(np.log10(2 * min_size_ws))) # log10(2*ws) relates to 5*10^M 228 | 229 | # Calculate the smallest power-of-10 spacing >= min_size_ws. 230 | candidate_spacing_10 = 10.0 ** min_ws_exp_base10 231 | # Calculate the smallest 5*(power-of-10) spacing >= min_size_ws. 232 | candidate_spacing_5 = 10.0 ** min_ws_exp_base5 / 2 233 | 234 | # *** Select the actual world-space spacing between graduations *** 235 | if candidate_spacing_10 <= candidate_spacing_5: 236 | grad_spacing_ws = candidate_spacing_10 237 | steps_per_major_grad = 10 # Use 10 steps per major interval (e.g., 0 to 10) 238 | mid_step_index = 5 # Intermediate tick at step 5 239 | else: 240 | grad_spacing_ws = candidate_spacing_5 241 | steps_per_major_grad = 2 # Use 2 steps per major interval (e.g., 0 to 10, spaced by 5) 242 | mid_step_index = None # No intermediate tick needed 243 | 244 | # Determine the dynamic world-space extent based on screen diagonal and factor. 245 | dynamic_axis_extent_ws = screen_context.get_scene_diagonal_ws() * self.factor / 2 246 | 247 | # Calculate how many intervals fit in half the extent. 248 | num_intervals_half_axis = (int(dynamic_axis_extent_ws // grad_spacing_ws) // 2) 249 | curr_pos = 0 #-(num_intervals_half_axis * grad_spacing_ws) 250 | 251 | gl.glLineWidth(self.grad_line_width_px) 252 | # Loop through calculated number of intervals for both negative and positive sides. 253 | 254 | for axis_idx in range(3): 255 | try: 256 | gl.glPushMatrix() 257 | if axis_idx == 1: 258 | gl.glRotate(90, 0, 0, 1) 259 | if axis_idx == 2: 260 | gl.glRotate(-90, 0, 1, 0) 261 | gl.glRotate(90, 1, 0, 0) 262 | 263 | gl.glColor3f(*self.grad_colors[axis_idx]) 264 | for interval_index in range(-num_intervals_half_axis, num_intervals_half_axis + 1): # Iterate interval count 265 | curr_pos = grad_spacing_ws * interval_index 266 | max_size_n = self.max_allowed_text_width_in_grad_multiples(abs(interval_index)) 267 | 268 | try: 269 | gl.glPushMatrix() 270 | gl.glTranslatef(curr_pos, 0, 0) 271 | text = self.format_for_scale(curr_pos) 272 | # Don't render if the string is wider than max_size_n grads. 273 | self.draw_text( 274 | text, 275 | max_size_n * grad_spacing_ws, 276 | l.translate((0, -text_margin_ws, 0)).A.T) 277 | 278 | # Select tick height based on position relative to major/mid steps 279 | if interval_index % steps_per_major_grad == 0: # Major tick 280 | current_tick_height_ws = tick_heights_ws[2] 281 | elif mid_step_index is not None and interval_index % mid_step_index == 0: # Mid tick 282 | current_tick_height_ws = tick_heights_ws[1] 283 | else: # Minor tick 284 | current_tick_height_ws = tick_heights_ws[0] 285 | 286 | gl.glBegin(gl.GL_LINES) 287 | gl.glVertex3f(0, 0, 0) 288 | gl.glVertex3f(0, current_tick_height_ws, 0) 289 | gl.glEnd() 290 | finally: 291 | gl.glPopMatrix() 292 | finally: 293 | gl.glPopMatrix() 294 | 295 | def draw_axes(self, viewer: ViewerBase, screen_context: ScreenContext): 296 | try: 297 | length = screen_context.get_scene_diagonal_ws() * self.factor / 2 298 | 299 | if length <= 0: 300 | length = 1.0 301 | 302 | lighting_was_enabled = gl.glIsEnabled(gl.GL_LIGHTING) 303 | if lighting_was_enabled: 304 | gl.glDisable(gl.GL_LIGHTING) 305 | 306 | gl.glLineWidth(self.line_width_px) 307 | gl.glColor3f(*self.color) 308 | 309 | self.draw_half_axes(length) 310 | try: 311 | if self.use_stipple: 312 | gl.glEnable(gl.GL_LINE_STIPPLE) 313 | gl.glLineStipple(self.stipple_factor, self.negative_stipple_pattern) 314 | self.draw_half_axes(-length) 315 | finally: 316 | if self.use_stipple: 317 | gl.glDisable(gl.GL_LINE_STIPPLE) 318 | 319 | if lighting_was_enabled: 320 | gl.glEnable(gl.GL_LIGHTING) 321 | 322 | except Exception as e: 323 | if PYOPENGL_VERBOSE: 324 | print(f"AxesRenderer: Error drawing axes: {e}") 325 | 326 | def draw_half_axes(self, length: float): 327 | gl.glBegin(gl.GL_LINES) 328 | try: 329 | for i, axis_vec in enumerate(self.AXES): 330 | p0 = (0.0, 0.0, 0.0) 331 | p1 = tuple(length * axis_vec) 332 | gl.glVertex3f(*p0) 333 | gl.glVertex3f(*p1) 334 | finally: 335 | gl.glEnd() 336 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/basic_models.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | from pythonopenscad.viewer.model import Model 4 | 5 | 6 | def create_triangle_model(size: float = 1.5) -> Model: 7 | """Create a simple triangle model for testing.""" 8 | # Create a simple colored triangle with different colors for each vertex 9 | s = size 10 | vertex_data = np.array([ 11 | # position (3) # color (4) # normal (3) 12 | -s, -s, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, # Red 13 | s, -s, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, # Green 14 | 0.0, s, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0 # Blue 15 | ], dtype=np.float32) 16 | 17 | return Model(vertex_data, num_points=3) 18 | 19 | def create_cube_model(size: float = 1.0, color: tuple[float, float, float, float] = (0.0, 1.0, 0.0, 1.0)) -> Model: 20 | """Create a simple cube model for testing.""" 21 | # Create a colored cube 22 | s = size / 2 23 | vertex_data = [] 24 | 25 | # Define the 8 vertices of the cube 26 | vertices = [ 27 | [-s, -s, -s], [s, -s, -s], [s, s, -s], [-s, s, -s], 28 | [-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s] 29 | ] 30 | 31 | # Define the 6 face normals 32 | normals = [ 33 | [0, 0, -1], [0, 0, 1], [0, -1, 0], 34 | [0, 1, 0], [-1, 0, 0], [1, 0, 0] 35 | ] 36 | 37 | # Define faces with different colors 38 | face_colors = [ 39 | [1.0, 0.0, 0.0, 1.0], # Red - back 40 | [0.0, 1.0, 0.0, 1.0], # Green - front 41 | [0.0, 0.0, 1.0, 1.0], # Blue - bottom 42 | [1.0, 1.0, 0.0, 1.0], # Yellow - top 43 | [0.0, 1.0, 1.0, 1.0], # Cyan - left 44 | [1.0, 0.0, 1.0, 1.0] # Magenta - right 45 | ] 46 | 47 | # Define the faces using indices 48 | faces = [ 49 | [0, 1, 2, 3], # back 50 | [4, 7, 6, 5], # front 51 | [0, 4, 5, 1], # bottom 52 | [3, 2, 6, 7], # top 53 | [0, 3, 7, 4], # left 54 | [1, 5, 6, 2] # right 55 | ] 56 | 57 | # Create vertex data for each face 58 | for face_idx, face in enumerate(faces): 59 | normal = normals[face_idx] 60 | face_color = face_colors[face_idx] 61 | 62 | # Create two triangles per face 63 | tri1 = [face[0], face[1], face[2]] 64 | tri2 = [face[0], face[2], face[3]] 65 | 66 | for tri in [tri1, tri2]: 67 | for vertex_idx in tri: 68 | # position 69 | vertex_data.extend(vertices[vertex_idx]) 70 | # color 71 | vertex_data.extend(face_color) 72 | # normal 73 | vertex_data.extend(normal) 74 | 75 | 76 | return Model(np.array(vertex_data, dtype=np.float32), num_points=36) 77 | 78 | def create_colored_test_cube(size: float = 2.0) -> Model: 79 | """Create a test cube with distinct bright colors for each face.""" 80 | s = size / 2 81 | vertex_data = [] 82 | 83 | # Define cube vertices 84 | vertices = [ 85 | [s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s], # 0-3 back face 86 | [s, -s, s], [-s, -s, s], [-s, s, s], [s, s, s] # 4-7 front face 87 | ] 88 | 89 | # Define face normals 90 | normals = [ 91 | [0, 0, -1], # back - red 92 | [0, 0, 1], # front - green 93 | [0, -1, 0], # bottom - blue 94 | [0, 1, 0], # top - yellow 95 | [-1, 0, 0], # left - magenta 96 | [1, 0, 0] # right - cyan 97 | ] 98 | 99 | alpha = 0.3 100 | # Bright colors for each face 101 | colors = [ 102 | [1.0, 0.0, 0.0, alpha], # red - back 103 | [0.0, 1.0, 0.0, alpha], # green - front 104 | [0.0, 0.0, 1.0, alpha], # blue - bottom 105 | [1.0, 1.0, 0.0, alpha], # yellow - top 106 | [1.0, 0.0, 1.0, alpha], # magenta - left 107 | [0.0, 1.0, 1.0, alpha] # cyan - right 108 | ] 109 | 110 | # Face definitions (vertices comprising each face) 111 | faces = [ 112 | [0, 1, 2, 3], # back 113 | [4, 7, 6, 5], # front 114 | [0, 4, 5, 1], # bottom 115 | [3, 2, 6, 7], # top 116 | [0, 3, 7, 4], # left 117 | [1, 5, 6, 2] # right 118 | ] 119 | 120 | # Create vertex data for each face 121 | for face_idx, face in enumerate(faces): 122 | normal = normals[face_idx] 123 | color = colors[face_idx] 124 | 125 | # Create two triangles per face 126 | tri1 = [face[0], face[1], face[2]] 127 | tri2 = [face[0], face[2], face[3]] 128 | 129 | for tri in [tri1, tri2]: 130 | for vertex_idx in tri: 131 | # position 132 | vertex_data.extend(vertices[vertex_idx]) 133 | # color 134 | vertex_data.extend(color) 135 | # normal 136 | vertex_data.extend(normal) 137 | 138 | return Model(np.array(vertex_data, dtype=np.float32), num_points=36) -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/bbox.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from datatrees import datatree, dtfield, Node 3 | 4 | 5 | @datatree 6 | class BoundingBox: 7 | """3D bounding box with min and max points.""" 8 | 9 | min_point: np.ndarray = dtfield( 10 | default_factory=lambda: np.array([float("inf"), float("inf"), float("inf")]) 11 | ) 12 | max_point: np.ndarray = dtfield( 13 | default_factory=lambda: np.array([float("-inf"), float("-inf"), float("-inf")]) 14 | ) 15 | 16 | @property 17 | def size(self) -> np.ndarray: 18 | """Get the size of the bounding box as a 3D vector.""" 19 | return self.max_point - self.min_point 20 | 21 | @property 22 | def center(self) -> np.ndarray: 23 | """Get the center of the bounding box.""" 24 | # Ensure we always return a 3D vector even for an empty bounding box 25 | if np.all(np.isinf(self.min_point)) or np.all(np.isinf(self.max_point)): 26 | return np.array([0.0, 0.0, 0.0]) 27 | return (self.max_point + self.min_point) / 2.0 28 | 29 | @property 30 | def diagonal(self) -> float: 31 | """Get the diagonal length of the bounding box.""" 32 | if np.all(np.isinf(self.min_point)) or np.all(np.isinf(self.max_point)): 33 | return 1.0 # Return a default value for empty/invalid bounding boxes 34 | return np.linalg.norm(self.size) 35 | 36 | def union(self, other: "BoundingBox") -> "BoundingBox": 37 | """Compute the union of this bounding box with another.""" 38 | # Handle the case where one of the bounding boxes is empty 39 | if np.all(np.isinf(self.min_point)): 40 | return other 41 | if np.all(np.isinf(other.min_point)): 42 | return self 43 | 44 | return BoundingBox( 45 | min_point=np.minimum(self.min_point, other.min_point), 46 | max_point=np.maximum(self.max_point, other.max_point), 47 | ) 48 | 49 | def contains_point(self, point: np.ndarray) -> bool: 50 | """Check if a point is inside the bounding box.""" 51 | if np.all(np.isinf(self.min_point)) or np.all(np.isinf(self.max_point)): 52 | return False 53 | return np.all(point >= self.min_point) and np.all(point <= self.max_point) 54 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/bbox_render.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Any 3 | 4 | from pythonopenscad.viewer.glctxt import PYOPENGL_VERBOSE 5 | 6 | import OpenGL.GL as gl 7 | 8 | class BBoxRender: 9 | @classmethod 10 | def render(cls, viewer: Any): 11 | """Draw the scene bounding box in the current mode (off/wireframe/solid).""" 12 | if viewer.bounding_box_mode == 0: 13 | return 14 | 15 | # Store current backface culling state and disable it for the bounding box 16 | was_culling_enabled = gl.glIsEnabled(gl.GL_CULL_FACE) 17 | if was_culling_enabled: 18 | gl.glDisable(gl.GL_CULL_FACE) 19 | 20 | # Get bounding box coordinates 21 | min_x, min_y, min_z = viewer.bounding_box.min_point 22 | max_x, max_y, max_z = viewer.bounding_box.max_point 23 | 24 | # Set up transparency for solid mode 25 | was_blend_enabled = False 26 | if viewer.bounding_box_mode == 2: 27 | try: 28 | # Save current blend state 29 | was_blend_enabled = gl.glIsEnabled(gl.GL_BLEND) 30 | 31 | # Enable blending 32 | gl.glEnable(gl.GL_BLEND) 33 | gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) 34 | 35 | # Semi-transparent green 36 | gl.glColor4f(0.0, 1.0, 0.0, 0.2) 37 | except Exception as e: 38 | if PYOPENGL_VERBOSE: 39 | print(f"Viewer: Blending setup failed: {e}") 40 | # Fall back to wireframe 41 | viewer.bounding_box_mode = 1 42 | if PYOPENGL_VERBOSE: 43 | print("Viewer: Blending not supported, falling back to wireframe mode") 44 | 45 | # Draw the bounding box 46 | if viewer.bounding_box_mode == 1: # Wireframe mode 47 | gl.glColor3f(0.0, 1.0, 0.0) # Green 48 | 49 | # Front face 50 | gl.glBegin(gl.GL_LINE_LOOP) 51 | gl.glVertex3f(min_x, min_y, max_z) 52 | gl.glVertex3f(max_x, min_y, max_z) 53 | gl.glVertex3f(max_x, max_y, max_z) 54 | gl.glVertex3f(min_x, max_y, max_z) 55 | gl.glEnd() 56 | 57 | # Back face 58 | gl.glBegin(gl.GL_LINE_LOOP) 59 | gl.glVertex3f(min_x, min_y, min_z) 60 | gl.glVertex3f(max_x, min_y, min_z) 61 | gl.glVertex3f(max_x, max_y, min_z) 62 | gl.glVertex3f(min_x, max_y, min_z) 63 | gl.glEnd() 64 | 65 | # Connecting edges 66 | gl.glBegin(gl.GL_LINES) 67 | gl.glVertex3f(min_x, min_y, min_z) 68 | gl.glVertex3f(min_x, min_y, max_z) 69 | 70 | gl.glVertex3f(max_x, min_y, min_z) 71 | gl.glVertex3f(max_x, min_y, max_z) 72 | 73 | gl.glVertex3f(max_x, max_y, min_z) 74 | gl.glVertex3f(max_x, max_y, max_z) 75 | 76 | gl.glVertex3f(min_x, max_y, min_z) 77 | gl.glVertex3f(min_x, max_y, max_z) 78 | gl.glEnd() 79 | 80 | else: # Solid mode 81 | # Front face 82 | gl.glBegin(gl.GL_QUADS) 83 | gl.glVertex3f(min_x, min_y, max_z) 84 | gl.glVertex3f(max_x, min_y, max_z) 85 | gl.glVertex3f(max_x, max_y, max_z) 86 | gl.glVertex3f(min_x, max_y, max_z) 87 | gl.glEnd() 88 | 89 | # Back face 90 | gl.glBegin(gl.GL_QUADS) 91 | gl.glVertex3f(min_x, min_y, min_z) 92 | gl.glVertex3f(max_x, min_y, min_z) 93 | gl.glVertex3f(max_x, max_y, min_z) 94 | gl.glVertex3f(min_x, max_y, min_z) 95 | gl.glEnd() 96 | 97 | # Top face 98 | gl.glBegin(gl.GL_QUADS) 99 | gl.glVertex3f(min_x, max_y, min_z) 100 | gl.glVertex3f(max_x, max_y, min_z) 101 | gl.glVertex3f(max_x, max_y, max_z) 102 | gl.glVertex3f(min_x, max_y, max_z) 103 | gl.glEnd() 104 | 105 | # Bottom face 106 | gl.glBegin(gl.GL_QUADS) 107 | gl.glVertex3f(min_x, min_y, min_z) 108 | gl.glVertex3f(max_x, min_y, min_z) 109 | gl.glVertex3f(max_x, min_y, max_z) 110 | gl.glVertex3f(min_x, min_y, max_z) 111 | gl.glEnd() 112 | 113 | # Right face 114 | gl.glBegin(gl.GL_QUADS) 115 | gl.glVertex3f(max_x, min_y, min_z) 116 | gl.glVertex3f(max_x, max_y, min_z) 117 | gl.glVertex3f(max_x, max_y, max_z) 118 | gl.glVertex3f(max_x, min_y, max_z) 119 | gl.glEnd() 120 | 121 | # Left face 122 | gl.glBegin(gl.GL_QUADS) 123 | gl.glVertex3f(min_x, min_y, min_z) 124 | gl.glVertex3f(min_x, max_y, min_z) 125 | gl.glVertex3f(min_x, max_y, max_z) 126 | gl.glVertex3f(min_x, min_y, max_z) 127 | gl.glEnd() 128 | 129 | # Clean up blending state 130 | if viewer.bounding_box_mode == 2 and not was_blend_enabled: 131 | try: 132 | gl.glDisable(gl.GL_BLEND) 133 | except Exception: 134 | pass 135 | 136 | # Restore backface culling state 137 | if was_culling_enabled: 138 | gl.glEnable(gl.GL_CULL_FACE) 139 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/glctxt.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from datatrees import datatree, dtfield, Node 3 | from typing import ClassVar, Optional 4 | import numpy as np 5 | import warnings 6 | 7 | 8 | import OpenGL.GL as gl 9 | import OpenGL.GLUT as glut 10 | 11 | # Enable PyOpenGL's error checking 12 | OpenGL = sys.modules["OpenGL"] 13 | OpenGL.ERROR_CHECKING = True 14 | OpenGL.ERROR_LOGGING = True 15 | # Ensure PyOpenGL allows the deprecated APIs 16 | OpenGL.FORWARD_COMPATIBLE_ONLY = False 17 | 18 | PYOPENGL_VERBOSE = False 19 | 20 | 21 | @datatree 22 | class GLContext: 23 | """Singleton class to manage OpenGL context and capabilities.""" 24 | 25 | # Class variable for the singleton instance 26 | _instance: ClassVar[Optional["GLContext"]] = None 27 | 28 | # OpenGL capabilities 29 | is_initialized: bool = False 30 | opengl_version: Optional[str] = None 31 | glsl_version: Optional[str] = None 32 | has_vbo: bool = False 33 | has_shader: bool = False 34 | has_vao: bool = False 35 | has_legacy_lighting: bool = False 36 | has_legacy_vertex_arrays: bool = False 37 | has_3_3: bool = False 38 | 39 | # GLUT state tracking 40 | temp_window_id: Optional[int] = None 41 | dummy_display_callback = None 42 | 43 | 44 | @classmethod 45 | def get_instance(cls) -> "GLContext": 46 | """Get or create the singleton instance.""" 47 | if cls._instance is None: 48 | cls._instance = GLContext() 49 | return cls._instance 50 | 51 | @classmethod 52 | def _dummy_display(cls): 53 | """Empty display function for the temporary initialization window.""" 54 | if PYOPENGL_VERBOSE: 55 | current_window = glut.glutGetWindow() 56 | print(f"GLContext: _dummy_display callback executed for window ID: {current_window}") 57 | 58 | def initialize(self): 59 | """Initialize the OpenGL context and detect capabilities. 60 | 61 | This must be called AFTER a valid OpenGL context has been created and made current 62 | (e.g., after glutCreateWindow). 63 | """ 64 | 65 | 66 | # --- Check if we have a current context --- 67 | current_window = 0 68 | try: 69 | current_window = glut.glutGetWindow() 70 | if current_window == 0: 71 | if PYOPENGL_VERBOSE: 72 | warnings.warn( 73 | "GLContext.initialize: No current GLUT window/context. Cannot detect capabilities." 74 | ) 75 | # Set fallback capabilities as we can't query OpenGL 76 | self._set_fallback_capabilities() 77 | self.is_initialized = True # Mark as initialized even with fallback 78 | return 79 | except Exception as e: 80 | if PYOPENGL_VERBOSE: 81 | warnings.warn( 82 | f"GLContext.initialize: Error getting current window ({e}). Cannot detect capabilities." 83 | ) 84 | self._set_fallback_capabilities() 85 | self.is_initialized = True # Mark as initialized even with fallback 86 | return 87 | # ----------------------------------------- 88 | 89 | if PYOPENGL_VERBOSE: 90 | print( 91 | f"GLContext: Detecting capabilities using context from window ID: {current_window}" 92 | ) 93 | print(f"GLContext: Error state BEFORE capability detection: {gl.glGetError()}") 94 | 95 | # We assume a valid context exists now, proceed with detection 96 | try: 97 | # Now we have a valid OpenGL context, detect capabilities 98 | try: 99 | self.opengl_version = gl.glGetString(gl.GL_VERSION) 100 | if self.opengl_version is not None: 101 | self.opengl_version = self.opengl_version.decode() 102 | else: 103 | self.opengl_version = "Unknown" 104 | except Exception: 105 | self.opengl_version = "Unknown" 106 | 107 | try: 108 | self.glsl_version = gl.glGetString(gl.GL_SHADING_LANGUAGE_VERSION) 109 | if self.glsl_version is not None: 110 | self.glsl_version = self.glsl_version.decode() 111 | else: 112 | self.glsl_version = "Not supported" 113 | except Exception: 114 | self.glsl_version = "Not supported" 115 | 116 | # Check for feature support 117 | self._detect_opengl_features() 118 | 119 | # Output detailed information if verbose 120 | if PYOPENGL_VERBOSE: 121 | warnings.warn(f"OpenGL version: {self.opengl_version}") 122 | warnings.warn(f"GLSL version: {self.glsl_version}") 123 | 124 | if not self.has_vbo: 125 | warnings.warn("OpenGL VBO functions not available. Rendering may not work.") 126 | if not self.has_shader: 127 | warnings.warn( 128 | "OpenGL shader functions not available. Using fixed-function pipeline." 129 | ) 130 | if not self.has_vao: 131 | warnings.warn( 132 | "OpenGL 3.3+ core profile features not available. Using compatibility mode." 133 | ) 134 | if not self.has_legacy_lighting and not self.has_shader: 135 | warnings.warn( 136 | "Neither modern shaders nor legacy lighting available. Rendering will be unlit." 137 | ) 138 | 139 | except Exception as e: 140 | if PYOPENGL_VERBOSE: 141 | warnings.warn(f"GLContext: Unexpected error during capability detection: {e}") 142 | # Use fallback if detection fails unexpectedly 143 | self._set_fallback_capabilities() 144 | 145 | self.is_initialized = True 146 | 147 | def _detect_opengl_features(self): 148 | """Detect available OpenGL features safely.""" 149 | # Start with no capabilities 150 | self.has_vbo = False 151 | self.has_shader = False 152 | self.has_vao = False 153 | self.has_3_3 = False 154 | self.has_legacy_lighting = False 155 | self.has_legacy_vertex_arrays = False 156 | 157 | try: 158 | # Check for errors before testing features 159 | if gl.glGetError() != gl.GL_NO_ERROR: 160 | if PYOPENGL_VERBOSE: 161 | print( 162 | "GLContext: OpenGL error state before feature detection, skipping feature tests" 163 | ) 164 | return 165 | 166 | # VBO support 167 | self.has_vbo = ( 168 | hasattr(gl, "glGenBuffers") and callable(gl.glGenBuffers) and bool(gl.glGenBuffers) 169 | ) 170 | 171 | # Shader support 172 | self.has_shader = ( 173 | hasattr(gl, "glCreateShader") 174 | and callable(gl.glCreateShader) 175 | and bool(gl.glCreateShader) 176 | and hasattr(gl, "glCreateProgram") 177 | and callable(gl.glCreateProgram) 178 | and bool(gl.glCreateProgram) 179 | ) 180 | 181 | # VAO support (OpenGL 3.0+) 182 | self.has_vao = ( 183 | self.has_vbo 184 | and self.has_shader 185 | and hasattr(gl, "glGenVertexArrays") 186 | and callable(gl.glGenVertexArrays) 187 | and bool(gl.glGenVertexArrays) 188 | ) 189 | 190 | self.has_3_3 = ( 191 | self.has_vao 192 | and self.has_shader 193 | and hasattr(gl, "glGenVertexArrays") 194 | and callable(gl.glGenVertexArrays) 195 | and bool(gl.glGenVertexArrays) 196 | ) 197 | 198 | # Legacy support 199 | self.has_legacy_lighting = hasattr(gl, "GL_LIGHTING") and hasattr(gl, "GL_LIGHT0") 200 | 201 | self.has_legacy_vertex_arrays = ( 202 | hasattr(gl, "GL_VERTEX_ARRAY") 203 | and hasattr(gl, "glEnableClientState") 204 | and hasattr(gl, "glVertexPointer") 205 | ) 206 | 207 | # Verify capabilities by actually trying to use them (for VAO) 208 | if self.has_vao: 209 | try: 210 | vao_id = gl.glGenVertexArrays(1) 211 | gl.glBindVertexArray(vao_id) 212 | gl.glBindVertexArray(0) 213 | gl.glDeleteVertexArrays(1, [vao_id]) 214 | except Exception as e: 215 | if PYOPENGL_VERBOSE: 216 | print(f"GLContext: VAO test failed: {e}") 217 | self.has_vao = False 218 | self.has_3_3 = False 219 | 220 | # Verify VBO capability 221 | if self.has_vbo: 222 | try: 223 | vbo_id = gl.glGenBuffers(1) 224 | gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo_id) 225 | gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) 226 | gl.glDeleteBuffers(1, [vbo_id]) 227 | except Exception as e: 228 | if PYOPENGL_VERBOSE: 229 | print(f"GLContext: VBO test failed: {e}") 230 | self.has_vbo = False 231 | # If VBO fails, VAO will also fail 232 | self.has_vao = False 233 | self.has_3_3 = False 234 | 235 | # Verify legacy lighting if we claim to support it 236 | if self.has_legacy_lighting: 237 | try: 238 | gl.glEnable(gl.GL_LIGHTING) 239 | gl.glDisable(gl.GL_LIGHTING) 240 | except Exception as e: 241 | if PYOPENGL_VERBOSE: 242 | print(f"GLContext: Legacy lighting test failed: {e}") 243 | self.has_legacy_lighting = False 244 | 245 | except (AttributeError, TypeError) as e: 246 | if PYOPENGL_VERBOSE: 247 | warnings.warn(f"Error detecting OpenGL capabilities: {e}") 248 | # Reset all capabilities to be safe 249 | self._set_fallback_capabilities() 250 | 251 | def _set_fallback_capabilities(self): 252 | """Set fallback capabilities for when detection fails.""" 253 | # Assume nothing works 254 | self.has_vbo = False 255 | self.has_shader = False 256 | self.has_vao = False 257 | self.has_3_3 = False 258 | self.has_legacy_lighting = False 259 | self.has_legacy_vertex_arrays = False 260 | self.opengl_version = "Unknown (detection failed)" 261 | self.glsl_version = "Not available (detection failed)" 262 | 263 | if PYOPENGL_VERBOSE: 264 | warnings.warn("Using fallback OpenGL capabilities (minimal feature set)") 265 | warnings.warn("Only the simplest rendering methods will be available") 266 | 267 | def request_context_version(self, major: int, minor: int, core_profile: bool = True): 268 | """Request a specific OpenGL context version. 269 | 270 | This should be called before creating the real window. 271 | """ 272 | try: 273 | glut.glutInitContextVersion(major, minor) 274 | if core_profile: 275 | glut.glutInitContextProfile(glut.GLUT_CORE_PROFILE) 276 | else: 277 | glut.glutInitContextProfile(glut.GLUT_COMPATIBILITY_PROFILE) 278 | except (AttributeError, ValueError) as e: 279 | if PYOPENGL_VERBOSE: 280 | warnings.warn(f"Failed to set OpenGL context version: {e}") 281 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/shader.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import sys 3 | from datatrees.datatrees import datatree, dtfield, Node 4 | from pythonopenscad.viewer.glctxt import PYOPENGL_VERBOSE 5 | 6 | import OpenGL.GL as gl 7 | 8 | def clear_gl_errors(): 9 | while gl.glGetError() != gl.GL_NO_ERROR: 10 | pass 11 | 12 | 13 | def shader_type_to_string(shader_type: int) -> str: 14 | if shader_type == gl.GL_VERTEX_SHADER: 15 | return "Vertex" 16 | elif shader_type == gl.GL_FRAGMENT_SHADER: 17 | return "Fragment" 18 | else: 19 | return "Unknown" 20 | 21 | 22 | class ErrorLogger(ABC): 23 | @abstractmethod 24 | def error(self, message: str): 25 | pass 26 | 27 | @abstractmethod 28 | def warn(self, message: str): 29 | pass 30 | 31 | @abstractmethod 32 | def info(self, message: str): 33 | pass 34 | 35 | 36 | class ConsoleErrorLogger(ErrorLogger): 37 | def error(self, message: str): 38 | print(message) 39 | 40 | def warn(self, message: str): 41 | if PYOPENGL_VERBOSE: 42 | print(message) 43 | 44 | def info(self, message: str): 45 | if PYOPENGL_VERBOSE: 46 | print(message) 47 | 48 | 49 | @datatree 50 | class Shader: 51 | name: str 52 | shader_type: int 53 | shader_source: str 54 | binding: tuple[str, ...] = () 55 | 56 | shader_id: int = dtfield(default=0, init=False) 57 | program_id: int = dtfield(default=0, init=False) 58 | is_bound: bool = dtfield(default=False, init=False) 59 | 60 | def compile(self, error_logger: ErrorLogger) -> bool: 61 | try: 62 | clear_gl_errors() 63 | 64 | # Create vertex shader 65 | self.shader_id = gl.glCreateShader(self.shader_type) 66 | if self.shader_id == 0: 67 | raise RuntimeError( 68 | f"Shader {self.name}: Failed to create " 69 | f"{shader_type_to_string(self.shader_type)} shader object" 70 | ) 71 | 72 | gl.glShaderSource(self.shader_id, self.shader_source) 73 | gl.glCompileShader(self.shader_id) 74 | 75 | # Check for vertex shader compilation errors 76 | compile_status = gl.glGetShaderiv(self.shader_id, gl.GL_COMPILE_STATUS) 77 | if not compile_status: 78 | error = gl.glGetShaderInfoLog(self.shader_id) 79 | raise RuntimeError(f"Viewer: {self.name} shader compilation failed: {error}") 80 | return True 81 | except: 82 | self.delete() 83 | raise 84 | 85 | def attach(self, program_id: int): 86 | self.program_id = program_id 87 | gl.glAttachShader(program_id, self.shader_id) 88 | 89 | def delete(self): 90 | if self.shader_id != 0: 91 | gl.glDeleteShader(self.shader_id) 92 | self.shader_id = 0 93 | self.is_bound = False 94 | 95 | def bind_to_program(self): 96 | if self.binding and not self.is_bound: 97 | for location, name in enumerate(self.binding): 98 | gl.glBindAttribLocation(self.program_id, location, name) 99 | self.is_bound = True 100 | 101 | 102 | @datatree 103 | class ShaderProgram: 104 | name: str 105 | vertex_shader: Shader 106 | fragment_shader: Shader 107 | error_logger: ErrorLogger = dtfield(default_factory=ConsoleErrorLogger) 108 | 109 | program_id: int = dtfield(default=0, init=False) 110 | is_bound: bool = dtfield(default=False, init=False) 111 | 112 | def compile(self) -> int | None: 113 | clear_gl_errors() 114 | 115 | try: 116 | self.vertex_shader.compile(self.error_logger) 117 | self.fragment_shader.compile(self.error_logger) 118 | 119 | # Create and link shader program 120 | self.program_id = gl.glCreateProgram() 121 | if self.program_id == 0: 122 | raise RuntimeError("Failed to create shader program object") 123 | 124 | self.vertex_shader.attach(self.program_id) 125 | self.fragment_shader.attach(self.program_id) 126 | 127 | # Bind attribute locations for GLSL 1.20 (before linking) 128 | self.vertex_shader.bind_to_program() 129 | self.fragment_shader.bind_to_program() 130 | 131 | gl.glLinkProgram(self.program_id) 132 | 133 | # Check for linking errors 134 | link_status = gl.glGetProgramiv(self.program_id, gl.GL_LINK_STATUS) 135 | if not link_status: 136 | error = gl.glGetProgramInfoLog(self.program_id) 137 | raise RuntimeError(f"Shader program linking failed: {error}") 138 | 139 | # Delete shaders (they're not needed after linking) 140 | self.vertex_shader.delete() 141 | self.fragment_shader.delete() 142 | 143 | # Validate the program 144 | gl.glValidateProgram(self.program_id) 145 | validate_status = gl.glGetProgramiv(self.program_id, gl.GL_VALIDATE_STATUS) 146 | if not validate_status: 147 | error = gl.glGetProgramInfoLog(self.program_id) 148 | raise RuntimeError(f"Shader program validation failed: {error}") 149 | 150 | self.error_logger.info( 151 | f"Successfully compiled and linked shader program: {self.program_id}" 152 | ) 153 | return self.program_id 154 | 155 | except Exception as e: 156 | self.error_logger.error(f"Shader program {self.name} compilation failed: {e}") 157 | self.delete() 158 | return None 159 | 160 | def delete(self): 161 | self.vertex_shader.delete() 162 | self.fragment_shader.delete() 163 | if self.program_id != 0: 164 | gl.glDeleteProgram(self.program_id) 165 | self.program_id = 0 166 | 167 | def use(self): 168 | gl.glUseProgram(self.program_id) 169 | 170 | def unuse(self): 171 | gl.glUseProgram(0) 172 | 173 | 174 | VERTEX_SHADER = Shader( 175 | name="vertex_shader", 176 | shader_type=gl.GL_VERTEX_SHADER, 177 | binding=("aPos", "aColor", "aNormal"), 178 | shader_source=""" 179 | #version 120 180 | 181 | attribute vec3 aPos; 182 | attribute vec4 aColor; 183 | attribute vec3 aNormal; 184 | 185 | uniform mat4 model; 186 | uniform mat4 view; 187 | uniform mat4 projection; 188 | 189 | varying vec3 FragPos; 190 | varying vec4 VertexColor; 191 | varying vec3 Normal; 192 | 193 | void main() { 194 | FragPos = vec3(model * vec4(aPos, 1.0)); 195 | // Simple normal transformation - avoiding inverse which fails on some drivers 196 | Normal = normalize(mat3(model) * aNormal); 197 | VertexColor = aColor; 198 | gl_Position = projection * view * model * vec4(aPos, 1.0); 199 | } 200 | """, 201 | ) 202 | 203 | FRAGMENT_SHADER = Shader( 204 | name="fragment_shader", 205 | shader_type=gl.GL_FRAGMENT_SHADER, 206 | shader_source=""" 207 | #version 120 208 | 209 | varying vec3 FragPos; 210 | varying vec4 VertexColor; 211 | varying vec3 Normal; 212 | 213 | uniform vec3 lightPos; 214 | uniform vec3 viewPos; 215 | 216 | void main() { 217 | // Ambient - increase to make colors more visible 218 | float ambientStrength = 0.5; // Increased from 0.3 219 | vec3 ambient = ambientStrength * VertexColor.rgb; 220 | 221 | // Diffuse - increase strength 222 | vec3 norm = normalize(Normal); 223 | vec3 lightDir = normalize(lightPos - FragPos); 224 | float diff = max(dot(norm, lightDir), 0.0); 225 | vec3 diffuse = diff * VertexColor.rgb * 0.8; // More diffuse influence 226 | 227 | // Specular - keep the same 228 | float specularStrength = 0.5; 229 | vec3 viewDir = normalize(viewPos - FragPos); 230 | vec3 reflectDir = reflect(-lightDir, norm); 231 | float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); 232 | vec3 specular = specularStrength * spec * vec3(1.0, 1.0, 1.0); 233 | 234 | // Result - ensure colors are visible regardless of lighting 235 | vec3 result = ambient + diffuse + specular; 236 | 237 | // Add a minimum brightness to ensure visibility 238 | result = max(result, VertexColor.rgb * 0.4); 239 | 240 | // Preserve alpha from vertex color for transparent objects 241 | gl_FragColor = vec4(result, VertexColor.a); 242 | } 243 | """, 244 | ) 245 | 246 | SHADER_PROGRAM = ShaderProgram( 247 | name="shader_program", 248 | vertex_shader=VERTEX_SHADER, 249 | fragment_shader=FRAGMENT_SHADER, 250 | ) 251 | 252 | 253 | # Basic fallback shader for maximum compatibility 254 | BASIC_VERTEX_SHADER = Shader( 255 | name="basic_vertex_shader", 256 | shader_type=gl.GL_VERTEX_SHADER, 257 | binding=("position", "color"), 258 | shader_source=""" 259 | #version 110 260 | 261 | attribute vec3 position; 262 | attribute vec4 color; 263 | 264 | varying vec4 fragColor; 265 | 266 | uniform mat4 modelViewProj; 267 | 268 | void main() { 269 | // Pass the color directly to the fragment shader 270 | fragColor = color; 271 | 272 | // Transform the vertex position 273 | gl_Position = modelViewProj * vec4(position, 1.0); 274 | 275 | // Set point size for better visibility 276 | gl_PointSize = 5.0; 277 | } 278 | """, 279 | ) 280 | 281 | BASIC_FRAGMENT_SHADER = Shader( 282 | name="basic_fragment_shader", 283 | shader_type=gl.GL_FRAGMENT_SHADER, 284 | shader_source=""" 285 | #version 110 286 | 287 | varying vec4 fragColor; 288 | 289 | void main() { 290 | // Use the interpolated color from the vertex shader 291 | gl_FragColor = fragColor; 292 | } 293 | """, 294 | ) 295 | 296 | BASIC_SHADER_PROGRAM = ShaderProgram( 297 | name="basic_shader_program", 298 | vertex_shader=BASIC_VERTEX_SHADER, 299 | fragment_shader=BASIC_FRAGMENT_SHADER, 300 | ) 301 | -------------------------------------------------------------------------------- /src/pythonopenscad/viewer/viewer_base.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from pyglm import glm 6 | 7 | 8 | class ViewerBase(ABC): 9 | 10 | @abstractmethod 11 | def get_projection_mat(self) -> glm.mat4: 12 | pass 13 | 14 | @abstractmethod 15 | def get_view_mat(self) -> glm.mat4: 16 | pass 17 | 18 | @abstractmethod 19 | def get_model_mat(self) -> glm.mat4: 20 | pass 21 | 22 | def get_mvp_mat(self) -> glm.mat4: 23 | return self.get_projection_mat() * self.get_view_mat() * self.get_model_mat() 24 | 25 | @abstractmethod 26 | def get_current_window_dims(self) -> tuple[int, int]: 27 | pass 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/base_m3dapi_test.py: -------------------------------------------------------------------------------- 1 | 2 | import pythonopenscad as posc 3 | 4 | -------------------------------------------------------------------------------- /tests/base_test.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Basic set of PythonOpenScad tests. 3 | 4 | ''' 5 | 6 | import unittest 7 | import pythonopenscad as base 8 | 9 | 10 | class Test(unittest.TestCase): 11 | # Check for duplicate names. 12 | def make_broken_duplicate_arg_named_specifier(self): 13 | base.OpenScadApiSpecifier( 14 | 'translate', 15 | ( 16 | base.Arg( 17 | 'v', base.VECTOR3_FLOAT, None, '(x,y,z) translation vector.', required=True 18 | ), 19 | base.Arg( 20 | 'v', base.VECTOR3_FLOAT, None, '(x,y,z) translation vector.', required=True 21 | ), 22 | ), 23 | 'url', 24 | ) 25 | 26 | def testDuplicateNameSpecifier(self): 27 | self.assertRaisesRegex( 28 | base.DuplicateNamingOfArgs, 29 | 'Duplicate parameter names \\[\'v\'\\]', 30 | self.make_broken_duplicate_arg_named_specifier, 31 | ) 32 | 33 | def testLinear_ExtrudeConstruction(self): 34 | obj = base.Linear_Extrude(scale=10) 35 | self.assertEqual(repr(obj), 'linear_extrude(height=100.0, scale=10.0)\n') 36 | 37 | def testCylinderConstruction(self): 38 | obj = base.Cylinder(10, 11) 39 | self.assertEqual(obj.h, 10, "Expected h 10") 40 | self.assertEqual(obj.r, 11, "Expected r 11") 41 | self.assertEqual(obj.get_r1(), 11, "Expected computed actual radius.") 42 | 43 | obj = base.Cylinder(10, 11, d=10) 44 | self.assertEqual(obj.h, 10, "Expected h 10") 45 | self.assertEqual(obj.r, 11, "Expected r 11") 46 | self.assertEqual(obj.get_r1(), 5, "Expected computed actual radius.") 47 | 48 | def testPoscModifiers(self): 49 | pm = base.PoscModifiers() 50 | self.assertFalse(pm.get_modifiers()) 51 | self.assertRaisesRegex(base.InvalidModifier, '.*x.*not a valid.*', pm.add_modifier, 'x') 52 | pm.add_modifier(base.DEBUG) 53 | self.assertEqual(pm.get_modifiers(), '#') 54 | pm.add_modifier(base.SHOW_ONLY) 55 | self.assertEqual(pm.get_modifiers(), '!#') 56 | pm.remove_modifier(base.DEBUG) 57 | self.assertEqual(pm.get_modifiers(), '!') 58 | 59 | obj = base.Cylinder(10, 11) 60 | obj.add_modifier(base.SHOW_ONLY) 61 | 62 | def testDocument_init_decorator_compains_about_init(self): 63 | class Z(base.PoscBase): 64 | OSC_API_SPEC = base.OpenScadApiSpecifier( 65 | 'zzz', (base.Arg('v', int, None, 'some value'),), 'url' 66 | ) 67 | 68 | def __init__(self): 69 | pass 70 | 71 | self.assertRaisesRegex( 72 | base.InitializerNotAllowed, 73 | 'class Z should not define __init__', 74 | base.apply_posc_attributes, 75 | Z, 76 | ) 77 | 78 | def testtestDocument_init_decorator(self): 79 | @base.apply_posc_attributes 80 | class Z(base.PoscBase): 81 | OSC_API_SPEC = base.OpenScadApiSpecifier( 82 | 'zzz', (base.Arg('v', int, None, 'some value'),), 'url' 83 | ) 84 | 85 | self.assertRegex(Z.__doc__, 'zzz', 'init_decorator failed to add docstring.') 86 | 87 | def testCodeDumper(self): 88 | cd = base.CodeDumper() 89 | self.assertRaisesRegex( 90 | base.IndentLevelStackEmpty, 91 | 'Empty indent level stack cannot be popped.', 92 | cd.pop_indent_level, 93 | ) 94 | line = 'A line\n' 95 | cd.write_line(line[:-1]) 96 | self.assertEqual(cd.writer.get(), line) 97 | 98 | cd = base.CodeDumper() 99 | cd.push_increase_indent() 100 | cd.write_line(line[:-1]) 101 | self.assertEqual(cd.writer.get(), ' ' + line) 102 | 103 | cd.pop_indent_level() 104 | cd.write_line(line[:-1]) 105 | self.assertEqual(cd.writer.get(), ' ' + line + line) 106 | 107 | self.assertRaisesRegex( 108 | base.IndentLevelStackEmpty, 109 | 'Empty indent level stack cannot be popped.', 110 | cd.pop_indent_level, 111 | ) 112 | 113 | def testCodeDumper_Function(self): 114 | cd = base.CodeDumper() 115 | cd.write_function('fname', ['a=1', 'b=2']) 116 | expected = 'fname(a=1, b=2)' 117 | self.assertEqual(cd.writer.get(), expected + ';\n') 118 | 119 | cd = base.CodeDumper() 120 | cd.push_increase_indent() 121 | cd.write_function('fname', ['a=1', 'b=2']) 122 | self.assertEqual(cd.writer.get(), ' ' + expected + ';\n') 123 | 124 | cd = base.CodeDumper() 125 | cd.push_increase_indent() 126 | cd.write_function('fname', ['a=1', 'b=2'], mod_prefix='!*') 127 | self.assertEqual(cd.writer.get(), ' !*' + expected + ';\n') 128 | 129 | cd = base.CodeDumper() 130 | cd.push_increase_indent() 131 | cd.write_function('fname', ['a=1', 'b=2'], mod_prefix='!*', suffix='') 132 | self.assertEqual(cd.writer.get(), ' !*' + expected + '\n') 133 | 134 | def testDumper(self): 135 | obj = base.Cylinder(10, 11) 136 | obj.add_modifier(base.DEBUG) 137 | cd = base.CodeDumper() 138 | obj.code_dump(cd) 139 | self.assertEqual(cd.writer.get(), '#cylinder(h=10.0, r=11.0, center=false);\n') 140 | 141 | def testTranslate(self): 142 | obj = base.Translate((10,)) 143 | self.assertEqual(str(obj), 'translate(v=[10.0, 0.0, 0.0]);\n') 144 | 145 | def testTranslateCylinder(self): 146 | obj = base.Cylinder(10, 11).translate((10,)) 147 | self.assertEqual( 148 | str(obj), 149 | 'translate(v=[10.0, 0.0, 0.0]) {\n cylinder(h=10.0, r=11.0, center=false);\n}\n', 150 | ) 151 | 152 | def testPassingByName(self): 153 | obj = base.Cylinder(h=10, r=11) 154 | 155 | def test_list_of(self): 156 | v = base.list_of(base.list_of(int, fill_to_min=0), fill_to_min=[1, 1, 1])([[0]]) 157 | self.assertEqual(repr(v), '[[0, 0, 0], [1, 1, 1], [1, 1, 1]]') 158 | 159 | def testRotateA_AX(self): 160 | obj = base.Rotate(10) 161 | self.assertEqual(str(obj), 'rotate(a=10.0);\n') 162 | obj = base.Rotate(10, [1, 1, 1]) 163 | self.assertEqual(str(obj), 'rotate(a=10.0, v=[1.0, 1.0, 1.0]);\n') 164 | 165 | def testRotateA3(self): 166 | obj = base.Rotate([10]) 167 | self.assertEqual(str(obj), 'rotate(a=[10.0, 0.0, 0.0]);\n') 168 | 169 | def test_List_of(self): 170 | converter = base.list_of(int, len_min_max=(None, None)) 171 | self.assertEqual(converter([]), []) 172 | self.assertEqual(converter([1.0]), [1]) 173 | self.assertEqual(converter([1.0] * 100), [1] * 100) 174 | 175 | def test_of_set(self): 176 | converter = base.of_set('a', 'b') 177 | self.assertRaisesRegex(base.InvalidValue, '\'c\' is not allowed with .*', converter, 'c') 178 | self.assertEqual(converter('a'), 'a') 179 | self.assertEqual(converter('b'), 'b') 180 | 181 | def test_osc_true_false(self): 182 | self.assertFalse(base.OSC_FALSE) 183 | self.assertTrue(base.OSC_TRUE) 184 | 185 | def test_offset(self): 186 | self.assertEqual(base.Offset(r=3.0).r, 3.0) 187 | self.assertEqual(base.Offset().r, 1.0) 188 | 189 | def testModifiers(self): 190 | obj = base.Cylinder(h=1) 191 | self.assertFalse(obj.has_modifier(base.DEBUG)) 192 | obj = base.Cylinder(h=1).add_modifier(base.DEBUG, base.TRANSPARENT) 193 | self.assertEqual(obj.get_modifiers(), '#%') 194 | obj.remove_modifier(base.DEBUG) 195 | self.assertEqual(obj.get_modifiers(), '%') 196 | self.assertEqual(str(obj), '%cylinder(h=1.0, r=1.0, center=false);\n') 197 | self.assertEqual( 198 | repr(obj), 'cylinder(h=1.0, r=1.0, center=False).add_modifier(*{TRANSPARENT})\n' 199 | ) 200 | self.assertFalse(obj.has_modifier(base.DEBUG)) 201 | self.assertTrue(obj.has_modifier(base.TRANSPARENT)) 202 | 203 | def testMetadataName(self): 204 | obj = base.Sphere() 205 | self.assertEqual(str(obj), 'sphere(r=1.0);\n') 206 | obj.setMetadataName("a_name") 207 | self.assertEqual(str(obj), "// 'a_name'\nsphere(r=1.0);\n") 208 | obj.setMetadataName(('a', 'tuple')) 209 | self.assertEqual(str(obj), "// ('a', 'tuple')\nsphere(r=1.0);\n") 210 | 211 | def testFill(self): 212 | obj1 = base.Circle(r=10) 213 | obj2 = base.Circle(r=5) 214 | 215 | result = base.difference()(obj1, obj2).fill() 216 | 217 | self.assertEqual( 218 | str(result), 219 | '\n'.join( 220 | ( 221 | 'fill() {', 222 | ' difference() {', 223 | ' circle(r=10.0);', 224 | ' circle(r=5.0);', 225 | ' }', 226 | '}\n', 227 | ) 228 | ), 229 | ) 230 | 231 | def testLazyUnion(self): 232 | obj1 = base.Circle(r=10) 233 | obj2 = base.Circle(r=5) 234 | 235 | result = base.lazy_union()(obj1, obj2) 236 | result.setMetadataName("a_name") 237 | 238 | self.assertEqual( 239 | repr(result), '''# 'a_name'\nlazy_union() (\n circle(r=10.0),\n circle(r=5.0)\n),\n''' 240 | ) 241 | 242 | self.assertEqual( 243 | str(result), 244 | '\n'.join( 245 | ( 246 | '// Start: lazy_union', 247 | 'circle(r=10.0);', 248 | 'circle(r=5.0);', 249 | '// End: lazy_union\n', 250 | ) 251 | ), 252 | ) 253 | 254 | def testModules(self): 255 | obj1 = base.module('obj1')(base.Circle(r=10)) 256 | obj1.setMetadataName("obj 1") 257 | obj2 = base.module('obj2')(base.Circle(r=5)) 258 | obj2.setMetadataName("obj 2") 259 | 260 | result = base.lazy_union()(obj1, obj2) 261 | result.setMetadataName("a_name") 262 | 263 | # Debug helper - uncomment to print the result. 264 | # print('str: ') 265 | # print(self.dump_str(str(result))) 266 | # print('repr: ') 267 | # print(self.dump_str(repr(result))) 268 | 269 | self.assertEqual( 270 | str(result), 271 | '\n'.join( 272 | [ 273 | '// Start: lazy_union', 274 | 'obj1();', 275 | 'obj2();', 276 | '// End: lazy_union', 277 | '', 278 | '// Modules.', 279 | '', 280 | "// 'obj 1'", 281 | 'module obj1() {', 282 | ' circle(r=10.0);', 283 | '} // end module obj1', 284 | '', 285 | "// 'obj 2'", 286 | 'module obj2() {', 287 | ' circle(r=5.0);', 288 | '} // end module obj2', 289 | '', 290 | ] 291 | ), 292 | ) 293 | 294 | self.assertEqual( 295 | repr(result), 296 | '\n'.join( 297 | [ 298 | "# 'a_name'", 299 | 'lazy_union() (', 300 | ' obj1();', 301 | ' obj2();', 302 | '),', 303 | '', 304 | '# Modules.', 305 | '', 306 | "# 'obj 1'", 307 | 'def obj1(): return (', 308 | ' circle(r=10.0)', 309 | '), # end module obj1', 310 | '', 311 | "# 'obj 2'", 312 | 'def obj2(): return (', 313 | ' circle(r=5.0)', 314 | '), # end module obj2', 315 | '', 316 | ] 317 | ), 318 | ) 319 | 320 | def testModules_nameCollision(self): 321 | obj1 = base.module('colliding_name')(base.Circle(r=10)) 322 | obj1.setMetadataName("obj 1") 323 | obj2 = base.module('colliding_name')(base.Circle(r=5)) 324 | obj2.setMetadataName("obj 2") 325 | 326 | result = base.lazy_union()(obj1, obj2) 327 | result.setMetadataName("a_name") 328 | 329 | # Debug helper - uncomment to print the result. 330 | # print('str: ') 331 | # print(self.dump_str(str(result))) 332 | # print('repr: ') 333 | # print(self.dump_str(repr(result))) 334 | 335 | self.assertEqual( 336 | str(result), 337 | '\n'.join( 338 | [ 339 | '// Start: lazy_union', 340 | 'colliding_name();', 341 | 'colliding_name_1();', 342 | '// End: lazy_union', 343 | '', 344 | '// Modules.', 345 | '', 346 | "// 'obj 1'", 347 | 'module colliding_name() {', 348 | ' circle(r=10.0);', 349 | '} // end module colliding_name', 350 | '', 351 | "// 'obj 2'", 352 | 'module colliding_name_1() {', 353 | ' circle(r=5.0);', 354 | '} // end module colliding_name_1', 355 | '', 356 | ] 357 | ), 358 | ) 359 | 360 | def testModules_nameCollision_elided(self): 361 | obj1 = base.module('colliding_name')(base.Circle(r=10)) 362 | obj1.setMetadataName("obj 1") 363 | obj2 = base.module('colliding_name')(base.Circle(r=10)) 364 | obj2.setMetadataName("obj 2") 365 | 366 | result = base.lazy_union()(obj1, obj2) 367 | result.setMetadataName("a_name") 368 | 369 | # Debug helper - uncomment to print the result. 370 | # print('str: ') 371 | # print(self.dump_str(str(result))) 372 | # print('repr: ') 373 | # print(self.dump_str(repr(result))) 374 | 375 | self.assertEqual( 376 | str(result), 377 | '\n'.join( 378 | [ 379 | '// Start: lazy_union', 380 | 'colliding_name();', 381 | 'colliding_name();', 382 | '// End: lazy_union', 383 | '', 384 | '// Modules.', 385 | '', 386 | "// 'obj 2'", 387 | 'module colliding_name() {', 388 | ' circle(r=10.0);', 389 | '} // end module colliding_name', 390 | '', 391 | ] 392 | ), 393 | ) 394 | 395 | def dump_str(self, s): 396 | return '[\n' + ',\n'.join([repr(l) for l in s.split('\n')]) + ']' 397 | 398 | 399 | if __name__ == "__main__": 400 | # import sys;sys.argv = ['', 'Test.testName'] 401 | unittest.main() 402 | -------------------------------------------------------------------------------- /tests/base_viewer_test.py: -------------------------------------------------------------------------------- 1 | from pythonopenscad.m3dapi import M3dRenderer 2 | from pythonopenscad.posc_main import posc_main, PoscModel 3 | import pythonopenscad as posc 4 | from pythonopenscad import PoscBase 5 | from pythonopenscad.viewer.model import Model 6 | 7 | def test_base_viewer(): 8 | def make_model(): 9 | linear_extrusion = posc.Color("darkseagreen")( 10 | posc.Translate([0.0, 0.0, 4.5])( 11 | posc.Linear_Extrude(height=3.0, twist=45, slices=16, scale=(2.5, 0.5))( 12 | posc.Translate([0.0, 0.0, 0.0])(posc.Square([1.0, 1.0])) 13 | ) 14 | ) 15 | ) 16 | return linear_extrusion 17 | 18 | #posc_main([make_model]) 19 | 20 | def make_model() -> PoscBase: 21 | return posc.Color("darkseagreen")( 22 | posc.Translate([0.0, 0.0, 4.5])( 23 | posc.Linear_Extrude(height=3.0, twist=45, slices=16, scale=(2.5, 0.5))( 24 | posc.Translate([0.0, 0.0, 0.0])(posc.Square([1.0, 1.0])) 25 | ) 26 | ) 27 | ) 28 | 29 | def test_base_viewer2(): 30 | model = make_model() 31 | # Save to OpenSCAD file 32 | model.write('my_model.scad') 33 | 34 | # Render to STL 35 | rc = model.renderObj(M3dRenderer()) 36 | #rc.write_solid_stl("mystl.stl") 37 | 38 | # Or, view the result in a 3D viewer. 39 | #posc_main([make_model]) 40 | 41 | if __name__ == '__main__': 42 | test_base_viewer2() 43 | 44 | -------------------------------------------------------------------------------- /tests/m3dapi_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | import manifold3d as m3d 4 | from pythonopenscad.m3dapi import ( 5 | M3dRenderer, 6 | RenderContext, 7 | RenderContextManifold, 8 | triangulate_3d_face, 9 | _make_array, 10 | manifold_to_stl, 11 | Mode 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def m3d_api(): 17 | return M3dRenderer() 18 | 19 | 20 | def assert_rendercontext(rc: RenderContext): 21 | assert isinstance(rc, RenderContext), "not a RenderContext" 22 | assert all(isinstance(m, m3d.Manifold) for m in rc.solid_objs), ( 23 | "solid_manifold not all Manifold" 24 | ) 25 | assert all(isinstance(m, m3d.Manifold) for m in rc.shell_objs), ( 26 | "shell_manifold not all Manifold" 27 | ) 28 | 29 | 30 | def test_cube(): 31 | api = M3dRenderer() 32 | result = api._cube((1.0, 2.0, 3.0)) 33 | assert_rendercontext(result) 34 | 35 | solid_manifold: m3d.Manifold = result.get_solid_manifold() 36 | assert isinstance(solid_manifold, m3d.Manifold) 37 | 38 | 39 | def test_write_stl(): 40 | api = M3dRenderer() 41 | result = api._cube((1.0, 2.0, 3.0)) 42 | 43 | solid_manifold: m3d.Manifold = result.get_solid_manifold() 44 | assert isinstance(solid_manifold, m3d.Manifold) 45 | 46 | # Use BytesIO instead of a file 47 | import io 48 | stl_file = io.BytesIO() 49 | manifold_to_stl(solid_manifold, "test_cube.stl", file_obj=stl_file) 50 | 51 | # Reset buffer position to start for reading 52 | stl_file.seek(0) 53 | 54 | import stl 55 | 56 | mesh = stl.mesh.Mesh.from_file("test_cube.stl", fh=stl_file) 57 | assert isinstance(mesh, stl.mesh.Mesh) 58 | assert mesh.v0.shape == (12, 3) 59 | 60 | def test_sphere(): 61 | api = M3dRenderer() 62 | result = api._sphere(radius=1.0, fn=32) 63 | assert_rendercontext(result) 64 | 65 | 66 | def test_cylinder(): 67 | api = M3dRenderer() 68 | # Test regular cylinder 69 | result = api._cylinder(h=2.0, r_base=1.0) 70 | assert_rendercontext(result) 71 | 72 | # Test cone 73 | result = api._cylinder(h=2.0, r_base=1.0, r_top=0.5) 74 | assert_rendercontext(result) 75 | 76 | # Test centered cylinder 77 | result = api._cylinder(h=2.0, r_base=1.0, center=True) 78 | assert_rendercontext(result) 79 | 80 | 81 | def test_polyhedron(): 82 | api = M3dRenderer() 83 | # Create a cylinder. 84 | count: int = 64 85 | radius: float = 10 86 | height: float = 15 87 | points2d: np.ndarray = ( 88 | np.array([ 89 | ( 90 | np.cos(i * 2 * np.pi / count), 91 | np.sin(i * 2 * np.pi / count), 92 | ) 93 | for i in range(count) 94 | ]) 95 | * radius 96 | ) 97 | 98 | upper_points3d: np.ndarray = np.hstack((points2d, np.tile((height,), (count, 1)))) 99 | lower_points3d: np.ndarray = np.hstack((points2d, np.tile((0.0,), (count, 1)))) 100 | 101 | points: np.ndarray = np.vstack((lower_points3d, upper_points3d)) 102 | 103 | faces: list[tuple[int, int, int, int]] = [] 104 | 105 | faces.append(list(range(count - 1, -1, -1))) 106 | faces.append(list(range(count, 2 * count))) 107 | 108 | for i in range(count): 109 | j = (i + 1) % count 110 | faces.append((i, j, j + count, i + count)) 111 | 112 | 113 | angle = 0 * np.pi / 4 114 | # Rotate the points3d by around the Y-axis to make the tesselator do something. 115 | cosr = np.cos(angle) 116 | sinr = np.sin(angle) 117 | # rotation_matrix = np.array([ 118 | # [cosr, 0, sinr], 119 | # [0, 1, 0], 120 | # [-sinr, 0, cosr], 121 | # ]) 122 | rotation_matrix = np.array([ 123 | [1, 0, 0], 124 | [0, cosr, -sinr], 125 | [0, sinr, cosr], 126 | ]) 127 | points = points @ rotation_matrix 128 | 129 | result = api._polyhedron(points, faces) 130 | assert isinstance(result, RenderContext) 131 | 132 | manifold = result.get_solid_manifold() 133 | #manifold_to_stl(manifold, "test_polyhedron.stl", mode=Mode.ASCII) 134 | m_mesh = manifold.to_mesh() 135 | tri_verts = m_mesh.tri_verts 136 | points = m_mesh.vert_properties[:, :3] 137 | 138 | assert len(tri_verts) == count * 4 - 4 139 | assert points.shape == (256, 3) # Normals added doubles points. 140 | 141 | 142 | def test_triangulate_3d_face(): 143 | # Test with a simple square face 144 | verts = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32) 145 | 146 | face = [0, 1, 2, 3] 147 | 148 | triangles = triangulate_3d_face(verts, [face]) 149 | assert len(triangles) == 2 # Should produce 2 triangles (6 indices) 150 | 151 | # Verify that all indices are within bounds 152 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 153 | 154 | 155 | def test_triangulate_3d_face_already_triangle(): 156 | # Test with an already triangular face 157 | verts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) 158 | 159 | face = [0, 1, 2] 160 | 161 | triangles = triangulate_3d_face(verts, [face]) 162 | assert triangles == [face] # Should return the same face unchanged 163 | 164 | 165 | def test_triangulate_3d_face_non_planar(): 166 | # Test with a non-planar face 167 | verts = np.array( 168 | [ 169 | [0, 0, 0], 170 | [1, 0, 0], 171 | [1, 1, 1], # Note the z=1 172 | [0, 1, 0], 173 | ], 174 | dtype=np.float32, 175 | ) 176 | 177 | face = [0, 1, 2, 3] 178 | 179 | triangles = triangulate_3d_face(verts, [face]) 180 | assert len(triangles) == 2 # Should still produce 2 triangles 181 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 182 | 183 | 184 | def test_make_array(): 185 | api = M3dRenderer() 186 | 187 | # Test with list 188 | input_list = [1, 2, 3] 189 | result = _make_array(np.array(input_list), np.uint32) 190 | assert isinstance(result, np.ndarray) 191 | assert result.dtype == np.uint32 192 | assert result.flags.c_contiguous 193 | assert result.flags.writeable 194 | 195 | # Test with None 196 | assert _make_array(None, np.float32) is None 197 | 198 | # Test with non-contiguous array 199 | non_contiguous = np.array([[1, 2], [3, 4]])[:, :1] 200 | result = _make_array(non_contiguous, np.float32) 201 | assert result.flags.c_contiguous 202 | assert result.flags.writeable 203 | 204 | 205 | def test_transform(): 206 | api = M3dRenderer() 207 | cube = api._cube((1.0, 2.0, 3.0)) 208 | 209 | # Test translation 210 | transform = np.eye(4) 211 | transform[0:3, 3] = [1.0, 2.0, 3.0] # Translation vector 212 | result = cube.transform(transform) 213 | assert isinstance(result, RenderContextManifold) 214 | 215 | # Test rotation 216 | angle = np.pi / 2 # 45 degrees 217 | transform = np.eye(4) 218 | transform[0:3, 0:3] = np.array([ 219 | [np.cos(angle), -np.sin(angle), 0], 220 | [np.sin(angle), np.cos(angle), 0], 221 | [0, 0, 1], 222 | ]) 223 | result = cube.transform(transform) 224 | assert isinstance(result, RenderContextManifold) 225 | 226 | 227 | def test_get_resize_scale(): 228 | api = M3dRenderer() 229 | cube = api._cube((2.0, 3.0, 4.0)) # Create a cube with known dimensions 230 | 231 | # Test exact resize 232 | newsize = np.array([4.0, 6.0, 8.0]) # Double all dimensions 233 | scale = cube.getResizeScale(newsize, auto=False) 234 | np.testing.assert_array_almost_equal(scale, np.array([2.0, 2.0, 2.0])) 235 | 236 | # Test auto resize with one dimension specified 237 | newsize = np.array([10.0, 0.0, 0.0]) 238 | auto = [False, True, True] 239 | scale = cube.getResizeScale(newsize, auto) 240 | np.testing.assert_array_almost_equal(scale, np.array([5.0, 5.0, 5.0])) 241 | 242 | # Test mixed resize (some auto, some exact) 243 | newsize = np.array([10.0, 3.0, 0.0]) 244 | auto = np.array([False, False, True]) 245 | scale = cube.getResizeScale(newsize, auto) 246 | np.testing.assert_array_almost_equal(scale, np.array([5.0, 1.0, 5.0])) 247 | 248 | 249 | if __name__ == "__main__": 250 | # test_polyhedron() 251 | pytest.main([__file__]) 252 | -------------------------------------------------------------------------------- /tests/test_text_render.py: -------------------------------------------------------------------------------- 1 | # pythonopenscad/tests/test_text_render.py 2 | import unittest 3 | import numpy as np 4 | import os 5 | 6 | # Adjust the import path based on your project structure 7 | # This assumes tests are run from the project root or PYTHONPATH is set 8 | try: 9 | from pythonopenscad import text_render 10 | except ImportError: 11 | # If running directly from tests directory, adjust path 12 | import sys 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'src'))) 14 | from pythonopenscad import text_render 15 | 16 | class TestTextRender(unittest.TestCase): 17 | 18 | def test_simple_text_rendering(self): 19 | """ 20 | Tests basic text rendering with default parameters. 21 | Checks if the function runs and returns data in the expected format. 22 | """ 23 | test_string = "A" 24 | try: 25 | points, contours = text_render.render_text( 26 | text=test_string, 27 | size=10, 28 | font="Liberation Sans", # Use a known fallback font 29 | halign="left", 30 | valign="baseline", 31 | spacing=1.0, 32 | direction="ltr", 33 | language="en", 34 | script="latin", 35 | fa=None, # Use defaults 36 | fs=None, 37 | fn=None 38 | ) 39 | 40 | # Basic structural checks 41 | self.assertIsInstance(points, np.ndarray, "Points should be a NumPy array") 42 | if points.size > 0: # Only check dimensions if points were generated 43 | self.assertEqual(points.ndim, 2, "Points array should be 2-dimensional") 44 | self.assertEqual(points.shape[1], 2, "Points should have 2 columns (x, y)") 45 | 46 | self.assertIsInstance(contours, list, "Contours should be a list") 47 | if contours: # Only check elements if contours exist 48 | self.assertTrue(all(isinstance(c, np.ndarray) for c in contours), 49 | "Each contour should be a NumPy array") 50 | self.assertTrue(all(c.dtype == np.int64 or c.dtype == np.int32 for c in contours), # Check for integer indices based on system/numpy defaults 51 | "Contour arrays should contain integer indices") 52 | 53 | # Very basic check: Did we get *something* back for a simple letter? 54 | # The exact number is highly dependent on font and fragmentation. 55 | self.assertGreater(points.shape[0], 3, "Expected more than 3 points for 'A'") 56 | self.assertGreaterEqual(len(contours), 1, "Expected at least one contour for 'A'") 57 | 58 | except RuntimeError as e: 59 | # Allow test to pass if font loading fails predictably (e.g., in CI) 60 | # but fail on other unexpected RuntimeErrors. 61 | if "Could not load font" in str(e): 62 | self.skipTest(f"Skipping test: Default font not found ({e})") 63 | else: 64 | self.fail(f"Text rendering failed with unexpected error: {e}") 65 | except ImportError as e: 66 | if 'freetype' in str(e): 67 | self.skipTest(f"Skipping test: freetype-py not installed ({e})") 68 | else: 69 | self.fail(f"Text rendering failed with unexpected import error: {e}") 70 | 71 | 72 | # TODO: Add more tests: 73 | # - Test different alignments (halign, valign) by checking bounding boxes 74 | # - Test 'spacing' by comparing output width 75 | # - Test 'size' by comparing output height/bounds 76 | # - Test explicit '$fn' values (e.g., low vs high) by checking point counts 77 | # - Test font not found error handling more specifically 78 | # - Test more complex strings ("Hello", strings requiring kerning/ligatures if HarfBuzz is added) 79 | # - Test different directions (rtl, ttb, btt) when implemented 80 | # - Test get_polygons_at when implemented 81 | 82 | if __name__ == '__main__': 83 | unittest.main() -------------------------------------------------------------------------------- /tests/test_text_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pythonopenscad.text_utils import CubicSpline, QuadraticSpline, extentsof 3 | from anchorscad_lib.test_tools import iterable_assert 4 | import numpy as np 5 | 6 | 7 | class TestTextUtils(unittest.TestCase): 8 | CUBIC_POINTS1 = [ 9 | (0, -1), 10 | (1, -1), 11 | (1, 1), 12 | (0, 1), 13 | ] 14 | QUADRATIC_POINTS1 = [ 15 | (0, -1), 16 | (1, 0), 17 | (0, 1), 18 | ] 19 | 20 | def test_cubic_spline(self): 21 | cubic = CubicSpline(self.CUBIC_POINTS1) 22 | iterable_assert(self.assertAlmostEqual, cubic.evaluate(0), (0, -1)) 23 | iterable_assert(self.assertAlmostEqual, cubic.evaluate(1), (0, 1)) 24 | iterable_assert(self.assertAlmostEqual, cubic.evaluate(0.5), (0.75, 0)) 25 | 26 | iterable_assert( 27 | self.assertAlmostEqual, cubic.evaluate([0, 0.5, 1]), ((0, -1), (0.75, 0), (0, 1)) 28 | ) 29 | 30 | def test_cubic_azimuth_t(self): 31 | cubic = CubicSpline(self.CUBIC_POINTS1) 32 | iterable_assert(self.assertAlmostEqual, cubic.azimuth_t(0), (0, 1)) 33 | iterable_assert(self.assertAlmostEqual, cubic.azimuth_t(90), (0.5,)) 34 | iterable_assert(self.assertAlmostEqual, cubic.azimuth_t(45), (0.19098300,)) 35 | 36 | def test_cubic_derivative(self): 37 | cubic = CubicSpline(self.CUBIC_POINTS1) 38 | iterable_assert(self.assertAlmostEqual, cubic.derivative(0), (-3, 0)) 39 | iterable_assert(self.assertAlmostEqual, cubic.derivative(1), (3, 0)) 40 | iterable_assert(self.assertAlmostEqual, cubic.derivative(0.5), (0, -3)) 41 | 42 | iterable_assert( 43 | self.assertAlmostEqual, cubic.derivative([0, 0.5, 1]), ((-3, 0), (0, -3), (3, 0)) 44 | ) 45 | 46 | def test_cubic_normal2d(self): 47 | cubic = CubicSpline(self.CUBIC_POINTS1) 48 | iterable_assert(self.assertAlmostEqual, cubic.normal2d(0), (0, 1)) 49 | iterable_assert(self.assertAlmostEqual, cubic.normal2d(1), (0, -1)) 50 | iterable_assert(self.assertAlmostEqual, cubic.normal2d(0.5), (-1, 0)) 51 | 52 | iterable_assert( 53 | self.assertAlmostEqual, cubic.normal2d([0, 0.5, 1]), ((0, 1), (-1, 0), (0, -1)) 54 | ) 55 | 56 | def test_quadratic_spline(self): 57 | quadratic = QuadraticSpline(self.QUADRATIC_POINTS1) 58 | iterable_assert(self.assertAlmostEqual, quadratic.evaluate(0), (0, -1)) 59 | iterable_assert(self.assertAlmostEqual, quadratic.evaluate(1), (0, 1)) 60 | iterable_assert(self.assertAlmostEqual, quadratic.evaluate(0.5), (0.5, 0)) 61 | 62 | iterable_assert( 63 | self.assertAlmostEqual, quadratic.evaluate([0, 0.5, 1]), ((0, -1), (0.5, 0), (0, 1)) 64 | ) 65 | 66 | def test_quadratic_azimuth_t(self): 67 | quadratic = QuadraticSpline(self.QUADRATIC_POINTS1) 68 | iterable_assert(self.assertAlmostEqual, quadratic.azimuth_t(45), (0.5,)) 69 | iterable_assert(self.assertAlmostEqual, quadratic.azimuth_t(90), (1,)) 70 | 71 | def test_quadratic_derivative(self): 72 | quadratic = QuadraticSpline(self.QUADRATIC_POINTS1) 73 | iterable_assert(self.assertAlmostEqual, quadratic.derivative(0), (2, 2)) 74 | iterable_assert(self.assertAlmostEqual, quadratic.derivative(1), (-2, 2)) 75 | iterable_assert(self.assertAlmostEqual, quadratic.derivative(0.5), (0, 2)) 76 | 77 | iterable_assert( 78 | self.assertAlmostEqual, quadratic.derivative([0, 0.5, 1]), ((2, 2), (0, 2), (-2, 2)) 79 | ) 80 | 81 | def test_quadratic_normal2d(self): 82 | quadratic = QuadraticSpline(self.QUADRATIC_POINTS1) 83 | sqrt2 = 1 / np.sqrt(2) 84 | iterable_assert(self.assertAlmostEqual, quadratic.normal2d(0), (sqrt2, -sqrt2)) 85 | iterable_assert(self.assertAlmostEqual, quadratic.normal2d(1), (sqrt2, sqrt2)) 86 | iterable_assert(self.assertAlmostEqual, quadratic.normal2d(0.5), (1, 0)) 87 | 88 | iterable_assert( 89 | self.assertAlmostEqual, 90 | quadratic.normal2d([0, 0.5, 1]), 91 | ((sqrt2, -sqrt2), (1, 0), (sqrt2, sqrt2)), 92 | ) 93 | 94 | def test_cubic_extentsof(self): 95 | cubic = CubicSpline(self.CUBIC_POINTS1) 96 | iterable_assert(self.assertAlmostEqual, cubic.extents(), [[0.0, -1.0], [0.75, 1.0]]) 97 | 98 | def test_quadratic_extentsof(self): 99 | quadratic = QuadraticSpline(self.QUADRATIC_POINTS1) 100 | iterable_assert(self.assertAlmostEqual, quadratic.extents(), [[0.0, -1.0], [0.5, 1.0]]) 101 | 102 | 103 | if __name__ == "__main__": 104 | unittest.main() 105 | -------------------------------------------------------------------------------- /tests/test_triangulate_3d_face.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from pythonopenscad.m3dapi import triangulate_3d_face 4 | 5 | def test_simple_square(): 6 | """Test triangulation of a simple square face in XY plane.""" 7 | verts = np.array([ 8 | [0, 0, 0], 9 | [1, 0, 0], 10 | [1, 1, 0], 11 | [0, 1, 0] 12 | ], dtype=np.float32) 13 | 14 | face = [0, 1, 2, 3] 15 | 16 | triangles = triangulate_3d_face(verts, [face]) 17 | assert len(triangles) == 2 # Should produce 2 triangles 18 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 19 | 20 | # Verify that all original edges are preserved 21 | edges = {(face[i], face[(i+1)%4]) for i in range(4)} 22 | triangulation_edges = set() 23 | for tri in triangles: 24 | triangulation_edges.add((tri[0], tri[1])) 25 | triangulation_edges.add((tri[1], tri[2])) 26 | triangulation_edges.add((tri[2], tri[0])) 27 | triangulation_edges.add((tri[1], tri[0])) 28 | triangulation_edges.add((tri[2], tri[1])) 29 | triangulation_edges.add((tri[0], tri[2])) 30 | 31 | # Check that each original edge appears in the triangulation 32 | for e1, e2 in edges: 33 | assert ((e1, e2) in triangulation_edges or 34 | (e2, e1) in triangulation_edges), f"Edge {(e1, e2)} not found in triangulation" 35 | 36 | def test_already_triangle(): 37 | """Test handling of an already triangular face.""" 38 | verts = np.array([ 39 | [0, 0, 0], 40 | [1, 0, 0], 41 | [0, 1, 0] 42 | ], dtype=np.float32) 43 | 44 | face = [0, 1, 2] 45 | 46 | triangles = triangulate_3d_face(verts, [face]) 47 | assert triangles == [face] # Should return the same face unchanged 48 | 49 | def test_non_planar_face(): 50 | """Test triangulation of a non-planar face.""" 51 | verts = np.array([ 52 | [0, 0, 0], 53 | [1, 0, 0], 54 | [1, 1, 1], # Note the z=1 55 | [0, 1, 0] 56 | ], dtype=np.float32) 57 | 58 | face = [0, 1, 2, 3] 59 | 60 | triangles = triangulate_3d_face(verts, [face]) 61 | assert len(triangles) == 2 # Should still produce 2 triangles 62 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 63 | 64 | def test_pentagon(): 65 | """Test triangulation of a pentagon (5-sided polygon).""" 66 | verts = np.array([ 67 | [0, 0, 0], 68 | [2, 0, 0], 69 | [3, 2, 0], 70 | [1.5, 3, 0], 71 | [0, 2, 0] 72 | ], dtype=np.float32) 73 | 74 | face = [0, 1, 2, 3, 4] 75 | 76 | triangles = triangulate_3d_face(verts, [face]) 77 | assert len(triangles) == 3 # A pentagon should be triangulated into 3 triangles 78 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 79 | 80 | # Verify that all original edges are preserved 81 | edges = {(face[i], face[(i+1)%5]) for i in range(5)} 82 | triangulation_edges = set() 83 | for tri in triangles: 84 | triangulation_edges.add((tri[0], tri[1])) 85 | triangulation_edges.add((tri[1], tri[2])) 86 | triangulation_edges.add((tri[2], tri[0])) 87 | triangulation_edges.add((tri[1], tri[0])) 88 | triangulation_edges.add((tri[2], tri[1])) 89 | triangulation_edges.add((tri[0], tri[2])) 90 | 91 | # Check that each original edge appears in the triangulation 92 | for e1, e2 in edges: 93 | assert ((e1, e2) in triangulation_edges or 94 | (e2, e1) in triangulation_edges), f"Edge {(e1, e2)} not found in triangulation" 95 | 96 | def test_vertical_face(): 97 | """Test triangulation of a vertical face (perpendicular to XY plane).""" 98 | verts = np.array([ 99 | [0, 0, 0], 100 | [0, 1, 0], 101 | [0, 1, 1], 102 | [0, 0, 1] 103 | ], dtype=np.float32) 104 | 105 | face = [0, 1, 2, 3] 106 | 107 | triangles = triangulate_3d_face(verts, [face]) 108 | assert len(triangles) == 2 # Should produce 2 triangles 109 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 110 | 111 | def test_concave_face(): 112 | """Test triangulation of a concave polygon (U-shape).""" 113 | verts = np.array([ 114 | [0, 0, 0.5], # 0 115 | [3, 0, -0.5], # 1 116 | [3, 1, 0], # 2 117 | [2, 1, 0.2], # 3 118 | [2, 2, 0.3], # 4 119 | [1, 2, 0.1], # 5 120 | [1, 1, 0.2], # 6 121 | [0, 1, 0] # 7 122 | ], dtype=np.float32) 123 | 124 | face = [0, 1, 2, 3, 4, 5, 6, 7] 125 | 126 | triangles = triangulate_3d_face(verts, [face]) 127 | assert len(triangles) == 6 # A U-shape should be triangulated into 6 triangles 128 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 129 | 130 | def test_slanted_face(): 131 | """Test triangulation of a face not aligned with any primary plane.""" 132 | verts = np.array([ 133 | [0, 0, 0], 134 | [1, 0, 1], 135 | [1, 1, 2], 136 | [0, 1, 1] 137 | ], dtype=np.float32) 138 | 139 | face = [0, 1, 2, 3] 140 | 141 | triangles = triangulate_3d_face(verts, [face]) 142 | assert len(triangles) == 2 # Should produce 2 triangles 143 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 144 | 145 | def test_area_preservation(): 146 | """Test that the triangulation preserves the area of the original polygon.""" 147 | # Create a simple square of known area 148 | verts = np.array([ 149 | [0, 0, 0], 150 | [2, 0, 0], 151 | [2, 2, 0], 152 | [0, 2, 0] 153 | ], dtype=np.float32) 154 | 155 | face = [0, 1, 2, 3] 156 | original_area = 4.0 # 2x2 square 157 | 158 | triangles = triangulate_3d_face(verts, [face]) 159 | 160 | # Calculate total area of triangles 161 | total_area = 0 162 | for tri in triangles: 163 | v1 = verts[tri[1]] - verts[tri[0]] 164 | v2 = verts[tri[2]] - verts[tri[0]] 165 | # Area = magnitude of cross product / 2 166 | area = np.linalg.norm(np.cross(v1, v2)) / 2 167 | total_area += area 168 | 169 | np.testing.assert_almost_equal(total_area, original_area, decimal=5) 170 | 171 | def test_winding_order(): 172 | """Test that the triangulation preserves the winding order of vertices.""" 173 | verts = np.array([ 174 | [0, 0, 0], 175 | [1, 0, 0], 176 | [1, 1, 0], 177 | [0, 1, 0] 178 | ], dtype=np.float32) 179 | 180 | face = [0, 1, 2, 3] # CCW order 181 | triangles = triangulate_3d_face(verts, [face]) 182 | 183 | # Check each triangle maintains CCW order 184 | for tri in triangles: 185 | v1 = verts[tri[1]] - verts[tri[0]] 186 | v2 = verts[tri[2]] - verts[tri[0]] 187 | normal = np.cross(v1, v2) 188 | # For CCW order in XY plane, normal should point in +Z direction 189 | assert normal[2] > 0 190 | 191 | def test_polygon_with_hole(): 192 | """Test triangulation of a polygon with a hole.""" 193 | verts = np.array([ 194 | # Outer square vertices (CCW) 195 | [0, 0, 0], # 0 196 | [4, 0, 0], # 1 197 | [4, 4, 0], # 2 198 | [0, 4, 0], # 3 199 | # Inner square vertices (CW to make it a hole) 200 | [1, 1, 0], # 4 201 | [1, 3, 0], # 5 202 | [3, 3, 0], # 6 203 | [3, 1, 0], # 7 204 | ], dtype=np.float32) 205 | 206 | # Define outer polygon (CCW) and inner polygon (CW) 207 | outer = [0, 1, 2, 3] 208 | inner = [4, 7, 6, 5] # CW order makes this a hole 209 | face = [outer, inner] 210 | 211 | triangles = triangulate_3d_face(verts, face) 212 | 213 | # A square with a square hole should be triangulated into 8 triangles 214 | assert len(triangles) == 8 215 | 216 | # Verify all indices are valid 217 | assert all(0 <= idx < len(verts) for tri in triangles for idx in tri) 218 | 219 | # Verify that all original outer edges are preserved 220 | outer_edges = {(outer[i], outer[(i+1)%4]) for i in range(4)} 221 | inner_edges = {(inner[i], inner[(i+1)%4]) for i in range(4)} 222 | triangulation_edges = set() 223 | for tri in triangles: 224 | triangulation_edges.add((tri[0], tri[1])) 225 | triangulation_edges.add((tri[1], tri[2])) 226 | triangulation_edges.add((tri[2], tri[0])) 227 | triangulation_edges.add((tri[1], tri[0])) 228 | triangulation_edges.add((tri[2], tri[1])) 229 | triangulation_edges.add((tri[0], tri[2])) 230 | 231 | # Check that each original edge appears in the triangulation 232 | for e1, e2 in outer_edges | inner_edges: 233 | assert ((e1, e2) in triangulation_edges or 234 | (e2, e1) in triangulation_edges), f"Edge {(e1, e2)} not found in triangulation" 235 | 236 | # Calculate and verify area (outer square - inner square) 237 | expected_area = 16.0 - 4.0 # 4x4 outer - 2x2 inner = 12 238 | total_area = 0 239 | for tri in triangles: 240 | v1 = verts[tri[1]] - verts[tri[0]] 241 | v2 = verts[tri[2]] - verts[tri[0]] 242 | area = np.linalg.norm(np.cross(v1, v2)) / 2 243 | total_area += area 244 | 245 | np.testing.assert_almost_equal(total_area, expected_area, decimal=5) 246 | 247 | if __name__ == '__main__': 248 | pytest.main([__file__]) -------------------------------------------------------------------------------- /tests/test_viewer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | import os 4 | 5 | # Try to import the viewer module 6 | try: 7 | from pythonopenscad.viewer.viewer import Model, Viewer, BoundingBox 8 | HAS_VIEWER = True 9 | except ImportError: 10 | HAS_VIEWER = False 11 | 12 | # Skip all tests if OpenGL is not available 13 | pytestmark = pytest.mark.skipif(not HAS_VIEWER, reason="Viewer module not available") 14 | 15 | def test_bounding_box(): 16 | """Test BoundingBox functionality.""" 17 | # Create a bounding box 18 | bbox = BoundingBox() 19 | 20 | # Test initial values 21 | assert np.all(bbox.min_point == np.array([float('inf'), float('inf'), float('inf')])) 22 | assert np.all(bbox.max_point == np.array([float('-inf'), float('-inf'), float('-inf')])) 23 | 24 | # Update min and max points 25 | bbox.min_point = np.array([-1.0, -2.0, -3.0]) 26 | bbox.max_point = np.array([4.0, 5.0, 6.0]) 27 | 28 | # Test size 29 | np.testing.assert_array_equal(bbox.size, np.array([5.0, 7.0, 9.0])) 30 | 31 | # Test center 32 | np.testing.assert_array_equal(bbox.center, np.array([1.5, 1.5, 1.5])) 33 | 34 | # Test diagonal 35 | assert abs(bbox.diagonal - np.sqrt(5**2 + 7**2 + 9**2)) < 1e-6 36 | 37 | # Test contains_point 38 | assert bbox.contains_point(np.array([0.0, 0.0, 0.0])) 39 | assert bbox.contains_point(np.array([4.0, 5.0, 6.0])) 40 | assert not bbox.contains_point(np.array([10.0, 0.0, 0.0])) 41 | 42 | # Test union 43 | other_bbox = BoundingBox( 44 | min_point=np.array([0.0, 0.0, 0.0]), 45 | max_point=np.array([10.0, 10.0, 10.0]) 46 | ) 47 | 48 | union_bbox = bbox.union(other_bbox) 49 | np.testing.assert_array_equal(union_bbox.min_point, np.array([-1.0, -2.0, -3.0])) 50 | np.testing.assert_array_equal(union_bbox.max_point, np.array([10.0, 10.0, 10.0])) 51 | 52 | def test_model_creation(): 53 | """Test Model creation with triangle data.""" 54 | # Create a simple triangle 55 | vertex_data = np.array([ 56 | # position (3) # color (4) # normal (3) 57 | -0.5, -0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 58 | 0.5, -0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 59 | 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0 60 | ], dtype=np.float32) 61 | 62 | # Create a model 63 | model = Model(vertex_data, num_points=3) 64 | 65 | # Test model properties 66 | assert model.num_points == 3 67 | assert model.position_offset == 0 68 | assert model.color_offset == 3 69 | assert model.normal_offset == 7 70 | assert model.stride == 10 71 | 72 | # Test bounding box 73 | np.testing.assert_array_almost_equal( 74 | model.bounding_box.min_point, 75 | np.array([-0.5, -0.5, 0.0]) 76 | ) 77 | np.testing.assert_array_almost_equal( 78 | model.bounding_box.max_point, 79 | np.array([0.5, 0.5, 0.0]) 80 | ) 81 | 82 | # Cleanup 83 | model.delete() 84 | 85 | def test_viewer_creation(): 86 | """Test Viewer creation with a model.""" 87 | # Skip if running in CI environment 88 | if os.environ.get('CI') == 'true': 89 | pytest.skip("Skipping in CI environment") 90 | 91 | # Create a simple model 92 | vertex_data = np.array([ 93 | # position (3) # color (4) # normal (3) 94 | -0.5, -0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 95 | 0.5, -0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 96 | 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0 97 | ], dtype=np.float32) 98 | 99 | model = Model(vertex_data, num_points=3) 100 | 101 | try: 102 | # Create a viewer with non-visible window 103 | viewer = Viewer([model], width=1, height=1, title="Test Viewer") 104 | 105 | # Test camera setup 106 | assert viewer.camera_pos is not None 107 | assert viewer.camera_front is not None 108 | assert viewer.camera_up is not None 109 | 110 | # Test shader program 111 | assert viewer.shader_program is not None 112 | 113 | # Test model storage 114 | assert len(viewer.models) == 1 115 | except Exception as e: 116 | pytest.skip(f"Viewer creation failed: {e}") 117 | finally: 118 | # Clean up resources 119 | if 'viewer' in locals(): 120 | viewer.close() 121 | model.delete() 122 | 123 | if __name__ == "__main__": 124 | pytest.main(["-v", __file__]) --------------------------------------------------------------------------------