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/README.rst b/README.rst index e2f050d1..946ed134 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,7 @@ +**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 ----------- @@ -27,7 +31,6 @@ SolidPython things: <#directions-up-down-left-right-forward-back-for-arranging-things>`__ - `Arcs <#arcs>`__ - `Extrude Along Path <#extrude_along_path>`__ - - `Basic color library <#basic-color-library>`__ - `Bill Of Materials <#bill-of-materials>`__ - `solid.screw\_thread <#solidscrew_thread>`__ @@ -47,7 +50,7 @@ simple example: This Python code: -:: +.. code:: python from solid import * d = difference()( @@ -58,7 +61,7 @@ This Python code: Generates this OpenSCAD code: -:: +.. code:: python difference(){ cube(10); @@ -68,7 +71,7 @@ Generates this OpenSCAD code: That doesn't seem like such a savings, but the following SolidPython code is a lot shorter (and I think clearer) than the SCAD code it compiles to: -:: +.. code:: python from solid import * from solid.utils import * @@ -76,7 +79,7 @@ code is a lot shorter (and I think clearer) than the SCAD code it compiles to: Generates this OpenSCAD code: -:: +.. code:: difference(){ union(){ @@ -105,7 +108,7 @@ Installing SolidPython - Install latest release via `PyPI `__: - :: + .. code:: bash pip install solidpython @@ -116,29 +119,29 @@ Installing SolidPython - Install current master straight from Github: - :: + .. code:: bash - pip install git+https://github.com/SolidCode/SolidPython.git + pip install git+https://github.com/SolidCode/SolidPython.git Using SolidPython ================= - Include SolidPython at the top of your Python file: - :: + .. code:: python - from solid import * - from solid.utils import * # Not required, but the utils module is useful + from solid import * + from solid.utils import * # Not required, but the utils module is useful (See `this issue `__ for - a discussion of other import styles + a discussion of other import styles) - OpenSCAD uses curly-brace blocks ({}) to create its tree. SolidPython uses parentheses with comma-delimited lists. **OpenSCAD:** - :: + .. code:: difference(){ cube(10); @@ -147,7 +150,7 @@ Using SolidPython **SolidPython:** - :: + .. code:: d = difference()( cube(10), # Note the comma between each element! @@ -167,19 +170,23 @@ Using SolidPython Importing OpenSCAD code ======================= -- Use ``solid.import_scad(path)`` to import OpenSCAD code. + +- Use ``solid.import_scad(path)`` to import OpenSCAD code. Relative paths will +check the current location designated in `OpenSCAD library directories `__. **Ex:** ``scadfile.scad`` -:: + +.. code:: module box(w,h,d){ cube([w,h,d]); } ``your_file.py`` -:: + +.. code:: python from solid import * @@ -189,12 +196,13 @@ Importing OpenSCAD code - Recursively import OpenSCAD code by calling ``import_scad()`` with a directory argument. -:: +.. code:: python from solid import * # MCAD is OpenSCAD's most common utility library: https://github.com/openscad/MCAD - mcad = import_scad('/path/to/MCAD') + # If it's installed for OpenSCAD (on MacOS, at: ``$HOME/Documents/OpenSCAD/libraries``) + mcad = import_scad('MCAD') # MCAD contains about 15 separate packages, each included as its own namespace print(dir(mcad)) # => ['bearing', 'bitmap', 'boxes', etc...] @@ -204,15 +212,18 @@ Importing OpenSCAD code - OpenSCAD has the ``use()`` and ``include()`` statements for importing SCAD code, and SolidPython has them, too. They pollute the global namespace, though, and you may have better luck with ``import_scad()``, **Ex:** + ``scadfile.scad`` -:: + +.. code:: module box(w,h,d){ cube([w,h,d]); } ``your_file.py`` -:: + +.. code:: python from solid import * @@ -229,7 +240,7 @@ The best way to learn how SolidPython works is to look at the included example code. If you've installed SolidPython, the following line of Python will print(the location of ) the examples directory: -:: +.. code:: python import os, solid; print(os.path.dirname(solid.__file__) + '/examples') @@ -250,13 +261,13 @@ Basic operators Following Elmo Mäntynen's suggestion, SCAD objects override the basic operators + (union), - (difference), and \* (intersection). So -:: +.. code:: python c = cylinder(r=10, h=5) + cylinder(r=2, h=30) is the same as: -:: +.. code:: python c = union()( cylinder(r=10, h=5), @@ -265,14 +276,14 @@ is the same as: Likewise: -:: +.. code:: python c = cylinder(r=10, h=5) c -= cylinder(r=2, h=30) is the same as: -:: +.. code:: python c = difference()( cylinder(r=10, h=5), @@ -297,7 +308,7 @@ structure. Example: -:: +.. code:: python outer = cylinder(r=pipe_od, h=seg_length) inner = cylinder(r=pipe_id, h=seg_length) @@ -333,7 +344,7 @@ Currently these include: Directions: (up, down, left, right, forward, back) for arranging things: ------------------------------------------------------------------------ -:: +.. code:: python up(10)( cylinder() @@ -341,7 +352,7 @@ Directions: (up, down, left, right, forward, back) for arranging things: seems a lot clearer to me than: -:: +.. code:: python translate( [0,0,10])( cylinder() @@ -356,13 +367,13 @@ Arcs I've found this useful for fillets and rounds. -:: +.. code:: python arc(rad=10, start_degrees=90, end_degrees=210) draws an arc of radius 10 counterclockwise from 90 to 210 degrees. -:: +.. code:: python arc_inverted(rad=10, start_degrees=0, end_degrees=90) @@ -373,45 +384,16 @@ rounds. Extrude Along Path ------------------ -``solid.utils.extrude_along_path(shape_pts, path_pts, scale_factors=None)`` +``solid.utils.extrude_along_path()`` is quite powerful. It can do everything that +OpenSCAD's ``linear_extrude() `` and ``rotate_extrude()`` can do, and lots, lots more. +Scale to custom values throughout the extrusion. Rotate smoothly through the entire +extrusion or specify particular rotations for each step. Apply arbitrary transform +functions to every point in the extrusion. See `solid/examples/path_extrude_example.py `__ for use. -Basic color library -------------------- - -You can change an object's color by using the OpenSCAD -``color([rgba_array])`` function: - -:: - - transparent_blue = color([0,0,1, 0.5])(cube(10)) # Specify with RGB[A] - red_obj = color(Red)(cube(10)) # Or use predefined colors - -These colors are pre-defined in solid.utils: - -+------------+---------+--------------+ -| Red | Green | Blue | -+------------+---------+--------------+ -| Cyan | Magenta | Yellow | -+------------+---------+--------------+ -| Black | White | Transparent | -+------------+---------+--------------+ -| Oak | Pine | Birch | -+------------+---------+--------------+ -| Iron | Steel | Stainless | -+------------+---------+--------------+ -| Aluminum | Brass | BlackPaint | -+------------+---------+--------------+ -| FiberBoard | | | -+------------+---------+--------------+ - -They're a conversion of the materials in the `MCAD OpenSCAD -library `__, as seen [here] -(https://github.com/openscad/MCAD/blob/master/materials.scad). - Bill Of Materials ----------------- @@ -458,7 +440,8 @@ Jupyter Renderer ---------------- Render SolidPython or OpenSCAD code in Jupyter notebooks using `ViewSCAD `__, or install directly via: -:: + +.. code:: bash pip install viewscad diff --git a/pyproject.toml b/pyproject.toml index 3cba801c..8b31bb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "solidpython" -version = "0.4.6" +version = "1.1.3" description = "Python interface to the OpenSCAD declarative geometry language" authors = ["Evan Jones "] homepage = "https://github.com/SolidCode/SolidPython" @@ -37,12 +37,13 @@ python = ">=3.7" euclid3 = "^0.1.0" pypng = "^0.0.19" PrettyTable = "=0.7.2" -regex = "^2019.4" +ply = "^3.11" +setuptools = ">=65.6.3" [tool.poetry.dev-dependencies] tox = "^tox 3.11" -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/solid/examples/basic_scad_include.py b/solid/examples/basic_scad_include.py index 5e432dad..a91020fc 100755 --- a/solid/examples/basic_scad_include.py +++ b/solid/examples/basic_scad_include.py @@ -13,6 +13,7 @@ def demo_import_scad(): scad_path = Path(__file__).parent / 'scad_to_include.scad' scad_mod = import_scad(scad_path) + scad_mod.optional_nondefault_arg(1) return scad_mod.steps(5) diff --git a/solid/examples/bom_scad.py b/solid/examples/bom_scad.py index a059a66f..7611835c 100755 --- a/solid/examples/bom_scad.py +++ b/solid/examples/bom_scad.py @@ -93,15 +93,16 @@ def doohickey(c): def assembly(): + nut = m3_nut() return union()( doohickey(c='blue'), translate((-10, 0, doohickey_h / 2))(m3_12()), translate((0, 0, doohickey_h / 2))(m3_16()), translate((10, 0, doohickey_h / 2))(m3_12()), # Nuts - translate((-10, 0, -nut_height - doohickey_h / 2))(m3_nut()), - translate((0, 0, -nut_height - doohickey_h / 2))(m3_nut()), - translate((10, 0, -nut_height - doohickey_h / 2))(m3_nut()), + translate((-10, 0, -nut_height - doohickey_h / 2))(nut), + translate((0, 0, -nut_height - doohickey_h / 2))(nut), + translate((10, 0, -nut_height - doohickey_h / 2))(nut), ) @@ -109,12 +110,12 @@ def assembly(): out_dir = sys.argv[1] if len(sys.argv) > 1 else None a = assembly() - bom = bill_of_materials() + bom = bill_of_materials(a) file_out = scad_render_to_file(a, out_dir=out_dir) print(f"{__file__}: SCAD file written to: \n{file_out}") print(bom) print("Or, Spreadsheet-ready TSV:\n\n") - bom = bill_of_materials(csv=True) + bom = bill_of_materials(a, csv=True) print(bom) diff --git a/solid/examples/path_extrude_example.py b/solid/examples/path_extrude_example.py index 12734607..296637c4 100755 --- a/solid/examples/path_extrude_example.py +++ b/solid/examples/path_extrude_example.py @@ -1,27 +1,191 @@ #! /usr/bin/env python3 +from solid.objects import linear_extrude +from solid.solidpython import OpenSCADObject import sys -from math import cos, radians, sin +from math import cos, radians, sin, pi, tau +from pathlib import Path -from euclid3 import Point3 +from euclid3 import Point2, Point3, Vector3 -from solid import scad_render_to_file -from solid.utils import extrude_along_path +from solid import scad_render_to_file, text, translate, cube, color, rotate +from solid.utils import UP_VEC, Vector23, distribute_in_grid, extrude_along_path +from solid.utils import down, right, frange, lerp + + +from typing import Set, Sequence, List, Callable, Optional, Union, Iterable, Tuple SEGMENTS = 48 +PATH_RAD = 50 +SHAPE_RAD = 15 + +TEXT_LOC = [-0.6 *PATH_RAD, 1.6 * PATH_RAD] + +def basic_extrude_example(): + path_rad = PATH_RAD + shape = star(num_points=5) + path = sinusoidal_ring(rad=path_rad, segments=240) + + # At its simplest, just sweep a shape along a path + extruded = extrude_along_path( shape_pts=shape, path_pts=path) + extruded += make_label('Basic Extrude') + return extruded + +def extrude_example_xy_scaling() -> OpenSCADObject: + num_points = SEGMENTS + path_rad = PATH_RAD + circle = circle_points(15) + path = circle_points(rad = path_rad) + + # If scales aren't included, they'll default to + # no scaling at each step along path. + no_scale_obj = make_label('No Scale') + no_scale_obj += extrude_along_path(circle, path) + + # angles: from 0 to 6*Pi + angles = list((frange(0, 3*tau, num_steps=len(path)))) + + # With a 1-D scale factor, an extrusion grows and shrinks uniformly + x_scales = [(1 + cos(a)/2) for a in angles] + x_obj = make_label('1D Scale') + x_obj += extrude_along_path(circle, path, scales=x_scales) + + # With a 2D scale factor, a shape's X & Y dimensions can scale + # independently, leading to more interesting shapes + # X & Y scales vary between 0.5 & 1.5 + xy_scales = [Point2( 1 + cos(a)/2, 1 + sin(a)/2) for a in angles] + xy_obj = make_label('2D Scale') + xy_obj += extrude_along_path(circle, path, scales=xy_scales) + + obj = no_scale_obj + right(3*path_rad)(x_obj) + right(6 * path_rad)(xy_obj) + return obj + +def extrude_example_capped_ends() -> OpenSCADObject: + num_points = SEGMENTS/2 + path_rad = 50 + circle = star(6) + path = circle_points(rad = path_rad)[:-4] + + # If `connect_ends` is False or unspecified, ends will be capped. + # Endcaps will be correct for most convex or mildly concave (e.g. stars) cross sections + capped_obj = make_label('Capped Ends') + capped_obj += extrude_along_path(circle, path, connect_ends=False, cap_ends=True) + + # If `connect_ends` is specified, create a continuous manifold object + connected_obj = make_label('Connected Ends') + connected_obj += extrude_along_path(circle, path, connect_ends=True) + + return capped_obj + right(3*path_rad)(connected_obj) + +def extrude_example_rotations() -> OpenSCADObject: + path_rad = PATH_RAD + shape = star(num_points=5) + path = circle_points(path_rad, num_points=240) + # For a simple example, make one complete revolution by the end of the extrusion + simple_rot = make_label('Simple Rotation') + simple_rot += extrude_along_path(shape, path, rotations=[360], connect_ends=True) -def sinusoidal_ring(rad=25, segments=SEGMENTS): + # For a more complex set of rotations, add a rotation degree for each point in path + complex_rotations = [] + degs = 0 + oscillation_max = 60 + + for i in frange(0, 1, num_steps=len(path)): + # For the first third of the path, do one complete rotation + if i <= 0.333: + degs = i/0.333*360 + # For the second third of the path, oscillate between +/- oscillation_max degrees + elif i <= 0.666: + angle = lerp(i, 0.333, 0.666, 0, 2*tau) + degs = oscillation_max * sin(angle) + # For the last third of the path, oscillate increasingly fast but with smaller magnitude + else: + # angle increases in a nonlinear curve, so + # oscillations should get quicker and quicker + x = lerp(i, 0.666, 1.0, 0, 2) + angle = pow(x, 2.2) * tau + # decrease the size of the oscillations by a factor of 10 + # over the course of this stretch + osc = lerp(i, 0.666, 1.0, oscillation_max, oscillation_max/10) + degs = osc * sin(angle) + complex_rotations.append(degs) + + complex_rot = make_label('Complex Rotation') + complex_rot += extrude_along_path(shape, path, rotations=complex_rotations) + + # Make some red markers to show the boundaries between the three sections of this path + marker_w = SHAPE_RAD * 1.5 + marker = translate([path_rad, 0, 0])( + cube([marker_w, 1, marker_w], center=True) + ) + markers = [color('red')(rotate([0,0,120*i])(marker)) for i in range(3)] + complex_rot += markers + + return simple_rot + right(3*path_rad)(complex_rot) + +def extrude_example_transforms() -> OpenSCADObject: + path_rad = PATH_RAD + height = 2*SHAPE_RAD + num_steps = 120 + + shape = circle_points(rad=path_rad, num_points=120) + path = [Point3(0,0,i) for i in frange(0, height, num_steps=num_steps)] + + max_rotation = radians(15) + max_z_displacement = height/10 + up = Vector3(0,0,1) + + # The transforms argument is powerful. + # Each point in the entire extrusion will call this function with unique arguments: + # -- `path_norm` in [0, 1] specifying how far along in the extrusion a point's loop is + # -- `loop_norm` in [0, 1] specifying where in its loop a point is. + def point_trans(point: Point3, path_norm:float, loop_norm: float) -> Point3: + # scale the point from 1x to 2x in the course of the + # extrusion, + scale = 1 + path_norm*path_norm/2 + p = scale * point + + # Rotate the points sinusoidally up to max_rotation + p = p.rotate_around(up, max_rotation*sin(tau*path_norm)) + + # Oscillate z values sinusoidally, growing from + # 0 magnitude to max_z_displacement, then decreasing to 0 magnitude at path_norm == 1 + max_z = sin(pi*path_norm) * max_z_displacement + angle = lerp(loop_norm, 0, 1, 0, 10*tau) + p.z += max_z*sin(angle) + return p + + no_trans = make_label('No Transform') + no_trans += down(height/2)( + extrude_along_path(shape, path, cap_ends=True) + ) + + # We can pass transforms a single function that will be called on all points, + # or pass a list with a transform function for each point along path + arb_trans = make_label('Arbitrary Transform') + arb_trans += down(height/2)( + extrude_along_path(shape, path, transforms=[point_trans], cap_ends=True) + ) + + return no_trans + right(3*path_rad)(arb_trans) + +# ============ +# = GEOMETRY = +# ============ +def sinusoidal_ring(rad=25, segments=SEGMENTS) -> List[Point3]: outline = [] for i in range(segments): - angle = i * 360 / segments - x = rad * cos(radians(angle)) - y = rad * sin(radians(angle)) - z = 2 * sin(radians(angle * 6)) + angle = radians(i * 360 / segments) + scaled_rad = (1 + 0.18*cos(angle*5)) * rad + x = scaled_rad * cos(angle) + y = scaled_rad * sin(angle) + z = 0 + # Or stir it up and add an oscillation in z as well + # z = 3 * sin(angle * 6) outline.append(Point3(x, y, z)) return outline - -def star(num_points=5, outer_rad=15, dip_factor=0.5): +def star(num_points=5, outer_rad=SHAPE_RAD, dip_factor=0.5) -> List[Point3]: star_pts = [] for i in range(2 * num_points): rad = outer_rad - i % 2 * dip_factor * outer_rad @@ -29,28 +193,34 @@ def star(num_points=5, outer_rad=15, dip_factor=0.5): star_pts.append(Point3(rad * cos(angle), rad * sin(angle), 0)) return star_pts +def circle_points(rad: float = SHAPE_RAD, num_points: int = SEGMENTS) -> List[Point2]: + angles = frange(0, tau, num_steps=num_points, include_end=True) + points = list([Point2(rad*cos(a), rad*sin(a)) for a in angles]) + return points -def extrude_example(): - # Note the incorrect triangulation at the two ends of the path. This - # is because star isn't convex, and the triangulation algorithm for - # the two end caps only works for convex shapes. - shape = star(num_points=5) - path = sinusoidal_ring(rad=50) - - # If scale_factors aren't included, they'll default to - # no scaling at each step along path. Here, let's - # make the shape twice as big at beginning and end of the path - scales = [1] * len(path) - scales[0] = 2 - scales[-1] = 2 +def make_label(message:str, text_loc:Tuple[float, float]=TEXT_LOC, height=5) -> OpenSCADObject: + return translate(text_loc)( + linear_extrude(height)( + text(message) + ) + ) - extruded = extrude_along_path(shape_pts=shape, path_pts=path, scale_factors=scales) +# =============== +# = ENTRY POINT = +# =============== +if __name__ == "__main__": + out_dir = sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent - return extruded + basic_extrude = basic_extrude_example() + scaled_extrusions = extrude_example_xy_scaling() + capped_extrusions = extrude_example_capped_ends() + rotated_extrusions = extrude_example_rotations() + arbitrary_transforms = extrude_example_transforms() + all_objs = [basic_extrude, scaled_extrusions, capped_extrusions, rotated_extrusions, arbitrary_transforms] + a = distribute_in_grid(all_objs, + max_bounding_box=[4*PATH_RAD, 4*PATH_RAD], + rows_and_cols=[len(all_objs), 1]) -if __name__ == '__main__': - out_dir = sys.argv[1] if len(sys.argv) > 1 else None - a = extrude_example() - file_out = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) + file_out = scad_render_to_file(a, out_dir=out_dir, include_orig_code=True) print(f"{__file__}: SCAD file written to: \n{file_out}") diff --git a/solid/examples/scad_to_include.scad b/solid/examples/scad_to_include.scad index a67ff9bb..e4e50e33 100644 --- a/solid/examples/scad_to_include.scad +++ b/solid/examples/scad_to_include.scad @@ -13,4 +13,13 @@ module steps(howmany=3){ } } -echo("This text should appear only when called with include(), not use()"); \ No newline at end of file +module blub(a, b=1) cube([a, 2, 2]); + +function scad_points() = [[0,0], [1,0], [0,1]]; + +// In Python, calling this function without an argument would be an error. +// Leave this here to confirm that this works in OpenSCAD. +function optional_nondefault_arg(arg1) = + let(s = arg1 ? arg1 : 1) cube([s,s,s]); + +echo("This text should appear only when called with include(), not use()"); diff --git a/solid/examples/splines_example.py b/solid/examples/splines_example.py index 7896b72e..002b5049 100755 --- a/solid/examples/splines_example.py +++ b/solid/examples/splines_example.py @@ -2,7 +2,7 @@ import os import sys from solid import * -from solid.utils import Red, right, forward +from solid.utils import Red, right, forward, back from solid.splines import catmull_rom_points, catmull_rom_polygon, control_points from solid.splines import bezier_polygon, bezier_points @@ -10,13 +10,18 @@ def assembly(): # Catmull-Rom Splines - a = basic_catmull_rom() - a += forward(4)(catmull_rom_spline_variants()) - a += forward(6)(bottle_shape(width=2, height=6)) + a = basic_catmull_rom() # Top row in OpenSCAD output + a += back(4)(catmull_rom_spline_variants()) # Row 2 + a += back(12)(bottle_shape(width=2, height=6)) # Row 3, the bottle shape + + # # TODO: include examples for 3D surfaces: + # a += back(16)(catmull_rom_patches()) + # a += back(20)(catmull_rom_prism()) + # a += back(24)(catmull_rom_prism_smooth()) # Bezier Splines - a += forward(12)(basic_bezier()) - a += forward(18)(bezier_points_variants()) + a += back(16)(basic_bezier()) # Row 4 + a += back(20)(bezier_points_variants()) # Row 5 return a def basic_catmull_rom(): @@ -67,6 +72,18 @@ def catmull_rom_spline_variants(): return a +def catmull_rom_patches(): + # TODO: write this + pass + +def catmull_rom_prism(): + # TODO: write this + pass + +def catmull_rom_prism_smooth(): + # TODO: write this + pass + def bottle_shape(width: float, height: float, neck_width:float=None, neck_height:float=None): if neck_width == None: neck_width = width * 0.4 diff --git a/solid/extrude_along_path.py b/solid/extrude_along_path.py new file mode 100644 index 00000000..bdd94760 --- /dev/null +++ b/solid/extrude_along_path.py @@ -0,0 +1,181 @@ +#! /usr/bin/env python +from math import radians +from solid import OpenSCADObject, Points, Indexes, ScadSize, polyhedron +from solid.utils import euclidify, euc_to_arr, transform_to_point, centroid, EPSILON +from euclid3 import Point2, Point3, Vector2, Vector3 + +from typing import Dict, Optional, Sequence, Tuple, Union, List, 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 == None: + next_loop_start_index = loop_start_index + loop_pt_count + loop_indices = list(range(loop_start_index, loop_pt_count + loop_start_index)) + [loop_start_index] + next_loop_indices = list(range(next_loop_start_index, loop_pt_count + next_loop_start_index )) + [next_loop_start_index] + + for i, (a, b) in enumerate(zip(loop_indices[:-1], loop_indices[1:])): + c, d = next_loop_indices[i: i+2] + # OpenSCAD's polyhedron will accept quads and do its own triangulation with them, + # so we could just append (a,b,d,c). + # However, this lets OpenSCAD (Or CGAL?) do its own triangulation, leading + # to some strange outcomes. Prefer to do our own triangulation. + # c--d + # |\ | + # | \| + # a--b + # facet_indices.append((a,b,d,c)) + facet_indices.append((a,b,c)) + facet_indices.append((b,d,c)) + return facet_indices + +def _rotate_loop(points:Sequence[Point3], rotation_degrees:float=None) -> List[Point3]: + if rotation_degrees is None: + return points + up = Vector3(0,0,1) + rads = radians(rotation_degrees) + return [p.rotate_around(up, rads) for p in points] + +def _scale_loop(points:Sequence[Point3], scale:Union[float, Point2, Tuple2]=None) -> List[Point3]: + if scale is None: + return points + + if isinstance(scale, (float, int)): + scale = [scale] * 2 + return [Point3(point.x * scale[0], point.y * scale[1], point.z) for point in points] + +def _transform_loop(points:Sequence[Point3], transform_func:Point3Transform = None, path_normal:float = None) -> List[Point3]: + # transform_func is a function that takes a point and optionally two floats, + # a `path_normal`, in [0,1] that indicates where this loop is in a path extrusion, + # and `loop_normal` in [0,1] that indicates where this point is in a list of points + if transform_func is None: + return points + + result = [] + for i, p in enumerate(points): + # i goes from 0 to 1 across points + loop_normal = i/(len(points) -1) + new_p = transform_func(p, path_normal, loop_normal) + result.append(new_p) + return result + diff --git a/solid/objects.py b/solid/objects.py index a242529f..22ecd2ed 100644 --- a/solid/objects.py +++ b/solid/objects.py @@ -3,9 +3,9 @@ """ from pathlib import Path from types import SimpleNamespace -from typing import Dict, Optional, Sequence, Tuple, Union +from typing import Dict, Optional, Sequence, Tuple, Union, List -from .solidpython import OpenSCADObject +from .solidpython import IncludedOpenSCADObject, OpenSCADObject PathStr = Union[Path, str] @@ -22,6 +22,7 @@ ScadSize = Union[int, Sequence[float]] OpenSCADObjectPlus = Union[OpenSCADObject, Sequence[OpenSCADObject]] +IMPORTED_SCAD_MODULES: Dict[Path, SimpleNamespace] = {} class polygon(OpenSCADObject): """ @@ -36,13 +37,25 @@ class polygon(OpenSCADObject): 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): @@ -206,7 +219,7 @@ class polyhedron(OpenSCADObject): :type convexity: int """ - def __init__(self, points: P3s, faces: Indexes, convexity: int = None, triangles: Indexes = None) -> None: + def __init__(self, points: P3s, faces: Indexes, convexity: int = 10, triangles: Indexes = None) -> None: super().__init__('polyhedron', {'points': points, 'faces': faces, 'convexity': convexity, @@ -367,11 +380,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): @@ -402,15 +418,21 @@ class offset(OpenSCADObject): 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: + 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: + elif delta is not None: kwargs = {'delta': delta, 'chamfer': chamfer} else: raise ValueError("offset(): Must supply r or delta") + if segments: + kwargs['segments'] = segments super().__init__('offset', kwargs) @@ -735,55 +757,118 @@ def disable(openscad_obj: OpenSCADObject) -> OpenSCADObject: # =========================== # = IMPORTING OPENSCAD CODE = # =========================== -def import_scad(scad_filepath: PathStr) -> Optional[SimpleNamespace]: - """ - import_scad() is the namespaced, more Pythonic way to import OpenSCAD code. - Return a python namespace containing all 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'] - - 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) - - 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_file_or_dir: PathStr) -> SimpleNamespace: + ''' + Recursively look in current directory & OpenSCAD library directories for + OpenSCAD files. Create Python mappings for all OpenSCAD modules & functions + Return a namespace or raise ValueError if no scad files found + ''' + global IMPORTED_SCAD_MODULES + + scad = Path(scad_file_or_dir) + candidates: List[Path] = [scad] + + ns = IMPORTED_SCAD_MODULES.get(scad) + if ns: + return ns + else: + if not scad.is_absolute(): + candidates = [d/scad for d in _openscad_library_paths()] + + for candidate_path in candidates: + namespace = _import_scad(candidate_path) + if namespace is not None: + IMPORTED_SCAD_MODULES[scad] = namespace + return namespace + raise ValueError(f'Could not find .scad files at or under {scad}. \nLocations searched were: {candidates}') + +def _import_scad(scad: Path) -> Optional[SimpleNamespace]: + ''' + cases: + single scad file: + return a namespace populated with `use()` + directory + recurse into all subdirectories and *.scad files + return namespace if scad files are underneath, otherwise None + non-scad file: + return None + ''' + namespace: Optional[SimpleNamespace] = None + if scad.is_file() and scad.suffix == '.scad': + namespace = SimpleNamespace() + use(scad.absolute(), dest_namespace_dict=namespace.__dict__) elif scad.is_dir(): - 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 - return namespace + 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('My Documents\OpenSCAD\libraries'), + Path('c:\Program Files\OpenSCAD\libraries') + ], + } + + paths += default_paths.get(platform.system(), []) + return paths + + +def _find_library(library_name: PathStr) -> Path: + result = Path(library_name) + + if not result.is_absolute(): + paths = _openscad_library_paths() + for p in paths: + f = p / result + # print(f'Checking {f} -> {f.exists()}') + if f.exists(): + result = f + break + + return result # use() & include() mimic OpenSCAD's use/include mechanics. # -- use() makes methods in scad_file_path.scad available to be called. # --include() makes those methods available AND executes all code in # scad_file_path.scad, which may have side effects. # Unless you have a specific need, call use(). + + def use(scad_file_path: PathStr, use_not_include: bool = True, dest_namespace_dict: Dict = None): """ Opens scad_file_path, parses it for all usable calls, @@ -795,18 +880,9 @@ 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'], @@ -825,6 +901,6 @@ def use(scad_file_path: PathStr, use_not_include: bool = True, dest_namespace_di return True - def include(scad_file_path: PathStr) -> bool: return use(scad_file_path, use_not_include=False) + diff --git a/solid/patch_euclid.py b/solid/patch_euclid.py index 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..9e2b20a9 --- /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\n access_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\n list_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\n assert_or_echo : ASSERT "(" opt_call_parameter_list ")"\n | ECHO "(" opt_call_parameter_list ")"\n constants : STRING\n | TRUE\n | FALSE\n | NUMBERopt_else : \n | ELSE expression %prec THEN\n for_or_if : for_loop expression %prec THEN\n | IF "(" expression ")" expression opt_else\n expression : 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 ")"\n assignment_list : ID "=" expression\n | assignment_list "," ID "=" expression\n call : ID "(" call_parameter_list ")"\n | ID "(" ")" tuple : "[" opt_expression_list "]"\n commas : commas ","\n | ","\n opt_expression_list : expression_list\n | expression_list commas\n | empty expression_list : expression_list commas expression\n | expression\n opt_call_parameter_list :\n | call_parameter_list\n call_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\n parameter_list : parameter_list commas parameter\n | parameterparameter : ID\n | ID "=" expressionfunction : FUNCTION ID "(" opt_parameter_list ")" "=" expression\n module : MODULE ID "(" opt_parameter_list ")" statement\n ' + +_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',32), + ('statements -> empty','statements',1,'p_statements_empty','scad_parser.py',38), + ('empty -> ','empty',0,'p_empty','scad_parser.py',42), + ('statement -> IF ( expression ) statement','statement',5,'p_statement','scad_parser.py',45), + ('statement -> IF ( expression ) statement ELSE statement','statement',7,'p_statement','scad_parser.py',46), + ('statement -> for_loop statement','statement',2,'p_statement','scad_parser.py',47), + ('statement -> LET ( assignment_list ) statement','statement',5,'p_statement','scad_parser.py',48), + ('statement -> ASSERT ( opt_call_parameter_list ) statement','statement',5,'p_statement','scad_parser.py',49), + ('statement -> ECHO ( opt_call_parameter_list ) statement','statement',5,'p_statement','scad_parser.py',50), + ('statement -> { statements }','statement',3,'p_statement','scad_parser.py',51), + ('statement -> % statement','statement',2,'p_statement','scad_parser.py',52), + ('statement -> * statement','statement',2,'p_statement','scad_parser.py',53), + ('statement -> ! statement','statement',2,'p_statement','scad_parser.py',54), + ('statement -> # statement','statement',2,'p_statement','scad_parser.py',55), + ('statement -> call statement','statement',2,'p_statement','scad_parser.py',56), + ('statement -> USE FILENAME','statement',2,'p_statement','scad_parser.py',57), + ('statement -> INCLUDE FILENAME','statement',2,'p_statement','scad_parser.py',58), + ('statement -> ;','statement',1,'p_statement','scad_parser.py',59), + ('for_loop -> FOR ( parameter_list )','for_loop',4,'p_for_loop','scad_parser.py',62), + ('for_loop -> FOR ( parameter_list ; expression ; parameter_list )','for_loop',8,'p_for_loop','scad_parser.py',63), + ('statement -> function','statement',1,'p_statement_function','scad_parser.py',66), + ('statement -> module','statement',1,'p_statement_module','scad_parser.py',70), + ('statement -> ID = expression ;','statement',4,'p_statement_assignment','scad_parser.py',74), + ('logic_expr -> - expression','logic_expr',2,'p_logic_expr','scad_parser.py',78), + ('logic_expr -> + expression','logic_expr',2,'p_logic_expr','scad_parser.py',79), + ('logic_expr -> ! expression','logic_expr',2,'p_logic_expr','scad_parser.py',80), + ('logic_expr -> expression ? expression : expression','logic_expr',5,'p_logic_expr','scad_parser.py',81), + ('logic_expr -> expression % expression','logic_expr',3,'p_logic_expr','scad_parser.py',82), + ('logic_expr -> expression + expression','logic_expr',3,'p_logic_expr','scad_parser.py',83), + ('logic_expr -> expression - expression','logic_expr',3,'p_logic_expr','scad_parser.py',84), + ('logic_expr -> expression / expression','logic_expr',3,'p_logic_expr','scad_parser.py',85), + ('logic_expr -> expression * expression','logic_expr',3,'p_logic_expr','scad_parser.py',86), + ('logic_expr -> expression ^ expression','logic_expr',3,'p_logic_expr','scad_parser.py',87), + ('logic_expr -> expression < expression','logic_expr',3,'p_logic_expr','scad_parser.py',88), + ('logic_expr -> expression > expression','logic_expr',3,'p_logic_expr','scad_parser.py',89), + ('logic_expr -> expression EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',90), + ('logic_expr -> expression NOT_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',91), + ('logic_expr -> expression GREATER_OR_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',92), + ('logic_expr -> expression LESS_OR_EQUAL expression','logic_expr',3,'p_logic_expr','scad_parser.py',93), + ('logic_expr -> expression AND expression','logic_expr',3,'p_logic_expr','scad_parser.py',94), + ('logic_expr -> expression OR expression','logic_expr',3,'p_logic_expr','scad_parser.py',95), + ('access_expr -> ID','access_expr',1,'p_access_expr','scad_parser.py',99), + ('access_expr -> expression . ID','access_expr',3,'p_access_expr','scad_parser.py',100), + ('access_expr -> expression ( call_parameter_list )','access_expr',4,'p_access_expr','scad_parser.py',101), + ('access_expr -> expression ( )','access_expr',3,'p_access_expr','scad_parser.py',102), + ('access_expr -> expression [ expression ]','access_expr',4,'p_access_expr','scad_parser.py',103), + ('list_stuff -> FUNCTION ( opt_parameter_list ) expression','list_stuff',5,'p_list_stuff','scad_parser.py',107), + ('list_stuff -> LET ( assignment_list ) expression','list_stuff',5,'p_list_stuff','scad_parser.py',108), + ('list_stuff -> EACH expression','list_stuff',2,'p_list_stuff','scad_parser.py',109), + ('list_stuff -> [ expression : expression ]','list_stuff',5,'p_list_stuff','scad_parser.py',110), + ('list_stuff -> [ expression : expression : expression ]','list_stuff',7,'p_list_stuff','scad_parser.py',111), + ('list_stuff -> [ for_loop expression ]','list_stuff',4,'p_list_stuff','scad_parser.py',112), + ('list_stuff -> tuple','list_stuff',1,'p_list_stuff','scad_parser.py',113), + ('assert_or_echo -> ASSERT ( opt_call_parameter_list )','assert_or_echo',4,'p_assert_or_echo','scad_parser.py',117), + ('assert_or_echo -> ECHO ( opt_call_parameter_list )','assert_or_echo',4,'p_assert_or_echo','scad_parser.py',118), + ('constants -> STRING','constants',1,'p_constants','scad_parser.py',121), + ('constants -> TRUE','constants',1,'p_constants','scad_parser.py',122), + ('constants -> FALSE','constants',1,'p_constants','scad_parser.py',123), + ('constants -> NUMBER','constants',1,'p_constants','scad_parser.py',124), + ('opt_else -> ','opt_else',0,'p_opt_else','scad_parser.py',127), + ('opt_else -> ELSE expression','opt_else',2,'p_opt_else','scad_parser.py',128), + ('for_or_if -> for_loop expression','for_or_if',2,'p_for_or_if','scad_parser.py',131), + ('for_or_if -> IF ( expression ) expression opt_else','for_or_if',6,'p_for_or_if','scad_parser.py',132), + ('expression -> access_expr','expression',1,'p_expression','scad_parser.py',136), + ('expression -> logic_expr','expression',1,'p_expression','scad_parser.py',137), + ('expression -> list_stuff','expression',1,'p_expression','scad_parser.py',138), + ('expression -> assert_or_echo','expression',1,'p_expression','scad_parser.py',139), + ('expression -> assert_or_echo expression','expression',2,'p_expression','scad_parser.py',140), + ('expression -> constants','expression',1,'p_expression','scad_parser.py',141), + ('expression -> for_or_if','expression',1,'p_expression','scad_parser.py',142), + ('expression -> ( expression )','expression',3,'p_expression','scad_parser.py',143), + ('assignment_list -> ID = expression','assignment_list',3,'p_assignment_list','scad_parser.py',147), + ('assignment_list -> assignment_list , ID = expression','assignment_list',5,'p_assignment_list','scad_parser.py',148), + ('call -> ID ( call_parameter_list )','call',4,'p_call','scad_parser.py',152), + ('call -> ID ( )','call',3,'p_call','scad_parser.py',153), + ('tuple -> [ opt_expression_list ]','tuple',3,'p_tuple','scad_parser.py',156), + ('commas -> commas ,','commas',2,'p_commas','scad_parser.py',160), + ('commas -> ,','commas',1,'p_commas','scad_parser.py',161), + ('opt_expression_list -> expression_list','opt_expression_list',1,'p_opt_expression_list','scad_parser.py',165), + ('opt_expression_list -> expression_list commas','opt_expression_list',2,'p_opt_expression_list','scad_parser.py',166), + ('opt_expression_list -> empty','opt_expression_list',1,'p_opt_expression_list','scad_parser.py',167), + ('expression_list -> expression_list commas expression','expression_list',3,'p_expression_list','scad_parser.py',169), + ('expression_list -> expression','expression_list',1,'p_expression_list','scad_parser.py',170), + ('opt_call_parameter_list -> ','opt_call_parameter_list',0,'p_opt_call_parameter_list','scad_parser.py',174), + ('opt_call_parameter_list -> call_parameter_list','opt_call_parameter_list',1,'p_opt_call_parameter_list','scad_parser.py',175), + ('call_parameter_list -> call_parameter_list commas call_parameter','call_parameter_list',3,'p_call_parameter_list','scad_parser.py',178), + ('call_parameter_list -> call_parameter','call_parameter_list',1,'p_call_parameter_list','scad_parser.py',179), + ('call_parameter -> expression','call_parameter',1,'p_call_parameter','scad_parser.py',182), + ('call_parameter -> ID = expression','call_parameter',3,'p_call_parameter','scad_parser.py',183), + ('opt_parameter_list -> parameter_list','opt_parameter_list',1,'p_opt_parameter_list','scad_parser.py',186), + ('opt_parameter_list -> parameter_list commas','opt_parameter_list',2,'p_opt_parameter_list','scad_parser.py',187), + ('opt_parameter_list -> empty','opt_parameter_list',1,'p_opt_parameter_list','scad_parser.py',188), + ('parameter_list -> parameter_list commas parameter','parameter_list',3,'p_parameter_list','scad_parser.py',196), + ('parameter_list -> parameter','parameter_list',1,'p_parameter_list','scad_parser.py',197), + ('parameter -> ID','parameter',1,'p_parameter','scad_parser.py',204), + ('parameter -> ID = expression','parameter',3,'p_parameter','scad_parser.py',205), + ('function -> FUNCTION ID ( opt_parameter_list ) = expression','function',7,'p_function','scad_parser.py',209), + ('module -> MODULE ID ( opt_parameter_list ) statement','module',6,'p_module','scad_parser.py',218), +] 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..d56134a6 --- /dev/null +++ b/solid/py_scadparser/scad_parser.py @@ -0,0 +1,286 @@ +from ply import lex, yacc + +#workaround relative imports.... make this module runable as script +if __name__ == "__main__": + from scad_ast import * + from scad_tokens import * +else: + from .scad_ast import * + from .scad_tokens import * + +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] != 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] != None: + p[0] = p[1] + else: + p[0] = [] + +def p_parameter_list(p): + '''parameter_list : parameter_list commas parameter + | parameter''' + if len(p) > 2: + p[0] = p[1] + [p[3]] + else: + p[0] = [p[1]] + +def p_parameter(p): + '''parameter : ID + | ID "=" expression''' + p[0] = ScadParameter(p[1], len(p) == 4) + +def p_function(p): + '''function : FUNCTION ID "(" opt_parameter_list ")" "=" expression + ''' + params = None + if p[4] != ")": + params = p[4] + + p[0] = ScadFunction(p[2], params) + +def p_module(p): + '''module : MODULE ID "(" opt_parameter_list ")" statement + ''' + params = None + if p[4] != ")": + params = p[4] + + p[0] = ScadModule(p[2], params) + + +def p_error(p): + print(f'py_scadparser: Syntax error: {p.lexer.filename}({p.lineno}) {p.type} - {p.value}') + +def parseFile(scadFile): + + lexer = lex.lex(debug=False) + lexer.filename = scadFile + parser = yacc.yacc(debug=False) + + modules = [] + functions = [] + globalVars = [] + + appendObject = { ScadTypes.MODULE : lambda x: modules.append(x), + ScadTypes.FUNCTION: lambda x: functions.append(x), + ScadTypes.GLOBAL_VAR: lambda x: globalVars.append(x), + } + + from pathlib import Path + with Path(scadFile).open() as f: + for i in parser.parse(f.read(), lexer=lexer): + appendObject[i.getType()](i) + + return modules, functions, globalVars + +def parseFileAndPrintGlobals(scadFile): + + print(f'======{scadFile}======') + modules, functions, globalVars = parseFile(scadFile) + + print("Modules:") + for m in modules: + print(f' {m}') + + print("Functions:") + for m in functions: + print(f' {m}') + + print("Global Variables:") + for m in globalVars: + print(f' {m.name}') + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print(f"usage: {sys.argv[0]} [-q] [ ...]\n -q : quiete") + + quiete = sys.argv[1] == "-q" + files = sys.argv[2:] if quiete else sys.argv[1:] + + for i in files: + if quiete: + print(i) + parseFile(i) + else: + parseFileAndPrintGlobals(i) + diff --git a/solid/py_scadparser/scad_tokens.py b/solid/py_scadparser/scad_tokens.py new file mode 100644 index 00000000..182048f5 --- /dev/null +++ b/solid/py_scadparser/scad_tokens.py @@ -0,0 +1,109 @@ +literals = [ + ".", ",", ";", + "=", + "!", + ">", "<", + "+", "-", "*", "/", "^", + "?", ":", + "[", "]", "{", "}", "(", ")", + "%", "#" +] + +reserved = { + 'use' : 'USE', + 'include': 'INCLUDE', + 'module' : 'MODULE', + 'function' : 'FUNCTION', + 'if' : 'IF', + 'else' : 'ELSE', + 'let' : 'LET', + 'assert' : 'ASSERT', + 'for' : 'FOR', + 'each' : 'EACH', + 'true' : 'TRUE', + 'false' : 'FALSE', + 'echo' : 'ECHO', +} + +tokens = [ + "ID", + "NUMBER", + "STRING", + "EQUAL", + "GREATER_OR_EQUAL", + "LESS_OR_EQUAL", + "NOT_EQUAL", + "AND", "OR", + "FILENAME", + ] + list(reserved.values()) + +#copy & paste from https://github.com/eliben/pycparser/blob/master/pycparser/c_lexer.py +#LICENSE: BSD +simple_escape = r"""([a-wyzA-Z._~!=&\^\-\\?'"]|x(?![0-9a-fA-F]))""" +decimal_escape = r"""(\d+)(?!\d)""" +hex_escape = r"""(x[0-9a-fA-F]+)(?![0-9a-fA-F])""" +bad_escape = r"""([\\][^a-zA-Z._~^!=&\^\-\\?'"x0-9])""" +escape_sequence = r"""(\\("""+simple_escape+'|'+decimal_escape+'|'+hex_escape+'))' +escape_sequence_start_in_string = r"""(\\[0-9a-zA-Z._~!=&\^\-\\?'"])""" +string_char = r"""([^"\\\n]|"""+escape_sequence_start_in_string+')' +t_STRING = '"'+string_char+'*"' + " | " + "'" +string_char+ "*'" + +t_EQUAL = "==" +t_GREATER_OR_EQUAL = ">=" +t_LESS_OR_EQUAL = "<=" +t_NOT_EQUAL = "!=" +t_AND = "\&\&" +t_OR = "\|\|" + +t_FILENAME = r'<[a-zA-Z_0-9/\\\.-]*>' + +def t_eat_escaped_quotes(t): + r"\\\"" + pass + +def t_comments1(t): + r'(/\*(.|\n)*?\*/)' + t.lexer.lineno += t.value.count("\n") + pass + +def t_comments2(t): + r'//.*[\n\']?' + t.lexer.lineno += 1 + pass + +def t_whitespace(t): + r'\s' + t.lexer.lineno += t.value.count("\n") + +def t_ID(t): + r'[\$]?[0-9]*[a-zA-Z_][a-zA-Z_0-9]*' + t.type = reserved.get(t.value,'ID') + return t + +def t_NUMBER(t): + r'[0-9]*\.?\d+([eE][-\+]\d+)?' + t.value = float(t.value) + return t + +def t_error(t): + print(f'py_scadparser: Illegal character: {t.lexer.filename}({t.lexer.lineno}) "{t.value[0]}"') + t.lexer.skip(1) + +if __name__ == "__main__": + import sys + from ply import lex + from pathlib import Path + + if len(sys.argv) < 2: + print(f"usage: {sys.argv[0]} ") + + p = Path(sys.argv[1]) + f = p.open() + lexer = lex.lex() + lexer.filename = p.as_posix() + lexer.input(''.join(f.readlines())) + for tok in iter(lexer.token, None): + if tok.type == "MODULE": + print("") + print(repr(tok.type), repr(tok.value), end='') + diff --git a/solid/screw_thread.py b/solid/screw_thread.py index e2bb9df7..e0f4192a 100755 --- a/solid/screw_thread.py +++ b/solid/screw_thread.py @@ -33,7 +33,8 @@ def thread(outline_pts: Points, segments_per_rot: int = 32, neck_in_degrees: float = 0, neck_out_degrees: float = 0, - rad_2: float=None): + 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. @@ -80,6 +81,13 @@ def thread(outline_pts: Points, threads, (i.e., pitch=tooth_height), I use pitch= tooth_height+EPSILON, since pitch=tooth_height will self-intersect for rotations >=1 """ + # FIXME: For small segments_per_rot where length is not a multiple of + # pitch, the the generated spiral will have irregularities, since we + # don't ensure that each level's segments are in line with those above or + # below. This would require a change in logic to fix. For now, larger values + # of segments_per_rot and length that divides pitch evenly should avoid this issue + # -ETJ 02 January 2020 + rad_2 = rad_2 or inner_rad rotations = length / pitch @@ -148,7 +156,8 @@ def thread(outline_pts: Points, # 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 @@ -166,8 +175,13 @@ 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 @@ -178,11 +192,8 @@ def thread(outline_pts: Points, # If the threading is internal, intersect with a central cylinder # to make sure nothing else remains tube = cylinder(r1=inner_rad, r2=rad_2, h=length, segments=segments_per_rot) - # FIXME: For reasons I haven't yet sussed out, the cylinder `tube` doesn't - # line up perfectly with the polyhedron `a`, which creates tiny extra facets - # at joints. These aren't large enough to mess up 3D prints, but they - # do make the shape messier than it needs to be. -ETJ 30 December 2019 - a *= tube + a *= tube + return a def default_thread_section(tooth_height: float, tooth_depth: float): diff --git a/solid/solidpython.py b/solid/solidpython.py index 900d075e..4fc935f2 100755 --- a/solid/solidpython.py +++ b/solid/solidpython.py @@ -21,10 +21,10 @@ from typing import Set, Sequence, List, Callable, Optional, Union, Iterable from types import ModuleType -from typing import Callable, Iterable, List, Optional, Sequence, Set, Union +from typing import Callable, Iterable, List, Optional, Sequence, Set, Union, Dict import pkg_resources -import regex as re +import re PathStr = Union[Path, str] AnimFunc = Callable[[Optional[float]], 'OpenSCADObject'] @@ -52,6 +52,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 @@ -398,9 +405,13 @@ def _find_include_strings(obj: Union[IncludedOpenSCADObject, OpenSCADObject]) -> 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: # Make this object the root of the tree root = scad_object @@ -418,7 +429,6 @@ def scad_render(scad_object: OpenSCADObject, file_header: str = '') -> str: return file_header + includes + scad_body - def scad_render_animated(func_to_animate: AnimFunc, steps: int =20, back_and_forth: bool=True, @@ -481,7 +491,6 @@ def scad_render_animated(func_to_animate: AnimFunc, f"}}\n" return rendered_string - def scad_render_animated_file(func_to_animate:AnimFunc, steps: int=20, back_and_forth: bool=True, @@ -499,10 +508,11 @@ def scad_render_to_file(scad_object: OpenSCADObject, 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 + 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) @@ -552,29 +562,26 @@ def _write_code_to_file(rendered_string: str, 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 """ - + version = '' try: # if SolidPython is installed use `pkg_resources` - return pkg_resources.get_distribution('solidpython').version + version = 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 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.") - + if version_file_path.exists(): + version_match = version_pattern.search(version_file_path.read_text()) + if version_match: + version = version_match.group(1) + return version def sp_code_in_scad_comment(calling_file: PathStr) -> str: """ @@ -604,58 +611,29 @@ def sp_code_in_scad_comment(calling_file: PathStr) -> 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}) - return callables + #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 def calling_module(stack_depth: int = 2) -> ModuleType: """ @@ -679,7 +657,6 @@ def calling_module(stack_depth: int = 2) -> ModuleType: import __main__ as calling_mod # type: ignore return calling_mod - def new_openscad_class_str(class_name: str, args: Sequence[str] = None, kwargs: Sequence[str] = None, @@ -732,33 +709,55 @@ def new_openscad_class_str(class_name: str, return result - 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") 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 - def py2openscad(o: Union[bool, float, str, Iterable]) -> str: if type(o) == bool: return str(o).lower() @@ -769,6 +768,8 @@ def py2openscad(o: Union[bool, float, str, Iterable]) -> str: 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 @@ -781,6 +782,5 @@ def py2openscad(o: Union[bool, float, str, Iterable]) -> str: return s return str(o) - def indent(s: str) -> str: return s.replace("\n", "\n\t") diff --git a/solid/splines.py b/solid/splines.py index 02878c67..ef4310f4 100644 --- a/solid/splines.py +++ b/solid/splines.py @@ -1,15 +1,25 @@ #! /usr/bin/env python from math import pow -from solid import circle, cylinder, polygon, color, OpenSCADObject, translate, linear_extrude -from solid.utils import bounding_box, right, Red +from solid import union, circle, cylinder, polygon, color, OpenSCADObject, translate, linear_extrude, polyhedron +from solid.utils import bounding_box, right, Red, Tuple3, euclidify from euclid3 import Vector2, Vector3, Point2, Point3 from typing import Sequence, Tuple, Union, List, cast Point23 = Union[Point2, Point3] +# These *Input types accept either euclid3.Point* objects, or bare n-tuples +Point2Input = Union[Point2, Tuple[float, float]] +Point3Input = Union[Point3, Tuple[float, float, float]] +Point23Input = Union[Point2Input, Point3Input] + +PointInputs = Sequence[Point23Input] + +FaceTrio = Tuple[int, int, int] +CMPatchPoints = Tuple[Sequence[Point3Input], Sequence[Point3Input]] + Vec23 = Union[Vector2, Vector3] -FourPoints = Tuple[Point23, Point23, Point23, Point23] +FourPoints = Tuple[Point23Input, Point23Input, Point23Input, Point23Input] SEGMENTS = 48 DEFAULT_SUBDIVISIONS = 10 @@ -18,7 +28,7 @@ # ======================= # = CATMULL-ROM SPLINES = # ======================= -def catmull_rom_polygon(points: Sequence[Point23], +def catmull_rom_polygon(points: Sequence[Point23Input], subdivisions: int = DEFAULT_SUBDIVISIONS, extrude_height: float = DEFAULT_EXTRUDE_HEIGHT, show_controls: bool =False, @@ -43,13 +53,13 @@ def catmull_rom_polygon(points: Sequence[Point23], shape += control_points(points, extrude_height, center) return shape -def catmull_rom_points( points: Sequence[Point23], - subdivisions:int = 10, +def catmull_rom_points( points: Sequence[Point23Input], + subdivisions:int = DEFAULT_SUBDIVISIONS, close_loop: bool=False, start_tangent: Vec23 = None, - end_tangent: Vec23 = None) -> List[Point23]: + end_tangent: Vec23 = None) -> List[Point3]: """ - Return a smooth set of points through `points`, with `subdivision` points + 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 @@ -60,19 +70,23 @@ def catmull_rom_points( points: Sequence[Point23], https://www.habrador.com/tutorials/interpolation/1-catmull-rom-splines/ retrieved 20190712 """ - catmull_points: List[Point23] = [] - cat_points: List[Point23] = [] - points_list = cast(List[Point23], points) + catmull_points: List[Point3] = [] + cat_points: List[Point3] = [] + # points_list = cast(List[Point23], points) + + points_list = list([euclidify(p, Point3) for p in points]) if close_loop: - cat_points = [points[-1]] + points_list + [points[0]] + cat_points = euclidify([points_list[-1]] + points_list + points_list[0:2], Point3) else: # Use supplied tangents or just continue the ends of the supplied points - start_tangent = start_tangent or (points[1] - points[0]) - end_tangent = end_tangent or (points[-2] - points[-1]) - cat_points = [points[0]+ start_tangent] + points_list + [points[-1] + end_tangent] + start_tangent = start_tangent or (points_list[1] - points_list[0]) + start_tangent = euclidify(start_tangent, Vector3) + end_tangent = end_tangent or (points_list[-2] - points_list[-1]) + end_tangent = euclidify(end_tangent, Vector3) + cat_points = [points_list[0]+ start_tangent] + points_list + [points_list[-1] + end_tangent] - last_point_range = len(cat_points) - 2 if close_loop else len(cat_points) - 3 + last_point_range = len(cat_points) - 3 if close_loop else len(cat_points) - 3 for i in range(0, last_point_range): include_last = True if i == last_point_range - 1 else False @@ -88,7 +102,7 @@ def catmull_rom_points( points: Sequence[Point23], def _catmull_rom_segment(controls: FourPoints, subdivisions: int, - include_last=False) -> List[Point23]: + 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. @@ -104,7 +118,7 @@ def _catmull_rom_segment(controls: FourPoints, if include_last: num_points += 1 - p0, p1, p2, p3 = controls + p0, p1, p2, p3 = [euclidify(p, Point3) for p in controls] a = 2 * p1 b = p2 - p0 c = 2* p0 - 5*p1 + 4*p2 - p3 @@ -113,9 +127,131 @@ def _catmull_rom_segment(controls: FourPoints, for i in range(num_points): t = i/subdivisions pos = 0.5 * (a + (b * t) + (c * t * t) + (d * t * t * t)) - positions.append(pos) + positions.append(Point3(*pos)) return positions +def catmull_rom_patch_points(patch:Tuple[PointInputs, PointInputs], + subdivisions:int = DEFAULT_SUBDIVISIONS, + index_start:int = 0) -> Tuple[List[Point3], List[FaceTrio]]: + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + cm_points_a = catmull_rom_points(patch[0], subdivisions=subdivisions) + cm_points_b = catmull_rom_points(patch[1], subdivisions=subdivisions) + + strip_length = len(cm_points_a) + + for i in range(subdivisions + 1): + frac = i/subdivisions + verts += list([affine_combination(a,b, frac) for a,b in zip(cm_points_a, cm_points_b)]) + a_start = i*strip_length + index_start + b_start = a_start + strip_length + # This connects the verts we just created to the verts we'll make on the + # next loop. So don't calculate for the last loop + if i < subdivisions: + faces += face_strip_list(a_start, b_start, strip_length) + + return verts, faces + +def catmull_rom_patch(patch:Tuple[PointInputs, PointInputs], subdivisions:int = DEFAULT_SUBDIVISIONS) -> OpenSCADObject: + + faces, vertices = catmull_rom_patch_points(patch, subdivisions) + return polyhedron(faces, vertices) + +def catmull_rom_prism( control_curves:Sequence[PointInputs], + subdivisions:int = DEFAULT_SUBDIVISIONS, + closed_ring:bool = True, + add_caps:bool = True, + smooth_edges: bool = False ) -> polyhedron: + if smooth_edges: + return catmull_rom_prism_smooth_edges(control_curves, subdivisions, closed_ring, add_caps) + + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + curves = list([euclidify(c) for c in control_curves]) + if closed_ring: + curves.append(curves[0]) + + curve_length = (len(curves[0]) -1) * subdivisions + 1 + for i, (a, b) in enumerate(zip(curves[:-1], curves[1:])): + index_start = len(verts) - curve_length + first_new_vert = curve_length + if i == 0: + index_start = 0 + first_new_vert = 0 + + new_verts, new_faces = catmull_rom_patch_points((a,b), subdivisions=subdivisions, index_start=index_start) + + # new_faces describes all the triangles in the patch we just computed, + # but new_verts shares its first curve_length vertices with the last + # curve_length vertices; Add on only the new points + verts += new_verts[first_new_vert:] + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, len(verts), curve_length) + top_indices = range(curve_length-1, len(verts), curve_length) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + +def catmull_rom_prism_smooth_edges( control_curves:Sequence[PointInputs], + subdivisions:int = DEFAULT_SUBDIVISIONS, + closed_ring:bool = True, + add_caps:bool = True ) -> polyhedron: + + verts: List[Point3] = [] + faces: List[FaceTrio] = [] + + # TODO: verify that each control_curve has the same length + + curves = list([euclidify(c) for c in control_curves]) + + expanded_curves = [catmull_rom_points(c, subdivisions, close_loop=False) for c in curves] + expanded_length = len(expanded_curves[0]) + for i in range(expanded_length): + contour_controls = [c[i] for c in expanded_curves] + contour = catmull_rom_points(contour_controls, subdivisions, close_loop=closed_ring) + verts += contour + + contour_length = len(contour) + # generate the face triangles between the last two rows of vertices + if i > 0: + a_start = len(verts) - 2 * contour_length + b_start = len(verts) - contour_length + # Note the b_start, a_start order here. This makes sure our faces + # are pointed outwards for the test cases I ran. I think if control + # curves were specified clockwise rather than counter-clockwise, all + # of the faces would be pointed inwards + new_faces = face_strip_list(b_start, a_start, length=contour_length, close_loop=closed_ring) + faces += new_faces + + if closed_ring and add_caps: + bot_indices = range(0, contour_length) + top_indices = range(len(verts) - contour_length, len(verts)) + + bot_centroid, bot_faces = centroid_endcap(verts, bot_indices) + verts.append(bot_centroid) + faces += bot_faces + # Note that bot_centroid must be added to verts before creating the + # top endcap; otherwise both endcaps would point to the same centroid point + top_centroid, top_faces = centroid_endcap(verts, top_indices, invert=True) + verts.append(top_centroid) + faces += top_faces + + p = polyhedron(faces=faces, points=verts, convexity=3) + return p + # ================== # = BEZIER SPLINES = # ================== @@ -127,8 +263,19 @@ def bezier_polygon( controls: FourPoints, extrude_height:float = DEFAULT_EXTRUDE_HEIGHT, show_controls: bool = False, center: bool = True) -> OpenSCADObject: + ''' + Return an OpenSCAD object representing a closed quadratic Bezier curve. + If extrude_height == 0, return a 2D `polygon()` object. + If extrude_height > 0, return a 3D extrusion of specified height. + Note that OpenSCAD won't render 2D & 3D objects together correctly, so pick + one and use that. + ''' points = bezier_points(controls, subdivisions) - shape = polygon(points) + # OpenSCAD can'ts handle Point3s in creating a polygon. Convert them to Point2s + # Note that this prevents us from making polygons outside of the XY plane, + # even though a polygon could reasonably be in some other plane while remaining 2D + points = list((Point2(p.x, p.y) for p in points)) + shape: OpenSCADObject = polygon(points) if extrude_height != 0: shape = linear_extrude(extrude_height, center=center)(shape) @@ -140,7 +287,7 @@ def bezier_polygon( controls: FourPoints, def bezier_points(controls: FourPoints, subdivisions: int = DEFAULT_SUBDIVISIONS, - include_last: bool = True) -> List[Point2]: + 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 @@ -155,17 +302,23 @@ def bezier_points(controls: FourPoints, # TODO: enable a smooth curve through arbitrarily many points, as described at: # https://www.algosome.com/articles/continuous-bezier-curve-line.html - points: List[Point2] = [] + points: List[Point3] = [] last_elt = 1 if include_last else 0 for i in range(subdivisions + last_elt): u = i/subdivisions points.append(_point_along_bez4(*controls, u)) return points -def _point_along_bez4(p0: Point23, p1: Point23, p2: Point23, p3: Point23, u:float) -> Point2: +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 - return Point2(x,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) @@ -179,10 +332,14 @@ def _bez23(u:float) -> float: def _bez33(u:float) -> float: return pow(u,3) +# ================ +# = HOBBY CURVES = +# ================ + # =========== # = HELPERS = # =========== -def control_points(points: Sequence[Point23], extrude_height:float=0, center:bool=True) -> OpenSCADObject: +def control_points(points: Sequence[Point23], extrude_height:float=0, center:bool=True, points_color:Tuple3=Red) -> OpenSCADObject: """ Return a list of red cylinders/circles (depending on `extrude_height`) at a supplied set of 2D points. Useful for visualizing and tweaking a curve's @@ -198,5 +355,91 @@ def control_points(points: Sequence[Point23], extrude_height:float=0, center:boo else: h = extrude_height * 1.1 c = cylinder(r=r, h=h, center=center) - controls = color(Red)([translate([p.x, p.y])(c) for p in points]) + controls = color(points_color)([translate((p.x, p.y, 0))(c) for p in points]) return controls + +def face_strip_list(a_start:int, b_start:int, length:int, close_loop:bool=False) -> List[FaceTrio]: + # If a_start is the index of the vertex at one end of a row of points in a surface, + # and b_start is the index of the vertex at the same end of the next row of points, + # return a list of lists of indices describing faces for the whole row: + # face_strip_list(a_start = 0, b_start = 3, length=3) => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # 3-4-5 + # |/|/| + # 0-1-2 => [[0,4,3], [0,1,4], [1,5,4], [1,2,5]] + # + # If close_loop is true, add one more pair of faces connecting the far + # edge of the strip to the near edge, in this case [[2,3,5], [2,0,3]] + faces: List[FaceTrio] = [] + loop = length - 1 + + for a, b in zip(range(a_start, a_start + loop), range(b_start, b_start + loop)): + faces.append((a, b+1, b)) + faces.append((a, a+1, b+1)) + if close_loop: + faces.append((a+loop, b, b+loop)) + faces.append((a+loop, a, b)) + return faces + +def fan_endcap_list(cap_points:int=3, index_start:int=0) -> List[FaceTrio]: + ''' + Return a face-triangles list for the endpoint of a tube with cap_points points + We construct a fan of triangles all starting at point index_start and going + to each point in turn. + + NOTE that this would not work for non-convex rings. + In that case, it would probably be better to create a new centroid point and have + all triangle reach out from it. That wouldn't handle all polygons, but would + work with mildly concave ones like a star, for example. + + So fan_endcap_list(cap_points=6, index_start=0), like so: + 0 + / \ + 5 1 + | | + 4 2 + \ / + 3 + + returns: [(0,1,2), (0,2,3), (0,3,4), (0,4,5)] + ''' + faces: List[FaceTrio] = [] + for i in range(index_start + 1, index_start + cap_points - 1): + faces.append((index_start, i, i+1)) + return faces + +def centroid_endcap(tube_points:Sequence[Point3], indices:Sequence[int], invert:bool = False) -> Tuple[Point3, List[FaceTrio]]: + # tube_points: all points in a polyhedron tube + # indices: the indexes of the points at the desired end of the tube + # invert: if True, invert the order of the generated faces. One endcap in + # each pair should be inverted + # + # Return all the triangle information needed to make an endcap polyhedron + # + # This is sufficient for some moderately concave polygonal endcaps, + # (a star shape, say), but wouldn't be enough for more irregularly convex + # polygons (anyplace where a segment from the centroid to a point on the + # polygon crosses an edge of the polygon) + faces: List[FaceTrio] = [] + center = centroid([tube_points[i] for i in indices]) + centroid_index = len(tube_points) + + for a,b in zip(indices[:-1], indices[1:]): + faces.append((centroid_index, a, b)) + faces.append((centroid_index, indices[-1], indices[0])) + + if invert: + faces = list((reversed(f) for f in faces)) # type: ignore + + return (center, faces) + +def centroid(points:Sequence[Point23]) -> Point23: + total = Point3(0,0,0) + for p in points: + total += p + total /= len(points) + return total + +def affine_combination(a:Point23, b:Point23, fraction:float) -> Point23: + # Return a Point[23] between a & b, where fraction==0 => a, fraction==1 => b + return (1-fraction) * a + fraction*b + diff --git a/solid/test/run_all_tests.sh b/solid/test/run_all_tests.sh index 3732b294..a40ad1a0 100755 --- a/solid/test/run_all_tests.sh +++ b/solid/test/run_all_tests.sh @@ -4,8 +4,16 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" cd $DIR -# Let unittest discover all the tests -python -m unittest discover . +export PYTHONPATH="../../":$PYTHONPATH +# Run all tests. Note that unittest's built-in discovery doesn't run the dynamic +# testcase generation they contain +for i in test_*.py; +do + echo $i; + python3 $i; + echo +done + # revert to original dir -cd - \ No newline at end of file +cd - diff --git a/solid/test/test_extrude_along_path.py b/solid/test/test_extrude_along_path.py new file mode 100755 index 00000000..2b935b5b --- /dev/null +++ b/solid/test/test_extrude_along_path.py @@ -0,0 +1,125 @@ +#! /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] + 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,.1,N)) + + # in earlier code, this would have thrown an exception + a = extrude_along_path(shape_pts=profile, path_pts=path, scales=scalepts) + +if __name__ == '__main__': + unittest.main() diff --git a/solid/test/test_screw_thread.py b/solid/test/test_screw_thread.py index 5a09320d..3860b931 100755 --- a/solid/test/test_screw_thread.py +++ b/solid/test/test_screw_thread.py @@ -8,9 +8,6 @@ SEGMENTS = 4 -def remove_whitespace(a_str): - return re.subn(r'[\s\n]','', a_str)[0] - class TestScrewThread(DiffOutput): def setUp(self): self.tooth_height = 10 @@ -18,6 +15,7 @@ def setUp(self): 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] self.assertEqual(remove_whitespace(a), remove_whitespace(b)) def test_thread(self): @@ -32,6 +30,7 @@ def test_thread(self): actual = scad_render(actual_obj) expected = '''intersection(){ polyhedron( + convexity=2, faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-14.9900000000,2.5000000000],[-0.0000000000,-19.9900000000,7.5000000000],[-0.0000000000,-14.9900000000,12.5000000000]] ); @@ -54,6 +53,7 @@ def test_thread_internal(self): 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], [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]] ); @@ -71,9 +71,9 @@ def test_conical_thread_external(self): neck_in_degrees=45, neck_out_degrees=45, external=True) - actual = remove_whitespace(scad_render(actual_obj)) + actual = scad_render(actual_obj) expected = '''intersection(){ - polyhedron( + 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]] ); @@ -97,9 +97,12 @@ def test_conical_thread_internal(self): 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);}''' + 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): @@ -120,6 +123,7 @@ def test_neck_in_out_degrees(self): actual = scad_render(actual_obj) expected = '''intersection(){ polyhedron( + convexity=2, faces=[[0,1,3],[1,4,3],[1,2,4],[2,5,4],[0,5,2],[0,3,5],[3,4,6],[4,7,6],[4,5,7],[5,8,7],[3,8,5],[3,6,8],[6,7,9],[7,10,9],[7,8,10],[8,11,10],[6,11,8],[6,9,11],[0,2,1],[9,10,11]], points=[[14.9900000000,0.0000000000,-5.0000000000],[19.9900000000,0.0000000000,0.0000000000],[14.9900000000,0.0000000000,5.0000000000],[0.0000000000,20.0000000000,-2.5000000000],[0.0000000000,25.0000000000,2.5000000000],[0.0000000000,20.0000000000,7.5000000000],[-20.0000000000,0.0000000000,0.0000000000],[-25.0000000000,0.0000000000,5.0000000000],[-20.0000000000,0.0000000000,10.0000000000],[-0.0000000000,-20.0000000000,2.5000000000],[-0.0000000000,-25.0000000000,7.5000000000],[-0.0000000000,-20.0000000000,12.5000000000]] ); diff --git a/solid/test/test_solidpython.py b/solid/test/test_solidpython.py index dce2513a..029c8bb5 100755 --- a/solid/test/test_solidpython.py +++ b/solid/test/test_solidpython.py @@ -16,40 +16,44 @@ 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]}, }, ] @@ -111,7 +115,7 @@ def test_infix_intersection(self): 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,26 +136,33 @@ 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': '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': ['var5'], 'kwargs': []}, + {'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']}, @@ -170,17 +181,22 @@ def test_parse_scad_callables(self): {'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': '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 actual = scad_render(a) abs_path = a._get_include_path(include_file) @@ -203,6 +219,68 @@ def test_import_scad(self): 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 + d.mkdir(exist_ok=True) + 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''' @@ -213,12 +291,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 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 actual = scad_render(b) expected = f"use <{path}>\n\n\nor(arg = 5);" self.assertEqual(expected, actual) @@ -229,7 +307,7 @@ 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') include(include_file) - a = steps(3) + a = steps(3) # type: ignore actual = scad_render(a) abs_path = a._get_include_path(include_file) @@ -270,6 +348,25 @@ def test_root(self): 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}' @@ -382,7 +479,7 @@ 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]);' actual = scad_render(numpy_cube) @@ -412,10 +509,10 @@ def __iter__(self): def single_test(test_dict): - name, args, kwargs, expected = test_dict['name'], test_dict['args'], test_dict['kwargs'], test_dict['expected'] + name, 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(): diff --git a/solid/test/test_splines.py b/solid/test/test_splines.py old mode 100644 new mode 100755 index 21fcba41..10641044 --- a/solid/test/test_splines.py +++ b/solid/test/test_splines.py @@ -3,24 +3,28 @@ import unittest from solid.test.ExpandedTestCase import DiffOutput from solid import * -from solid.splines import catmull_rom_points, bezier_points -from euclid3 import Point2, Vector2 +from solid.utils import euclidify +from solid.splines import catmull_rom_points, catmull_rom_prism, bezier_points, bezier_polygon +from euclid3 import Point2, Point3, Vector2, Vector3 +from math import pi SEGMENTS = 8 class TestSplines(DiffOutput): def setUp(self): self.points = [ - Point2(0,0), - Point2(1,1), - Point2(2,1), + Point3(0,0), + Point3(1,1), + Point3(2,1), ] + self.points_raw = [ (0,0), (1,1), (2,1), ] self.bezier_controls = [ - Point2(0,0), - Point2(1,1), - Point2(2,1), - Point2(2,-1), + Point3(0,0), + Point3(1,1), + Point3(2,1), + Point3(2,-1), ] + self.bezier_controls_raw = [ (0,0), (1,1), (2,1), (2,-1) ] self.subdivisions = 2 def assertPointsListsEqual(self, a, b): @@ -28,23 +32,65 @@ def assertPointsListsEqual(self, a, b): self.assertEqual(str_list(a), str_list(b)) def test_catmull_rom_points(self): - expected = [Vector2(0.00, 0.00), Vector2(0.38, 0.44), Vector2(1.00, 1.00), Vector2(1.62, 1.06), Vector2(2.00, 1.00)] + expected = [Point3(0.00, 0.00), Point3(0.38, 0.44), Point3(1.00, 1.00), Point3(1.62, 1.06), Point3(2.00, 1.00)] actual = catmull_rom_points(self.points, subdivisions=self.subdivisions, close_loop=False) self.assertPointsListsEqual(expected, actual) - # actual = list((str(v) for v in actual)) - # expected = list((str(v) for v in expected)) - # self.assertEqual(expected, actual) # TODO: verify we always have the right number of points for a given call # verify that `close_loop` always behaves correctly - # verify that catmull_rom_polygon() returns an OpenSCADObject # verify that start_tangent and end_tangent behavior is correct + def test_catmull_rom_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [Point3(0.00, 0.00), Point3(0.38, 0.44), Point3(1.00, 1.00), Point3(1.62, 1.06), Point3(2.00, 1.00)] + actual = catmull_rom_points(self.points_raw, subdivisions=self.subdivisions, close_loop=False) + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_points_3d(self): + points = [Point3(-1,-1,0), Point3(0,0,1), Point3(1,1,0)] + expected = [Point3(-1.00, -1.00, 0.00), Point3(-0.62, -0.62, 0.50), Point3(0.00, 0.00, 1.00), Point3(0.62, 0.62, 0.50), Point3(1.00, 1.00, 0.00)] + actual = catmull_rom_points(points, subdivisions=2) + self.assertPointsListsEqual(expected, actual) + def test_bezier_points(self): - expected = [Point2(0.00, 0.00), Point2(1.38, 0.62), Point2(2.00, -1.00)] + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] actual = bezier_points(self.bezier_controls, subdivisions=self.subdivisions) self.assertPointsListsEqual(expected, actual) + def test_bezier_points_raw(self): + # Verify that we can use raw sequences of floats as inputs (e.g [(1,2), (3.2,4)]) + # rather than sequences of Point2s + expected = [Point3(0.00, 0.00), Point3(1.38, 0.62), Point3(2.00, -1.00)] + actual = bezier_points(self.bezier_controls_raw, subdivisions=self.subdivisions) + self.assertPointsListsEqual(expected, actual) + + def test_bezier_points_3d(self): + # verify that we get a valid bezier curve back even when its control points + # are outside the XY plane and aren't coplanar + controls_3d = [Point3(-2,-1, 0), Point3(-0.5, -0.5, 1), Point3(0.5, 0.5, 1), Point3(2,1,0)] + actual = bezier_points(controls_3d, subdivisions=self.subdivisions) + expected = [Point3(-2.00, -1.00, 0.00),Point3(0.00, 0.00, 0.75), Point3(2.00, 1.00, 0.00)] + self.assertPointsListsEqual(expected, actual) + + def test_catmull_rom_prism(self): + sides = 3 + UP = Vector3(0,0,1) + + control_points = [[10, 10, 0], [10, 10, 5], [8, 8, 15]] + + cat_tube = [] + angle_step = 2*pi/sides + for i in range(sides): + rotated_controls = list((euclidify(p, Point3).rotate_around(UP, angle_step*i) for p in control_points)) + cat_tube.append(rotated_controls) + + poly = catmull_rom_prism(cat_tube, self.subdivisions, closed_ring=True, add_caps=True) + actual = (len(poly.params['points']), len(poly.params['faces'])) + expected = (37, 62) + self.assertEqual(expected, actual) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/solid/test/test_utils.py b/solid/test/test_utils.py index 116582bc..8ba3c2de 100755 --- a/solid/test/test_utils.py +++ b/solid/test/test_utils.py @@ -1,57 +1,74 @@ #! /usr/bin/env python import difflib +from solid.solidpython import OpenSCADObject, scad_render_to_file 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 extrude_along_path, 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}'), + # 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}'), - ('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]]);'), - ] other_test_cases = [ - (euclidify, [[0, 0, 0]], 'Vector3(0.00, 0.00, 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)]'), - (euc_to_arr, [Vector3(0, 0, 0)], '[0, 0, 0]'), + ('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, [cube(2)], 'True'), + ('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], '[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)]'), + ('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] + 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] @@ -62,8 +79,7 @@ def test_split_body_planar(self): 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}', @@ -78,22 +94,64 @@ def test_split_body_planar(self): def test_fillet_2d_add(self): 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) + 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 test_scad(self): diff --git a/solid/utils.py b/solid/utils.py index ed16d667..52f3c117 100755 --- a/solid/utils.py +++ b/solid/utils.py @@ -1,29 +1,40 @@ #! /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 solid import union, cube, translate, rotate, square, circle, polyhedron -from solid import difference, intersection, multmatrix +from solid import union, cube, translate, rotate, square, circle, polyhedron, 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 euclid3 import Point2, Point3, Vector2, Vector3, Line2, Line3 from euclid3 import LineSegment2, LineSegment3, Matrix4 run_euclid_patch() +# ========== +# = TYPING = +# ========== +from typing import Any, Union, Tuple, Sequence, List, Optional, Callable, Dict, cast +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, - Tuple[float, float], - Tuple[float, float, float] + Tuple2, + Tuple3 ] +DirectionLR = float # LEFT or RIGHT in 2D + +# ============= +# = CONSTANTS = +# ============= EPSILON = 0.01 RIGHT, TOP, LEFT, BOTTOM = range(4) @@ -41,6 +52,8 @@ # ========== # = 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) @@ -94,7 +107,6 @@ def grid_plane(grid_unit:int=12, count:int=10, line_weight:float=0.1, plane:str= return t - def distribute_in_grid(objects:Sequence[OpenSCADObject], max_bounding_box:Tuple[float,float], rows_and_cols: Tuple[int,int]=None) -> OpenSCADObject: @@ -120,7 +132,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)))) @@ -138,32 +150,24 @@ def distribute_in_grid(objects:Sequence[OpenSCADObject], # ============== # = Directions = # ============== - - def up(z:float) -> OpenSCADObject: return translate((0, 0, z)) - def down(z: float) -> OpenSCADObject: return translate((0, 0, -z)) - def right(x: float) -> OpenSCADObject: return translate((x, 0, 0)) - def left(x: float) -> OpenSCADObject: return translate((-x, 0, 0)) - def forward(y: float) -> OpenSCADObject: return translate((0, y, 0)) - def back(y: float) -> OpenSCADObject: return translate((0, -y, 0)) - # =========================== # = Box-alignment rotations = # =========================== @@ -171,27 +175,21 @@ 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: return rotate(a=180, v=FORWARD_VEC)(obj) - def rot_z_to_right(obj:OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=FORWARD_VEC)(obj) - def rot_z_to_left(obj:OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=FORWARD_VEC)(obj) - def rot_z_to_forward(obj:OpenSCADObject) -> OpenSCADObject: return rotate(a=-90, v=RIGHT_VEC)(obj) - def rot_z_to_back(obj:OpenSCADObject) -> OpenSCADObject: return rotate(a=90, v=RIGHT_VEC)(obj) - # ================================ # = Box-aligment and translation = # ================================ @@ -219,35 +217,27 @@ def box_align(obj:OpenSCADObject, # = 90-degree Rotations = # ======================= - 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: return rotate(a=-90, v=FORWARD_VEC)(obj) - 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: return rotate(a=-90, v=RIGHT_VEC)(obj) - 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: return rotate(a=-90, v=UP_VEC)(obj) # ======= # = Arc = # ======= - - 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 @@ -276,7 +266,6 @@ def arc(rad:float, start_degrees:float, end_degrees:float, segments:int=None) -> return ret - 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 @@ -336,8 +325,6 @@ def arc_inverted(rad:float, start_degrees:float, end_degrees:float, segments:int # ====================== # = 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,26 +336,29 @@ 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): + 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) @@ -382,7 +372,7 @@ def split_planar(self, cutting_plane_normal=RIGHT_VEC, cut_proportion=0.5, add_w # 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,32 +400,24 @@ 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) ) 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, @@ -486,11 +468,10 @@ 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)) @@ -501,7 +482,7 @@ def section_cut_xz(body, y_cut_point=0): # ===================== # 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) @@ -511,42 +492,63 @@ def section_cut_xz(body, y_cut_point=0): # 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] = [] 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(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(): + all_costs: Dict[str, float] = {} + for desc, elements in bom_parts_dict.items(): + row = [] count = elements['Count'] currency = elements['currency'] price = elements['Unit Price'] @@ -584,10 +586,10 @@ def bill_of_materials(csv=False): 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 # or CSV is requested @@ -614,9 +616,7 @@ def _table_string(field_names, rows, csv=False): # ================ # = Bounding Box = # ================ - - -def bounding_box(points): +def bounding_box(points: Sequence[EucOrTuple]) -> Tuple[Tuple3, Tuple3]: all_x = [] all_y = [] all_z = [] @@ -624,12 +624,11 @@ 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 = @@ -638,7 +637,6 @@ def bounding_box(points): '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}, @@ -652,7 +650,7 @@ def bounding_box(points): '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 @@ -666,7 +664,7 @@ def screw(screw_type='m3', screw_length=16): ) 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'] @@ -677,7 +675,7 @@ def nut(screw_type='m3'): ) 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 @@ -692,42 +690,85 @@ def bearing(bearing_type='624'): ) 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, l in enumerate(lines): + t = text(text=l, 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 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 @@ -745,6 +786,22 @@ def euc_to_arr(euc_obj_or_list: EucOrTuple) -> List[float]: # Inverse of euclid result = euc_obj_or_list # type: ignore return result +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) @@ -756,10 +813,36 @@ def scad_matrix(euclid_matrix4): [a.m, a.n, a.o, a.p] ] +def centroid(points:Sequence[PointVec23]) -> PointVec23: + if not points: + raise ValueError(f"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 @@ -814,13 +897,14 @@ def _orig_euclid_look_at(eye, at, up): 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 @@ -856,320 +940,218 @@ def draw_segment(euc_line=None, endless=False, arrow_rad=7, vec_color=None): # ========== # = 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) - - return offset_pts + # otherwise use first and last points in lines + intersections.insert(0, lines[0].p) + intersections.append(lines[-1].p + lines[-1].v) + return intersections + +def offset_point(a:Point2, b:Point2, c:Point2, offset:float, direction:DirectionLR=LEFT_DIR) -> Point2: + ab_perp = perpendicular_vector(b-a, direction, length=offset) + bc_perp = perpendicular_vector(c-b, direction, length=offset) + + ab_par = Line2(a + ab_perp, b + ab_perp) + bc_par = Line2(b + bc_perp, c + bc_perp) + result = ab_par.intersect(bc_par) + return result # ================== # = Offset helpers = # ================== -def _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 pairwise_zip(l:Sequence) -> zip: # type:ignore + return zip(l[:-1], l[1:]) -def _three_point_normal(a:Point3, b:Point3, c:Point3) -> Vector3: - ab = b - a - bc = c - b +def cross_2d(a:Vector2, b:Vector2) -> float: + """ + scalar value; tells direction of rotation from a to b; + see direction_of_bend() + # See http://www.allenchou.net/2013/07/cross-product-of-2d-vectors/ + """ + return a.x * b.y - a.y * b.x + +def direction_of_bend(a:Point2, b:Point2, c:Point2) -> DirectionLR: + """ + Return LEFT_DIR if angle abc is a turn to the left, otherwise RIGHT_DIR + Returns RIGHT_DIR if ab and bc are colinear + """ + direction = LEFT_DIR if cross_2d(b-a, c-b) > 0 else RIGHT_DIR + return direction - seg_ab = Line3(a, ab) - seg_bc = Line3(b, bc) - x = seg_ab.v.cross(seg_bc.v) - return x +def opposite_direction(direction:DirectionLR) -> DirectionLR: + return LEFT_DIR if direction == RIGHT_DIR else RIGHT_DIR + +def perpendicular_vector(v:Vector2, direction:DirectionLR=RIGHT_DIR, length:float=None) -> Vector2: + perp_vec = Vector2(v.y, -v.x) + result = perp_vec if direction == RIGHT_DIR else -perp_vec + if length is not None: + result.set_length(length) + return result + +def vectors_between_points(points: Sequence[Point23]) -> List[Vector23]: + """ + Return a list of the vectors from each point in points to the point that follows + """ + vecs = list((b-a for a,b in pairwise_zip(points))) # type:ignore + return vecs # ============= # = 2D Fillet = # ============= -def _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 - - if end_degrees - start_degrees >= 180: - start_degrees, end_degrees = end_degrees, start_degrees - epsilon_degrees = 2 - return start_degrees - epsilon_degrees, end_degrees + epsilon_degrees +def fillet_2d(three_point_sets: 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`. -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 = [] + 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 - assert len(three_points) in (2, 3) - # make two vectors out of the three points passed in - a, b, c = euclidify(three_points, Point3) - - # Find the center of the arc we'll have to make - offset = offset_points([a, b, c], offset=fillet_rad, inside=True) - center_pt = offset[1] - - a2, b2, c2, cp2 = [Point2(p.x, p.y) for p in (a, b, c, center_pt)] + direction = direction_of_bend(a, b, c) - a2b2 = LineSegment2(a2, b2) - c2b2 = LineSegment2(c2, b2) + # 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 - # 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) + start_degrees = degrees(atan2(ab_perp.y, ab_perp.x)) + end_degrees = degrees(atan2(bc_perp.y, bc_perp.x)) - afp, cfp = [ - seg.p1 if seg.p1 != cp2 else seg.p2 for seg in (afs, cfs)] - - a_degs, c_degs = [ - (degrees(atan2(seg.v.y, seg.v.x))) % 360 for seg in (afs, cfs)] - - start_degs = a_degs - end_degs = c_degs - - # Widen start_degs and end_degs slightly so they overlap the areas + # 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_inverted( - rad=fillet_rad, start_degrees=start_degs, end_degrees=end_degs) + arc_obj = translate(center.as_arr())( + arc_inverted(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 - - 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) ) - - # Cap the start of the polyhedron - for i in range(1, shape_pt_count - 1): - facet_indices.append((0, i, i + 1)) - - # 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) ) - - return polyhedron(points=euc_to_arr(polyhedron_pts), faces=facet_indices) # type: ignore + poly = orig_poly + arc_objs + return poly +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 -# {{{ http://code.activestate.com/recipes/577068/ (r1) + if end_degrees - start_degrees >= 180: + start_degrees, end_degrees = end_degrees, start_degrees + epsilon_degrees = 0.1 + return start_degrees - epsilon_degrees, end_degrees + epsilon_degrees -def frange(*args): - """frange([start, ] end [, step [, mode]]) -> generator +# ============== +# = 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]]) + + 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 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. @@ -1224,13 +1206,21 @@ 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: # For debugging. This prints a string of all of an object's # children, with whatever attributes are specified in vars_to_print @@ -1256,3 +1246,9 @@ def obj_tree_str(sp_obj:OpenSCADObject, vars_to_print:Sequence[str]=None) -> str 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