diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1f5d11c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v1 + + - name: Sync dev dependencies + run: uv sync --group dev + + - name: Run pytest + run: uv run pytest + + - name: Build docs + run: uv run sphinx-build -b html Doc Doc/_build/html + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: Doc/_build/html \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c7489ab..26fc47d6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ parts lib lib64 Doc/_build +scratch/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Doc/conf.py b/Doc/conf.py index 9e84cb95..07ad9938 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -15,174 +15,178 @@ import sys import os +from importlib import metadata # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, os.path.abspath("..")) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'SolidPython' -copyright = '2014, Evan Jones' +project = "SolidPython" +author = "Evan Jones" +copyright = "2014-2025, Evan Jones" -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1.2' -# The full version, including alpha/beta/rc tags. -release = '0.1.2' +try: + release = metadata.version("solidpython") +except metadata.PackageNotFoundError: + from pathlib import Path + import tomllib + + with (Path(__file__).parent.parent / "pyproject.toml").open("rb") as f: + release = tomllib.load(f)["project"]["version"] + +version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'SolidPythondoc' +htmlhelp_basename = "SolidPythondoc" # -- Options for LaTeX output --------------------------------------------- @@ -190,10 +194,8 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. #'preamble': '', } @@ -202,42 +204,38 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'SolidPython.tex', 'SolidPython Documentation', - 'Evan Jones', 'manual'), + ("index", "SolidPython.tex", "SolidPython Documentation", "Evan Jones", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'solidpython', 'SolidPython Documentation', - ['Evan Jones'], 1) -] +man_pages = [("index", "solidpython", "SolidPython Documentation", ["Evan Jones"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -246,19 +244,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'SolidPython', 'SolidPython Documentation', - 'Evan Jones', 'SolidPython', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "SolidPython", + "SolidPython Documentation", + "Evan Jones", + "SolidPython", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/Doc/index.rst b/Doc/index.rst index b73e8c3b..715aed82 100644 --- a/Doc/index.rst +++ b/Doc/index.rst @@ -11,14 +11,19 @@ Contents: .. toctree:: :maxdepth: 2 +.. include:: ../README.rst + +Library Reference +================= + +.. automodule:: solid.solidpython + :members: .. automodule:: solid :members: .. automodule:: solid.screw_thread :members: .. automodule:: solid.utils :members: -.. automodule:: solid.solidpython - :members: Indices and tables @@ -27,4 +32,4 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - :members: \ No newline at end of file + :members: diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d7c7cb31..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include solid/examples/*.sh -include solid/examples/*.scad -include solid/test/*.sh diff --git a/README.md b/README.md deleted file mode 100644 index 39adea96..00000000 --- a/README.md +++ /dev/null @@ -1,294 +0,0 @@ -SolidPython ------------ -- [SolidPython: OpenSCAD for Python](#solidpython--openscad-for-python) -- [Advantages](#advantages) -- [Installing SolidPython](#installing-solidpython) -- [Using SolidPython](#using-solidpython) -- [Example Code](#example-code) -- [Extra syntactic sugar](#extra-syntactic-sugar) - - [Basic operators](#basic-operators) - - [First-class Negative Space (Holes)](#first-class-negative-space-holes) - - [Animation](#animation) -- [solid.utils](#solidutils) - - [Directions: (up, down, left, right, forward, back) for arranging things:](#directions-up-down-left-right-forward-back-for-arranging-things) - - [Arcs](#arcs) - - [Offsets](#offsets) - - [Extrude Along Path](#extrude_along_path) - - [Basic color library](#basic-color-library) - - [Bill Of Materials](#bill-of-materials) -- [solid.screw_thread](#solidscrew_thread) -- [Contact](#contact) -- [License](#license) - -**Table of Contents** *generated with [DocToc](http://doctoc.herokuapp.com/)* - - -# SolidPython: OpenSCAD for Python - -SolidPython is a generalization of Phillip Tiefenbacher's openscad module, -found on [Thingiverse](http://www.thingiverse.com/thing:1481). It generates valid OpenSCAD -code from Python code with minimal overhead. Here's a simple example: - -This Python code: - - from solid import * - d = difference()( - cube(10), - sphere(15) - ) - print(scad_render(d)) - - -Generates this OpenSCAD code: - - difference(){ - cube(10); - sphere(15); - } - -That doesn't seem like such a savings, but the following SolidPython code is a -lot shorter (and I think a lot clearer) than the SCAD code it compiles to: - - d = cube(5) + right(5)(sphere(5)) - cylinder(r=2, h=6) - -Generates this OpenSCAD code: - - difference(){ - union(){ - cube(5); - translate( [5, 0,0]){ - sphere(5); - } - } - cylinder(r=2, h=6); - } - -# Advantages -Because you're using Python, a lot of things are easy that would be hard or -impossible in pure OpenSCAD. Among these are: - -* built-in dictionary types -* mutable, slice-able list and string types -* recursion -* external libraries (images! 3D geometry! web-scraping! ...) - -# Installing SolidPython -* Install via [PyPI](python setup.py sdist bdist_wininst upload): - - pip install solidpython - - (You may need to use `sudo pip install solidpython`, depending on your environment.) - -* **OR:** Download SolidPython (Click [here](https://github.com/SolidCode/SolidPython/archive/master.zip) to download directly, or use git to pull it all down) - - (Note that SolidPython also depends on the [PyEuclid](http://pypi.python.org/pypi/euclid) Vector math library, installable via `sudo pip install euclid`) - - * Unzip the file, probably in ~/Downloads/SolidPython-master - * In a terminal, cd to location of file: - - cd ~/Downloads/SolidPython-master - - * Run the install script: - - sudo python setup.py --install - -# Using SolidPython -* Include SolidPython at the top of your Python file: - - from solid import * - from solid.utils import * # Not required, but the utils module is useful -* To include other scad code, call ```use("/path/to/scadfile.scad")``` or ```include("/path/to/scadfile.scad")```. This is identical to what you would - do in OpenSCAD. -* OpenSCAD uses curly-brace blocks ({}) to create its tree. SolidPython uses - parentheses with comma-delimited lists. - __OpenSCAD:__ - - difference(){ - cube(10); - sphere(15); - } - - __SolidPython:__ - - d = difference()( - cube(10), # Note the comma between each element! - sphere(15) - ) - -* Call ```scad_render(py_scad_obj)``` to generate SCAD code. This returns a string of valid OpenSCAD code. -* *or*: call ```scad_render_to_file(py_scad_obj, filepath)``` to - store that code in a file. -* If 'filepath' is open in the OpenSCAD IDE and Design => - 'Automatic Reload and Compile' is checked (in the OpenSCAD IDE), calling - ```scad_render_to_file()``` from Python will load the object in - the IDE. -* Alternately, you could call OpenSCAD's command line and render straight - to STL. - -# Example Code -The best way to learn how SolidPython works is to look at the included example code. -If you've installed SolidPython, the following line of Python will print(the location of ) -the examples directory: - - import os, solid; print(os.path.dirname(solid.__file__) + '/examples') - -Or browse the example code on Github [here](https://github.com/SolidCode/SolidPython/tree/master/solid/examples) - -Adding your own code to the example file [`solid/examples/solidpython_template.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/solidpython_template.py) will make some of the setup easier. - -# Extra syntactic sugar -### Basic operators -Following Elmo Mäntynen's suggestion, SCAD objects override -the basic operators + (union), - (difference), and * (intersection). -So - - c = cylinder(r=10, h=5) + cylinder(r=2, h=30) -is the same as: - - c = union()( - cylinder(r=10, h=5), - cylinder(r=2, h=30) - ) - -Likewise: - - c = cylinder(r=10, h=5) - c -= cylinder(r=2, h=30) - -is the same as: - - c = difference()( - cylinder(r=10, h=5), - cylinder(r=2, h=30) - ) - -### First-class Negative Space (Holes) -OpenSCAD requires you to be very careful with the order in which you add or -subtract objects. SolidPython's `hole()` function makes this process easier. - -Consider making a joint where two pipes come together. In OpenSCAD you need to -make two cylinders, union them, then make two smaller cylinders, union -them, then subtract the smaller from the larger. - -Using hole(), you can make a pipe, specify that its center should remain open, -and then add two pipes together knowing that the central void area will stay -empty no matter what other objects are added to that structure. - -Example: - - outer = cylinder(r=pipe_od, h=seg_length) - inner = cylinder(r=pipe_id, h=seg_length) - pipe_a = outer - hole()(inner) - -Once you've made something a hole, eventually you'll want to put something, -like a bolt, into it. To do this, we need to specify that there's a given -'part' with a hole and that other parts may occupy the space in that hole. -This is done with the `part()` function. - -See [`solid/examples/hole_example.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/hole_example.py) for the complete picture. - -### Animation -OpenSCAD has a special variable, `$t`, that can be used to animate motion. -SolidPython can do this, too, using the special function `scad_render_animated_file()`. - -See [`solid/examples/animation_example.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/animation_example.py) for more details. - -# solid.utils -SolidPython includes a number of useful functions in [`solid/utils.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/utils.py). Currently these include: - -### Directions: (up, down, left, right, forward, back) for arranging things: - - up(10)( - cylinder() - ) - -seems a lot clearer to me than: - - translate( [0,0,10])( - cylinder() - ) - - -I took this from someone's SCAD work and have lost track of the original author. -My apologies. - -### Arcs -I've found this useful for fillets and rounds. - - arc(rad=10, start_degrees=90, end_degrees=210) - -draws an arc of radius 10 counterclockwise from 90 to 210 degrees. - - arc_inverted(rad=10, start_degrees=0, end_degrees=90) - -draws the portion of a 10x10 square NOT in a 90 degree circle of radius 10. -This is the shape you need to add to make fillets or remove to make rounds. - -### Offsets -To offset a set of points in one direction or another (inside or outside a closed -figure, for example) use `solid.utils.offset_points(point_arr, offset, inside=True)` - -Note that, for a non-convex figure, inside and outside may be non-intuitive. The -simple solution is to manually check that your offset is going in the direction you -intend, and change the boolean value of `inside` if you're not happy. - -See the code for futher explanation. Improvements on the inside/outside algorithm would be welcome. - -### Extrude Along Path -`solid.utils.extrude_along_path(shape_pts, path_pts, scale_factors=None)` - -See [`solid/examples/path_extrude_example.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/path_extrude_example.py) for use. - - -### Basic color library -You can change an object's color by using the OpenSCAD ```color([rgba_array])``` function: - - transparent_blue = color([0,0,1, 0.5])(cube(10)) # Specify with RGB[A] - red_obj = color(Red)(cube(10)) # Or use predefined colors - -These colors are pre-defined in solid.utils: - - - - - - - - - -
Red Green Blue
Cyan Magenta Yellow
Black White Transparent
Oak Pine Birch
Iron Steel Stainless
Aluminum Brass BlackPaint
FiberBoard
- -They're a conversion of the materials in the [MCAD OpenSCAD library](https://github.com/openscad/MCAD), as seen [here] (https://github.com/openscad/MCAD/blob/master/materials.scad). - -### Bill Of Materials -Put ```@bom_part()``` before any method that defines a part, then -call ```bill_of_materials()``` after the program is run, and all parts will be -counted, priced and reported. - -The example file [`solid/examples/bom_scad.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/bom_scad.py) illustrates this. Check it out. - -## solid.screw_thread -solid.screw_thread includes a method, thread() that makes internal and external -screw threads. - -See [`solid/examples/screw_thread_example.py`](https://github.com/SolidCode/SolidPython/blob/master/solid/examples/screw_thread_example.py) for more details. - -# Contact -Enjoy, and please send any questions or bug reports to me at ```evan_t_jones@mac.com```. - -Cheers! - -Evan - -# License -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 2.1 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -[Full text of the license](http://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt). diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..7d7ba678 --- /dev/null +++ b/README.rst @@ -0,0 +1,479 @@ +**Hey!** All the energy and improvements in this project are going into **SolidPython V2**. Check it out at `Github `_ or on its `PyPI page `_ before you commit to an older version. + + + +SolidPython +----------- + +.. image:: https://github.com/SolidCode/SolidPython/actions/workflows/ci.yml/badge.svg + :target: https://github.com/SolidCode/SolidPython/actions/workflows/ci.yml +.. image:: https://readthedocs.org/projects/solidpython/badge/?version=latest + :target: http://solidpython.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +- `SolidPython: OpenSCAD for + Python <#solidpython--openscad-for-python>`__ +- `Advantages <#advantages>`__ +- `Installing SolidPython <#installing-solidpython>`__ +- `Using SolidPython <#using-solidpython>`__ +- `Importing OpenSCAD Code <#importing-openscad-code>`__ +- `Example Code <#example-code>`__ +- `Extra syntactic sugar <#extra-syntactic-sugar>`__ + + - `Basic operators <#basic-operators>`__ + - `First-class Negative Space + (Holes) <#first-class-negative-space-holes>`__ + - `Animation <#animation>`__ + +- `solid.utils <#solidutils>`__ + + - `Directions: (up, down, left, right, forward, back) for arranging + things: <#directions-up-down-left-right-forward-back-for-arranging-things>`__ + - `Arcs <#arcs>`__ + - `Extrude Along Path <#extrude_along_path>`__ + - `Bill Of Materials <#bill-of-materials>`__ + +- `solid.screw\_thread <#solidscrew_thread>`__ +- `solid.splines <#solidsplines>`__ +- `Jupyter Renderer <#jupyter-renderer>`__ +- `Contact <#contact>`__ +- `License <#license>`__ + +SolidPython: OpenSCAD for Python +================================ + +SolidPython is a generalization of Phillip Tiefenbacher's openscad +module, found on +`Thingiverse `__. It generates +valid OpenSCAD code from Python code with minimal overhead. Here's a +simple example: + +This Python code: + +.. code:: python + + from solid import * + d = difference()( + cube(10), + sphere(15) + ) + print(scad_render(d)) + +Generates this OpenSCAD code: + +.. code:: python + + difference(){ + cube(10); + sphere(15); + } + +That doesn't seem like such a savings, but the following SolidPython +code is a lot shorter (and I think clearer) than the SCAD code it compiles to: + +.. code:: python + + from solid import * + from solid.utils import * + d = cube(5) + right(5)(sphere(5)) - cylinder(r=2, h=6) + +Generates this OpenSCAD code: + +.. code:: + + difference(){ + union(){ + cube(5); + translate( [5, 0,0]){ + sphere(5); + } + } + cylinder(r=2, h=6); + } + +Advantages +========== + +Because you're using Python, a lot of things are easy that would be hard +or impossible in pure OpenSCAD. Among these are: + +- built-in dictionary types +- mutable, slice-able list and string types +- recursion +- external libraries (images! 3D geometry! web-scraping! ...) + +Installing SolidPython +====================== + +- Install latest release via + `PyPI `__: + + .. code:: bash + + pip install solidpython + + (You may need to use ``sudo pip install solidpython``, depending on + your environment. This is commonly discouraged though. You'll be happiest + working in a `virtual environment `__ + where you can easily control dependencies for a given project) + +- Install current master straight from Github: + + .. code:: bash + + pip install git+https://github.com/SolidCode/SolidPython.git + +Using SolidPython +================= + +- Include SolidPython at the top of your Python file: + + .. code:: python + + from solid import * + from solid.utils import * # Not required, but the utils module is useful + + (See `this issue `__ for + a discussion of other import styles) + +- OpenSCAD uses curly-brace blocks ({}) to create its tree. SolidPython + uses parentheses with comma-delimited lists. + + **OpenSCAD:** + + .. code:: + + difference(){ + cube(10); + sphere(15); + } + + **SolidPython:** + + .. code:: + + d = difference()( + cube(10), # Note the comma between each element! + sphere(15) + ) + +- Call ``scad_render(py_scad_obj)`` to generate SCAD code. This returns + a string of valid OpenSCAD code. +- *or*: call ``scad_render_to_file(py_scad_obj, filepath.scad)`` to store + that code in a file. +- If ``filepath.scad`` is open in the OpenSCAD IDE and Design => 'Automatic + Reload and Compile' is checked in the OpenSCAD IDE, running + ``scad_render_to_file()`` from Python will load the object in the + IDE. +- Alternately, you could call OpenSCAD's command line and render + straight to STL. + +Importing OpenSCAD code +======================= + +- Use ``solid.import_scad(path)`` to import OpenSCAD code. Relative paths will +check the current location designated in `OpenSCAD library directories `__. + +**Ex:** + +``scadfile.scad`` + +.. code:: + + module box(w,h,d){ + cube([w,h,d]); + } + +``your_file.py`` + +.. code:: python + + from solid import * + + scadfile = import_scad('/path/to/scadfile.scad') + b = scadfile.box(2,4,6) + scad_render_to_file(b, 'out_file.scad') + +- Recursively import OpenSCAD code by calling ``import_scad()`` with a directory argument. + +.. code:: python + + from solid import * + + # MCAD is OpenSCAD's most common utility library: https://github.com/openscad/MCAD + # If it's installed for OpenSCAD (on MacOS, at: ``$HOME/Documents/OpenSCAD/libraries``) + mcad = import_scad('MCAD') + + # MCAD contains about 15 separate packages, each included as its own namespace + print(dir(mcad)) # => ['bearing', 'bitmap', 'boxes', etc...] + mount = mcad.motors.stepper_motor_mount(nema_standard=17) + scad_render_to_file(mount, 'motor_mount_file.scad') + +- OpenSCAD has the ``use()`` and ``include()`` statements for importing SCAD code, and SolidPython has them, too. They pollute the global namespace, though, and you may have better luck with ``import_scad()``, + +**Ex:** + +``scadfile.scad`` + +.. code:: + + module box(w,h,d){ + cube([w,h,d]); + } + +``your_file.py`` + +.. code:: python + + from solid import * + + # use() puts the module `box()` into the global namespace + use('/path/to/scadfile.scad') + b = box(2,4,6) + scad_render_to_file(b, 'out_file.scad') + + +Example Code +============ + +The best way to learn how SolidPython works is to look at the included +example code. If you've installed SolidPython, the following line of +Python will print(the location of ) the examples directory: + +.. code:: python + + import os, solid; print(os.path.dirname(solid.__file__) + '/examples') + + +Or browse the example code on Github +`here `__ + +Adding your own code to the example file +`solid/examples/solidpython_template.py `__ +will make some of the setup easier. + +Extra syntactic sugar +===================== + +Basic operators +--------------- + +Following Elmo Mäntynen's suggestion, SCAD objects override the basic +operators + (union), - (difference), and \* (intersection). So + +.. code:: python + + c = cylinder(r=10, h=5) + cylinder(r=2, h=30) + +is the same as: + +.. code:: python + + c = union()( + cylinder(r=10, h=5), + cylinder(r=2, h=30) + ) + +Likewise: + +.. code:: python + + c = cylinder(r=10, h=5) + c -= cylinder(r=2, h=30) + +is the same as: + +.. code:: python + + c = difference()( + cylinder(r=10, h=5), + cylinder(r=2, h=30) + ) + +First-class Negative Space (Holes) +---------------------------------- + +OpenSCAD requires you to be very careful with the order in which you add +or subtract objects. SolidPython's ``hole()`` function makes this +process easier. + +Consider making a joint where two pipes come together. In OpenSCAD you +need to make two cylinders, union them, then make two smaller cylinders, +union them, then subtract the smaller from the larger. + +Using hole(), you can make a pipe, specify that its center should remain +open, and then add two pipes together knowing that the central void area +will stay empty no matter what other objects are added to that +structure. + +Example: + +.. code:: python + + outer = cylinder(r=pipe_od, h=seg_length) + inner = cylinder(r=pipe_id, h=seg_length) + pipe_a = outer - hole()(inner) + +Once you've made something a hole, eventually you'll want to put +something, like a bolt, into it. To do this, we need to specify that +there's a given 'part' with a hole and that other parts may occupy the +space in that hole. This is done with the ``part()`` function. + +See +`solid/examples/hole_example.py `__ +for the complete picture. + +Animation +--------- + +OpenSCAD has a special variable, ``$t``, that can be used to animate +motion. SolidPython can do this, too, using the special function +``scad_render_animated_file()``. + +See +`solid/examples/animation_example.py `__ +for more details. + +solid.utils +=========== + +SolidPython includes a number of useful functions in +`solid/utils.py `__. +Currently these include: + +Directions: (up, down, left, right, forward, back) for arranging things: +------------------------------------------------------------------------ + +.. code:: python + + up(10)( + cylinder() + ) + +seems a lot clearer to me than: + +.. code:: python + + translate( [0,0,10])( + cylinder() + ) + +| I took this from someone's SCAD work and have lost track of the + original author. +| My apologies. + +Arcs +---- + +I've found this useful for fillets and rounds. + +.. code:: python + + arc(rad=10, start_degrees=90, end_degrees=210) + +draws an arc of radius 10 counterclockwise from 90 to 210 degrees. + +.. code:: python + + arc_inverted(rad=10, start_degrees=0, end_degrees=90) + +draws the portion of a 10x10 square NOT in a 90 degree circle of radius +10. This is the shape you need to add to make fillets or remove to make +rounds. + +Extrude Along Path +------------------ + +``solid.utils.extrude_along_path()`` is quite powerful. It can do everything that +OpenSCAD's ``linear_extrude() `` and ``rotate_extrude()`` can do, and lots, lots more. +Scale to custom values throughout the extrusion. Rotate smoothly through the entire +extrusion or specify particular rotations for each step. Apply arbitrary transform +functions to every point in the extrusion. + +See +`solid/examples/path_extrude_example.py `__ +for use. + +Bill Of Materials +----------------- + +Put ``@bom_part()`` before any method that defines a part, then call +``bill_of_materials()`` after the program is run, and all parts will be +counted, priced and reported. + +The example file +`solid/examples/bom_scad.py `__ +illustrates this. Check it out. + +solid.screw\_thread +------------------- + +solid.screw\_thread includes a method, thread() that makes internal and +external screw threads. + +See +`solid/examples/screw_thread_example.py `__ +for more details. + +solid.splines +------------- + +`solid.splines` contains functions to generate smooth Catmull-Rom curves through +control points. + +:: + + from solid import translate + from solid.splines import catmull_rom_polygon, bezier_polygon + from euclid3 import Point2 + + points = [ Point2(0,0), Point2(1,1), Point2(2,1), Point2(2,-1) ] + shape = catmull_rom_polygon(points, show_controls=True) + + bezier_shape = translate([3,0,0])(bezier_polygon(points, subdivisions=20)) + +See +`solid/examples/splines_example.py `__ +for more details and options. + +Jupyter Renderer +---------------- + +Render SolidPython or OpenSCAD code in Jupyter notebooks using `ViewSCAD `__, or install directly via: + +.. code:: bash + + pip install viewscad + +(Take a look at the `repo page `__, though, since there's a tiny bit more installation required) + +Contact +======= + +Enjoy, and please send any questions or bug reports to me at +``evan_t_jones@mac.com``. + +Cheers! + +Evan + +License +======= + +This library is free software; you can redistribute it and/or modify it +under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation; either version 2.1 of the License, or (at +your option) any later version. + +This library is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +General Public License for more details. + +`Full text of the +license `__. + +Some class docstrings are derived from the `OpenSCAD User Manual +`__, so +are available under the `Creative Commons Attribution-ShareAlike License +`__. diff --git a/TODO_SolidPython.txt b/TODO_SolidPython.txt index bd480939..5ae134b6 100644 --- a/TODO_SolidPython.txt +++ b/TODO_SolidPython.txt @@ -31,10 +31,6 @@ } } } --- Once you've declared something a hole(), you *can't put anything in - it*. This kind of defeats the purpose of most holes in mechanical design. - This feature may need some rethinking. --- bill_of_materials() entries should be left-justified in their columns. -- Applescript or other inter-app communication that will recompile in OpenSCAD whenever requested via your Python code @@ -77,4 +73,10 @@ Completed: -- Add to PyPI so SolidPython can be installed with pip install solidpython -- Update README with PyEuclid information and PyPI-aware install info -- Comprehensive docs +-- Once you've declared something a hole(), you *can't put anything in + it*. This kind of defeats the purpose of most holes in mechanical design. + This feature may need some rethinking. (Resolved via the part() class) +-- bill_of_materials() entries should be left-justified in their columns. (BOM + now created via prettytable module) + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..06e88e66 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "solidpython" +version = "1.1.5" +description = "Python interface to the OpenSCAD declarative geometry language" +authors = [{ name = "Evan Jones", email = "evan_t_jones@mac.com" }] +license = "LGPL-2.1" +keywords = [ + "3D", + "CAD", + "CSG", + "constructive solid geometry", + "geometry", + "modeling", + "OpenSCAD", +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Development Status :: 4 - Beta", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Mathematics", +] + + +readme = "README.rst" +requires-python = ">=3.10" +dependencies = [ + "euclid3>=0.1.0", + "pypng>=0.0.19", + "PrettyTable==0.7.2", + "ply>=3.11", +] + + +[project.urls] +homepage = "https://github.com/SolidCode/SolidPython" +repository = "https://github.com/SolidCode/SolidPython" +documentation = "https://solidpython.readthedocs.io/en/latest/" + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "sphinx>=8.1.3", + "sphinx-rtd-theme>=3.0.2", + "tox>=4.30.3", +] + +[tool.setuptools.packages.find] +include = ["solid*"] +exclude = ["Doc*"] diff --git a/setup.py b/setup.py deleted file mode 100755 index 0e9a3f45..00000000 --- a/setup.py +++ /dev/null @@ -1,25 +0,0 @@ -# from distutils.core import setup -from setuptools import setup, find_packages - -setup( - name='solidpython', - version='0.1.2', - description='Python interface to the OpenSCAD declarative geometry language', - author='Evan Jones', - author_email='evan_t_jones@mac.com', - url='https://github.com/SolidCode/SolidPython', - py_modules=['solid'], - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Development Status :: 4 - Beta", - "Environment :: Other Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Mathematics", - ], - packages=find_packages(), - install_requires=['euclid', 'PyPNG'], -) diff --git a/solid/__init__.py b/solid/__init__.py index 73d44df4..d7c9cb1f 100644 --- a/solid/__init__.py +++ b/solid/__init__.py @@ -1,7 +1,11 @@ # Some __init__ magic so we can include all solidpython code with: # from solid import * # from solid.utils import * -try: - from solidpython import * -except ImportError: - from solid.solidpython import * +from .solidpython import scad_render, scad_render_to_file +from .solidpython import scad_render_animated, scad_render_animated_file +from .solidpython import OpenSCADObject, IncludedOpenSCADObject +from .objects import * +from .patch_euclid import run_euclid_patch + +# Type hints +from .objects import P2, P3, P4, Vec3 , Vec4, Vec34, P3s, P23, Points, Indexes, ScadSize, OpenSCADObjectPlus \ No newline at end of file diff --git a/solid/examples/animation_example.py b/solid/examples/animation_example.py index cc639e6f..3d03abab 100755 --- a/solid/examples/animation_example.py +++ b/solid/examples/animation_example.py @@ -1,27 +1,24 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os +#! /usr/bin/env python3 import sys -import re +from math import cos, sin +from typing import Optional -from solid import * -from solid.utils import * +from solid import scad_render_animated_file +from solid.objects import square, translate +from solid.solidpython import OpenSCADObject -def my_animate(_time=0): +def my_animate(_time: Optional[float] = 0) -> OpenSCADObject: # _time will range from 0 to 1, not including 1 rads = _time * 2 * 3.1416 rad = 15 - c = translate([rad * cos(rads), rad * sin(rads)])(square(10)) + c = translate((rad * cos(rads), rad * sin(rads)))(square(10)) return c -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'animation_example.scad') - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else None # To animate in OpenSCAD: # - Run this program to generate a SCAD file. @@ -31,16 +28,15 @@ def my_animate(_time=0): # at the bottom of the OpenSCAD window # - FPS & Steps are flexible. For a start, set both to 20 # play around from there - scad_render_animated_file(my_animate, # A function that takes a float argument - # called '_time' in [0,1) - # and returns an OpenSCAD object - steps=20, # Number of steps to create one complete motion - back_and_forth=True, # If true, runs the complete motion - # forward and then in reverse, - # to avoid discontinuity - filepath=file_out, # Output file - include_orig_code=True ) # Append SolidPython code - # to the end of the generated - # OpenSCAD code. - - \ No newline at end of file + file_out = scad_render_animated_file(my_animate, # A function that takes a float argument + # called '_time' in [0,1) + # and returns an OpenSCAD object + steps=20, # Number of steps to create one complete motion + back_and_forth=True, # If true, runs the complete motion + # forward and then in reverse, + # to avoid discontinuity + out_dir=out_dir, + include_orig_code=True) # Append SolidPython code + # to the end of the generated + # OpenSCAD code. + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/append_solidpython_code.py b/solid/examples/append_solidpython_code.py index e5a6fcb2..3df37a01 100755 --- a/solid/examples/append_solidpython_code.py +++ b/solid/examples/append_solidpython_code.py @@ -1,10 +1,9 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -import os +#! /usr/bin/env python3 import sys -from solid import * -from solid.utils import * +from solid import scad_render_to_file +from solid.objects import cylinder +from solid.utils import up SEGMENTS = 48 @@ -14,16 +13,15 @@ def show_appended_python_code(): return a -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'append_solidpython_code.scad') +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else None a = show_appended_python_code() - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) # ================================================================ # = include_orig_code appends all python code as comments to the # = bottom of the generated OpenSCAD code, so the final document # = contains the easy-to-read python code as well as the SCAD. # = ------------------------------------------------------------ = - scad_render_to_file(a, file_out, include_orig_code=True) + file_out = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/basic_geometry.py b/solid/examples/basic_geometry.py index 91146b03..984282e0 100755 --- a/solid/examples/basic_geometry.py +++ b/solid/examples/basic_geometry.py @@ -1,12 +1,9 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os +#! /usr/bin/env python3 import sys -import re -from solid import * -from solid.utils import * +from solid import scad_render_to_file +from solid.objects import cube, cylinder, difference, translate, union +from solid.utils import right SEGMENTS = 48 @@ -18,18 +15,18 @@ def basic_geometry(): # left_piece uses standard OpenSCAD grammar (note the commas between # block elements; OpenSCAD doesn't require this) - left_piece = union()( - translate([-15, 0, 0])( - cube([10, 5, 3], center=True) - ), - translate([-10, 0, 0])( - difference()( - cylinder(r=5, h=15, center=True), - cylinder(r=4, h=16, center=True) - ) - ) + left_piece = union()( + translate((-15, 0, 0))( + cube([10, 5, 3], center=True) + ), + translate((-10, 0, 0))( + difference()( + cylinder(r=5, h=15, center=True), + cylinder(r=4, h=16, center=True) ) - + ) + ) + # Right piece uses a more Pythonic grammar. + (plus) is equivalent to union(), # - (minus) is equivalent to difference() and * (star) is equivalent to intersection # solid.utils also defines up(), down(), left(), right(), forward(), and back() @@ -40,16 +37,15 @@ def basic_geometry(): return union()(left_piece, right_piece) + if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'basic_geometry.scad') + out_dir = sys.argv[1] if len(sys.argv) > 1 else None a = basic_geometry() - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - # Adding the file_header argument as shown allows you to change # the detail of arcs by changing the SEGMENTS variable. This can # be expensive when making lots of small curves, but is otherwise # useful. - scad_render_to_file(a, file_out, file_header='$fn = %s;' % SEGMENTS) + file_out = scad_render_to_file(a, out_dir=out_dir, file_header=f'$fn = {SEGMENTS};') + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/basic_scad_include.py b/solid/examples/basic_scad_include.py index 8458aa48..a91020fc 100755 --- a/solid/examples/basic_scad_include.py +++ b/solid/examples/basic_scad_include.py @@ -1,29 +1,34 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- +#! /usr/bin/env python3 import sys -import os +from pathlib import Path + +from solid import scad_render_to_file +from solid.objects import import_scad, use -from solid import * # Import OpenSCAD code and call it from Python code. -# The path given to use() (or include()) must be absolute or findable in -# sys.path +# The path given to use() or import_scad() must be absolute or findable in sys.path -def demo_scad_include(): - # scad_to_include.scad includes a module called steps() - scad_path = os.path.join(os.path.dirname(__file__), "scad_to_include.scad") - use(scad_path) # could also use 'include', but that has side-effects; - # 'use' just imports without executing any of the imported code - return steps(5) +def demo_import_scad(): + scad_path = Path(__file__).parent / 'scad_to_include.scad' + scad_mod = import_scad(scad_path) + scad_mod.optional_nondefault_arg(1) + return scad_mod.steps(5) -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'scad_include_example.scad') +# The `use()` function mimics the behavior of OpenSCAD's use()` +def demo_scad_use(): + # scad_to_include.scad includes a module called steps() + scad_path = Path(__file__).parent / 'scad_to_include.scad' + # `This adds the SCAD module `steps()` to the global namespace + use(scad_path) - a = demo_scad_include() + return steps(5) - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - scad_render_to_file(a, file_out) +if __name__ == '__main__': + out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else None + a = demo_import_scad() + file_out = scad_render_to_file(a, out_dir=out_dir) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/bom_scad.py b/solid/examples/bom_scad.py index 4f301439..7611835c 100755 --- a/solid/examples/bom_scad.py +++ b/solid/examples/bom_scad.py @@ -1,5 +1,4 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- +#! /usr/bin/env python3 # Basic shape with several repeated parts, demonstrating the use of # solid.utils.bill_of_materials() @@ -18,11 +17,12 @@ # # -ETJ 08 Mar 2011 -import os import sys -from solid import * -from solid.utils import * +from solid import scad_render_to_file +from solid.objects import cube, color, cylinder, difference, translate, union +from solid.utils import EPSILON +from solid.utils import bill_of_materials, bom_part, set_bom_headers head_rad = 2.65 head_height = 2.8 @@ -34,31 +34,33 @@ doohickey_h = 5 +set_bom_headers("link", "leftover") + def head(): return cylinder(h=head_height, r=head_rad) -@bom_part("M3x16 Bolt", 0.12, currency="€") +@bom_part("M3x16 Bolt", 0.12, currency="€", link="http://example.io/M3x16", leftover=0) def m3_16(a=3): bolt_height = 16 m = union()( - head(), - translate([0, 0, -bolt_height])( - cylinder(r=m3_rad, h=bolt_height) - ) + head(), + translate((0, 0, -bolt_height))( + cylinder(r=m3_rad, h=bolt_height) + ) ) return m -@bom_part("M3x12 Bolt", 0.09) +@bom_part("M3x12 Bolt", 0.09, leftover=0) def m3_12(): bolt_height = 12 m = union()( - head(), - translate([0, 0, -bolt_height])( - cylinder(r=m3_rad, h=bolt_height) - ) + head(), + translate((0, 0, -bolt_height))( + cylinder(r=m3_rad, h=bolt_height) + ) ) return m @@ -68,49 +70,52 @@ def m3_nut(): hx = cylinder(r=nut_rad, h=nut_height) hx.add_param('$fn', 6) # make the nut hexagonal n = difference()( - hx, - translate([0, 0, -EPSILON])( - cylinder(r=m3_rad, h=nut_height + 2 * EPSILON) - ) + hx, + translate((0, 0, -EPSILON))( + cylinder(r=m3_rad, h=nut_height + 2 * EPSILON) + ) ) return n @bom_part() -def doohickey(): - hole_cyl = translate([0, 0, -EPSILON])( - cylinder(r=m3_rad, h=doohickey_h + 2 * EPSILON) +def doohickey(c): + hole_cyl = translate((0, 0, -EPSILON))( + cylinder(r=m3_rad, h=doohickey_h + 2 * EPSILON) ) d = difference()( - cube([30, 10, doohickey_h], center=True), - translate([-10, 0, 0])(hole_cyl), - hole_cyl, - translate([10, 0, 0])(hole_cyl) + cube([30, 10, doohickey_h], center=True), + translate((-10, 0, 0))(hole_cyl), + hole_cyl, + translate((10, 0, 0))(hole_cyl) ) - return d + return color(c)(d) -def assemble(): +def assembly(): + nut = m3_nut() return union()( - doohickey(), - translate([-10, 0, doohickey_h / 2])(m3_12()), - translate([ 0, 0, doohickey_h / 2])(m3_16()), - translate([ 10, 0, doohickey_h / 2])(m3_12()), - # Nuts - translate([-10, 0, -nut_height - doohickey_h / 2])(m3_nut()), - translate([ 0, 0, -nut_height - doohickey_h / 2])(m3_nut()), - translate([ 10, 0, -nut_height - doohickey_h / 2])(m3_nut()), + doohickey(c='blue'), + translate((-10, 0, doohickey_h / 2))(m3_12()), + translate((0, 0, doohickey_h / 2))(m3_16()), + translate((10, 0, doohickey_h / 2))(m3_12()), + # Nuts + translate((-10, 0, -nut_height - doohickey_h / 2))(nut), + translate((0, 0, -nut_height - doohickey_h / 2))(nut), + translate((10, 0, -nut_height - doohickey_h / 2))(nut), ) -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'BOM_example.scad') - a = assemble() +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else None - bom = bill_of_materials() + a = assembly() + bom = bill_of_materials(a) + file_out = scad_render_to_file(a, out_dir=out_dir) - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) + print(f"{__file__}: SCAD file written to: \n{file_out}") print(bom) - scad_render_to_file(a, file_out) + print("Or, Spreadsheet-ready TSV:\n\n") + bom = bill_of_materials(a, csv=True) + print(bom) diff --git a/solid/examples/hole_example.py b/solid/examples/hole_example.py index b694a8d2..8052f6a2 100755 --- a/solid/examples/hole_example.py +++ b/solid/examples/hole_example.py @@ -1,13 +1,9 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os +#! /usr/bin/env python3 import sys -import re -# Assumes SolidPython is in site-packages or elsewhwere in sys.path -from solid import * -from solid.utils import * +from solid import scad_render_to_file +from solid.objects import cube, cylinder, hole, part, rotate +from solid.utils import FORWARD_VEC, right, up SEGMENTS = 120 @@ -79,13 +75,11 @@ def multipart_hole(): # The section of the bolt inside not_part disappears. The section # of the bolt inside is_part is still there. - a = not_part + bolt + right(45)(is_part + bolt) + return not_part + bolt + right(45)(is_part + bolt) - return a if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'hole_example.scad') + out_dir = sys.argv[1] if len(sys.argv) > 1 else None # On the left, pipes with no explicit holes, which can give # unexpected walls where we don't want them. @@ -97,5 +91,5 @@ def multipart_hole(): b = up(40)(multipart_hole()) a += b - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - scad_render_to_file(a, file_out, file_header='$fn = %s;' % SEGMENTS, include_orig_code=True) + file_out = scad_render_to_file(a, out_dir=out_dir, file_header=f'$fn = {SEGMENTS};', include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/koch.py b/solid/examples/koch.py index 654d46a0..971fe19a 100755 --- a/solid/examples/koch.py +++ b/solid/examples/koch.py @@ -1,22 +1,21 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -import os +#! /usr/bin/env python3 import sys -import re +from pathlib import Path -from solid import * -from solid.utils import * +from euclid3 import LineSegment2, LineSegment3, Point2, Point3 -from euclid import * +from solid import scad_render_to_file +from solid.objects import polygon, polyhedron, union +from solid.utils import forward, up -ONE_THIRD = 1 / 3.0 +ONE_THIRD = 1 / 3 def affine_combination(a, b, weight=0.5): - ''' + """ Note that weight is a fraction of the distance between self and other. - So... 0.33 is a point .33 of the way between self and other. - ''' + So... 0.33 is a point .33 of the way between self and other. + """ if hasattr(a, 'z'): return Point3((1 - weight) * a.x + weight * b.x, (1 - weight) * a.y + weight * b.y, @@ -33,12 +32,12 @@ def kochify_3d(a, b, c, pyr_a_weight=ONE_THIRD, pyr_b_weight=ONE_THIRD, pyr_c_weight=ONE_THIRD, pyr_height_weight=ONE_THIRD ): - ''' - Point3s a, b, and c must be coplanar and define a face + """ + Point3s a, b, and c must be coplanar and define a face ab_weight, etc define the subdivision of the original face pyr_a_weight, etc define where the point of the new pyramid face will go pyr_height determines how far from the face the new pyramid's point will be - ''' + """ triangles = [] new_a = affine_combination(a, b, ab_weight) new_b = affine_combination(b, c, bc_weight) @@ -97,8 +96,6 @@ def main_3d(out_dir): pyr_b_weight = ONE_THIRD pyr_c_weight = ONE_THIRD pyr_height_weight = ONE_THIRD - pyr_height_weight = ONE_THIRD - # pyr_height_weight = .25 all_polys = union() @@ -122,7 +119,6 @@ def main_3d(out_dir): ab_weight, bc_weight, ca_weight, pyr_a_weight, pyr_b_weight, pyr_c_weight, pyr_height_weight) - # new_tris = kochify_3d( a, b, c) generations[g].extend(new_tris) # Put all generations into SCAD @@ -141,20 +137,19 @@ def main_3d(out_dir): # Do the SCAD edges = [list(range(len(points)))] - all_polys.add(up(h)( - polyhedron(points=points, faces=faces) - ) + all_polys.add( + up(h)( + polyhedron(points=points, faces=faces) + ) ) - file_out = os.path.join(out_dir, 'koch_3d.scad') - cur_file = __file__ - print("%(cur_file)s: SCAD file written to: %(file_out)s" % vars()) - scad_render_to_file(all_polys, file_out, include_orig_code=True) + file_out = Path(out_dir) / 'koch_3d.scad' + file_out = scad_render_to_file(all_polys, file_out, include_orig_code=True) + print(f"{__file__}: SCAD file written to: {file_out}") def main(out_dir): # Parameters - midpoint_weight = 0.5 height_ratio = 0.25 left_loc = ONE_THIRD midpoint_loc = 0.5 @@ -178,15 +173,11 @@ def main(out_dir): generations.append([]) for seg in generations[g - 1]: generations[g].extend(kochify(seg, height_ratio, left_loc, midpoint_loc, right_loc)) - # generations[g].extend(kochify(seg)) # # Put all generations into SCAD orig_length = abs(generations[0][0]) for g, a_gen in enumerate(generations): points = [s.p1 for s in a_gen] - # points.append(a_gen[-1].p2) # add the last point - - rect_offset = 10 # Just use arrays for points so SCAD understands points = [[p.x, p.y] for p in points] @@ -198,12 +189,11 @@ def main(out_dir): edges = [list(range(len(points)))] all_polys.add(forward(h)(polygon(points=points, paths=edges))) - file_out = os.path.join(out_dir, 'koch.scad') - cur_file = __file__ - print("%(cur_file)s: SCAD file written to: %(file_out)s " % vars()) - scad_render_to_file(all_polys, file_out, include_orig_code=True) + file_out = scad_render_to_file(all_polys, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: {file_out}") + if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir + out_dir = sys.argv[1] if len(sys.argv) > 1 else None main_3d(out_dir) main(out_dir) diff --git a/solid/examples/mazebox/__init__.py b/solid/examples/mazebox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/solid/examples/mazebox/inset.py b/solid/examples/mazebox/inset.py index c6016842..fd7926ea 100644 --- a/solid/examples/mazebox/inset.py +++ b/solid/examples/mazebox/inset.py @@ -1,11 +1,11 @@ -from math import * -from trianglemath import * +from math import sqrt class Vec2D: def __init__(self, x, y): - self.set(x, y) + self.x = x + self.y = y def set(self, x, y): self.x = x @@ -60,7 +60,7 @@ def parallelMove(self, d): def intersect(self, l): solve = LinearSolve2(l.dir.x, -self.dir.x, l.dir.y, -self.dir.y, self.start.x - l.start.x, self.start.y - l.start.y) - if (solve.error): + if solve.error: return None else: point = self.start.plus(self.dir.times(solve.x2)) @@ -73,17 +73,18 @@ def intersect(self, l): def det(a, b, c, d): return a * d - b * c + # solves system of 2 linear equations in 2 unknown class LinearSolve2: - # the equations look like thsi looks like this + # the equations look like this looks like this # x1*a + x2*b = r1 # x1*c + x2*d = r2 def __init__(self, a, b, c, d, r1, r2): q = det(a, b, c, d) - if (abs(q) < 0.000000001): + if abs(q) < 0.000000001: self.error = True else: self.error = False @@ -97,77 +98,55 @@ def asVec2D(l): def insetPoly(poly, inset): points = [] - inverted = [] - for i in range(0, len(poly)): + for i in range(len(poly)): iprev = (i + len(poly) - 1) % len(poly) inext = (i + 1) % len(poly) prev = MetaCADLine(asVec2D(poly[iprev]), asVec2D(poly[i])) - oldnorm = Vec2D(prev.normal.x, prev.normal.y) next = MetaCADLine(asVec2D(poly[i]), asVec2D(poly[inext])) prev.parallelMove(inset) next.parallelMove(inset) intersect = prev.intersect(next) - if intersect == None: + if intersect is None: # take parallel moved poly[i] # from the line thats longer (in case we have a degenerate line # in there) - if (prev.dir.length() < next.dir.length()): + if prev.dir.len() < next.dir.len(): intersect = Vec2D(next.start.x, next.start.y) else: intersect = Vec2D(prev.end.x, prev.end.y) points.append(intersect.asTripple(poly[i][2])) - if (len(points) >= 2): - newLine = MetaCADLine(asVec2D(points[iprev]), asVec2D(points[i])) - diff = newLine.normal.minus(oldnorm).len() - if (diff > 0.1): - pass - #print("error inverting") - # exit() - else: - pass - # print("ok") + istart = -1 ilen = 0 - for i in range(0, len(poly)): + for i in range(len(poly)): iprev = (i + len(poly) - 1) % len(poly) - inext = (i + 1) % len(poly) prev = MetaCADLine(asVec2D(poly[iprev]), asVec2D(poly[i])) oldnorm = Vec2D(prev.normal.x, prev.normal.y) newLine = MetaCADLine(asVec2D(points[iprev]), asVec2D(points[i])) diff = newLine.normal.minus(oldnorm).len() - if (diff > 0.1): - #print("wrong dir detected") - if (istart == -1): + if diff > 0.1: + if istart == -1: istart = i ilen = 1 else: ilen += 1 else: - if (ilen > 0): - if (istart == 0): - pass - #print("oh noes") - # exit() - else: - #print("trying to save: ", istart, i) + if ilen > 0: + if istart != 0: idxs = (len(poly) + istart - 1) % len(poly) - idxe = (i) % len(poly) + idxe = i % len(poly) p1 = points[idxs] p2 = points[idxe] - #points[idxs] = p2 - #points[idxe] = p1 for j in range(istart, i): t = float(1 + j - istart) / (1 + i - istart) - # print(t) points[j] = [ - p2[0] * t + p1[0] * (1 - t), p2[1] * t + p1[1] * (1 - t), p2[2] * t + p1[2] * (1 - t)] + p2[0] * t + p1[0] * (1 - t), + p2[1] * t + p1[1] * (1 - t), + p2[2] * t + p1[2] * (1 - t) + ] istart = -1 ilen = 0 - - iprev = (i + len(poly) - 1) % len(poly) - inext = (i + 1) % len(poly) - return points diff --git a/solid/examples/mazebox/playground/maze7.png b/solid/examples/mazebox/maze7.png similarity index 100% rename from solid/examples/mazebox/playground/maze7.png rename to solid/examples/mazebox/maze7.png diff --git a/solid/examples/mazebox/mazebox.py b/solid/examples/mazebox/mazebox.py new file mode 100755 index 00000000..4d8f03eb --- /dev/null +++ b/solid/examples/mazebox/mazebox.py @@ -0,0 +1,176 @@ +#! /usr/bin/env python3 + +# A-Mazing Box, http://www.thingiverse.com/thing:1481 +# Copyright (C) 2009 Philipp Tiefenbacher +# With very minor changes for SolidPython compatibility, 8 March 2011 +# With further changes for clarity, 25 September 2018 +# + +import os +import sys +from math import cos, pi, sin + +import png + +from solid import scad_render_to_file +from solid.objects import cylinder, difference, intersection, polyhedron, sphere, translate, union +from inset import insetPoly +from trianglemath import Tripple2Vec3D, angleBetweenPlanes + +SEGMENTS = 48 + +rn = 3 * 64 +# r = 10 +innerR = 25 +gap = 0.5 +wall = 1.50 +baseH = 2 +gripH = 9 +hn = 90 +s = 0.775 + +h = hn * s +hone = h / hn + +toph = (h - gripH) + 3 + + +def getPNG(fn): + with open(fn, 'rb') as f: + r = png.Reader(file=f) + data = r.read() + pixel = data[2] + raw = [] + for row in pixel: + r = [] + raw.append(r) + for px in row: + r.append(px) + return raw + + +def build_depth_map(img_path): + depth = [] + for i in range(0, hn): + depth.append([0.0] * rn) + + depth = getPNG(img_path) + depth.reverse() + return depth + + +def getPx(depth_map, x, y, default): + x = int(x) + y = int(y) + x %= len(depth_map[0]) + if y >= len(depth_map): + y = len(depth_map) - 1 + if 0 <= x < len(depth_map[0]) and 0 <= y < len(depth_map): + return depth_map[y][x] + return default + + +def myComp(x, y): + d = Tripple2Vec3D(y).angle2D() - Tripple2Vec3D(x).angle2D() + if d < 0: + return -1 + elif d == 0: + return 0 + else: + return 1 + + +def bumpMapCylinder(depth_map, the_r, hn_, inset, default): + pts = [] + trls = [] + for i in range(hn_): + circ = [] + for j in range(rn): + a = j * 2 * pi / rn + r = the_r - ((255 - getPx(depth_map, j, i, default)) / 150) + p = [r * cos(a), r * sin(a), i * hone] + circ.append(p) + circ = insetPoly(circ, inset) + for c in circ: + pts.append(c) + + pts.append([0, 0, 0]) + pts.append([0, 0, i * hone]) + + for j in range(rn): + t = [j, (j + 1) % rn, rn * hn_] + trls.append(t) + t = [(rn * hn_ - 1) - j, (rn * hn_ - 1) - ((j + 1) % rn), rn * hn_ + 1] + trls.append(t) + for i in range(0, hn_ - 1): + p1 = i * rn + ((j + 1) % rn) + p2 = i * rn + j + p3 = (i + 1) * rn + j + p4 = (i + 1) * rn + ((j + 1) % rn) + a1 = angleBetweenPlanes([pts[p1], pts[p2], pts[p3]], [pts[p4], pts[p1], pts[p3]]) + a1 = min(a1, pi - a1) + a2 = angleBetweenPlanes([pts[p2], pts[p1], pts[p4]], [pts[p2], pts[p3], pts[p4]]) + a2 = min(a2, pi - a2) + if a1 < a2: + t = [p1, p2, p3] + trls.append(t) + t = [p4, p1, p3] + trls.append(t) + else: + t = [p2, p4, p1] + trls.append(t) + t = [p2, p3, p4] + trls.append(t) + + return polyhedron(pts, trls, 6) + + +def top_part(): + maze_path = os.path.join(os.path.dirname(__file__), 'maze7.png') + + depth_map = build_depth_map(maze_path) + + d = difference() + u = union() + u.add(bumpMapCylinder(depth_map, innerR, hn, 0, 255)) + u.add(cylinder(r=innerR + wall + gap, h=gripH)) + d.add(u) + d.add(intersection() + .add(bumpMapCylinder(depth_map, innerR, hn + 2, wall, 0).set_modifier("")) + .add(translate((0, 0, baseH)) + .add(cylinder(r=innerR + 2 * wall, h=h * 1.1).set_modifier("")))) + return d + + +def bottom_part(): + top = difference() + u = union() + u2 = union() + top.add(u) + d = difference() + d.add(cylinder(r=innerR + wall + gap, h=toph)) + d.add(translate((0, 0, baseH)).add(cylinder(r=innerR + gap, h=toph))) + u.add(d) + top.add(u2) + for i in range(0, 3): + a = i * 2 * pi / 3 + r = innerR + gap + wall / 2 + u.add(translate(((r - 0.3) * cos(a), (r - 0.3) * sin(a), toph - 6)).add(sphere(r=2.4))) + u2.add(translate(((r + wall - 0.3) * cos(a), (r + wall - 0.3) * sin(a), toph - 6)).add(sphere(r=2.4))) + + return top + + +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else None + file_out = os.path.join(out_dir, 'mazebox.scad') + + assm = union()( + top_part(), + translate((3 * innerR, 0, 0))( + bottom_part() + ) + ) + + print(f"{__file__}: SCAD file written to: \n{file_out}") + scad_render_to_file(assm, file_out, file_header=f'$fn = {SEGMENTS};') diff --git a/solid/examples/mazebox/mazebox_clean2_stable.py b/solid/examples/mazebox/mazebox_clean2_stable.py deleted file mode 100644 index 5833a8a3..00000000 --- a/solid/examples/mazebox/mazebox_clean2_stable.py +++ /dev/null @@ -1,182 +0,0 @@ -# A-Mazing Box, http://www.thingiverse.com/thing:1481 -# Copyright (C) 2009 Philipp Tiefenbacher -# With very minor changes for SolidPython compatibility, 8 March 2011 -# - -# Make sure we can import the OpenScad translation module -import sys -import os - -from math import * -from solid import * -# Requires pypng module, which can be found with 'pip install pypng', -# 'easy_install pypng', or at http://code.google.com/p/pypng/ -from testpng import * -from inset import * -from trianglemath import * - -rn = 3 * 64 -#r = 10 -innerR = 25 -gap = 0.5 -wall = 1.50 -baseH = 2 -gripH = 9 -hn = 90 -s = 0.775 - - -h = hn * s -hone = h / hn - -toph = (h - gripH) + 3 - -depth = [] - - -def flip(img): - # for l in img: - # l.reverse() - img.reverse() - return img - - -for i in range(0, hn): - depth.append([]) - for j in range(0, rn): - depth[i].append(0.0) - - -depth = getPNG('playground/maze7.png') -depth = flip(depth) - - -def getPx(x, y, default): - x = int(x) - y = int(y) - x = x % len(depth[0]) - if (y >= len(depth)): - y = len(depth) - 1 - if (x >= 0 and x < len(depth[0]) and y >= 0 and y < len(depth)): - return depth[y][x] - return default - - -def myComp(x, y): - d = Tripple2Vec3D(y).angle2D() - Tripple2Vec3D(x).angle2D() - if (d < 0): - return -1 - elif (d == 0): - return 0 - else: - return 1 - - -def bumpMapCylinder(theR, hn, inset, default): - pts = [] - trls = [] - for i in xrange(0, hn): - circ = [] - for j in xrange(0, rn): - a = j * 2 * pi / rn - r = theR - ((255 - getPx(j, i, default)) / 150.0) - p = [r * cos(a), r * sin(a), i * hone] - circ.append(p) - circ = insetPoly(circ, inset) - #circ.sort(lambda x, y: -1 if (Tripple2Vec3D(y).angle2D() - Tripple2Vec3D(x).angle2D() < 0) else 1) - aold = Tripple2Vec3D(circ[0]).angle2D() - for c in circ: - a = Tripple2Vec3D(c).angle2D() - # print(a) - if (a > aold and (abs(a - aold) < 1 * pi)): - #print(a, aold) - # exit() - pass - aold = a - pts.append(c) - - pts.append([0, 0, 0]) - pts.append([0, 0, i * hone]) - - for j in range(0, rn): - t = [j, (j + 1) % rn, rn * hn] - trls.append(t) - t = [(rn * hn - 1) - j, (rn * hn - 1) - ((j + 1) % rn), rn * hn + 1] - trls.append(t) - for i in range(0, hn - 1): - p1 = i * rn + ((j + 1) % rn) - p2 = i * rn + j - p3 = (i + 1) * rn + j - p4 = (i + 1) * rn + ((j + 1) % rn) - a1 = angleBetweenPlanes([pts[p1], pts[p2], pts[p3]], [pts[p4], pts[p1], pts[p3]]) - a1 = min(a1, pi - a1) - a2 = angleBetweenPlanes([pts[p2], pts[p1], pts[p4]], [pts[p2], pts[p3], pts[p4]]) - a2 = min(a2, pi - a2) - #print(a1, a2) - if (a1 < a2): - t = [p1, p2, p3] - trls.append(t) - t = [p4, p1, p3] - trls.append(t) - else: - t = [p2, p4, p1] - trls.append(t) - t = [p2, p3, p4] - trls.append(t) - - return polyhedron(pts, trls, 6) - -# to generate the top part -part = 1 - -# to generate the bottom part -# part = 2 - -if part == 1: - d = difference() - u = union() - u.add(bumpMapCylinder(innerR, hn, 0, 255)) - u.add(cylinder(r=innerR + wall + gap, h=gripH)) - d.add(u) - #u.add(translate([80,0,0]).add(bumpMapCylinder(innerR, wall))) - d.add(intersection().add(bumpMapCylinder(innerR, hn + 2, wall, 0).set_modifier("") - ).add(translate([0, 0, baseH]).add(cylinder(r=innerR + 2 * wall, h=h * 1.1).set_modifier("")))) - # u.add() - print("$fa=2; $fs=0.5;\n") - print(d._render()) -elif part == 2: - top = difference() - u = union() - u2 = union() - top.add(u) - d = difference() - d.add(cylinder(r=innerR + wall + gap, h=toph)) - d.add(translate([0, 0, baseH]).add(cylinder(r=innerR + gap, h=toph))) - u.add(d) - top.add(u2) - for i in range(0, 3): - a = i * 2 * pi / 3.0 - r = innerR + gap + wall / 2 - u.add(translate([(r - 0.3) * cos(a), (r - 0.3) * sin(a), toph - 6]).add(sphere(r=2.4))) - u2.add(translate([(r + wall - 0.3) * cos(a), (r + wall - 0.3) * sin(a), toph - 6]).add(sphere(r=2.4))) - #top.add(cylinder(r = innerR+wall+gap, h=h)) - print("$fa=2; $fs=0.5;\n") - print(top._render()) -else: - top = difference() - u = union() - u2 = union() - top.add(u) - d = difference() - d.add(cylinder(r=innerR + wall + gap, h=6)) - d.add(translate([0, 0, -baseH]).add(cylinder(r=innerR + gap, h=h))) - u.add(d) - top.add(u2) - for i in range(0, 3): - a = i * 2 * pi / 3.0 - r = innerR + gap + wall / 2 - u.add(translate([r * cos(a), r * sin(a), 4]).add(sphere(r=2.3))) - u2.add(translate([(r + wall) * cos(a), (r + wall) * sin(a), 4]).add(sphere(r=2.3))) - #top.add(cylinder(r = innerR+wall+gap, h=h)) - print("//$fn=20;\n") - print(top._render()) diff --git a/solid/examples/mazebox/testpng.py b/solid/examples/mazebox/testpng.py deleted file mode 100644 index a8b42d63..00000000 --- a/solid/examples/mazebox/testpng.py +++ /dev/null @@ -1,18 +0,0 @@ -import png -import urllib - - -def getPNG(fn): - r = png.Reader(file=urllib.urlopen(fn)) - data = r.read() - pixel = data[2] - raw = [] - # print(data) - for row in pixel: - # print(row) - # exit() - r = [] - raw.append(r) - for px in row: - r.append(px) - return raw diff --git a/solid/examples/mazebox/trianglemath.py b/solid/examples/mazebox/trianglemath.py index 966014af..abf97c3d 100644 --- a/solid/examples/mazebox/trianglemath.py +++ b/solid/examples/mazebox/trianglemath.py @@ -1,4 +1,4 @@ -from math import * +from math import acos, atan2, pi, sqrt def Tripple2Vec3D(t): @@ -8,11 +8,13 @@ def Tripple2Vec3D(t): class Vec3D: def __init__(self, x, y, z): - self.set(x, y, z) + self.x = x + self.y = y + self.z = z def angle2D(self): a = atan2(self.x, self.y) - if (a < 0): + if a < 0: a += 2 * pi return a @@ -24,7 +26,7 @@ def set(self, x, y, z): def times(self, t): return Vec3D(self.x * t, self.y * t, self.z * t) - # changes the objetct itself + # changes the object itself def add(self, v): self.x += v.x self.y += v.y @@ -73,12 +75,10 @@ def angleBetweenPlanes(p1, p2): n2 = planeNormal(p2) n1.normalize() n2.normalize() - # print(n1.asTripple()) - # print(n2.asTripple()) + s = n1.scalarProduct(n2) - # print(s) - if (s > 1): + if s > 1: s = 1 - if (s < -1): + if s < -1: s = -1 return acos(s) diff --git a/solid/examples/path_extrude_example.py b/solid/examples/path_extrude_example.py index 5f9ba1b5..296637c4 100755 --- a/solid/examples/path_extrude_example.py +++ b/solid/examples/path_extrude_example.py @@ -1,29 +1,191 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os +#! /usr/bin/env python3 +from solid.objects import linear_extrude +from solid.solidpython import OpenSCADObject import sys -import re +from math import cos, radians, sin, pi, tau +from pathlib import Path -# Assumes SolidPython is in site-packages or elsewhwere in sys.path -from solid import * -from solid.utils import * +from euclid3 import Point2, Point3, Vector3 + +from solid import scad_render_to_file, text, translate, cube, color, rotate +from solid.utils import UP_VEC, Vector23, distribute_in_grid, extrude_along_path +from solid.utils import down, right, frange, lerp + + +from typing import Set, Sequence, List, Callable, Optional, Union, Iterable, Tuple SEGMENTS = 48 +PATH_RAD = 50 +SHAPE_RAD = 15 + +TEXT_LOC = [-0.6 *PATH_RAD, 1.6 * PATH_RAD] + +def basic_extrude_example(): + path_rad = PATH_RAD + shape = star(num_points=5) + path = sinusoidal_ring(rad=path_rad, segments=240) + + # At its simplest, just sweep a shape along a path + extruded = extrude_along_path( shape_pts=shape, path_pts=path) + extruded += make_label('Basic Extrude') + return extruded + +def extrude_example_xy_scaling() -> OpenSCADObject: + num_points = SEGMENTS + path_rad = PATH_RAD + circle = circle_points(15) + path = circle_points(rad = path_rad) + + # If scales aren't included, they'll default to + # no scaling at each step along path. + no_scale_obj = make_label('No Scale') + no_scale_obj += extrude_along_path(circle, path) + + # angles: from 0 to 6*Pi + angles = list((frange(0, 3*tau, num_steps=len(path)))) + + # With a 1-D scale factor, an extrusion grows and shrinks uniformly + x_scales = [(1 + cos(a)/2) for a in angles] + x_obj = make_label('1D Scale') + x_obj += extrude_along_path(circle, path, scales=x_scales) + + # With a 2D scale factor, a shape's X & Y dimensions can scale + # independently, leading to more interesting shapes + # X & Y scales vary between 0.5 & 1.5 + xy_scales = [Point2( 1 + cos(a)/2, 1 + sin(a)/2) for a in angles] + xy_obj = make_label('2D Scale') + xy_obj += extrude_along_path(circle, path, scales=xy_scales) + + obj = no_scale_obj + right(3*path_rad)(x_obj) + right(6 * path_rad)(xy_obj) + return obj + +def extrude_example_capped_ends() -> OpenSCADObject: + num_points = SEGMENTS/2 + path_rad = 50 + circle = star(6) + path = circle_points(rad = path_rad)[:-4] + + # If `connect_ends` is False or unspecified, ends will be capped. + # Endcaps will be correct for most convex or mildly concave (e.g. stars) cross sections + capped_obj = make_label('Capped Ends') + capped_obj += extrude_along_path(circle, path, connect_ends=False, cap_ends=True) + + # If `connect_ends` is specified, create a continuous manifold object + connected_obj = make_label('Connected Ends') + connected_obj += extrude_along_path(circle, path, connect_ends=True) + + return capped_obj + right(3*path_rad)(connected_obj) + +def extrude_example_rotations() -> OpenSCADObject: + path_rad = PATH_RAD + shape = star(num_points=5) + path = circle_points(path_rad, num_points=240) + + # For a simple example, make one complete revolution by the end of the extrusion + simple_rot = make_label('Simple Rotation') + simple_rot += extrude_along_path(shape, path, rotations=[360], connect_ends=True) + # For a more complex set of rotations, add a rotation degree for each point in path + complex_rotations = [] + degs = 0 + oscillation_max = 60 -def sinusoidal_ring(rad=25, segments=SEGMENTS): + for i in frange(0, 1, num_steps=len(path)): + # For the first third of the path, do one complete rotation + if i <= 0.333: + degs = i/0.333*360 + # For the second third of the path, oscillate between +/- oscillation_max degrees + elif i <= 0.666: + angle = lerp(i, 0.333, 0.666, 0, 2*tau) + degs = oscillation_max * sin(angle) + # For the last third of the path, oscillate increasingly fast but with smaller magnitude + else: + # angle increases in a nonlinear curve, so + # oscillations should get quicker and quicker + x = lerp(i, 0.666, 1.0, 0, 2) + angle = pow(x, 2.2) * tau + # decrease the size of the oscillations by a factor of 10 + # over the course of this stretch + osc = lerp(i, 0.666, 1.0, oscillation_max, oscillation_max/10) + degs = osc * sin(angle) + complex_rotations.append(degs) + + complex_rot = make_label('Complex Rotation') + complex_rot += extrude_along_path(shape, path, rotations=complex_rotations) + + # Make some red markers to show the boundaries between the three sections of this path + marker_w = SHAPE_RAD * 1.5 + marker = translate([path_rad, 0, 0])( + cube([marker_w, 1, marker_w], center=True) + ) + markers = [color('red')(rotate([0,0,120*i])(marker)) for i in range(3)] + complex_rot += markers + + return simple_rot + right(3*path_rad)(complex_rot) + +def extrude_example_transforms() -> OpenSCADObject: + path_rad = PATH_RAD + height = 2*SHAPE_RAD + num_steps = 120 + + shape = circle_points(rad=path_rad, num_points=120) + path = [Point3(0,0,i) for i in frange(0, height, num_steps=num_steps)] + + max_rotation = radians(15) + max_z_displacement = height/10 + up = Vector3(0,0,1) + + # The transforms argument is powerful. + # Each point in the entire extrusion will call this function with unique arguments: + # -- `path_norm` in [0, 1] specifying how far along in the extrusion a point's loop is + # -- `loop_norm` in [0, 1] specifying where in its loop a point is. + def point_trans(point: Point3, path_norm:float, loop_norm: float) -> Point3: + # scale the point from 1x to 2x in the course of the + # extrusion, + scale = 1 + path_norm*path_norm/2 + p = scale * point + + # Rotate the points sinusoidally up to max_rotation + p = p.rotate_around(up, max_rotation*sin(tau*path_norm)) + + # Oscillate z values sinusoidally, growing from + # 0 magnitude to max_z_displacement, then decreasing to 0 magnitude at path_norm == 1 + max_z = sin(pi*path_norm) * max_z_displacement + angle = lerp(loop_norm, 0, 1, 0, 10*tau) + p.z += max_z*sin(angle) + return p + + no_trans = make_label('No Transform') + no_trans += down(height/2)( + extrude_along_path(shape, path, cap_ends=True) + ) + + # We can pass transforms a single function that will be called on all points, + # or pass a list with a transform function for each point along path + arb_trans = make_label('Arbitrary Transform') + arb_trans += down(height/2)( + extrude_along_path(shape, path, transforms=[point_trans], cap_ends=True) + ) + + return no_trans + right(3*path_rad)(arb_trans) + +# ============ +# = GEOMETRY = +# ============ +def sinusoidal_ring(rad=25, segments=SEGMENTS) -> List[Point3]: outline = [] for i in range(segments): - angle = i * 360 / segments - x = rad * cos(radians(angle)) - y = rad * sin(radians(angle)) - z = 2 * sin(radians(angle * 6)) + angle = radians(i * 360 / segments) + scaled_rad = (1 + 0.18*cos(angle*5)) * rad + x = scaled_rad * cos(angle) + y = scaled_rad * sin(angle) + z = 0 + # Or stir it up and add an oscillation in z as well + # z = 3 * sin(angle * 6) outline.append(Point3(x, y, z)) return outline - -def star(num_points=5, outer_rad=15, dip_factor=0.5): +def star(num_points=5, outer_rad=SHAPE_RAD, dip_factor=0.5) -> List[Point3]: star_pts = [] for i in range(2 * num_points): rad = outer_rad - i % 2 * dip_factor * outer_rad @@ -31,31 +193,34 @@ def star(num_points=5, outer_rad=15, dip_factor=0.5): star_pts.append(Point3(rad * cos(angle), rad * sin(angle), 0)) return star_pts +def circle_points(rad: float = SHAPE_RAD, num_points: int = SEGMENTS) -> List[Point2]: + angles = frange(0, tau, num_steps=num_points, include_end=True) + points = list([Point2(rad*cos(a), rad*sin(a)) for a in angles]) + return points -def extrude_example(): +def make_label(message:str, text_loc:Tuple[float, float]=TEXT_LOC, height=5) -> OpenSCADObject: + return translate(text_loc)( + linear_extrude(height)( + text(message) + ) + ) - # Note the incorrect triangulation at the two ends of the path. This - # is because star isn't convex, and the triangulation algorithm for - # the two end caps only works for convex shapes. - shape = star(num_points=5) - path = sinusoidal_ring(rad=50) - - # If scale_factors aren't included, they'll default to - # no scaling at each step along path. Here, let's - # make the shape twice as big at beginning and end of the path - scales = [1] * len(path) - scales[0] = 2 - scales[-1] = 2 - - extruded = extrude_along_path(shape_pts=shape, path_pts=path, scale_factors=scales) - - return extruded +# =============== +# = ENTRY POINT = +# =============== +if __name__ == "__main__": + out_dir = sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'path_extrude_example.scad') + basic_extrude = basic_extrude_example() + scaled_extrusions = extrude_example_xy_scaling() + capped_extrusions = extrude_example_capped_ends() + rotated_extrusions = extrude_example_rotations() + arbitrary_transforms = extrude_example_transforms() + all_objs = [basic_extrude, scaled_extrusions, capped_extrusions, rotated_extrusions, arbitrary_transforms] - a = extrude_example() + a = distribute_in_grid(all_objs, + max_bounding_box=[4*PATH_RAD, 4*PATH_RAD], + rows_and_cols=[len(all_objs), 1]) - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - scad_render_to_file(a, file_out, include_orig_code=True) + file_out = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/run_all_examples.sh b/solid/examples/run_all_examples.sh index 659983c2..558f0e86 100755 --- a/solid/examples/run_all_examples.sh +++ b/solid/examples/run_all_examples.sh @@ -1,21 +1,28 @@ +#!/usr/bin/env bash + +# Set CWD to this script's directory +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +cd "$DIR" || exit 1 + COMPILED_EXAMPLES=${PWD}/Compiled_examples echo # if COMPILED_EXAMPLES doesn't exist, create it. -if [ ! -e $COMPILED_EXAMPLES ]; - then mkdir $COMPILED_EXAMPLES; -fi - -for py in *.py; -do - echo "==================================================="; - echo "python $py $COMPILED_EXAMPLES"; - python $py $COMPILED_EXAMPLES; - echo "==================================================="; -echo -done +if [ ! -e "$COMPILED_EXAMPLES" ]; then + mkdir "$COMPILED_EXAMPLES" +fi + +function run_example() { + echo "===================================================" + python "$1" "$2" + echo "===================================================" +} + +for py in *.py; do + run_example "$py" "$COMPILED_EXAMPLES" +done + +run_example mazebox/mazebox.py "$COMPILED_EXAMPLES" -# Note: mazebox example isn't included because it requires a -# significant python package (pypng) to be installed. -# Comments in examples/mazebox/mazebox_clean2_stable.py -# explain how to install pypng \ No newline at end of file +# revert to original dir +cd - || exit 1 diff --git a/solid/examples/scad_to_include.scad b/solid/examples/scad_to_include.scad index a67ff9bb..e4e50e33 100644 --- a/solid/examples/scad_to_include.scad +++ b/solid/examples/scad_to_include.scad @@ -13,4 +13,13 @@ module steps(howmany=3){ } } -echo("This text should appear only when called with include(), not use()"); \ No newline at end of file +module blub(a, b=1) cube([a, 2, 2]); + +function scad_points() = [[0,0], [1,0], [0,1]]; + +// In Python, calling this function without an argument would be an error. +// Leave this here to confirm that this works in OpenSCAD. +function optional_nondefault_arg(arg1) = + let(s = arg1 ? arg1 : 1) cube([s,s,s]); + +echo("This text should appear only when called with include(), not use()"); diff --git a/solid/examples/screw_thread_example.py b/solid/examples/screw_thread_example.py index dcc8f8e2..0d1c02ee 100755 --- a/solid/examples/screw_thread_example.py +++ b/solid/examples/screw_thread_example.py @@ -1,13 +1,9 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os +#! /usr/bin/env python3 import sys -import re -from solid import * -from solid.utils import * +from solid import scad_render_to_file from solid import screw_thread +from solid.objects import cylinder SEGMENTS = 48 @@ -17,19 +13,20 @@ def assembly(): section = screw_thread.default_thread_section(tooth_height=10, tooth_depth=5) - s = screw_thread.thread(outline_pts=section, inner_rad=inner_rad, - pitch=screw_height, length=screw_height, segments_per_rot=SEGMENTS) - #, neck_in_degrees=90, neck_out_degrees=90) + s = screw_thread.thread(outline_pts=section, + inner_rad=inner_rad, + pitch=screw_height, + length=screw_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=90, + neck_out_degrees=90) c = cylinder(r=inner_rad, h=screw_height) return s + c -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir - file_out = os.path.join(out_dir, 'screw_thread_example.scad') +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else None a = assembly() - - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - - scad_render_to_file(a, file_out, include_orig_code=True) + file_out = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/sierpinski.py b/solid/examples/sierpinski.py index 00ad1517..617f6498 100755 --- a/solid/examples/sierpinski.py +++ b/solid/examples/sierpinski.py @@ -1,13 +1,12 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -import os +#! /usr/bin/env python3 +import math +import random import sys +from pathlib import Path -from solid import * -from solid.utils import * +from solid import scad_render_to_file +from solid.objects import cube, polyhedron, translate, union -import random -import math # ========================================================= # = A basic recursive Sierpinski's gasket implementation, @@ -68,10 +67,10 @@ def weighted_midpoint(a, b, weight=0.5, jitter_range_vec=None): def sierpinski_3d(generation, scale=1, midpoint_weight=0.5, jitter_range_vec=None): - orig_tet = SierpinskiTetrahedron([[ 1.0, 1.0, 1.0], - [-1.0, -1.0, 1.0], - [-1.0, 1.0, -1.0], - [ 1.0, -1.0, -1.0]]) + orig_tet = SierpinskiTetrahedron([[1.0, 1.0, 1.0], + [-1.0, -1.0, 1.0], + [-1.0, 1.0, -1.0], + [1.0, -1.0, -1.0]]) all_tets = [orig_tet] for i in range(generation): all_tets = [subtet for tet in all_tets for subtet in tet.next_gen(midpoint_weight, jitter_range_vec)] @@ -83,7 +82,7 @@ def sierpinski_3d(generation, scale=1, midpoint_weight=0.5, jitter_range_vec=Non if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir + out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() generations = 3 midpoint_weight = 0.5 @@ -101,6 +100,6 @@ def sierpinski_3d(generation, scale=1, midpoint_weight=0.5, jitter_range_vec=Non for p in tet.points: t.add(translate(p).add(cube(5, center=True))) - file_out = os.path.join(out_dir, 'gasket_%s_gen.scad' % generations) - print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) - scad_render_to_file(t, file_out) + file_out = out_dir / f'gasket_{generations}_gen.scad' + file_out = scad_render_to_file(t, file_out) + print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/solidpython_template.py b/solid/examples/solidpython_template.py index 5bfb1d16..860c1f86 100755 --- a/solid/examples/solidpython_template.py +++ b/solid/examples/solidpython_template.py @@ -1,13 +1,6 @@ -#! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os -import sys -import re - -# Assumes SolidPython is in site-packages or elsewhwere in sys.path -from solid import * -from solid.utils import * +#! /usr/bin/env python3 +from solid import scad_render_to_file +from solid.objects import union SEGMENTS = 48 @@ -18,6 +11,7 @@ def assembly(): return a + if __name__ == '__main__': a = assembly() - scad_render_to_file(a, file_header='$fn = %s;' % SEGMENTS, include_orig_code=True) + scad_render_to_file(a, file_header=f'$fn = {SEGMENTS};', include_orig_code=True) diff --git a/solid/examples/splines_example.py b/solid/examples/splines_example.py new file mode 100755 index 00000000..002b5049 --- /dev/null +++ b/solid/examples/splines_example.py @@ -0,0 +1,170 @@ +#! /usr/bin/env python +import os +import sys +from solid import * +from solid.utils import Red, right, forward, back + +from solid.splines import catmull_rom_points, catmull_rom_polygon, control_points +from solid.splines import bezier_polygon, bezier_points +from euclid3 import Vector2, Vector3, Point2, Point3 + +def assembly(): + # Catmull-Rom Splines + a = basic_catmull_rom() # Top row in OpenSCAD output + a += back(4)(catmull_rom_spline_variants()) # Row 2 + a += back(12)(bottle_shape(width=2, height=6)) # Row 3, the bottle shape + + # # TODO: include examples for 3D surfaces: + # a += back(16)(catmull_rom_patches()) + # a += back(20)(catmull_rom_prism()) + # a += back(24)(catmull_rom_prism_smooth()) + + # Bezier Splines + a += back(16)(basic_bezier()) # Row 4 + a += back(20)(bezier_points_variants()) # Row 5 + return a + +def basic_catmull_rom(): + points = [ + Point2(0,0), + Point2(1,1), + Point2(2,1), + Point2(2,-1), + ] + # In its simplest form, catmull_rom_polygon() will just make a C1-continuous + # closed shape. Easy. + shape_easy = catmull_rom_polygon(points) + # There are some other options as well... + shape = catmull_rom_polygon(points, subdivisions=20, extrude_height=5, show_controls=True) + return shape_easy + right(3)(shape) + +def catmull_rom_spline_variants(): + points = [ + Point2(0,0), + Point2(1,1), + Point2(2,1), + Point2(2,-1), + ] + controls = control_points(points) + + # By default, catmull_rom_points() will return a closed smooth shape + curve_points_closed = catmull_rom_points(points, close_loop=True) + + # If `close_loop` is False, it will return only points between the start and + # end control points, and make a best guess about tangents for the first and last segments + curve_points_open = catmull_rom_points(points, close_loop=False) + + # By specifying start_tangent and end_tangent, you can change a shape + # significantly. This is similar to what you might do with Illustrator's Pen Tool. + # Try changing these vectors to see the effects this has on the rightmost curve in the example + start_tangent = Vector2(-2, 0) + end_tangent = Vector2(3, 0) + tangent_pts = [points[0] + start_tangent, *points, points[-1] + end_tangent] + tangent_controls = control_points(tangent_pts) + curve_points_tangents = catmull_rom_points(points, close_loop=False, + start_tangent=start_tangent, end_tangent=end_tangent) + + closed = polygon(curve_points_closed) + controls + opened = polygon(curve_points_open) + controls + tangents = polygon(curve_points_tangents) + tangent_controls + + a = closed + right(3)(opened) + right(10)(tangents) + + return a + +def catmull_rom_patches(): + # TODO: write this + pass + +def catmull_rom_prism(): + # TODO: write this + pass + +def catmull_rom_prism_smooth(): + # TODO: write this + pass + +def bottle_shape(width: float, height: float, neck_width:float=None, neck_height:float=None): + if neck_width == None: + neck_width = width * 0.4 + + if neck_height == None: + neck_height = height * 0.2 + + w2 = width/2 + nw2 = neck_width/2 + h = height + nh = neck_height + + corner_rad = 0.5 + + # Add extra tangent points near curves to keep cubics from going crazy. + # Try taking some of these out and see how this affects the final shape + points = [ + Point2(nw2, h), + Point2(nw2, h-nh + 1), # <- extra tangent + Point2(nw2, h - nh), + Point2(w2, h-nh-h/6), # <- extra tangent + Point2(w2, corner_rad + 1), # <- extra tangent + Point2(w2, corner_rad), + Point2(w2-corner_rad, 0), + Point2(0,0), + ] + # Use catmull_rom_points() when you don't want all corners in a polygon + # smoothed out or want to combine the curve with other shapes. + # Extra points can then be added to the list you get back + cr_points = catmull_rom_points(points) + + # Insert a point at the top center of the bottle at the beginning of the + # points list. This is how the bottle has a sharp right angle corner at the + # sides of the neck; otherwise we'd have to insert several extra control + # points to make a sharp corner + cr_points.insert(0, (0,h)) + + # Make OpenSCAD polygons out of the shapes once all points are calculated + a = polygon(cr_points) + a += mirror(v=(1,0))(a) + + # Show control points. These aren't required for anything, but seeing them + # makes refining a curve much easier + controls = control_points(points) + a += controls + return a + +def basic_bezier(): + # A basic cubic Bezier curve will pass through its first and last + # points, but not through the central control points + controls = [ + Point2(0, 3), + Point2(1, 1), + Point2(2, 1), + Point2(3, 3) + ] + shape = bezier_polygon(controls, show_controls=True) + return shape + +def bezier_points_variants(): + controls = [ + Point2(0,0), + Point2(1,2), + Point2(2, -1), + Point2(3,0), + ] + points = bezier_points(controls, subdivisions=20) + # For non-smooth curves, add extra points + points += [ + Point2(2, -2), + Point2(1, -2) + ] + shape = polygon(points) + control_points(controls, extrude_height=0) + return shape + + +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir + + a = assembly() + + out_path = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{out_path}") + diff --git a/solid/extrude_along_path.py b/solid/extrude_along_path.py new file mode 100644 index 00000000..6e720706 --- /dev/null +++ b/solid/extrude_along_path.py @@ -0,0 +1,213 @@ +#! /usr/bin/env python +from math import radians +from solid import OpenSCADObject, Points, polyhedron +from solid.utils import euclidify, euc_to_arr, transform_to_point, EPSILON +from euclid3 import Point2, Point3, Vector2, Vector3 + +from typing import Optional, Sequence, Union, Callable + +Tuple2 = tuple[float, float] +FacetIndices = tuple[int, int, int] +Point3Transform = Callable[[Point3, Optional[float], Optional[float]], Point3] + + +# ========================== +# = Extrusion along a path = +# ========================== +def extrude_along_path( + shape_pts: Points, + path_pts: Points, + scales: Sequence[Union[Vector2, float, Tuple2]] = None, + rotations: Sequence[float] = None, + transforms: Sequence[Point3Transform] = None, + connect_ends=False, + cap_ends=True, +) -> OpenSCADObject: + """ + Extrude the curve defined by shape_pts along path_pts. + -- For predictable results, shape_pts must be planar, convex, and lie + in the XY plane centered around the origin. *Some* nonconvexity (e.g, star shapes) + and nonplanarity will generally work fine + + -- len(scales) should equal len(path_pts). No-op if not supplied + Each entry may be a single number for uniform scaling, or a pair of + numbers (or Point2) for differential X/Y scaling + If not supplied, no scaling will occur. + + -- len(rotations) should equal 1 or len(path_pts). No-op if not supplied. + Each point in shape_pts will be rotated by rotations[i] degrees at + each point in path_pts. Or, if only one rotation is supplied, the shape + will be rotated smoothly over rotations[0] degrees in the course of the extrusion + + -- len(transforms) should be 1 or be equal to len(path_pts). No-op if not supplied. + Each entry should be have the signature: + def transform_func(p:Point3, path_norm:float, loop_norm:float): Point3 + where path_norm is in [0,1] and expresses progress through the extrusion + and loop_norm is in [0,1] and express progress through a single loop of the extrusion + + -- if connect_ends is True, the first and last loops of the extrusion will + be joined, which is useful for toroidal geometries. Overrides cap_ends + + -- if cap_ends is True, each point in the first and last loops of the extrusion + will be connected to the centroid of that loop. For planar, convex shapes, this + works nicely. If shape is less planar or convex, some self-intersection may happen. + Not applied if connect_ends is True + """ + + polyhedron_pts: Points = [] + facet_indices: list[tuple[int, int, int]] = [] + + # Make sure we've got Euclid Point3's for all elements + shape_pts = euclidify(shape_pts, Point3) + path_pts = euclidify(path_pts, Point3) + + src_up = Vector3(0, 0, 1) + + shape_pt_count = len(shape_pts) + + tangent_path_points: list[Point3] = [] + + # If first & last points are the same, let's close the shape + first_last_equal = (path_pts[0] - path_pts[-1]).magnitude_squared() < EPSILON + if first_last_equal: + connect_ends = True + path_pts = path_pts[:][:-1] + + if connect_ends: + tangent_path_points = [path_pts[-1]] + path_pts + [path_pts[0]] + else: + first = Point3(*(path_pts[0] - (path_pts[1] - path_pts[0]))) + last = Point3(*(path_pts[-1] - (path_pts[-2] - path_pts[-1]))) + tangent_path_points = [first] + path_pts + [last] + tangents = [ + tangent_path_points[i + 2] - tangent_path_points[i] + for i in range(len(path_pts)) + ] + + for which_loop in range(len(path_pts)): + # path_normal is 0 at the first path_pts and 1 at the last + path_normal = which_loop / (len(path_pts) - 1) + + path_pt = path_pts[which_loop] + tangent = tangents[which_loop] + scale = scales[which_loop] if scales else 1 + + rotate_degrees = None + if rotations: + rotate_degrees = ( + rotations[which_loop] + if len(rotations) > 1 + else rotations[0] * path_normal + ) + + transform_func = None + if transforms: + transform_func = ( + transforms[which_loop] if len(transforms) > 1 else transforms[0] + ) + + this_loop = shape_pts[:] + this_loop = _scale_loop(this_loop, scale) + this_loop = _rotate_loop(this_loop, rotate_degrees) + this_loop = _transform_loop(this_loop, transform_func, path_normal) + + this_loop = transform_to_point( + this_loop, dest_point=path_pt, dest_normal=tangent, src_up=src_up + ) + loop_start_index = which_loop * shape_pt_count + + if which_loop < len(path_pts) - 1: + loop_facets = _loop_facet_indices(loop_start_index, shape_pt_count) + facet_indices += loop_facets + + # Add the transformed points & facets to our final list + polyhedron_pts += this_loop + + if connect_ends: + connect_loop_start_index = len(polyhedron_pts) - shape_pt_count + loop_facets = _loop_facet_indices(connect_loop_start_index, shape_pt_count, 0) + facet_indices += loop_facets + + elif cap_ends: + # OpenSCAD's polyhedron will automatically triangulate faces as needed. + # So just include all points at each end of the tube + last_loop_start_index = len(polyhedron_pts) - shape_pt_count + start_loop_indices = list(reversed(range(shape_pt_count))) + end_loop_indices = list( + range(last_loop_start_index, last_loop_start_index + shape_pt_count) + ) + facet_indices.append(start_loop_indices) + facet_indices.append(end_loop_indices) + + return polyhedron(points=euc_to_arr(polyhedron_pts), faces=facet_indices) # type: ignore + + +def _loop_facet_indices( + loop_start_index: int, loop_pt_count: int, next_loop_start_index=None +) -> list[FacetIndices]: + facet_indices: list[FacetIndices] = [] + # nlsi == next_loop_start_index + if next_loop_start_index is None: + next_loop_start_index = loop_start_index + loop_pt_count + loop_indices = list(range(loop_start_index, loop_pt_count + loop_start_index)) + [ + loop_start_index + ] + next_loop_indices = list( + range(next_loop_start_index, loop_pt_count + next_loop_start_index) + ) + [next_loop_start_index] + + for i, (a, b) in enumerate(zip(loop_indices[:-1], loop_indices[1:])): + c, d = next_loop_indices[i : i + 2] + # OpenSCAD's polyhedron will accept quads and do its own triangulation with them, + # so we could just append (a,b,d,c). + # However, this lets OpenSCAD (Or CGAL?) do its own triangulation, leading + # to some strange outcomes. Prefer to do our own triangulation. + # c--d + # |\ | + # | \| + # a--b + # facet_indices.append((a,b,d,c)) + facet_indices.append((a, b, c)) + facet_indices.append((b, d, c)) + return facet_indices + + +def _rotate_loop( + points: Sequence[Point3], rotation_degrees: float = None +) -> list[Point3]: + if rotation_degrees is None: + return points + up = Vector3(0, 0, 1) + rads = radians(rotation_degrees) + return [p.rotate_around(up, rads) for p in points] + + +def _scale_loop( + points: Sequence[Point3], scale: Union[float, Point2, Tuple2] = None +) -> list[Point3]: + if scale is None: + return points + + if isinstance(scale, (float, int)): + scale = [scale] * 2 + return [Point3(point.x * scale[0], point.y * scale[1], point.z) for point in points] + + +def _transform_loop( + points: Sequence[Point3], + transform_func: Point3Transform = None, + path_normal: float = None, +) -> list[Point3]: + # transform_func is a function that takes a point and optionally two floats, + # a `path_normal`, in [0,1] that indicates where this loop is in a path extrusion, + # and `loop_normal` in [0,1] that indicates where this point is in a list of points + if transform_func is None: + return points + + result = [] + for i, p in enumerate(points): + # i goes from 0 to 1 across points + loop_normal = i / (len(points) - 1) + new_p = transform_func(p, path_normal, loop_normal) + result.append(new_p) + return result diff --git a/solid/mypy/mypy_test.py b/solid/mypy/mypy_test.py new file mode 100755 index 00000000..81d8ed41 --- /dev/null +++ b/solid/mypy/mypy_test.py @@ -0,0 +1,11 @@ +#! /usr/bin/env python3 +from solid import cube, scad_render_to_file + + +def main(): + a = cube() + scad_render_to_file(a) + + +if __name__ == '__main__': + main() diff --git a/solid/objects.py b/solid/objects.py new file mode 100644 index 00000000..bdb685d2 --- /dev/null +++ b/solid/objects.py @@ -0,0 +1,1044 @@ +""" +Classes for OpenSCAD builtins +""" + +from pathlib import Path, PureWindowsPath +from types import SimpleNamespace +from typing import Optional, Sequence, Union + +from .solidpython import IncludedOpenSCADObject, OpenSCADObject + +PathStr = Union[Path, str] + +P2 = tuple[float, float] +P3 = tuple[float, float, float] +P4 = tuple[float, float, float, float] +Vec3 = P3 +Vec4 = P4 +Vec34 = Union[Vec3, Vec4] +P3s = Sequence[P3] +P23 = Union[P2, P3] +Points = Sequence[P23] +Indexes = Union[Sequence[int], Sequence[Sequence[int]]] +ScadSize = Union[int, Sequence[float]] +OpenSCADObjectPlus = Union[OpenSCADObject, Sequence[OpenSCADObject]] + +IMPORTED_SCAD_MODULES: dict[Path, SimpleNamespace] = {} + + +class polygon(OpenSCADObject): + """ + Create a polygon with the specified points and paths. + + :param points: the list of points of the polygon + :type points: sequence of 2 element sequences + + :param paths: Either a single vector, enumerating the point list, ie. the + order to traverse the points, or, a vector of vectors, ie a list of point + lists for each separate curve of the polygon. The latter is required if the + polygon has holes. The parameter is optional and if omitted the points are + assumed in order. (The 'pN' components of the *paths* vector are 0-indexed + references to the elements of the *points* vector.) + + :param convexity: OpenSCAD's convexity... yadda yadda + + NOTE: OpenSCAD accepts only 2D points for `polygon()`. Convert any 3D points + to 2D before compiling + """ + + def __init__( + self, + points: Union[Points, IncludedOpenSCADObject], + paths: Indexes = None, + convexity: int = None, + ) -> None: + # Force points to 2D if they're defined in Python, pass through if they're + # included OpenSCAD code + pts = points # type: ignore + if not isinstance(points, IncludedOpenSCADObject): + pts = list([(p[0], p[1]) for p in points]) # type: ignore + + args = {"points": pts, "convexity": convexity} + # If not supplied, OpenSCAD assumes all points in order for paths + if paths: + args["paths"] = paths # type: ignore + super().__init__("polygon", args) + + +class circle(OpenSCADObject): + """ + Creates a circle at the origin of the coordinate system. The argument + name is optional. + + :param r: This is the radius of the circle. Default value is 1. + :type r: number + + :param d: This is the diameter of the circle. Default value is 1. + :type d: number + + :param segments: Number of fragments in 360 degrees. + :type segments: int + """ + + def __init__(self, r: float = None, d: float = None, segments: int = None) -> None: + super().__init__("circle", {"r": r, "d": d, "segments": segments}) + + +class square(OpenSCADObject): + """ + Creates a square at the origin of the coordinate system. When center is + True the square will be centered on the origin, otherwise it is created + in the first quadrant. The argument names are optional if the arguments + are given in the same order as specified in the parameters + + :param size: If a single number is given, the result will be a square with + sides of that length. If a 2 value sequence is given, then the values will + correspond to the lengths of the X and Y sides. Default value is 1. + :type size: number or 2 value sequence + + :param center: This determines the positioning of the object. If True, + object is centered at (0,0). Otherwise, the square is placed in the positive + quadrant with one corner at (0,0). Defaults to False. + :type center: boolean + """ + + def __init__(self, size: ScadSize = None, center: bool = None) -> None: + super().__init__("square", {"size": size, "center": center}) + + +class sphere(OpenSCADObject): + """ + Creates a sphere at the origin of the coordinate system. The argument + name is optional. + + :param r: Radius of the sphere. + :type r: number + + :param d: Diameter of the sphere. + :type d: number + + :param segments: Resolution of the sphere + :type segments: int + """ + + def __init__(self, r: float = None, d: float = None, segments: int = None) -> None: + super().__init__("sphere", {"r": r, "d": d, "segments": segments}) + + +class cube(OpenSCADObject): + """ + Creates a cube at the origin of the coordinate system. When center is + True the cube will be centered on the origin, otherwise it is created in + the first octant. The argument names are optional if the arguments are + given in the same order as specified in the parameters + + :param size: If a single number is given, the result will be a cube with + sides of that length. If a 3 value sequence is given, then the values will + correspond to the lengths of the X, Y, and Z sides. Default value is 1. + :type size: number or 3 value sequence + + :param center: This determines the positioning of the object. If True, + object is centered at (0,0,0). Otherwise, the cube is placed in the positive + quadrant with one corner at (0,0,0). Defaults to False + :type center: boolean + """ + + def __init__(self, size: ScadSize = None, center: bool = None) -> None: + super().__init__("cube", {"size": size, "center": center}) + + +class cylinder(OpenSCADObject): + """ + Creates a cylinder or cone at the origin of the coordinate system. A + single radius (r) makes a cylinder, two different radii (r1, r2) make a + cone. + + :param h: This is the height of the cylinder. Default value is 1. + :type h: number + + :param r: The radius of both top and bottom ends of the cylinder. Use this + parameter if you want plain cylinder. Default value is 1. + :type r: number + + :param r1: This is the radius of the cone on bottom end. Default value is 1. + :type r1: number + + :param r2: This is the radius of the cone on top end. Default value is 1. + :type r2: number + + :param d: The diameter of both top and bottom ends of the cylinder. Use t + his parameter if you want plain cylinder. Default value is 1. + :type d: number + + :param d1: This is the diameter of the cone on bottom end. Default value is 1. + :type d1: number + + :param d2: This is the diameter of the cone on top end. Default value is 1. + :type d2: number + + :param center: If True will center the height of the cone/cylinder around + the origin. Default is False, placing the base of the cylinder or r1 radius + of cone at the origin. + :type center: boolean + + :param segments: Number of fragments in 360 degrees. + :type segments: int + """ + + def __init__( + self, + r: float = None, + h: float = None, + r1: float = None, + r2: float = None, + d: float = None, + d1: float = None, + d2: float = None, + center: bool = None, + segments: int = None, + ) -> None: + super().__init__( + "cylinder", + { + "r": r, + "h": h, + "r1": r1, + "r2": r2, + "d": d, + "d1": d1, + "d2": d2, + "center": center, + "segments": segments, + }, + ) + + +class polyhedron(OpenSCADObject): + """ + Create a polyhedron with a list of points and a list of faces. The point + list is all the vertices of the shape, the faces list is how the points + relate to the surfaces of the polyhedron. + + *note: if your version of OpenSCAD is lower than 2014.03 replace "faces" + with "triangles" in the below examples* + + :param points: sequence of points or vertices (each a 3 number sequence). + + :param triangles: (*deprecated in version 2014.03, use faces*) vector of + point triplets (each a 3 number sequence). Each number is the 0-indexed point + number from the point vector. + + :param faces: (*introduced in version 2014.03*) vector of point n-tuples + with n >= 3. Each number is the 0-indexed point number from the point vector. + That is, faces=[[0,1,4]] specifies a triangle made from the first, second, + and fifth point listed in points. When referencing more than 3 points in a + single tuple, the points must all be on the same plane. + + :param convexity: The convexity parameter specifies the maximum number of + front sides (back sides) a ray intersecting the object might penetrate. This + parameter is only needed for correctly displaying the object in OpenCSG + preview mode and has no effect on the polyhedron rendering. + :type convexity: int + """ + + def __init__( + self, + points: P3s, + faces: Indexes, + convexity: int = 10, + triangles: Indexes = None, + ) -> None: + super().__init__( + "polyhedron", + { + "points": points, + "faces": faces, + "convexity": convexity, + "triangles": triangles, + }, + ) + + +class union(OpenSCADObject): + """ + Creates a union of all its child nodes. This is the **sum** of all + children. + """ + + def __init__(self) -> None: + super().__init__("union", {}) + + def __add__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: + new_union = union() + for child in self.children: + new_union.add(child) + new_union.add(x) + + return new_union + + +class intersection(OpenSCADObject): + """ + Creates the intersection of all child nodes. This keeps the + **overlapping** portion + """ + + def __init__(self) -> None: + super().__init__("intersection", {}) + + def __mul__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: + new_int = intersection() + for child in self.children: + new_int.add(child) + new_int.add(x) + + return new_int + + +class difference(OpenSCADObject): + """ + Subtracts the 2nd (and all further) child nodes from the first one. + """ + + def __init__(self) -> None: + super().__init__("difference", {}) + + def __sub__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: + new_diff = difference() + for child in self.children: + new_diff.add(child) + new_diff.add(x) + + return new_diff + + +class hole(OpenSCADObject): + def __init__(self) -> None: + super().__init__("hole", {}) + self.set_hole(is_hole=True) + + +class part(OpenSCADObject): + def __init__(self) -> None: + super().__init__("part", {}) + self.set_part_root(is_root=True) + + +class translate(OpenSCADObject): + """ + Translates (moves) its child elements along the specified vector. + + :param v: X, Y and Z translation + :type v: 3 value sequence + """ + + def __init__(self, v: P3 = None) -> None: + super().__init__("translate", {"v": v}) + + +class scale(OpenSCADObject): + """ + Scales its child elements using the specified vector. + + :param v: X, Y and Z scale factor + :type v: 3 value sequence + """ + + def __init__(self, v: P3 = None) -> None: + super().__init__("scale", {"v": v}) + + +class rotate(OpenSCADObject): + """ + Rotates its child 'a' degrees about the origin of the coordinate system + or around an arbitrary axis. + + :param a: degrees of rotation, or sequence for degrees of rotation in each of the X, Y and Z axis. + :type a: number or 3 value sequence + + :param v: sequence specifying 0 or 1 to indicate which axis to rotate by 'a' degrees. Ignored if 'a' is a sequence. + :type v: 3 value sequence + """ + + def __init__(self, a: Union[float, Vec3] = None, v: Vec3 = None) -> None: + super().__init__("rotate", {"a": a, "v": v}) + + +class mirror(OpenSCADObject): + """ + Mirrors the child element on a plane through the origin. + + :param v: the normal vector of a plane intersecting the origin through which to mirror the object. + :type v: 3 number sequence + + """ + + def __init__(self, v: Vec3) -> None: + super().__init__("mirror", {"v": v}) + + +class resize(OpenSCADObject): + """ + Modify the size of the child object to match the given new size. + + :param newsize: X, Y and Z values + :type newsize: 3 value sequence + + :param auto: 3-tuple of booleans to specify which axes should be scaled + :type auto: 3 boolean sequence + """ + + def __init__(self, newsize: Vec3, auto: tuple[bool, bool, bool] = None) -> None: + super().__init__("resize", {"newsize": newsize, "auto": auto}) + + +class multmatrix(OpenSCADObject): + """ + Multiplies the geometry of all child elements with the given 4x4 + transformation matrix. + + :param m: transformation matrix + :type m: sequence of 4 sequences, each containing 4 numbers. + """ + + def __init__(self, m: tuple[Vec4, Vec4, Vec4, Vec4]) -> None: + super().__init__("multmatrix", {"m": m}) + + +class color(OpenSCADObject): + """ + Displays the child elements using the specified RGB color + alpha value. + This is only used for the F5 preview as CGAL and STL (F6) do not + currently support color. The alpha value will default to 1.0 (opaque) if + not specified. + + :param c: RGB color + alpha value. + :type c: sequence of 3 or 4 numbers between 0 and 1, OR 3-, 4-, 6-, or 8-digit RGB/A hex code, OR string color name as described at https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#color + + :param alpha: Alpha value from 0 to 1 + :type alpha: float + """ + + def __init__(self, c: Union[Vec34, str], alpha: float = 1.0) -> None: + super().__init__("color", {"c": c, "alpha": alpha}) + + +class minkowski(OpenSCADObject): + """ + Renders the `minkowski + sum `__ + of child nodes. + """ + + def __init__(self) -> None: + super().__init__("minkowski", {}) + + +class offset(OpenSCADObject): + """ + + :param r: Amount to offset the polygon (rounded corners). When negative, + the polygon is offset inwards. The parameter r specifies the radius + that is used to generate rounded corners, using delta gives straight edges. + :type r: number + + :param delta: Amount to offset the polygon (sharp corners). When negative, + the polygon is offset inwards. The parameter r specifies the radius + that is used to generate rounded corners, using delta gives straight edges. + :type delta: number + + :param chamfer: When using the delta parameter, this flag defines if edges + should be chamfered (cut off with a straight line) or not (extended to + their intersection). + :type chamfer: bool + + :param segments: Resolution of any radial curves + :type segments: int + """ + + def __init__( + self, + r: float = None, + delta: float = None, + chamfer: bool = False, + segments: int = None, + ) -> None: + if r is not None: + kwargs = {"r": r} + elif delta is not None: + kwargs = {"delta": delta, "chamfer": chamfer} + else: + raise ValueError("offset(): Must supply r or delta") + if segments: + kwargs["segments"] = segments + super().__init__("offset", kwargs) + + +class hull(OpenSCADObject): + """ + Renders the `convex + hull `__ + of child nodes. + """ + + def __init__(self) -> None: + super().__init__("hull", {}) + + +class render(OpenSCADObject): + """ + Always calculate the CSG model for this tree (even in OpenCSG preview + mode). + + :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. + :type convexity: int + """ + + def __init__(self, convexity: int = None) -> None: + super().__init__("render", {"convexity": convexity}) + + +class linear_extrude(OpenSCADObject): + """ + Linear Extrusion is a modeling operation that takes a 2D polygon as + input and extends it in the third dimension. This way a 3D shape is + created. + + :param height: the extrusion height. + :type height: number + + :param center: determines if the object is centered on the Z-axis after extrusion. + :type center: boolean + + :param convexity: The convexity parameter specifies the maximum number of + front sides (back sides) a ray intersecting the object might penetrate. This + parameter is only needed for correctly displaying the object in OpenCSG + preview mode and has no effect on the polyhedron rendering. + :type convexity: int + + :param twist: Twist is the number of degrees of through which the shape is + extruded. Setting to 360 will extrude through one revolution. The twist + direction follows the left hand rule. + :type twist: number + + :param slices: number of slices to extrude. Can be used to improve the output. + :type slices: int + + :param scale: relative size of the top of the extrusion compared to the start + :type scale: number + + """ + + def __init__( + self, + height: float = None, + center: bool = None, + convexity: int = None, + twist: float = None, + slices: int = None, + scale: float = None, + ) -> None: + super().__init__( + "linear_extrude", + { + "height": height, + "center": center, + "convexity": convexity, + "twist": twist, + "slices": slices, + "scale": scale, + }, + ) + + +class rotate_extrude(OpenSCADObject): + """ + A rotational extrusion is a Linear Extrusion with a twist, literally. + Unfortunately, it can not be used to produce a helix for screw threads + as the 2D outline must be normal to the axis of rotation, ie they need + to be flat in 2D space. + + The 2D shape needs to be either completely on the positive, or negative + side (not recommended), of the X axis. It can touch the axis, i.e. zero, + however if the shape crosses the X axis a warning will be shown in the + console windows and the rotate/_extrude() will be ignored. If the shape + is in the negative axis the faces will be inside-out, you probably don't + want to do that; it may be fixed in the future. + + :param angle: Defaults to 360. Specifies the number of degrees to sweep, + starting at the positive X axis. The direction of the sweep follows the + Right Hand Rule, hence a negative angle will sweep clockwise. + :type angle: number + + :param segments: Number of fragments in 360 degrees. + :type segments: int + + :param convexity: The convexity parameter specifies the maximum number of + front sides (back sides) a ray intersecting the object might penetrate. This + parameter is only needed for correctly displaying the object in OpenCSG + preview mode and has no effect on the polyhedron rendering. + :type convexity: int + + """ + + def __init__( + self, angle: float = 360, convexity: int = None, segments: int = None + ) -> None: + super().__init__( + "rotate_extrude", + {"angle": angle, "segments": segments, "convexity": convexity}, + ) + + +class dxf_linear_extrude(OpenSCADObject): + def __init__( + self, + file: PathStr, + layer: float = None, + height: float = None, + center: bool = None, + convexity: int = None, + twist: float = None, + slices: int = None, + ) -> None: + super().__init__( + "dxf_linear_extrude", + { + "file": Path(file).as_posix(), + "layer": layer, + "height": height, + "center": center, + "convexity": convexity, + "twist": twist, + "slices": slices, + }, + ) + + +class projection(OpenSCADObject): + """ + Creates 2d shapes from 3d models, and export them to the dxf format. + It works by projecting a 3D model to the (x,y) plane, with z at 0. + + :param cut: when True only points with z=0 will be considered (effectively + cutting the object) When False points above and below the plane will be + considered as well (creating a proper projection). + :type cut: boolean + """ + + def __init__(self, cut: bool = None) -> None: + super().__init__("projection", {"cut": cut}) + + +class surface(OpenSCADObject): + """ + Surface reads information from text or image files. + + :param file: The path to the file containing the heightmap data. + :type file: PathStr + + :param center: This determines the positioning of the generated object. If + True, object is centered in X- and Y-axis. Otherwise, the object is placed + in the positive quadrant. Defaults to False. + :type center: boolean + + :param invert: Inverts how the color values of imported images are translated + into height values. This has no effect when importing text data files. + Defaults to False. + :type invert: boolean + + :param convexity: The convexity parameter specifies the maximum number of + front sides (back sides) a ray intersecting the object might penetrate. + This parameter is only needed for correctly displaying the object in OpenCSG + preview mode and has no effect on the polyhedron rendering. + :type convexity: int + """ + + def __init__( + self, file, center: bool = None, convexity: int = None, invert=None + ) -> None: + super().__init__( + "surface", + {"file": file, "center": center, "convexity": convexity, "invert": invert}, + ) + + +class text(OpenSCADObject): + """ + Create text using fonts installed on the local system or provided as separate + font file. + + :param text: The text to generate. + :type text: string + + :param size: The generated text will have approximately an ascent of the given + value (height above the baseline). Default is 10. Note that specific fonts + will vary somewhat and may not fill the size specified exactly, usually + slightly smaller. + :type size: number + + :param font: The name of the font that should be used. This is not the name + of the font file, but the logical font name (internally handled by the + fontconfig library). A list of installed fonts can be obtained using the + font list dialog (Help -> Font list). + :type font: string + + :param halign: The horizontal alignment for the text. Possible values are + "left", "center" and "right". Default is "left". + :type halign: string + + :param valign: The vertical alignment for the text. Possible values are + "top", "center", "baseline" and "bottom". Default is "baseline". + :type valign: string + + :param spacing: Factor to increase/decrease the character spacing. The + default value of 1 will result in the normal spacing for the font, giving + a value greater than 1 will cause the letters to be spaced further apart. + :type spacing: number + + :param direction: Direction of the text flow. Possible values are "ltr" + (left-to-right), "rtl" (right-to-left), "ttb" (top-to-bottom) and "btt" + (bottom-to-top). Default is "ltr". + :type direction: string + + :param language: The language of the text. Default is "en". + :type language: string + + :param script: The script of the text. Default is "latin". + :type script: string + + :param segments: used for subdividing the curved path segments provided by + freetype + :type segments: int + """ + + def __init__( + self, + text: str, + size: float = None, + font: str = None, + halign: str = None, + valign: str = None, + spacing: float = None, + direction: str = None, + language: str = None, + script: str = None, + segments: int = None, + ) -> None: + super().__init__( + "text", + { + "text": text, + "size": size, + "font": font, + "halign": halign, + "valign": valign, + "spacing": spacing, + "direction": direction, + "language": language, + "script": script, + "segments": segments, + }, + ) + + +class child(OpenSCADObject): + def __init__( + self, index: int = None, vector: Sequence[int] = None, range=None + ) -> None: + super().__init__("child", {"index": index, "vector": vector, "range": range}) + + +class children(OpenSCADObject): + """ + The child nodes of the module instantiation can be accessed using the + children() statement within the module. The number of module children + can be accessed using the $children variable. + + :param index: select one child, at index value. Index start at 0 and should + be less than or equal to $children-1. + :type index: int + + :param vector: select children with index in vector. Index should be between + 0 and $children-1. + :type vector: sequence of int + + :param range: [:] or [::]. select children between to , incremented by (default 1). + """ + + def __init__( + self, index: int = None, vector: float = None, range: P23 = None + ) -> None: + super().__init__("children", {"index": index, "vector": vector, "range": range}) + + +class import_stl(OpenSCADObject): + def __init__( + self, + file: PathStr, + origin: P2 = (0, 0), + convexity: int = None, + layer: int = None, + ) -> None: + super().__init__( + "import", + { + "file": Path(file).as_posix(), + "origin": origin, + "convexity": convexity, + "layer": layer, + }, + ) + + +class import_dxf(OpenSCADObject): + def __init__( + self, file, origin=(0, 0), convexity: int = None, layer: int = None + ) -> None: + super().__init__( + "import", + {"file": file, "origin": origin, "convexity": convexity, "layer": layer}, + ) + + +class import_(OpenSCADObject): + """ + Imports a file for use in the current OpenSCAD model. OpenSCAD currently + supports import of DXF and STL (both ASCII and Binary) files. + + :param file: path to the STL or DXF file. + :type file: PathStr + + :param convexity: The convexity parameter specifies the maximum number of + front sides (back sides) a ray intersecting the object might penetrate. This + parameter is only needed for correctly displaying the object in OpenCSG + preview mode and has no effect on the polyhedron rendering. + :type convexity: int + """ + + def __init__( + self, + file: PathStr, + origin: P2 = (0, 0), + convexity: int = None, + layer: int = None, + ) -> None: + super().__init__( + "import", + { + "file": Path(file).as_posix(), + "origin": origin, + "convexity": convexity, + "layer": layer, + }, + ) + + +class intersection_for(OpenSCADObject): + """ + Iterate over the values in a vector or range and take an + intersection of the contents. + """ + + def __init__(self, n: int) -> None: + super().__init__("intersection_for", {"n": n}) + + +class assign(OpenSCADObject): + def __init__(self) -> None: + super().__init__("assign", {}) + + +# ================================ +# = Modifier Convenience Methods = +# ================================ +def debug(openscad_obj: OpenSCADObject) -> OpenSCADObject: + openscad_obj.set_modifier("#") + return openscad_obj + + +def background(openscad_obj: OpenSCADObject) -> OpenSCADObject: + openscad_obj.set_modifier("%") + return openscad_obj + + +def root(openscad_obj: OpenSCADObject) -> OpenSCADObject: + openscad_obj.set_modifier("!") + return openscad_obj + + +def disable(openscad_obj: OpenSCADObject) -> OpenSCADObject: + openscad_obj.set_modifier("*") + return openscad_obj + + +# =========================== +# = IMPORTING OPENSCAD CODE = +# =========================== +def import_scad(scad_file_or_dir: PathStr) -> SimpleNamespace: + """ + Recursively look in current directory & OpenSCAD library directories for + OpenSCAD files. Create Python mappings for all OpenSCAD modules & functions + Return a namespace or raise ValueError if no scad files found + """ + global IMPORTED_SCAD_MODULES + + scad = Path(scad_file_or_dir) + candidates: list[Path] = [scad] + + ns = IMPORTED_SCAD_MODULES.get(scad) + if ns: + return ns + else: + if not scad.is_absolute(): + candidates = [d / scad for d in _openscad_library_paths()] + + for candidate_path in candidates: + namespace = _import_scad(candidate_path) + if namespace is not None: + IMPORTED_SCAD_MODULES[scad] = namespace + return namespace + raise ValueError( + f"Could not find .scad files at or under {scad}. \nLocations searched were: {candidates}" + ) + + +def _import_scad(scad: Path) -> Optional[SimpleNamespace]: + """ + cases: + single scad file: + return a namespace populated with `use()` + directory + recurse into all subdirectories and *.scad files + return namespace if scad files are underneath, otherwise None + non-scad file: + return None + """ + namespace: Optional[SimpleNamespace] = None + if scad.is_file() and scad.suffix == ".scad": + namespace = SimpleNamespace() + use(scad.absolute(), dest_namespace_dict=namespace.__dict__) + elif scad.is_dir(): + subspaces = [ + (f, _import_scad(f)) + for f in scad.iterdir() + if f.is_dir() or f.suffix == ".scad" + ] + for f, subspace in subspaces: + if subspace: + if namespace is None: + namespace = SimpleNamespace() + # Add a subspace to namespace named by the file/dir it represents + package_name = f.stem + # Prefix an underscore to packages starting with a digit, which + # are valid in OpenSCAD but not in Python + if package_name[0].isdigit(): + package_name = "_" + package_name + + setattr(namespace, package_name, subspace) + + return namespace + + +def _openscad_library_paths() -> list[Path]: + """ + Return system-dependent OpenSCAD library paths or paths defined in os.environ['OPENSCADPATH'] + """ + import platform + import os + import re + + paths = [Path(".")] + + user_path = os.environ.get("OPENSCADPATH") + if user_path: + for s in re.split(r"\s*[;:]\s*", user_path): + paths.append(Path(s)) + + default_paths = { + "Linux": [ + Path.home() / ".local/share/OpenSCAD/libraries", + Path("/usr/share/openscad/libraries"), + ], + "Darwin": [ + Path.home() / "Documents/OpenSCAD/libraries", + Path("/Applications/OpenSCAD.app/Contents/Resources/libraries"), + ], + "Windows": [ + Path(PureWindowsPath("C:/Program Files/OpenSCAD/libraries")), + Path( + PureWindowsPath("C:/Program Files/OpenSCAD/libraries") + ), # add others here + ], + } + + paths += default_paths.get(platform.system(), []) + return paths + + +def _find_library(library_name: PathStr) -> Path: + result = Path(library_name) + + if not result.is_absolute(): + paths = _openscad_library_paths() + for p in paths: + f = p / result + # print(f'Checking {f} -> {f.exists()}') + if f.exists(): + result = f + break + + return result + + +# use() & include() mimic OpenSCAD's use/include mechanics. +# -- use() makes methods in scad_file_path.scad available to be called. +# --include() makes those methods available AND executes all code in +# scad_file_path.scad, which may have side effects. +# Unless you have a specific need, call use(). + + +def use( + scad_file_path: PathStr, + use_not_include: bool = True, + dest_namespace_dict: dict = None, +): + """ + Opens scad_file_path, parses it for all usable calls, + and adds them to caller's namespace. + """ + # These functions in solidpython are used here and only here; don't pollute + # the global namespace with them + from .solidpython import parse_scad_callables + from .solidpython import new_openscad_class_str + from .solidpython import calling_module + + scad_file_path = _find_library(scad_file_path) + + symbols_dicts = parse_scad_callables(scad_file_path) + + for sd in symbols_dicts: + class_str = new_openscad_class_str( + sd["name"], + sd["args"], + sd["kwargs"], + scad_file_path.as_posix(), + use_not_include, + ) + # If this is called from 'include', we have to look deeper in the stack + # to find the right module to add the new class to. + if dest_namespace_dict is None: + stack_depth = 2 if use_not_include else 3 + dest_namespace_dict = calling_module(stack_depth).__dict__ + try: + exec(class_str, dest_namespace_dict) + except Exception as e: + classname = sd["name"] + msg = f"Unable to import SCAD module: `{classname}` from `{scad_file_path.name}`, with error: {e}" + print(msg) + + return True + + +def include(scad_file_path: PathStr) -> bool: + return use(scad_file_path, use_not_include=False) diff --git a/solid/patch_euclid.py b/solid/patch_euclid.py index 38523bcb..68c30f1d 100644 --- a/solid/patch_euclid.py +++ b/solid/patch_euclid.py @@ -1,17 +1,16 @@ -import euclid -from euclid import * - -from solid.utils import * # Only needed for EPSILON. Tacky. +import euclid3 +from euclid3 import Vector3, Vector2, Line3 # NOTE: The PyEuclid on PyPi doesn't include several elements added to # the module as of 13 Feb 2013. Add them here until euclid supports them +def as_arr_local2(self): + return [self.x, self.y] -def as_arr_local(self): +def as_arr_local3(self): return [self.x, self.y, self.z] - -def set_length_local(self, length): +def set_length_local2(self, length): d = self.magnitude() if d: factor = length / d @@ -20,24 +19,38 @@ def set_length_local(self, length): return self +def set_length_local3(self, length): + d = self.magnitude() + if d: + factor = length / d + self.x *= factor + self.y *= factor + self.z *= factor + + return self def _intersect_line3_line3(A, B): # Connect A & B # If the length of the connecting segment is 0, they intersect # at the endpoint(s) of the connecting segment - sol = euclid._connect_line3_line3(A, B) + sol = euclid3._connect_line3_line3(A, B) # TODO: Ray3 and LineSegment3 would like to be able to know # if their intersection points fall within the segment. - if sol.magnitude_squared() < EPSILON: + if sol.magnitude_squared() < 0.001: return sol.p else: return None - -def run_patch(): +def run_euclid_patch(): if 'as_arr' not in dir(Vector3): - Vector3.as_arr = as_arr_local + Vector3.as_arr = as_arr_local3 + if 'as_arr' not in dir(Vector2): + Vector2.as_arr = as_arr_local2 + if 'set_length' not in dir(Vector3): - Vector3.set_length = set_length_local + Vector3.set_length = set_length_local3 + if 'set_length' not in dir(Vector2): + Vector2.set_length = set_length_local2 + if '_intersect_line3' not in dir(Line3): Line3._intersect_line3 = _intersect_line3_line3 diff --git a/solid/py_scadparser/LICENSE b/solid/py_scadparser/LICENSE new file mode 100644 index 00000000..0e259d42 --- /dev/null +++ b/solid/py_scadparser/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/solid/py_scadparser/README.md b/solid/py_scadparser/README.md new file mode 100644 index 00000000..29ec2812 --- /dev/null +++ b/solid/py_scadparser/README.md @@ -0,0 +1,6 @@ +# py_scadparser +A basic openscad parser written in python using ply. + +This parser is intended to be used within solidpython to import openscad code. For this purpose we only need to extract the global definitions of a openscad file. That's exactly what this package does. It parses a openscad file and extracts top level definitions. This includes "use"d and "include"d filenames, global variables, function and module definitions. + +Even though this parser actually parses (almost?) the entire openscad language (at least the portions used in my test libraries) 90% is dismissed and only the needed definitions are processed and extracted. diff --git a/solid/py_scadparser/parsetab.py b/solid/py_scadparser/parsetab.py new file mode 100644 index 00000000..ba409cb3 --- /dev/null +++ b/solid/py_scadparser/parsetab.py @@ -0,0 +1,128 @@ + +# parsetab.py +# This file is automatically generated. Do not edit. +# pylint: disable=W,C,R +_tabversion = '3.10' + +_lr_method = 'LALR' + +_lr_signature = 'nonassocASSERTnonassocECHOnonassocTHENnonassocELSEnonassoc?nonassoc:nonassoc(){}nonassoc=leftANDORleftEQUALNOT_EQUALGREATER_OR_EQUALLESS_OR_EQUAL>" expression\n| expression EQUAL expression\n| expression NOT_EQUAL expression\n| expression GREATER_OR_EQUAL expression\n| expression LESS_OR_EQUAL expression\n| expression AND expression\n| expression OR expression\naccess_expr : ID %prec ACCESS\n| expression "." ID %prec ACCESS\n| expression "(" call_parameter_list ")" %prec ACCESS\n| expression "(" ")" %prec ACCESS\n| expression "[" expression "]" %prec ACCESS\nlist_stuff : FUNCTION "(" opt_parameter_list ")" expression\n| LET "(" assignment_list ")" expression %prec THEN\n| EACH expression %prec THEN\n| "[" expression ":" expression "]"\n| "[" expression ":" expression ":" expression "]"\n| "[" for_loop expression "]"\n| tuple\nassert_or_echo : ASSERT "(" opt_call_parameter_list ")"\n| ECHO "(" opt_call_parameter_list ")"\nconstants : STRING\n| TRUE\n| FALSE\n| NUMBERopt_else :\n| ELSE expression %prec THEN\nfor_or_if : for_loop expression %prec THEN\n| IF "(" expression ")" expression opt_else\nexpression : access_expr\n| logic_expr\n| list_stuff\n| assert_or_echo\n| assert_or_echo expression %prec ASSERT\n| constants\n| for_or_if\n| "(" expression ")"\nassignment_list : ID "=" expression\n| assignment_list "," ID "=" expression\ncall : ID "(" call_parameter_list ")"\n| ID "(" ")"tuple : "[" opt_expression_list "]" commas : commas ","\n| ","\nopt_expression_list : expression_list\n| expression_list commas\n| emptyexpression_list : expression_list commas expression\n| expression\nopt_call_parameter_list :\n| call_parameter_list\ncall_parameter_list : call_parameter_list commas call_parameter\n| call_parametercall_parameter : expression\n| ID "=" expressionopt_parameter_list : parameter_list\n| parameter_list commas\n| empty\nparameter_list : parameter_list commas parameter\n| parameterparameter : ID\n| ID "=" expressionfunction : FUNCTION ID "(" opt_parameter_list ")" "=" expressionmodule : MODULE ID "(" opt_parameter_list ")" statement' + +_lr_action_items = {'IF':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,4,-2,-1,4,-3,4,4,4,4,4,-18,-21,-22,42,-6,42,42,4,-11,-12,-13,-14,-15,-16,-17,42,42,42,-64,-65,-66,42,-69,-70,-42,42,42,42,42,42,42,-53,-56,-57,-58,-59,-10,-75,42,42,4,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,42,-68,42,-24,-25,-26,-49,-62,42,42,4,42,4,42,-78,42,4,-23,-74,-19,42,42,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,42,-76,42,-7,-8,-77,-9,4,42,-44,4,-46,42,-52,42,42,-54,-55,42,42,-98,-60,-5,-27,42,-50,-47,-48,-97,-63,42,-20,-61,-51,]),'LET':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,6,-2,-1,6,-3,6,6,6,6,6,-18,-21,-22,57,-6,57,57,6,-11,-12,-13,-14,-15,-16,-17,57,57,57,-64,-65,-66,57,-69,-70,-42,57,57,57,57,57,57,-53,-56,-57,-58,-59,-10,-75,57,57,6,57,57,57,57,57,57,57,57,57,57,57,57,57,57,57,57,-68,57,-24,-25,-26,-49,-62,57,57,6,57,6,57,-78,57,6,-23,-74,-19,57,57,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,57,-76,57,-7,-8,-77,-9,6,57,-44,6,-46,57,-52,57,57,-54,-55,57,57,-98,-60,-5,-27,57,-50,-47,-48,-97,-63,57,-20,-61,-51,]),'ASSERT':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,7,-2,-1,7,-3,7,7,7,7,7,-18,-21,-22,61,-6,61,61,7,-11,-12,-13,-14,-15,-16,-17,61,61,61,-64,-65,-66,61,-69,-70,-42,61,61,61,61,61,61,-53,-56,-57,-58,-59,-10,-75,61,61,7,61,61,61,61,61,61,61,61,61,61,61,61,61,61,61,61,-68,61,-24,-25,-26,-49,-62,61,61,7,61,7,61,-78,61,7,-23,-74,-19,61,61,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,61,-76,61,-7,-8,-77,-9,7,61,-44,7,-46,61,-52,61,61,-54,-55,61,61,-98,-60,-5,-27,61,-50,-47,-48,-97,-63,61,-20,-61,-51,]),'ECHO':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,8,-2,-1,8,-3,8,8,8,8,8,-18,-21,-22,62,-6,62,62,8,-11,-12,-13,-14,-15,-16,-17,62,62,62,-64,-65,-66,62,-69,-70,-42,62,62,62,62,62,62,-53,-56,-57,-58,-59,-10,-75,62,62,8,62,62,62,62,62,62,62,62,62,62,62,62,62,62,62,62,-68,62,-24,-25,-26,-49,-62,62,62,8,62,8,62,-78,62,8,-23,-74,-19,62,62,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,62,-76,62,-7,-8,-77,-9,8,62,-44,8,-46,62,-52,62,62,-54,-55,62,62,-98,-60,-5,-27,62,-50,-47,-48,-97,-63,62,-20,-61,-51,]),'{':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,78,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,9,-2,-1,9,-3,9,9,9,9,9,-18,-21,-22,-6,9,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-75,9,-68,-24,-25,-26,-49,-62,9,9,9,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,9,-44,9,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),'%':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,75,76,78,85,87,105,106,111,112,113,116,117,120,123,127,128,129,130,138,139,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,168,170,171,174,175,176,178,181,183,184,185,187,188,189,192,193,197,198,199,200,202,203,204,205,207,208,210,211,212,213,],[-3,10,-2,-1,10,-3,10,10,10,10,10,-18,-21,-22,-6,10,-11,-12,-13,-14,-15,-16,-17,91,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,91,-42,-10,91,-75,91,10,91,91,-24,-25,-26,91,91,10,10,10,-23,-74,-19,91,-71,-45,-4,-43,91,91,-28,91,91,-31,-32,-33,91,91,91,91,91,91,91,91,91,-76,-7,91,-8,91,-9,91,91,10,-44,10,-46,91,-52,91,-54,-55,-98,91,-5,91,-50,91,91,91,91,-63,91,-20,91,-51,]),'*':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,75,76,78,85,87,105,106,111,112,113,116,117,120,123,127,128,129,130,138,139,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,168,170,171,174,175,176,178,181,183,184,185,187,188,189,192,193,197,198,199,200,202,203,204,205,207,208,210,211,212,213,],[-3,11,-2,-1,11,-3,11,11,11,11,11,-18,-21,-22,-6,11,-11,-12,-13,-14,-15,-16,-17,95,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,95,-42,-10,95,-75,95,11,95,95,-24,-25,-26,95,95,11,11,11,-23,-74,-19,95,-71,-45,-4,-43,95,95,95,95,95,-31,-32,-33,95,95,95,95,95,95,95,95,95,-76,-7,95,-8,95,-9,95,95,11,-44,11,-46,95,-52,95,-54,-55,-98,95,-5,95,-50,95,95,95,95,-63,95,-20,95,-51,]),'!':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,12,-2,-1,12,-3,12,12,12,12,12,-18,-21,-22,55,-6,55,55,12,-11,-12,-13,-14,-15,-16,-17,55,55,55,-64,-65,-66,55,-69,-70,-42,55,55,55,55,55,55,-53,-56,-57,-58,-59,-10,-75,55,55,12,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,-68,55,-24,-25,-26,-49,-62,55,55,12,55,12,55,-78,55,12,-23,-74,-19,55,55,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,55,-76,55,-7,-8,-77,-9,12,55,-44,12,-46,55,-52,55,55,-54,-55,55,55,-98,-60,-5,-27,55,-50,-47,-48,-97,-63,55,-20,-61,-51,]),'#':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,78,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,13,-2,-1,13,-3,13,13,13,13,13,-18,-21,-22,-6,13,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-75,13,-68,-24,-25,-26,-49,-62,13,13,13,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,13,-44,13,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),'USE':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,78,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,15,-2,-1,15,-3,15,15,15,15,15,-18,-21,-22,-6,15,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-75,15,-68,-24,-25,-26,-49,-62,15,15,15,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,15,-44,15,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),'INCLUDE':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,78,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,16,-2,-1,16,-3,16,16,16,16,16,-18,-21,-22,-6,16,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-75,16,-68,-24,-25,-26,-49,-62,16,16,16,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,16,-44,16,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),';':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,76,78,79,80,81,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,176,177,178,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,17,-2,-1,17,-3,17,17,17,17,17,-18,-21,-22,-6,17,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,128,-75,131,-94,-95,17,-68,-24,-25,-26,-49,-62,17,17,17,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,195,-93,-96,17,-44,17,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),'ID':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,82,83,84,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,180,181,182,183,184,185,186,188,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,20,-2,-1,20,-3,20,20,20,20,20,-18,-21,-22,40,41,51,-6,68,73,73,20,-11,-12,-13,-14,-15,-16,-17,51,73,81,51,-64,-65,-66,51,-69,-70,-42,51,51,51,51,51,51,-53,-56,-57,-58,-59,-10,-75,81,81,51,73,20,143,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,51,-68,51,-24,-25,-26,81,68,-49,-62,73,73,20,169,51,20,73,-78,51,20,-23,-74,-19,51,81,51,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,51,-76,51,-7,-8,-77,-9,81,20,51,-44,20,-46,51,-52,51,51,-54,-55,51,81,51,-98,-60,-5,-27,51,-50,-47,-48,-97,-63,51,-20,-61,-51,]),'FOR':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,21,-2,-1,21,-3,21,21,21,21,21,-18,-21,-22,21,-6,21,21,21,-11,-12,-13,-14,-15,-16,-17,21,21,21,-64,-65,-66,21,-69,-70,-42,21,21,21,21,21,21,-53,-56,-57,-58,-59,-10,-75,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,-68,21,-24,-25,-26,-49,-62,21,21,21,21,21,21,-78,21,21,-23,-74,-19,21,21,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,21,-76,21,-7,-8,-77,-9,21,21,-44,21,-46,21,-52,21,21,-54,-55,21,21,-98,-60,-5,-27,21,-50,-47,-48,-97,-63,21,-20,-61,-51,]),'FUNCTION':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,24,25,27,28,29,30,31,32,33,34,35,36,37,38,43,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,75,78,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,107,111,112,113,116,117,118,119,120,122,123,124,125,126,127,128,129,130,131,133,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,162,163,168,171,173,175,181,182,183,184,185,186,188,190,191,192,193,194,196,197,198,199,200,201,202,203,204,207,208,209,211,212,213,],[-3,22,-2,-1,22,-3,22,22,22,22,22,-18,-21,-22,56,-6,56,56,22,-11,-12,-13,-14,-15,-16,-17,56,56,56,-64,-65,-66,56,-69,-70,-42,56,56,56,56,56,56,-53,-56,-57,-58,-59,-10,-75,56,56,22,56,56,56,56,56,56,56,56,56,56,56,56,56,56,56,56,-68,56,-24,-25,-26,-49,-62,56,56,22,56,22,56,-78,56,22,-23,-74,-19,56,56,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,56,-76,56,-7,-8,-77,-9,22,56,-44,22,-46,56,-52,56,56,-54,-55,56,56,-98,-60,-5,-27,56,-50,-47,-48,-97,-63,56,-20,-61,-51,]),'MODULE':([0,1,2,3,5,9,10,11,12,13,14,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,78,87,105,111,112,113,116,117,120,123,127,128,129,130,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,181,183,184,185,188,192,193,197,198,199,200,202,203,204,207,208,211,212,213,],[-3,23,-2,-1,23,-3,23,23,23,23,23,-18,-21,-22,-6,23,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-75,23,-68,-24,-25,-26,-49,-62,23,23,23,-23,-74,-19,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,23,-44,23,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-20,-61,-51,]),'$end':([0,1,2,3,17,18,19,25,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,105,111,112,113,116,117,128,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,183,185,188,192,193,197,198,199,200,202,203,204,207,208,212,213,],[-3,0,-2,-1,-18,-21,-22,-6,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-68,-24,-25,-26,-49,-62,-23,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,-44,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-61,-51,]),'}':([2,3,9,17,18,19,25,29,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,105,111,112,113,116,117,128,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,183,185,188,192,193,197,198,199,200,202,203,204,207,208,212,213,],[-2,-1,-3,-18,-21,-22,-6,75,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-68,-24,-25,-26,-49,-62,-23,-71,-45,-4,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,-44,-46,-52,-54,-55,-98,-60,-5,-27,-50,-47,-48,-97,-63,-61,-51,]),'(':([4,6,7,8,20,21,24,27,28,37,38,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,72,73,76,84,85,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,111,112,113,116,117,118,119,122,124,125,126,130,131,133,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,170,173,174,176,178,182,183,185,186,187,188,189,190,191,192,193,194,196,198,200,201,202,203,204,205,207,208,209,210,211,212,213,],[24,26,27,28,38,39,43,43,43,43,43,82,83,84,43,86,-64,-65,-66,43,-69,-70,-42,43,43,43,43,114,115,43,43,-53,118,119,-56,-57,-58,-59,86,-42,86,43,86,43,43,43,43,43,43,43,43,43,43,43,43,43,43,43,43,43,86,86,43,-24,-25,-26,86,86,43,43,43,43,-78,43,-19,43,43,86,-71,-45,-43,86,86,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,43,86,-76,43,86,-77,86,86,86,43,-44,-46,43,86,-52,86,43,43,-54,-55,43,43,86,86,43,-50,86,86,86,86,-63,43,86,-20,86,-51,]),'FILENAME':([15,16,],[35,36,]),'ELSE':([17,18,19,25,30,31,32,33,34,35,36,45,46,47,48,49,50,51,60,63,64,65,66,75,105,111,112,113,116,117,128,139,141,142,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,168,171,175,183,185,188,192,193,197,198,199,200,202,203,204,207,208,212,213,],[-18,-21,-22,-6,-11,-12,-13,-14,-15,-16,-17,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-10,-68,-24,-25,-26,-49,-62,-23,-71,-45,184,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,-7,-8,-9,-44,-46,-52,-54,-55,-98,209,-5,-27,-50,-47,-48,-97,-63,-61,-51,]),'=':([20,68,73,81,169,179,],[37,122,126,133,194,196,]),'-':([24,27,28,37,38,43,44,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,72,73,76,84,85,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,111,112,113,116,117,118,119,122,124,125,126,130,131,133,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,170,173,174,176,178,182,183,185,186,187,188,189,190,191,192,193,194,196,198,200,201,202,203,204,205,207,208,209,210,211,212,213,],[53,53,53,53,53,53,93,-64,-65,-66,53,-69,-70,-42,53,53,53,53,53,53,-53,-56,-57,-58,-59,93,-42,93,53,93,53,53,53,53,53,53,53,53,53,53,53,53,53,53,53,53,53,93,93,53,-24,-25,-26,93,93,53,53,53,53,-78,53,-19,53,53,93,-71,-45,-43,93,93,-28,-29,-30,-31,-32,-33,93,93,93,93,93,93,93,93,53,93,-76,53,93,-77,93,93,93,53,-44,-46,53,93,-52,93,53,53,-54,-55,53,53,93,93,53,-50,93,93,93,93,-63,53,93,-20,93,-51,]),'+':([24,27,28,37,38,43,44,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,72,73,76,84,85,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,111,112,113,116,117,118,119,122,124,125,126,130,131,133,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,170,173,174,176,178,182,183,185,186,187,188,189,190,191,192,193,194,196,198,200,201,202,203,204,205,207,208,209,210,211,212,213,],[54,54,54,54,54,54,92,-64,-65,-66,54,-69,-70,-42,54,54,54,54,54,54,-53,-56,-57,-58,-59,92,-42,92,54,92,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,54,92,92,54,-24,-25,-26,92,92,54,54,54,54,-78,54,-19,54,54,92,-71,-45,-43,92,92,-28,-29,-30,-31,-32,-33,92,92,92,92,92,92,92,92,54,92,-76,54,92,-77,92,92,92,54,-44,-46,54,92,-52,92,54,54,-54,-55,54,54,92,92,54,-50,92,92,92,92,-63,54,92,-20,92,-51,]),'EACH':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,125,126,130,131,133,160,163,173,182,186,190,191,192,193,194,196,201,209,211,],[58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,58,-78,58,-19,58,58,58,58,-77,58,58,58,58,-54,-55,58,58,58,58,-20,]),'[':([24,27,28,37,38,43,44,45,46,47,48,49,50,51,52,53,54,55,58,59,60,63,64,65,66,72,73,76,84,85,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,111,112,113,116,117,118,119,122,124,125,126,130,131,133,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,170,173,174,176,178,182,183,185,186,187,188,189,190,191,192,193,194,196,198,200,201,202,203,204,205,207,208,209,210,211,212,213,],[52,52,52,52,52,52,89,-64,-65,-66,52,-69,-70,-42,52,52,52,52,52,52,-53,-56,-57,-58,-59,89,-42,89,52,89,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,52,-68,89,52,-24,-25,-26,-49,-62,52,52,52,52,-78,52,-19,52,52,89,-71,-45,-43,89,89,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,52,-62,-76,52,89,-77,89,89,89,52,-44,-46,52,89,-52,89,52,52,-54,-55,52,52,89,-27,52,-50,-47,-48,89,89,-63,52,89,-20,-61,-51,]),'STRING':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,125,126,130,131,133,160,163,173,182,186,190,191,192,193,194,196,201,209,211,],[63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,63,-78,63,-19,63,63,63,63,-77,63,63,63,63,-54,-55,63,63,63,63,-20,]),'TRUE':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,125,126,130,131,133,160,163,173,182,186,190,191,192,193,194,196,201,209,211,],[64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,64,-78,64,-19,64,64,64,64,-77,64,64,64,64,-54,-55,64,64,64,64,-20,]),'FALSE':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,125,126,130,131,133,160,163,173,182,186,190,191,192,193,194,196,201,209,211,],[65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,-78,65,-19,65,65,65,65,-77,65,65,65,65,-54,-55,65,65,65,65,-20,]),'NUMBER':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,125,126,130,131,133,160,163,173,182,186,190,191,192,193,194,196,201,209,211,],[66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,66,-78,66,-19,66,66,66,66,-77,66,66,66,66,-54,-55,66,66,66,66,-20,]),')':([27,28,38,44,45,46,47,48,49,50,51,60,63,64,65,66,67,69,70,71,72,73,74,77,79,80,81,82,83,85,86,105,111,112,113,114,116,117,118,119,125,134,135,136,137,138,139,140,141,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,162,164,165,166,167,170,172,173,174,177,178,180,183,185,188,192,193,198,200,202,203,204,205,206,208,212,213,],[-84,-84,78,87,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,120,123,-85,-87,-88,-42,127,129,130,-94,-95,-3,-3,139,141,-68,-24,-25,-26,-3,-49,-62,-84,-84,-78,179,-90,-92,181,182,-71,183,-45,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-76,190,191,192,193,-72,-86,-77,-89,-93,-96,-91,-44,-46,-52,-54,-55,-60,-27,-50,-47,-48,-73,211,-63,-61,-51,]),'.':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[88,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,88,-42,88,88,-68,88,-24,-25,-26,-49,-62,88,-71,-45,-43,88,88,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-62,-76,88,88,88,88,-44,-46,88,-52,88,-54,-55,88,-27,-50,-47,-48,88,88,-63,88,-61,-51,]),'?':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[90,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,90,-42,90,90,90,90,-24,-25,-26,90,90,90,-71,-45,-43,90,90,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,90,-76,90,90,90,90,-44,-46,90,-52,90,-54,-55,90,-27,-50,-47,90,90,90,-63,90,90,-51,]),'/':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[94,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,94,-42,94,94,94,94,-24,-25,-26,94,94,94,-71,-45,-43,94,94,94,94,94,-31,-32,-33,94,94,94,94,94,94,94,94,94,-76,94,94,94,94,-44,-46,94,-52,94,-54,-55,94,94,-50,94,94,94,94,-63,94,94,-51,]),'^':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[96,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,96,-42,96,96,96,96,-24,-25,-26,96,96,96,-71,-45,-43,96,96,96,96,96,96,96,96,96,96,96,96,96,96,96,96,96,-76,96,96,96,96,-44,-46,96,-52,96,-54,-55,96,96,-50,96,96,96,96,-63,96,96,-51,]),'<':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[97,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,97,-42,97,97,97,97,-24,-25,-26,97,97,97,-71,-45,-43,97,97,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,97,97,97,-76,97,97,97,97,-44,-46,97,-52,97,-54,-55,97,97,-50,97,97,97,97,-63,97,97,-51,]),'>':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[98,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,98,-42,98,98,98,98,-24,-25,-26,98,98,98,-71,-45,-43,98,98,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,98,98,98,-76,98,98,98,98,-44,-46,98,-52,98,-54,-55,98,98,-50,98,98,98,98,-63,98,98,-51,]),'EQUAL':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[99,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,99,-42,99,99,99,99,-24,-25,-26,99,99,99,-71,-45,-43,99,99,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,99,99,99,-76,99,99,99,99,-44,-46,99,-52,99,-54,-55,99,99,-50,99,99,99,99,-63,99,99,-51,]),'NOT_EQUAL':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[100,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,100,-42,100,100,100,100,-24,-25,-26,100,100,100,-71,-45,-43,100,100,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,100,100,100,-76,100,100,100,100,-44,-46,100,-52,100,-54,-55,100,100,-50,100,100,100,100,-63,100,100,-51,]),'GREATER_OR_EQUAL':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[101,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,101,-42,101,101,101,101,-24,-25,-26,101,101,101,-71,-45,-43,101,101,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,101,101,101,-76,101,101,101,101,-44,-46,101,-52,101,-54,-55,101,101,-50,101,101,101,101,-63,101,101,-51,]),'LESS_OR_EQUAL':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[102,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,102,-42,102,102,102,102,-24,-25,-26,102,102,102,-71,-45,-43,102,102,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,102,102,102,-76,102,102,102,102,-44,-46,102,-52,102,-54,-55,102,102,-50,102,102,102,102,-63,102,102,-51,]),'AND':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[103,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,103,-42,103,103,103,103,-24,-25,-26,103,103,103,-71,-45,-43,103,103,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,103,-76,103,103,103,103,-44,-46,103,-52,103,-54,-55,103,103,-50,103,103,103,103,-63,103,103,-51,]),'OR':([44,45,46,47,48,49,50,51,60,63,64,65,66,72,73,76,85,105,106,111,112,113,116,117,138,139,141,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,170,174,176,178,183,185,187,188,189,192,193,198,200,202,203,204,205,207,208,210,212,213,],[104,-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,104,-42,104,104,104,104,-24,-25,-26,104,104,104,-71,-45,-43,104,104,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,104,-76,104,104,104,104,-44,-46,104,-52,104,-54,-55,104,104,-50,104,104,104,104,-63,104,104,-51,]),',':([45,46,47,48,49,50,51,60,63,64,65,66,67,70,71,72,73,77,79,80,81,105,106,109,111,112,113,116,117,124,125,132,135,139,140,141,143,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,163,165,170,172,173,174,177,178,180,183,185,188,189,192,193,198,200,202,203,204,205,206,208,212,213,],[-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,121,125,-87,-88,-42,125,125,-94,-95,-68,-83,125,-24,-25,-26,-49,-62,173,-78,173,125,-71,125,-45,-43,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-62,-76,173,121,-72,-86,-77,-89,-93,-96,173,-44,-46,-52,-82,-54,-55,-60,-27,-50,-47,-48,-73,125,-63,-61,-51,]),':':([45,46,47,48,49,50,51,60,63,64,65,66,105,106,111,112,113,116,117,139,141,143,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,183,185,187,188,192,193,198,200,202,203,204,208,212,213,],[-64,-65,-66,-67,-69,-70,-42,-53,-56,-57,-58,-59,-68,160,-24,-25,-26,-49,-62,-71,-45,-43,186,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-62,-76,-44,-46,201,-52,-54,-55,-60,-27,-50,-47,-48,-63,-61,-51,]),']':([45,46,47,48,49,50,51,52,60,63,64,65,66,105,106,108,109,110,111,112,113,116,117,125,139,141,143,144,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,162,163,173,183,185,187,188,189,192,193,198,200,202,203,204,208,210,212,213,],[-64,-65,-66,-67,-69,-70,-42,-3,-53,-56,-57,-58,-59,-68,-83,162,-79,-81,-24,-25,-26,-49,-62,-78,-71,-45,-43,185,-28,-29,-30,-31,-32,-33,-34,-35,-36,-37,-38,-39,-40,-41,-62,-76,-80,-77,-44,-46,202,-52,-82,-54,-55,-60,-27,-50,-47,-48,-63,213,-61,-51,]),} + +_lr_action = {} +for _k, _v in _lr_action_items.items(): + for _x,_y in zip(_v[0],_v[1]): + if not _x in _lr_action: _lr_action[_x] = {} + _lr_action[_x][_k] = _y +del _lr_action_items + +_lr_goto_items = {'statements':([0,9,],[1,29,]),'empty':([0,9,52,82,83,114,],[2,2,110,136,136,136,]),'statement':([1,5,10,11,12,13,14,29,87,120,123,127,181,184,],[3,25,30,31,32,33,34,3,142,168,171,175,197,199,]),'for_loop':([1,5,10,11,12,13,14,24,27,28,29,37,38,43,48,52,53,54,55,58,59,84,86,87,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,120,122,123,124,126,127,131,133,160,163,181,182,184,186,190,191,194,196,201,209,],[5,5,5,5,5,5,5,59,59,59,5,59,59,59,59,107,59,59,59,59,59,59,59,5,59,59,59,59,59,59,59,59,59,59,59,59,59,59,59,59,59,59,59,5,59,5,59,59,5,59,59,59,59,5,59,5,59,59,59,59,59,59,59,]),'call':([1,5,10,11,12,13,14,29,87,120,123,127,181,184,],[14,14,14,14,14,14,14,14,14,14,14,14,14,14,]),'function':([1,5,10,11,12,13,14,29,87,120,123,127,181,184,],[18,18,18,18,18,18,18,18,18,18,18,18,18,18,]),'module':([1,5,10,11,12,13,14,29,87,120,123,127,181,184,],[19,19,19,19,19,19,19,19,19,19,19,19,19,19,]),'expression':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[44,72,72,76,72,85,105,106,111,112,113,116,117,138,72,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,161,72,72,170,72,174,176,178,187,189,198,200,203,204,205,207,210,212,]),'access_expr':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,45,]),'logic_expr':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,46,]),'list_stuff':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,47,]),'assert_or_echo':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,48,]),'constants':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,49,]),'for_or_if':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,50,]),'tuple':([24,27,28,37,38,43,48,52,53,54,55,58,59,84,86,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,107,118,119,122,124,126,131,133,160,163,182,186,190,191,194,196,201,209,],[60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,60,]),'assignment_list':([26,115,],[67,165,]),'opt_call_parameter_list':([27,28,118,119,],[69,74,166,167,]),'call_parameter_list':([27,28,38,86,118,119,],[70,70,77,140,70,70,]),'call_parameter':([27,28,38,86,118,119,124,],[71,71,71,71,71,71,172,]),'parameter_list':([39,82,83,114,195,],[79,135,135,135,206,]),'parameter':([39,82,83,114,132,180,195,],[80,80,80,80,177,177,80,]),'opt_expression_list':([52,],[108,]),'expression_list':([52,],[109,]),'commas':([70,77,79,109,135,140,206,],[124,124,132,163,180,124,132,]),'opt_parameter_list':([82,83,114,],[134,137,164,]),'opt_else':([198,],[208,]),} + +_lr_goto = {} +for _k, _v in _lr_goto_items.items(): + for _x, _y in zip(_v[0], _v[1]): + if not _x in _lr_goto: _lr_goto[_x] = {} + _lr_goto[_x][_k] = _y +del _lr_goto_items +_lr_productions = [ + ("S' -> statements","S'",1,None,None,None), + ('statements -> statements statement','statements',2,'p_statements','scad_parser.py',44), + ('statements -> empty','statements',1,'p_statements_empty','scad_parser.py',51), + ('empty -> ','empty',0,'p_empty','scad_parser.py',56), + ('statement -> IF ( expression ) statement','statement',5,'p_statement','scad_parser.py',60), + ('statement -> IF ( expression ) statement ELSE statement','statement',7,'p_statement','scad_parser.py',61), + ('statement -> for_loop statement','statement',2,'p_statement','scad_parser.py',62), + ('statement -> LET ( assignment_list ) statement','statement',5,'p_statement','scad_parser.py',63), + ('statement -> ASSERT ( opt_call_parameter_list ) statement','statement',5,'p_statement','scad_parser.py',64), + ('statement -> ECHO ( opt_call_parameter_list ) statement','statement',5,'p_statement','scad_parser.py',65), + ('statement -> { statements }','statement',3,'p_statement','scad_parser.py',66), + ('statement -> % statement','statement',2,'p_statement','scad_parser.py',67), + ('statement -> * statement','statement',2,'p_statement','scad_parser.py',68), + ('statement -> ! statement','statement',2,'p_statement','scad_parser.py',69), + ('statement -> # statement','statement',2,'p_statement','scad_parser.py',70), + ('statement -> call statement','statement',2,'p_statement','scad_parser.py',71), + ('statement -> USE FILENAME','statement',2,'p_statement','scad_parser.py',72), + ('statement -> INCLUDE FILENAME','statement',2,'p_statement','scad_parser.py',73), + ('statement -> ;','statement',1,'p_statement','scad_parser.py',74), + ('for_loop -> FOR ( parameter_list )','for_loop',4,'p_for_loop','scad_parser.py',79), + ('for_loop -> FOR ( parameter_list ; expression ; parameter_list )','for_loop',8,'p_for_loop','scad_parser.py',80), + ('statement -> function','statement',1,'p_statement_function','scad_parser.py',84), + ('statement -> module','statement',1,'p_statement_module','scad_parser.py',89), + ('statement -> ID = expression ;','statement',4,'p_statement_assignment','scad_parser.py',94), + ('logic_expr -> - expression','logic_expr',2,'p_logic_expr','scad_parser.py',99), + ('logic_expr -> + expression','logic_expr',2,'p_logic_expr','scad_parser.py',100), + ('logic_expr -> ! expression','logic_expr',2,'p_logic_expr','scad_parser.py',101), + ('logic_expr -> expression ? expression : expression','logic_expr',5,'p_logic_expr','scad_parser.py',102), + ('logic_expr -> expression % expression','logic_expr',3,'p_logic_expr','scad_parser.py',103), + ('logic_expr -> expression + expression','logic_expr',3,'p_logic_expr','scad_parser.py',104), + ('logic_expr -> expression - expression','logic_expr',3,'p_logic_expr','scad_parser.py',105), + ('logic_expr -> expression / expression','logic_expr',3,'p_logic_expr','scad_parser.py',106), + ('logic_expr -> expression * expression','logic_expr',3,'p_logic_expr','scad_parser.py',107), + ('logic_expr -> expression ^ expression','logic_expr',3,'p_logic_expr','scad_parser.py',108), + ('logic_expr -> expression < expression','logic_expr',3,'p_logic_expr','scad_parser.py',109), + ('logic_expr -> expression > expression','logic_expr',3,'p_logic_expr','scad_parser.py',110), + ('logic_expr -> expression EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',111), + ('logic_expr -> expression NOT_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',112), + ('logic_expr -> expression GREATER_OR_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',113), + ('logic_expr -> expression LESS_OR_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',114), + ('logic_expr -> expression AND expression','logic_expr',3,'p_logic_expr','scad_parser.py',115), + ('logic_expr -> expression OR expression','logic_expr',3,'p_logic_expr','scad_parser.py',116), + ('access_expr -> ID','access_expr',1,'p_access_expr','scad_parser.py',121), + ('access_expr -> expression . ID','access_expr',3,'p_access_expr','scad_parser.py',122), + ('access_expr -> expression ( call_parameter_list )','access_expr',4,'p_access_expr','scad_parser.py',123), + ('access_expr -> expression ( )','access_expr',3,'p_access_expr','scad_parser.py',124), + ('access_expr -> expression [ expression ]','access_expr',4,'p_access_expr','scad_parser.py',125), + ('list_stuff -> FUNCTION ( opt_parameter_list ) expression','list_stuff',5,'p_list_stuff','scad_parser.py',130), + ('list_stuff -> LET ( assignment_list ) expression','list_stuff',5,'p_list_stuff','scad_parser.py',131), + ('list_stuff -> EACH expression','list_stuff',2,'p_list_stuff','scad_parser.py',132), + ('list_stuff -> [ expression : expression ]','list_stuff',5,'p_list_stuff','scad_parser.py',133), + ('list_stuff -> [ expression : expression : expression ]','list_stuff',7,'p_list_stuff','scad_parser.py',134), + ('list_stuff -> [ for_loop expression ]','list_stuff',4,'p_list_stuff','scad_parser.py',135), + ('list_stuff -> tuple','list_stuff',1,'p_list_stuff','scad_parser.py',136), + ('assert_or_echo -> ASSERT ( opt_call_parameter_list )','assert_or_echo',4,'p_assert_or_echo','scad_parser.py',141), + ('assert_or_echo -> ECHO ( opt_call_parameter_list )','assert_or_echo',4,'p_assert_or_echo','scad_parser.py',142), + ('constants -> STRING','constants',1,'p_constants','scad_parser.py',147), + ('constants -> TRUE','constants',1,'p_constants','scad_parser.py',148), + ('constants -> FALSE','constants',1,'p_constants','scad_parser.py',149), + ('constants -> NUMBER','constants',1,'p_constants','scad_parser.py',150), + ('opt_else -> ','opt_else',0,'p_opt_else','scad_parser.py',154), + ('opt_else -> ELSE expression','opt_else',2,'p_opt_else','scad_parser.py',155), + ('for_or_if -> for_loop expression','for_or_if',2,'p_for_or_if','scad_parser.py',161), + ('for_or_if -> IF ( expression ) expression opt_else','for_or_if',6,'p_for_or_if','scad_parser.py',162), + ('expression -> access_expr','expression',1,'p_expression','scad_parser.py',167), + ('expression -> logic_expr','expression',1,'p_expression','scad_parser.py',168), + ('expression -> list_stuff','expression',1,'p_expression','scad_parser.py',169), + ('expression -> assert_or_echo','expression',1,'p_expression','scad_parser.py',170), + ('expression -> assert_or_echo expression','expression',2,'p_expression','scad_parser.py',171), + ('expression -> constants','expression',1,'p_expression','scad_parser.py',172), + ('expression -> for_or_if','expression',1,'p_expression','scad_parser.py',173), + ('expression -> ( expression )','expression',3,'p_expression','scad_parser.py',174), + ('assignment_list -> ID = expression','assignment_list',3,'p_assignment_list','scad_parser.py',180), + ('assignment_list -> assignment_list , ID = expression','assignment_list',5,'p_assignment_list','scad_parser.py',181), + ('call -> ID ( call_parameter_list )','call',4,'p_call','scad_parser.py',186), + ('call -> ID ( )','call',3,'p_call','scad_parser.py',187), + ('tuple -> [ opt_expression_list ]','tuple',3,'p_tuple','scad_parser.py',191), + ('commas -> commas ,','commas',2,'p_commas','scad_parser.py',195), + ('commas -> ,','commas',1,'p_commas','scad_parser.py',196), + ('opt_expression_list -> expression_list','opt_expression_list',1,'p_opt_expression_list','scad_parser.py',201), + ('opt_expression_list -> expression_list commas','opt_expression_list',2,'p_opt_expression_list','scad_parser.py',202), + ('opt_expression_list -> empty','opt_expression_list',1,'p_opt_expression_list','scad_parser.py',203), + ('expression_list -> expression_list commas expression','expression_list',3,'p_expression_list','scad_parser.py',207), + ('expression_list -> expression','expression_list',1,'p_expression_list','scad_parser.py',208), + ('opt_call_parameter_list -> ','opt_call_parameter_list',0,'p_opt_call_parameter_list','scad_parser.py',213), + ('opt_call_parameter_list -> call_parameter_list','opt_call_parameter_list',1,'p_opt_call_parameter_list','scad_parser.py',214), + ('call_parameter_list -> call_parameter_list commas call_parameter','call_parameter_list',3,'p_call_parameter_list','scad_parser.py',219), + ('call_parameter_list -> call_parameter','call_parameter_list',1,'p_call_parameter_list','scad_parser.py',220), + ('call_parameter -> expression','call_parameter',1,'p_call_parameter','scad_parser.py',224), + ('call_parameter -> ID = expression','call_parameter',3,'p_call_parameter','scad_parser.py',225), + ('opt_parameter_list -> parameter_list','opt_parameter_list',1,'p_opt_parameter_list','scad_parser.py',229), + ('opt_parameter_list -> parameter_list commas','opt_parameter_list',2,'p_opt_parameter_list','scad_parser.py',230), + ('opt_parameter_list -> empty','opt_parameter_list',1,'p_opt_parameter_list','scad_parser.py',231), + ('parameter_list -> parameter_list commas parameter','parameter_list',3,'p_parameter_list','scad_parser.py',240), + ('parameter_list -> parameter','parameter_list',1,'p_parameter_list','scad_parser.py',241), + ('parameter -> ID','parameter',1,'p_parameter','scad_parser.py',249), + ('parameter -> ID = expression','parameter',3,'p_parameter','scad_parser.py',250), + ('function -> FUNCTION ID ( opt_parameter_list ) = expression','function',7,'p_function','scad_parser.py',255), + ('module -> MODULE ID ( opt_parameter_list ) statement','module',6,'p_module','scad_parser.py',264), +] diff --git a/solid/py_scadparser/scad_ast.py b/solid/py_scadparser/scad_ast.py new file mode 100644 index 00000000..9bfc1aa4 --- /dev/null +++ b/solid/py_scadparser/scad_ast.py @@ -0,0 +1,48 @@ +from enum import Enum + +class ScadTypes(Enum): + GLOBAL_VAR = 0 + MODULE = 1 + FUNCTION = 2 + USE = 3 + INCLUDE = 4 + PARAMETER = 5 + +class ScadObject: + def __init__(self, scadType): + self.scadType = scadType + + def getType(self): + return self.scadType + +class ScadGlobalVar(ScadObject): + def __init__(self, name): + super().__init__(ScadTypes.GLOBAL_VAR) + self.name = name + +class ScadCallable(ScadObject): + def __init__(self, name, parameters, scadType): + super().__init__(scadType) + self.name = name + self.parameters = parameters + + def __repr__(self): + return f'{self.name} ({self.parameters})' + +class ScadModule(ScadCallable): + def __init__(self, name, parameters): + super().__init__(name, parameters, ScadTypes.MODULE) + +class ScadFunction(ScadCallable): + def __init__(self, name, parameters): + super().__init__(name, parameters, ScadTypes.FUNCTION) + +class ScadParameter(ScadObject): + def __init__(self, name, optional=False): + super().__init__(ScadTypes.PARAMETER) + self.name = name + self.optional = optional + + def __repr__(self): + return self.name + "=None" if self.optional else self.name + diff --git a/solid/py_scadparser/scad_parser.py b/solid/py_scadparser/scad_parser.py new file mode 100644 index 00000000..d878928c --- /dev/null +++ b/solid/py_scadparser/scad_parser.py @@ -0,0 +1,339 @@ +from ply import lex, yacc + +# workaround relative imports.... make this module runable as script +if __name__ == "__main__": + from scad_ast import ( + ScadGlobalVar, + ScadFunction, + ScadModule, + ScadParameter, + ScadTypes, + ) + + # Note that the lexer depends on importing all elements in scad_tokens + from scad_tokens import * # noqa: F403 +else: + from .scad_ast import ( + ScadGlobalVar, + ScadFunction, + ScadModule, + ScadParameter, + ScadTypes, + ) + + # Note that the lexer depends on importing all elements in scad_tokens + from .scad_tokens import * # noqa: F403 + +precedence = ( + ("nonassoc", "ASSERT"), + ("nonassoc", "ECHO"), + ("nonassoc", "THEN"), + ("nonassoc", "ELSE"), + ("nonassoc", "?"), + ("nonassoc", ":"), + ("nonassoc", "(", ")", "{", "}"), + ("nonassoc", "="), + ("left", "AND", "OR"), + ("left", "EQUAL", "NOT_EQUAL", "GREATER_OR_EQUAL", "LESS_OR_EQUAL", ">", "<"), + ("left", "+", "-"), + ("left", "%"), + ("left", "*", "/"), + ("right", "^"), + ("right", "NEG", "POS", "BACKGROUND", "NOT"), + ("left", "ACCESS"), +) + + +def p_statements(p): + """statements : statements statement""" + p[0] = p[1] + if p[2] is not None: + p[0].append(p[2]) + + +def p_statements_empty(p): + """statements : empty""" + p[0] = [] + + +def p_empty(p): + "empty :" + + +def p_statement(p): + """statement : IF "(" expression ")" statement %prec THEN + | IF "(" expression ")" statement ELSE statement + | for_loop statement + | LET "(" assignment_list ")" statement %prec THEN + | ASSERT "(" opt_call_parameter_list ")" statement + | ECHO "(" opt_call_parameter_list ")" statement + | "{" statements "}" + | "%" statement %prec BACKGROUND + | "*" statement %prec BACKGROUND + | "!" statement %prec BACKGROUND + | "#" statement %prec BACKGROUND + | call statement + | USE FILENAME + | INCLUDE FILENAME + | ";" + """ + + +def p_for_loop(p): + '''for_loop : FOR "(" parameter_list ")" + | FOR "(" parameter_list ";" expression ";" parameter_list ")"''' + + +def p_statement_function(p): + "statement : function" + p[0] = p[1] + + +def p_statement_module(p): + "statement : module" + p[0] = p[1] + + +def p_statement_assignment(p): + 'statement : ID "=" expression ";"' + p[0] = ScadGlobalVar(p[1]) + + +def p_logic_expr(p): + """logic_expr : "-" expression %prec NEG + | "+" expression %prec POS + | "!" expression %prec NOT + | expression "?" expression ":" expression + | expression "%" expression + | expression "+" expression + | expression "-" expression + | expression "/" expression + | expression "*" expression + | expression "^" expression + | expression "<" expression + | expression ">" expression + | expression EQUAL expression + | expression NOT_EQUAL expression + | expression GREATER_OR_EQUAL expression + | expression LESS_OR_EQUAL expression + | expression AND expression + | expression OR expression + """ + + +def p_access_expr(p): + """access_expr : ID %prec ACCESS + | expression "." ID %prec ACCESS + | expression "(" call_parameter_list ")" %prec ACCESS + | expression "(" ")" %prec ACCESS + | expression "[" expression "]" %prec ACCESS + """ + + +def p_list_stuff(p): + """list_stuff : FUNCTION "(" opt_parameter_list ")" expression + | LET "(" assignment_list ")" expression %prec THEN + | EACH expression %prec THEN + | "[" expression ":" expression "]" + | "[" expression ":" expression ":" expression "]" + | "[" for_loop expression "]" + | tuple + """ + + +def p_assert_or_echo(p): + """assert_or_echo : ASSERT "(" opt_call_parameter_list ")" + | ECHO "(" opt_call_parameter_list ")" + """ + + +def p_constants(p): + """constants : STRING + | TRUE + | FALSE + | NUMBER""" + + +def p_opt_else(p): + """opt_else : + | ELSE expression %prec THEN + """ + # this causes some shift/reduce conflicts, but I don't know how to solve it + + +def p_for_or_if(p): + """for_or_if : for_loop expression %prec THEN + | IF "(" expression ")" expression opt_else + """ + + +def p_expression(p): + """expression : access_expr + | logic_expr + | list_stuff + | assert_or_echo + | assert_or_echo expression %prec ASSERT + | constants + | for_or_if + | "(" expression ")" + """ + # the assert_or_echo stuff causes some shift/reduce conflicts, but I don't know how to solve it + + +def p_assignment_list(p): + """assignment_list : ID "=" expression + | assignment_list "," ID "=" expression + """ + + +def p_call(p): + '''call : ID "(" call_parameter_list ")" + | ID "(" ")"''' + + +def p_tuple(p): + """tuple : "[" opt_expression_list "]" """ + + +def p_commas(p): + """commas : commas "," + | "," + """ + + +def p_opt_expression_list(p): + """opt_expression_list : expression_list + | expression_list commas + | empty""" + + +def p_expression_list(p): + """expression_list : expression_list commas expression + | expression + """ + + +def p_opt_call_parameter_list(p): + """opt_call_parameter_list : + | call_parameter_list + """ + + +def p_call_parameter_list(p): + """call_parameter_list : call_parameter_list commas call_parameter + | call_parameter""" + + +def p_call_parameter(p): + """call_parameter : expression + | ID "=" expression""" + + +def p_opt_parameter_list(p): + """opt_parameter_list : parameter_list + | parameter_list commas + | empty + """ + if p[1] is not None: + p[0] = p[1] + else: + p[0] = [] + + +def p_parameter_list(p): + """parameter_list : parameter_list commas parameter + | parameter""" + if len(p) > 2: + p[0] = p[1] + [p[3]] + else: + p[0] = [p[1]] + + +def p_parameter(p): + """parameter : ID + | ID "=" expression""" + p[0] = ScadParameter(p[1], len(p) == 4) + + +def p_function(p): + """function : FUNCTION ID "(" opt_parameter_list ")" "=" expression""" + params = None + if p[4] != ")": + params = p[4] + + p[0] = ScadFunction(p[2], params) + + +def p_module(p): + """module : MODULE ID "(" opt_parameter_list ")" statement""" + params = None + if p[4] != ")": + params = p[4] + + p[0] = ScadModule(p[2], params) + + +def p_error(p): + print( + f"py_scadparser: Syntax error: {p.lexer.filename}({p.lineno}) {p.type} - {p.value}" + ) + + +def parseFile(scadFile): + lexer = lex.lex(debug=False) + lexer.filename = scadFile + parser = yacc.yacc(debug=False) + + modules = [] + functions = [] + globalVars = [] + + appendObject = { + ScadTypes.MODULE: lambda x: modules.append(x), + ScadTypes.FUNCTION: lambda x: functions.append(x), + ScadTypes.GLOBAL_VAR: lambda x: globalVars.append(x), + } + + from pathlib import Path + + with Path(scadFile).open() as f: + for i in parser.parse(f.read(), lexer=lexer): + appendObject[i.getType()](i) + + return modules, functions, globalVars + + +def parseFileAndPrintGlobals(scadFile): + print(f"======{scadFile}======") + modules, functions, globalVars = parseFile(scadFile) + + print("Modules:") + for m in modules: + print(f" {m}") + + print("Functions:") + for m in functions: + print(f" {m}") + + print("Global Variables:") + for m in globalVars: + print(f" {m.name}") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print( + f"usage: {sys.argv[0]} [-q] [ ...]\n -q : quiete" + ) + + quiete = sys.argv[1] == "-q" + files = sys.argv[2:] if quiete else sys.argv[1:] + + for i in files: + if quiete: + print(i) + parseFile(i) + else: + parseFileAndPrintGlobals(i) diff --git a/solid/py_scadparser/scad_tokens.py b/solid/py_scadparser/scad_tokens.py new file mode 100644 index 00000000..182048f5 --- /dev/null +++ b/solid/py_scadparser/scad_tokens.py @@ -0,0 +1,109 @@ +literals = [ + ".", ",", ";", + "=", + "!", + ">", "<", + "+", "-", "*", "/", "^", + "?", ":", + "[", "]", "{", "}", "(", ")", + "%", "#" +] + +reserved = { + 'use' : 'USE', + 'include': 'INCLUDE', + 'module' : 'MODULE', + 'function' : 'FUNCTION', + 'if' : 'IF', + 'else' : 'ELSE', + 'let' : 'LET', + 'assert' : 'ASSERT', + 'for' : 'FOR', + 'each' : 'EACH', + 'true' : 'TRUE', + 'false' : 'FALSE', + 'echo' : 'ECHO', +} + +tokens = [ + "ID", + "NUMBER", + "STRING", + "EQUAL", + "GREATER_OR_EQUAL", + "LESS_OR_EQUAL", + "NOT_EQUAL", + "AND", "OR", + "FILENAME", + ] + list(reserved.values()) + +#copy & paste from https://github.com/eliben/pycparser/blob/master/pycparser/c_lexer.py +#LICENSE: BSD +simple_escape = r"""([a-wyzA-Z._~!=&\^\-\\?'"]|x(?![0-9a-fA-F]))""" +decimal_escape = r"""(\d+)(?!\d)""" +hex_escape = r"""(x[0-9a-fA-F]+)(?![0-9a-fA-F])""" +bad_escape = r"""([\\][^a-zA-Z._~^!=&\^\-\\?'"x0-9])""" +escape_sequence = r"""(\\("""+simple_escape+'|'+decimal_escape+'|'+hex_escape+'))' +escape_sequence_start_in_string = r"""(\\[0-9a-zA-Z._~!=&\^\-\\?'"])""" +string_char = r"""([^"\\\n]|"""+escape_sequence_start_in_string+')' +t_STRING = '"'+string_char+'*"' + " | " + "'" +string_char+ "*'" + +t_EQUAL = "==" +t_GREATER_OR_EQUAL = ">=" +t_LESS_OR_EQUAL = "<=" +t_NOT_EQUAL = "!=" +t_AND = "\&\&" +t_OR = "\|\|" + +t_FILENAME = r'<[a-zA-Z_0-9/\\\.-]*>' + +def t_eat_escaped_quotes(t): + r"\\\"" + pass + +def t_comments1(t): + r'(/\*(.|\n)*?\*/)' + t.lexer.lineno += t.value.count("\n") + pass + +def t_comments2(t): + r'//.*[\n\']?' + t.lexer.lineno += 1 + pass + +def t_whitespace(t): + r'\s' + t.lexer.lineno += t.value.count("\n") + +def t_ID(t): + r'[\$]?[0-9]*[a-zA-Z_][a-zA-Z_0-9]*' + t.type = reserved.get(t.value,'ID') + return t + +def t_NUMBER(t): + r'[0-9]*\.?\d+([eE][-\+]\d+)?' + t.value = float(t.value) + return t + +def t_error(t): + print(f'py_scadparser: Illegal character: {t.lexer.filename}({t.lexer.lineno}) "{t.value[0]}"') + t.lexer.skip(1) + +if __name__ == "__main__": + import sys + from ply import lex + from pathlib import Path + + if len(sys.argv) < 2: + print(f"usage: {sys.argv[0]} ") + + p = Path(sys.argv[1]) + f = p.open() + lexer = lex.lex() + lexer.filename = p.as_posix() + lexer.input(''.join(f.readlines())) + for tok in iter(lexer.token, None): + if tok.type == "MODULE": + print("") + print(repr(tok.type), repr(tok.value), end='') + diff --git a/solid/screw_thread.py b/solid/screw_thread.py index eadc5e62..d3445a8b 100755 --- a/solid/screw_thread.py +++ b/solid/screw_thread.py @@ -1,62 +1,108 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -import os -import sys -import re - -from solid import * -from solid.utils import * -from euclid import * +#! /usr/bin/env python3 +import math +from typing import Sequence, Tuple, Union + +from euclid3 import Point3, Vector3 + +from solid import scad_render_to_file +from solid.objects import cylinder, polyhedron +from solid.utils import EPSILON, UP_VEC, bounding_box +from math import radians + # NOTE: The PyEuclid on PyPi doesn't include several elements added to # the module as of 13 Feb 2013. Add them here until euclid supports them # TODO: when euclid updates, remove this cruft. -ETJ 13 Feb 2013 -import solid.patch_euclid -solid.patch_euclid.run_patch() +from solid import run_euclid_patch + +run_euclid_patch() + +P2 = Tuple[float, float] +P3 = Tuple[float, float, float] +P23 = Union[P2, P3] +Points = Sequence[P23] + + +def map_segment( + x: float, domain_min: float, domain_max: float, range_min: float, range_max: float +) -> float: + if domain_min == domain_max or range_min == range_max: + return range_min + proportion = (x - domain_min) / (domain_max - domain_min) + return (1 - proportion) * range_min + proportion * range_max + + +def thread( + outline_pts: Points, + inner_rad: float, + pitch: float, + length: float, + external: bool = True, + segments_per_rot: int = 32, + neck_in_degrees: float = 0, + neck_out_degrees: float = 0, + rad_2: float = None, + inverse_thread_direction: bool = False, +): + """ + Sweeps outline_pts (an array of points describing a closed polygon in XY) + through a spiral. + :param outline_pts: a list of points (NOT an OpenSCAD polygon) that define the cross section of the thread + :type outline_pts: list -def thread(outline_pts, inner_rad, pitch, length, external=True, segments_per_rot=32, neck_in_degrees=0, neck_out_degrees=0): - ''' - Sweeps outline_pts (an array of points describing a closed polygon in XY) - through a spiral. - - This is done by creating and returning one huge polyhedron, with potentially - thousands of faces. An alternate approach would make one single polyhedron, - then repeat it over and over in the spiral shape, unioning them all together. - This would create a similar number of SCAD objects and operations, but still - require a lot of transforms and unions to be done in the SCAD code rather than - in the python, as here. Also would take some doing to make the neck-in work - as well. Not sure how the two approaches compare in terms of render-time. - -ETJ 16 Mar 2011 - - outline_pts: a list of points (NOT an OpenSCAD polygon) that define the cross - section of the thread - - inner_rad: radius of cylinder the screw will wrap around - pitch: height for one revolution; must be <= the height of outline_pts - bounding box to avoid self-intersection - length: distance from bottom-most point of screw to topmost - external: if True, the cross-section is external to a cylinder. If False, - the segment is internal to it, and outline_pts will be - mirrored right-to-left - segments_per_rot: segments per rotation - neck_in_degrees: degrees through which the outer edge of the screw thread will move from - a thickness of zero (inner_rad) to its full thickness - neck_out_degrees: degrees through which outer edge of the screw thread will move from - full thickness back to zero - - NOTE: if pitch is less than the or equal to the height of each tooth (outline_pts), - OpenSCAD will likely crash, since the resulting screw would self-intersect - all over the place. For screws with essentially no space between - threads, (i.e., pitch=tooth_height), I use pitch= tooth_height+EPSILON, - since pitch=tooth_height will self-intersect for rotations >=1 - ''' - a = union() - rotations = float(length) / pitch + :param inner_rad: radius of cylinder the screw will wrap around; at base of screw + :type inner_rad: number + + :param pitch: height for one revolution; must be <= the height of outline_pts bounding box to avoid self-intersection + :type pitch: number + + :param length: distance from bottom-most point of screw to topmost + :type length: number - total_angle = 360.0 * rotations - up_step = float(length) / (rotations * segments_per_rot) + :param external: if True, the cross-section is external to a cylinder. If False,the segment is internal to it, and outline_pts will be mirrored right-to-left + :type external: bool + + :param segments_per_rot: segments per rotation + :type segments_per_rot: int + + :param neck_in_degrees: degrees through which the outer edge of the screw thread will move from a thickness of zero (inner_rad) to its full thickness + :type neck_in_degrees: number + + :param neck_out_degrees: degrees through which outer edge of the screw thread will move from full thickness back to zero + :type neck_out_degrees: number + + :param rad_2: radius of cylinder the screw will wrap around at top of screw. Defaults to inner_rad + :type rad_2: number + + NOTE: This functions works by creating and returning one huge polyhedron, with + potentially thousands of faces. An alternate approach would make one single + polyhedron,then repeat it over and over in the spiral shape, unioning them + all together. This would create a similar number of SCAD objects and + operations, but still require a lot of transforms and unions to be done + in the SCAD code rather than in the python, as here. Also would take some + doing to make the neck-in work as well. Not sure how the two approaches + compare in terms of render-time. -ETJ 16 Mar 2011 + + NOTE: if pitch is less than or equal to the height of each tooth (outline_pts), + OpenSCAD will likely crash, since the resulting screw would self-intersect + all over the place. For screws with essentially no space between + threads, (i.e., pitch=tooth_height), I use pitch= tooth_height+EPSILON, + since pitch=tooth_height will self-intersect for rotations >=1 + """ + # FIXME: For small segments_per_rot where length is not a multiple of + # pitch, the the generated spiral will have irregularities, since we + # don't ensure that each level's segments are in line with those above or + # below. This would require a change in logic to fix. For now, larger values + # of segments_per_rot and length that divides pitch evenly should avoid this issue + # -ETJ 02 January 2020 + + rad_2 = rad_2 or inner_rad + rotations = length / pitch + + total_angle = 360 * rotations + up_step = length / (rotations * segments_per_rot) # Add one to total_steps so we have total_steps *segments* - total_steps = int(ceil(rotations * segments_per_rot)) + 1 + total_steps = math.ceil(rotations * segments_per_rot) + 1 step_angle = total_angle / (total_steps - 1) all_points = [] @@ -64,28 +110,45 @@ def thread(outline_pts, inner_rad, pitch, length, external=True, segments_per_ro euc_up = Vector3(*UP_VEC) poly_sides = len(outline_pts) - # Figure out how wide the tooth profile is - min_bb, max_bb = bounding_box(outline_pts) - outline_w = max_bb[0] - min_bb[0] - outline_h = max_bb[1] - min_bb[1] - - min_rad = max(0, inner_rad - outline_w - EPSILON) - max_rad = inner_rad + outline_w + EPSILON + # Make Point3s from outline_pts and flip inward for internal threads + int_ext_angle = 0 if external else math.pi + outline_pts = [ + Point3(p[0], p[1], 0).rotate_around(axis=euc_up, theta=int_ext_angle) + for p in outline_pts + ] + + # If this screw is conical, we'll need to rotate tooth profile to + # keep it perpendicular to the side of the cone. + if inner_rad != rad_2: + cone_angle = -math.atan((rad_2 - inner_rad) / length) + outline_pts = [ + p.rotate_around(axis=Vector3(*UP_VEC), theta=cone_angle) + for p in outline_pts + ] # outline_pts, since they were created in 2D , are in the XY plane. # But spirals move a profile in XZ around the Z-axis. So swap Y and Z - # co-ords... and hope users know about this - # Also add inner_rad to the profile - euc_points = [] - for p in outline_pts: - # If p is in [x, y] format, make it [x, y, 0] - if len(p) == 2: - p.append(0) - # [x, y, z] => [ x+inner_rad, z, y] - external_mult = 1 if external else -1 - # adding inner_rad, swapping Y & Z - s = Point3(external_mult * p[0], p[2], p[1]) - euc_points.append(s) + # coordinates... and hope users know about this + euc_points = list([Point3(p[0], 0, p[1]) for p in outline_pts]) + + # Figure out how wide the tooth profile is + min_bb, max_bb = bounding_box(outline_pts) + outline_w = max_bb[0] - min_bb[0] + # outline_h = max_bb[1] - min_bb[1] + + # Calculate where neck-in and neck-out starts/ends + neck_out_start = total_angle - neck_out_degrees + neck_distance = (outline_w + EPSILON) * (1 if external else -1) + section_rads = [ + # radius at start of thread + max(0, inner_rad - neck_distance), + # end of neck-in + map_segment(neck_in_degrees, 0, total_angle, inner_rad, rad_2), + # start of neck-out + map_segment(neck_out_start, 0, total_angle, inner_rad, rad_2), + # end of thread (& neck-out) + rad_2 - neck_distance, + ] for i in range(total_steps): angle = i * step_angle @@ -96,78 +159,95 @@ def thread(outline_pts, inner_rad, pitch, length, external=True, segments_per_ro elevation = length # Handle the neck-in radius for internal and external threads - rad = inner_rad - int_ext_mult = 1 if external else -1 - neck_in_rad = min_rad if external else max_rad - - if angle < neck_in_degrees: - rad = neck_in_rad + int_ext_mult * angle / neck_in_degrees * outline_w - elif angle > total_angle - neck_in_degrees: - rad = neck_in_rad + int_ext_mult * (total_angle - angle) / neck_out_degrees * outline_w + if 0 <= angle < neck_in_degrees: + rad = map_segment( + angle, 0, neck_in_degrees, section_rads[0], section_rads[1] + ) + elif neck_in_degrees <= angle < neck_out_start: + rad = map_segment( + angle, neck_in_degrees, neck_out_start, section_rads[1], section_rads[2] + ) + elif neck_out_start <= angle <= total_angle: + rad = map_segment( + angle, neck_out_start, total_angle, section_rads[2], section_rads[3] + ) elev_vec = Vector3(rad, 0, elevation) # create new points for p in euc_points: - pt = (p + elev_vec).rotate_around(axis=euc_up, theta=radians(angle)) + theta = radians(angle) * (-1 if inverse_thread_direction else 1) + pt = (p + elev_vec).rotate_around(axis=euc_up, theta=theta) all_points.append(pt.as_arr()) # Add the connectivity information if i < total_steps - 1: ind = i * poly_sides for j in range(ind, ind + poly_sides - 1): - all_tris.append([j, j + 1, j + poly_sides]) + all_tris.append([j, j + 1, j + poly_sides]) all_tris.append([j + 1, j + poly_sides + 1, j + poly_sides]) - all_tris.append([ind, ind + poly_sides - 1 + poly_sides, ind + poly_sides - 1]) + all_tris.append( + [ind, ind + poly_sides - 1 + poly_sides, ind + poly_sides - 1] + ) all_tris.append([ind, ind + poly_sides, ind + poly_sides - 1 + poly_sides]) # End triangle fans for beginning and end last_loop = len(all_points) - poly_sides for i in range(poly_sides - 2): - all_tris.append([0, i + 2, i + 1]) + all_tris.append([0, i + 2, i + 1]) all_tris.append([last_loop, last_loop + i + 1, last_loop + i + 2]) - # Make the polyhedron - a = polyhedron(points=all_points, faces=all_tris) + # Moving in the opposite direction, we need to reverse the order of + # corners in each face so the OpenSCAD preview renders correctly + if inverse_thread_direction: + all_tris = list([reversed(trio) for trio in all_tris]) + + # Make the polyhedron; convexity info needed for correct OpenSCAD render + a = polyhedron(points=all_points, faces=all_tris, convexity=2) if external: # Intersect with a cylindrical tube to make sure we fit into # the correct dimensions - tube = cylinder(r=inner_rad + outline_w + EPSILON, h=length, segments=segments_per_rot) - tube -= cylinder(r=inner_rad, h=length, segments=segments_per_rot) + tube = cylinder( + r1=inner_rad + outline_w + EPSILON, + r2=rad_2 + outline_w + EPSILON, + h=length, + segments=segments_per_rot, + ) + tube -= cylinder(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) else: # If the threading is internal, intersect with a central cylinder # to make sure nothing else remains - tube = cylinder(r=inner_rad, h=length, segments=segments_per_rot) + tube = cylinder(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) a *= tube - return render()(a) + + return a -def default_thread_section(tooth_height, tooth_depth): - # An isoceles triangle, tooth_height vertically, tooth_depth wide: - res = [[0, -tooth_height / 2], - [tooth_depth, 0], - [0, tooth_height / 2] - ] +def default_thread_section(tooth_height: float, tooth_depth: float): + """ + An isosceles triangle, tooth_height vertically, tooth_depth wide: + """ + res = [[0, -tooth_height / 2], [tooth_depth, 0], [0, tooth_height / 2]] return res def assembly(): - # Scad code here - a = union() - - rad = 5 - pts = [[0, -1, 0], - [1, 0, 0], - [0, 1, 0], - [-1, 0, 0], - [-1, -1, 0]] - - a = thread(pts, inner_rad=10, pitch=6, length=2, segments_per_rot=31, - neck_in_degrees=30, neck_out_degrees=30) + pts = [(0, -1, 0), (1, 0, 0), (0, 1, 0), (-1, 0, 0), (-1, -1, 0)] + + a = thread( + pts, + inner_rad=10, + pitch=6, + length=2, + segments_per_rot=31, + neck_in_degrees=30, + neck_out_degrees=30, + ) return a + cylinder(10 + EPSILON, 2) -if __name__ == '__main__': + +if __name__ == "__main__": a = assembly() scad_render_to_file(a) diff --git a/solid/solidpython.py b/solid/solidpython.py index 95c9b651..2e2f7245 100755 --- a/solid/solidpython.py +++ b/solid/solidpython.py @@ -1,271 +1,84 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- # Simple Python OpenSCAD Code Generator # Copyright (C) 2009 Philipp Tiefenbacher -# Amendments & additions, (C) 2011 Evan Jones +# Amendments & additions, (C) 2011-2019 Evan Jones # # License: LGPL 2.1 or later # +from __future__ import annotations -import os, sys, re +import datetime import inspect +import os import subprocess +import sys import tempfile +from pathlib import Path +import keyword +from typing import Set, Sequence, List, Callable, Optional, Union, Iterable - -# These are features added to SolidPython but NOT in OpenSCAD. -# Mark them for special treatment -non_rendered_classes = ['hole', 'part'] - -# ================================ -# = Modifier Convenience Methods = -# ================================ - - -def debug(openscad_obj): - openscad_obj.set_modifier("#") - return openscad_obj - - -def background(openscad_obj): - openscad_obj.set_modifier("%") - return openscad_obj - - -def root(openscad_obj): - openscad_obj.set_modifier("!") - return openscad_obj - - -def disable(openscad_obj): - openscad_obj.set_modifier("*") - return openscad_obj - - -# =============== -# = Including OpenSCAD code = -# =============== - -# use() & include() mimic OpenSCAD's use/include mechanics. -# -- use() makes methods in scad_file_path.scad available to -# be called. -# --include() makes those methods available AND executes all code in -# scad_file_path.scad, which may have side effects. -# Unless you have a specific need, call use(). -def use(scad_file_path, use_not_include=True): - ''' - TODO: doctest needed - ''' - # Opens scad_file_path, parses it for all usable calls, - # and adds them to caller's namespace - try: - module = open(scad_file_path) - contents = module.read() - module.close() - except Exception as e: - raise Exception("Failed to import SCAD module '%(scad_file_path)s' " - "with error: %(e)s " % vars()) - - # Once we have a list of all callables and arguments, dynamically - # add OpenSCADObject subclasses for all callables to the calling module's - # namespace. - symbols_dicts = extract_callable_signatures(scad_file_path) - - for sd in symbols_dicts: - class_str = new_openscad_class_str(sd['name'], sd['args'], sd['kwargs'], - scad_file_path, use_not_include) - # If this is called from 'include', we have to look deeper in the stack - # to find the right module to add the new class to. - stack_depth = 2 if use_not_include else 3 - exec(class_str, calling_module(stack_depth).__dict__) - - return True - - -def include(scad_file_path): - return use(scad_file_path, use_not_include=False) +from types import ModuleType -# ========================================= -# = Rendering Python code to OpenSCAD code= -# ========================================= -def _find_include_strings(obj): - include_strings = set() - if isinstance(obj, IncludedOpenSCADObject): - include_strings.add(obj.include_string) - for child in obj.children: - include_strings.update(_find_include_strings(child)) - return include_strings - - -def scad_render(scad_object, file_header=''): - # Make this object the root of the tree - root = scad_object - - # Scan the tree for all instances of - # IncludedOpenSCADObject, storing their strings - include_strings = _find_include_strings(root) - - # and render the string - includes = ''.join(include_strings) + "\n" - scad_body = root._render() - return file_header + includes + scad_body +from importlib import metadata +from importlib.metadata import PackageNotFoundError +import re -def scad_render_animated(func_to_animate, steps=20, back_and_forth=True, filepath=None, file_header=''): - # func_to_animate takes a single float argument, _time in [0, 1), and - # returns an OpenSCADObject instance. - # - # Outputs an OpenSCAD file with func_to_animate() evaluated at "steps" - # points between 0 & 1, with time never evaluated at exactly 1 - - # If back_and_forth is True, smoothly animate the full extent of the motion - # and then reverse it to the beginning; this avoids skipping between beginning - # and end of the animated motion - - # NOTE: This is a hacky way to solve a simple problem. To use OpenSCAD's - # animation feature, our code needs to respond to changes in the value - # of the OpenSCAD variable $t, but I can't think of a way to get a - # float variable from our code and put it into the actual SCAD code. - # Instead, we just evaluate our code at each desired step, and write it - # all out in the SCAD code for each case, with an if/else tree. Depending - # on the number of steps, this could create hundreds of times more SCAD - # code than is needed. But... it does work, with minimal Python code, so - # here it is. Better solutions welcome. -ETJ 28 Mar 2013 - - # NOTE: information on the OpenSCAD manual wiki as of November 2012 implies - # that the OpenSCAD app does its animation irregularly; sometimes it animates - # one loop in steps iterations, and sometimes in (steps + 1). Do it here - # in steps iterations, meaning that we won't officially reach $t =1. - - # Note also that we check for ranges of time rather than equality; this - # should avoid any rounding error problems, and doesn't require the file - # to be animated with an identical number of steps to the way it was - # created. -ETJ 28 Mar 2013 - scad_obj = func_to_animate() - include_strings = _find_include_strings(scad_obj) - # and render the string - includes = ''.join(include_strings) + "\n" - - rendered_string = file_header + includes - - if back_and_forth: - steps *= 2 - - for i in range(steps): - time = i * 1.0 / steps - end_time = (i + 1) * 1.0 / steps - eval_time = time - # Looping back and forth means there's no jump between the start and - # end position - if back_and_forth: - if time < 0.5: - eval_time = time * 2 - else: - eval_time = 2 - 2 * time - scad_obj = func_to_animate(_time=eval_time) - - scad_str = indent(scad_obj._render()) - rendered_string += ("if ($t >= %(time)s && $t < %(end_time)s){" - " %(scad_str)s\n" - "}\n" % vars()) - return rendered_string - - -def scad_render_animated_file(func_to_animate, steps=20, back_and_forth=True, - filepath=None, file_header='', include_orig_code=True): - rendered_string = scad_render_animated(func_to_animate, steps, - back_and_forth, file_header) - return _write_code_to_file(rendered_string, filepath, include_orig_code) - - -def scad_render_to_file(scad_object, filepath=None, file_header='', include_orig_code=True): - rendered_string = scad_render(scad_object, file_header) - return _write_code_to_file(rendered_string, filepath, include_orig_code) - - -def _write_code_to_file(rendered_string, filepath=None, include_orig_code=True): - try: - calling_file = os.path.abspath(calling_module(stack_depth=3).__file__) - - if include_orig_code: - rendered_string += sp_code_in_scad_comment(calling_file) - - # This write is destructive, and ought to do some checks that the write - # was successful. - # If filepath isn't supplied, place a .scad file with the same name - # as the calling module next to it - if not filepath: - filepath = os.path.splitext(calling_file)[0] + '.scad' - except AttributeError as e: - # If no calling_file was found, this is being called from the terminal. - # We can't read original code from a file, so don't try, - # and can't read filename from the calling file either, so just save to - # solid.scad. - if not filepath: - filepath = os.path.abspath('.') + "/solid.scad" - - f = open(filepath, "w") - f.write(rendered_string) - f.close() - return True - +PathStr = Union[Path, str] +AnimFunc = Callable[[Optional[float]], "OpenSCADObject"] +# These are features added to SolidPython but NOT in OpenSCAD. +# Mark them for special treatment +non_rendered_classes = ["hole", "part"] -def sp_code_in_scad_comment(calling_file): - # Once a SCAD file has been created, it's difficult to reconstruct - # how it got there, since it has no variables, modules, etc. So, include - # the Python code that generated the scad code as comments at the end of - # the SCAD code - pyopenscad_str = open(calling_file, 'r').read() +# Words reserved in Python but not OpenSCAD +# Re: https://github.com/SolidCode/SolidPython/issues/99 - # TODO: optimally, this would also include a version number and - # git hash (& date & github URL?) for the version of solidpython used - # to create a given file; That would future-proof any given SP-created - # code because it would point to the relevant dependencies as well as - # the actual code - pyopenscad_str = ("\n" - "/***********************************************\n" - "********* SolidPython code: **********\n" - "************************************************\n" - " \n" - "%(pyopenscad_str)s \n" - " \n" - "************************************************/\n") % vars() - return pyopenscad_str +PYTHON_ONLY_RESERVED_WORDS = keyword.kwlist # ========================= # = Internal Utilities = # ========================= -class OpenSCADObject(object): - - def __init__(self, name, params): +class OpenSCADObject: + def __init__(self, name: str, params: dict): self.name = name self.params = params - self.children = [] + self.children: List["OpenSCADObject"] = [] self.modifier = "" - self.parent = None + self.parent: Optional["OpenSCADObject"] = None self.is_hole = False self.has_hole_children = False self.is_part_root = False + self.traits: dict[str, dict[str, float]] = {} - def set_hole(self, is_hole=True): + def add_trait(self, trait_name: str, trait_data: dict[str, float]): + self.traits[trait_name] = trait_data + + def get_trait(self, trait_name: str) -> Optional[dict[str, float]]: + return self.traits.get(trait_name) + + def set_hole(self, is_hole: bool = True) -> "OpenSCADObject": self.is_hole = is_hole return self - def set_part_root(self, is_root=True): + def set_part_root(self, is_root: bool = True) -> "OpenSCADObject": self.is_part_root = is_root return self - def find_hole_children(self, path=None): - # Because we don't force a copy every time we re-use a node - # (e.g a = cylinder(2, 6); b = right(10) (a) - # the identical 'a' object appears in the tree twice), - # we can't count on an object's 'parent' field to trace its - # path to the root. Instead, keep track explicitly + def find_hole_children( + self, path: List["OpenSCADObject"] = None + ) -> List["OpenSCADObject"]: + """ + Because we don't force a copy every time we re-use a node + (e.g a = cylinder(2, 6); b = right(10) (a) + the identical 'a' object appears in the tree twice), + we can't count on an object's 'parent' field to trace its + path to the root. Instead, keep track explicitly + """ path = path if path else [self] hole_kids = [] @@ -286,27 +99,31 @@ def find_hole_children(self, path=None): return hole_kids - def set_modifier(self, m): - # Used to add one of the 4 single-character modifiers: - # #(debug) !(root) %(background) or *(disable) - string_vals = {'disable': '*', - 'debug': '#', - 'background': '%', - 'root': '!', - '*': '*', - '#': '#', - '%': '%', - '!': '!'} - - self.modifier = string_vals.get(m.lower(), '') + def set_modifier(self, m: str) -> "OpenSCADObject": + """ + Used to add one of the 4 single-character modifiers: + #(debug) !(root) %(background) or *(disable) + """ + string_vals = { + "disable": "*", + "debug": "#", + "background": "%", + "root": "!", + "*": "*", + "#": "#", + "%": "%", + "!": "!", + } + + self.modifier = string_vals.get(m.lower(), "") return self - def _render(self, render_holes=False): - ''' + def _render(self, render_holes: bool = False) -> str: + """ NOTE: In general, you won't want to call this method. For most purposes, - you really want scad_render(), + you really want scad_render(), Calling obj._render won't include necessary 'use' or 'include' statements - ''' + """ # First, render all children s = "" for child in self.children: @@ -341,37 +158,44 @@ def _render(self, render_holes=False): s = "\ndifference(){" + indent(s) + " /* End Holes */ \n}" return s - def _render_str_no_children(self): - s = "\n" + self.modifier + self.name + "(" + def _render_str_no_children(self) -> str: + callable_name = _unsubbed_keyword(self.name) + s = "\n" + self.modifier + callable_name + "(" first = True + # Re: https://github.com/SolidCode/SolidPython/issues/99 + # OpenSCAD will accept Python reserved words as callables or argument names, + # but they won't compile in Python. Those have already been substituted + # out (e.g 'or' => 'or_'). Sub them back here. + self.params = {_unsubbed_keyword(k): v for k, v in self.params.items()} + # OpenSCAD doesn't have a 'segments' argument, but it does # have '$fn'. Swap one for the other - if 'segments' in self.params: - self.params['$fn'] = self.params.pop('segments') + if "segments" in self.params: + self.params["$fn"] = self.params.pop("segments") valid_keys = self.params.keys() # intkeys are the positional parameters - intkeys = list(filter(lambda x: type(x) == int, valid_keys)) + intkeys = list(filter(lambda x: type(x) is int, valid_keys)) intkeys.sort() # named parameters - nonintkeys = list(filter(lambda x: not type(x) == int, valid_keys)) + nonintkeys = list(filter(lambda x: type(x) is not int, valid_keys)) all_params_sorted = intkeys + nonintkeys if all_params_sorted: all_params_sorted = sorted(all_params_sorted) for k in all_params_sorted: v = self.params[k] - if v == None: + if v is None: continue if not first: s += ", " first = False - if type(k) == int: + if type(k) is int: s += py2openscad(v) else: s += k + " = " + py2openscad(v) @@ -379,7 +203,7 @@ def _render_str_no_children(self): s += ")" return s - def _render_hole_children(self): + def _render_hole_children(self) -> str: # Run down the tree, rendering only those nodes # that are holes or have holes beneath them if not self.has_hole_children: @@ -389,77 +213,85 @@ def _render_hole_children(self): if child.is_hole: s += child._render(render_holes=True) elif child.has_hole_children: - # Holes exist in the compiled tree in two pieces: - # The shapes of the holes themselves, (an object for which - # obj.is_hole is True, and all its children) and the - # transforms necessary to put that hole in place, which - # are inherited from non-hole geometry. - - # Non-hole Intersections & differences can change (shrink) - # the size of holes, and that shouldn't happen: an - # intersection/difference with an empty space should be the - # entirety of the empty space. - # In fact, the intersection of two empty spaces should be - # everything contained in both of them: their union. - # So... replace all super-hole intersection/diff transforms - # with union in the hole segment of the compiled tree. - # And if you figure out a better way to explain this, - # please, please do... because I think this works, but I - # also think my rationale is shaky and imprecise. - # -ETJ 19 Feb 2013 - s = s.replace("intersection", "union") - s = s.replace("difference", "union") s += child._render_hole_children() if self.name in non_rendered_classes: pass else: s = self._render_str_no_children() + "{" + indent(s) + "\n}" + + # Holes exist in the compiled tree in two pieces: + # The shapes of the holes themselves, (an object for which + # obj.is_hole is True, and all its children) and the + # transforms necessary to put that hole in place, which + # are inherited from non-hole geometry. + + # Non-hole Intersections & differences can change (shrink) + # the size of holes, and that shouldn't happen: an + # intersection/difference with an empty space should be the + # entirety of the empty space. + # In fact, the intersection of two empty spaces should be + # everything contained in both of them: their union. + # So... replace all super-hole intersection/diff transforms + # with union in the hole segment of the compiled tree. + # And if you figure out a better way to explain this, + # please, please do... because I think this works, but I + # also think my rationale is shaky and imprecise. + # -ETJ 19 Feb 2013 + s = s.replace("intersection", "union") + s = s.replace("difference", "union") + return s - def add(self, child): - ''' - if child is a single object, assume it's an OpenSCADObject and + def add( + self, child: Union["OpenSCADObject", Sequence["OpenSCADObject"]] + ) -> "OpenSCADObject": + """ + if child is a single object, assume it's an OpenSCADObjects and add it to self.children if child is a list, assume its members are all OpenSCADObjects and add them all to self.children - ''' + """ if isinstance(child, (list, tuple)): # __call__ passes us a list inside a tuple, but we only care # about the list, so skip single-member tuples containing lists if len(child) == 1 and isinstance(child[0], (list, tuple)): child = child[0] [self.add(c) for c in child] + elif isinstance(child, int): + # Allowing for creating object by adding to 0 (as in sum()) + if child != 0: + raise ValueError else: - self.children.append(child) - child.set_parent(self) + self.children.append(child) # type: ignore + child.set_parent(self) # type: ignore return self - def set_parent(self, parent): + def set_parent(self, parent: "OpenSCADObject"): self.parent = parent - def add_param(self, k, v): - if k == '$fn': - k = 'segments' + def add_param(self, k: str, v: float) -> "OpenSCADObject": + if k == "$fn": + k = "segments" self.params[k] = v return self - def copy(self): - # Provides a copy of this object and all children, - # but doesn't copy self.parent, meaning the new object belongs - # to a different tree - # If we're copying a scad object, we know it is an instance of - # a dynamically created class called self.name. - # Initialize an instance of that class with the same params - # that created self, the object being copied. + def copy(self) -> "OpenSCADObject": + """ + Provides a copy of this object and all children, + but doesn't copy self.parent, meaning the new object belongs + to a different tree + Initialize an instance of this class with the same params + that created self, the object being copied. + """ # Python can't handle an '$fn' argument, while openSCAD only wants # '$fn'. Swap back and forth as needed; the final renderer will # sort this out. - if '$fn' in self.params: - self.params['segments'] = self.params.pop('$fn') + if "$fn" in self.params: + self.params["segments"] = self.params.pop("$fn") - other = globals()[self.name](**self.params) + other = type(self)(**self.params) other.set_modifier(self.modifier) other.set_hole(self.is_hole) other.set_part_root(self.is_part_root) @@ -468,43 +300,50 @@ def copy(self): other.add(c.copy()) return other - def __call__(self, *args): - ''' + def __call__(self, *args: "OpenSCADObject") -> "OpenSCADObject": + """ Adds all objects in args to self. This enables OpenSCAD-like syntax, e.g.: union()( cube(), sphere() ) - ''' + """ return self.add(args) - def __add__(self, x): - ''' + def __add__(self, x: "OpenSCADObject") -> "OpenSCADObject": + """ This makes u = a+b identical to: u = union()(a, b ) - ''' - return union()(self, x) + """ + return objects.union()(self, x) - def __sub__(self, x): - ''' + def __radd__(self, x: "OpenSCADObject") -> "OpenSCADObject": + """ + This makes u = a+b identical to: + u = union()(a, b ) + """ + return objects.union()(self, x) + + def __sub__(self, x: "OpenSCADObject") -> "OpenSCADObject": + """ This makes u = a - b identical to: u = difference()(a, b ) - ''' - return difference()(self, x) + """ + return objects.difference()(self, x) - def __mul__(self, x): - ''' + def __mul__(self, x: "OpenSCADObject") -> "OpenSCADObject": + """ This makes u = a * b identical to: u = intersection()(a, b ) - ''' - return intersection()(self, x) + """ + return objects.intersection()(self, x) - def _repr_png_(self): - ''' + def _repr_png_(self) -> Optional[bytes]: + """ Allow rich clients such as the IPython Notebook, to display the current OpenSCAD rendering of this object. - ''' + """ png_data = None tmp = tempfile.NamedTemporaryFile(suffix=".scad", delete=False) tmp_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False) @@ -513,12 +352,9 @@ def _repr_png_(self): tmp.write(scad_text) tmp.close() tmp_png.close() - subprocess.Popen([ - "openscad", - "--preview", - "-o", tmp_png.name, - tmp.name - ]).communicate() + subprocess.Popen( + ["openscad", "--preview", "-o", tmp_png.name, tmp.name] + ).communicate() with open(tmp_png.name, "rb") as png: png_data = png.read() @@ -530,20 +366,19 @@ def _repr_png_(self): class IncludedOpenSCADObject(OpenSCADObject): - - ''' + """ Identical to OpenSCADObject, but each subclass of IncludedOpenSCADObject represents imported scad code, so each instance needs to store the path to the scad file it's included from. - ''' + """ - def __init__(self, name, params, include_file_path, use_not_include=False, **kwargs): + def __init__( + self, name, params, include_file_path, use_not_include=False, **kwargs + ): self.include_file_path = self._get_include_path(include_file_path) - if use_not_include: - self.include_string = 'use <%s>\n' % self.include_file_path - else: - self.include_string = 'include <%s>\n' % self.include_file_path + use_str = "use" if use_not_include else "include" + self.include_string = f"{use_str} <{self.include_file_path}>\n" # Just pass any extra arguments straight on to OpenSCAD; it'll accept # them @@ -564,366 +399,429 @@ def _get_include_path(self, include_file_path): return os.path.abspath(whole_path) # No loadable SCAD file was found in sys.path. Raise an error - raise ValueError("Unable to find included SCAD file: " - "%(include_file_path)s in sys.path" % vars()) - - -def calling_module(stack_depth=2): - ''' - Returns the module *2* back in the frame stack. That means: - code in module A calls code in module B, which asks calling_module() - for module A. - - This means that we have to know exactly how far back in the stack - our desired module is; if code in module B calls another function in - module B, we have to increase the stack_depth argument to account for - this. - - Got that? - ''' - frm = inspect.stack()[stack_depth] - calling_mod = inspect.getmodule(frm[0]) - # If calling_mod is None, this is being called from an interactive session. - # Return that module. (Note that __main__ doesn't have a __file__ attr, - # but that's caught elsewhere.) - if not calling_mod: - import __main__ as calling_mod - return calling_mod - - -def new_openscad_class_str(class_name, args=[], kwargs=[], include_file_path=None, use_not_include=True): - args_str = '' - args_pairs = '' - - for arg in args: - args_str += ', ' + arg - args_pairs += "'%(arg)s':%(arg)s, " % vars() - - # kwargs have a default value defined in their SCAD versions. We don't - # care what that default value will be (SCAD will take care of that), just - # that one is defined. - for kwarg in kwargs: - args_str += ', %(kwarg)s=None' % vars() - args_pairs += "'%(kwarg)s':%(kwarg)s, " % vars() - - if include_file_path: - # include_file_path may include backslashes on Windows; escape them - # again here so any backslashes don't get used as escape characters - # themselves - include_file_path = include_file_path.replace('\\', '\\\\') - - # NOTE the explicit import of 'solid' below. This is a fix for: - # https://github.com/SolidCode/SolidPython/issues/20 -ETJ 16 Jan 2014 - result = ("import solid\n" - "class %(class_name)s(solid.IncludedOpenSCADObject):\n" - " def __init__(self%(args_str)s, **kwargs):\n" - " solid.IncludedOpenSCADObject.__init__(self, '%(class_name)s', {%(args_pairs)s }, include_file_path='%(include_file_path)s', use_not_include=%(use_not_include)s, **kwargs )\n" - " \n" - "\n" % vars()) - else: - result = ("class %(class_name)s(OpenSCADObject):\n" - " def __init__(self%(args_str)s):\n" - " OpenSCADObject.__init__(self, '%(class_name)s', {%(args_pairs)s })\n" - " \n" - "\n" % vars()) - - return result - - -def py2openscad(o): - if type(o) == bool: - return str(o).lower() - if type(o) == float: - return "%.10f" % o - if type(o) == list or type(o) == tuple: - s = "[" - first = True - for i in o: - if not first: - s += ", " - first = False - s += py2openscad(i) - s += "]" - return s - if type(o) == str: - return '"' + o + '"' - return str(o) - - -def indent(s): - return s.replace("\n", "\n\t") - - -# =========== -# = Parsing = -# =========== -def extract_callable_signatures(scad_file_path): - with open(scad_file_path) as f: - scad_code_str = f.read() - return parse_scad_callables(scad_code_str) - - -def parse_scad_callables(scad_code_str): - callables = [] - - # Note that this isn't comprehensive; tuples or nested data structures in - # a module definition will defeat it. - - # Current implementation would throw an error if you tried to call a(x, y) - # since Python would expect a(x); OpenSCAD itself ignores extra arguments, - # but that's not really preferable behavior - - # TODO: write a pyparsing grammar for OpenSCAD, or, even better, use the yacc parse grammar - # used by the language itself. -ETJ 06 Feb 2011 - - no_comments_re = r'(?mxs)(//.*?\n|/\*.*?\*/)' - - # Also note: this accepts: 'module x(arg) =' and 'function y(arg) {', both - # of which are incorrect syntax - mod_re = r'(?mxs)^\s*(?:module|function)\s+(?P\w+)\s*\((?P.*?)\)\s*(?:{|=)' - - # This is brittle. To get a generally applicable expression for all arguments, - # we'd need a real parser to handle nested-list default args or parenthesized statements. - # For the moment, assume a maximum of one square-bracket-delimited list - args_re = r'(?mxs)(?P\w+)(?:\s*=\s*(?P[\w.-]+|\[.*\]))?(?:,|$)' - - # remove all comments from SCAD code - scad_code_str = re.sub(no_comments_re, '', scad_code_str) - # get all SCAD callables - mod_matches = re.finditer(mod_re, scad_code_str) - - for m in mod_matches: - callable_name = m.group('callable_name') - args = [] - kwargs = [] - all_args = m.group('all_args') - if all_args: - arg_matches = re.finditer(args_re, all_args) - for am in arg_matches: - arg_name = am.group('arg_name') - if am.group('default_val'): - kwargs.append(arg_name) - else: - args.append(arg_name) - - callables.append({'name': callable_name, 'args': args, 'kwargs': kwargs}) - - return callables - - -# =============================== -# Classes for OpenSCAD builtins = -# =============================== -class polygon(OpenSCADObject): - def __init__(self, points, paths=None): - if not paths: - paths = [list(range(len(points)))] - OpenSCADObject.__init__(self, 'polygon', - {'points': points, 'paths': paths}) - + raise ValueError( + f"Unable to find included SCAD file: {include_file_path} in sys.path" + ) -class circle(OpenSCADObject): - def __init__(self, r=None, d=None, segments=None): - OpenSCADObject.__init__(self, 'circle', - {'r': r, 'd': d, 'segments': segments}) +# ========================================= +# = Rendering Python code to OpenSCAD code= +# ========================================= +def _find_include_strings( + obj: Union[IncludedOpenSCADObject, OpenSCADObject], +) -> Set[str]: + include_strings = set() + if isinstance(obj, IncludedOpenSCADObject): + include_strings.add(obj.include_string) + for child in obj.children: + include_strings.update(_find_include_strings(child)) + # We also accept IncludedOpenSCADObject instances as parameters to functions, + # so search in obj.params as well + for param in obj.params.values(): + if isinstance(param, OpenSCADObject): + include_strings.update(_find_include_strings(param)) + return include_strings -class square(OpenSCADObject): - def __init__(self, size=None, center=None): - OpenSCADObject.__init__(self, 'square', - {'size': size, 'center': center}) +def scad_render(scad_object: OpenSCADObject, file_header: str = "") -> str: + # Make this object the root of the tree + root = scad_object -class sphere(OpenSCADObject): - def __init__(self, r=None, d=None, segments=None): - OpenSCADObject.__init__(self, 'sphere', - {'r': r, 'd': d, 'segments': segments}) + # Scan the tree for all instances of + # IncludedOpenSCADObject, storing their strings + include_strings = _find_include_strings(root) + # and render the string + includes = "".join(include_strings) + "\n" + scad_body = root._render() -class cube(OpenSCADObject): - def __init__(self, size=None, center=None): - OpenSCADObject.__init__(self, 'cube', - {'size': size, 'center': center}) + if file_header and not file_header.endswith("\n"): + file_header += "\n" + return file_header + includes + scad_body -class cylinder(OpenSCADObject): - def __init__(self, r=None, h=None, r1=None, r2=None, d=None, d1=None, - d2=None, center=None, segments=None): - OpenSCADObject.__init__(self, 'cylinder', - {'r': r, 'h': h, 'r1': r1, 'r2': r2, 'd': d, - 'd1': d1, 'd2': d2, 'center': center, - 'segments': segments}) +def scad_render_animated( + func_to_animate: AnimFunc, + steps: int = 20, + back_and_forth: bool = True, + file_header: str = "", +) -> str: + # func_to_animate takes a single float argument, _time in [0, 1), and + # returns an OpenSCADObject instance. + # + # Outputs an OpenSCAD file with func_to_animate() evaluated at "steps" + # points between 0 & 1, with time never evaluated at exactly 1 -class polyhedron(OpenSCADObject): - def __init__(self, points, faces, convexity=None, triangles=None): - OpenSCADObject.__init__(self, 'polyhedron', - {'points': points, 'faces': faces, - 'convexity': convexity, - 'triangles': triangles}) + # If back_and_forth is True, smoothly animate the full extent of the motion + # and then reverse it to the beginning; this avoids skipping between beginning + # and end of the animated motion + # NOTE: This is a hacky way to solve a simple problem. To use OpenSCAD's + # animation feature, our code needs to respond to changes in the value + # of the OpenSCAD variable $t, but I can't think of a way to get a + # float variable from our code and put it into the actual SCAD code. + # Instead, we just evaluate our code at each desired step, and write it + # all out in the SCAD code for each case, with an if/else tree. Depending + # on the number of steps, this could create hundreds of times more SCAD + # code than is needed. But... it does work, with minimal Python code, so + # here it is. Better solutions welcome. -ETJ 28 Mar 2013 -class union(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'union', {}) + # NOTE: information on the OpenSCAD manual wiki as of November 2012 implies + # that the OpenSCAD app does its animation irregularly; sometimes it animates + # one loop in steps iterations, and sometimes in (steps + 1). Do it here + # in steps iterations, meaning that we won't officially reach $t =1. + # Note also that we check for ranges of time rather than equality; this + # should avoid any rounding error problems, and doesn't require the file + # to be animated with an identical number of steps to the way it was + # created. -ETJ 28 Mar 2013 + scad_obj = func_to_animate(_time=0) # type: ignore + include_strings = _find_include_strings(scad_obj) + # and render the string + includes = "".join(include_strings) + "\n" -class intersection(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'intersection', {}) + rendered_string = file_header + includes + if back_and_forth: + steps *= 2 -class difference(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'difference', {}) + for i in range(steps): + time = i * 1.0 / steps + end_time = (i + 1) * 1.0 / steps + eval_time = time + # Looping back and forth means there's no jump between the start and + # end position + if back_and_forth: + if time < 0.5: + eval_time = time * 2 + else: + eval_time = 2 - 2 * time + scad_obj = func_to_animate(_time=eval_time) # type: ignore + scad_str = indent(scad_obj._render()) + rendered_string += f"if ($t >= {time} && $t < {end_time}){{ {scad_str}\n}}\n" + return rendered_string -class hole(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'hole', {}) - self.set_hole(True) +def scad_render_animated_file( + func_to_animate: AnimFunc, + steps: int = 20, + back_and_forth: bool = True, + filepath: Optional[str] = None, + out_dir: PathStr = None, + file_header: str = "", + include_orig_code: bool = True, +) -> str: + rendered_string = scad_render_animated( + func_to_animate, steps, back_and_forth, file_header + ) + return _write_code_to_file( + rendered_string, filepath, out_dir=out_dir, include_orig_code=include_orig_code + ) + + +def scad_render_to_file( + scad_object: OpenSCADObject, + filepath: PathStr = None, + out_dir: PathStr = None, + file_header: str = "", + include_orig_code: bool = True, +) -> str: + header = file_header + if include_orig_code: + version = _get_version() + date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + header = f"// Generated by SolidPython {version} on {date}\n" + file_header + + rendered_string = scad_render(scad_object, header) + return _write_code_to_file(rendered_string, filepath, out_dir, include_orig_code) + + +def _write_code_to_file( + rendered_string: str, + filepath: PathStr = None, + out_dir: PathStr = None, + include_orig_code: bool = True, +) -> str: + try: + calling_file = Path(calling_module(stack_depth=3).__file__).absolute() + # Output path is determined four ways: + # -- If filepath is supplied, use filepath + # -- If no filepath is supplied but an out_dir is supplied, + # give the calling file a .scad suffix and put it in out_dir + # -- If neither filepath nor out_dir are supplied, give the new + # file a .scad suffix and put it next to the calling file + # -- If no path info is supplied and we can't find a calling file + # (i.e, this is being called from an interactive terminal), + # write a file to Path.cwd() / 'solid.scad' + out_path = Path() + if filepath: + out_path = Path(filepath) + elif out_dir: + odp = Path(out_dir) + if not odp.exists(): + odp.mkdir() + out_path = odp / calling_file.with_suffix(".scad").name + else: + out_path = calling_file.with_suffix(".scad") -class part(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'part', {}) - self.set_part_root(True) + if include_orig_code: + rendered_string += sp_code_in_scad_comment(calling_file) + except AttributeError: + # If no calling_file was found, this is being called from the terminal. + # We can't read original code from a file, so don't try, + # and can't read filename from the calling file either, so just save to + # solid.scad. + if filepath: + out_path = Path(filepath) + else: + odp = Path(out_dir) if out_dir else Path.cwd() + if not odp.exists(): + odp.mkdir() + out_path = odp / "solid.scad" -class translate(OpenSCADObject): - def __init__(self, v=None): - OpenSCADObject.__init__(self, 'translate', {'v': v}) + out_path.write_text(rendered_string) + return out_path.absolute().as_posix() -class scale(OpenSCADObject): - def __init__(self, v=None): - OpenSCADObject.__init__(self, 'scale', {'v': v}) +def _get_version() -> str: + """ + Returns SolidPython version + Returns '' if no version can be found + """ + try: + return metadata.version("solidpython") + except PackageNotFoundError: + version_pattern = re.compile(r"version = ['\"]([^'\"]*)['\"]") + version_file_path = Path(__file__).parent.parent / "pyproject.toml" + if version_file_path.exists(): + version_match = version_pattern.search(version_file_path.read_text()) + if version_match: + return version_match.group(1) + return "" + + +def sp_code_in_scad_comment(calling_file: PathStr) -> str: + """ + Once a SCAD file has been created, it's difficult to reconstruct + how it got there, since it has no variables, modules, etc. So, include + the Python code that generated the scad code as comments at the end of + the SCAD code + """ + pyopenscad_str = Path(calling_file).read_text() + # TODO: optimally, this would also include a version number and + # git hash (& date & github URL?) for the version of solidpython used + # to create a given file; That would future-proof any given SP-created + # code because it would point to the relevant dependencies as well as + # the actual code + pyopenscad_str = ( + f"\n" + f"/***********************************************\n" + f"********* SolidPython code: **********\n" + f"************************************************\n" + f" \n" + f"{pyopenscad_str} \n" + f" \n" + f"************************************************/\n" + ) + return pyopenscad_str -class rotate(OpenSCADObject): - def __init__(self, a=None, v=None): - OpenSCADObject.__init__(self, 'rotate', {'a': a, 'v': v}) +# =========== +# = Parsing = +# =========== +def parse_scad_callables(filename: str) -> List[dict]: + from .py_scadparser import scad_parser -class mirror(OpenSCADObject): - def __init__(self, v): - OpenSCADObject.__init__(self, 'mirror', {'v': v}) + modules, functions, _ = scad_parser.parseFile(filename) + callables = [] + for c in modules + functions: + args = [] + kwargs = [] -class multmatrix(OpenSCADObject): - def __init__(self, m): - OpenSCADObject.__init__(self, 'multmatrix', {'m': m}) + # for some reason solidpython needs to treat all openscad arguments as if + # they where optional. I don't know why, but at least to pass the tests + # it's neccessary to handle it like this !?!?! + for p in c.parameters: + kwargs.append(p.name) + # if p.optional: + # kwargs.append(p.name) + # else: + # args.append(p.name) + callables.append({"name": c.name, "args": args, "kwargs": kwargs}) -class color(OpenSCADObject): - def __init__(self, c): - OpenSCADObject.__init__(self, 'color', {'c': c}) + return callables -class minkowski(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'minkowski', {}) +def calling_module(stack_depth: int = 2) -> ModuleType: + """ + Returns the module *2* back in the frame stack. That means: + code in module A calls code in module B, which asks calling_module() + for module A. + This means that we have to know exactly how far back in the stack + our desired module is; if code in module B calls another function in + module B, we have to increase the stack_depth argument to account for + this. -class hull(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'hull', {}) + Got that? + """ + frm = inspect.stack()[stack_depth] + calling_mod = inspect.getmodule(frm[0]) + # If calling_mod is None, this is being called from an interactive session. + # Return that module. (Note that __main__ doesn't have a __file__ attr, + # but that's caught elsewhere.) + if not calling_mod: + import __main__ as calling_mod # type: ignore + return calling_mod -class render(OpenSCADObject): - def __init__(self, convexity=None): - OpenSCADObject.__init__(self, 'render', {'convexity': convexity}) +def new_openscad_class_str( + class_name: str, + args: Sequence[str] = None, + kwargs: Sequence[str] = None, + include_file_path: Optional[str] = None, + use_not_include: bool = True, +) -> str: + args_str = "" + args_pairs = "" + args = args or [] + kwargs = kwargs or [] -class linear_extrude(OpenSCADObject): - def __init__(self, height=None, center=None, convexity=None, twist=None, - slices=None): - OpenSCADObject.__init__(self, 'linear_extrude', - {'height': height, 'center': center, - 'convexity': convexity, 'twist': twist, - 'slices': slices}) + # Re: https://github.com/SolidCode/SolidPython/issues/99 + # Don't allow any reserved words as argument names or module names + # (They might be valid OpenSCAD argument names, but not in Python) + class_name = _subbed_keyword(class_name) + args = map(_subbed_keyword, args) # type: ignore + for arg in args: + args_str += ", " + arg + args_pairs += f"'{arg}':{arg}, " -class rotate_extrude(OpenSCADObject): - def __init__(self, convexity=None, segments=None): - OpenSCADObject.__init__(self, 'rotate_extrude', - {'convexity': convexity, 'segments': segments}) + # kwargs have a default value defined in their SCAD versions. We don't + # care what that default value will be (SCAD will take care of that), just + # that one is defined. + kwargs = map(_subbed_keyword, kwargs) # type: ignore + for kwarg in kwargs: + args_str += f", {kwarg}=None" + args_pairs += f"'{kwarg}':{kwarg}, " + if include_file_path: + # include_file_path may include backslashes on Windows; escape them + # again here so any backslashes don't get used as escape characters + # themselves + include_file_str = Path(include_file_path).as_posix() -class dxf_linear_extrude(OpenSCADObject): - def __init__(self, file, layer=None, height=None, center=None, - convexity=None, twist=None, slices=None): - OpenSCADObject.__init__(self, 'dxf_linear_extrude', - {'file': file, 'layer': layer, - 'height': height, 'center': center, - 'convexity': convexity, 'twist': twist, - 'slices': slices}) + # NOTE the explicit import of 'solid' below. This is a fix for: + # https://github.com/SolidCode/SolidPython/issues/20 -ETJ 16 Jan 2014 + result = ( + f"import solid\n" + f"class {class_name}(solid.IncludedOpenSCADObject):\n" + f" def __init__(self{args_str}, **kwargs):\n" + f" solid.IncludedOpenSCADObject.__init__(self, '{class_name}', {{{args_pairs} }}, include_file_path='{include_file_str}', use_not_include={use_not_include}, **kwargs )\n" + f" \n" + f"\n" + ) + else: + result = ( + f"class {class_name}(OpenSCADObject):\n" + f" def __init__(self{args_str}):\n" + f" OpenSCADObject.__init__(self, '{class_name}', {{{args_pairs}}})\n" + f" \n" + f"\n" + ) + return result -class projection(OpenSCADObject): - def __init__(self, cut=None): - OpenSCADObject.__init__(self, 'projection', {'cut': cut}) +def _subbed_keyword(keyword: str) -> str: + """ + Append an underscore to any python reserved word. + Prepend an underscore to any OpenSCAD identifier starting with a digit. + No-op for all other strings, e.g. 'or' => 'or_', 'other' => 'other' + """ + new_key = keyword -class surface(OpenSCADObject): - def __init__(self, file, center=None, convexity=None, invert=None): - OpenSCADObject.__init__(self, 'surface', - {'file': file, 'center': center, - 'convexity': convexity, 'invert': invert}) + if keyword in PYTHON_ONLY_RESERVED_WORDS: + new_key = keyword + "_" + elif keyword[0].isdigit(): + new_key = "_" + keyword -class text(OpenSCADObject): - def __init__(self, text, size=None, font=None, halign=None, valign=None, - spacing=None, direction=None, language=None, script=None, - segments=None): - OpenSCADObject.__init__(self, 'text', - {'text': text, 'size': size, 'font': font, - 'halign': halign, 'valign': valign, - 'spacing': spacing, 'direction': direction, - 'language': language, 'script': script, - 'segments': segments}) + elif keyword == "$fn": + new_key = "segments" + elif keyword[0] == "$": + new_key = "__" + keyword[1:] -class child(OpenSCADObject): - def __init__(self, index=None, vector=None, range=None): - OpenSCADObject.__init__(self, 'child', - {'index': index, 'vector': vector, - 'range': range}) + if new_key != keyword: + print( + f"\nFound OpenSCAD code that's not compatible with Python. \n" + f"Imported OpenSCAD code using `{keyword}` \n" + f"can be accessed with `{new_key}` in SolidPython\n" + ) + return new_key -class children(OpenSCADObject): - def __init__(self, index=None, vector=None, range=None): - OpenSCADObject.__init__(self, 'children', - {'index': index, 'vector': vector, - 'range': range}) +def _unsubbed_keyword(subbed_keyword: str) -> str: + """ + Remove trailing underscore for already-subbed python reserved words. + Remove prepending underscore if remaining identifier starts with a digit. + No-op for all other strings: e.g. 'or_' => 'or', 'other_' => 'other_' + """ + if ( + subbed_keyword.endswith("_") + and subbed_keyword[:-1] in PYTHON_ONLY_RESERVED_WORDS + ): + return subbed_keyword[:-1] + elif subbed_keyword.startswith("__"): + return "$" + subbed_keyword[2:] -class import_stl(OpenSCADObject): - def __init__(self, file, origin=(0, 0), layer=None): - OpenSCADObject.__init__(self, 'import', - {'file': file, 'origin': origin, - 'layer': layer}) + elif subbed_keyword.startswith("_") and subbed_keyword[1].isdigit(): + return subbed_keyword[1:] + elif subbed_keyword == "segments": + return "$fn" -class import_dxf(OpenSCADObject): - def __init__(self, file, origin=(0, 0), layer=None): - OpenSCADObject.__init__(self, 'import', - {'file': file, 'origin': origin, - 'layer': layer}) + return subbed_keyword -class import_(OpenSCADObject): - def __init__(self, file, origin=(0, 0), layer=None): - OpenSCADObject.__init__(self, 'import', - {'file': file, 'origin': origin, - 'layer': layer}) +# now that we have the base class defined, we can do a circular import +from . import objects # noqa: E402 -class intersection_for(OpenSCADObject): - def __init__(self, n): - OpenSCADObject.__init__(self, 'intersection_for', {'n': n}) +def py2openscad(o: Union[bool, float, str, Iterable]) -> str: + if type(o) is bool: + return str(o).lower() + if type(o) is float: + return f"{o:.10f}" # type: ignore + if type(o) is str: + return f'"{o}"' # type: ignore + if type(o).__name__ == "ndarray": + import numpy # type: ignore + + return numpy.array2string(o, separator=",", threshold=1000000000) + if isinstance(o, IncludedOpenSCADObject): + return o._render()[1:-1] + if hasattr(o, "__iter__"): + s = "[" + first = True + for i in o: # type: ignore + if not first: + s += ", " + first = False + s += py2openscad(i) + s += "]" + return s + return str(o) -class assign(OpenSCADObject): - def __init__(self): - OpenSCADObject.__init__(self, 'assign', {}) +def indent(s: str) -> str: + return s.replace("\n", "\n\t") diff --git a/solid/splines.py b/solid/splines.py new file mode 100644 index 00000000..9a954630 --- /dev/null +++ b/solid/splines.py @@ -0,0 +1,522 @@ +#! /usr/bin/env python +from math import pow + +from solid import ( + circle, + cylinder, + polygon, + color, + OpenSCADObject, + translate, + linear_extrude, + polyhedron, +) +from solid.utils import bounding_box, Red, Tuple3, euclidify +from euclid3 import Vector2, Vector3, Point2, Point3 + +from typing import Sequence, Tuple, Union, List, cast + +Point23 = Union[Point2, Point3] +# These *Input types accept either euclid3.Point* objects, or bare n-tuples +Point2Input = Union[Point2, Tuple[float, float]] +Point3Input = Union[Point3, Tuple[float, float, float]] +Point23Input = Union[Point2Input, Point3Input] + +PointInputs = Sequence[Point23Input] + +FaceTrio = Tuple[int, int, int] +CMPatchPoints = Tuple[Sequence[Point3Input], Sequence[Point3Input]] + +Vec23 = Union[Vector2, Vector3] +FourPoints = Tuple[Point23Input, Point23Input, Point23Input, Point23Input] +SEGMENTS = 48 + +DEFAULT_SUBDIVISIONS = 10 +DEFAULT_EXTRUDE_HEIGHT = 1 + + +# ======================= +# = CATMULL-ROM SPLINES = +# ======================= +def catmull_rom_polygon( + points: Sequence[Point23Input], + subdivisions: int = DEFAULT_SUBDIVISIONS, + extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, + show_controls: bool = False, + center: bool = True, +) -> OpenSCADObject: + """ + Return a closed OpenSCAD polygon object through all of `points`, + extruded to `extrude_height`. If `show_controls` is True, return red + cylinders at each of the specified control points; this makes it easier to + move determine which points should move to get a desired shape. + + NOTE: if `extrude_height` is 0, this function returns a 2D `polygon()` + object, which OpenSCAD can only combine with other 2D objects + (e.g. `square`, `circle`, but not `cube` or `cylinder`). If `extrude_height` + is nonzero, the object returned will be 3D and only combine with 3D objects. + """ + catmull_points = catmull_rom_points(points, subdivisions, close_loop=True) + shape = polygon(catmull_points) + if extrude_height > 0: + shape = linear_extrude(height=extrude_height, center=center)(shape) + + if show_controls: + shape += control_points(points, extrude_height, center) + return shape + + +def catmull_rom_points( + points: Sequence[Point23Input], + subdivisions: int = DEFAULT_SUBDIVISIONS, + close_loop: bool = False, + start_tangent: Vec23 = None, + end_tangent: Vec23 = None, +) -> List[Point3]: + """ + Return a smooth set of points through `points`, with `subdivisions` points + between each pair of control points. + + If `close_loop` is False, `start_tangent` and `end_tangent` can specify + tangents at the open ends of the returned curve. If not supplied, tangents + will be colinear with first and last supplied segments + + Credit due: Largely taken from C# code at: + https://www.habrador.com/tutorials/interpolation/1-catmull-rom-splines/ + retrieved 20190712 + """ + catmull_points: List[Point3] = [] + cat_points: List[Point3] = [] + # points_list = cast(List[Point23], points) + + points_list = list([euclidify(p, Point3) for p in points]) + + if close_loop: + cat_points = euclidify( + [points_list[-1]] + points_list + points_list[0:2], Point3 + ) + else: + # Use supplied tangents or just continue the ends of the supplied points + start_tangent = start_tangent or (points_list[1] - points_list[0]) + start_tangent = euclidify(start_tangent, Vector3) + end_tangent = end_tangent or (points_list[-2] - points_list[-1]) + end_tangent = euclidify(end_tangent, Vector3) + cat_points = ( + [points_list[0] + start_tangent] + + points_list + + [points_list[-1] + end_tangent] + ) + + last_point_range = len(cat_points) - 3 if close_loop else len(cat_points) - 3 + + for i in range(0, last_point_range): + include_last = True if i == last_point_range - 1 else False + controls = cat_points[i : i + 4] + # If we're closing a loop, controls needs to wrap around the end of the array + points_needed = 4 - len(controls) + if points_needed > 0: + controls += cat_points[0:points_needed] + controls_tuple = cast(FourPoints, controls) + catmull_points += _catmull_rom_segment( + controls_tuple, subdivisions, include_last + ) + + return catmull_points + + +def _catmull_rom_segment( + controls: FourPoints, subdivisions: int, include_last=False +) -> List[Point3]: + """ + Returns `subdivisions` Points between the 2nd & 3rd elements of `controls`, + on a quadratic curve that passes through all 4 control points. + If `include_last` is True, return `subdivisions` + 1 points, the last being + controls[2]. + + No reason to call this unless you're trying to do something very specific + """ + pos: Point23 = None + positions: List[Point23] = [] + + num_points = subdivisions + if include_last: + num_points += 1 + + p0, p1, p2, p3 = [euclidify(p, Point3) for p in controls] + a = 2 * p1 + b = p2 - p0 + c = 2 * p0 - 5 * p1 + 4 * p2 - p3 + d = -p0 + 3 * p1 - 3 * p2 + p3 + + for i in range(num_points): + t = i / subdivisions + pos = 0.5 * (a + (b * t) + (c * t * t) + (d * t * t * t)) + positions.append(Point3(*pos)) + return positions + + +def catmull_rom_patch_points( + patch: Tuple[PointInputs, PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + index_start: int = 0, +) -> Tuple[List[Point3], List[FaceTrio]]: + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + cm_points_a = catmull_rom_points(patch[0], subdivisions=subdivisions) + cm_points_b = catmull_rom_points(patch[1], subdivisions=subdivisions) + + strip_length = len(cm_points_a) + + for i in range(subdivisions + 1): + frac = i / subdivisions + verts += list( + [affine_combination(a, b, frac) for a, b in zip(cm_points_a, cm_points_b)] + ) + a_start = i * strip_length + index_start + b_start = a_start + strip_length + # This connects the verts we just created to the verts we'll make on the + # next loop. So don't calculate for the last loop + if i < subdivisions: + faces += face_strip_list(a_start, b_start, strip_length) + + return verts, faces + + +def catmull_rom_patch( + patch: Tuple[PointInputs, PointInputs], subdivisions: int = DEFAULT_SUBDIVISIONS +) -> OpenSCADObject: + faces, vertices = catmull_rom_patch_points(patch, subdivisions) + return polyhedron(faces, vertices) + + +def catmull_rom_prism( + control_curves: Sequence[PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + closed_ring: bool = True, + add_caps: bool = True, + smooth_edges: bool = False, +) -> polyhedron: + if smooth_edges: + return catmull_rom_prism_smooth_edges( + control_curves, subdivisions, closed_ring, add_caps + ) + + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + curves = list([euclidify(c) for c in control_curves]) + if closed_ring: + curves.append(curves[0]) + + curve_length = (len(curves[0]) - 1) * subdivisions + 1 + for i, (a, b) in enumerate(zip(curves[:-1], curves[1:])): + index_start = len(verts) - curve_length + first_new_vert = curve_length + if i == 0: + index_start = 0 + first_new_vert = 0 + + new_verts, new_faces = catmull_rom_patch_points( + (a, b), subdivisions=subdivisions, index_start=index_start + ) + + # new_faces describes all the triangles in the patch we just computed, + # but new_verts shares its first curve_length vertices with the last + # curve_length vertices; Add on only the new points + verts += new_verts[first_new_vert:] + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, len(verts), curve_length) + top_indices = range(curve_length - 1, len(verts), curve_length) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + + +def catmull_rom_prism_smooth_edges( + control_curves: Sequence[PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + closed_ring: bool = True, + add_caps: bool = True, +) -> polyhedron: + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + # TODO: verify that each control_curve has the same length + + curves = list([euclidify(c) for c in control_curves]) + + expanded_curves = [ + catmull_rom_points(c, subdivisions, close_loop=False) for c in curves + ] + expanded_length = len(expanded_curves[0]) + for i in range(expanded_length): + contour_controls = [c[i] for c in expanded_curves] + contour = catmull_rom_points( + contour_controls, subdivisions, close_loop=closed_ring + ) + verts += contour + + contour_length = len(contour) + # generate the face triangles between the last two rows of vertices + if i > 0: + a_start = len(verts) - 2 * contour_length + b_start = len(verts) - contour_length + # Note the b_start, a_start order here. This makes sure our faces + # are pointed outwards for the test cases I ran. I think if control + # curves were specified clockwise rather than counter-clockwise, all + # of the faces would be pointed inwards + new_faces = face_strip_list( + b_start, a_start, length=contour_length, close_loop=closed_ring + ) + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, contour_length) + top_indices = range(len(verts) - contour_length, len(verts)) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + + +# ================== +# = BEZIER SPLINES = +# ================== +# Ported from William A. Adams' Bezier OpenSCAD code at: +# https://www.thingiverse.com/thing:8443 + + +def bezier_polygon( + controls: FourPoints, + subdivisions: int = DEFAULT_SUBDIVISIONS, + extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, + show_controls: bool = False, + center: bool = True, +) -> OpenSCADObject: + """ + Return an OpenSCAD object representing a closed quadratic Bezier curve. + If extrude_height == 0, return a 2D `polygon()` object. + If extrude_height > 0, return a 3D extrusion of specified height. + Note that OpenSCAD won't render 2D & 3D objects together correctly, so pick + one and use that. + """ + points = bezier_points(controls, subdivisions) + # OpenSCAD can'ts handle Point3s in creating a polygon. Convert them to Point2s + # Note that this prevents us from making polygons outside of the XY plane, + # even though a polygon could reasonably be in some other plane while remaining 2D + points = list((Point2(p.x, p.y) for p in points)) + shape: OpenSCADObject = polygon(points) + if extrude_height != 0: + shape = linear_extrude(extrude_height, center=center)(shape) + + if show_controls: + control_objs = control_points( + controls, extrude_height=extrude_height, center=center + ) + shape += control_objs + + return shape + + +def bezier_points( + controls: FourPoints, + subdivisions: int = DEFAULT_SUBDIVISIONS, + include_last: bool = True, +) -> List[Point3]: + """ + Returns a list of `subdivisions` (+ 1, if `include_last` is True) points + on the cubic bezier curve defined by `controls`. The curve passes through + controls[0] and controls[3] + + If `include_last` is True, the last point returned will be controls[3]; if + False, (useful for linking several curves together), controls[3] won't be included + + Ported from William A. Adams' Bezier OpenSCAD code at: + https://www.thingiverse.com/thing:8443 + """ + # TODO: enable a smooth curve through arbitrarily many points, as described at: + # https://www.algosome.com/articles/continuous-bezier-curve-line.html + + points: List[Point3] = [] + last_elt = 1 if include_last else 0 + for i in range(subdivisions + last_elt): + u = i / subdivisions + points.append(_point_along_bez4(*controls, u)) + return points + + +def _point_along_bez4( + p0: Point23Input, p1: Point23Input, p2: Point23Input, p3: Point23Input, u: float +) -> Point3: + p0 = euclidify(p0) + p1 = euclidify(p1) + p2 = euclidify(p2) + p3 = euclidify(p3) + + x = _bez03(u) * p0.x + _bez13(u) * p1.x + _bez23(u) * p2.x + _bez33(u) * p3.x + y = _bez03(u) * p0.y + _bez13(u) * p1.y + _bez23(u) * p2.y + _bez33(u) * p3.y + z = _bez03(u) * p0.z + _bez13(u) * p1.z + _bez23(u) * p2.z + _bez33(u) * p3.z + return Point3(x, y, z) + + +def _bez03(u: float) -> float: + return pow((1 - u), 3) + + +def _bez13(u: float) -> float: + return 3 * u * (pow((1 - u), 2)) + + +def _bez23(u: float) -> float: + return 3 * (pow(u, 2)) * (1 - u) + + +def _bez33(u: float) -> float: + return pow(u, 3) + + +# ================ +# = HOBBY CURVES = +# ================ + + +# =========== +# = HELPERS = +# =========== +def control_points( + points: Sequence[Point23], + extrude_height: float = 0, + center: bool = True, + points_color: Tuple3 = Red, +) -> OpenSCADObject: + """ + Return a list of red cylinders/circles (depending on `extrude_height`) at + a supplied set of 2D points. Useful for visualizing and tweaking a curve's + control points + """ + # Figure out how big the circles/cylinders should be based on the spread of points + min_bb, max_bb = bounding_box(points) + outline_w = max_bb[0] - min_bb[0] + outline_h = max_bb[1] - min_bb[1] + r = min(outline_w, outline_h) / 20 # + if extrude_height == 0: + c = circle(r=r) + else: + h = extrude_height * 1.1 + c = cylinder(r=r, h=h, center=center) + controls = color(points_color)([translate((p.x, p.y, 0))(c) for p in points]) + return controls + + +def face_strip_list( + a_start: int, b_start: int, length: int, close_loop: bool = False +) -> List[FaceTrio]: + # If a_start is the index of the vertex at one end of a row of points in a surface, + # and b_start is the index of the vertex at the same end of the next row of points, + # return a list of lists of indices describing faces for the whole row: + # face_strip_list(a_start = 0, b_start = 3, length=3) => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # 3-4-5 + # |/|/| + # 0-1-2 => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # + # If close_loop is true, add one more pair of faces connecting the far + # edge of the strip to the near edge, in this case [[2,3,5], [2,0,3]] + faces: List[FaceTrio] = [] + loop = length - 1 + + for a, b in zip(range(a_start, a_start + loop), range(b_start, b_start + loop)): + faces.append((a, b + 1, b)) + faces.append((a, a + 1, b + 1)) + if close_loop: + faces.append((a + loop, b, b + loop)) + faces.append((a + loop, a, b)) + return faces + + +def fan_endcap_list(cap_points: int = 3, index_start: int = 0) -> List[FaceTrio]: + """ + Return a face-triangles list for the endpoint of a tube with cap_points points + We construct a fan of triangles all starting at point index_start and going + to each point in turn. + + NOTE that this would not work for non-convex rings. + In that case, it would probably be better to create a new centroid point and have + all triangle reach out from it. That wouldn't handle all polygons, but would + work with mildly concave ones like a star, for example. + + So fan_endcap_list(cap_points=6, index_start=0), like so: + 0 + / \ + 5 1 + | | + 4 2 + \ / + 3 + + returns: [(0,1,2), (0,2,3), (0,3,4), (0,4,5)] + """ + faces: List[FaceTrio] = [] + for i in range(index_start + 1, index_start + cap_points - 1): + faces.append((index_start, i, i + 1)) + return faces + + +def centroid_endcap( + tube_points: Sequence[Point3], indices: Sequence[int], invert: bool = False +) -> Tuple[Point3, List[FaceTrio]]: + # tube_points: all points in a polyhedron tube + # indices: the indexes of the points at the desired end of the tube + # invert: if True, invert the order of the generated faces. One endcap in + # each pair should be inverted + # + # Return all the triangle information needed to make an endcap polyhedron + # + # This is sufficient for some moderately concave polygonal endcaps, + # (a star shape, say), but wouldn't be enough for more irregularly convex + # polygons (anyplace where a segment from the centroid to a point on the + # polygon crosses an edge of the polygon) + faces: List[FaceTrio] = [] + center = centroid([tube_points[i] for i in indices]) + centroid_index = len(tube_points) + + for a, b in zip(indices[:-1], indices[1:]): + faces.append((centroid_index, a, b)) + faces.append((centroid_index, indices[-1], indices[0])) + + if invert: + faces = list((reversed(f) for f in faces)) # type: ignore + + return (center, faces) + + +def centroid(points: Sequence[Point23]) -> Point23: + total = Point3(0, 0, 0) + for p in points: + total += p + total /= len(points) + return total + + +def affine_combination(a: Point23, b: Point23, fraction: float) -> Point23: + # Return a Point[23] between a & b, where fraction==0 => a, fraction==1 => b + return (1 - fraction) * a + fraction * b diff --git a/solid/t_slots.py b/solid/t_slots.py deleted file mode 100755 index f83a3ad5..00000000 --- a/solid/t_slots.py +++ /dev/null @@ -1,193 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import division -import os -import sys -import re - -# Assumes SolidPython is in site-packages or elsewhwere in sys.path -from solid import * -from solid.utils import * - -SEGMENTS = 24 - -# FIXME: ought to be 5 -DFM = 5 # Default Material thickness - -tab_width = 5 -tab_offset = 4 -tab_curve_rad = .35 - -# TODO: Slots & tabs make it kind of difficult to align pieces, since we -# always need the slot piece to overlap the tab piece by a certain amount. -# It might be easier to have the edges NOT overlap at all and then have tabs -# for the slots added programmatically. -ETJ 06 Mar 2013 - - -def t_slot_holes(poly, point=None, edge_vec=RIGHT_VEC, screw_vec=DOWN_VEC, screw_type='m3', screw_length=16, material_thickness=DFM, kerf=0): - ''' - Cuts a screw hole and two notches in poly so they'll - interface with the features cut by t_slot() - - Returns a copy of poly with holes removed - - -- material_thickness is the thickness of the material *that will - be attached** to the t-slot, NOT necessarily the material that poly - will be cut on. - - -- screw_vec is the direction the screw through poly will face; normal to poly - -- edge_vec orients the holes to the edge they run parallel to - - TODO: add kerf calculations - ''' - point = point if point else ORIGIN - point = euclidify(point, Point3) - screw_vec = euclidify(screw_vec, Vector3) - edge_vec = euclidify(edge_vec, Vector3) - - src_up = screw_vec.cross(edge_vec) - - a_hole = square([tab_width, material_thickness], center=True) - move_hole = tab_offset + tab_width / 2 - tab_holes = left(move_hole)(a_hole) + right(move_hole)(a_hole) - - # Only valid for m3-m5 screws now - screw_dict = screw_dimensions.get(screw_type.lower()) - if screw_dict: - screw_w = screw_dict['screw_outer_diam'] - else: - raise ValueError( - "Don't have screw dimensions for requested screw size %s" % screw_type) - - # add the screw hole - tab_holes += circle(screw_w / 2) # NOTE: needs any extra space? - - tab_holes = transform_to_point( - tab_holes, point, dest_normal=screw_vec, src_normal=UP_VEC, src_up=src_up) - - return poly - tab_holes - - -def t_slot(poly, point=None, screw_vec=DOWN_VEC, face_normal=UP_VEC, screw_type='m3', screw_length=16, material_thickness=DFM, kerf=0): - ''' - Cuts a t-shaped shot in poly and adds two tabs - on the outside edge of poly. - - Needs to be combined with t_slot_holes() on another - poly to make a valid t-slot connection - - -- material_thickness is the thickness of the material *that will - be attached** to the t-slot, NOT necessarily the material that poly - will be cut on. - - -- This method will align the t-slots where you tell them to go, - using point, screw_vec (the direction the screw will be inserted), and - face_normal, a vector normal to the face being altered. To avoid confusion, - it's often easiest to work on the XY plane. - - - TODO: include kerf in calculations - ''' - point = point if point else ORIGIN - point = euclidify(point, Point3) - screw_vec = euclidify(screw_vec, Vector3) - face_normal = euclidify(face_normal, Vector3) - - tab = tab_poly(material_thickness=material_thickness) - slot = nut_trap_slot( - screw_type, screw_length, material_thickness=material_thickness) - - # NOTE: dest_normal & src_normal are the same. This should matter, right? - tab = transform_to_point( - tab, point, dest_normal=face_normal, src_normal=face_normal, src_up=-screw_vec) - slot = transform_to_point( - slot, point, dest_normal=face_normal, src_normal=face_normal, src_up=-screw_vec) - - return poly + tab - slot - - -def tab_poly(material_thickness=DFM): - - r = [[tab_width + tab_offset, -EPSILON], - [tab_offset, -EPSILON], - [tab_offset, material_thickness], - [tab_width + tab_offset, material_thickness], ] - - l = [[-rp[0], rp[1]] for rp in r] - tab_pts = l + r - - tab_faces = [[0, 1, 2, 3], [4, 5, 6, 7]] - tab = polygon(tab_pts, tab_faces) - - # Round off the top points so tabs slide in more easily - round_tabs = False - if round_tabs: - points_to_round = [[r[1], r[2], r[3]], - [r[2], r[3], r[0]], - [l[1], l[2], l[3]], - [l[2], l[3], l[0]], - ] - tab = fillet_2d(three_point_sets=points_to_round, orig_poly=tab, - fillet_rad=1, remove_material=True) - - return tab - - -def nut_trap_slot(screw_type='m3', screw_length=16, material_thickness=DFM): - # This shape has a couple uses. - # 1) Right angle joint between two pieces of material. - # A bolt goes through the second piece and into the first. - - # 2) Set-screw for attaching to motor spindles. - # Bolt goes full length into a sheet of material. Set material_thickness - # to something small (1-2 mm) to make sure there's adequate room to - # tighten onto the shaft - - # Only valid for m3-m5 screws now - screw_dict = screw_dimensions.get(screw_type.lower()) - if screw_dict: - screw_w = screw_dict['screw_outer_diam'] - screw_w2 = screw_w / 2 - # NOTE: How are these tolerances? - nut_hole_x = (screw_dict['nut_inner_diam'] + 0.2) / 2 - nut_hole_h = screw_dict['nut_thickness'] + 0.5 - slot_depth = material_thickness - screw_length - 0.5 - # If a nut isn't far enough into the material, the sections - # that hold the nut in may break off. Make sure it's at least - # half a centimeter. More would be better, actually - nut_loc = -5 - else: - raise ValueError( - "Don't have screw dimensions for requested screw size %s" % screw_type) - - slot_pts = [[screw_w2, EPSILON], - [screw_w2, nut_loc], - [nut_hole_x, nut_loc], - [nut_hole_x, nut_loc - nut_hole_h], - [screw_w2, nut_loc - nut_hole_h], - [screw_w2, slot_depth], - ] - # mirror the slot points on the left - slot_pts += [[-x, y] for x, y in slot_pts][-1::-1] - - # TODO: round off top corners of slot - - # Add circles around t edges to prevent acrylic breakage - slot = polygon(slot_pts) - slot = union()( - slot, - translate([nut_hole_x, nut_loc])(circle(tab_curve_rad)), - translate([-nut_hole_x, nut_loc])(circle(tab_curve_rad)) - ) - return render()(slot) - - -def assembly(): - a = union() - - return a - -if __name__ == '__main__': - a = assembly() - scad_render_to_file(a, file_header='$fn = %s;' % - SEGMENTS, include_orig_code=True) diff --git a/solid/test/ExpandedTestCase.py b/solid/test/ExpandedTestCase.py index cba00a43..2f16f8f1 100644 --- a/solid/test/ExpandedTestCase.py +++ b/solid/test/ExpandedTestCase.py @@ -1,28 +1,21 @@ """ A version of unittest that gives output in an easier to use format """ -import sys + import unittest import difflib class DiffOutput(unittest.TestCase): - def assertEqual(self, first, second, msg=None): """ Override assertEqual and print(a context diff if msg=None) """ - # Test if both are strings, in Python 2 & 3 - string_types = str if sys.version_info[0] == 3 else basestring - - if isinstance(first, string_types) and isinstance(second, string_types): + if isinstance(first, str) and isinstance(second, str): if not msg: - msg = 'Strings are not equal:\n' + ''.join( + msg = "Strings are not equal:\n" + "".join( difflib.unified_diff( - [first], - [second], - fromfile='actual', - tofile='expected' + [first], [second], fromfile="actual", tofile="expected" ) ) return super(DiffOutput, self).assertEqual(first, second, msg=msg) diff --git a/solid/test/run_all_tests.sh b/solid/test/run_all_tests.sh index dc37e0c7..a40ad1a0 100755 --- a/solid/test/run_all_tests.sh +++ b/solid/test/run_all_tests.sh @@ -1,7 +1,19 @@ -# Note that this file needs to be run from solid/test. +#!/usr/bin/env bash + +# Set CWD to this script's directory +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +cd $DIR + +export PYTHONPATH="../../":$PYTHONPATH +# Run all tests. Note that unittest's built-in discovery doesn't run the dynamic +# testcase generation they contain for i in test_*.py; do -echo $i; -python $i; -echo + echo $i; + python3 $i; + echo done + + +# revert to original dir +cd - diff --git a/solid/test/test_extrude_along_path.py b/solid/test/test_extrude_along_path.py new file mode 100755 index 00000000..aee6fd77 --- /dev/null +++ b/solid/test/test_extrude_along_path.py @@ -0,0 +1,136 @@ +#! /usr/bin/env python3 +import unittest +import re + +from solid import OpenSCADObject, scad_render +from solid.utils import extrude_along_path +from euclid3 import Point2, Point3 + +from typing import Union + +tri = [Point3(0, 0, 0), Point3(10, 0, 0), Point3(0, 10, 0)] + + +class TestExtrudeAlongPath(unittest.TestCase): + # Test cases will be dynamically added to this instance + # using the test case arrays above + def assertEqualNoWhitespace(self, a, b): + remove_whitespace = lambda s: re.subn(r"[\s\n]", "", s)[0] # noqa: E731 + self.assertEqual(remove_whitespace(a), remove_whitespace(b)) + + def assertEqualOpenScadObject( + self, expected: str, actual: Union[OpenSCADObject, str] + ): + if isinstance(actual, OpenSCADObject): + act = scad_render(actual) + elif isinstance(actual, str): + act = actual + self.assertEqualNoWhitespace(expected, act) + + def test_extrude_along_path(self): + path = [[0, 0, 0], [0, 20, 0]] + # basic test + actual = extrude_along_path(tri, path) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[10.0000000000,0.0000000000,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[0.0000000000,20.0000000000,0.0000000000],[10.0000000000,20.0000000000,0.0000000000],[0.0000000000,20.0000000000,10.0000000000]]);" + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_vertical(self): + # make sure we still look good extruding along z axis; gimbal lock can mess us up + vert_path = [[0, 0, 0], [0, 0, 20]] + actual = extrude_along_path(tri, vert_path) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[-10.0000000000,0.0000000000,0.0000000000],[0.0000000000,10.0000000000,0.0000000000],[0.0000000000,0.0000000000,20.0000000000],[-10.0000000000,0.0000000000,20.0000000000],[0.0000000000,10.0000000000,20.0000000000]]); " + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_1d_scale(self): + # verify that we can apply scalar scaling + path = [[0, 0, 0], [0, 20, 0]] + scales_1d = [1.5, 0.5] + actual = extrude_along_path(tri, path, scales=scales_1d) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[15.0000000000,0.0000000000,0.0000000000],[0.0000000000,0.0000000000,15.0000000000],[0.0000000000,20.0000000000,0.0000000000],[5.0000000000,20.0000000000,0.0000000000],[0.0000000000,20.0000000000,5.0000000000]]);" + + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_2d_scale(self): + # verify that we can apply differential x & y scaling + path = [[0, 0, 0], [0, 20, 0], [0, 40, 0]] + scales_2d = [ + Point2(1, 1), + Point2(0.5, 1.5), + Point2(1.5, 0.5), + ] + actual = extrude_along_path(tri, path, scales=scales_2d) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[5,3,8],[3,6,8],[2,1,0],[6,7,8]],points=[[0.0000000000,0.0000000000,0.0000000000],[10.0000000000,0.0000000000,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[0.0000000000,20.0000000000,0.0000000000],[5.0000000000,20.0000000000,0.0000000000],[0.0000000000,20.0000000000,15.0000000000],[0.0000000000,40.0000000000,0.0000000000],[15.0000000000,40.0000000000,0.0000000000],[0.0000000000,40.0000000000,5.0000000000]]);" + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_2d_scale_list_input(self): + # verify that we can apply differential x & y scaling + path = [[0, 0, 0], [0, 20, 0], [0, 40, 0]] + scales_2d = [ + (1, 1), + (0.5, 1.5), + (1.5, 0.5), + ] + actual = extrude_along_path(tri, path, scales=scales_2d) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[5,3,8],[3,6,8],[2,1,0],[6,7,8]],points=[[0.0000000000,0.0000000000,0.0000000000],[10.0000000000,0.0000000000,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[0.0000000000,20.0000000000,0.0000000000],[5.0000000000,20.0000000000,0.0000000000],[0.0000000000,20.0000000000,15.0000000000],[0.0000000000,40.0000000000,0.0000000000],[15.0000000000,40.0000000000,0.0000000000],[0.0000000000,40.0000000000,5.0000000000]]);" + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_end_caps(self): + path = [[0, 0, 0], [0, 20, 0]] + actual = scad_render(extrude_along_path(tri, path, connect_ends=False)) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[10.0000000000,0.0000000000,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[0.0000000000,20.0000000000,0.0000000000],[10.0000000000,20.0000000000,0.0000000000],[0.0000000000,20.0000000000,10.0000000000]]); " + self.assertEqualNoWhitespace(expected, actual) + + def test_extrude_along_path_connect_ends(self): + path = [[0, 0, 0], [20, 0, 0], [20, 20, 0], [0, 20, 0]] + actual = extrude_along_path(tri, path, connect_ends=True) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[5,3,8],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[8,6,11],[6,9,11],[9,10,0],[10,1,0],[10,11,1],[11,2,1],[11,9,2],[9,0,2]],points=[[0.0000000000,0.0000000000,0.0000000000],[-7.0710678119,-7.0710678119,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[20.0000000000,0.0000000000,0.0000000000],[27.0710678119,-7.0710678119,0.0000000000],[20.0000000000,0.0000000000,10.0000000000],[20.0000000000,20.0000000000,0.0000000000],[27.0710678119,27.0710678119,0.0000000000],[20.0000000000,20.0000000000,10.0000000000],[0.0000000000,20.0000000000,0.0000000000],[-7.0710678119,27.0710678119,0.0000000000],[0.0000000000,20.0000000000,10.0000000000]]); " + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_rotations(self): + # confirm we can rotate for each point in path + path = [[0, 0, 0], [20, 0, 0]] + rotations = [-45, 45] + actual = extrude_along_path(tri, path, rotations=rotations) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[0.0000000000,-7.0710678119,-7.0710678119],[0.0000000000,-7.0710678119,7.0710678119],[20.0000000000,0.0000000000,0.0000000000],[20.0000000000,-7.0710678119,7.0710678119],[20.0000000000,7.0710678119,7.0710678119]]); " + self.assertEqualOpenScadObject(expected, actual) + + # confirm we can rotate with a single supplied value + path = [[0, 0, 0], [20, 0, 0]] + rotations = [45] + actual = extrude_along_path(tri, path, rotations=rotations) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[0.0000000000,-10.0000000000,0.0000000000],[0.0000000000,0.0000000000,10.0000000000],[20.0000000000,0.0000000000,0.0000000000],[20.0000000000,-7.0710678119,7.0710678119],[20.0000000000,7.0710678119,7.0710678119]]); " + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_transforms(self): + path = [[0, 0, 0], [20, 0, 0]] + # scale points by a factor of 2 & then 1/2 + # Make sure we can take a transform function for each point in path + transforms = [lambda p, path, loop: 2 * p, lambda p, path, loop: 0.5 * p] + actual = extrude_along_path(tri, path, transforms=transforms) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[0.0000000000,-20.0000000000,0.0000000000],[0.0000000000,0.0000000000,20.0000000000],[20.0000000000,0.0000000000,0.0000000000],[20.0000000000,-5.0000000000,0.0000000000],[20.0000000000,0.0000000000,5.0000000000]]); " + self.assertEqualOpenScadObject(expected, actual) + + # Make sure we can take a single transform function for all points + transforms = [lambda p, path, loop: 2 * p] + actual = extrude_along_path(tri, path, transforms=transforms) + expected = "polyhedron(convexity=10,faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[2,0,5],[0,3,5],[2,1,0],[3,4,5]],points=[[0.0000000000,0.0000000000,0.0000000000],[0.0000000000,-20.0000000000,0.0000000000],[0.0000000000,0.0000000000,20.0000000000],[20.0000000000,0.0000000000,0.0000000000],[20.0000000000,-20.0000000000,0.0000000000],[20.0000000000,0.0000000000,20.0000000000]]);" + self.assertEqualOpenScadObject(expected, actual) + + def test_extrude_along_path_numpy(self): + try: + import numpy as np # type: ignore + except ImportError: + return + + N = 3 + thetas = np.linspace(0, np.pi, N) + path = list(zip(3 * np.sin(thetas), 3 * np.cos(thetas), thetas)) + profile = list(zip(np.sin(thetas), np.cos(thetas), [0] * len(thetas))) + scalepts = list(np.linspace(1, 0.1, N)) + + # in earlier code, this would have thrown an exception + extrude_along_path(shape_pts=profile, path_pts=path, scales=scalepts) + + +if __name__ == "__main__": + unittest.main() diff --git a/solid/test/test_screw_thread.py b/solid/test/test_screw_thread.py index 260be1e7..1827ac12 100755 --- a/solid/test/test_screw_thread.py +++ b/solid/test/test_screw_thread.py @@ -1,49 +1,152 @@ #! /usr/bin/env python -# -*- coding: UTF-8 -*- -from __future__ import division -import os -import sys +import unittest import re -# Assumes SolidPython is in site-packages or elsewhwere in sys.path -import unittest +from solid.screw_thread import default_thread_section, thread +from solid.solidpython import scad_render from solid.test.ExpandedTestCase import DiffOutput -from solid import * -from solid.screw_thread import thread, default_thread_section -SEGMENTS = 8 +SEGMENTS = 4 class TestScrewThread(DiffOutput): + def setUp(self): + self.tooth_height = 10 + self.tooth_depth = 5 + self.outline = default_thread_section( + tooth_height=self.tooth_height, tooth_depth=self.tooth_depth + ) + + def assertEqualNoWhitespace(self, a, b): + remove_whitespace = lambda s: re.subn(r"[\s\n]", "", s)[0] # noqa: E731 + self.assertEqual(remove_whitespace(a), remove_whitespace(b)) def test_thread(self): - tooth_height = 10 - tooth_depth = 5 - outline = default_thread_section(tooth_height=tooth_height, tooth_depth=tooth_depth) - actual_obj = thread(outline_pts=outline, inner_rad=20, pitch=tooth_height, - length=0.75 * tooth_height, segments_per_rot=SEGMENTS, - neck_in_degrees=45, neck_out_degrees=45) + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + ) + actual = scad_render(actual_obj) - expected = '\n\nrender() {\n\tintersection() {\n\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], [16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [0, 2, 1], [18, 19, 20]], points = [[14.9900000000, 0.0000000000, -5.0000000000], [19.9900000000, 0.0000000000, 0.0000000000], [14.9900000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -3.7500000000], [17.6776695297, 17.6776695297, 1.2500000000], [14.1421356237, 14.1421356237, 6.2500000000], [0.0000000000, 20.0000000000, -2.5000000000], [0.0000000000, 25.0000000000, 2.5000000000], [0.0000000000, 20.0000000000, 7.5000000000], [-14.1421356237, 14.1421356237, -1.2500000000], [-17.6776695297, 17.6776695297, 3.7500000000], [-14.1421356237, 14.1421356237, 8.7500000000], [-20.0000000000, 0.0000000000, 0.0000000000], [-25.0000000000, 0.0000000000, 5.0000000000], [-20.0000000000, 0.0000000000, 10.0000000000], [-14.1421356237, -14.1421356237, 1.2500000000], [-17.6776695297, -17.6776695297, 6.2500000000], [-14.1421356237, -14.1421356237, 11.2500000000], [-0.0000000000, -14.9900000000, 2.5000000000], [-0.0000000000, -19.9900000000, 7.5000000000], [-0.0000000000, -14.9900000000, 12.5000000000]]);\n\t\tdifference() {\n\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 25.0100000000);\n\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 20);\n\t\t}\n\t}\n}' - self.assertEqual(expected, actual) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-14.9900000000,2.5000000000],[-0.0000000000,-19.9900000000,7.5000000000],[-0.0000000000,-14.9900000000,12.5000000000]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=25.0100000000,r2=25.0100000000); + cylinder($fn=4,h=7.5000000000,r1=20,r2=20); + } + }""" + self.assertEqualNoWhitespace(expected, actual) def test_thread_internal(self): - tooth_height = 10 - tooth_depth = 5 - outline = default_thread_section(tooth_height=tooth_height, tooth_depth=tooth_depth) - actual_obj = thread(outline_pts=outline, inner_rad=20, pitch=2 * tooth_height, - length=2 * tooth_height, segments_per_rot=SEGMENTS, - neck_in_degrees=45, neck_out_degrees=45, - external=False) + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + pitch=2 * self.tooth_height, + length=2 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + external=False, + ) actual = scad_render(actual_obj) - expected = '\n\nrender() {\n\tintersection() {\n\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], [16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [18, 19, 21], [19, 22, 21], [19, 20, 22], [20, 23, 22], [18, 23, 20], [18, 21, 23], [21, 22, 24], [22, 25, 24], [22, 23, 25], [23, 26, 25], [21, 26, 23], [21, 24, 26], [0, 2, 1], [24, 25, 26]], points = [[25.0100000000, 0.0000000000, -5.0000000000], [20.0100000000, 0.0000000000, 0.0000000000], [25.0100000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -2.5000000000], [10.6066017178, 10.6066017178, 2.5000000000], [14.1421356237, 14.1421356237, 7.5000000000], [0.0000000000, 20.0000000000, 0.0000000000], [0.0000000000, 15.0000000000, 5.0000000000], [0.0000000000, 20.0000000000, 10.0000000000], [-14.1421356237, 14.1421356237, 2.5000000000], [-10.6066017178, 10.6066017178, 7.5000000000], [-14.1421356237, 14.1421356237, 12.5000000000], [-20.0000000000, 0.0000000000, 5.0000000000], [-15.0000000000, 0.0000000000, 10.0000000000], [-20.0000000000, 0.0000000000, 15.0000000000], [-14.1421356237, -14.1421356237, 7.5000000000], [-10.6066017178, -10.6066017178, 12.5000000000], [-14.1421356237, -14.1421356237, 17.5000000000], [-0.0000000000, -20.0000000000, 10.0000000000], [-0.0000000000, -15.0000000000, 15.0000000000], [-0.0000000000, -20.0000000000, 20.0000000000], [14.1421356237, -14.1421356237, 12.5000000000], [10.6066017178, -10.6066017178, 17.5000000000], [14.1421356237, -14.1421356237, 22.5000000000], [25.0100000000, -0.0000000000, 15.0000000000], [20.0100000000, -0.0000000000, 20.0000000000], [25.0100000000, -0.0000000000, 25.0000000000]]);\n\t\tcylinder($fn = 8, h = 20, r = 20);\n\t}\n}' - self.assertEqual(expected, actual) + expected = """intersection() { + polyhedron( + convexity=2, + faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [0, 2, 1], [12, 13, 14]], + points = [[25.0100000000, 0.0000000000, 5.0000000000], [20.0100000000, 0.0000000000, 0.0000000000], [25.0100000000, 0.0000000000, -5.0000000000], [0.0000000000, 20.0000000000, 10.0000000000], [0.0000000000, 15.0000000000, 5.0000000000], [0.0000000000, 20.0000000000, 0.0000000000], [-20.0000000000, 0.0000000000, 15.0000000000], [-15.0000000000, 0.0000000000, 10.0000000000], [-20.0000000000, 0.0000000000, 5.0000000000], [-0.0000000000, -20.0000000000, 20.0000000000], [-0.0000000000, -15.0000000000, 15.0000000000], [-0.0000000000, -20.0000000000, 10.0000000000], [25.0100000000, -0.0000000000, 25.0000000000], [20.0100000000, -0.0000000000, 20.0000000000], [25.0100000000, -0.0000000000, 15.0000000000]] + ); + cylinder($fn = 4, h = 20, r1 = 20, r2 = 20); + }""" + self.assertEqualNoWhitespace(expected, actual) + + def test_conical_thread_external(self): + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + rad_2=40, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + external=True, + ) + actual = scad_render(actual_obj) + expected = """intersection(){ + polyhedron(convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[5.9450623365,0.0000000000,-1.7556172079],[12.3823254323,0.0000000000,-4.6816458878],[15.3083541122,0.0000000000,1.7556172079],[0.0000000000,21.9850207788,0.7443827921],[0.0000000000,28.4222838746,-2.1816458878],[0.0000000000,31.3483125545,4.2556172079],[-28.6516874455,0.0000000000,3.2443827921],[-35.0889505413,0.0000000000,0.3183541122],[-38.0149792212,0.0000000000,6.7556172079],[-0.0000000000,-25.9450623365,5.7443827921],[-0.0000000000,-32.3823254323,2.8183541122],[-0.0000000000,-35.3083541122,9.2556172079]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=29.3732917757,r2=49.3732917757); + cylinder($fn=4,h=7.5000000000,r1=20,r2=40); + } + }""" + self.assertEqualNoWhitespace(expected, actual) + + def test_conical_thread_internal(self): + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + rad_2=40, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + external=False, + ) + actual = scad_render(actual_obj) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[34.0549376635,0.0000000000,1.7556172079],[27.6176745677,0.0000000000,4.6816458878],[24.6916458878,0.0000000000,-1.7556172079],[0.0000000000,31.3483125545,4.2556172079],[0.0000000000,24.9110494587,7.1816458878],[0.0000000000,21.9850207788,0.7443827921],[-38.0149792212,0.0000000000,6.7556172079],[-31.5777161254,0.0000000000,9.6816458878],[-28.6516874455,0.0000000000,3.2443827921],[-0.0000000000,-54.0549376635,9.2556172079],[-0.0000000000,-47.6176745677,12.1816458878],[-0.0000000000,-44.6916458878,5.7443827921]] + ); + cylinder($fn=4,h=7.5000000000,r1=20,r2=40); + }""" + self.assertEqualNoWhitespace(expected, actual) def test_default_thread_section(self): expected = [[0, -5], [5, 0], [0, 5]] actual = default_thread_section(tooth_height=10, tooth_depth=5) self.assertEqual(expected, actual) + def test_neck_in_out_degrees(self): + # Non-specified neck_in_degrees and neck_out_degrees would crash prior + # to the fix for https://github.com/SolidCode/SolidPython/issues/92 + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=0, + ) + actual = scad_render(actual_obj) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-20.0000000000,2.5000000000],[-0.0000000000,-25.0000000000,7.5000000000],[-0.0000000000,-20.0000000000,12.5000000000]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=25.0100000000,r2=25.0100000000); + cylinder($fn=4,h=7.5000000000,r1=20,r2=20); + } + }""" + self.assertEqualNoWhitespace(expected, actual) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/solid/test/test_solidpython.py b/solid/test/test_solidpython.py index aa11065f..3e2e1bf2 100755 --- a/solid/test/test_solidpython.py +++ b/solid/test/test_solidpython.py @@ -1,152 +1,679 @@ #! /usr/bin/env python -# -*- coding: UTF-8 -*- import os -import sys -import re - -import unittest import tempfile +import unittest +from pathlib import Path + +from solid.objects import background, circle, cube, cylinder, debug, disable +from solid.objects import hole, import_scad, include, part, root, rotate, sphere +from solid.objects import square, translate, use, color, polygon + +# NOTE: the following impports aren't explicitly tested +# from solid.objects import difference, hull +# from solid.objects import ( +# import_, +# intersection, +# intersection_for, +# linear_extrude, +# import_dxf, +# ) +# from solid.objects import import_stl, minkowski, mirror, multmatrix, offset, polygon +# from solid.objects import polyhedron, projection, render, resize, rotate_extrude +# from solid.objects import scale, surface, union + +from solid.solidpython import ( + scad_render, + scad_render_animated_file, + scad_render_to_file, +) from solid.test.ExpandedTestCase import DiffOutput -from solid import * scad_test_case_templates = [ -{'name': 'polygon', 'kwargs': {'paths': [[0, 1, 2]]}, 'expected': '\n\npolygon(paths = [[0, 1, 2]], points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]);', 'args': {'points': [[0, 0, 0], [1, 0, 0], [0, 1, 0]]}, }, -{'name': 'circle', 'kwargs': {'segments': 12, 'r': 1}, 'expected': '\n\ncircle($fn = 12, r = 1);', 'args': {}, }, -{'name': 'circle', 'kwargs': {'segments': 12, 'd': 1}, 'expected': '\n\ncircle($fn = 12, d = 1);', 'args': {}, }, -{'name': 'square', 'kwargs': {'center': False, 'size': 1}, 'expected': '\n\nsquare(center = false, size = 1);', 'args': {}, }, -{'name': 'sphere', 'kwargs': {'segments': 12, 'r': 1}, 'expected': '\n\nsphere($fn = 12, r = 1);', 'args': {}, }, -{'name': 'sphere', 'kwargs': {'segments': 12, 'd': 1}, 'expected': '\n\nsphere($fn = 12, d = 1);', 'args': {}, }, -{'name': 'cube', 'kwargs': {'center': False, 'size': 1}, 'expected': '\n\ncube(center = false, size = 1);', 'args': {}, }, -{'name': 'cylinder', 'kwargs': {'r1': None, 'r2': None, 'h': 1, 'segments': 12, 'r': 1, 'center': False}, 'expected': '\n\ncylinder($fn = 12, center = false, h = 1, r = 1);', 'args': {}, }, -{'name': 'cylinder', 'kwargs': {'d1': 4, 'd2': 2, 'h': 1, 'segments': 12, 'center': False}, 'expected': '\n\ncylinder($fn = 12, center = false, d1 = 4, d2 = 2, h = 1);', 'args': {}, }, -{'name': 'polyhedron', 'kwargs': {'convexity': None}, 'expected': '\n\npolyhedron(faces = [[0, 1, 2]], points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]);', 'args': {'points': [[0, 0, 0], [1, 0, 0], [0, 1, 0]], 'faces': [[0, 1, 2]]}, }, -{'name': 'union', 'kwargs': {}, 'expected': '\n\nunion();', 'args': {}, }, -{'name': 'intersection','kwargs': {}, 'expected': '\n\nintersection();', 'args': {}, }, -{'name': 'difference', 'kwargs': {}, 'expected': '\n\ndifference();', 'args': {}, }, -{'name': 'translate', 'kwargs': {'v': [1, 0, 0]}, 'expected': '\n\ntranslate(v = [1, 0, 0]);', 'args': {}, }, -{'name': 'scale', 'kwargs': {'v': 0.5}, 'expected': '\n\nscale(v = 0.5000000000);', 'args': {}, }, -{'name': 'rotate', 'kwargs': {'a': 45, 'v': [0, 0, 1]}, 'expected': '\n\nrotate(a = 45, v = [0, 0, 1]);', 'args': {}, }, -{'name': 'mirror', 'kwargs': {}, 'expected': '\n\nmirror(v = [0, 0, 1]);', 'args': {'v': [0, 0, 1]}, }, -{'name': 'multmatrix', 'kwargs': {}, 'expected': '\n\nmultmatrix(m = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);', 'args': {'m': [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, }, -{'name': 'color', 'kwargs': {}, 'expected': '\n\ncolor(c = [1, 0, 0]);', 'args': {'c': [1, 0, 0]}, }, -{'name': 'minkowski', 'kwargs': {}, 'expected': '\n\nminkowski();', 'args': {}, }, -{'name': 'hull', 'kwargs': {}, 'expected': '\n\nhull();', 'args': {}, }, -{'name': 'render', 'kwargs': {'convexity': None}, 'expected': '\n\nrender();', 'args': {}, }, -{'name': 'projection', 'kwargs': {'cut': None}, 'expected': '\n\nprojection();', 'args': {}, }, -{'name': 'surface', 'kwargs': {'center': False, 'convexity': None}, 'expected': '\n\nsurface(center = false, file = "/Path/to/dummy.dxf");', 'args': {'file': "'/Path/to/dummy.dxf'"}, }, -{'name': 'import_stl', 'kwargs': {'layer': None, 'origin': (0,0)}, 'expected': '\n\nimport(file = "/Path/to/dummy.stl", origin = [0, 0]);', 'args': {'file': "'/Path/to/dummy.stl'"}, }, -{'name': 'import_dxf', 'kwargs': {'layer': None, 'origin': (0,0)}, 'expected': '\n\nimport(file = "/Path/to/dummy.dxf", origin = [0, 0]);', 'args': {'file': "'/Path/to/dummy.dxf'"}, }, -{'name': 'import_', 'kwargs': {'layer': None, 'origin': (0,0)}, 'expected': '\n\nimport(file = "/Path/to/dummy.dxf", origin = [0, 0]);', 'args': {'file': "'/Path/to/dummy.dxf'"}, }, -{'name': 'linear_extrude', 'kwargs': {'twist': None, 'slices': None, 'center': False, 'convexity': None, 'height': 1}, 'expected': '\n\nlinear_extrude(center = false, height = 1);', 'args': {}, }, -{'name': 'rotate_extrude', 'kwargs': {'convexity': None}, 'expected': '\n\nrotate_extrude();', 'args': {}, }, -{'name': 'intersection_for', 'kwargs': {}, 'expected': '\n\nintersection_for(n = [0, 1, 2]);', 'args': {'n': [0, 1, 2]}, }, + { + "name": "polygon", + "class": "polygon", + "kwargs": {"paths": [[0, 1, 2]]}, + "expected": "\n\npolygon(paths = [[0, 1, 2]], points = [[0, 0], [1, 0], [0, 1]]);", + "args": {"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]]}, + }, + { + "name": "polygon", + "class": "polygon", + "kwargs": {}, + "expected": "\n\npolygon(points = [[0, 0], [1, 0], [0, 1]]);", + "args": {"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]]}, + }, + { + "name": "polygon", + "class": "polygon", + "kwargs": {}, + "expected": "\n\npolygon(convexity = 3, points = [[0, 0], [1, 0], [0, 1]]);", + "args": {"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], "convexity": 3}, + }, + { + "name": "circle", + "class": "circle", + "kwargs": {"segments": 12, "r": 1}, + "expected": "\n\ncircle($fn = 12, r = 1);", + "args": {}, + }, + { + "name": "circle_diam", + "class": "circle", + "kwargs": {"segments": 12, "d": 1}, + "expected": "\n\ncircle($fn = 12, d = 1);", + "args": {}, + }, + { + "name": "square", + "class": "square", + "kwargs": {"center": False, "size": 1}, + "expected": "\n\nsquare(center = false, size = 1);", + "args": {}, + }, + { + "name": "sphere", + "class": "sphere", + "kwargs": {"segments": 12, "r": 1}, + "expected": "\n\nsphere($fn = 12, r = 1);", + "args": {}, + }, + { + "name": "sphere_diam", + "class": "sphere", + "kwargs": {"segments": 12, "d": 1}, + "expected": "\n\nsphere($fn = 12, d = 1);", + "args": {}, + }, + { + "name": "cube", + "class": "cube", + "kwargs": {"center": False, "size": 1}, + "expected": "\n\ncube(center = false, size = 1);", + "args": {}, + }, + { + "name": "cylinder", + "class": "cylinder", + "kwargs": { + "r1": None, + "r2": None, + "h": 1, + "segments": 12, + "r": 1, + "center": False, + }, + "expected": "\n\ncylinder($fn = 12, center = false, h = 1, r = 1);", + "args": {}, + }, + { + "name": "cylinder_d1d2", + "class": "cylinder", + "kwargs": {"d1": 4, "d2": 2, "h": 1, "segments": 12, "center": False}, + "expected": "\n\ncylinder($fn = 12, center = false, d1 = 4, d2 = 2, h = 1);", + "args": {}, + }, + { + "name": "polyhedron", + "class": "polyhedron", + "kwargs": {"convexity": None}, + "expected": "\n\npolyhedron(faces = [[0, 1, 2]], points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]);", + "args": {"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], "faces": [[0, 1, 2]]}, + }, + { + "name": "polyhedron_default_convexity", + "class": "polyhedron", + "kwargs": {}, + "expected": "\n\npolyhedron(convexity = 10, faces = [[0, 1, 2]], points = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]);", + "args": {"points": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], "faces": [[0, 1, 2]]}, + }, + { + "name": "union", + "class": "union", + "kwargs": {}, + "expected": "\n\nunion();", + "args": {}, + }, + { + "name": "intersection", + "class": "intersection", + "kwargs": {}, + "expected": "\n\nintersection();", + "args": {}, + }, + { + "name": "difference", + "class": "difference", + "kwargs": {}, + "expected": "\n\ndifference();", + "args": {}, + }, + { + "name": "translate", + "class": "translate", + "kwargs": {"v": [1, 0, 0]}, + "expected": "\n\ntranslate(v = [1, 0, 0]);", + "args": {}, + }, + { + "name": "scale", + "class": "scale", + "kwargs": {"v": 0.5}, + "expected": "\n\nscale(v = 0.5000000000);", + "args": {}, + }, + { + "name": "rotate", + "class": "rotate", + "kwargs": {"a": 45, "v": [0, 0, 1]}, + "expected": "\n\nrotate(a = 45, v = [0, 0, 1]);", + "args": {}, + }, + { + "name": "mirror", + "class": "mirror", + "kwargs": {}, + "expected": "\n\nmirror(v = [0, 0, 1]);", + "args": {"v": [0, 0, 1]}, + }, + { + "name": "resize", + "class": "resize", + "kwargs": {"newsize": [5, 5, 5], "auto": [True, True, False]}, + "expected": "\n\nresize(auto = [true, true, false], newsize = [5, 5, 5]);", + "args": {}, + }, + { + "name": "multmatrix", + "class": "multmatrix", + "kwargs": {}, + "expected": "\n\nmultmatrix(m = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);", + "args": {"m": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}, + }, + { + "name": "minkowski", + "class": "minkowski", + "kwargs": {}, + "expected": "\n\nminkowski();", + "args": {}, + }, + { + "name": "offset", + "class": "offset", + "kwargs": {"r": 1}, + "expected": "\n\noffset(r = 1);", + "args": {}, + }, + { + "name": "offset_segments", + "class": "offset", + "kwargs": {"r": 1, "segments": 12}, + "expected": "\n\noffset($fn = 12, r = 1);", + "args": {}, + }, + { + "name": "offset_chamfer", + "class": "offset", + "kwargs": {"delta": 1}, + "expected": "\n\noffset(chamfer = false, delta = 1);", + "args": {}, + }, + { + "name": "offset_zero_delta", + "class": "offset", + "kwargs": {"r": 0}, + "expected": "\n\noffset(r = 0);", + "args": {}, + }, + { + "name": "hull", + "class": "hull", + "kwargs": {}, + "expected": "\n\nhull();", + "args": {}, + }, + { + "name": "render", + "class": "render", + "kwargs": {"convexity": None}, + "expected": "\n\nrender();", + "args": {}, + }, + { + "name": "projection", + "class": "projection", + "kwargs": {"cut": None}, + "expected": "\n\nprojection();", + "args": {}, + }, + { + "name": "surface", + "class": "surface", + "kwargs": {"center": False, "convexity": None}, + "expected": '\n\nsurface(center = false, file = "/Path/to/dummy.dxf");', + "args": {"file": "'/Path/to/dummy.dxf'"}, + }, + { + "name": "import_stl", + "class": "import_stl", + "kwargs": {"layer": None, "origin": (0, 0)}, + "expected": '\n\nimport(file = "/Path/to/dummy.stl", origin = [0, 0]);', + "args": {"file": "'/Path/to/dummy.stl'"}, + }, + { + "name": "import_dxf", + "class": "import_dxf", + "kwargs": {"layer": None, "origin": (0, 0)}, + "expected": '\n\nimport(file = "/Path/to/dummy.dxf", origin = [0, 0]);', + "args": {"file": "'/Path/to/dummy.dxf'"}, + }, + { + "name": "import_", + "class": "import_", + "kwargs": {"layer": None, "origin": (0, 0)}, + "expected": '\n\nimport(file = "/Path/to/dummy.dxf", origin = [0, 0]);', + "args": {"file": "'/Path/to/dummy.dxf'"}, + }, + { + "name": "import__convexity", + "class": "import_", + "kwargs": {"layer": None, "origin": (0, 0), "convexity": 2}, + "expected": '\n\nimport(convexity = 2, file = "/Path/to/dummy.dxf", origin = [0, 0]);', + "args": {"file": "'/Path/to/dummy.dxf'"}, + }, + { + "name": "linear_extrude", + "class": "linear_extrude", + "kwargs": { + "twist": None, + "slices": None, + "center": False, + "convexity": None, + "height": 1, + "scale": 0.9, + }, + "expected": "\n\nlinear_extrude(center = false, height = 1, scale = 0.9000000000);", + "args": {}, + }, + { + "name": "rotate_extrude", + "class": "rotate_extrude", + "kwargs": {"angle": 90, "segments": 4, "convexity": None}, + "expected": "\n\nrotate_extrude($fn = 4, angle = 90);", + "args": {}, + }, + { + "name": "intersection_for", + "class": "intersection_for", + "kwargs": {}, + "expected": "\n\nintersection_for(n = [0, 1, 2]);", + "args": {"n": [0, 1, 2]}, + }, ] +class TemporaryFileBuffer(object): + name = None + contents = None + + def __enter__(self): + f = tempfile.NamedTemporaryFile(delete=False) + self.name = f.name + try: + f.close() + except: + self._cleanup() + raise + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + with open(self.name, "r") as f: + self.contents = f.read() + finally: + self._cleanup() + + def _cleanup(self): + try: + os.unlink(self.name) + except Exception: + pass + + class TestSolidPython(DiffOutput): # test cases will be dynamically added to this instance def expand_scad_path(self, filename): - path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../') - return os.path.join(path, filename) + path = Path(__file__).absolute().parent.parent / filename + return path def test_infix_union(self): a = cube(2) b = sphere(2) - expected = '\n\nunion() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' + expected = "\n\nunion() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}" actual = scad_render(a + b) self.assertEqual(expected, actual) def test_infix_difference(self): a = cube(2) b = sphere(2) - expected = '\n\ndifference() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' + expected = "\n\ndifference() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}" actual = scad_render(a - b) self.assertEqual(expected, actual) def test_infix_intersection(self): a = cube(2) b = sphere(2) - expected = '\n\nintersection() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' + expected = "\n\nintersection() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}" actual = scad_render(a * b) self.assertEqual(expected, actual) def test_parse_scad_callables(self): - test_str = ("" - "module hex (width=10, height=10, \n" - " flats= true, center=false){}\n" - "function righty (angle=90) = 1;\n" - "function lefty(avar) = 2;\n" - "module more(a=[something, other]) {}\n" - "module pyramid(side=10, height=-1, square=false, centerHorizontal=true, centerVertical=false){}\n" - "module no_comments(arg=10, //test comment\n" - "other_arg=2, /* some extra comments\n" - "on empty lines */\n" - "last_arg=4){}\n" - "module float_arg(arg=1.0){}\n") - expected = [{'args': [], 'name': 'hex', 'kwargs': ['width', 'height', 'flats', 'center']}, {'args': [], 'name': 'righty', 'kwargs': ['angle']}, {'args': ['avar'], 'name': 'lefty', 'kwargs': []}, {'args': [], 'name': 'more', 'kwargs': ['a']}, { - 'args': [], 'name': 'pyramid', 'kwargs': ['side', 'height', 'square', 'centerHorizontal', 'centerVertical']}, {'args': [], 'name': 'no_comments', 'kwargs': ['arg', 'other_arg', 'last_arg']}, {'args': [], 'name': 'float_arg', 'kwargs': ['arg']}] - actual = parse_scad_callables(test_str) - self.assertEqual(expected, actual) + test_str = """ + module hex (width=10, height=10, + flats= true, center=false){} + function righty (angle=90) = 1; + function lefty(avar) = 2; + module more(a=[something, other]) {} + module pyramid(side=10, height=-1, square=false, centerHorizontal=true, centerVertical=false){} + module no_comments(arg=10, //test comment + other_arg=2, /* some extra comments + on empty lines */ + last_arg=4){} + module float_arg(arg=1.0){} + module arg_var(var5){} + module kwarg_var(var2=78){} + module var_true(var_true = true){} + module var_false(var_false = false){} + module var_int(var_int = 5){} + module var_negative(var_negative = -5){} + module var_float(var_float = 5.5){} + module var_number(var_number = -5e89){} + module var_empty_vector(var_empty_vector = []){} + module var_simple_string(var_simple_string = "simple string"){} + module var_complex_string(var_complex_string = "a \\"complex\\"\\tstring with a\\\\"){} + module var_vector(var_vector = [5454445, 565, [44545]]){} + module var_complex_vector(var_complex_vector = [545 + 4445, 565, [cos(75) + len("yes", 45)]]){} + module var_vector(var_vector = [5, 6, "string\\twith\\ttab"]){} + module var_range(var_range = [0:10e10]){} + module var_range_step(var_range_step = [-10:0.5:10]){} + module var_with_arithmetic(var_with_arithmetic = 8 * 9 - 1 + 89 / 15){} + module var_with_parentheses(var_with_parentheses = 8 * ((9 - 1) + 89) / 15){} + module var_with_functions(var_with_functions = abs(min(chamferHeight2, 0)) / 1){} + module var_with_conditional_assignment(var_with_conditional_assignment = mytest ? 45 : yop){} + + """ + + scad_file = "" + with tempfile.NamedTemporaryFile(suffix=".scad", delete=False) as f: + f.write(test_str.encode("utf-8")) + scad_file = f.name + + expected = [ + { + "name": "hex", + "args": [], + "kwargs": ["width", "height", "flats", "center"], + }, + {"name": "righty", "args": [], "kwargs": ["angle"]}, + {"name": "lefty", "args": [], "kwargs": ["avar"]}, + {"name": "more", "args": [], "kwargs": ["a"]}, + { + "name": "pyramid", + "args": [], + "kwargs": [ + "side", + "height", + "square", + "centerHorizontal", + "centerVertical", + ], + }, + { + "name": "no_comments", + "args": [], + "kwargs": ["arg", "other_arg", "last_arg"], + }, + {"name": "float_arg", "args": [], "kwargs": ["arg"]}, + {"name": "arg_var", "args": [], "kwargs": ["var5"]}, + {"name": "kwarg_var", "args": [], "kwargs": ["var2"]}, + {"name": "var_true", "args": [], "kwargs": ["var_true"]}, + {"name": "var_false", "args": [], "kwargs": ["var_false"]}, + {"name": "var_int", "args": [], "kwargs": ["var_int"]}, + {"name": "var_negative", "args": [], "kwargs": ["var_negative"]}, + {"name": "var_float", "args": [], "kwargs": ["var_float"]}, + {"name": "var_number", "args": [], "kwargs": ["var_number"]}, + {"name": "var_empty_vector", "args": [], "kwargs": ["var_empty_vector"]}, + {"name": "var_simple_string", "args": [], "kwargs": ["var_simple_string"]}, + { + "name": "var_complex_string", + "args": [], + "kwargs": ["var_complex_string"], + }, + {"name": "var_vector", "args": [], "kwargs": ["var_vector"]}, + { + "name": "var_complex_vector", + "args": [], + "kwargs": ["var_complex_vector"], + }, + {"name": "var_vector", "args": [], "kwargs": ["var_vector"]}, + {"name": "var_range", "args": [], "kwargs": ["var_range"]}, + {"name": "var_range_step", "args": [], "kwargs": ["var_range_step"]}, + { + "name": "var_with_arithmetic", + "args": [], + "kwargs": ["var_with_arithmetic"], + }, + { + "name": "var_with_parentheses", + "args": [], + "kwargs": ["var_with_parentheses"], + }, + { + "name": "var_with_functions", + "args": [], + "kwargs": ["var_with_functions"], + }, + { + "name": "var_with_conditional_assignment", + "args": [], + "kwargs": ["var_with_conditional_assignment"], + }, + ] + + from solid.solidpython import parse_scad_callables + + actual = parse_scad_callables(scad_file) + + for e in expected: + self.assertEqual(e in actual, True) + + os.unlink(scad_file) def test_use(self): include_file = self.expand_scad_path("examples/scad_to_include.scad") use(include_file) - a = steps(3) + + a = steps(3) # type: ignore # noqa: F821 actual = scad_render(a) abs_path = a._get_include_path(include_file) - expected = "use <%s>\n\n\nsteps(howmany = 3);" % abs_path + expected = f"use <{abs_path}>\n\n\nsteps(howmany = 3);" self.assertEqual(expected, actual) + def test_import_scad(self): + include_file = self.expand_scad_path("examples/scad_to_include.scad") + mod = import_scad(include_file) + a = mod.steps(3) + actual = scad_render(a) + + abs_path = a._get_include_path(include_file) + expected = f"use <{abs_path}>\n\n\nsteps(howmany = 3);" + self.assertEqual(expected, actual) + + # Make sure this plays nicely with `scad_render()`'s `file_header` arg + header = "$fn = 24;" + actual = scad_render(a, file_header=header) + expected = f"{header}\nuse <{abs_path}>\n\n\nsteps(howmany = 3);" + self.assertEqual(expected, actual) + + # Confirm that we can leave out even non-default arguments in OpenSCAD + a = mod.optional_nondefault_arg() + actual = scad_render(a) + expected = f"use <{abs_path}>\n\n\noptional_nondefault_arg();" + self.assertEqual(expected, actual) + # Make sure we throw ValueError on nonexistent imports + self.assertRaises(ValueError, import_scad, "path/doesnt/exist.scad") + + # Test that we recursively import directories correctly + examples = import_scad(include_file.parent) + self.assertTrue(hasattr(examples, "scad_to_include")) + self.assertTrue(hasattr(examples.scad_to_include, "steps")) + + # Test that: + # A) scad files in the designated OpenSCAD library directories + # (path-dependent, see: solid.objects._openscad_library_paths()) + # are imported correctly. + # B) scad files in the designated app-install library directories + from solid import objects + + lib_dirs = objects._openscad_library_paths() + for i, ld in enumerate(lib_dirs): + if ld.as_posix() == ".": + continue + if not ld.exists(): + continue + temp_dirname = f"test_{i}" + d = ld / temp_dirname + try: + d.mkdir(exist_ok=True) + except PermissionError: + # We won't always have permissions to write to the library directory. + # In that case, skip this test. + continue + p = d / "scad_to_include.scad" + p.write_text(include_file.read_text()) + temp_file_str = f"{temp_dirname}/scad_to_include.scad" + + mod = import_scad(temp_file_str) + a = mod.steps(3) + actual = scad_render(a) + expected = f"use <{p.absolute()}>\n\n\nsteps(howmany = 3);" + self.assertEqual( + actual, expected, f"Unexpected file contents at {p} for dir: {ld}" + ) + + # remove generated file and directories + p.unlink() + d.rmdir() + + def test_multiple_import_scad(self): + # For Issue #172. Originally, multiple `import_scad()` calls would + # re-import the entire module, rather than cache a module after one use + include_file = self.expand_scad_path("examples/scad_to_include.scad") + mod1 = import_scad(include_file) + mod2 = import_scad(include_file) + self.assertEqual(mod1, mod2) + + def test_imported_scad_arguments(self): + include_file = self.expand_scad_path("examples/scad_to_include.scad") + mod = import_scad(include_file) + points = mod.scad_points() + poly = polygon(points) + actual = scad_render(poly) + abs_path = points._get_include_path(include_file) + expected = f"use <{abs_path}>\n\n\npolygon(points = scad_points());" + self.assertEqual(expected, actual) + + def test_use_reserved_words(self): + scad_str = """module reserved_word_arg(or=3){\n\tcube(or);\n}\nmodule or(arg=3){\n\tcube(arg);\n}\n""" + + fd, path = tempfile.mkstemp(text=True) + try: + os.close(fd) + with open(path, "w") as f: + f.write(scad_str) + + use(path) + a = reserved_word_arg(or_=5) # type: ignore # noqa: F821 + actual = scad_render(a) + expected = f"use <{path}>\n\n\nreserved_word_arg(or = 5);" + self.assertEqual(expected, actual) + + b = or_(arg=5) # type: ignore # noqa: F821 + actual = scad_render(b) + expected = f"use <{path}>\n\n\nor(arg = 5);" + self.assertEqual(expected, actual) + finally: + os.remove(path) + def test_include(self): include_file = self.expand_scad_path("examples/scad_to_include.scad") - self.assertIsNotNone(include_file, 'examples/scad_to_include.scad not found') + self.assertIsNotNone(include_file, "examples/scad_to_include.scad not found") include(include_file) - a = steps(3) + a = steps(3) # type: ignore # noqa: F821 actual = scad_render(a) abs_path = a._get_include_path(include_file) - expected = "include <%s>\n\n\nsteps(howmany = 3);" % abs_path + expected = f"include <{abs_path}>\n\n\nsteps(howmany = 3);" self.assertEqual(expected, actual) def test_extra_args_to_included_scad(self): include_file = self.expand_scad_path("examples/scad_to_include.scad") - use(include_file) - a = steps(3, external_var=True) + mod = import_scad(include_file) + a = mod.steps(3, external_var=True) actual = scad_render(a) abs_path = a._get_include_path(include_file) - expected = "use <%s>\n\n\nsteps(external_var = true, howmany = 3);" % abs_path + expected = f"use <{abs_path}>\n\n\nsteps(external_var = true, howmany = 3);" self.assertEqual(expected, actual) def test_background(self): a = cube(10) - expected = '\n\n%cube(size = 10);' + expected = "\n\n%cube(size = 10);" actual = scad_render(background(a)) self.assertEqual(expected, actual) def test_debug(self): a = cube(10) - expected = '\n\n#cube(size = 10);' + expected = "\n\n#cube(size = 10);" actual = scad_render(debug(a)) self.assertEqual(expected, actual) def test_disable(self): a = cube(10) - expected = '\n\n*cube(size = 10);' + expected = "\n\n*cube(size = 10);" actual = scad_render(disable(a)) self.assertEqual(expected, actual) def test_root(self): a = cube(10) - expected = '\n\n!cube(size = 10);' + expected = "\n\n!cube(size = 10);" actual = scad_render(root(a)) self.assertEqual(expected, actual) + def test_color(self): + all_args = [ + {"c": [1, 0, 0]}, + {"c": [1, 0, 0], "alpha": 0.5}, + {"c": "#66F"}, + {"c": "Teal", "alpha": 0.5}, + ] + + expecteds = [ + "\n\ncolor(alpha = 1.0000000000, c = [1, 0, 0]);", + "\n\ncolor(alpha = 0.5000000000, c = [1, 0, 0]);", + '\n\ncolor(alpha = 1.0000000000, c = "#66F");', + '\n\ncolor(alpha = 0.5000000000, c = "Teal");', + ] + for args, expected in zip(all_args, expecteds): + col = color(**args) + actual = scad_render(col) + self.assertEqual(expected, actual) + def test_explicit_hole(self): a = cube(10, center=True) + hole()(cylinder(2, 20, center=True)) - expected = '\n\ndifference(){\n\tunion() {\n\t\tcube(center = true, size = 10);\n\t}\n\t/* Holes Below*/\n\tunion(){\n\t\tcylinder(center = true, h = 20, r = 2);\n\t} /* End Holes */ \n}' + expected = "\n\ndifference(){\n\tunion() {\n\t\tcube(center = true, size = 10);\n\t}\n\t/* Holes Below*/\n\tunion(){\n\t\tcylinder(center = true, h = 20, r = 2);\n\t} /* End Holes */ \n}" actual = scad_render(a) self.assertEqual(expected, actual) @@ -154,18 +681,12 @@ def test_hole_transform_propagation(self): # earlier versions of holes had problems where a hole # that was used a couple places wouldn't propagate correctly. # Confirm that's still happening as it's supposed to - h = hole()( - rotate(a=90, v=[0, 1, 0])( - cylinder(2, 20, center=True) - ) - ) + h = hole()(rotate(a=90, v=[0, 1, 0])(cylinder(2, 20, center=True))) - h_vert = rotate(a=-90, v=[0, 1, 0])( - h - ) + h_vert = rotate(a=-90, v=[0, 1, 0])(h) a = cube(10, center=True) + h + h_vert - expected = '\n\ndifference(){\n\tunion() {\n\t\tunion() {\n\t\t\tcube(center = true, size = 10);\n\t\t}\n\t\trotate(a = -90, v = [0, 1, 0]) {\n\t\t}\n\t}\n\t/* Holes Below*/\n\tunion(){\n\t\tunion(){\n\t\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t\t}\n\t\t}\n\t\trotate(a = -90, v = [0, 1, 0]){\n\t\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t\t}\n\t\t}\n\t} /* End Holes */ \n}' + expected = "\n\ndifference(){\n\tunion() {\n\t\tcube(center = true, size = 10);\n\t\trotate(a = -90, v = [0, 1, 0]) {\n\t\t}\n\t}\n\t/* Holes Below*/\n\tunion(){\n\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t}\n\t\trotate(a = -90, v = [0, 1, 0]){\n\t\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t\t}\n\t\t}\n\t} /* End Holes */ \n}" actual = scad_render(a) self.assertEqual(expected, actual) @@ -190,64 +711,129 @@ def test_separate_part_hole(self): a = p1 + p2 - expected = '\n\nunion() {\n\tdifference(){\n\t\tdifference() {\n\t\t\tcube(center = true, size = 10);\n\t\t}\n\t\t/* Holes Below*/\n\t\tdifference(){\n\t\t\tcylinder(center = true, h = 12, r = 2);\n\t\t} /* End Holes */ \n\t}\n\tcylinder(center = true, h = 14, r = 1.5000000000);\n}' + expected = "\n\nunion() {\n\tdifference(){\n\t\tdifference() {\n\t\t\tcube(center = true, size = 10);\n\t\t}\n\t\t/* Holes Below*/\n\t\tunion(){\n\t\t\tcylinder(center = true, h = 12, r = 2);\n\t\t} /* End Holes */ \n\t}\n\tcylinder(center = true, h = 14, r = 1.5000000000);\n}" actual = scad_render(a) self.assertEqual(expected, actual) def test_scad_render_animated_file(self): def my_animate(_time=0): import math + # _time will range from 0 to 1, not including 1 rads = _time * 2 * math.pi rad = 15 c = translate([rad * math.cos(rads), rad * math.sin(rads)])(square(10)) return c - tmp = tempfile.NamedTemporaryFile() - - scad_render_animated_file(my_animate, steps=2, back_and_forth=False, - filepath=tmp.name, include_orig_code=False) - tmp.seek(0) - actual = tmp.read() - expected = b'\nif ($t >= 0.0 && $t < 0.5){ \n\ttranslate(v = [15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\nif ($t >= 0.5 && $t < 1.0){ \n\ttranslate(v = [-15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\n' - tmp.close() + + with TemporaryFileBuffer() as tmp: + scad_render_animated_file( + my_animate, + steps=2, + back_and_forth=False, + filepath=tmp.name, + include_orig_code=False, + ) + + actual = tmp.contents + expected = "\nif ($t >= 0.0 && $t < 0.5){ \n\ttranslate(v = [15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\nif ($t >= 0.5 && $t < 1.0){ \n\ttranslate(v = [-15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\n" + self.assertEqual(expected, actual) def test_scad_render_to_file(self): a = circle(10) # No header, no included original code - tmp = tempfile.NamedTemporaryFile() - scad_render_to_file(a, filepath=tmp.name, include_orig_code=False) - tmp.seek(0) - actual = tmp.read() - expected = b'\n\ncircle(r = 10);' - tmp.close() - self.assertEqual(expected, actual) + with TemporaryFileBuffer() as tmp: + scad_render_to_file(a, filepath=tmp.name, include_orig_code=False) + + actual = tmp.contents + expected = "\n\ncircle(r = 10);" + + # scad_render_to_file also adds a date & version stamp before scad code; + # That won't match here, so just make sure the correct code is at the end + self.assertTrue(actual.endswith(expected)) # Header - tmp = tempfile.NamedTemporaryFile() - scad_render_to_file(a, filepath=tmp.name, include_orig_code=False, - file_header='$fn = 24;') - tmp.seek(0) - actual = tmp.read() - expected = b'$fn = 24;\n\ncircle(r = 10);' - tmp.close() - self.assertEqual(expected, actual) + with TemporaryFileBuffer() as tmp: + scad_render_to_file( + a, filepath=tmp.name, include_orig_code=False, file_header="$fn = 24;" + ) + + actual = tmp.contents + expected = "$fn = 24;\n\n\ncircle(r = 10);" + + self.assertTrue(actual.endswith(expected)) + + # Test out_dir specification, both using an existing dir & creating one + # Using existing directory + with TemporaryFileBuffer() as tmp: + out_dir = Path(tmp.name).parent + expected = (out_dir / "test_solidpython.scad").as_posix() + actual = scad_render_to_file(a, out_dir=out_dir) + self.assertEqual(expected, actual) + + # Creating a directory on demand + with TemporaryFileBuffer() as tmp: + out_dir = Path(tmp.name).parent / "SCAD" + expected = (out_dir / "test_solidpython.scad").as_posix() + actual = scad_render_to_file(a, out_dir=out_dir) + self.assertEqual(expected, actual) # TODO: test include_orig_code=True, but that would have to # be done from a separate file, or include everything in this one + def test_numpy_type(self): + try: + import numpy # type: ignore + + numpy_cube = cube(size=numpy.array([1, 2, 3])) + expected = "\n\ncube(size = [1,2,3]);" + actual = scad_render(numpy_cube) + self.assertEqual( + expected, actual, "Numpy SolidPython not rendered correctly" + ) + except ImportError: + pass + + def test_custom_iterables(self): + from euclid3 import Vector3 + + class CustomIterable: + def __iter__(self): + return iter([1, 2, 3]) + + expected = "\n\ncube(size = [1, 2, 3]);" + iterables = [ + [1, 2, 3], + (1, 2, 3), + Vector3(1, 2, 3), + CustomIterable(), + ] + + for iterable in iterables: + name = type(iterable).__name__ + actual = scad_render(cube(size=iterable)) + self.assertEqual( + expected, actual, f"{name} SolidPython not rendered correctly" + ) + def single_test(test_dict): - name, args, kwargs, expected = test_dict['name'], test_dict['args'], test_dict['kwargs'], test_dict['expected'] + _, cls, args, kwargs, expected = ( + test_dict["name"], + test_dict["class"], + test_dict["args"], + test_dict["kwargs"], + test_dict["expected"], + ) def test(self): - call_str = name + "(" + call_str = cls + "(" for k, v in args.items(): - call_str += "%s=%s, " % (k, v) + call_str += f"{k}={v}, " for k, v in kwargs.items(): - call_str += "%s=%s, " % (k, v) - call_str += ')' + call_str += f"{k}={v}, " + call_str += ")" scad_obj = eval(call_str) actual = scad_render(scad_obj) @@ -260,10 +846,10 @@ def test(self): def generate_cases_from_templates(): for test_dict in scad_test_case_templates: test = single_test(test_dict) - test_name = "test_%(name)s" % test_dict + test_name = f"test_{test_dict['name']}" setattr(TestSolidPython, test_name, test) -if __name__ == '__main__': +if __name__ == "__main__": generate_cases_from_templates() unittest.main() diff --git a/solid/test/test_splines.py b/solid/test/test_splines.py new file mode 100755 index 00000000..80b4cc2c --- /dev/null +++ b/solid/test/test_splines.py @@ -0,0 +1,137 @@ +#! /usr/bin/env python + +import unittest +from solid.test.ExpandedTestCase import DiffOutput +from solid.utils import euclidify +from solid.splines import catmull_rom_points, catmull_rom_prism, bezier_points +from euclid3 import Point3, Vector3 +from math import pi + +SEGMENTS = 8 + + +class TestSplines(DiffOutput): + def setUp(self): + self.points = [ + Point3(0, 0), + Point3(1, 1), + Point3(2, 1), + ] + self.points_raw = [ + (0, 0), + (1, 1), + (2, 1), + ] + self.bezier_controls = [ + Point3(0, 0), + Point3(1, 1), + Point3(2, 1), + Point3(2, -1), + ] + self.bezier_controls_raw = [(0, 0), (1, 1), (2, 1), (2, -1)] + self.subdivisions = 2 + + def assertPointsListsEqual(self, a, b): + str_list = lambda x: list(str(v) for v in x) # noqa: E731 + self.assertEqual(str_list(a), str_list(b)) + + def test_catmull_rom_points(self): + expected = [ + Point3(0.00, 0.00), + Point3(0.38, 0.44), + Point3(1.00, 1.00), + Point3(1.62, 1.06), + Point3(2.00, 1.00), + ] + actual = catmull_rom_points( + self.points, subdivisions=self.subdivisions, close_loop=False + ) + self.assertPointsListsEqual(expected, actual) + + # TODO: verify we always have the right number of points for a given call + # verify that `close_loop` always behaves correctly + # verify that start_tangent and end_tangent behavior is correct + + def test_catmull_rom_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [ + Point3(0.00, 0.00), + Point3(0.38, 0.44), + Point3(1.00, 1.00), + Point3(1.62, 1.06), + Point3(2.00, 1.00), + ] + actual = catmull_rom_points( + self.points_raw, subdivisions=self.subdivisions, close_loop=False + ) + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_points_3d(self): + points = [Point3(-1, -1, 0), Point3(0, 0, 1), Point3(1, 1, 0)] + expected = [ + Point3(-1.00, -1.00, 0.00), + Point3(-0.62, -0.62, 0.50), + Point3(0.00, 0.00, 1.00), + Point3(0.62, 0.62, 0.50), + Point3(1.00, 1.00, 0.00), + ] + actual = catmull_rom_points(points, subdivisions=2) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points(self): + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] + actual = bezier_points(self.bezier_controls, subdivisions=self.subdivisions) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] + actual = bezier_points(self.bezier_controls_raw, subdivisions=self.subdivisions) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points_3d(self): + # verify that we get a valid bezier curve back even when its control points + # are outside the XY plane and aren't coplanar + controls_3d = [ + Point3(-2, -1, 0), + Point3(-0.5, -0.5, 1), + Point3(0.5, 0.5, 1), + Point3(2, 1, 0), + ] + actual = bezier_points(controls_3d, subdivisions=self.subdivisions) + expected = [ + Point3(-2.00, -1.00, 0.00), + Point3(0.00, 0.00, 0.75), + Point3(2.00, 1.00, 0.00), + ] + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_prism(self): + sides = 3 + UP = Vector3(0, 0, 1) + + control_points = [[10, 10, 0], [10, 10, 5], [8, 8, 15]] + + cat_tube = [] + angle_step = 2 * pi / sides + for i in range(sides): + rotated_controls = list( + ( + euclidify(p, Point3).rotate_around(UP, angle_step * i) + for p in control_points + ) + ) + cat_tube.append(rotated_controls) + + poly = catmull_rom_prism( + cat_tube, self.subdivisions, closed_ring=True, add_caps=True + ) + actual = (len(poly.params["points"]), len(poly.params["faces"])) + expected = (37, 62) + self.assertEqual(expected, actual) + + +if __name__ == "__main__": + unittest.main() diff --git a/solid/test/test_utils.py b/solid/test/test_utils.py index 9cc518d1..89ff7724 100755 --- a/solid/test/test_utils.py +++ b/solid/test/test_utils.py @@ -1,61 +1,152 @@ #! /usr/bin/env python -# -*- coding: UTF-8 -*- -import os -import sys -import re - +from solid.solidpython import OpenSCADObject import unittest +import re +from euclid3 import Point3, Vector3, Point2 -from solid import * -from solid.utils import * -from euclid import * -import difflib +from solid import scad_render +from solid.objects import cube, polygon, sphere, translate from solid.test.ExpandedTestCase import DiffOutput +from solid.utils import BoundingBox, arc, arc_inverted, euc_to_arr, euclidify +from solid.utils import fillet_2d, is_scad, offset_points +from solid.utils import split_body_planar, transform_to_point, project_to_2D +from solid.utils import path_2d, path_2d_polygon +from solid.utils import FORWARD_VEC, RIGHT_VEC, UP_VEC +from solid.utils import back, down, forward, left, right, up +from solid.utils import label +from typing import Union tri = [Point3(0, 0, 0), Point3(10, 0, 0), Point3(0, 10, 0)] + scad_test_cases = [ - ( up, [2], '\n\ntranslate(v = [0, 0, 2]);'), - ( down, [2], '\n\ntranslate(v = [0, 0, -2]);'), - ( left, [2], '\n\ntranslate(v = [-2, 0, 0]);'), - ( right, [2], '\n\ntranslate(v = [2, 0, 0]);'), - ( forward, [2], '\n\ntranslate(v = [0, 2, 0]);'), - ( back, [2], '\n\ntranslate(v = [0, -2, 0]);'), - ( arc, [10, 0, 90, 24], '\n\ndifference() {\n\tcircle($fn = 24, r = 10);\n\trotate(a = 0) {\n\t\ttranslate(v = [0, -10, 0]) {\n\t\t\tsquare(center = true, size = [30, 20]);\n\t\t}\n\t}\n\trotate(a = -90) {\n\t\ttranslate(v = [0, -10, 0]) {\n\t\t\tsquare(center = true, size = [30, 20]);\n\t\t}\n\t}\n}'), - ( arc_inverted, [10, 0, 90, 24], '\n\ndifference() {\n\tintersection() {\n\t\trotate(a = 0) {\n\t\t\ttranslate(v = [-990, 0]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t\trotate(a = 90) {\n\t\t\ttranslate(v = [-990, -1000]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t}\n\tcircle($fn = 24, r = 10);\n}'), - ( 'transform_to_point_scad', transform_to_point, [cube(2), [2,2,2], [3,3,1]], '\n\nmultmatrix(m = [[0.7071067812, -0.1622214211, -0.6882472016, 2], [-0.7071067812, -0.1622214211, -0.6882472016, 2], [0.0000000000, 0.9733285268, -0.2294157339, 2], [0, 0, 0, 1.0000000000]]) {\n\tcube(size = 2);\n}'), - ( 'offset_polygon_inside', offset_polygon, [tri, 2, True], '\n\npolygon(paths = [[0, 1, 2]], points = [[2.0000000000, 2.0000000000, 0.0000000000], [5.1715728753, 2.0000000000, 0.0000000000], [2.0000000000, 5.1715728753, 0.0000000000]]);'), - ( 'offset_polygon_outside', offset_polygon, [tri, 2, False], '\n\npolygon(paths = [[0, 1, 2]], points = [[-2.0000000000, -2.0000000000, 0.0000000000], [14.8284271247, -2.0000000000, 0.0000000000], [-2.0000000000, 14.8284271247, 0.0000000000]]);'), - ( 'extrude_along_path', extrude_along_path, [tri, [[0,0,0],[0,20,0]]], '\n\npolyhedron(faces = [[0, 3, 1], [1, 3, 4], [1, 4, 2], [2, 4, 5], [0, 2, 5], [0, 5, 3], [0, 1, 2], [3, 5, 4]], points = [[0.0000000000, 0.0000000000, 0.0000000000], [10.0000000000, 0.0000000000, 0.0000000000], [0.0000000000, 0.0000000000, 10.0000000000], [0.0000000000, 20.0000000000, 0.0000000000], [10.0000000000, 20.0000000000, 0.0000000000], [0.0000000000, 20.0000000000, 10.0000000000]]);'), - ( 'extrude_along_path_vertical',extrude_along_path, [tri, [[0,0,0],[0,0,20]]], '\n\npolyhedron(faces = [[0, 3, 1], [1, 3, 4], [1, 4, 2], [2, 4, 5], [0, 2, 5], [0, 5, 3], [0, 1, 2], [3, 5, 4]], points = [[0.0000000000, 0.0000000000, 0.0000000000], [-10.0000000000, 0.0000000000, 0.0000000000], [0.0000000000, 10.0000000000, 0.0000000000], [0.0000000000, 0.0000000000, 20.0000000000], [-10.0000000000, 0.0000000000, 20.0000000000], [0.0000000000, 10.0000000000, 20.0000000000]]);'), - -] + # Test name, function, args, expected value + ("up", up, [2], "\n\ntranslate(v = [0, 0, 2]);"), + ("down", down, [2], "\n\ntranslate(v = [0, 0, -2]);"), + ("left", left, [2], "\n\ntranslate(v = [-2, 0, 0]);"), + ("right", right, [2], "\n\ntranslate(v = [2, 0, 0]);"), + ("forward", forward, [2], "\n\ntranslate(v = [0, 2, 0]);"), + ("back", back, [2], "\n\ntranslate(v = [0, -2, 0]);"), + ( + "arc", + arc, + [10, 0, 90, 24], + "\n\ndifference() {\n\tcircle($fn = 24, r = 10);\n\trotate(a = 0) {\n\t\ttranslate(v = [0, -10, 0]) {\n\t\t\tsquare(center = true, size = [30, 20]);\n\t\t}\n\t}\n\trotate(a = -90) {\n\t\ttranslate(v = [0, -10, 0]) {\n\t\t\tsquare(center = true, size = [30, 20]);\n\t\t}\n\t}\n}", + ), + ( + "arc_inverted", + arc_inverted, + [10, 0, 90, 24], + "\n\ndifference() {\n\tintersection() {\n\t\trotate(a = 0) {\n\t\t\ttranslate(v = [-990, 0, 0]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t\trotate(a = 90) {\n\t\t\ttranslate(v = [-990, -1000, 0]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t}\n\tcircle($fn = 24, r = 10);\n}", + ), + ( + "transform_to_point_scad", + transform_to_point, + [cube(2), [2, 2, 2], [3, 3, 1]], + "\n\nmultmatrix(m = [[0.7071067812, -0.1622214211, -0.6882472016, 2], [-0.7071067812, -0.1622214211, -0.6882472016, 2], [0.0000000000, 0.9733285268, -0.2294157339, 2], [0, 0, 0, 1.0000000000]]) {\n\tcube(size = 2);\n}", + ), +] other_test_cases = [ - ( euclidify, [[0,0,0]], 'Vector3(0.00, 0.00, 0.00)'), - ( 'euclidify_recursive', euclidify, [[[0,0,0], [1,0,0]]], '[Vector3(0.00, 0.00, 0.00), Vector3(1.00, 0.00, 0.00)]'), - ( 'euclidify_Vector', euclidify, [Vector3(0,0,0)], 'Vector3(0.00, 0.00, 0.00)'), - ( 'euclidify_recursive_Vector', euclidify, [[Vector3(0,0,0), Vector3(0,0,1)]], '[Vector3(0.00, 0.00, 0.00), Vector3(0.00, 0.00, 1.00)]'), - ( euc_to_arr, [Vector3(0,0,0)], '[0, 0, 0]'), - ( 'euc_to_arr_recursive', euc_to_arr, [[Vector3(0,0,0), Vector3(0,0,1)]], '[[0, 0, 0], [0, 0, 1]]'), - ( 'euc_to_arr_arr', euc_to_arr, [[0,0,0]], '[0, 0, 0]'), - ( 'euc_to_arr_arr_recursive', euc_to_arr, [[[0,0,0], [1,0,0]]], '[[0, 0, 0], [1, 0, 0]]'), - ( is_scad, [cube(2)], 'True'), - ( 'is_scad_false', is_scad, [2], 'False'), - ( 'transform_to_point_single_arr', transform_to_point, [[1,0,0], [2,2,2], [3,3,1]], 'Point3(2.71, 1.29, 2.00)'), - ( 'transform_to_point_single_pt3', transform_to_point, [Point3(1,0,0), [2,2,2], [3,3,1]], 'Point3(2.71, 1.29, 2.00)'), - ( 'transform_to_point_arr_arr', transform_to_point, [[[1,0,0], [0,1,0], [0,0,1]] , [2,2,2], [3,3,1]], '[Point3(2.71, 1.29, 2.00), Point3(1.84, 1.84, 2.97), Point3(1.31, 1.31, 1.77)]'), - ( 'transform_to_point_pt3_arr', transform_to_point, [[Point3(1,0,0), Point3(0,1,0), Point3(0,0,1)], [2,2,2], [3,3,1]], '[Point3(2.71, 1.29, 2.00), Point3(1.84, 1.84, 2.97), Point3(1.31, 1.31, 1.77)]') , - ( 'transform_to_point_redundant', transform_to_point, [ [Point3(0,0,0), Point3(10,0,0), Point3(0,10,0)], [2,2,2], Vector3(0,0,1), Point3(0,0,0), Vector3(0,1,0), Vector3(0,0,1)], '[Point3(2.00, 2.00, 2.00), Point3(-8.00, 2.00, 2.00), Point3(2.00, 12.00, 2.00)]'), - ( 'offset_points_inside', offset_points, [tri, 2, True], '[Point3(2.00, 2.00, 0.00), Point3(5.17, 2.00, 0.00), Point3(2.00, 5.17, 0.00)]'), - ( 'offset_points_outside', offset_points, [tri, 2, False], '[Point3(-2.00, -2.00, 0.00), Point3(14.83, -2.00, 0.00), Point3(-2.00, 14.83, 0.00)]'), - ( 'offset_points_open_poly', offset_points, [tri, 2, False, False], '[Point3(0.00, -2.00, 0.00), Point3(14.83, -2.00, 0.00), Point3(1.41, 11.41, 0.00)]'), + # Test name, function, args, expected value + ("euclidify", euclidify, [[0, 0, 0]], "Vector3(0.00, 0.00, 0.00)"), + ( + "euclidify_recursive", + euclidify, + [[[0, 0, 0], [1, 0, 0]]], + "[Vector3(0.00, 0.00, 0.00), Vector3(1.00, 0.00, 0.00)]", + ), + ("euclidify_Vector", euclidify, [Vector3(0, 0, 0)], "Vector3(0.00, 0.00, 0.00)"), + ( + "euclidify_recursive_Vector", + euclidify, + [[Vector3(0, 0, 0), Vector3(0, 0, 1)]], + "[Vector3(0.00, 0.00, 0.00), Vector3(0.00, 0.00, 1.00)]", + ), + ("euclidify_3_to_2", euclidify, [Point3(0, 1, 2), Point2], "Point2(0.00, 1.00)"), + ("euc_to_arr", euc_to_arr, [Vector3(0, 0, 0)], "[0, 0, 0]"), + ( + "euc_to_arr_recursive", + euc_to_arr, + [[Vector3(0, 0, 0), Vector3(0, 0, 1)]], + "[[0, 0, 0], [0, 0, 1]]", + ), + ("euc_to_arr_arr", euc_to_arr, [[0, 0, 0]], "[0, 0, 0]"), + ( + "euc_to_arr_arr_recursive", + euc_to_arr, + [[[0, 0, 0], [1, 0, 0]]], + "[[0, 0, 0], [1, 0, 0]]", + ), + ("is_scad", is_scad, [cube(2)], "True"), + ("is_scad_false", is_scad, [2], "False"), + ( + "transform_to_point_single_arr", + transform_to_point, + [[1, 0, 0], [2, 2, 2], [3, 3, 1]], + "Point3(2.71, 1.29, 2.00)", + ), + ( + "transform_to_point_single_pt3", + transform_to_point, + [Point3(1, 0, 0), [2, 2, 2], [3, 3, 1]], + "Point3(2.71, 1.29, 2.00)", + ), + ( + "transform_to_point_arr_arr", + transform_to_point, + [[[1, 0, 0], [0, 1, 0], [0, 0, 1]], [2, 2, 2], [3, 3, 1]], + "[Point3(2.71, 1.29, 2.00), Point3(1.84, 1.84, 2.97), Point3(1.31, 1.31, 1.77)]", + ), + ( + "transform_to_point_pt3_arr", + transform_to_point, + [[Point3(1, 0, 0), Point3(0, 1, 0), Point3(0, 0, 1)], [2, 2, 2], [3, 3, 1]], + "[Point3(2.71, 1.29, 2.00), Point3(1.84, 1.84, 2.97), Point3(1.31, 1.31, 1.77)]", + ), + ( + "transform_to_point_redundant", + transform_to_point, + [ + [Point3(0, 0, 0), Point3(10, 0, 0), Point3(0, 10, 0)], + [2, 2, 2], + Vector3(0, 0, 1), + Point3(0, 0, 0), + Vector3(0, 1, 0), + Vector3(0, 0, 1), + ], + "[Point3(2.00, 2.00, 2.00), Point3(-8.00, 2.00, 2.00), Point3(2.00, 12.00, 2.00)]", + ), + ( + "offset_points_inside", + offset_points, + [tri, 2, True], + "[Point2(2.00, 2.00), Point2(5.17, 2.00), Point2(2.00, 5.17)]", + ), + ( + "offset_points_outside", + offset_points, + [tri, 2, False], + "[Point2(-2.00, -2.00), Point2(14.83, -2.00), Point2(-2.00, 14.83)]", + ), ] class TestSPUtils(DiffOutput): # Test cases will be dynamically added to this instance # using the test case arrays above + def assertEqualNoWhitespace(self, a, b): + remove_whitespace = lambda s: re.subn(r"[\s\n]", "", s)[0] # noqa: E731 + self.assertEqual(remove_whitespace(a), remove_whitespace(b)) + + def assertEqualOpenScadObject( + self, expected: str, actual: Union[OpenSCADObject, str] + ): + if isinstance(actual, OpenSCADObject): + act = scad_render(actual) + elif isinstance(actual, str): + act = actual + self.assertEqualNoWhitespace(expected, act) def test_split_body_planar(self): offset = [10, 10, 10] @@ -63,43 +154,110 @@ def test_split_body_planar(self): body_bb = BoundingBox([40, 40, 40], offset) actual = [] for split_dir in [RIGHT_VEC, FORWARD_VEC, UP_VEC]: - actual_tuple = split_body_planar(body, body_bb, cutting_plane_normal=split_dir, cut_proportion=0.25) + actual_tuple = split_body_planar( + body, body_bb, cutting_plane_normal=split_dir, cut_proportion=0.25 + ) actual.append(actual_tuple) - # Ignore the bounding box object that come back, taking only the SCAD - # objects + # Ignore the bounding box object that come back, taking only the SCAD objects actual = [scad_render(a) for splits in actual for a in splits[::2]] - expected = ['\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [-5.0000000000, 10, 10]) {\n\t\tcube(center = true, size = [10.0000000000, 40, 40]);\n\t}\n}', - '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [15.0000000000, 10, 10]) {\n\t\tcube(center = true, size = [30.0000000000, 40, 40]);\n\t}\n}', - '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, -5.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 10.0000000000, 40]);\n\t}\n}', - '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 15.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 30.0000000000, 40]);\n\t}\n}', - '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, -5.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 10.0000000000]);\n\t}\n}', - '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, 15.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 30.0000000000]);\n\t}\n}' - ] + expected = [ + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [-5.0000000000, 10, 10]) {\n\t\tcube(center = true, size = [10.0000000000, 40, 40]);\n\t}\n}", + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [15.0000000000, 10, 10]) {\n\t\tcube(center = true, size = [30.0000000000, 40, 40]);\n\t}\n}", + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, -5.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 10.0000000000, 40]);\n\t}\n}", + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 15.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 30.0000000000, 40]);\n\t}\n}", + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, -5.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 10.0000000000]);\n\t}\n}", + "\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, 15.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 30.0000000000]);\n\t}\n}", + ] self.assertEqual(actual, expected) def test_fillet_2d_add(self): - pts = [[0, 5], [5, 5], [5, 0], [10, 0], [10, 10], [0, 10], ] + pts = [ + [0, 5], + [5, 5], + [5, 0], + [10, 0], + [10, 10], + [0, 10], + ] p = polygon(pts) - newp = fillet_2d(euclidify(pts[0:3], Point3), orig_poly=p, fillet_rad=2, remove_material=False) - expected = '\n\nunion() {\n\tpolygon(paths = [[0, 1, 2, 3, 4, 5]], points = [[0, 5], [5, 5], [5, 0], [10, 0], [10, 10], [0, 10]]);\n\ttranslate(v = [3.0000000000, 3.0000000000, 0.0000000000]) {\n\t\tdifference() {\n\t\t\tintersection() {\n\t\t\t\trotate(a = 358.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, 0]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trotate(a = 452.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, -1000]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcircle(r = 2);\n\t\t}\n\t}\n}' - actual = scad_render(newp) - self.assertEqual(expected, actual) + three_points = [euclidify(pts[0:3], Point2)] + actual = fillet_2d( + three_points, orig_poly=p, fillet_rad=2, remove_material=False + ) + expected = "union(){polygon(points=[[0,5],[5,5],[5,0],[10,0],[10,10],[0,10]]);translate(v=[3.0000000000,3.0000000000]){difference(){intersection(){rotate(a=359.9000000000){translate(v=[-998,0,0]){square(center=false,size=[1000,1000]);}}rotate(a=450.1000000000){translate(v=[-998,-1000,0]){square(center=false,size=[1000,1000]);}}}circle(r=2);}}}" + self.assertEqualOpenScadObject(expected, actual) def test_fillet_2d_remove(self): - pts = tri - poly = polygon(euc_to_arr(tri)) - - newp = fillet_2d(tri, orig_poly=poly, fillet_rad=2, remove_material=True) - expected = '\n\ndifference() {\n\tpolygon(paths = [[0, 1, 2]], points = [[0, 0, 0], [10, 0, 0], [0, 10, 0]]);\n\ttranslate(v = [5.1715728753, 2.0000000000, 0.0000000000]) {\n\t\tdifference() {\n\t\t\tintersection() {\n\t\t\t\trotate(a = 268.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, 0]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trotate(a = 407.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, -1000]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcircle(r = 2);\n\t\t}\n\t}\n}' - actual = scad_render(newp) - if expected != actual: - print(''.join(difflib.unified_diff(expected, actual))) + pts = list((project_to_2D(p) for p in tri)) + poly = polygon(euc_to_arr(pts)) + actual = fillet_2d([pts], orig_poly=poly, fillet_rad=2, remove_material=True) + expected = "difference(){polygon(points=[[0,0],[10,0],[0,10]]);translate(v=[5.1715728753,2.0000000000]){difference(){intersection(){rotate(a=-90.1000000000){translate(v=[-998,0,0]){square(center=false,size=[1000,1000]);}}rotate(a=45.1000000000){translate(v=[-998,-1000,0]){square(center=false,size=[1000,1000]);}}}circle(r=2);}}}" + self.assertEqualOpenScadObject(expected, actual) + + def test_euclidify_non_mutating(self): + base_tri = [Point2(0, 0), Point2(10, 0), Point2(0, 10)] + next_tri = euclidify(base_tri, Point2) # noqa: F841 + expected = 3 + actual = len(base_tri) + self.assertEqual(expected, actual, "euclidify should not mutate its arguments") + + def test_offset_points_closed(self): + actual = euc_to_arr(offset_points(tri, offset=1, closed=True)) + expected = [[1.0, 1.0], [7.585786437626904, 1.0], [1.0, 7.585786437626905]] self.assertEqual(expected, actual) + def test_offset_points_open(self): + actual = euc_to_arr(offset_points(tri, offset=1, closed=False)) + expected = [ + [0.0, 1.0], + [7.585786437626904, 1.0], + [-0.7071067811865479, 9.292893218813452], + ] + self.assertEqual(expected, actual) -def test_generator_scad(func, args, expected): + def test_path_2d(self): + base_tri = [Point2(0, 0), Point2(10, 0), Point2(10, 10)] + actual = euc_to_arr(path_2d(base_tri, width=2, closed=False)) + expected = [ + [0.0, 1.0], + [9.0, 1.0], + [9.0, 10.0], + [11.0, 10.0], + [11.0, -1.0], + [0.0, -1.0], + ] + self.assertEqual(expected, actual) + + def test_path_2d_polygon(self): + base_tri = [Point2(0, 0), Point2(10, 0), Point2(10, 10), Point2(0, 10)] + poly = path_2d_polygon(base_tri, width=2, closed=True) + expected = [ + (1.0, 1.0), + (9.0, 1.0), + (9.0, 9.0), + (1.0, 9.0), + (-1.0, 11.0), + (11.0, 11.0), + (11.0, -1.0), + (-1.0, -1.0), + ] + actual = euc_to_arr(poly.params["points"]) + self.assertEqual(expected, actual) + + # Make sure the inner and outer paths in the polygon are disjoint + expected = [[0, 1, 2, 3], [4, 5, 6, 7]] + actual = poly.params["paths"] + self.assertEqual(expected, actual) + + def test_label(self): + expected = 'translate(v=[0,5.0000000000,0]){resize(newsize=[15,0,0.5000000000]){union(){translate(v=[0,0.0000000000,0]){linear_extrude(height=1){text($fn=40,font="MgOpenModata:style=Bold",halign="left",spacing=1,text="Hello,",valign="baseline");}}translate(v=[0,-11.5000000000,0]){linear_extrude(height=1){text($fn=40,font="MgOpenModata:style=Bold",halign="left",spacing=1,text="World",valign="baseline");}}}}}' + actual = label("Hello,\nWorld") + self.assertEqualOpenScadObject(expected, actual) + + +def generate_scad_test(func, args, expected): def test_scad(self): scad_obj = func(*args) actual = scad_render(scad_obj) @@ -108,7 +266,7 @@ def test_scad(self): return test_scad -def test_generator_no_scad(func, args, expected): +def generate_no_scad_test(func, args, expected): def test_no_scad(self): actual = str(func(*args)) self.assertEqual(expected, actual) @@ -120,26 +278,27 @@ def read_test_tuple(test_tuple): if len(test_tuple) == 3: # If test name not supplied, create it programmatically func, args, expected = test_tuple - test_name = 'test_%s' % func.__name__ + test_name = f"test_{func.__name__}" elif len(test_tuple) == 4: test_name, func, args, expected = test_tuple - test_name = 'test_%s' % test_name + test_name = f"test_{test_name}" else: - print("test_tuple has %d args :%s" % (len(test_tuple), test_tuple)) + print(f"test_tuple has {len(test_tuple):d} args :{test_tuple}") return test_name, func, args, expected def create_tests(): for test_tuple in scad_test_cases: test_name, func, args, expected = read_test_tuple(test_tuple) - test = test_generator_scad(func, args, expected) + test = generate_scad_test(func, args, expected) setattr(TestSPUtils, test_name, test) for test_tuple in other_test_cases: test_name, func, args, expected = read_test_tuple(test_tuple) - test = test_generator_no_scad(func, args, expected) + test = generate_no_scad_test(func, args, expected) setattr(TestSPUtils, test_name, test) -if __name__ == '__main__': + +if __name__ == "__main__": create_tests() unittest.main() diff --git a/solid/utils.py b/solid/utils.py index 804b8ae6..ae2e966d 100755 --- a/solid/utils.py +++ b/solid/utils.py @@ -1,97 +1,123 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import division -import os -import sys -import re -from solid import * -from math import * +from itertools import zip_longest +from math import ceil, sqrt, atan2, degrees + +from solid import union, cube, translate, rotate, square, circle, polygon +from solid import difference, intersection, multmatrix, cylinder, color +from solid import text, linear_extrude, resize +from solid import run_euclid_patch +from solid import OpenSCADObject, Vec3 + +from euclid3 import Point2, Point3, Vector2, Vector3, Line2, Line3 +from euclid3 import LineSegment2, LineSegment3, Matrix4 + +from textwrap import indent + +run_euclid_patch() + +# ========== +# = TYPING = +# ========== +from typing import Any, Union, Sequence, Optional, Callable, cast # noqa + +Point23 = Union[Point2, Point3] +Vector23 = Union[Vector2, Vector3] +PointVec23 = Union[Point2, Point3, Vector2, Vector3] +Line23 = Union[Line2, Line3] +LineSegment23 = Union[LineSegment2, LineSegment3] + +Tuple2 = tuple[float, float] +Tuple3 = tuple[float, float, float] +EucOrTuple = Union[Point3, Vector3, Tuple2, Tuple3] +DirectionLR = float # LEFT or RIGHT in 2D + +# ============= +# = CONSTANTS = +# ============= -RIGHT, TOP, LEFT, BOTTOM = range(4) EPSILON = 0.01 -TAU = 2 * pi +RIGHT, TOP, LEFT, BOTTOM = range(4) X, Y, Z = (0, 1, 2) -ORIGIN = ( 0, 0, 0) -UP_VEC = ( 0, 0, 1) -RIGHT_VEC = ( 1, 0, 0) -FORWARD_VEC = ( 0, 1, 0) -DOWN_VEC = ( 0, 0,-1) -LEFT_VEC = (-1, 0, 0) -BACK_VEC = ( 0,-1, 0) +ORIGIN = (0, 0, 0) +UP_VEC = (0, 0, 1) +RIGHT_VEC = (1, 0, 0) +FORWARD_VEC = (0, 1, 0) +DOWN_VEC = (0, 0, -1) +LEFT_VEC = (-1, 0, 0) +BACK_VEC = (0, -1, 0) # ========== # = Colors = -# ========== +# ========== +# Deprecated, but kept for backwards compatibility . Note that OpenSCAD natively +# accepts SVG Color names, as seen here: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#color # From Hans Häggström's materials.scad in MCAD: https://github.com/openscad/MCAD -Red = (1, 0, 0) -Green = (0, 1, 0) -Blue = (0, 0, 1) -Cyan = (0, 1, 1) -Magenta = (1, 0, 1) -Yellow = (1, 1, 0) -Black = (0, 0, 0) -White = (1, 1, 1) -Oak = (0.65, 0.50, 0.40) -Pine = (0.85, 0.70, 0.45) -Birch = (0.90, 0.80, 0.60) -FiberBoard = (0.70, 0.67, 0.60) -BlackPaint = (0.20, 0.20, 0.20) -Iron = (0.36, 0.33, 0.33) -Steel = (0.65, 0.67, 0.72) -Stainless = (0.45, 0.43, 0.50) -Aluminum = (0.77, 0.77, 0.80) -Brass = (0.88, 0.78, 0.50) -Transparent = (1, 1, 1, 0.2) - -# ======================== -# = Degrees <==> Radians = -# ======================== - - -def degrees(x_radians): - return 360.0 * x_radians / TAU - - -def radians(x_degrees): - return x_degrees / 360.0 * TAU +Red = (1, 0, 0) +Green = (0, 1, 0) +Blue = (0, 0, 1) +Cyan = (0, 1, 1) +Magenta = (1, 0, 1) +Yellow = (1, 1, 0) +Black = (0, 0, 0) +White = (1, 1, 1) +Oak = (0.65, 0.50, 0.40) +Pine = (0.85, 0.70, 0.45) +Birch = (0.90, 0.80, 0.60) +FiberBoard = (0.70, 0.67, 0.60) +BlackPaint = (0.20, 0.20, 0.20) +Iron = (0.36, 0.33, 0.33) +Steel = (0.65, 0.67, 0.72) +Stainless = (0.45, 0.43, 0.50) +Aluminum = (0.77, 0.77, 0.80) +Brass = (0.88, 0.78, 0.50) +Transparent = (1, 1, 1, 0.2) # ============== # = Grid Plane = # ============== -def grid_plane(grid_unit=12, count=10, line_weight=0.1, plane='xz'): - +def grid_plane( + grid_unit: int = 12, count: int = 10, line_weight: float = 0.1, plane: str = "xz" +) -> OpenSCADObject: # Draws a grid of thin lines in the specified plane. Helpful for # reference during debugging. - l = count * grid_unit + ls = count * grid_unit t = union() - t.set_modifier('background') - for i in range(-count / 2, count / 2 + 1): - if 'xz' in plane: + t.set_modifier("background") + for i in range(int(-count / 2), int(count / 2 + 1)): + if "xz" in plane: # xz-plane - h = up(i * grid_unit)(cube([l, line_weight, line_weight], center=True)) - v = right(i * grid_unit)(cube([line_weight, line_weight, l], center=True)) + h = up(i * grid_unit)(cube([ls, line_weight, line_weight], center=True)) + v = right(i * grid_unit)(cube([line_weight, line_weight, ls], center=True)) t.add([h, v]) # xy plane - if 'xy' in plane: - h = forward(i * grid_unit)(cube([l, line_weight, line_weight], center=True)) - v = right(i * grid_unit)(cube([line_weight, l, line_weight], center=True)) + if "xy" in plane: + h = forward(i * grid_unit)( + cube([ls, line_weight, line_weight], center=True) + ) + v = right(i * grid_unit)(cube([line_weight, ls, line_weight], center=True)) t.add([h, v]) # yz plane - if 'yz' in plane: - h = up(i * grid_unit)(cube([line_weight, l, line_weight], center=True)) - v = forward(i * grid_unit)(cube([line_weight, line_weight, l], center=True)) + if "yz" in plane: + h = up(i * grid_unit)(cube([line_weight, ls, line_weight], center=True)) + v = forward(i * grid_unit)( + cube([line_weight, line_weight, ls], center=True) + ) t.add([h, v]) return t -def distribute_in_grid(objects, max_bounding_box, rows_and_cols=None): +def distribute_in_grid( + objects: Sequence[OpenSCADObject], + max_bounding_box: tuple[float, float], + rows_and_cols: tuple[int, int] = None, +) -> OpenSCADObject: # Translate each object in objects in a grid with each cell of size # max_bounding_box. # @@ -107,20 +133,14 @@ def distribute_in_grid(objects, max_bounding_box, rows_and_cols=None): # with objects spaced max_bounding_box apart if isinstance(max_bounding_box, (list, tuple)): x_trans, y_trans = max_bounding_box[0:2] - elif isinstance(max_bounding_box, (int, long, float, complex)): + elif isinstance(max_bounding_box, (int, float, complex)): x_trans = y_trans = max_bounding_box else: pass # TypeError - # If we only got passed one object, just return it - try: - l = len(objects) - except: - return objects - ret = [] if rows_and_cols: - grid_w, grid_h = rows_and_cols + grid_h, grid_w = rows_and_cols else: grid_w = grid_h = int(ceil(sqrt(len(objects)))) @@ -129,124 +149,130 @@ def distribute_in_grid(objects, max_bounding_box, rows_and_cols=None): for x in range(grid_w): if objs_placed < len(objects): ret.append( - translate([x * x_trans, y * y_trans])(objects[objs_placed])) + translate((x * x_trans, y * y_trans, 0))(objects[objs_placed]) + ) objs_placed += 1 else: break - return union()(ret) + return union()(*ret) + # ============== # = Directions = # ============== +def up(z: float) -> OpenSCADObject: + return translate((0, 0, z)) -def up(z): - return translate([0, 0, z]) +def down(z: float) -> OpenSCADObject: + return translate((0, 0, -z)) -def down(z): - return translate([0, 0, -z]) +def right(x: float) -> OpenSCADObject: + return translate((x, 0, 0)) -def right(x): - return translate([x, 0, 0]) +def left(x: float) -> OpenSCADObject: + return translate((-x, 0, 0)) -def left(x): - return translate([-x, 0, 0]) +def forward(y: float) -> OpenSCADObject: + return translate((0, y, 0)) -def forward(y): - return translate([0, y, 0]) - - -def back(y): - return translate([0, -y, 0]) +def back(y: float) -> OpenSCADObject: + return translate((0, -y, 0)) # =========================== # = Box-alignment rotations = # =========================== -def rot_z_to_up(obj): +def rot_z_to_up(obj: OpenSCADObject) -> OpenSCADObject: # NOTE: Null op return rotate(a=0, v=FORWARD_VEC)(obj) -def rot_z_to_down(obj): +def rot_z_to_down(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=180, v=FORWARD_VEC)(obj) -def rot_z_to_right(obj): +def rot_z_to_right(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=FORWARD_VEC)(obj) -def rot_z_to_left(obj): +def rot_z_to_left(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=FORWARD_VEC)(obj) -def rot_z_to_forward(obj): +def rot_z_to_forward(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=RIGHT_VEC)(obj) -def rot_z_to_back(obj): +def rot_z_to_back(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=RIGHT_VEC)(obj) # ================================ # = Box-aligment and translation = # ================================ -def box_align(obj, direction_func=up, distance=0): +def box_align( + obj: OpenSCADObject, + direction_func: Callable[[float], OpenSCADObject] = up, + distance: float = 0, +) -> OpenSCADObject: # Given a box side (up, left, etc) and a distance, # rotate obj (assumed to be facing up) in the # correct direction and move it distance in that # direction trans_and_rot = { - up: rot_z_to_up, # Null - down: rot_z_to_down, - right: rot_z_to_right, - left: rot_z_to_left, - forward: rot_z_to_forward, - back: rot_z_to_back, + up: rot_z_to_up, # Null + down: rot_z_to_down, + right: rot_z_to_right, + left: rot_z_to_left, + forward: rot_z_to_forward, + back: rot_z_to_back, } - assert(direction_func in trans_and_rot) + assert direction_func in trans_and_rot rot = trans_and_rot[direction_func] return direction_func(distance)(rot(obj)) + # ======================= # = 90-degree Rotations = # ======================= -def rot_z_to_x(obj): +def rot_z_to_x(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=FORWARD_VEC)(obj) -def rot_z_to_neg_x(obj): +def rot_z_to_neg_x(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=FORWARD_VEC)(obj) -def rot_z_to_neg_y(obj): +def rot_z_to_neg_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=RIGHT_VEC)(obj) -def rot_z_to_y(obj): +def rot_z_to_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=RIGHT_VEC)(obj) -def rot_x_to_y(obj): +def rot_x_to_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=UP_VEC)(obj) -def rot_x_to_neg_y(obj): +def rot_x_to_neg_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=UP_VEC)(obj) + # ======= # = Arc = # ======= - - -def arc(rad, start_degrees, end_degrees, segments=None): +def arc( + rad: float, start_degrees: float, end_degrees: float, segments: int = None +) -> OpenSCADObject: # Note: the circle that this arc is drawn from gets segments, # not the arc itself. That means a quarter-circle arc will # have segments/4 segments. @@ -261,21 +287,23 @@ def arc(rad, start_degrees, end_degrees, segments=None): ret = difference()( start_shape, rotate(a=start_degrees)(bottom_half_square.copy()), - rotate(a=end_angle)(bottom_half_square.copy()) + rotate(a=end_angle)(bottom_half_square.copy()), ) else: ret = intersection()( start_shape, union()( rotate(a=start_degrees)(top_half_square.copy()), - rotate(a=end_degrees)(bottom_half_square.copy()) - ) + rotate(a=end_degrees)(bottom_half_square.copy()), + ), ) return ret -def arc_inverted(rad, start_degrees, end_degrees, segments=None): +def arc_inverted( + rad: float, start_degrees: float, end_degrees: float, segments: int = None +) -> OpenSCADObject: # Return the segment of an arc *outside* the circle of radius rad, # bounded by two tangents to the circle. This is the shape # needed for fillets. @@ -310,15 +338,20 @@ def arc_inverted(rad, start_degrees, end_degrees, segments=None): # since if the two angles differ by more than 180 degrees, # the tangent lines don't converge if end_degrees - start_degrees == 180: - raise ValueError("Unable to draw inverted arc over 180 or more " - "degrees. start_degrees: %s end_degrees: %s" - % (start_degrees, end_degrees)) + raise ValueError( + "Unable to draw inverted arc over 180 or more " + "degrees. start_degrees: %s end_degrees: %s" % (start_degrees, end_degrees) + ) wide = 1000 high = 1000 - top_half_square = translate([-(wide - rad), 0])(square([wide, high], center=False)) - bottom_half_square = translate([-(wide - rad), -high])(square([wide, high], center=False)) + top_half_square = translate((-(wide - rad), 0, 0))( + square([wide, high], center=False) + ) + bottom_half_square = translate((-(wide - rad), -high, 0))( + square([wide, high], center=False) + ) a = rotate(start_degrees)(top_half_square) b = rotate(end_degrees)(bottom_half_square) @@ -327,15 +360,15 @@ def arc_inverted(rad, start_degrees, end_degrees, segments=None): return ret + # TODO: arc_to that creates an arc from point to another point. # This is useful for making paths. See the SVG path command: # See: http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + # ====================== # = Bounding Box Class = # ====================== - - class BoundingBox(object): # A basic Bounding Box representation to enable some more introspection about # objects. For instance, a BB will let us say "put this new object on top of @@ -347,40 +380,51 @@ class BoundingBox(object): # Basically you can use a BoundingBox to describe the extents of an object # the moment it's created, but once you perform any CSG operation on it, it's # more or less useless. - def __init__(self, size, loc=None): + def __init__(self, size: list[float], loc: list[float] = None): loc = loc if loc else [0, 0, 0] # self.w, self.h, self.d = size # self.x, self.y, self.z = loc self.set_size(size) self.set_position(loc) - def size(self): + def size(self) -> list[float]: return [self.w, self.h, self.d] - def position(self): + def position(self) -> list[float]: return [self.x, self.y, self.z] - def set_position(self, position): + def set_position(self, position: Sequence[float]): self.x, self.y, self.z = position - def set_size(self, size): + def set_size(self, size: Sequence[float]): self.w, self.h, self.d = size - def split_planar(self, cutting_plane_normal=RIGHT_VEC, cut_proportion=0.5, add_wall_thickness=0): - cpd = {RIGHT_VEC: 0, LEFT_VEC: 0, FORWARD_VEC: 1, - BACK_VEC: 1, UP_VEC: 2, DOWN_VEC: 2} + def split_planar( + self, + cutting_plane_normal: Vec3 = RIGHT_VEC, + cut_proportion: float = 0.5, + add_wall_thickness: float = 0, + ) -> list["BoundingBox"]: + cpd = { + RIGHT_VEC: 0, + LEFT_VEC: 0, + FORWARD_VEC: 1, + BACK_VEC: 1, + UP_VEC: 2, + DOWN_VEC: 2, + } cutting_plane = cpd.get(cutting_plane_normal, 2) # Figure what the cutting plane offset should be dim_center = self.position()[cutting_plane] dim = self.size()[cutting_plane] dim_min = dim_center - dim / 2 - dim_max = dim_center + dim / 2 - cut_point = (cut_proportion) * dim_min + (1 - cut_proportion) * dim_max + # dim_max = dim_center + dim / 2 + # cut_point = (cut_proportion) * dim_min + (1 - cut_proportion) * dim_max # Now create bounding boxes with the appropriate sizes part_bbs = [] - a_sum = 0 + a_sum = 0.0 for i, part in enumerate([cut_proportion, (1 - cut_proportion)]): part_size = self.size() part_size[cutting_plane] = part_size[cutting_plane] * part @@ -408,37 +452,30 @@ def split_planar(self, cutting_plane_normal=RIGHT_VEC, cut_proportion=0.5, add_w return part_bbs - def cube(self, larger=False): + def cube(self, larger: bool = False) -> OpenSCADObject: c_size = self.size() if not larger else [s + 2 * EPSILON for s in self.size()] - c = translate(self.position())( - cube(c_size, center=True) - ) + c = translate(self.position())(cube(c_size, center=True)) return c - def min(self, which_dim=None): - min_pt = [p - s / 2 for p, s in zip(self.position(), self.size())] - if which_dim: - return min_pt[which_dim] - else: - return min_pt - - def max(self, which_dim=None): - max_pt = [p + s / 2 for p, s in zip(self.position(), self.size())] - if which_dim: - return max_pt[which_dim] - else: - return max_pt - # =================== # = Model Splitting = # =================== -def split_body_planar(obj, obj_bb, cutting_plane_normal=UP_VEC, cut_proportion=0.5, dowel_holes=False, dowel_rad=4.5, hole_depth=15, add_wall_thickness=0): +def split_body_planar( + obj: OpenSCADObject, + obj_bb: BoundingBox, + cutting_plane_normal: Vec3 = UP_VEC, + cut_proportion: float = 0.5, + dowel_holes: bool = False, + dowel_rad: float = 4.5, + hole_depth: float = 15, + add_wall_thickness=0, +) -> tuple[OpenSCADObject, BoundingBox, OpenSCADObject, BoundingBox]: # Split obj along the specified plane, returning two pieces and # general bounding boxes for each. # Note that the bounding boxes are NOT accurate to the sections, # they just indicate which portion of the original BB is in each - # section. Given the limits of OpenSCAD, this is the best we can do + # section. Given the limits of OpenSCAD, this is the best we can do # -ETJ 17 Oct 2013 # Optionally, leave holes in both bodies to allow the pieces to be put @@ -446,7 +483,8 @@ def split_body_planar(obj, obj_bb, cutting_plane_normal=UP_VEC, cut_proportion=0 # Find the splitting bounding boxes part_bbs = obj_bb.split_planar( - cutting_plane_normal, cut_proportion, add_wall_thickness=add_wall_thickness) + cutting_plane_normal, cut_proportion, add_wall_thickness=add_wall_thickness + ) # And intersect the bounding boxes with the object itself slices = [obj * part_bb.cube() for part_bb in part_bbs] @@ -455,8 +493,14 @@ def split_body_planar(obj, obj_bb, cutting_plane_normal=UP_VEC, cut_proportion=0 # In case the bodies need to be aligned properly, make two holes, # separated by one dowel-width if dowel_holes: - cpd = {RIGHT_VEC: 0, LEFT_VEC: 0, FORWARD_VEC: 1, - BACK_VEC: 1, UP_VEC: 2, DOWN_VEC: 2} + cpd = { + RIGHT_VEC: 0, + LEFT_VEC: 0, + FORWARD_VEC: 1, + BACK_VEC: 1, + UP_VEC: 2, + DOWN_VEC: 2, + } cutting_plane = cpd.get(cutting_plane_normal, 2) dowel = cylinder(r=dowel_rad, h=hole_depth * 2, center=True) @@ -465,8 +509,10 @@ def split_body_planar(obj, obj_bb, cutting_plane_normal=UP_VEC, cut_proportion=0 rot_vec = RIGHT_VEC if cutting_plane == 1 else FORWARD_VEC dowel = rotate(a=90, v=rot_vec)(dowel) - cut_point = part_bbs[ - 0].position()[cutting_plane] + part_bbs[0].size()[cutting_plane] / 2 + cut_point = ( + part_bbs[0].position()[cutting_plane] + + part_bbs[0].size()[cutting_plane] / 2 + ) # Move dowels away from center of face by 2*dowel_rad in each # appropriate direction @@ -484,99 +530,182 @@ def split_body_planar(obj, obj_bb, cutting_plane_normal=UP_VEC, cut_proportion=0 # subtract dowels from each slice slices = [s - dowels for s in slices] - slices_and_bbs = [slices[0], part_bbs[0], slices[1], part_bbs[1]] + slices_and_bbs = (slices[0], part_bbs[0], slices[1], part_bbs[1]) return slices_and_bbs -def section_cut_xz(body, y_cut_point=0): +def section_cut_xz(body: OpenSCADObject, y_cut_point: float = 0) -> OpenSCADObject: big_w = 10000 d = 2 c = forward(d / 2 + y_cut_point)(cube([big_w, d, big_w], center=True)) return c * body + # ===================== # = Bill of Materials = # ===================== -# Any part defined in a method can be automatically counted using the -# @bom_part() decorator. After all parts have been created, call -# bill_of_materials() -# to generate a report. Se examples/bom_scad.py for usage -g_parts_dict = {} - - -def bom_part(description='', per_unit_price=None, currency='US$'): +# Any part defined in a method can be automatically counted using the +# `@bom_part()` decorator. After all parts have been created, call +# `bill_of_materials()` +# to generate a report. See `examples/bom_scad.py` for usage +# +# Additional columns can be added (such as leftover material or URL to part) +# by calling `set_bom_headers()` with a series of string arguments. +# +# Calling `bom_part()` with additional, non-keyworded arguments will +# populate the new columns in order of their addition via bom_headers, or +# keyworded arguments can be used in any order. + +g_bom_headers: list[str] = [] + + +def set_bom_headers(*args): + global g_bom_headers + g_bom_headers += args + + +def bom_part( + description: str = "", + per_unit_price: float = None, + currency: str = "US$", + *args, + **kwargs, +) -> Callable: def wrap(f): name = description if description else f.__name__ - g_parts_dict[name] = [0, currency, per_unit_price] - def wrapped_f(*args): - name = description if description else f.__name__ - g_parts_dict[name][0] += 1 - return f(*args) + elements = { + "name": name, + "Count": 0, + "currency": currency, + "Unit Price": per_unit_price, + } + # This update also adds empty key value pairs to prevent key exceptions. + elements.update(dict(zip_longest(g_bom_headers, args, fillvalue=""))) + elements.update(kwargs) + + def wrapped_f(*wargs, **wkwargs): + scad_obj = f(*wargs, **wkwargs) + scad_obj.add_trait("BOM", elements) + return scad_obj return wrapped_f return wrap -def bill_of_materials(): - res = '' - res += "%8s\t%8s\t%8s\t%8s\n" % ("Desc.", "Count", "Unit Price", "Total Price") - all_costs = {} - for desc, (count, currency, price) in g_parts_dict.items(): +def bill_of_materials(root_obj: OpenSCADObject, csv: bool = False) -> str: + traits_dicts = _traits_bom_dicts(root_obj) + # Build a single dictionary from the ones stored on each child object + # (This is an adaptation of an earlier version, and probably not the most + # direct way to accomplish this) + all_bom_traits = {} + for traits_dict in traits_dicts: + name = traits_dict["name"] + if name in all_bom_traits: + all_bom_traits[name]["Count"] += 1 + else: + all_bom_traits[name] = traits_dict + all_bom_traits[name]["Count"] = 1 + bom = _make_bom(all_bom_traits, csv) + return bom + + +def _traits_bom_dicts(root_obj: OpenSCADObject) -> list[dict[str, float]]: + all_child_traits = [_traits_bom_dicts(c) for c in root_obj.children] + child_traits = [item for subl in all_child_traits for item in subl if item] + bom_trait = root_obj.get_trait("BOM") + if bom_trait: + child_traits.append(bom_trait) + return child_traits + + +def _make_bom( + bom_parts_dict: dict[str, float], + csv: bool = False, +) -> str: + field_names = ["Description", "Count", "Unit Price", "Total Price"] + field_names += g_bom_headers + + rows = [] + + all_costs: dict[str, float] = {} + for desc, elements in bom_parts_dict.items(): + row = [] + count = elements["Count"] + currency = elements["currency"] + price = elements["Unit Price"] + if count > 0: if price: total = price * count - try: - all_costs[currency] += total - except: - all_costs[currency] = total + if currency not in all_costs: + all_costs[currency] = 0 - res += ("%8s\t%8d\t%s %8f\t%s %8.2f\n" - % (desc, count, currency, price, currency, total)) + all_costs[currency] += total + unit_price = _currency_str(price, currency) + total_price = _currency_str(total, currency) else: - res += "%8s\t%8d\n" % (desc, count) + unit_price = total_price = "" + row = [desc, count, unit_price, total_price] + + for key in g_bom_headers: + value = elements[key] + row.append(value) + rows.append(row) + + # Add total costs if we have values to add if len(all_costs) > 0: - res += "_" * 60 + '\n' - res += "Total Cost:\n" - for currency in all_costs.keys(): - res += "\t\t%s %.2f\n" % (currency, all_costs[currency]) - res += "\n" - return res + empty_row = [""] * len(field_names) + rows.append(empty_row) + for currency, cost in all_costs.items(): + row = empty_row[:] + row[0] = "Total Cost, {currency:>4}".format(**vars()) + row[3] = "{currency:>4} {cost:.2f}".format(**vars()) + rows.append(row) -# FIXME: finish this. -def bill_of_materials_justified(): - res = '' - columns = [s.rjust(8) - for s in ("Desc.", "Count", "Unit Price", "Total Price")] - all_costs = {} - for desc, (count, currency, price) in g_parts_dict.items(): - if count > 0: - if price: - total = price * count - try: - all_costs[currency] += total - except: - all_costs[currency] = total + res = _table_string(field_names, rows, csv) - res += "%(desc)s %(count)s %(currency)s %(price)s %(currency)s %(total)s \n" % vars() - else: - res += "%(desc)s %(count)s " % vars() - if all_costs > 0: - res += "_" * 60 + '\n' - res += "Total Cost:\n" - for currency in all_costs.keys(): - res += "\t\t%s %.2f\n" % (currency, all_costs[currency]) - res += "\n" return res + +def _currency_str(value: float, currency: str = "$") -> str: + return "{currency:>4} {value:.2f}".format(**vars()) + + +def _table_string( + field_names: Sequence[str], rows: Sequence[Sequence[float]], csv: bool = False +) -> str: + # Output a justified table string using the prettytable module. + # Fall back to Excel-ready tab-separated values if prettytable's not found + # or CSV is requested + if not csv: + try: + import prettytable + + table = prettytable.PrettyTable(field_names=field_names) + for row in rows: + table.add_row(row) + res = table.get_string() + except ImportError: + print("Unable to import prettytable module. Outputting in TSV format") + csv = True + if csv: + lines = ["\t".join(field_names)] + for row in rows: + line = "\t".join([str(f) for f in row]) + lines.append(line) + + res = "\n".join(lines) + + return res + "\n" + + # ================ # = Bounding Box = # ================ - - -def bounding_box(points): +def bounding_box(points: Sequence[EucOrTuple]) -> tuple[Tuple3, Tuple3]: all_x = [] all_y = [] all_z = [] @@ -584,520 +713,620 @@ def bounding_box(points): all_x.append(p[0]) all_y.append(p[1]) if len(p) > 2: - all_z.append(p[2]) + all_z.append(p[2]) # type:ignore else: all_z.append(0) - return [[min(all_x), min(all_y), min(all_z)], [max(all_x), max(all_y), max(all_z)]] + return ((min(all_x), min(all_y), min(all_z)), (max(all_x), max(all_y), max(all_z))) # ======================= # = Hardware dimensions = # ======================= screw_dimensions = { - 'm3': {'nut_thickness': 2.4, 'nut_inner_diam': 5.4, 'nut_outer_diam': 6.1, 'screw_outer_diam': 3.0, 'cap_diam': 5.5, 'cap_height': 3.0}, - 'm4': {'nut_thickness': 3.1, 'nut_inner_diam': 7.0, 'nut_outer_diam': 7.9, 'screw_outer_diam': 4.0, 'cap_diam': 6.9, 'cap_height': 3.9}, - 'm5': {'nut_thickness': 4.7, 'nut_inner_diam': 7.9, 'nut_outer_diam': 8.8, 'screw_outer_diam': 5.0, 'cap_diam': 8.7, 'cap_height': 5}, - + "m3": { + "nut_thickness": 2.4, + "nut_inner_diam": 5.4, + "nut_outer_diam": 6.1, + "screw_outer_diam": 3.0, + "cap_diam": 5.5, + "cap_height": 3.0, + }, + "m4": { + "nut_thickness": 3.1, + "nut_inner_diam": 7.0, + "nut_outer_diam": 7.9, + "screw_outer_diam": 4.0, + "cap_diam": 6.9, + "cap_height": 3.9, + }, + "m5": { + "nut_thickness": 4.7, + "nut_inner_diam": 7.9, + "nut_outer_diam": 8.8, + "screw_outer_diam": 5.0, + "cap_diam": 8.7, + "cap_height": 5, + }, +} +bearing_dimensions = { + "608": {"inner_d": 8, "outer_d": 22, "thickness": 7}, + "688": {"inner_d": 8, "outer_d": 16, "thickness": 5}, + "686": {"inner_d": 6, "outer_d": 13, "thickness": 5}, + "626": {"inner_d": 6, "outer_d": 19, "thickness": 6}, + "625": {"inner_d": 5, "outer_d": 16, "thickness": 5}, + "624": {"inner_d": 4, "outer_d": 13, "thickness": 5}, + "623": {"inner_d": 3, "outer_d": 10, "thickness": 4}, + "603": {"inner_d": 3, "outer_d": 9, "thickness": 5}, + "633": {"inner_d": 3, "outer_d": 13, "thickness": 5}, } -def screw(screw_type='m3', screw_length=16): +def screw(screw_type: str = "m3", screw_length: float = 16) -> OpenSCADObject: dims = screw_dimensions[screw_type.lower()] - shaft_rad = dims['screw_outer_diam'] / 2 - cap_rad = dims['cap_diam'] / 2 - cap_height = dims['cap_height'] + shaft_rad = dims["screw_outer_diam"] / 2 + cap_rad = dims["cap_diam"] / 2 + cap_height = dims["cap_height"] ret = union()( - cylinder(shaft_rad, screw_length), - up(screw_length)( - cylinder(cap_rad, cap_height) - ) + cylinder(shaft_rad, screw_length + EPSILON), + up(screw_length)(cylinder(cap_rad, cap_height)), ) return ret -def nut(screw_type='m3'): +def nut(screw_type: str = "m3") -> OpenSCADObject: dims = screw_dimensions[screw_type.lower()] - outer_rad = dims['nut_outer_diam'] - inner_rad = dims['screw_outer_diam'] + outer_rad = dims["nut_outer_diam"] + inner_rad = dims["screw_outer_diam"] - ret = difference()( - circle(outer_rad, segments=6), - circle(inner_rad) - ) + ret = difference()(circle(outer_rad, segments=6), circle(inner_rad)) return ret +def bearing(bearing_type: str = "624") -> OpenSCADObject: + dims = bearing_dimensions[bearing_type.lower()] + outerR = dims["outer_d"] / 2 + innerR = dims["inner_d"] / 2 + thickness = dims["thickness"] + bearing = cylinder(outerR, thickness) + bearing.add_param("$fs", 1) + hole = cylinder(innerR, thickness + 2) + hole.add_param("$fs", 1) + bearing = difference()(bearing, translate([0, 0, -1])(hole)) + return bearing + + +# ========= +# = LABEL = +# ========= +def label( + a_str: str, + width: float = 15, + halign: str = "left", + valign: str = "baseline", + size: int = 10, + depth: float = 0.5, + lineSpacing: float = 1.15, + font: str = "MgOpen Modata:style=Bold", + segments: int = 40, + spacing: int = 1, +) -> OpenSCADObject: + """Renders a multi-line string into a single 3D object. + + __author__ = 'NerdFever.com' + __copyright__ = 'Copyright 2018-2019 NerdFever.com' + __version__ = '' + __email__ = 'dave@nerdfever.com' + __status__ = 'Development' + __license__ = Copyright 2018-2019 NerdFever.com + """ + + lines = a_str.splitlines() + + texts = [] + + for idx, line in enumerate(lines): + t = text( + text=line, halign=halign, valign=valign, font=font, spacing=spacing + ).add_param("$fn", segments) + t = linear_extrude(height=1)(t) + t = translate([0, -size * idx * lineSpacing, 0])(t) + + texts.append(t) + + result = union()(texts) + result = resize([width, 0, depth])(result) + result = translate([0, (len(lines) - 1) * size / 2, 0])(result) + + return result + + # ================== # = PyEuclid Utils = -# = -------------- = -try: - import euclid - from euclid import * - # NOTE: The PyEuclid on PyPi doesn't include several elements added to - # the module as of 13 Feb 2013. Add them here until euclid supports them - # TODO: when euclid updates, remove this cruft. -ETJ 13 Feb 2013 - import solid.patch_euclid - solid.patch_euclid.run_patch() - - def euclidify(an_obj, intended_class=Vector3): - # If an_obj is an instance of the appropriate PyEuclid class, - # return it. Otherwise, try to turn an_obj into the appropriate - # class and throw an exception on failure - - # Since we often want to convert an entire array - # of objects (points, etc.) accept arrays of arrays - - ret = an_obj - - # See if this is an array of arrays. If so, convert all sublists - if isinstance(an_obj, (list, tuple)): - if isinstance(an_obj[0], (list, tuple)): - ret = [intended_class(*p) for p in an_obj] - elif isinstance(an_obj[0], intended_class): - # this array is already euclidified; return it - ret = an_obj - else: - try: - ret = intended_class(*an_obj) - except: - raise TypeError("Object: %s ought to be PyEuclid class %s or " - "able to form one, but is not." - % (an_obj, intended_class.__name__)) - elif not isinstance(an_obj, intended_class): - try: - ret = intended_class(*an_obj) - except: - raise TypeError("Object: %s ought to be PyEuclid class %s or " - "able to form one, but is not." - % (an_obj, intended_class.__name__)) - return ret - - def euc_to_arr(euc_obj_or_list): # Inverse of euclidify() - # Call as_arr on euc_obj_or_list or on all its members if it's a list - if hasattr(euc_obj_or_list, "as_arr"): - return euc_obj_or_list.as_arr() - elif isinstance(euc_obj_or_list, (list, tuple)) and hasattr(euc_obj_or_list[0], 'as_arr'): - return [euc_to_arr(p) for p in euc_obj_or_list] - else: - # euc_obj_or_list is neither an array-based PyEuclid object, - # nor a list of them. Assume it's a list of points or vectors, - # and return the list unchanged. We could be wrong about this, - # though. - return euc_obj_or_list - - def is_scad(obj): - return isinstance(obj, OpenSCADObject) - - def scad_matrix(euclid_matrix4): - a = euclid_matrix4 - return [[a.a, a.b, a.c, a.d], - [a.e, a.f, a.g, a.h], - [a.i, a.j, a.k, a.l], - [a.m, a.n, a.o, a.p] - ] - - # ============== - # = Transforms = - # ============== - def transform_to_point(body, dest_point, dest_normal, src_point=Point3(0, 0, 0), src_normal=Vector3(0, 1, 0), src_up=Vector3(0, 0, 1)): - # Transform body to dest_point, looking at dest_normal. - # Orientation & offset can be changed by supplying the src arguments - - # Body may be: - # -- an openSCAD object - # -- a list of 3-tuples or PyEuclid Point3s - # -- a single 3-tuple or Point3 - dest_point = euclidify(dest_point, Point3) - dest_normal = euclidify(dest_normal, Vector3) - at = dest_point + dest_normal - - EUC_UP = euclidify(UP_VEC) - EUC_FORWARD = euclidify(FORWARD_VEC) - EUC_ORIGIN = euclidify(ORIGIN, Vector3) - # if dest_normal and src_up are parallel, the transform collapses - # all points to dest_point. Instead, use EUC_FORWARD if needed - if dest_normal.cross(src_up) == EUC_ORIGIN: - if src_up.cross(EUC_UP) == EUC_ORIGIN: - src_up = EUC_FORWARD - else: - src_up = EUC_UP +# ================== +def euclidify( + an_obj: EucOrTuple, intended_class: type = Vector3 +) -> Union[Point23, Vector23, list[Union[Point23, Vector23]]]: + """ + Accept an object or list of objects of any relevant type (2-tuples, 3-tuples, Vector2/3, Point2/3) + and return one or more euclid3 objects of intended_class. - look_at_matrix = Matrix4.new_look_at(eye=dest_point, at=at, up=src_up) + # -- 3D input has its z-values dropped when intended_class is 2D + # -- 2D input has its z-values set to 0 when intended_class is 3D + + The general idea is to take in data in whatever form is handy to users + and return euclid3 types with vector math capabilities + """ + sequence = (list, tuple) + euclidable = (list, tuple, Vector2, Vector3, Point2, Point3) + # numeric = (int, float) + # If this is a list of lists, return a list of euclid objects + if isinstance(an_obj, sequence) and isinstance(an_obj[0], euclidable): + return list((_euc_obj(ao, intended_class) for ao in an_obj)) + elif isinstance(an_obj, euclidable): + return _euc_obj(an_obj, intended_class) + else: + raise TypeError(f"""Object: {an_obj} ought to be PyEuclid class + {intended_class.__name__} or able to form one, but is not.""") + + +def _euc_obj(an_obj: Any, intended_class: type = Vector3) -> Union[Point23, Vector23]: + """Take a single object (not a list of them!) and return a euclid type + # If given a euclid obj, return the desired type, + # -- 3d types are projected to z=0 when intended_class is 2D + # -- 2D types are projected to z=0 when intended class is 3D + _euc_obj( Vector3(0,1,2), Vector3) -> Vector3(0,1,2) + _euc_obj( Vector3(0,1,2), Point3) -> Point3(0,1,2) + _euc_obj( Vector2(0,1), Vector3) -> Vector3(0,1,0) + _euc_obj( Vector2(0,1), Point3) -> Point3(0,1,0) + _euc_obj( (0,1), Vector3) -> Vector3(0,1,0) + _euc_obj( (0,1), Point3) -> Point3(0,1,0) + _euc_obj( (0,1), Point2) -> Point2(0,1,0) + _euc_obj( (0,1,2), Point2) -> Point2(0,1) + _euc_obj( (0,1,2), Point3) -> Point3(0,1,2) + """ + elts_in_constructor = 3 + if intended_class in (Point2, Vector2): + elts_in_constructor = 2 + result = intended_class(*an_obj[:elts_in_constructor]) + return result + + +def euc_to_arr(euc_obj_or_list: EucOrTuple) -> list[float]: # Inverse of euclidify() + # Call as_arr on euc_obj_or_list or on all its members if it's a list + result: list[float] = [] + + if hasattr(euc_obj_or_list, "as_arr"): + result = euc_obj_or_list.as_arr() # type: ignore + elif isinstance(euc_obj_or_list, (list, tuple)) and hasattr( + euc_obj_or_list[0], "as_arr" + ): + result = [euc_to_arr(p) for p in euc_obj_or_list] # type: ignore + else: + # euc_obj_or_list is neither an array-based PyEuclid object, + # nor a list of them. Assume it's a list of points or vectors, + # and return the list unchanged. We could be wrong about this, + # though. + result = euc_obj_or_list # type: ignore + return result - if is_scad(body): - # If the body being altered is a SCAD object, do the matrix mult - # in OpenSCAD - sc_matrix = scad_matrix(look_at_matrix) - res = multmatrix(m=sc_matrix)(body) - else: - body = euclidify(body, Point3) - if isinstance(body, (list, tuple)): - res = [look_at_matrix * p for p in body] - else: - res = look_at_matrix * body - return res - - # ======================================== - # = Vector drawing: 3D arrow from a line = - # = -------------- ======================= - def draw_segment(euc_line=None, endless=False, arrow_rad=7, vec_color=None): - # Draw a tradtional arrow-head vector in 3-space. - vec_arrow_rad = arrow_rad - vec_arrow_head_rad = vec_arrow_rad * 1.5 - vec_arrow_head_length = vec_arrow_rad * 3 - - if isinstance(euc_line, Vector3): - p = Point3(*ORIGIN) - v = euc_line - elif isinstance(euc_line, Line3): - p = euc_line.p - v = -euc_line.v - elif isinstance(euc_line, list) or isinstance(euc_line, tuple): - # TODO: This assumes p & v are PyEuclid classes. - # Really, they could as easily be two 3-tuples. Should - # check for this. - p, v = euc_line[0], euc_line[1] - - shaft_length = v.magnitude() - vec_arrow_head_length - arrow = cylinder(r=vec_arrow_rad, h=shaft_length) - arrow += up(shaft_length)( - cylinder(r1=vec_arrow_head_rad, r2=0, h=vec_arrow_head_length) - ) - if endless: - endless_length = max(v.magnitude() * 10, 200) - arrow += cylinder(r=vec_arrow_rad / 3, - h=endless_length, center=True) - - arrow = transform_to_point(body=arrow, dest_point=p, dest_normal=v) - - if vec_color: - arrow = color(vec_color)(arrow) - - return arrow - - # ========== - # = Offset = - # = ------ = - LEFT, RIGHT = radians(90), radians(-90) - - def offset_polygon(point_arr, offset, inside=True, closed_poly=True): - # returns a closed solidPython polygon offset by offset distance - # from the polygon described by point_arr. - op = offset_points(point_arr, offset=offset, inside=inside, closed_poly=closed_poly) - return polygon(euc_to_arr(op)) - - def offset_points(point_arr, offset, inside=True, closed_poly=True): - # Given a set of points, return a set of points offset from - # them. - # To get reasonable results, the points need to be all in a plane. - # (Non-planar point_arr will still return results, but what constitutes - # 'inside' or 'outside' would be different in that situation.) - # - # What direction inside and outside lie in is determined by the first - # three points (first corner). In a convex closed shape, this corresponds - # to inside and outside. If the first three points describe a concave - # portion of a closed shape, inside and outside will be switched. - # - # Basically this means that if you're offsetting a complicated shape, - # you'll likely have to try both directions (inside=True/False) to - # figure out which direction you're offsetting to. - # - # CAD programs generally require an interactive user choice about which - # side is outside and which is inside. Robust behavior with this - # function will require similar checking. - - # Also note that short segments or narrow areas can cause problems - # as well. This method suffices for most planar convex figures where - # segment length is greater than offset, but changing any of those - # assumptions will cause unattractive results. If you want real - # offsets, use SolidWorks. - - # TODO: check for self-intersections in the line connecting the - # offset points, and remove them. - - # Using the first three points in point_arr, figure out which direction - # is inside and what plane to put the points in - point_arr = euclidify(point_arr[:], Point3) - in_dir = _inside_direction(*point_arr[0:3]) - normal = _three_point_normal(*point_arr[0:3]) - direction = in_dir if inside else _other_dir(in_dir) - - # Generate offset points for the correct direction - # for all of point_arr. - segs = [] - offset_pts = [] - point_arr += point_arr[0:2] # Add first two points to the end as well - if closed_poly: - for i in range(len(point_arr) - 1): - a, b = point_arr[i:i + 2] - par_seg = _parallel_seg(a, b, normal=normal, offset=offset, direction=direction) - segs.append(par_seg) - if len(segs) > 1: - int_pt = segs[-2].intersect(segs[-1]) - if int_pt: - offset_pts.append(int_pt) - - # When calculating based on a closed curve, we can't find the - # first offset point until all others have been calculated. - # Now that we've done so, put the last point back to first place - last = offset_pts[-1] - offset_pts.insert(0, last) - del(offset_pts[-1]) +def project_to_2D(euc_obj: Union[Point23, Vector23]) -> Union[Vector2, Point2]: + """ + Given a Point3/Vector3, return a Point2/Vector2 ignoring the original Z coordinate + """ + result: Union[Vector2, Point2] = None + if isinstance(euc_obj, (Point2, Vector2)): + result = euc_obj + elif isinstance(euc_obj, Point3): + result = Point2(euc_obj.x, euc_obj.y) + elif isinstance(euc_obj, Vector3): + result = Vector2(euc_obj.x, euc_obj.y) + else: + raise ValueError(f"Can't transform object {euc_obj} to a Point2 or Vector2") + + return result + + +def is_scad(obj: OpenSCADObject) -> bool: + return isinstance(obj, OpenSCADObject) + + +def scad_matrix(euclid_matrix4): + a = euclid_matrix4 + return [ + [a.a, a.b, a.c, a.d], + [a.e, a.f, a.g, a.h], + [a.i, a.j, a.k, a.l], + [a.m, a.n, a.o, a.p], + ] + + +def centroid(points: Sequence[PointVec23]) -> PointVec23: + if not points: + raise ValueError("centroid(): argument `points` is empty") + first = points[0] + is_3d = isinstance(first, (Vector3, Point3)) + if is_3d: + total = Vector3(0, 0, 0) + else: + total = Vector2(0, 0) + + for p in points: + total += p + total /= len(points) + + if isinstance(first, Point2): + return Point2(*total) + elif isinstance(first, Point3): + return Point3(*total) + else: + return total + + +# ============== +# = Transforms = +# ============== +def transform_to_point( + body: OpenSCADObject, + dest_point: Point3, + dest_normal: Vector3, + src_point: Point3 = Point3(0, 0, 0), + src_normal: Vector3 = Vector3(0, 1, 0), + src_up: Vector3 = Vector3(0, 0, 1), +) -> OpenSCADObject: + # Transform body to dest_point, looking at dest_normal. + # Orientation & offset can be changed by supplying the src arguments + + # Body may be: + # -- an openSCAD object + # -- a list of 3-tuples or PyEuclid Point3s + # -- a single 3-tuple or Point3 + dest_point = euclidify(dest_point, Point3) + dest_normal = euclidify(dest_normal, Vector3) + at = dest_point + dest_normal + + EUC_UP = euclidify(UP_VEC) + EUC_FORWARD = euclidify(FORWARD_VEC) + EUC_ORIGIN = euclidify(ORIGIN, Vector3) + # if dest_normal and src_up are parallel, the transform collapses + # all points to dest_point. Instead, use EUC_FORWARD if needed + if dest_normal.cross(src_up) == EUC_ORIGIN: + if src_up.cross(EUC_UP) == EUC_ORIGIN: + src_up = EUC_FORWARD else: - for i in range(len(point_arr) - 2): - a, b = point_arr[i:i + 2] - par_seg = _parallel_seg(a, b, normal=normal, offset=offset, direction=direction) - segs.append(par_seg) - # In an open poly, first and last points will be parallel - # to the first and last segments, not intersecting other segs - if i == 0: - offset_pts.append(par_seg.p1) - elif i == len(point_arr) - 3: - offset_pts.append(segs[-2].p2) - else: - int_pt = segs[-2].intersect(segs[-1]) - if int_pt: - offset_pts.append(int_pt) - - return offset_pts - - # ================== - # = Offset helpers = - # ================== - def _parallel_seg(p, q, offset, normal=Vector3(0, 0, 1), direction=LEFT): - # returns a PyEuclid Line3 parallel to pq, in the plane determined - # by p,normal, to the left or right of pq. - v = q - p - angle = direction - - rot_v = v.rotate_around(axis=normal, theta=angle) - rot_v.set_length(offset) - return Line3(p + rot_v, v) - - def _inside_direction(a, b, c, offset=10): - # determines which direction (LEFT, RIGHT) is 'inside' the triangle - # made by a, b, c. If ab and bc are parallel, return LEFT - x = _three_point_normal(a, b, c) - - # Make two vectors (left & right) for each segment. - l_segs = [_parallel_seg(p, q, normal=x, offset=offset, direction=LEFT) for p, q in ((a, b), (b, c))] - r_segs = [_parallel_seg(p, q, normal=x, offset=offset, direction=RIGHT) for p, q in ((a, b), (b, c))] - - # Find their intersections. - p1 = l_segs[0].intersect(l_segs[1]) - p2 = r_segs[0].intersect(r_segs[1]) - - # The only way I've figured out to determine which direction is - # 'inside' or 'outside' a joint is to calculate both inner and outer - # vectors and then to find the intersection point closest to point a. - # This ought to work but it seems like there ought to be a more direct - # way to figure this out. -ETJ 21 Dec 2012 - - # The point that's closer to point a is the inside point. - if a.distance(p1) <= a.distance(p2): - return LEFT + src_up = EUC_UP + + def _orig_euclid_look_at(eye, at, up): + """ + Taken from the original source of PyEuclid's Matrix4.new_look_at() + prior to 1184a07d119a62fc40b2c6becdbeaf053a699047 (11 Jan 2015), + as discussed here: + https://github.com/ezag/pyeuclid/commit/1184a07d119a62fc40b2c6becdbeaf053a699047 + + We were dependent on the old behavior, which is duplicated here: + """ + z = (eye - at).normalized() + x = up.cross(z).normalized() + y = z.cross(x) + + m = Matrix4.new_rotate_triple_axis(x, y, z) + m.d, m.h, m.l = eye.x, eye.y, eye.z + return m + + look_at_matrix = _orig_euclid_look_at(eye=dest_point, at=at, up=src_up) + + if is_scad(body): + # If the body being altered is a SCAD object, do the matrix mult + # in OpenSCAD + sc_matrix = scad_matrix(look_at_matrix) + res = multmatrix(m=sc_matrix)(body) + else: + body = euclidify(body, Point3) + if isinstance(body, (list, tuple)): + res = [look_at_matrix * p for p in body] else: - return RIGHT + res = look_at_matrix * body + return res - def _other_dir(left_or_right): - if left_or_right == LEFT: - return RIGHT - else: - return LEFT - - def _three_point_normal(a, b, c): - ab = b - a - bc = c - b - - seg_ab = Line3(a, ab) - seg_bc = Line3(b, bc) - x = seg_ab.v.cross(seg_bc.v) - return x - - # ============= - # = 2D Fillet = - # ============= - def _widen_angle_for_fillet(start_degrees, end_degrees): - # Fix start/end degrees as needed; find a way to make an acute angle - if end_degrees < start_degrees: - end_degrees += 360 - - if end_degrees - start_degrees >= 180: - start_degrees, end_degrees = end_degrees, start_degrees - - epsilon_degrees = 2 - return start_degrees - epsilon_degrees, end_degrees + epsilon_degrees - - def fillet_2d(three_point_sets, orig_poly, fillet_rad, remove_material=True): - # NOTE: three_point_sets must be a list of sets of three points - # (i.e., a list of 3-tuples of points), even if only one fillet is being done: - # e.g. [[a, b, c]] - # a, b, and c are three points that form a corner at b. - # Return a negative arc (the area NOT covered by a circle) of radius rad - # in the direction of the more acute angle between - - # Note that if rad is greater than a.distance(b) or c.distance(b), for a - # 90-degree corner, the returned shape will include a jagged edge. - - # TODO: use fillet_rad = min(fillet_rad, a.distance(b), c.distance(b)) - - # If a shape is being filleted in several places, it is FAR faster - # to add/ remove its set of shapes all at once rather than - # to cycle through all the points, since each method call requires - # a relatively complex boolean with the original polygon. - # So... three_point_sets is either a list of three Euclid points that - # determine the corner to be filleted, OR, a list of those lists, in - # which case everything will be removed / added at once. - # NOTE that if material is being added (fillets) or removed (rounds) - # each must be called separately. - - if len(three_point_sets) == 3 and isinstance(three_point_sets[0], (Vector2, Vector3)): - three_point_sets = [three_point_sets] - - arc_objs = [] - for three_points in three_point_sets: - - assert len(three_points) in (2, 3) - # make two vectors out of the three points passed in - a, b, c = euclidify(three_points, Point3) - - # Find the center of the arc we'll have to make - offset = offset_points([a, b, c], offset=fillet_rad, inside=True) - center_pt = offset[1] - - a2, b2, c2, cp2 = [Point2(p.x, p.y) for p in (a, b, c, center_pt)] - - a2b2 = LineSegment2(a2, b2) - c2b2 = LineSegment2(c2, b2) - - # Find the point on each segment where the arc starts; Point2.connect() - # returns a segment with two points; Take the one that's not the - # center - afs = cp2.connect(a2b2) - cfs = cp2.connect(c2b2) - - afp, cfp = [ - seg.p1 if seg.p1 != cp2 else seg.p2 for seg in (afs, cfs)] - - a_degs, c_degs = [ - (degrees(math.atan2(seg.v.y, seg.v.x))) % 360 for seg in (afs, cfs)] - - start_degs = a_degs - end_degs = c_degs - - # Widen start_degs and end_degs slightly so they overlap the areas - # they're supposed to join/ remove. - start_degs, end_degs = _widen_angle_for_fillet(start_degs, end_degs) - - arc_obj = translate(center_pt.as_arr())( - arc_inverted( - rad=fillet_rad, start_degrees=start_degs, end_degrees=end_degs) + +# ======================================== +# = Vector drawing: 3D arrow from a line = +# ======================================== +def draw_segment( + euc_line: Union[Vector3, Line3] = None, + endless: bool = False, + arrow_rad: float = 7, + vec_color: Union[str, Tuple3] = None, +) -> OpenSCADObject: + # Draw a traditional arrow-head vector in 3-space. + vec_arrow_rad = arrow_rad + vec_arrow_head_rad = vec_arrow_rad * 1.5 + vec_arrow_head_length = vec_arrow_rad * 3 + + if isinstance(euc_line, Vector3): + p = Point3(*ORIGIN) + v = euc_line + elif isinstance(euc_line, Line3): + p = euc_line.p + v = -euc_line.v + elif isinstance(euc_line, list) or isinstance(euc_line, tuple): + # TODO: This assumes p & v are PyEuclid classes. + # Really, they could as easily be two 3-tuples. Should + # check for this. + p, v = euc_line[0], euc_line[1] + + shaft_length = v.magnitude() - vec_arrow_head_length + arrow = cylinder(r=vec_arrow_rad, h=shaft_length) + arrow += up(shaft_length)( + cylinder(r1=vec_arrow_head_rad, r2=0, h=vec_arrow_head_length) + ) + if endless: + endless_length = max(v.magnitude() * 10, 200) + arrow += cylinder(r=vec_arrow_rad / 3, h=endless_length, center=True) + + arrow = transform_to_point(body=arrow, dest_point=p, dest_normal=v) + + if vec_color: + arrow = color(vec_color)(arrow) + + return arrow + + +# ========== +# = Offset = +# ========== +# TODO: Make a NamedTuple for LEFT_DIR and RIGHT_DIR +LEFT_DIR, RIGHT_DIR = 1, 2 + + +def offset_points( + points: Sequence[Point23], offset: float, internal: bool = True, closed=True +) -> list[Point2]: + """ + Given a set of points, return a set of points offset by `offset`, in the + direction specified by `internal`. + + NOTE: OpenSCAD has the native `offset()` function that generates offset + polygons nicely as well as doing fillets & rounds. If you just need a shape, + prefer using the native `offset()`. If you need the actual points for some + purpose, use this function. + See: https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Transformations#offset + + # NOTE: We accept Point2s or Point3s, but ignore all Z values and return Point2s + + What is internal or external is defined by by the direction of curvature + between the first and second points; for non-convex shapes, we will return + an incorrect (internal points are all external, or vice versa) if the first + segment pair is concave. This could be mitigated with a point_is_in_polygon() + function, but I haven't written that yet. + """ + # Note that we could just call offset_point() repeatedly, but we'd do + # a lot of repeated calculations that way + src_points = euclidify(points, Point2) + if closed: + src_points.append(src_points[0]) + + vecs = vectors_between_points(src_points) + direction = direction_of_bend(*src_points[:3]) + if not internal: + direction = opposite_direction(direction) + + perp_vecs = list( + (perpendicular_vector(v, direction=direction, length=offset) for v in vecs) + ) + + lines: list[Line2] = [] + for perp, a, b in zip(perp_vecs, src_points[:-1], src_points[1:]): + lines.append(Line2(a + perp, b + perp)) + + intersections = list((a.intersect(b) for a, b in zip(lines[:-1], lines[1:]))) + if closed: + # First point is determined by intersection of first and last lines + intersections.insert(0, lines[0].intersect(lines[-1])) + else: + # otherwise use first and last points in lines + intersections.insert(0, lines[0].p) + intersections.append(lines[-1].p + lines[-1].v) + return intersections + + +def offset_point( + a: Point2, b: Point2, c: Point2, offset: float, direction: DirectionLR = LEFT_DIR +) -> Point2: + ab_perp = perpendicular_vector(b - a, direction, length=offset) + bc_perp = perpendicular_vector(c - b, direction, length=offset) + + ab_par = Line2(a + ab_perp, b + ab_perp) + bc_par = Line2(b + bc_perp, c + bc_perp) + result = ab_par.intersect(bc_par) + return result + + +# ================== +# = Offset helpers = +# ================== + + +def pairwise_zip(ls: Sequence) -> zip: # type:ignore + return zip(ls[:-1], ls[1:]) + + +def cross_2d(a: Vector2, b: Vector2) -> float: + """ + scalar value; tells direction of rotation from a to b; + see direction_of_bend() + # See http://www.allenchou.net/2013/07/cross-product-of-2d-vectors/ + """ + return a.x * b.y - a.y * b.x + + +def direction_of_bend(a: Point2, b: Point2, c: Point2) -> DirectionLR: + """ + Return LEFT_DIR if angle abc is a turn to the left, otherwise RIGHT_DIR + Returns RIGHT_DIR if ab and bc are colinear + """ + direction = LEFT_DIR if cross_2d(b - a, c - b) > 0 else RIGHT_DIR + return direction + + +def opposite_direction(direction: DirectionLR) -> DirectionLR: + return LEFT_DIR if direction == RIGHT_DIR else RIGHT_DIR + + +def perpendicular_vector( + v: Vector2, direction: DirectionLR = RIGHT_DIR, length: float = None +) -> Vector2: + perp_vec = Vector2(v.y, -v.x) + result = perp_vec if direction == RIGHT_DIR else -perp_vec + if length is not None: + result.set_length(length) + return result + + +def vectors_between_points(points: Sequence[Point23]) -> list[Vector23]: + """ + Return a list of the vectors from each point in points to the point that follows + """ + vecs = list((b - a for a, b in pairwise_zip(points))) # type:ignore + return vecs + + +# ============= +# = 2D Fillet = +# ============= + + +def fillet_2d( + three_point_sets: Sequence[tuple[Point23, Point23, Point23]], + orig_poly: OpenSCADObject, + fillet_rad: float, + remove_material: bool = True, +) -> OpenSCADObject: + """ + Return a polygon with arcs of radius `fillet_rad` added/removed (according to + `remove_material`) to corners specified in `three_point_sets`. + + e.g. Turn a sharp external corner to a rounded one, or add material + to a sharp interior corner to smooth it out. + """ + arc_objs: list[OpenSCADObject] = [] + # TODO: accept Point3s, and project them all to z==0 + for three_points in three_point_sets: + a, b, c = (project_to_2D(p) for p in three_points) + ab = a - b + bc = b - c + + direction = direction_of_bend(a, b, c) + + # center lies at the intersection of two lines parallel to + # ab and bc, respectively, each offset from their respective + # line by fillet_rad + ab_perp = perpendicular_vector(ab, direction, length=fillet_rad) + bc_perp = perpendicular_vector(bc, direction, length=fillet_rad) + center = offset_point(a, b, c, offset=fillet_rad, direction=direction) + # start_pt = center + ab_perp + # end_pt = center + bc_perp + + start_degrees = degrees(atan2(ab_perp.y, ab_perp.x)) + end_degrees = degrees(atan2(bc_perp.y, bc_perp.x)) + + # Widen start_degrees and end_degrees slightly so they overlap the areas + # they're supposed to join/ remove. + start_degrees, end_degrees = _widen_angle_for_fillet(start_degrees, end_degrees) + + arc_obj = translate(center.as_arr())( + arc_inverted( + rad=fillet_rad, start_degrees=start_degrees, end_degrees=end_degrees ) + ) + arc_objs.append(arc_obj) - arc_objs.append(arc_obj) + if remove_material: + poly = orig_poly - arc_objs + else: + poly = orig_poly + arc_objs - if remove_material: - poly = orig_poly - arc_objs - else: - poly = orig_poly + arc_objs - - return poly - - # ========================== - # = Extrusion along a path = - # = ---------------------- = - # Possible: twist - def extrude_along_path(shape_pts, path_pts, scale_factors=None): - # Extrude the convex curve defined by shape_pts along path_pts. - # -- For predictable results, shape_pts must be planar, convex, and lie - # in the XY plane centered around the origin. - # - # -- len(scale_factors) should equal len(path_pts). If not present, scale - # will be assumed to be 1.0 for each point in path_pts - # -- Future additions might include corner styles (sharp, flattened, round) - # or a twist factor - polyhedron_pts = [] - facet_indices = [] - - if not scale_factors: - scale_factors = [1.0] * len(path_pts) - - # Make sure we've got Euclid Point3's for all elements - shape_pts = euclidify(shape_pts, Point3) - path_pts = euclidify(path_pts, Point3) - - src_up = Vector3(*UP_VEC) - - for which_loop in range(len(path_pts)): - path_pt = path_pts[which_loop] - scale = scale_factors[which_loop] - - # calculate the tangent to the curve at this point - if which_loop > 0 and which_loop < len(path_pts) - 1: - prev_pt = path_pts[which_loop - 1] - next_pt = path_pts[which_loop + 1] - - v_prev = path_pt - prev_pt - v_next = next_pt - path_pt - tangent = v_prev + v_next - elif which_loop == 0: - tangent = path_pts[which_loop + 1] - path_pt - elif which_loop == len(path_pts) - 1: - tangent = path_pt - path_pts[which_loop - 1] - - # Scale points - if scale != 1.0: - this_loop = [(scale * sh) for sh in shape_pts] - # Convert this_loop back to points; scaling changes them to - # Vectors - this_loop = [Point3(v.x, v.y, v.z) for v in this_loop] - else: - this_loop = shape_pts[:] + return poly - # Rotate & translate - this_loop = transform_to_point(this_loop, dest_point=path_pt, - dest_normal=tangent, src_up=src_up) - # Add the transformed points to our final list - polyhedron_pts += this_loop - # And calculate the facet indices - shape_pt_count = len(shape_pts) - segment_start = which_loop * shape_pt_count - segment_end = segment_start + shape_pt_count - 1 - if which_loop < len(path_pts) - 1: - for i in range(segment_start, segment_end): - facet_indices.append([i, i + shape_pt_count, i + 1]) - facet_indices.append([i + 1, i + shape_pt_count, i + shape_pt_count + 1]) - facet_indices.append([segment_start, segment_end, segment_end + shape_pt_count]) - facet_indices.append([segment_start, segment_end + shape_pt_count, segment_start + shape_pt_count]) +def _widen_angle_for_fillet( + start_degrees: float, end_degrees: float +) -> tuple[float, float]: + # Fix start/end degrees as needed; find a way to make an acute angle + if end_degrees < start_degrees: + end_degrees += 360 - # Cap the start of the polyhedron - for i in range(1, shape_pt_count - 1): - facet_indices.append([0, i, i + 1]) + if end_degrees - start_degrees >= 180: + start_degrees, end_degrees = end_degrees, start_degrees - # And the end (could be rolled into the earlier loop) - # FIXME: concave cross-sections will cause this end-capping algorithm - # to fail - end_cap_base = len(polyhedron_pts) - shape_pt_count - for i in range(end_cap_base + 1, len(polyhedron_pts) - 1): - facet_indices.append([end_cap_base, i + 1, i]) + epsilon_degrees = 0.1 + return start_degrees - epsilon_degrees, end_degrees + epsilon_degrees - return polyhedron(points=euc_to_arr(polyhedron_pts), faces=facet_indices) +# ============== +# = 2D DRAWING = +# ============== +def path_2d( + points: Sequence[Point23], width: float = 1, closed: bool = False +) -> list[Point2]: + """ + Return a set of points describing a path of width `width` around `points`, + suitable for use as a polygon(). -except Exception as e: - # euclid isn't available; these methods won't be either - print("\n\nUnable to load euclid library. Skipping euclid-based tests " - "with exception: \n%s\n" % e) + Note that if `closed` is True, the polygon will have a hole in it, meaning + that `polygon()` would need to specify its `paths` argument. Assuming 3 elements + in the original `points` list, we'd have to call: + path_points = path_2d(points, closed=True) + poly = polygon(path_points, paths=[[0,1,2],[3,4,5]]) -# {{{ http://code.activestate.com/recipes/577068/ (r1) + Or, you know, just call `path_2d_polygon()` and let it do that for you + """ + p_a = offset_points(points, offset=width / 2, internal=True, closed=closed) + p_b = list( + reversed(offset_points(points, offset=width / 2, internal=False, closed=closed)) + ) + return p_a + p_b -def frange(*args): - """frange([start, ] end [, step [, mode]]) -> generator +def path_2d_polygon( + points: Sequence[Point23], width: float = 1, closed: bool = False +) -> polygon: + """ + Return an OpenSCAD `polygon()` in an area `width` units wide around `points` + """ + path_points = path_2d(points, width, closed) + paths = [list(range(len(path_points)))] + if closed: + paths = [list(range(len(points))), list(range(len(points), len(path_points)))] + return polygon(path_points, paths=paths) + + +# ================= +# = NUMERIC UTILS = +# ================= +def frange( + start: float, + end: float, + num_steps: int = None, + step_size: float = 1.0, + include_end=True, +): + # if both step_size AND num_steps are supplied, num_steps will be used + step_size = step_size or 1.0 + + if num_steps: + step_count = num_steps - 1 if include_end else num_steps + step_size = (end - start) / step_count + mode = 3 if include_end else 1 + return _frange_orig(start, end, step_size, mode) + + +def _frange_orig(*args): + """ + # {{{ http://code.activestate.com/recipes/577068/ (r1) + frange([start, ] end [, step [, mode]]) -> generator A float range generator. If not specified, the default start is 0.0 and the default step is 1.0. @@ -1123,16 +1352,16 @@ def frange(*args): mode = args[3] args = args[0:3] elif n != 3: - raise TypeError('frange expects 1-4 arguments, got %d' % n) + raise TypeError("frange expects 1-4 arguments, got %d" % n) assert len(args) == 3 try: start, end, step = [a + 0.0 for a in args] except TypeError: - raise TypeError('arguments must be numbers') + raise TypeError("arguments must be numbers") if step == 0.0: - raise ValueError('step must not be zero') + raise ValueError("step must not be zero") if not isinstance(mode, int): - raise TypeError('mode must be an int') + raise TypeError("mode must be an int") if mode & 1: i, x = 0, start else: @@ -1152,14 +1381,27 @@ def frange(*args): i += 1 x = start + i * step -# end of http://code.activestate.com/recipes/577068/ }}} + +def clamp(val: float, min_val: float, max_val: float) -> float: + result = max(min(val, max_val), min_val) + return result + + +def lerp( + val: float, min_in: float, max_in: float, min_out: float, max_out: float +) -> float: + if min_in == max_in or min_out == max_out: + return min_out + + ratio = (val - min_in) / (max_in - min_in) + result = min_out + ratio * (max_out - min_out) + return result + # ===================== # = D e b u g g i n g = # ===================== - - -def obj_tree_str(sp_obj, vars_to_print=None): +def obj_tree_str(sp_obj: OpenSCADObject, vars_to_print: Sequence[str] = None) -> str: # For debugging. This prints a string of all of an object's # children, with whatever attributes are specified in vars_to_print @@ -1181,6 +1423,13 @@ def obj_tree_str(sp_obj, vars_to_print=None): # Add all children for c in sp_obj.children: - s += indent(obj_tree_str(c, vars_to_print)) + s += indent(obj_tree_str(c, vars_to_print)) # type: ignore return s + + +# ===================== +# = DEPENDENT IMPORTS = +# ===================== +# imported here to mitigate import loops +from solid.extrude_along_path import extrude_along_path # noqa diff --git a/tox.ini b/tox.ini index 40a577c3..e4867642 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,17 @@ -# content of: tox.ini , put in same dir as setup.py [tox] -envlist = py27,pep8 +envlist = tests, docs +skipsdist = true [testenv] -commands= - python solid/test/test_screw_thread.py - python solid/test/test_solidpython.py - python solid/test/test_utils.py - -[testenv:pep8] -deps=flake8 +allowlist_externals = uv +commands_pre = + uv sync --group dev commands = - flake8 solid + uv run pytest -[flake8] -show-source = true -ignore = E111,E113,E121,E122,E126,E127,E201,E202,E203,E221,E222,E231,E241,E261,E265,E303,E401,E501,E711,F401,F403,F841,H101,H201,H301,H302,H303,H305,H306,H307,H404,H405,W291,W293,W391 +[testenv:docs] +allowlist_externals = uv +commands_pre = + uv sync --group dev +commands = + uv run sphinx-build -b html Doc Doc/_build/html \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..84054176 --- /dev/null +++ b/uv.lock @@ -0,0 +1,692 @@ +version = 1 +revision = 2 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "euclid3" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d2/80730bee6b51f2a0faacaec51abb919f144c8b1fff5907fe019ec0e95698/euclid3-0.01.tar.gz", hash = "sha256:25b827a57adbfd9a3fa8625e43abc3e907f61de622343e7e538482ef9b46fd0b", size = 13201, upload-time = "2014-05-14T10:51:19.907Z" } + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, +] + +[[package]] +name = "prettytable" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/30/4b0746848746ed5941f052479e7c23d2b56d174b82f4fd34a25e389831f5/prettytable-0.7.2.tar.bz2", hash = "sha256:853c116513625c738dc3ce1aee148b5b5757a86727e67eff6502c7ca59d43c36", size = 21755, upload-time = "2013-04-07T01:37:55.502Z" } + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pypng" +version = "0.20220715.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992, upload-time = "2022-07-15T14:11:05.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057, upload-time = "2022-07-15T14:11:03.713Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "solidpython" +version = "1.1.5" +source = { virtual = "." } +dependencies = [ + { name = "euclid3" }, + { name = "ply" }, + { name = "prettytable" }, + { name = "pypng" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-rtd-theme" }, + { name = "tox" }, +] + +[package.metadata] +requires-dist = [ + { name = "euclid3", specifier = ">=0.1.0" }, + { name = "ply", specifier = ">=3.11" }, + { name = "prettytable", specifier = "==0.7.2" }, + { name = "pypng", specifier = ">=0.0.19" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "sphinx", specifier = ">=8.1.3" }, + { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, + { name = "tox", specifier = ">=4.30.3" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tox" +version = "4.30.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/b2/cee55172e5e10ce030b087cd3ac06641e47d08a3dc8d76c17b157dba7558/tox-4.30.3.tar.gz", hash = "sha256:f3dd0735f1cd4e8fbea5a3661b77f517456b5f0031a6256432533900e34b90bf", size = 202799, upload-time = "2025-10-02T16:24:39.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/e4/8bb9ce952820df4165eb34610af347665d6cb436898a234db9d84d093ce6/tox-4.30.3-py3-none-any.whl", hash = "sha256:a9f17b4b2d0f74fe0d76207236925a119095011e5c2e661a133115a8061178c9", size = 175512, upload-time = "2025-10-02T16:24:38.209Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +]