diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index ff3d6645..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,54 +0,0 @@ -version: 2 -jobs: - build: - working_directory: ~/SolidCode/SolidPython - parallelism: 1 - shell: /bin/bash --login - environment: - CIRCLE_ARTIFACTS: /tmp/circleci-artifacts - CIRCLE_TEST_REPORTS: /tmp/circleci-test-results - # In CircleCI 1.0 we used a pre-configured image with a large number of languages and other packages. - # In CircleCI 2.0 you can now specify your own image, or use one of our pre-configured images. - # The following configuration line tells CircleCI to use the specified docker image as the runtime environment for you job. - # We have selected a pre-built image that mirrors the build environment we use on - # the 1.0 platform, but we recommend you choose an image more tailored to the needs - # of each job. For more information on choosing an image (or alternatively using a - # VM instead of a container) see https://circleci.com/docs/2.0/executor-types/ - # To see the list of pre-built images that CircleCI provides for most common languages see - # https://circleci.com/docs/2.0/circleci-images/ - docker: - # - image: circleci/build-image:ubuntu-14.04-XXL-upstart-1189-5614f37 - - image: circleci/python:3.7.4-stretch-browsers - steps: - - checkout - - run: mkdir -p $CIRCLE_ARTIFACTS $CIRCLE_TEST_REPORTS - - restore_cache: - keys: - # This branch if available - - v1-dep-{{ .Branch }}- - # Default branch if not - - v1-dep-master- - # Any branch if there are none on the default branch - this should be unnecessary if you have your default branch configured correctly - - v1-dep- - - run: pip install --user tox tox-pyenv - - save_cache: - key: v1-dep-{{ .Branch }}-{{ epoch }} - paths: - # This is a broad list of cache paths to include many possible development environments - # You can probably delete some of these entries - - vendor/bundle - - ~/virtualenvs - - # Test - # This would typically be a build job when using workflows, possibly combined with build - - run: /home/circleci/.local/bin/tox - # Teardown - # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each - # Save test results - - store_test_results: - path: /tmp/circleci-test-results - # Save artifacts - - store_artifacts: - path: /tmp/circleci-artifacts - - store_artifacts: - path: /tmp/circleci-test-results 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/README.rst b/README.rst index e2f050d1..7d7ba678 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,12 @@ +**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://circleci.com/gh/SolidCode/SolidPython.svg?style=shield - :target: https://circleci.com/gh/SolidCode/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 @@ -27,7 +31,6 @@ SolidPython things: <#directions-up-down-left-right-forward-back-for-arranging-things>`__ - `Arcs <#arcs>`__ - `Extrude Along Path <#extrude_along_path>`__ - - `Basic color library <#basic-color-library>`__ - `Bill Of Materials <#bill-of-materials>`__ - `solid.screw\_thread <#solidscrew_thread>`__ @@ -47,7 +50,7 @@ simple example: This Python code: -:: +.. code:: python from solid import * d = difference()( @@ -58,7 +61,7 @@ This Python code: Generates this OpenSCAD code: -:: +.. code:: python difference(){ cube(10); @@ -68,7 +71,7 @@ Generates this OpenSCAD code: 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 * @@ -76,7 +79,7 @@ code is a lot shorter (and I think clearer) than the SCAD code it compiles to: Generates this OpenSCAD code: -:: +.. code:: difference(){ union(){ @@ -105,7 +108,7 @@ Installing SolidPython - Install latest release via `PyPI `__: - :: + .. code:: bash pip install solidpython @@ -116,29 +119,29 @@ Installing SolidPython - Install current master straight from Github: - :: + .. code:: bash - pip install git+https://github.com/SolidCode/SolidPython.git + 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 + 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 + 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); @@ -147,7 +150,7 @@ Using SolidPython **SolidPython:** - :: + .. code:: d = difference()( cube(10), # Note the comma between each element! @@ -167,19 +170,23 @@ Using SolidPython Importing OpenSCAD code ======================= -- Use ``solid.import_scad(path)`` to import 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 * @@ -189,12 +196,13 @@ Importing OpenSCAD code - 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 - mcad = import_scad('/path/to/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...] @@ -204,15 +212,18 @@ Importing OpenSCAD code - 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 * @@ -229,7 +240,7 @@ 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') @@ -250,13 +261,13 @@ 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), @@ -265,14 +276,14 @@ is the same as: 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), @@ -297,7 +308,7 @@ structure. Example: -:: +.. code:: python outer = cylinder(r=pipe_od, h=seg_length) inner = cylinder(r=pipe_id, h=seg_length) @@ -333,7 +344,7 @@ Currently these include: Directions: (up, down, left, right, forward, back) for arranging things: ------------------------------------------------------------------------ -:: +.. code:: python up(10)( cylinder() @@ -341,7 +352,7 @@ Directions: (up, down, left, right, forward, back) for arranging things: seems a lot clearer to me than: -:: +.. code:: python translate( [0,0,10])( cylinder() @@ -356,13 +367,13 @@ 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) @@ -373,45 +384,16 @@ rounds. Extrude Along Path ------------------ -``solid.utils.extrude_along_path(shape_pts, path_pts, scale_factors=None)`` +``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. -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 `__, as seen [here] -(https://github.com/openscad/MCAD/blob/master/materials.scad). - Bill Of Materials ----------------- @@ -458,7 +440,8 @@ Jupyter Renderer ---------------- Render SolidPython or OpenSCAD code in Jupyter notebooks using `ViewSCAD `__, or install directly via: -:: + +.. code:: bash pip install viewscad diff --git a/pyproject.toml b/pyproject.toml index 3cba801c..06e88e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,48 +1,53 @@ -[tool.poetry] +[project] name = "solidpython" -version = "0.4.6" +version = "1.1.5" description = "Python interface to the OpenSCAD declarative geometry language" -authors = ["Evan Jones "] -homepage = "https://github.com/SolidCode/SolidPython" -repository = "https://github.com/SolidCode/SolidPython" -documentation = "https://solidpython.readthedocs.io/en/latest/" +authors = [{ name = "Evan Jones", email = "evan_t_jones@mac.com" }] license = "LGPL-2.1" keywords = [ - "3D", - "CAD", - "CSG", + "3D", + "CAD", + "CSG", "constructive solid geometry", "geometry", - "modeling", - "OpenSCAD", + "modeling", + "OpenSCAD", ] - -classifiers=[ +classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.7", "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=[ - { include = "solid"} + + +readme = "README.rst" +requires-python = ">=3.10" +dependencies = [ + "euclid3>=0.1.0", + "pypng>=0.0.19", + "PrettyTable==0.7.2", + "ply>=3.11", ] -[tool.poetry.dependencies] -python = ">=3.7" -euclid3 = "^0.1.0" -pypng = "^0.0.19" -PrettyTable = "=0.7.2" -regex = "^2019.4" -[tool.poetry.dev-dependencies] -tox = "^tox 3.11" +[project.urls] +homepage = "https://github.com/SolidCode/SolidPython" +repository = "https://github.com/SolidCode/SolidPython" +documentation = "https://solidpython.readthedocs.io/en/latest/" -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +[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/solid/examples/basic_scad_include.py b/solid/examples/basic_scad_include.py index 5e432dad..a91020fc 100755 --- a/solid/examples/basic_scad_include.py +++ b/solid/examples/basic_scad_include.py @@ -13,6 +13,7 @@ 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) diff --git a/solid/examples/bom_scad.py b/solid/examples/bom_scad.py index a059a66f..7611835c 100755 --- a/solid/examples/bom_scad.py +++ b/solid/examples/bom_scad.py @@ -93,15 +93,16 @@ def doohickey(c): def assembly(): + nut = m3_nut() return union()( 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))(m3_nut()), - translate((0, 0, -nut_height - doohickey_h / 2))(m3_nut()), - translate((10, 0, -nut_height - doohickey_h / 2))(m3_nut()), + 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), ) @@ -109,12 +110,12 @@ def assembly(): out_dir = sys.argv[1] if len(sys.argv) > 1 else None a = assembly() - bom = bill_of_materials() + bom = bill_of_materials(a) file_out = scad_render_to_file(a, out_dir=out_dir) print(f"{__file__}: SCAD file written to: \n{file_out}") print(bom) print("Or, Spreadsheet-ready TSV:\n\n") - bom = bill_of_materials(csv=True) + bom = bill_of_materials(a, csv=True) print(bom) diff --git a/solid/examples/path_extrude_example.py b/solid/examples/path_extrude_example.py index 12734607..296637c4 100755 --- a/solid/examples/path_extrude_example.py +++ b/solid/examples/path_extrude_example.py @@ -1,27 +1,191 @@ #! /usr/bin/env python3 +from solid.objects import linear_extrude +from solid.solidpython import OpenSCADObject import sys -from math import cos, radians, sin +from math import cos, radians, sin, pi, tau +from pathlib import Path -from euclid3 import Point3 +from euclid3 import Point2, Point3, Vector3 -from solid import scad_render_to_file -from solid.utils import extrude_along_path +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) -def sinusoidal_ring(rad=25, segments=SEGMENTS): + # For a more complex set of rotations, add a rotation degree for each point in path + complex_rotations = [] + degs = 0 + oscillation_max = 60 + + 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 @@ -29,28 +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(): - # 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 +def make_label(message:str, text_loc:Tuple[float, float]=TEXT_LOC, height=5) -> OpenSCADObject: + return translate(text_loc)( + linear_extrude(height)( + text(message) + ) + ) - extruded = extrude_along_path(shape_pts=shape, path_pts=path, scale_factors=scales) +# =============== +# = ENTRY POINT = +# =============== +if __name__ == "__main__": + out_dir = sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent - return extruded + 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 = distribute_in_grid(all_objs, + max_bounding_box=[4*PATH_RAD, 4*PATH_RAD], + rows_and_cols=[len(all_objs), 1]) -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else None - a = extrude_example() - file_out = scad_render_to_file(a, out_dir=out_dir, 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/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/splines_example.py b/solid/examples/splines_example.py index 7896b72e..002b5049 100755 --- a/solid/examples/splines_example.py +++ b/solid/examples/splines_example.py @@ -2,7 +2,7 @@ import os import sys from solid import * -from solid.utils import Red, right, forward +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 @@ -10,13 +10,18 @@ def assembly(): # Catmull-Rom Splines - a = basic_catmull_rom() - a += forward(4)(catmull_rom_spline_variants()) - a += forward(6)(bottle_shape(width=2, height=6)) + 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 += forward(12)(basic_bezier()) - a += forward(18)(bezier_points_variants()) + a += back(16)(basic_bezier()) # Row 4 + a += back(20)(bezier_points_variants()) # Row 5 return a def basic_catmull_rom(): @@ -67,6 +72,18 @@ def catmull_rom_spline_variants(): 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 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/objects.py b/solid/objects.py index a242529f..bdb685d2 100644 --- a/solid/objects.py +++ b/solid/objects.py @@ -1,17 +1,18 @@ """ Classes for OpenSCAD builtins """ -from pathlib import Path + +from pathlib import Path, PureWindowsPath from types import SimpleNamespace -from typing import Dict, Optional, Sequence, Tuple, Union +from typing import Optional, Sequence, Union -from .solidpython import OpenSCADObject +from .solidpython import IncludedOpenSCADObject, OpenSCADObject PathStr = Union[Path, str] -P2 = Tuple[float, float] -P3 = Tuple[float, float, float] -P4 = Tuple[float, float, float, float] +P2 = tuple[float, float] +P3 = tuple[float, float, float] +P4 = tuple[float, float, float, float] Vec3 = P3 Vec4 = P4 Vec34 = Union[Vec3, Vec4] @@ -22,6 +23,8 @@ ScadSize = Union[int, Sequence[float]] OpenSCADObjectPlus = Union[OpenSCADObject, Sequence[OpenSCADObject]] +IMPORTED_SCAD_MODULES: dict[Path, SimpleNamespace] = {} + class polygon(OpenSCADObject): """ @@ -30,19 +33,36 @@ class polygon(OpenSCADObject): :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 + :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: Points, paths: Indexes = None) -> None: - if not paths: - paths = [list(range(len(points)))] - super().__init__('polygon', - {'points': points, 'paths': paths}) + 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): @@ -61,8 +81,7 @@ class circle(OpenSCADObject): """ def __init__(self, r: float = None, d: float = None, segments: int = None) -> None: - super().__init__('circle', - {'r': r, 'd': d, 'segments': segments}) + super().__init__("circle", {"r": r, "d": d, "segments": segments}) class square(OpenSCADObject): @@ -72,20 +91,19 @@ class square(OpenSCADObject): 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 + :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 + :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}) + super().__init__("square", {"size": size, "center": center}) class sphere(OpenSCADObject): @@ -104,8 +122,7 @@ class sphere(OpenSCADObject): """ def __init__(self, r: float = None, d: float = None, segments: int = None) -> None: - super().__init__('sphere', - {'r': r, 'd': d, 'segments': segments}) + super().__init__("sphere", {"r": r, "d": d, "segments": segments}) class cube(OpenSCADObject): @@ -115,20 +132,19 @@ class cube(OpenSCADObject): 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 + :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 + :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}) + super().__init__("cube", {"size": size, "center": center}) class cylinder(OpenSCADObject): @@ -140,7 +156,7 @@ class cylinder(OpenSCADObject): :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 + :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 @@ -160,8 +176,8 @@ class cylinder(OpenSCADObject): :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 + :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 @@ -169,13 +185,32 @@ class cylinder(OpenSCADObject): :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}) + 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): @@ -189,28 +224,39 @@ class polyhedron(OpenSCADObject): :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 + :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 + :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 + :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 = None, triangles: Indexes = None) -> None: - super().__init__('polyhedron', - {'points': points, 'faces': faces, - 'convexity': convexity, - 'triangles': triangles}) + 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): @@ -220,7 +266,7 @@ class union(OpenSCADObject): """ def __init__(self) -> None: - super().__init__('union', {}) + super().__init__("union", {}) def __add__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: new_union = union() @@ -238,7 +284,7 @@ class intersection(OpenSCADObject): """ def __init__(self) -> None: - super().__init__('intersection', {}) + super().__init__("intersection", {}) def __mul__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: new_int = intersection() @@ -255,7 +301,7 @@ class difference(OpenSCADObject): """ def __init__(self) -> None: - super().__init__('difference', {}) + super().__init__("difference", {}) def __sub__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: new_diff = difference() @@ -268,13 +314,13 @@ def __sub__(self, x: OpenSCADObjectPlus) -> OpenSCADObject: class hole(OpenSCADObject): def __init__(self) -> None: - super().__init__('hole', {}) + super().__init__("hole", {}) self.set_hole(is_hole=True) class part(OpenSCADObject): def __init__(self) -> None: - super().__init__('part', {}) + super().__init__("part", {}) self.set_part_root(is_root=True) @@ -287,7 +333,7 @@ class translate(OpenSCADObject): """ def __init__(self, v: P3 = None) -> None: - super().__init__('translate', {'v': v}) + super().__init__("translate", {"v": v}) class scale(OpenSCADObject): @@ -299,7 +345,7 @@ class scale(OpenSCADObject): """ def __init__(self, v: P3 = None) -> None: - super().__init__('scale', {'v': v}) + super().__init__("scale", {"v": v}) class rotate(OpenSCADObject): @@ -315,7 +361,7 @@ class rotate(OpenSCADObject): """ def __init__(self, a: Union[float, Vec3] = None, v: Vec3 = None) -> None: - super().__init__('rotate', {'a': a, 'v': v}) + super().__init__("rotate", {"a": a, "v": v}) class mirror(OpenSCADObject): @@ -328,7 +374,7 @@ class mirror(OpenSCADObject): """ def __init__(self, v: Vec3) -> None: - super().__init__('mirror', {'v': v}) + super().__init__("mirror", {"v": v}) class resize(OpenSCADObject): @@ -337,13 +383,13 @@ class resize(OpenSCADObject): :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}) + def __init__(self, newsize: Vec3, auto: tuple[bool, bool, bool] = None) -> None: + super().__init__("resize", {"newsize": newsize, "auto": auto}) class multmatrix(OpenSCADObject): @@ -355,8 +401,8 @@ class multmatrix(OpenSCADObject): :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}) + def __init__(self, m: tuple[Vec4, Vec4, Vec4, Vec4]) -> None: + super().__init__("multmatrix", {"m": m}) class color(OpenSCADObject): @@ -367,11 +413,14 @@ class color(OpenSCADObject): not specified. :param c: RGB color + alpha value. - :type c: sequence of 3 or 4 numbers between 0 and 1 + :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: Vec34) -> None: - super().__init__('color', {'c': c}) + def __init__(self, c: Union[Vec34, str], alpha: float = 1.0) -> None: + super().__init__("color", {"c": c, "alpha": alpha}) class minkowski(OpenSCADObject): @@ -382,36 +431,47 @@ class minkowski(OpenSCADObject): """ def __init__(self) -> None: - super().__init__('minkowski', {}) + 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 + + :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 + + :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 + + :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) -> None: - if r: - kwargs = {'r': r} - elif delta: - kwargs = {'delta': delta, 'chamfer': chamfer} + 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") - super().__init__('offset', kwargs) + if segments: + kwargs["segments"] = segments + super().__init__("offset", kwargs) class hull(OpenSCADObject): @@ -422,7 +482,7 @@ class hull(OpenSCADObject): """ def __init__(self) -> None: - super().__init__('hull', {}) + super().__init__("hull", {}) class render(OpenSCADObject): @@ -435,7 +495,7 @@ class render(OpenSCADObject): """ def __init__(self, convexity: int = None) -> None: - super().__init__('render', {'convexity': convexity}) + super().__init__("render", {"convexity": convexity}) class linear_extrude(OpenSCADObject): @@ -450,14 +510,14 @@ class linear_extrude(OpenSCADObject): :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 + :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 + :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 @@ -469,12 +529,26 @@ class linear_extrude(OpenSCADObject): """ - 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}) + 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): @@ -491,37 +565,54 @@ class rotate_extrude(OpenSCADObject): 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 + :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 + :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}) + 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}) + 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): @@ -529,14 +620,14 @@ 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 + :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}) + super().__init__("projection", {"cut": cut}) class surface(OpenSCADObject): @@ -546,64 +637,67 @@ class surface(OpenSCADObject): :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 + :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. + :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 + :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}) + 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 + 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 + :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). + :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 + :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 + :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 + :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" + :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 @@ -613,27 +707,46 @@ class text(OpenSCADObject): :param script: The script of the text. Default is "latin". :type script: string - :param segments: used for subdividing the curved path segments provided by + :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}) + 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}) + 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): @@ -642,35 +755,50 @@ class children(OpenSCADObject): 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 + :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 + :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}) + 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}) + 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}) + 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): @@ -681,17 +809,29 @@ class import_(OpenSCADObject): :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 + :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}) + 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): @@ -701,12 +841,12 @@ class intersection_for(OpenSCADObject): """ def __init__(self, n: int) -> None: - super().__init__('intersection_for', {'n': n}) + super().__init__("intersection_for", {"n": n}) class assign(OpenSCADObject): def __init__(self) -> None: - super().__init__('assign', {}) + super().__init__("assign", {}) # ================================ @@ -735,56 +875,134 @@ def disable(openscad_obj: OpenSCADObject) -> OpenSCADObject: # =========================== # = IMPORTING OPENSCAD CODE = # =========================== -def import_scad(scad_filepath: PathStr) -> Optional[SimpleNamespace]: +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 """ - import_scad() is the namespaced, more Pythonic way to import OpenSCAD code. - Return a python namespace containing all imported SCAD modules + global IMPORTED_SCAD_MODULES - If scad_filepath is a single .scad file, all modules will be imported, - e.g. - motors = solid.import_scad(' [_stepper_motor_mount', 'stepper_motor_mount'] + scad = Path(scad_file_or_dir) + candidates: list[Path] = [scad] - If scad_filepath is a directory, recursively import all scad files below - the directory and subdirectories within it. - e.g. - mcad = solid.import_scad(' ['bearing', 'boxes', 'constants', 'curves',...] - dir(mcad.bearing) # => ['bearing', 'bearingDimensions', ...] - """ - scad = Path(scad_filepath) + 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}" + ) - namespace: Optional[SimpleNamespace] = SimpleNamespace() - scad_found = False - if scad.is_file(): - scad_found = True - use(scad.absolute().as_posix(), dest_namespace_dict=namespace.__dict__) +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(): - for f in scad.glob('*.scad'): - subspace = import_scad(f.absolute().as_posix()) - setattr(namespace, f.stem, subspace) - scad_found = True - - # recurse through subdirectories, adding namespaces only if they have - # valid scad code under them. - subdirs = list([d for d in scad.iterdir() if d.is_dir()]) - for subd in subdirs: - subspace = import_scad(subd.absolute().as_posix()) - if subspace is not None: - setattr(namespace, subd.stem, subspace) - scad_found = True - - namespace = namespace if scad_found else None + 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): + + +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. @@ -795,22 +1013,18 @@ def use(scad_file_path: PathStr, use_not_include: bool = True, dest_namespace_di from .solidpython import new_openscad_class_str from .solidpython import calling_module - scad_file_path = Path(scad_file_path) - - contents = None - try: - contents = scad_file_path.read_text() - except Exception as e: - raise Exception(f"Failed to import SCAD module '{scad_file_path}' with error: {e} ") + scad_file_path = _find_library(scad_file_path) - # 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 = parse_scad_callables(contents) + 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) + 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: @@ -819,7 +1033,7 @@ def use(scad_file_path: PathStr, use_not_include: bool = True, dest_namespace_di try: exec(class_str, dest_namespace_dict) except Exception as e: - classname = sd['name'] + classname = sd["name"] msg = f"Unable to import SCAD module: `{classname}` from `{scad_file_path.name}`, with error: {e}" print(msg) diff --git a/solid/patch_euclid.py b/solid/patch_euclid.py index 61bef8c7..68c30f1d 100644 --- a/solid/patch_euclid.py +++ b/solid/patch_euclid.py @@ -1,18 +1,31 @@ import euclid3 -from euclid3 import * +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_local(self): +def as_arr_local2(self): + return [self.x, self.y] + +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 + self.x *= factor + self.y *= factor + + 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 @@ -30,8 +43,14 @@ def _intersect_line3_line3(A, B): 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 e2bb9df7..d3445a8b 100755 --- a/solid/screw_thread.py +++ b/solid/screw_thread.py @@ -5,13 +5,15 @@ from euclid3 import Point3, Vector3 from solid import scad_render_to_file -from solid.objects import cylinder, polyhedron, render -from solid.utils import EPSILON, UP_VEC, bounding_box, radians +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 from solid import run_euclid_patch + run_euclid_patch() P2 = Tuple[float, float] @@ -19,21 +21,28 @@ 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: + +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): + 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. @@ -63,7 +72,7 @@ def thread(outline_pts: Points, :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 + :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 @@ -80,6 +89,13 @@ def thread(outline_pts: Points, 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 @@ -96,13 +112,19 @@ def thread(outline_pts: Points, # 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] + 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 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] + 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 @@ -112,20 +134,20 @@ def thread(outline_pts: Points, # 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] + # 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 + # 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), + 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 + rad_2 - neck_distance, ] for i in range(total_steps): @@ -138,17 +160,24 @@ def thread(outline_pts: Points, # Handle the neck-in radius for internal and external threads if 0 <= angle < neck_in_degrees: - rad = map_segment(angle, 0, neck_in_degrees, section_rads[0], section_rads[1]) + 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]) + 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]) + 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 @@ -157,7 +186,9 @@ def thread(outline_pts: Points, for j in range(ind, ind + poly_sides - 1): 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 @@ -166,48 +197,57 @@ def thread(outline_pts: Points, 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(r1=inner_rad + outline_w + EPSILON, r2=rad_2 + outline_w + EPSILON, 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(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) - # FIXME: For reasons I haven't yet sussed out, the cylinder `tube` doesn't - # line up perfectly with the polyhedron `a`, which creates tiny extra facets - # at joints. These aren't large enough to mess up 3D prints, but they - # do make the shape messier than it needs to be. -ETJ 30 December 2019 a *= tube + return a + 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] - ] + res = [[0, -tooth_height / 2], [tooth_depth, 0], [0, tooth_height / 2]] return res -def assembly(): - 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) +def assembly(): + 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 900d075e..2e2f7245 100755 --- a/solid/solidpython.py +++ b/solid/solidpython.py @@ -21,16 +21,18 @@ from typing import Set, Sequence, List, Callable, Optional, Union, Iterable from types import ModuleType -from typing import Callable, Iterable, List, Optional, Sequence, Set, Union -import pkg_resources -import regex as re + +from importlib import metadata +from importlib.metadata import PackageNotFoundError + +import re PathStr = Union[Path, str] -AnimFunc = Callable[[Optional[float]], 'OpenSCADObject'] +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'] +non_rendered_classes = ["hole", "part"] # Words reserved in Python but not OpenSCAD # Re: https://github.com/SolidCode/SolidPython/issues/99 @@ -42,7 +44,6 @@ # = Internal Utilities = # ========================= class OpenSCADObject: - def __init__(self, name: str, params: dict): self.name = name self.params = params @@ -52,6 +53,13 @@ def __init__(self, name: str, params: dict): self.is_hole = False self.has_hole_children = False self.is_part_root = False + self.traits: dict[str, dict[str, float]] = {} + + 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 @@ -61,7 +69,9 @@ def set_part_root(self, is_root: bool = True) -> "OpenSCADObject": self.is_part_root = is_root return self - def find_hole_children(self, path: List["OpenSCADObject"] = None) -> List["OpenSCADObject"]: + 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) @@ -94,22 +104,24 @@ 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(), '') + string_vals = { + "disable": "*", + "debug": "#", + "background": "%", + "root": "!", + "*": "*", + "#": "#", + "%": "%", + "!": "!", + } + + self.modifier = string_vals.get(m.lower(), "") return self 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 @@ -159,17 +171,17 @@ def _render_str_no_children(self) -> str: # 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) @@ -183,7 +195,7 @@ def _render_str_no_children(self) -> str: s += ", " first = False - if type(k) == int: + if type(k) is int: s += py2openscad(v) else: s += k + " = " + py2openscad(v) @@ -223,16 +235,18 @@ def _render_hole_children(self) -> str: # 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. + # 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: Union["OpenSCADObject", Sequence["OpenSCADObject"]]) -> "OpenSCADObject": + def add( + self, child: Union["OpenSCADObject", Sequence["OpenSCADObject"]] + ) -> "OpenSCADObject": """ - if child is a single object, assume it's an OpenSCADObjects and + 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 @@ -257,8 +271,8 @@ def set_parent(self, parent: "OpenSCADObject"): self.parent = parent def add_param(self, k: str, v: float) -> "OpenSCADObject": - if k == '$fn': - k = 'segments' + if k == "$fn": + k = "segments" self.params[k] = v return self @@ -274,8 +288,8 @@ def copy(self) -> "OpenSCADObject": # 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 = type(self)(**self.params) other.set_modifier(self.modifier) @@ -338,12 +352,9 @@ def _repr_png_(self) -> Optional[bytes]: 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() @@ -361,11 +372,13 @@ class IncludedOpenSCADObject(OpenSCADObject): 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) - use_str = 'use' if use_not_include else 'include' - self.include_string = f'{use_str} <{self.include_file_path}>\n' + 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 @@ -386,22 +399,31 @@ 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(f"Unable to find included SCAD file: {include_file_path} in sys.path") + raise ValueError( + f"Unable to find included SCAD file: {include_file_path} in sys.path" + ) # ========================================= # = Rendering Python code to OpenSCAD code= # ========================================= -def _find_include_strings(obj: Union[IncludedOpenSCADObject, OpenSCADObject]) -> Set[str]: +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 -def scad_render(scad_object: OpenSCADObject, file_header: str = '') -> str: +def scad_render(scad_object: OpenSCADObject, file_header: str = "") -> str: # Make this object the root of the tree root = scad_object @@ -410,19 +432,21 @@ def scad_render(scad_object: OpenSCADObject, file_header: str = '') -> str: include_strings = _find_include_strings(root) # and render the string - includes = ''.join(include_strings) + "\n" + includes = "".join(include_strings) + "\n" scad_body = root._render() - if file_header and not file_header.endswith('\n'): - file_header += '\n' + if file_header and not file_header.endswith("\n"): + file_header += "\n" return file_header + includes + scad_body -def scad_render_animated(func_to_animate: AnimFunc, - steps: int =20, - back_and_forth: bool=True, - file_header: str='') -> str: +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. # @@ -455,7 +479,7 @@ def scad_render_animated(func_to_animate: AnimFunc, 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" + includes = "".join(include_strings) + "\n" rendered_string = file_header + includes @@ -476,51 +500,60 @@ def scad_render_animated(func_to_animate: AnimFunc, 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}){{" \ - f" {scad_str}\n" \ - f"}}\n" + rendered_string += f"if ($t >= {time} && $t < {end_time}){{ {scad_str}\n}}\n" return rendered_string -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 = "// Generated by SolidPython {version} on {date}\n".format( - version=_get_version(), - date=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - header += file_header +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: + +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, + # -- 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), + # -- 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: @@ -529,13 +562,13 @@ def _write_code_to_file(rendered_string: str, odp = Path(out_dir) if not odp.exists(): odp.mkdir() - out_path = odp / calling_file.with_suffix('.scad').name + out_path = odp / calling_file.with_suffix(".scad").name else: - out_path = calling_file.with_suffix('.scad') - + out_path = calling_file.with_suffix(".scad") + if include_orig_code: rendered_string += sp_code_in_scad_comment(calling_file) - except AttributeError as e: + 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 @@ -547,33 +580,27 @@ def _write_code_to_file(rendered_string: str, odp = Path(out_dir) if out_dir else Path.cwd() if not odp.exists(): odp.mkdir() - out_path = odp / 'solid.scad' + out_path = odp / "solid.scad" out_path.write_text(rendered_string) return out_path.absolute().as_posix() -def _get_version(): +def _get_version() -> str: """ Returns SolidPython version - Raises a RuntimeError if the version cannot be determined + Returns '' if no version can be found """ - try: - # if SolidPython is installed use `pkg_resources` - return pkg_resources.get_distribution('solidpython').version - - except pkg_resources.DistributionNotFound: - # if the running SolidPython is not the one installed via pip, - # try to read it from the project setup file + return metadata.version("solidpython") + except PackageNotFoundError: version_pattern = re.compile(r"version = ['\"]([^'\"]*)['\"]") - version_file_path = Path(__file__).parent.parent / 'pyproject.toml' - - version_match = version_pattern.search(version_file_path.read_text()) - if version_match: - return version_match.group(1) - - raise RuntimeError("Unable to determine software 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: @@ -590,69 +617,43 @@ def sp_code_in_scad_comment(calling_file: PathStr) -> str: # 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") + 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 # =========== # = Parsing = # =========== -def extract_callable_signatures(scad_file_path: PathStr) -> List[dict]: - scad_code_str = Path(scad_file_path).read_text() - return parse_scad_callables(scad_code_str) +def parse_scad_callables(filename: str) -> List[dict]: + from .py_scadparser import scad_parser + modules, functions, _ = scad_parser.parseFile(filename) -def parse_scad_callables(scad_code_str: str) -> List[dict]: 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 - - # FIXME: OpenSCAD use/import includes top level variables. We should parse - # those out (e.g. x = someValue;) as well -ETJ 21 May 2019 - 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*(?:{|=)' - - # See https://github.com/SolidCode/SolidPython/issues/95; Thanks to https://github.com/Torlos - args_re = r'(?mxs)(?P\w+)(?:\s*=\s*(?P([\w.\"\s\?:\-+\\\/*]+|\((?>[^()]|(?2))*\)|\[(?>[^\[\]]|(?2))*\])+))?(?:,|$)' - - # 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') + for c in modules + functions: 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}) + + # 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}) return callables @@ -664,7 +665,7 @@ def calling_module(stack_depth: int = 2) -> ModuleType: 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 + 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. @@ -680,13 +681,15 @@ def calling_module(stack_depth: int = 2) -> ModuleType: return calling_mod -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 = '' +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 [] @@ -698,7 +701,7 @@ def new_openscad_class_str(class_name: str, args = map(_subbed_keyword, args) # type: ignore for arg in args: - args_str += ', ' + arg + args_str += ", " + arg args_pairs += f"'{arg}':{arg}, " # kwargs have a default value defined in their SCAD versions. We don't @@ -706,7 +709,7 @@ def new_openscad_class_str(class_name: str, # that one is defined. kwargs = map(_subbed_keyword, kwargs) # type: ignore for kwarg in kwargs: - args_str += f', {kwarg}=None' + args_str += f", {kwarg}=None" args_pairs += f"'{kwarg}':{kwarg}, " if include_file_path: @@ -717,18 +720,22 @@ def new_openscad_class_str(class_name: str, # 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") + 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") + 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 @@ -736,39 +743,73 @@ def new_openscad_class_str(class_name: str, 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 + '_' if keyword in PYTHON_ONLY_RESERVED_WORDS else keyword + new_key = keyword + + if keyword in PYTHON_ONLY_RESERVED_WORDS: + new_key = keyword + "_" + + elif keyword[0].isdigit(): + new_key = "_" + keyword + + elif keyword == "$fn": + new_key = "segments" + + elif keyword[0] == "$": + new_key = "__" + keyword[1:] + 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") + 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 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_' """ - shortened = subbed_keyword[:-1] - return shortened if shortened in PYTHON_ONLY_RESERVED_WORDS else subbed_keyword + 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:] + + elif subbed_keyword.startswith("_") and subbed_keyword[1].isdigit(): + return subbed_keyword[1:] + + elif subbed_keyword == "segments": + return "$fn" + + return subbed_keyword # now that we have the base class defined, we can do a circular import -from . import objects +from . import objects # noqa: E402 def py2openscad(o: Union[bool, float, str, Iterable]) -> str: - if type(o) == bool: + if type(o) is bool: return str(o).lower() - if type(o) == float: + if type(o) is float: return f"{o:.10f}" # type: ignore - if type(o) == str: - return f'\"{o}\"' # 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 diff --git a/solid/splines.py b/solid/splines.py index 02878c67..9a954630 100644 --- a/solid/splines.py +++ b/solid/splines.py @@ -1,36 +1,58 @@ #! /usr/bin/env python from math import pow -from solid import circle, cylinder, polygon, color, OpenSCADObject, translate, linear_extrude -from solid.utils import bounding_box, right, Red +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[Point23, Point23, Point23, Point23] +FourPoints = Tuple[Point23Input, Point23Input, Point23Input, Point23Input] SEGMENTS = 48 DEFAULT_SUBDIVISIONS = 10 DEFAULT_EXTRUDE_HEIGHT = 1 + # ======================= # = CATMULL-ROM SPLINES = # ======================= -def catmull_rom_polygon(points: Sequence[Point23], - subdivisions: int = DEFAULT_SUBDIVISIONS, - extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, - show_controls: bool =False, - center: bool=True) -> OpenSCADObject: +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 + 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 + 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. """ @@ -43,57 +65,73 @@ def catmull_rom_polygon(points: Sequence[Point23], shape += control_points(points, extrude_height, center) return shape -def catmull_rom_points( points: Sequence[Point23], - subdivisions:int = 10, - close_loop: bool=False, - start_tangent: Vec23 = None, - end_tangent: Vec23 = None) -> List[Point23]: + +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 `subdivision` 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 + 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: + Credit due: Largely taken from C# code at: https://www.habrador.com/tutorials/interpolation/1-catmull-rom-splines/ retrieved 20190712 """ - catmull_points: List[Point23] = [] - cat_points: List[Point23] = [] - points_list = cast(List[Point23], points) + 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 = [points[-1]] + points_list + [points[0]] + 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[1] - points[0]) - end_tangent = end_tangent or (points[-2] - points[-1]) - cat_points = [points[0]+ start_tangent] + points_list + [points[-1] + end_tangent] + 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) - 2 if close_loop else len(cat_points) - 3 + 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] + 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) + 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[Point23]: + +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]. + controls[2]. No reason to call this unless you're trying to do something very specific """ @@ -104,99 +142,381 @@ def _catmull_rom_segment(controls: FourPoints, if include_last: num_points += 1 - p0, p1, p2, p3 = controls + 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 + c = 2 * p0 - 5 * p1 + 4 * p2 - p3 + d = -p0 + 3 * p1 - 3 * p2 + p3 for i in range(num_points): - t = i/subdivisions + t = i / subdivisions pos = 0.5 * (a + (b * t) + (c * t * t) + (d * t * t * t)) - positions.append(pos) + 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: +# 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: + +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) - shape = polygon(points) + # 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) + 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[Point2]: + +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 + 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: + 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[Point2] = [] + points: List[Point3] = [] last_elt = 1 if include_last else 0 for i in range(subdivisions + last_elt): - u = i/subdivisions + u = i / subdivisions points.append(_point_along_bez4(*controls, u)) return points -def _point_along_bez4(p0: Point23, p1: Point23, p2: Point23, p3: Point23, u:float) -> Point2: - 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 - return Point2(x,y) -def _bez03(u:float) -> float: - return pow((1-u), 3) +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 _bez13(u:float) -> float: - return 3*u*(pow((1-u),2)) +def _bez03(u: float) -> float: + return pow((1 - u), 3) -def _bez23(u:float) -> float: - return 3*(pow(u,2))*(1-u) -def _bez33(u:float) -> float: - return pow(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) -> OpenSCADObject: +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 + 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 # + 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(Red)([translate([p.x, p.y])(c) for p in points]) + 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/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 3732b294..a40ad1a0 100755 --- a/solid/test/run_all_tests.sh +++ b/solid/test/run_all_tests.sh @@ -4,8 +4,16 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" cd $DIR -# Let unittest discover all the tests -python -m unittest discover . +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; + python3 $i; + echo +done + # revert to original dir -cd - \ No newline at end of file +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 5a09320d..1827ac12 100755 --- a/solid/test/test_screw_thread.py +++ b/solid/test/test_screw_thread.py @@ -8,30 +8,34 @@ SEGMENTS = 4 -def remove_whitespace(a_str): - return re.subn(r'[\s\n]','', a_str)[0] 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) + 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): - 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_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 = '''intersection(){ + 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]] ); @@ -39,41 +43,46 @@ def test_thread(self): 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): - 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_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 = '''intersection() { + 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 = remove_whitespace(scad_render(actual_obj)) - expected = '''intersection(){ - polyhedron( + 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]] ); @@ -81,25 +90,30 @@ def test_conical_thread_external(self): 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_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(){ + 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);}''' + 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): @@ -110,16 +124,19 @@ def test_default_thread_section(self): 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_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(){ + 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]] ); @@ -127,9 +144,9 @@ def test_neck_in_out_degrees(self): 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 dce2513a..3e2e1bf2 100755 --- a/solid/test/test_solidpython.py +++ b/solid/test/test_solidpython.py @@ -6,50 +6,309 @@ 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, 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.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 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': 'resize', 'kwargs': {'newsize': [5, 5, 5], 'auto': [True, True, False]}, 'expected': '\n\nresize(auto = [true, true, false], newsize = [5, 5, 5]);', 'args': {}, }, - {'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': 'offset', 'kwargs': {'r': 1}, 'expected': '\n\noffset(r = 1);', 'args': {}, }, - {'name': 'offset', 'kwargs': {'delta': 1}, 'expected': '\n\noffset(chamfer = false, delta = 1);', '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': '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', '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', 'kwargs': {'angle': 90, 'segments': 4, 'convexity': None}, 'expected': '\n\nrotate_extrude($fn = 4, angle = 90);', '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]}, + }, ] @@ -69,7 +328,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): try: - with open(self.name, 'r') as f: + with open(self.name, "r") as f: self.contents = f.read() finally: self._cleanup() @@ -77,7 +336,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def _cleanup(self): try: os.unlink(self.name) - except: + except Exception: pass @@ -91,27 +350,27 @@ def expand_scad_path(self, filename): 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, + module hex (width=10, height=10, flats= true, center=false){} function righty (angle=90) = 1; function lefty(avar) = 2; @@ -132,55 +391,110 @@ def test_parse_scad_callables(self): 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_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_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_conditionnal_assignment(var_with_conditionnal_assignment = mytest ? 45 : yop){} + 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': ['avar'], 'kwargs': []}, - {'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': ['var5'], 'kwargs': []}, - {'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_conditionnal_assignment', 'args': [], 'kwargs': ['var_with_conditionnal_assignment']} + { + "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(test_str) - self.assertEqual(expected, actual) + + 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) @@ -198,13 +512,81 @@ def test_import_scad(self): self.assertEqual(expected, actual) # Make sure this plays nicely with `scad_render()`'s `file_header` arg - header = '$fn = 24;' + 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''' + 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: @@ -213,12 +595,12 @@ def test_use_reserved_words(self): f.write(scad_str) use(path) - a = reserved_word_arg(or_=5) + 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) + 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) @@ -227,9 +609,9 @@ def test_use_reserved_words(self): 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) @@ -248,31 +630,50 @@ def test_extra_args_to_included_scad(self): 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) @@ -280,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\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}' + 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) @@ -316,13 +711,14 @@ 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\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}' + 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 @@ -330,11 +726,16 @@ def my_animate(_time=0): return c with TemporaryFileBuffer() as tmp: - scad_render_animated_file(my_animate, steps=2, back_and_forth=False, - filepath=tmp.name, include_orig_code=False) + 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' + 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) @@ -346,7 +747,7 @@ def test_scad_render_to_file(self): scad_render_to_file(a, filepath=tmp.name, include_orig_code=False) actual = tmp.contents - expected = '\n\ncircle(r = 10);' + 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 @@ -354,11 +755,12 @@ def test_scad_render_to_file(self): # Header with TemporaryFileBuffer() as tmp: - scad_render_to_file(a, filepath=tmp.name, include_orig_code=False, - file_header='$fn = 24;') + 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);' + expected = "$fn = 24;\n\n\ncircle(r = 10);" self.assertTrue(actual.endswith(expected)) @@ -366,14 +768,14 @@ def test_scad_render_to_file(self): # Using existing directory with TemporaryFileBuffer() as tmp: out_dir = Path(tmp.name).parent - expected = (out_dir / 'test_solidpython.scad').as_posix() + 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() + 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) @@ -382,11 +784,14 @@ def test_scad_render_to_file(self): def test_numpy_type(self): try: - import numpy + import numpy # type: ignore + numpy_cube = cube(size=numpy.array([1, 2, 3])) - expected = '\n\ncube(size = [1,2,3]);' + expected = "\n\ncube(size = [1,2,3]);" actual = scad_render(numpy_cube) - self.assertEqual(expected, actual, 'Numpy SolidPython not rendered correctly') + self.assertEqual( + expected, actual, "Numpy SolidPython not rendered correctly" + ) except ImportError: pass @@ -397,7 +802,7 @@ class CustomIterable: def __iter__(self): return iter([1, 2, 3]) - expected = '\n\ncube(size = [1, 2, 3]);' + expected = "\n\ncube(size = [1, 2, 3]);" iterables = [ [1, 2, 3], (1, 2, 3), @@ -408,19 +813,27 @@ def __iter__(self): for iterable in iterables: name = type(iterable).__name__ actual = scad_render(cube(size=iterable)) - self.assertEqual(expected, actual, f'{name} SolidPython not rendered correctly') + 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 += f"{k}={v}, " for k, v in kwargs.items(): call_str += f"{k}={v}, " - call_str += ')' + call_str += ")" scad_obj = eval(call_str) actual = scad_render(scad_obj) @@ -437,6 +850,6 @@ def generate_cases_from_templates(): 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 old mode 100644 new mode 100755 index 21fcba41..80b4cc2c --- a/solid/test/test_splines.py +++ b/solid/test/test_splines.py @@ -2,49 +2,136 @@ import unittest from solid.test.ExpandedTestCase import DiffOutput -from solid import * -from solid.splines import catmull_rom_points, bezier_points -from euclid3 import Point2, Vector2 +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 = [ - Point2(0,0), - Point2(1,1), - Point2(2,1), + Point3(0, 0), + Point3(1, 1), + Point3(2, 1), + ] + self.points_raw = [ + (0, 0), + (1, 1), + (2, 1), ] self.bezier_controls = [ - Point2(0,0), - Point2(1,1), - Point2(2,1), - Point2(2,-1), + 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) + 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 = [Vector2(0.00, 0.00), Vector2(0.38, 0.44), Vector2(1.00, 1.00), Vector2(1.62, 1.06), Vector2(2.00, 1.00)] - actual = catmull_rom_points(self.points, subdivisions=self.subdivisions, close_loop=False) + 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) - # actual = list((str(v) for v in actual)) - # expected = list((str(v) for v in expected)) - # self.assertEqual(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 catmull_rom_polygon() returns an OpenSCADObject + # 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 = [Point2(0.00, 0.00), Point2(1.38, 0.62), Point2(2.00, -1.00)] + 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() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/solid/test/test_utils.py b/solid/test/test_utils.py index 116582bc..89ff7724 100755 --- a/solid/test/test_utils.py +++ b/solid/test/test_utils.py @@ -1,57 +1,152 @@ #! /usr/bin/env python -import difflib +from solid.solidpython import OpenSCADObject import unittest - -from euclid3 import Point3, Vector3 +import re +from euclid3 import Point3, Vector3, Point2 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, extrude_along_path, fillet_2d, is_scad, offset_points, split_body_planar, transform_to_point +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, 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}'), - ('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]]);'), +scad_test_cases = [ + # 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] @@ -59,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, 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, 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}\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, 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, 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}\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_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 test_generator_scad(func, args, expected): +def generate_scad_test(func, args, expected): def test_scad(self): scad_obj = func(*args) actual = scad_render(scad_obj) @@ -104,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) @@ -116,10 +278,10 @@ 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 = f'test_{func.__name__}' + test_name = f"test_{func.__name__}" elif len(test_tuple) == 4: test_name, func, args, expected = test_tuple - test_name = f'test_{test_name}' + test_name = f"test_{test_name}" else: print(f"test_tuple has {len(test_tuple):d} args :{test_tuple}") return test_name, func, args, expected @@ -128,15 +290,15 @@ def read_test_tuple(test_tuple): 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 ed16d667..ae2e966d 100755 --- a/solid/utils.py +++ b/solid/utils.py @@ -1,103 +1,123 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import division -import sys from itertools import zip_longest -from math import pi, ceil, floor, sqrt, atan2, degrees, radians +from math import ceil, sqrt, atan2, degrees -from solid import union, cube, translate, rotate, square, circle, polyhedron -from solid import difference, intersection, multmatrix +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, P2, P3, P4, Vec3 , Vec4, Vec34, P3s, P23 -from solid import Points, Indexes, ScadSize - -from typing import Union, Tuple, Sequence, List, Optional, Callable - +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() -EucOrTuple = Union[Point3, - Vector3, - Tuple[float, float], - Tuple[float, float, float] - ] +# ========== +# = 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 = +# ============= EPSILON = 0.01 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) +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:int=12, count:int=10, line_weight:float=0.1, plane:str='xz') -> OpenSCADObject: - +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') + t.set_modifier("background") for i in range(int(-count / 2), int(count / 2 + 1)): - if 'xz' in plane: + 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:Sequence[OpenSCADObject], - max_bounding_box:Tuple[float,float], - rows_and_cols: Tuple[int,int]=None) -> OpenSCADObject: +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. # @@ -120,7 +140,7 @@ def distribute_in_grid(objects:Sequence[OpenSCADObject], 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,18 +149,18 @@ def distribute_in_grid(objects:Sequence[OpenSCADObject], for x in range(grid_w): if objs_placed < len(objects): ret.append( - translate((x * x_trans, y * y_trans, 0))(objects[objs_placed])) + translate((x * x_trans, y * y_trans, 0))(objects[objs_placed]) + ) objs_placed += 1 else: break return union()(*ret) + # ============== # = Directions = # ============== - - -def up(z:float) -> OpenSCADObject: +def up(z: float) -> OpenSCADObject: return translate((0, 0, z)) @@ -167,88 +187,92 @@ def back(y: float) -> OpenSCADObject: # =========================== # = Box-alignment rotations = # =========================== -def rot_z_to_up(obj:OpenSCADObject) -> OpenSCADObject: +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:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_down(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=180, v=FORWARD_VEC)(obj) -def rot_z_to_right(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_right(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=FORWARD_VEC)(obj) -def rot_z_to_left(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_left(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=FORWARD_VEC)(obj) -def rot_z_to_forward(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_forward(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=RIGHT_VEC)(obj) -def rot_z_to_back(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_back(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=RIGHT_VEC)(obj) # ================================ # = Box-aligment and translation = # ================================ -def box_align(obj:OpenSCADObject, - direction_func:Callable[[float], OpenSCADObject]=up, - distance:float=0) -> OpenSCADObject: +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:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_x(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=FORWARD_VEC)(obj) -def rot_z_to_neg_x(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_neg_x(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=FORWARD_VEC)(obj) -def rot_z_to_neg_y(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_neg_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=RIGHT_VEC)(obj) -def rot_z_to_y(obj:OpenSCADObject) -> OpenSCADObject: +def rot_z_to_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=RIGHT_VEC)(obj) -def rot_x_to_y(obj:OpenSCADObject) -> OpenSCADObject: +def rot_x_to_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=UP_VEC)(obj) -def rot_x_to_neg_y(obj:OpenSCADObject) -> OpenSCADObject: +def rot_x_to_neg_y(obj: OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=UP_VEC)(obj) + # ======= # = Arc = # ======= - - -def arc(rad:float, start_degrees:float, end_degrees:float, segments:int=None) -> OpenSCADObject: +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. @@ -263,21 +287,23 @@ def arc(rad:float, start_degrees:float, end_degrees:float, segments:int=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:float, start_degrees:float, end_degrees:float, segments:int=None) -> OpenSCADObject: +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. @@ -312,15 +338,20 @@ def arc_inverted(rad:float, start_degrees:float, end_degrees:float, segments:int # 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, 0))(square([wide, high], center=False)) - bottom_half_square = translate((-(wide - rad), -high, 0))(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) @@ -329,15 +360,15 @@ def arc_inverted(rad:float, start_degrees:float, end_degrees:float, segments:int 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 @@ -349,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 @@ -410,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 @@ -448,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] @@ -457,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) @@ -467,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 @@ -486,77 +530,118 @@ 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()` +# `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. +# 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 +# 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_parts_dict = {} -g_bom_headers: List[str] = [] +g_bom_headers: list[str] = [] + def set_bom_headers(*args): global g_bom_headers g_bom_headers += args -def bom_part(description='', per_unit_price=None, currency='US$', *args, **kwargs): + +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__ - elements = {} - elements.update({'Count':0, 'currency':currency, 'Unit Price':per_unit_price}) + 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(dict(zip_longest(g_bom_headers, args, fillvalue=""))) elements.update(kwargs) - g_parts_dict[name] = elements - def wrapped_f(*wargs, **wkwargs): - name = description if description else f.__name__ - g_parts_dict[name]['Count'] += 1 - return 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(csv=False): + +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 = {} - for desc, elements in g_parts_dict.items(): - count = elements['Count'] - currency = elements['currency'] - price = elements['Unit Price'] + + 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 if currency not in all_costs: - all_costs[currency] = 0 - + all_costs[currency] = 0 + all_costs[currency] += total unit_price = _currency_str(price, currency) total_price = _currency_str(total, currency) @@ -577,28 +662,33 @@ def bill_of_materials(csv=False): row = empty_row[:] row[0] = "Total Cost, {currency:>4}".format(**vars()) row[3] = "{currency:>4} {cost:.2f}".format(**vars()) - + rows.append(row) res = _table_string(field_names, rows, csv) return res -def _currency_str(value, currency="$"): + +def _currency_str(value: float, currency: str = "$") -> str: return "{currency:>4} {value:.2f}".format(**vars()) - -def _table_string(field_names, rows, csv=False): + + +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 + # 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 as e: + except ImportError: print("Unable to import prettytable module. Outputting in TSV format") csv = True if csv: @@ -607,16 +697,15 @@ def _table_string(field_names, rows, csv=False): line = "\t".join([str(f) for f in row]) lines.append(line) - res = "\n".join(lines) - - return res + "\n" + 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 = [] @@ -624,142 +713,269 @@ 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}, + "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 + EPSILON), - up(screw_length)( - cylinder(cap_rad, cap_height) - ) + 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='624'): + +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) - ) + 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 = -# = -------------- = -def euclidify(an_obj:EucOrTuple, - intended_class=Vector3) -> Union[Point3, 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 # type: ignore - -def euc_to_arr(euc_obj_or_list: EucOrTuple) -> List[float]: # Inverse of euclidify() +# ================== +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. + + # -- 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] = [] + 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 + 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 + result = euc_obj_or_list # type: ignore return result -def is_scad(obj:OpenSCADObject) -> bool: + +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] - ] + 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, dest_point, dest_normal, src_point=Point3(0, 0, 0), src_normal=Vector3(0, 1, 0), src_up=Vector3(0, 0, 1)): +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 @@ -781,16 +997,16 @@ def transform_to_point(body, dest_point, dest_normal, src_point=Point3(0, 0, 0), src_up = EUC_FORWARD else: 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), + """ + 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) @@ -798,9 +1014,9 @@ def _orig_euclid_look_at(eye, at, up): 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 @@ -813,14 +1029,18 @@ def _orig_euclid_look_at(eye, at, up): 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. +# ======================================== +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 @@ -844,8 +1064,7 @@ def draw_segment(euc_line=None, endless=False, arrow_rad=7, vec_color=None): ) if endless: endless_length = max(v.magnitude() * 10, 200) - arrow += cylinder(r=vec_arrow_rad / 3, - h=endless_length, center=True) + arrow += cylinder(r=vec_arrow_rad / 3, h=endless_length, center=True) arrow = transform_to_point(body=arrow, dest_point=p, dest_normal=v) @@ -854,322 +1073,260 @@ def draw_segment(euc_line=None, endless=False, arrow_rad=7, vec_color=None): return arrow + # ========== # = Offset = -# = ------ = -LEFT, RIGHT = radians(90), radians(-90) - -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]) +# ========== +# 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: - 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) + # 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 - 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 - else: - return RIGHT -def _other_dir(left_or_right:int) -> int: - if left_or_right == LEFT: - return RIGHT - else: - return LEFT -def _three_point_normal(a:Point3, b:Point3, c:Point3) -> Vector3: - ab = b - a - bc = c - b +def pairwise_zip(ls: Sequence) -> zip: # type:ignore + return zip(ls[:-1], ls[1:]) - 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: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 +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 - 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 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 fillet_2d(three_point_sets:Sequence[Tuple[Point2, Point2, Point2]], - orig_poly:OpenSCADObject, - fillet_rad:float, - remove_material:bool=True) -> OpenSCADObject: - # 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] # type: ignore - - 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) +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 + - # 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] +# ============= +# = 2D Fillet = +# ============= - 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) +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`. - # 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) + 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 - afp, cfp = [ - seg.p1 if seg.p1 != cp2 else seg.p2 for seg in (afs, cfs)] + direction = direction_of_bend(a, b, c) - a_degs, c_degs = [ - (degrees(atan2(seg.v.y, seg.v.x))) % 360 for seg in (afs, cfs)] + # 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_degs = a_degs - end_degs = c_degs + start_degrees = degrees(atan2(ab_perp.y, ab_perp.x)) + end_degrees = degrees(atan2(bc_perp.y, bc_perp.x)) - # Widen start_degs and end_degs slightly so they overlap the areas + # Widen start_degrees and end_degrees slightly so they overlap the areas # they're supposed to join/ remove. - start_degs, end_degs = _widen_angle_for_fillet(start_degs, end_degs) + start_degrees, end_degrees = _widen_angle_for_fillet(start_degrees, end_degrees) - arc_obj = translate(center_pt.as_arr())( + arc_obj = translate(center.as_arr())( arc_inverted( - rad=fillet_rad, start_degrees=start_degs, end_degrees=end_degs) + rad=fillet_rad, start_degrees=start_degrees, end_degrees=end_degrees + ) ) - arc_objs.append(arc_obj) if remove_material: - poly = orig_poly - arc_objs # type: ignore + poly = orig_poly - arc_objs else: - poly = orig_poly + arc_objs # type: ignore + poly = orig_poly + arc_objs return poly -# ========================== -# = Extrusion along a path = -# = ---------------------- = -# Possible: twist -def extrude_along_path(shape_pts:Points, - path_pts:Points, - scale_factors:Sequence[float]=None) -> OpenSCADObject: - # 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:Points= [] - facet_indices:List[Tuple[int, int, int]] = [] - - 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 - this_loop:Point3 = [] - 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[:] # type: ignore - - # 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) # type: ignore +# ============== +# = 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(). + 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. @@ -1195,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: @@ -1224,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:OpenSCADObject, vars_to_print:Sequence[str]=None) -> str: +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 @@ -1253,6 +1423,13 @@ def obj_tree_str(sp_obj:OpenSCADObject, vars_to_print:Sequence[str]=None) -> str # Add all children for c in sp_obj.children: - s += indent(obj_tree_str(c, vars_to_print)) # type: ignore + 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 3214ddc8..e4867642 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,17 @@ -# content of: tox.ini , put in same dir as setup.py [tox] -# envlist = py27,py36 -skipsdist = True -envlist = py37 -# formerly included ,pep8 +envlist = tests, docs +skipsdist = true [testenv] -whitelist_externals = poetry -commands= - poetry install -v - python solid/test/test_screw_thread.py - python solid/test/test_solidpython.py - python solid/test/test_utils.py +allowlist_externals = uv +commands_pre = + uv sync --group dev +commands = + uv run pytest [testenv:docs] -deps=sphinx -commands = python setup.py build_sphinx - -# [testenv:pep8] -# deps=flake8 -# commands = -# flake8 solid -# -# [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 +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" }, +]