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 e8a7ddf8..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,10 +31,10 @@ 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>`__ +- `solid.splines <#solidsplines>`__ - `Jupyter Renderer <#jupyter-renderer>`__ - `Contact <#contact>`__ - `License <#license>`__ @@ -46,7 +50,7 @@ simple example: This Python code: -:: +.. code:: python from solid import * d = difference()( @@ -57,7 +61,7 @@ This Python code: Generates this OpenSCAD code: -:: +.. code:: python difference(){ cube(10); @@ -67,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 * @@ -75,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(){ @@ -101,38 +105,43 @@ or impossible in pure OpenSCAD. Among these are: Installing SolidPython ====================== -- Install via +- Install latest release via `PyPI `__: - :: + .. code:: bash pip install solidpython (You may need to use ``sudo pip install solidpython``, depending on your environment. This is commonly discouraged though. You'll be happiest - working in a `virtual environnment `__ + working in a `virtual environment `__ where you can easily control dependencies for a given project) +- Install current master straight from Github: + + .. code:: bash + + pip install git+https://github.com/SolidCode/SolidPython.git Using SolidPython ================= - Include SolidPython at the top of your Python file: - :: + .. code:: python - from solid import * - from solid.utils import * # Not required, but the utils module is useful + 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); @@ -141,7 +150,7 @@ Using SolidPython **SolidPython:** - :: + .. code:: d = difference()( cube(10), # Note the comma between each element! @@ -161,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 * @@ -183,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...] @@ -198,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 * @@ -223,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') @@ -244,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), @@ -259,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), @@ -291,7 +308,7 @@ structure. Example: -:: +.. code:: python outer = cylinder(r=pipe_od, h=seg_length) inner = cylinder(r=pipe_id, h=seg_length) @@ -327,7 +344,7 @@ Currently these include: Directions: (up, down, left, right, forward, back) for arranging things: ------------------------------------------------------------------------ -:: +.. code:: python up(10)( cylinder() @@ -335,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() @@ -350,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) @@ -367,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 ----------------- @@ -427,11 +415,33 @@ See `solid/examples/screw_thread_example.py `__ for more details. +solid.splines +------------- + +`solid.splines` contains functions to generate smooth Catmull-Rom curves through +control points. + +:: + + from solid import translate + from solid.splines import catmull_rom_polygon, bezier_polygon + from euclid3 import Point2 + + points = [ Point2(0,0), Point2(1,1), Point2(2,1), Point2(2,-1) ] + shape = catmull_rom_polygon(points, show_controls=True) + + bezier_shape = translate([3,0,0])(bezier_polygon(points, subdivisions=20)) + +See +`solid/examples/splines_example.py `__ +for more details and options. + Jupyter Renderer ---------------- Render SolidPython or OpenSCAD code in Jupyter notebooks using `ViewSCAD `__, or install directly via: -:: + +.. code:: bash pip install viewscad diff --git a/pyproject.toml b/pyproject.toml index 2495774c..06e88e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,53 @@ -[tool.poetry] +[project] name = "solidpython" -version = "0.4.4" +version = "1.1.5" description = "Python interface to the OpenSCAD declarative geometry language" -authors = ["Evan Jones "] -url="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" - -classifiers=[ +keywords = [ + "3D", + "CAD", + "CSG", + "constructive solid geometry", + "geometry", + "modeling", + "OpenSCAD", +] +classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.7", "Development Status :: 4 - Beta", "Environment :: Other Environment", "Intended Audience :: Developers", - "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 new file mode 100755 index 00000000..002b5049 --- /dev/null +++ b/solid/examples/splines_example.py @@ -0,0 +1,170 @@ +#! /usr/bin/env python +import os +import sys +from solid import * +from solid.utils import Red, right, forward, back + +from solid.splines import catmull_rom_points, catmull_rom_polygon, control_points +from solid.splines import bezier_polygon, bezier_points +from euclid3 import Vector2, Vector3, Point2, Point3 + +def assembly(): + # Catmull-Rom Splines + a = basic_catmull_rom() # Top row in OpenSCAD output + a += back(4)(catmull_rom_spline_variants()) # Row 2 + a += back(12)(bottle_shape(width=2, height=6)) # Row 3, the bottle shape + + # # TODO: include examples for 3D surfaces: + # a += back(16)(catmull_rom_patches()) + # a += back(20)(catmull_rom_prism()) + # a += back(24)(catmull_rom_prism_smooth()) + + # Bezier Splines + a += back(16)(basic_bezier()) # Row 4 + a += back(20)(bezier_points_variants()) # Row 5 + return a + +def basic_catmull_rom(): + points = [ + Point2(0,0), + Point2(1,1), + Point2(2,1), + Point2(2,-1), + ] + # In its simplest form, catmull_rom_polygon() will just make a C1-continuous + # closed shape. Easy. + shape_easy = catmull_rom_polygon(points) + # There are some other options as well... + shape = catmull_rom_polygon(points, subdivisions=20, extrude_height=5, show_controls=True) + return shape_easy + right(3)(shape) + +def catmull_rom_spline_variants(): + points = [ + Point2(0,0), + Point2(1,1), + Point2(2,1), + Point2(2,-1), + ] + controls = control_points(points) + + # By default, catmull_rom_points() will return a closed smooth shape + curve_points_closed = catmull_rom_points(points, close_loop=True) + + # If `close_loop` is False, it will return only points between the start and + # end control points, and make a best guess about tangents for the first and last segments + curve_points_open = catmull_rom_points(points, close_loop=False) + + # By specifying start_tangent and end_tangent, you can change a shape + # significantly. This is similar to what you might do with Illustrator's Pen Tool. + # Try changing these vectors to see the effects this has on the rightmost curve in the example + start_tangent = Vector2(-2, 0) + end_tangent = Vector2(3, 0) + tangent_pts = [points[0] + start_tangent, *points, points[-1] + end_tangent] + tangent_controls = control_points(tangent_pts) + curve_points_tangents = catmull_rom_points(points, close_loop=False, + start_tangent=start_tangent, end_tangent=end_tangent) + + closed = polygon(curve_points_closed) + controls + opened = polygon(curve_points_open) + controls + tangents = polygon(curve_points_tangents) + tangent_controls + + a = closed + right(3)(opened) + right(10)(tangents) + + return a + +def catmull_rom_patches(): + # TODO: write this + pass + +def catmull_rom_prism(): + # TODO: write this + pass + +def catmull_rom_prism_smooth(): + # TODO: write this + pass + +def bottle_shape(width: float, height: float, neck_width:float=None, neck_height:float=None): + if neck_width == None: + neck_width = width * 0.4 + + if neck_height == None: + neck_height = height * 0.2 + + w2 = width/2 + nw2 = neck_width/2 + h = height + nh = neck_height + + corner_rad = 0.5 + + # Add extra tangent points near curves to keep cubics from going crazy. + # Try taking some of these out and see how this affects the final shape + points = [ + Point2(nw2, h), + Point2(nw2, h-nh + 1), # <- extra tangent + Point2(nw2, h - nh), + Point2(w2, h-nh-h/6), # <- extra tangent + Point2(w2, corner_rad + 1), # <- extra tangent + Point2(w2, corner_rad), + Point2(w2-corner_rad, 0), + Point2(0,0), + ] + # Use catmull_rom_points() when you don't want all corners in a polygon + # smoothed out or want to combine the curve with other shapes. + # Extra points can then be added to the list you get back + cr_points = catmull_rom_points(points) + + # Insert a point at the top center of the bottle at the beginning of the + # points list. This is how the bottle has a sharp right angle corner at the + # sides of the neck; otherwise we'd have to insert several extra control + # points to make a sharp corner + cr_points.insert(0, (0,h)) + + # Make OpenSCAD polygons out of the shapes once all points are calculated + a = polygon(cr_points) + a += mirror(v=(1,0))(a) + + # Show control points. These aren't required for anything, but seeing them + # makes refining a curve much easier + controls = control_points(points) + a += controls + return a + +def basic_bezier(): + # A basic cubic Bezier curve will pass through its first and last + # points, but not through the central control points + controls = [ + Point2(0, 3), + Point2(1, 1), + Point2(2, 1), + Point2(3, 3) + ] + shape = bezier_polygon(controls, show_controls=True) + return shape + +def bezier_points_variants(): + controls = [ + Point2(0,0), + Point2(1,2), + Point2(2, -1), + Point2(3,0), + ] + points = bezier_points(controls, subdivisions=20) + # For non-smooth curves, add extra points + points += [ + Point2(2, -2), + Point2(1, -2) + ] + shape = polygon(points) + control_points(controls, extrude_height=0) + return shape + + +if __name__ == '__main__': + out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir + + a = assembly() + + out_path = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + print(f"{__file__}: SCAD file written to: \n{out_path}") + diff --git a/solid/extrude_along_path.py b/solid/extrude_along_path.py new file mode 100644 index 00000000..6e720706 --- /dev/null +++ b/solid/extrude_along_path.py @@ -0,0 +1,213 @@ +#! /usr/bin/env python +from math import radians +from solid import OpenSCADObject, Points, polyhedron +from solid.utils import euclidify, euc_to_arr, transform_to_point, EPSILON +from euclid3 import Point2, Point3, Vector2, Vector3 + +from typing import Optional, Sequence, Union, Callable + +Tuple2 = tuple[float, float] +FacetIndices = tuple[int, int, int] +Point3Transform = Callable[[Point3, Optional[float], Optional[float]], Point3] + + +# ========================== +# = Extrusion along a path = +# ========================== +def extrude_along_path( + shape_pts: Points, + path_pts: Points, + scales: Sequence[Union[Vector2, float, Tuple2]] = None, + rotations: Sequence[float] = None, + transforms: Sequence[Point3Transform] = None, + connect_ends=False, + cap_ends=True, +) -> OpenSCADObject: + """ + Extrude the curve defined by shape_pts along path_pts. + -- For predictable results, shape_pts must be planar, convex, and lie + in the XY plane centered around the origin. *Some* nonconvexity (e.g, star shapes) + and nonplanarity will generally work fine + + -- len(scales) should equal len(path_pts). No-op if not supplied + Each entry may be a single number for uniform scaling, or a pair of + numbers (or Point2) for differential X/Y scaling + If not supplied, no scaling will occur. + + -- len(rotations) should equal 1 or len(path_pts). No-op if not supplied. + Each point in shape_pts will be rotated by rotations[i] degrees at + each point in path_pts. Or, if only one rotation is supplied, the shape + will be rotated smoothly over rotations[0] degrees in the course of the extrusion + + -- len(transforms) should be 1 or be equal to len(path_pts). No-op if not supplied. + Each entry should be have the signature: + def transform_func(p:Point3, path_norm:float, loop_norm:float): Point3 + where path_norm is in [0,1] and expresses progress through the extrusion + and loop_norm is in [0,1] and express progress through a single loop of the extrusion + + -- if connect_ends is True, the first and last loops of the extrusion will + be joined, which is useful for toroidal geometries. Overrides cap_ends + + -- if cap_ends is True, each point in the first and last loops of the extrusion + will be connected to the centroid of that loop. For planar, convex shapes, this + works nicely. If shape is less planar or convex, some self-intersection may happen. + Not applied if connect_ends is True + """ + + polyhedron_pts: Points = [] + facet_indices: list[tuple[int, int, int]] = [] + + # Make sure we've got Euclid Point3's for all elements + shape_pts = euclidify(shape_pts, Point3) + path_pts = euclidify(path_pts, Point3) + + src_up = Vector3(0, 0, 1) + + shape_pt_count = len(shape_pts) + + tangent_path_points: list[Point3] = [] + + # If first & last points are the same, let's close the shape + first_last_equal = (path_pts[0] - path_pts[-1]).magnitude_squared() < EPSILON + if first_last_equal: + connect_ends = True + path_pts = path_pts[:][:-1] + + if connect_ends: + tangent_path_points = [path_pts[-1]] + path_pts + [path_pts[0]] + else: + first = Point3(*(path_pts[0] - (path_pts[1] - path_pts[0]))) + last = Point3(*(path_pts[-1] - (path_pts[-2] - path_pts[-1]))) + tangent_path_points = [first] + path_pts + [last] + tangents = [ + tangent_path_points[i + 2] - tangent_path_points[i] + for i in range(len(path_pts)) + ] + + for which_loop in range(len(path_pts)): + # path_normal is 0 at the first path_pts and 1 at the last + path_normal = which_loop / (len(path_pts) - 1) + + path_pt = path_pts[which_loop] + tangent = tangents[which_loop] + scale = scales[which_loop] if scales else 1 + + rotate_degrees = None + if rotations: + rotate_degrees = ( + rotations[which_loop] + if len(rotations) > 1 + else rotations[0] * path_normal + ) + + transform_func = None + if transforms: + transform_func = ( + transforms[which_loop] if len(transforms) > 1 else transforms[0] + ) + + this_loop = shape_pts[:] + this_loop = _scale_loop(this_loop, scale) + this_loop = _rotate_loop(this_loop, rotate_degrees) + this_loop = _transform_loop(this_loop, transform_func, path_normal) + + this_loop = transform_to_point( + this_loop, dest_point=path_pt, dest_normal=tangent, src_up=src_up + ) + loop_start_index = which_loop * shape_pt_count + + if which_loop < len(path_pts) - 1: + loop_facets = _loop_facet_indices(loop_start_index, shape_pt_count) + facet_indices += loop_facets + + # Add the transformed points & facets to our final list + polyhedron_pts += this_loop + + if connect_ends: + connect_loop_start_index = len(polyhedron_pts) - shape_pt_count + loop_facets = _loop_facet_indices(connect_loop_start_index, shape_pt_count, 0) + facet_indices += loop_facets + + elif cap_ends: + # OpenSCAD's polyhedron will automatically triangulate faces as needed. + # So just include all points at each end of the tube + last_loop_start_index = len(polyhedron_pts) - shape_pt_count + start_loop_indices = list(reversed(range(shape_pt_count))) + end_loop_indices = list( + range(last_loop_start_index, last_loop_start_index + shape_pt_count) + ) + facet_indices.append(start_loop_indices) + facet_indices.append(end_loop_indices) + + return polyhedron(points=euc_to_arr(polyhedron_pts), faces=facet_indices) # type: ignore + + +def _loop_facet_indices( + loop_start_index: int, loop_pt_count: int, next_loop_start_index=None +) -> list[FacetIndices]: + facet_indices: list[FacetIndices] = [] + # nlsi == next_loop_start_index + if next_loop_start_index is None: + next_loop_start_index = loop_start_index + loop_pt_count + loop_indices = list(range(loop_start_index, loop_pt_count + loop_start_index)) + [ + loop_start_index + ] + next_loop_indices = list( + range(next_loop_start_index, loop_pt_count + next_loop_start_index) + ) + [next_loop_start_index] + + for i, (a, b) in enumerate(zip(loop_indices[:-1], loop_indices[1:])): + c, d = next_loop_indices[i : i + 2] + # OpenSCAD's polyhedron will accept quads and do its own triangulation with them, + # so we could just append (a,b,d,c). + # However, this lets OpenSCAD (Or CGAL?) do its own triangulation, leading + # to some strange outcomes. Prefer to do our own triangulation. + # c--d + # |\ | + # | \| + # a--b + # facet_indices.append((a,b,d,c)) + facet_indices.append((a, b, c)) + facet_indices.append((b, d, c)) + return facet_indices + + +def _rotate_loop( + points: Sequence[Point3], rotation_degrees: float = None +) -> list[Point3]: + if rotation_degrees is None: + return points + up = Vector3(0, 0, 1) + rads = radians(rotation_degrees) + return [p.rotate_around(up, rads) for p in points] + + +def _scale_loop( + points: Sequence[Point3], scale: Union[float, Point2, Tuple2] = None +) -> list[Point3]: + if scale is None: + return points + + if isinstance(scale, (float, int)): + scale = [scale] * 2 + return [Point3(point.x * scale[0], point.y * scale[1], point.z) for point in points] + + +def _transform_loop( + points: Sequence[Point3], + transform_func: Point3Transform = None, + path_normal: float = None, +) -> list[Point3]: + # transform_func is a function that takes a point and optionally two floats, + # a `path_normal`, in [0,1] that indicates where this loop is in a path extrusion, + # and `loop_normal` in [0,1] that indicates where this point is in a list of points + if transform_func is None: + return points + + result = [] + for i, p in enumerate(points): + # i goes from 0 to 1 across points + loop_normal = i / (len(points) - 1) + new_p = transform_func(p, path_normal, loop_normal) + result.append(new_p) + return result diff --git a/solid/objects.py b/solid/objects.py index f1820287..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,57 +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. +# -- 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. @@ -796,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: @@ -820,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 e712ef6d..d3445a8b 100755 --- a/solid/screw_thread.py +++ b/solid/screw_thread.py @@ -1,17 +1,19 @@ #! /usr/bin/env python3 -from math import ceil +import math from typing import Sequence, Tuple, Union from euclid3 import Point3, Vector3 from solid import scad_render_to_file -from solid.objects import cylinder, polyhedron, 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] @@ -20,14 +22,27 @@ Points = Sequence[P23] -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): +def map_segment( + x: float, domain_min: float, domain_max: float, range_min: float, range_max: float +) -> float: + if domain_min == domain_max or range_min == range_max: + return range_min + proportion = (x - domain_min) / (domain_max - domain_min) + return (1 - proportion) * range_min + proportion * range_max + + +def thread( + outline_pts: Points, + inner_rad: float, + pitch: float, + length: float, + external: bool = True, + segments_per_rot: int = 32, + neck_in_degrees: float = 0, + neck_out_degrees: float = 0, + rad_2: float = None, + inverse_thread_direction: bool = False, +): """ Sweeps outline_pts (an array of points describing a closed polygon in XY) through a spiral. @@ -35,7 +50,7 @@ def thread(outline_pts: Points, :param outline_pts: a list of points (NOT an OpenSCAD polygon) that define the cross section of the thread :type outline_pts: list - :param inner_rad: radius of cylinder the screw will wrap around + :param inner_rad: radius of cylinder the screw will wrap around; at base of screw :type inner_rad: number :param pitch: height for one revolution; must be <= the height of outline_pts bounding box to avoid self-intersection @@ -56,6 +71,9 @@ def thread(outline_pts: Points, :param neck_out_degrees: degrees through which outer edge of the screw thread will move from full thickness back to zero :type neck_out_degrees: number + :param rad_2: radius of cylinder the screw will wrap around at top of screw. Defaults to inner_rad + :type rad_2: number + NOTE: This functions works by creating and returning one huge polyhedron, with potentially thousands of faces. An alternate approach would make one single polyhedron,then repeat it over and over in the spiral shape, unioning them @@ -71,12 +89,20 @@ 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 total_angle = 360 * rotations up_step = length / (rotations * segments_per_rot) # Add one to total_steps so we have total_steps *segments* - total_steps = ceil(rotations * segments_per_rot) + 1 + total_steps = math.ceil(rotations * segments_per_rot) + 1 step_angle = total_angle / (total_steps - 1) all_points = [] @@ -84,28 +110,45 @@ def thread(outline_pts: Points, euc_up = Vector3(*UP_VEC) poly_sides = len(outline_pts) - # Figure out how wide the tooth profile is - min_bb, max_bb = bounding_box(outline_pts) - outline_w = max_bb[0] - min_bb[0] - outline_h = max_bb[1] - min_bb[1] - - min_rad = max(0, inner_rad - outline_w - EPSILON) - max_rad = inner_rad + outline_w + EPSILON + # Make Point3s from outline_pts and flip inward for internal threads + int_ext_angle = 0 if external else math.pi + outline_pts = [ + Point3(p[0], p[1], 0).rotate_around(axis=euc_up, theta=int_ext_angle) + for p in outline_pts + ] + + # If this screw is conical, we'll need to rotate tooth profile to + # keep it perpendicular to the side of the cone. + if inner_rad != rad_2: + cone_angle = -math.atan((rad_2 - inner_rad) / length) + outline_pts = [ + p.rotate_around(axis=Vector3(*UP_VEC), theta=cone_angle) + for p in outline_pts + ] # outline_pts, since they were created in 2D , are in the XY plane. # But spirals move a profile in XZ around the Z-axis. So swap Y and Z # coordinates... and hope users know about this - # Also add inner_rad to the profile - euc_points = [] - for p in outline_pts: - # If p is in [x, y] format, make it [x, y, 0] - if len(p) == 2: - p.append(0) - # [x, y, z] => [ x+inner_rad, z, y] - external_mult = 1 if external else -1 - # adding inner_rad, swapping Y & Z - s = Point3(external_mult * p[0], p[2], p[1]) - euc_points.append(s) + euc_points = list([Point3(p[0], 0, p[1]) for p in outline_pts]) + + # Figure out how wide the tooth profile is + min_bb, max_bb = bounding_box(outline_pts) + outline_w = max_bb[0] - min_bb[0] + # outline_h = max_bb[1] - min_bb[1] + + # Calculate where neck-in and neck-out starts/ends + neck_out_start = total_angle - neck_out_degrees + neck_distance = (outline_w + EPSILON) * (1 if external else -1) + section_rads = [ + # radius at start of thread + max(0, inner_rad - neck_distance), + # end of neck-in + map_segment(neck_in_degrees, 0, total_angle, inner_rad, rad_2), + # start of neck-out + map_segment(neck_out_start, 0, total_angle, inner_rad, rad_2), + # end of thread (& neck-out) + rad_2 - neck_distance, + ] for i in range(total_steps): angle = i * step_angle @@ -116,20 +159,25 @@ def thread(outline_pts: Points, elevation = length # Handle the neck-in radius for internal and external threads - rad = inner_rad - int_ext_mult = 1 if external else -1 - neck_in_rad = min_rad if external else max_rad - - if neck_in_degrees != 0 and angle < neck_in_degrees: - rad = neck_in_rad + int_ext_mult * angle / neck_in_degrees * outline_w - elif neck_out_degrees != 0 and angle > total_angle - neck_out_degrees: - rad = neck_in_rad + int_ext_mult * (total_angle - angle) / neck_out_degrees * outline_w + if 0 <= angle < neck_in_degrees: + rad = map_segment( + angle, 0, neck_in_degrees, section_rads[0], section_rads[1] + ) + elif neck_in_degrees <= angle < neck_out_start: + rad = map_segment( + angle, neck_in_degrees, neck_out_start, section_rads[1], section_rads[2] + ) + elif neck_out_start <= angle <= total_angle: + rad = map_segment( + angle, neck_out_start, total_angle, section_rads[2], section_rads[3] + ) elev_vec = Vector3(rad, 0, elevation) # create new points for p in euc_points: - pt = (p + elev_vec).rotate_around(axis=euc_up, theta=radians(angle)) + theta = radians(angle) * (-1 if inverse_thread_direction else 1) + pt = (p + elev_vec).rotate_around(axis=euc_up, theta=theta) all_points.append(pt.as_arr()) # Add the connectivity information @@ -138,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 @@ -147,46 +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(r=inner_rad + outline_w + EPSILON, h=length, segments=segments_per_rot) - tube -= cylinder(r=inner_rad, h=length, segments=segments_per_rot) + tube = cylinder( + r1=inner_rad + outline_w + EPSILON, + r2=rad_2 + outline_w + EPSILON, + h=length, + segments=segments_per_rot, + ) + tube -= cylinder(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) else: # If the threading is internal, intersect with a central cylinder # to make sure nothing else remains - tube = cylinder(r=inner_rad, h=length, segments=segments_per_rot) + tube = cylinder(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) a *= tube - return render()(a) + + return a def default_thread_section(tooth_height: 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) + 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 4f282908..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,15 +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" + 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. # @@ -451,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 @@ -472,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: @@ -525,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 @@ -543,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: @@ -586,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 @@ -660,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. @@ -676,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 [] @@ -694,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 @@ -702,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: @@ -713,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 @@ -732,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 new file mode 100644 index 00000000..9a954630 --- /dev/null +++ b/solid/splines.py @@ -0,0 +1,522 @@ +#! /usr/bin/env python +from math import pow + +from solid import ( + circle, + cylinder, + polygon, + color, + OpenSCADObject, + translate, + linear_extrude, + polyhedron, +) +from solid.utils import bounding_box, Red, Tuple3, euclidify +from euclid3 import Vector2, Vector3, Point2, Point3 + +from typing import Sequence, Tuple, Union, List, cast + +Point23 = Union[Point2, Point3] +# These *Input types accept either euclid3.Point* objects, or bare n-tuples +Point2Input = Union[Point2, Tuple[float, float]] +Point3Input = Union[Point3, Tuple[float, float, float]] +Point23Input = Union[Point2Input, Point3Input] + +PointInputs = Sequence[Point23Input] + +FaceTrio = Tuple[int, int, int] +CMPatchPoints = Tuple[Sequence[Point3Input], Sequence[Point3Input]] + +Vec23 = Union[Vector2, Vector3] +FourPoints = Tuple[Point23Input, Point23Input, Point23Input, Point23Input] +SEGMENTS = 48 + +DEFAULT_SUBDIVISIONS = 10 +DEFAULT_EXTRUDE_HEIGHT = 1 + + +# ======================= +# = CATMULL-ROM SPLINES = +# ======================= +def catmull_rom_polygon( + points: Sequence[Point23Input], + subdivisions: int = DEFAULT_SUBDIVISIONS, + extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, + show_controls: bool = False, + center: bool = True, +) -> OpenSCADObject: + """ + Return a closed OpenSCAD polygon object through all of `points`, + extruded to `extrude_height`. If `show_controls` is True, return red + cylinders at each of the specified control points; this makes it easier to + move determine which points should move to get a desired shape. + + NOTE: if `extrude_height` is 0, this function returns a 2D `polygon()` + object, which OpenSCAD can only combine with other 2D objects + (e.g. `square`, `circle`, but not `cube` or `cylinder`). If `extrude_height` + is nonzero, the object returned will be 3D and only combine with 3D objects. + """ + catmull_points = catmull_rom_points(points, subdivisions, close_loop=True) + shape = polygon(catmull_points) + if extrude_height > 0: + shape = linear_extrude(height=extrude_height, center=center)(shape) + + if show_controls: + shape += control_points(points, extrude_height, center) + return shape + + +def catmull_rom_points( + points: Sequence[Point23Input], + subdivisions: int = DEFAULT_SUBDIVISIONS, + close_loop: bool = False, + start_tangent: Vec23 = None, + end_tangent: Vec23 = None, +) -> List[Point3]: + """ + Return a smooth set of points through `points`, with `subdivisions` points + between each pair of control points. + + If `close_loop` is False, `start_tangent` and `end_tangent` can specify + tangents at the open ends of the returned curve. If not supplied, tangents + will be colinear with first and last supplied segments + + Credit due: Largely taken from C# code at: + https://www.habrador.com/tutorials/interpolation/1-catmull-rom-splines/ + retrieved 20190712 + """ + catmull_points: List[Point3] = [] + cat_points: List[Point3] = [] + # points_list = cast(List[Point23], points) + + points_list = list([euclidify(p, Point3) for p in points]) + + if close_loop: + cat_points = euclidify( + [points_list[-1]] + points_list + points_list[0:2], Point3 + ) + else: + # Use supplied tangents or just continue the ends of the supplied points + start_tangent = start_tangent or (points_list[1] - points_list[0]) + start_tangent = euclidify(start_tangent, Vector3) + end_tangent = end_tangent or (points_list[-2] - points_list[-1]) + end_tangent = euclidify(end_tangent, Vector3) + cat_points = ( + [points_list[0] + start_tangent] + + points_list + + [points_list[-1] + end_tangent] + ) + + last_point_range = len(cat_points) - 3 if close_loop else len(cat_points) - 3 + + for i in range(0, last_point_range): + include_last = True if i == last_point_range - 1 else False + controls = cat_points[i : i + 4] + # If we're closing a loop, controls needs to wrap around the end of the array + points_needed = 4 - len(controls) + if points_needed > 0: + controls += cat_points[0:points_needed] + controls_tuple = cast(FourPoints, controls) + catmull_points += _catmull_rom_segment( + controls_tuple, subdivisions, include_last + ) + + return catmull_points + + +def _catmull_rom_segment( + controls: FourPoints, subdivisions: int, include_last=False +) -> List[Point3]: + """ + Returns `subdivisions` Points between the 2nd & 3rd elements of `controls`, + on a quadratic curve that passes through all 4 control points. + If `include_last` is True, return `subdivisions` + 1 points, the last being + controls[2]. + + No reason to call this unless you're trying to do something very specific + """ + pos: Point23 = None + positions: List[Point23] = [] + + num_points = subdivisions + if include_last: + num_points += 1 + + p0, p1, p2, p3 = [euclidify(p, Point3) for p in controls] + a = 2 * p1 + b = p2 - p0 + c = 2 * p0 - 5 * p1 + 4 * p2 - p3 + d = -p0 + 3 * p1 - 3 * p2 + p3 + + for i in range(num_points): + t = i / subdivisions + pos = 0.5 * (a + (b * t) + (c * t * t) + (d * t * t * t)) + positions.append(Point3(*pos)) + return positions + + +def catmull_rom_patch_points( + patch: Tuple[PointInputs, PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + index_start: int = 0, +) -> Tuple[List[Point3], List[FaceTrio]]: + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + cm_points_a = catmull_rom_points(patch[0], subdivisions=subdivisions) + cm_points_b = catmull_rom_points(patch[1], subdivisions=subdivisions) + + strip_length = len(cm_points_a) + + for i in range(subdivisions + 1): + frac = i / subdivisions + verts += list( + [affine_combination(a, b, frac) for a, b in zip(cm_points_a, cm_points_b)] + ) + a_start = i * strip_length + index_start + b_start = a_start + strip_length + # This connects the verts we just created to the verts we'll make on the + # next loop. So don't calculate for the last loop + if i < subdivisions: + faces += face_strip_list(a_start, b_start, strip_length) + + return verts, faces + + +def catmull_rom_patch( + patch: Tuple[PointInputs, PointInputs], subdivisions: int = DEFAULT_SUBDIVISIONS +) -> OpenSCADObject: + faces, vertices = catmull_rom_patch_points(patch, subdivisions) + return polyhedron(faces, vertices) + + +def catmull_rom_prism( + control_curves: Sequence[PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + closed_ring: bool = True, + add_caps: bool = True, + smooth_edges: bool = False, +) -> polyhedron: + if smooth_edges: + return catmull_rom_prism_smooth_edges( + control_curves, subdivisions, closed_ring, add_caps + ) + + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + curves = list([euclidify(c) for c in control_curves]) + if closed_ring: + curves.append(curves[0]) + + curve_length = (len(curves[0]) - 1) * subdivisions + 1 + for i, (a, b) in enumerate(zip(curves[:-1], curves[1:])): + index_start = len(verts) - curve_length + first_new_vert = curve_length + if i == 0: + index_start = 0 + first_new_vert = 0 + + new_verts, new_faces = catmull_rom_patch_points( + (a, b), subdivisions=subdivisions, index_start=index_start + ) + + # new_faces describes all the triangles in the patch we just computed, + # but new_verts shares its first curve_length vertices with the last + # curve_length vertices; Add on only the new points + verts += new_verts[first_new_vert:] + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, len(verts), curve_length) + top_indices = range(curve_length - 1, len(verts), curve_length) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + + +def catmull_rom_prism_smooth_edges( + control_curves: Sequence[PointInputs], + subdivisions: int = DEFAULT_SUBDIVISIONS, + closed_ring: bool = True, + add_caps: bool = True, +) -> polyhedron: + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + # TODO: verify that each control_curve has the same length + + curves = list([euclidify(c) for c in control_curves]) + + expanded_curves = [ + catmull_rom_points(c, subdivisions, close_loop=False) for c in curves + ] + expanded_length = len(expanded_curves[0]) + for i in range(expanded_length): + contour_controls = [c[i] for c in expanded_curves] + contour = catmull_rom_points( + contour_controls, subdivisions, close_loop=closed_ring + ) + verts += contour + + contour_length = len(contour) + # generate the face triangles between the last two rows of vertices + if i > 0: + a_start = len(verts) - 2 * contour_length + b_start = len(verts) - contour_length + # Note the b_start, a_start order here. This makes sure our faces + # are pointed outwards for the test cases I ran. I think if control + # curves were specified clockwise rather than counter-clockwise, all + # of the faces would be pointed inwards + new_faces = face_strip_list( + b_start, a_start, length=contour_length, close_loop=closed_ring + ) + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, contour_length) + top_indices = range(len(verts) - contour_length, len(verts)) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + + +# ================== +# = BEZIER SPLINES = +# ================== +# Ported from William A. Adams' Bezier OpenSCAD code at: +# https://www.thingiverse.com/thing:8443 + + +def bezier_polygon( + controls: FourPoints, + subdivisions: int = DEFAULT_SUBDIVISIONS, + extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, + show_controls: bool = False, + center: bool = True, +) -> OpenSCADObject: + """ + Return an OpenSCAD object representing a closed quadratic Bezier curve. + If extrude_height == 0, return a 2D `polygon()` object. + If extrude_height > 0, return a 3D extrusion of specified height. + Note that OpenSCAD won't render 2D & 3D objects together correctly, so pick + one and use that. + """ + points = bezier_points(controls, subdivisions) + # OpenSCAD can'ts handle Point3s in creating a polygon. Convert them to Point2s + # Note that this prevents us from making polygons outside of the XY plane, + # even though a polygon could reasonably be in some other plane while remaining 2D + points = list((Point2(p.x, p.y) for p in points)) + shape: OpenSCADObject = polygon(points) + if extrude_height != 0: + shape = linear_extrude(extrude_height, center=center)(shape) + + if show_controls: + control_objs = control_points( + controls, extrude_height=extrude_height, center=center + ) + shape += control_objs + + return shape + + +def bezier_points( + controls: FourPoints, + subdivisions: int = DEFAULT_SUBDIVISIONS, + include_last: bool = True, +) -> List[Point3]: + """ + Returns a list of `subdivisions` (+ 1, if `include_last` is True) points + on the cubic bezier curve defined by `controls`. The curve passes through + controls[0] and controls[3] + + If `include_last` is True, the last point returned will be controls[3]; if + False, (useful for linking several curves together), controls[3] won't be included + + Ported from William A. Adams' Bezier OpenSCAD code at: + https://www.thingiverse.com/thing:8443 + """ + # TODO: enable a smooth curve through arbitrarily many points, as described at: + # https://www.algosome.com/articles/continuous-bezier-curve-line.html + + points: List[Point3] = [] + last_elt = 1 if include_last else 0 + for i in range(subdivisions + last_elt): + u = i / subdivisions + points.append(_point_along_bez4(*controls, u)) + return points + + +def _point_along_bez4( + p0: Point23Input, p1: Point23Input, p2: Point23Input, p3: Point23Input, u: float +) -> Point3: + p0 = euclidify(p0) + p1 = euclidify(p1) + p2 = euclidify(p2) + p3 = euclidify(p3) + + x = _bez03(u) * p0.x + _bez13(u) * p1.x + _bez23(u) * p2.x + _bez33(u) * p3.x + y = _bez03(u) * p0.y + _bez13(u) * p1.y + _bez23(u) * p2.y + _bez33(u) * p3.y + z = _bez03(u) * p0.z + _bez13(u) * p1.z + _bez23(u) * p2.z + _bez33(u) * p3.z + return Point3(x, y, z) + + +def _bez03(u: float) -> float: + return pow((1 - u), 3) + + +def _bez13(u: float) -> float: + return 3 * u * (pow((1 - u), 2)) + + +def _bez23(u: float) -> float: + return 3 * (pow(u, 2)) * (1 - u) + + +def _bez33(u: float) -> float: + return pow(u, 3) + + +# ================ +# = HOBBY CURVES = +# ================ + + +# =========== +# = HELPERS = +# =========== +def control_points( + points: Sequence[Point23], + extrude_height: float = 0, + center: bool = True, + points_color: Tuple3 = Red, +) -> OpenSCADObject: + """ + Return a list of red cylinders/circles (depending on `extrude_height`) at + a supplied set of 2D points. Useful for visualizing and tweaking a curve's + control points + """ + # Figure out how big the circles/cylinders should be based on the spread of points + min_bb, max_bb = bounding_box(points) + outline_w = max_bb[0] - min_bb[0] + outline_h = max_bb[1] - min_bb[1] + r = min(outline_w, outline_h) / 20 # + if extrude_height == 0: + c = circle(r=r) + else: + h = extrude_height * 1.1 + c = cylinder(r=r, h=h, center=center) + controls = color(points_color)([translate((p.x, p.y, 0))(c) for p in points]) + return controls + + +def face_strip_list( + a_start: int, b_start: int, length: int, close_loop: bool = False +) -> List[FaceTrio]: + # If a_start is the index of the vertex at one end of a row of points in a surface, + # and b_start is the index of the vertex at the same end of the next row of points, + # return a list of lists of indices describing faces for the whole row: + # face_strip_list(a_start = 0, b_start = 3, length=3) => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # 3-4-5 + # |/|/| + # 0-1-2 => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # + # If close_loop is true, add one more pair of faces connecting the far + # edge of the strip to the near edge, in this case [[2,3,5], [2,0,3]] + faces: List[FaceTrio] = [] + loop = length - 1 + + for a, b in zip(range(a_start, a_start + loop), range(b_start, b_start + loop)): + faces.append((a, b + 1, b)) + faces.append((a, a + 1, b + 1)) + if close_loop: + faces.append((a + loop, b, b + loop)) + faces.append((a + loop, a, b)) + return faces + + +def fan_endcap_list(cap_points: int = 3, index_start: int = 0) -> List[FaceTrio]: + """ + Return a face-triangles list for the endpoint of a tube with cap_points points + We construct a fan of triangles all starting at point index_start and going + to each point in turn. + + NOTE that this would not work for non-convex rings. + In that case, it would probably be better to create a new centroid point and have + all triangle reach out from it. That wouldn't handle all polygons, but would + work with mildly concave ones like a star, for example. + + So fan_endcap_list(cap_points=6, index_start=0), like so: + 0 + / \ + 5 1 + | | + 4 2 + \ / + 3 + + returns: [(0,1,2), (0,2,3), (0,3,4), (0,4,5)] + """ + faces: List[FaceTrio] = [] + for i in range(index_start + 1, index_start + cap_points - 1): + faces.append((index_start, i, i + 1)) + return faces + + +def centroid_endcap( + tube_points: Sequence[Point3], indices: Sequence[int], invert: bool = False +) -> Tuple[Point3, List[FaceTrio]]: + # tube_points: all points in a polyhedron tube + # indices: the indexes of the points at the desired end of the tube + # invert: if True, invert the order of the generated faces. One endcap in + # each pair should be inverted + # + # Return all the triangle information needed to make an endcap polyhedron + # + # This is sufficient for some moderately concave polygonal endcaps, + # (a star shape, say), but wouldn't be enough for more irregularly convex + # polygons (anyplace where a segment from the centroid to a point on the + # polygon crosses an edge of the polygon) + faces: List[FaceTrio] = [] + center = centroid([tube_points[i] for i in indices]) + centroid_index = len(tube_points) + + for a, b in zip(indices[:-1], indices[1:]): + faces.append((centroid_index, a, b)) + faces.append((centroid_index, indices[-1], indices[0])) + + if invert: + faces = list((reversed(f) for f in faces)) # type: ignore + + return (center, faces) + + +def centroid(points: Sequence[Point23]) -> Point23: + total = Point3(0, 0, 0) + for p in points: + total += p + total /= len(points) + return total + + +def affine_combination(a: Point23, b: Point23, fraction: float) -> Point23: + # Return a Point[23] between a & b, where fraction==0 => a, fraction==1 => b + return (1 - fraction) * a + fraction * b diff --git a/solid/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 af8a541c..a40ad1a0 100755 --- a/solid/test/run_all_tests.sh +++ b/solid/test/run_all_tests.sh @@ -4,12 +4,16 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" cd $DIR +export PYTHONPATH="../../":$PYTHONPATH +# Run all tests. Note that unittest's built-in discovery doesn't run the dynamic +# testcase generation they contain for i in test_*.py; do -echo $i; -python $i; -echo + echo $i; + python3 $i; + echo done + # revert to original dir -cd - \ 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 ac500ec4..1827ac12 100755 --- a/solid/test/test_screw_thread.py +++ b/solid/test/test_screw_thread.py @@ -1,91 +1,120 @@ #! /usr/bin/env python import unittest +import re from solid.screw_thread import default_thread_section, thread from solid.solidpython import scad_render from solid.test.ExpandedTestCase import DiffOutput -SEGMENTS = 8 +SEGMENTS = 4 class TestScrewThread(DiffOutput): def setUp(self): self.tooth_height = 10 self.tooth_depth = 5 - self.outline = default_thread_section(tooth_height=self.tooth_height, tooth_depth=self.tooth_depth) + 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 = '\n\nrender() {\n' \ - '\tintersection() {\n' \ - '\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], ' \ - '[4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], ' \ - '[6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], ' \ - '[12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], ' \ - '[16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [0, 2, 1], [18, 19, 20]], ' \ - 'points = [[14.9900000000, 0.0000000000, -5.0000000000], [19.9900000000, 0.0000000000, 0.0000000000], ' \ - '[14.9900000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -3.7500000000], ' \ - '[17.6776695297, 17.6776695297, 1.2500000000], [14.1421356237, 14.1421356237, 6.2500000000], ' \ - '[0.0000000000, 20.0000000000, -2.5000000000], [0.0000000000, 25.0000000000, 2.5000000000], ' \ - '[0.0000000000, 20.0000000000, 7.5000000000], [-14.1421356237, 14.1421356237, -1.2500000000], ' \ - '[-17.6776695297, 17.6776695297, 3.7500000000], [-14.1421356237, 14.1421356237, 8.7500000000], ' \ - '[-20.0000000000, 0.0000000000, 0.0000000000], [-25.0000000000, 0.0000000000, 5.0000000000], ' \ - '[-20.0000000000, 0.0000000000, 10.0000000000], [-14.1421356237, -14.1421356237, 1.2500000000], ' \ - '[-17.6776695297, -17.6776695297, 6.2500000000], [-14.1421356237, -14.1421356237, 11.2500000000], ' \ - '[-0.0000000000, -14.9900000000, 2.5000000000], [-0.0000000000, -19.9900000000, 7.5000000000], ' \ - '[-0.0000000000, -14.9900000000, 12.5000000000]]);\n' \ - '\t\tdifference() {\n' \ - '\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 25.0100000000);\n' \ - '\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 20);\n' \ - '\t\t}\n' \ - '\t}\n' \ - '}' - self.assertEqual(expected, actual) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-14.9900000000,2.5000000000],[-0.0000000000,-19.9900000000,7.5000000000],[-0.0000000000,-14.9900000000,12.5000000000]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=25.0100000000,r2=25.0100000000); + cylinder($fn=4,h=7.5000000000,r1=20,r2=20); + } + }""" + self.assertEqualNoWhitespace(expected, actual) def test_thread_internal(self): - 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 = '\n\nrender() {\n' \ - '\tintersection() {\n' \ - '\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], ' \ - '[3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], ' \ - '[8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], ' \ - '[9, 14, 11], [9, 12, 14], [12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], ' \ - '[12, 15, 17], [15, 16, 18], [16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], ' \ - '[18, 19, 21], [19, 22, 21], [19, 20, 22], [20, 23, 22], [18, 23, 20], [18, 21, 23], [21, 22, 24], ' \ - '[22, 25, 24], [22, 23, 25], [23, 26, 25], [21, 26, 23], [21, 24, 26], [0, 2, 1], [24, 25, 26]], ' \ - 'points = [[25.0100000000, 0.0000000000, -5.0000000000], [20.0100000000, 0.0000000000, 0.0000000000], ' \ - '[25.0100000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -2.5000000000], ' \ - '[10.6066017178, 10.6066017178, 2.5000000000], [14.1421356237, 14.1421356237, 7.5000000000], ' \ - '[0.0000000000, 20.0000000000, 0.0000000000], [0.0000000000, 15.0000000000, 5.0000000000], ' \ - '[0.0000000000, 20.0000000000, 10.0000000000], [-14.1421356237, 14.1421356237, 2.5000000000], ' \ - '[-10.6066017178, 10.6066017178, 7.5000000000], [-14.1421356237, 14.1421356237, 12.5000000000],' \ - ' [-20.0000000000, 0.0000000000, 5.0000000000], [-15.0000000000, 0.0000000000, 10.0000000000], ' \ - '[-20.0000000000, 0.0000000000, 15.0000000000], [-14.1421356237, -14.1421356237, 7.5000000000],' \ - ' [-10.6066017178, -10.6066017178, 12.5000000000], [-14.1421356237, -14.1421356237, 17.5000000000], ' \ - '[-0.0000000000, -20.0000000000, 10.0000000000], [-0.0000000000, -15.0000000000, 15.0000000000],' \ - ' [-0.0000000000, -20.0000000000, 20.0000000000], [14.1421356237, -14.1421356237, 12.5000000000],' \ - ' [10.6066017178, -10.6066017178, 17.5000000000], [14.1421356237, -14.1421356237, 22.5000000000],' \ - ' [25.0100000000, -0.0000000000, 15.0000000000], [20.0100000000, -0.0000000000, 20.0000000000], ' \ - '[25.0100000000, -0.0000000000, 25.0000000000]]);\n' \ - '\t\tcylinder($fn = 8, h = 20, r = 20);\n' \ - '\t}\n' \ - '}' - self.assertEqual(expected, actual) + expected = """intersection() { + polyhedron( + convexity=2, + faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [0, 2, 1], [12, 13, 14]], + points = [[25.0100000000, 0.0000000000, 5.0000000000], [20.0100000000, 0.0000000000, 0.0000000000], [25.0100000000, 0.0000000000, -5.0000000000], [0.0000000000, 20.0000000000, 10.0000000000], [0.0000000000, 15.0000000000, 5.0000000000], [0.0000000000, 20.0000000000, 0.0000000000], [-20.0000000000, 0.0000000000, 15.0000000000], [-15.0000000000, 0.0000000000, 10.0000000000], [-20.0000000000, 0.0000000000, 5.0000000000], [-0.0000000000, -20.0000000000, 20.0000000000], [-0.0000000000, -15.0000000000, 15.0000000000], [-0.0000000000, -20.0000000000, 10.0000000000], [25.0100000000, -0.0000000000, 25.0000000000], [20.0100000000, -0.0000000000, 20.0000000000], [25.0100000000, -0.0000000000, 15.0000000000]] + ); + cylinder($fn = 4, h = 20, r1 = 20, r2 = 20); + }""" + self.assertEqualNoWhitespace(expected, actual) + + def test_conical_thread_external(self): + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + rad_2=40, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + external=True, + ) + actual = scad_render(actual_obj) + expected = """intersection(){ + polyhedron(convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[5.9450623365,0.0000000000,-1.7556172079],[12.3823254323,0.0000000000,-4.6816458878],[15.3083541122,0.0000000000,1.7556172079],[0.0000000000,21.9850207788,0.7443827921],[0.0000000000,28.4222838746,-2.1816458878],[0.0000000000,31.3483125545,4.2556172079],[-28.6516874455,0.0000000000,3.2443827921],[-35.0889505413,0.0000000000,0.3183541122],[-38.0149792212,0.0000000000,6.7556172079],[-0.0000000000,-25.9450623365,5.7443827921],[-0.0000000000,-32.3823254323,2.8183541122],[-0.0000000000,-35.3083541122,9.2556172079]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=29.3732917757,r2=49.3732917757); + cylinder($fn=4,h=7.5000000000,r1=20,r2=40); + } + }""" + self.assertEqualNoWhitespace(expected, actual) + + def test_conical_thread_internal(self): + actual_obj = thread( + outline_pts=self.outline, + inner_rad=20, + rad_2=40, + pitch=self.tooth_height, + length=0.75 * self.tooth_height, + segments_per_rot=SEGMENTS, + neck_in_degrees=45, + neck_out_degrees=45, + external=False, + ) + actual = scad_render(actual_obj) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[34.0549376635,0.0000000000,1.7556172079],[27.6176745677,0.0000000000,4.6816458878],[24.6916458878,0.0000000000,-1.7556172079],[0.0000000000,31.3483125545,4.2556172079],[0.0000000000,24.9110494587,7.1816458878],[0.0000000000,21.9850207788,0.7443827921],[-38.0149792212,0.0000000000,6.7556172079],[-31.5777161254,0.0000000000,9.6816458878],[-28.6516874455,0.0000000000,3.2443827921],[-0.0000000000,-54.0549376635,9.2556172079],[-0.0000000000,-47.6176745677,12.1816458878],[-0.0000000000,-44.6916458878,5.7443827921]] + ); + cylinder($fn=4,h=7.5000000000,r1=20,r2=40); + }""" + self.assertEqualNoWhitespace(expected, actual) def test_default_thread_section(self): expected = [[0, -5], [5, 0], [0, 5]] @@ -95,40 +124,29 @@ 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 = '\n\nrender() {\n' \ - '\tintersection() {\n' \ - '\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], ' \ - '[4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], ' \ - '[6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], ' \ - '[12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], ' \ - '[16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [0, 2, 1], [18, 19, 20]], ' \ - 'points = [[14.9900000000, 0.0000000000, -5.0000000000], [19.9900000000, 0.0000000000, 0.0000000000], ' \ - '[14.9900000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -3.7500000000], ' \ - '[17.6776695297, 17.6776695297, 1.2500000000], [14.1421356237, 14.1421356237, 6.2500000000], ' \ - '[0.0000000000, 20.0000000000, -2.5000000000], [0.0000000000, 25.0000000000, 2.5000000000], ' \ - '[0.0000000000, 20.0000000000, 7.5000000000], [-14.1421356237, 14.1421356237, -1.2500000000], ' \ - '[-17.6776695297, 17.6776695297, 3.7500000000], [-14.1421356237, 14.1421356237, 8.7500000000], ' \ - '[-20.0000000000, 0.0000000000, 0.0000000000], [-25.0000000000, 0.0000000000, 5.0000000000], ' \ - '[-20.0000000000, 0.0000000000, 10.0000000000], [-14.1421356237, -14.1421356237, 1.2500000000], ' \ - '[-17.6776695297, -17.6776695297, 6.2500000000], [-14.1421356237, -14.1421356237, 11.2500000000], ' \ - '[-0.0000000000, -20.0000000000, 2.5000000000], [-0.0000000000, -25.0000000000, 7.5000000000], ' \ - '[-0.0000000000, -20.0000000000, 12.5000000000]]);\n' \ - '\t\tdifference() {\n' \ - '\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 25.0100000000);\n' \ - '\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 20);\n' \ - '\t\t}\n' \ - '\t}\n' \ - '}' - self.assertEqual(expected, actual) + expected = """intersection(){ + polyhedron( + convexity=2, + faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], + points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-20.0000000000,2.5000000000],[-0.0000000000,-25.0000000000,7.5000000000],[-0.0000000000,-20.0000000000,12.5000000000]] + ); + difference(){ + cylinder($fn=4,h=7.5000000000,r1=25.0100000000,r2=25.0100000000); + cylinder($fn=4,h=7.5000000000,r1=20,r2=20); + } + }""" + self.assertEqualNoWhitespace(expected, actual) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/solid/test/test_solidpython.py b/solid/test/test_solidpython.py index 249321d9..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) @@ -197,8 +511,82 @@ def test_import_scad(self): expected = f"use <{abs_path}>\n\n\nsteps(howmany = 3);" self.assertEqual(expected, actual) + # Make sure this plays nicely with `scad_render()`'s `file_header` arg + header = "$fn = 24;" + actual = scad_render(a, file_header=header) + expected = f"{header}\nuse <{abs_path}>\n\n\nsteps(howmany = 3);" + self.assertEqual(expected, actual) + + # Confirm that we can leave out even non-default arguments in OpenSCAD + a = mod.optional_nondefault_arg() + actual = scad_render(a) + expected = f"use <{abs_path}>\n\n\noptional_nondefault_arg();" + self.assertEqual(expected, actual) + # Make sure we throw ValueError on nonexistent imports + self.assertRaises(ValueError, import_scad, "path/doesnt/exist.scad") + + # Test that we recursively import directories correctly + examples = import_scad(include_file.parent) + self.assertTrue(hasattr(examples, "scad_to_include")) + self.assertTrue(hasattr(examples.scad_to_include, "steps")) + + # Test that: + # A) scad files in the designated OpenSCAD library directories + # (path-dependent, see: solid.objects._openscad_library_paths()) + # are imported correctly. + # B) scad files in the designated app-install library directories + from solid import objects + + lib_dirs = objects._openscad_library_paths() + for i, ld in enumerate(lib_dirs): + if ld.as_posix() == ".": + continue + if not ld.exists(): + continue + temp_dirname = f"test_{i}" + d = ld / temp_dirname + try: + d.mkdir(exist_ok=True) + except PermissionError: + # We won't always have permissions to write to the library directory. + # In that case, skip this test. + continue + p = d / "scad_to_include.scad" + p.write_text(include_file.read_text()) + temp_file_str = f"{temp_dirname}/scad_to_include.scad" + + mod = import_scad(temp_file_str) + a = mod.steps(3) + actual = scad_render(a) + expected = f"use <{p.absolute()}>\n\n\nsteps(howmany = 3);" + self.assertEqual( + actual, expected, f"Unexpected file contents at {p} for dir: {ld}" + ) + + # remove generated file and directories + p.unlink() + d.rmdir() + + def test_multiple_import_scad(self): + # For Issue #172. Originally, multiple `import_scad()` calls would + # re-import the entire module, rather than cache a module after one use + include_file = self.expand_scad_path("examples/scad_to_include.scad") + mod1 = import_scad(include_file) + mod2 = import_scad(include_file) + self.assertEqual(mod1, mod2) + + def test_imported_scad_arguments(self): + include_file = self.expand_scad_path("examples/scad_to_include.scad") + mod = import_scad(include_file) + points = mod.scad_points() + poly = polygon(points) + actual = scad_render(poly) + abs_path = points._get_include_path(include_file) + expected = f"use <{abs_path}>\n\n\npolygon(points = scad_points());" + self.assertEqual(expected, actual) + def test_use_reserved_words(self): - scad_str = '''module reserved_word_arg(or=3){\n\tcube(or);\n}\nmodule or(arg=3){\n\tcube(arg);\n}\n''' + 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: @@ -207,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) @@ -221,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) @@ -242,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) @@ -274,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) @@ -310,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 @@ -324,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) @@ -340,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 @@ -348,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\ncircle(r = 10);' + expected = "$fn = 24;\n\n\ncircle(r = 10);" self.assertTrue(actual.endswith(expected)) @@ -360,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) @@ -376,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 @@ -391,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), @@ -402,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) @@ -431,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 new file mode 100755 index 00000000..80b4cc2c --- /dev/null +++ b/solid/test/test_splines.py @@ -0,0 +1,137 @@ +#! /usr/bin/env python + +import unittest +from solid.test.ExpandedTestCase import DiffOutput +from solid.utils import euclidify +from solid.splines import catmull_rom_points, catmull_rom_prism, bezier_points +from euclid3 import Point3, Vector3 +from math import pi + +SEGMENTS = 8 + + +class TestSplines(DiffOutput): + def setUp(self): + self.points = [ + Point3(0, 0), + Point3(1, 1), + Point3(2, 1), + ] + self.points_raw = [ + (0, 0), + (1, 1), + (2, 1), + ] + self.bezier_controls = [ + Point3(0, 0), + Point3(1, 1), + Point3(2, 1), + Point3(2, -1), + ] + self.bezier_controls_raw = [(0, 0), (1, 1), (2, 1), (2, -1)] + self.subdivisions = 2 + + def assertPointsListsEqual(self, a, b): + str_list = lambda x: list(str(v) for v in x) # noqa: E731 + self.assertEqual(str_list(a), str_list(b)) + + def test_catmull_rom_points(self): + expected = [ + Point3(0.00, 0.00), + Point3(0.38, 0.44), + Point3(1.00, 1.00), + Point3(1.62, 1.06), + Point3(2.00, 1.00), + ] + actual = catmull_rom_points( + self.points, subdivisions=self.subdivisions, close_loop=False + ) + self.assertPointsListsEqual(expected, actual) + + # TODO: verify we always have the right number of points for a given call + # verify that `close_loop` always behaves correctly + # verify that start_tangent and end_tangent behavior is correct + + def test_catmull_rom_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [ + Point3(0.00, 0.00), + Point3(0.38, 0.44), + Point3(1.00, 1.00), + Point3(1.62, 1.06), + Point3(2.00, 1.00), + ] + actual = catmull_rom_points( + self.points_raw, subdivisions=self.subdivisions, close_loop=False + ) + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_points_3d(self): + points = [Point3(-1, -1, 0), Point3(0, 0, 1), Point3(1, 1, 0)] + expected = [ + Point3(-1.00, -1.00, 0.00), + Point3(-0.62, -0.62, 0.50), + Point3(0.00, 0.00, 1.00), + Point3(0.62, 0.62, 0.50), + Point3(1.00, 1.00, 0.00), + ] + actual = catmull_rom_points(points, subdivisions=2) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points(self): + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] + actual = bezier_points(self.bezier_controls, subdivisions=self.subdivisions) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] + actual = bezier_points(self.bezier_controls_raw, subdivisions=self.subdivisions) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points_3d(self): + # verify that we get a valid bezier curve back even when its control points + # are outside the XY plane and aren't coplanar + controls_3d = [ + Point3(-2, -1, 0), + Point3(-0.5, -0.5, 1), + Point3(0.5, 0.5, 1), + Point3(2, 1, 0), + ] + actual = bezier_points(controls_3d, subdivisions=self.subdivisions) + expected = [ + Point3(-2.00, -1.00, 0.00), + Point3(0.00, 0.00, 0.75), + Point3(2.00, 1.00, 0.00), + ] + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_prism(self): + sides = 3 + UP = Vector3(0, 0, 1) + + control_points = [[10, 10, 0], [10, 10, 5], [8, 8, 15]] + + cat_tube = [] + angle_step = 2 * pi / sides + for i in range(sides): + rotated_controls = list( + ( + euclidify(p, Point3).rotate_around(UP, angle_step * i) + for p in control_points + ) + ) + cat_tube.append(rotated_controls) + + poly = catmull_rom_prism( + cat_tube, self.subdivisions, closed_ring=True, add_caps=True + ) + actual = (len(poly.params["points"]), len(poly.params["faces"])) + expected = (37, 62) + self.assertEqual(expected, actual) + + +if __name__ == "__main__": + unittest.main() diff --git a/solid/test/test_utils.py b/solid/test/test_utils.py index 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" }, +]