├── __init__.py ├── solid ├── test │ ├── __init__.py │ ├── solidpython.egg-info │ │ ├── top_level.txt │ │ ├── dependency_links.txt │ │ ├── SOURCES.txt │ │ └── PKG-INFO │ ├── run_all_tests.sh │ ├── ExpandedTestCase.py │ ├── test_screw_thread.py │ ├── test_utils.py │ └── test_solidpython.py ├── examples │ ├── __init__.py │ ├── mazebox │ │ ├── playground │ │ │ └── maze7.png │ │ ├── testpng.py │ │ ├── trianglemath.py │ │ ├── inset.py │ │ └── mazebox_clean2_stable.py │ ├── scad_to_include.scad │ ├── solidpython_template.py │ ├── run_all_examples.sh │ ├── basic_scad_include.py │ ├── append_solidpython_code.py │ ├── screw_thread_example.py │ ├── animation_example.py │ ├── path_extrude_example.py │ ├── basic_geometry.py │ ├── bom_scad.py │ ├── hole_example.py │ ├── sierpinski.py │ └── koch.py ├── __init__.py ├── patch_euclid.py ├── t_slots.py ├── screw_thread.py ├── solidpython.py └── objects.py ├── euclid3 └── __init__.py ├── .gitignore ├── keyboard-layout.json ├── keyboard-layout-104.json ├── README.md ├── keyboard-layout-atreus.json ├── keyboard-layout-symbolics.json ├── keyboard-layout-ergodox.json ├── keyboard-layout-prog.json └── BoardBuilder.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /solid/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /solid/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /euclid3/__init__.py: -------------------------------------------------------------------------------- 1 | from .euclid3 import * 2 | 3 | -------------------------------------------------------------------------------- /solid/test/solidpython.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /solid/test/solidpython.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.dxf 2 | *.orig 3 | *.pyc 4 | *.scad 5 | *.swp 6 | *.swo 7 | -------------------------------------------------------------------------------- /solid/examples/mazebox/playground/maze7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CurrentResident/BoardBuilder/HEAD/solid/examples/mazebox/playground/maze7.png -------------------------------------------------------------------------------- /solid/test/run_all_tests.sh: -------------------------------------------------------------------------------- 1 | # Note that this file needs to be run from solid/test. 2 | for i in test_*.py; 3 | do 4 | echo $i; 5 | python $i; 6 | echo 7 | done 8 | -------------------------------------------------------------------------------- /solid/test/solidpython.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | solidpython.egg-info/PKG-INFO 2 | solidpython.egg-info/SOURCES.txt 3 | solidpython.egg-info/dependency_links.txt 4 | solidpython.egg-info/top_level.txt -------------------------------------------------------------------------------- /solid/test/solidpython.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.0 2 | Name: solidpython 3 | Version: 0.1 4 | Summary: UNKNOWN 5 | Home-page: UNKNOWN 6 | Author: UNKNOWN 7 | Author-email: UNKNOWN 8 | License: UNKNOWN 9 | Description: UNKNOWN 10 | Platform: UNKNOWN 11 | -------------------------------------------------------------------------------- /solid/__init__.py: -------------------------------------------------------------------------------- 1 | # Some __init__ magic so we can include all solidpython code with: 2 | # from solid import * 3 | # from solid.utils import * 4 | from .solidpython import scad_render, scad_render_to_file 5 | from .solidpython import scad_render_animated, scad_render_animated_file 6 | from .objects import * 7 | -------------------------------------------------------------------------------- /solid/examples/mazebox/testpng.py: -------------------------------------------------------------------------------- 1 | import png 2 | import urllib 3 | 4 | 5 | def getPNG(fn): 6 | r = png.Reader(file=urllib.urlopen(fn)) 7 | data = r.read() 8 | pixel = data[2] 9 | raw = [] 10 | # print(data) 11 | for row in pixel: 12 | # print(row) 13 | # exit() 14 | r = [] 15 | raw.append(r) 16 | for px in row: 17 | r.append(px) 18 | return raw 19 | -------------------------------------------------------------------------------- /solid/examples/scad_to_include.scad: -------------------------------------------------------------------------------- 1 | external_var = false; 2 | module steps(howmany=3){ 3 | union(){ 4 | for (i=[0:howmany-1]){ 5 | translate( [i*10,0,0]){ 6 | cube( [10,10,(i+1)*10]); 7 | } 8 | } 9 | } 10 | 11 | if (external_var){ 12 | echo( "external_var passed in as true"); 13 | } 14 | } 15 | 16 | echo("This text should appear only when called with include(), not use()"); -------------------------------------------------------------------------------- /solid/examples/solidpython_template.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | # Assumes SolidPython is in site-packages or elsewhwere in sys.path 9 | from solid import * 10 | from solid.utils import * 11 | 12 | SEGMENTS = 48 13 | 14 | 15 | def assembly(): 16 | # Your code here! 17 | a = union() 18 | 19 | return a 20 | 21 | if __name__ == '__main__': 22 | a = assembly() 23 | scad_render_to_file(a, file_header='$fn = %s;' % SEGMENTS, include_orig_code=True) 24 | -------------------------------------------------------------------------------- /solid/examples/run_all_examples.sh: -------------------------------------------------------------------------------- 1 | COMPILED_EXAMPLES=${PWD}/Compiled_examples 2 | 3 | echo 4 | # if COMPILED_EXAMPLES doesn't exist, create it. 5 | if [ ! -e $COMPILED_EXAMPLES ]; 6 | then mkdir $COMPILED_EXAMPLES; 7 | fi 8 | 9 | for py in *.py; 10 | do 11 | echo "==================================================="; 12 | echo "python $py $COMPILED_EXAMPLES"; 13 | python $py $COMPILED_EXAMPLES; 14 | echo "==================================================="; 15 | echo 16 | done 17 | 18 | # Note: mazebox example isn't included because it requires a 19 | # significant python package (pypng) to be installed. 20 | # Comments in examples/mazebox/mazebox_clean2_stable.py 21 | # explain how to install pypng -------------------------------------------------------------------------------- /solid/examples/basic_scad_include.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | import os 5 | 6 | from solid import * 7 | 8 | # Import OpenSCAD code and call it from Python code. 9 | # The path given to use() (or include()) must be absolute or findable in 10 | # sys.path 11 | 12 | 13 | def demo_scad_include(): 14 | # scad_to_include.scad includes a module called steps() 15 | scad_path = os.path.join(os.path.dirname(__file__), "scad_to_include.scad") 16 | use(scad_path) # could also use 'include', but that has side-effects; 17 | # 'use' just imports without executing any of the imported code 18 | return steps(5) 19 | 20 | 21 | if __name__ == '__main__': 22 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 23 | file_out = os.path.join(out_dir, 'scad_include_example.scad') 24 | 25 | a = demo_scad_include() 26 | 27 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 28 | 29 | scad_render_to_file(a, file_out) 30 | -------------------------------------------------------------------------------- /solid/test/ExpandedTestCase.py: -------------------------------------------------------------------------------- 1 | """ 2 | A version of unittest that gives output in an easier to use format 3 | """ 4 | import sys 5 | import unittest 6 | import difflib 7 | 8 | 9 | class DiffOutput(unittest.TestCase): 10 | 11 | def assertEqual(self, first, second, msg=None): 12 | """ 13 | Override assertEqual and print(a context diff if msg=None) 14 | """ 15 | # Test if both are strings, in Python 2 & 3 16 | string_types = str if sys.version_info[0] == 3 else basestring 17 | 18 | if isinstance(first, string_types) and isinstance(second, string_types): 19 | if not msg: 20 | msg = 'Strings are not equal:\n' + ''.join( 21 | difflib.unified_diff( 22 | [first], 23 | [second], 24 | fromfile='actual', 25 | tofile='expected' 26 | ) 27 | ) 28 | return super(DiffOutput, self).assertEqual(first, second, msg=msg) 29 | -------------------------------------------------------------------------------- /solid/examples/append_solidpython_code.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | import os 4 | import sys 5 | 6 | from solid import * 7 | from solid.utils import * 8 | 9 | SEGMENTS = 48 10 | 11 | 12 | def show_appended_python_code(): 13 | a = cylinder(r=10, h=10, center=True) + up(5)(cylinder(r1=10, r2=0, h=10)) 14 | 15 | return a 16 | 17 | if __name__ == '__main__': 18 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 19 | file_out = os.path.join(out_dir, 'append_solidpython_code.scad') 20 | 21 | a = show_appended_python_code() 22 | 23 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 24 | # ================================================================ 25 | # = include_orig_code appends all python code as comments to the 26 | # = bottom of the generated OpenSCAD code, so the final document 27 | # = contains the easy-to-read python code as well as the SCAD. 28 | # = ------------------------------------------------------------ = 29 | scad_render_to_file(a, file_out, include_orig_code=True) 30 | -------------------------------------------------------------------------------- /solid/examples/screw_thread_example.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | from solid import * 9 | from solid.utils import * 10 | from solid import screw_thread 11 | 12 | SEGMENTS = 48 13 | 14 | inner_rad = 40 15 | screw_height = 80 16 | 17 | 18 | def assembly(): 19 | section = screw_thread.default_thread_section(tooth_height=10, tooth_depth=5) 20 | s = screw_thread.thread(outline_pts=section, inner_rad=inner_rad, 21 | pitch=screw_height, length=screw_height, segments_per_rot=SEGMENTS) 22 | #, neck_in_degrees=90, neck_out_degrees=90) 23 | 24 | c = cylinder(r=inner_rad, h=screw_height) 25 | return s + c 26 | 27 | if __name__ == '__main__': 28 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 29 | file_out = os.path.join(out_dir, 'screw_thread_example.scad') 30 | 31 | a = assembly() 32 | 33 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 34 | 35 | scad_render_to_file(a, file_out, include_orig_code=True) 36 | -------------------------------------------------------------------------------- /solid/patch_euclid.py: -------------------------------------------------------------------------------- 1 | import euclid3 2 | from euclid3 import * 3 | 4 | from solid.utils import EPSILON 5 | 6 | # NOTE: The PyEuclid on PyPi doesn't include several elements added to 7 | # the module as of 13 Feb 2013. Add them here until euclid supports them 8 | 9 | 10 | def as_arr_local(self): 11 | return [self.x, self.y, self.z] 12 | 13 | 14 | def set_length_local(self, length): 15 | d = self.magnitude() 16 | if d: 17 | factor = length / d 18 | self.x *= factor 19 | self.y *= factor 20 | 21 | return self 22 | 23 | 24 | def _intersect_line3_line3(A, B): 25 | # Connect A & B 26 | # If the length of the connecting segment is 0, they intersect 27 | # at the endpoint(s) of the connecting segment 28 | sol = euclid3._connect_line3_line3(A, B) 29 | # TODO: Ray3 and LineSegment3 would like to be able to know 30 | # if their intersection points fall within the segment. 31 | if sol.magnitude_squared() < EPSILON: 32 | return sol.p 33 | else: 34 | return None 35 | 36 | 37 | def run_patch(): 38 | if 'as_arr' not in dir(Vector3): 39 | Vector3.as_arr = as_arr_local 40 | if 'set_length' not in dir(Vector3): 41 | Vector3.set_length = set_length_local 42 | if '_intersect_line3' not in dir(Line3): 43 | Line3._intersect_line3 = _intersect_line3_line3 44 | -------------------------------------------------------------------------------- /solid/examples/animation_example.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | from solid import * 9 | from solid.utils import * 10 | 11 | 12 | def my_animate(_time=0): 13 | # _time will range from 0 to 1, not including 1 14 | rads = _time * 2 * 3.1416 15 | rad = 15 16 | c = translate([rad * cos(rads), rad * sin(rads)])(square(10)) 17 | 18 | return c 19 | 20 | if __name__ == '__main__': 21 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 22 | file_out = os.path.join(out_dir, 'animation_example.scad') 23 | 24 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 25 | 26 | # To animate in OpenSCAD: 27 | # - Run this program to generate a SCAD file. 28 | # - Open the generated SCAD file in OpenSCAD 29 | # - Choose "View -> Animate" 30 | # - Enter FPS (frames per second) and Steps in the fields 31 | # at the bottom of the OpenSCAD window 32 | # - FPS & Steps are flexible. For a start, set both to 20 33 | # play around from there 34 | scad_render_animated_file(my_animate, # A function that takes a float argument 35 | # called '_time' in [0,1) 36 | # and returns an OpenSCAD object 37 | steps=20, # Number of steps to create one complete motion 38 | back_and_forth=True, # If true, runs the complete motion 39 | # forward and then in reverse, 40 | # to avoid discontinuity 41 | filepath=file_out, # Output file 42 | include_orig_code=True ) # Append SolidPython code 43 | # to the end of the generated 44 | # OpenSCAD code. 45 | 46 | -------------------------------------------------------------------------------- /solid/examples/path_extrude_example.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | # Assumes SolidPython is in site-packages or elsewhwere in sys.path 9 | from solid import * 10 | from solid.utils import * 11 | 12 | SEGMENTS = 48 13 | 14 | 15 | def sinusoidal_ring(rad=25, segments=SEGMENTS): 16 | outline = [] 17 | for i in range(segments): 18 | angle = i * 360 / segments 19 | x = rad * cos(radians(angle)) 20 | y = rad * sin(radians(angle)) 21 | z = 2 * sin(radians(angle * 6)) 22 | outline.append(Point3(x, y, z)) 23 | return outline 24 | 25 | 26 | def star(num_points=5, outer_rad=15, dip_factor=0.5): 27 | star_pts = [] 28 | for i in range(2 * num_points): 29 | rad = outer_rad - i % 2 * dip_factor * outer_rad 30 | angle = radians(360 / (2 * num_points) * i) 31 | star_pts.append(Point3(rad * cos(angle), rad * sin(angle), 0)) 32 | return star_pts 33 | 34 | 35 | def extrude_example(): 36 | 37 | # Note the incorrect triangulation at the two ends of the path. This 38 | # is because star isn't convex, and the triangulation algorithm for 39 | # the two end caps only works for convex shapes. 40 | shape = star(num_points=5) 41 | path = sinusoidal_ring(rad=50) 42 | 43 | # If scale_factors aren't included, they'll default to 44 | # no scaling at each step along path. Here, let's 45 | # make the shape twice as big at beginning and end of the path 46 | scales = [1] * len(path) 47 | scales[0] = 2 48 | scales[-1] = 2 49 | 50 | extruded = extrude_along_path(shape_pts=shape, path_pts=path, scale_factors=scales) 51 | 52 | return extruded 53 | 54 | if __name__ == '__main__': 55 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 56 | file_out = os.path.join(out_dir, 'path_extrude_example.scad') 57 | 58 | a = extrude_example() 59 | 60 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 61 | scad_render_to_file(a, file_out, include_orig_code=True) 62 | -------------------------------------------------------------------------------- /solid/examples/mazebox/trianglemath.py: -------------------------------------------------------------------------------- 1 | from math import * 2 | 3 | 4 | def Tripple2Vec3D(t): 5 | return Vec3D(t[0], t[1], t[2]) 6 | 7 | 8 | class Vec3D: 9 | 10 | def __init__(self, x, y, z): 11 | self.set(x, y, z) 12 | 13 | def angle2D(self): 14 | a = atan2(self.x, self.y) 15 | if (a < 0): 16 | a += 2 * pi 17 | return a 18 | 19 | def set(self, x, y, z): 20 | self.x = x 21 | self.y = y 22 | self.z = z 23 | 24 | def times(self, t): 25 | return Vec3D(self.x * t, self.y * t, self.z * t) 26 | 27 | # changes the objetct itself 28 | def add(self, v): 29 | self.x += v.x 30 | self.y += v.y 31 | self.z += v.z 32 | 33 | def plus(self, v): 34 | return Vec3D(self.x + v.x, self.y + v.y, self.z + v.z) 35 | 36 | def minus(self, v): 37 | return Vec3D(self.x - v.x, self.y - v.y, self.z - v.z) 38 | 39 | def len(self): 40 | return sqrt(self.x * self.x + self.y * self.y + self.z * self.z) 41 | 42 | # changes the object itself 43 | def normalize(self): 44 | l = self.len() 45 | self.x /= l 46 | self.y /= l 47 | self.z /= l 48 | 49 | def asTripple(self): 50 | return [self.x, self.y, self.z] 51 | 52 | def scalarProduct(self, v): 53 | return self.x * v.x + self.y * v.y + self.z * v.z 54 | 55 | def crossProduct(self, v): 56 | return Vec3D(self.y * v.z - self.z * v.y, 57 | self.z * v.x - self.x * v.z, 58 | self.x * v.y - self.y * v.x) 59 | 60 | 61 | def planeNormal(p): 62 | t1 = Tripple2Vec3D(p[0]) 63 | t2 = Tripple2Vec3D(p[1]) 64 | t3 = Tripple2Vec3D(p[2]) 65 | t1.add(t3.times(-1)) 66 | t2.add(t3.times(-1)) 67 | return t1.crossProduct(t2) 68 | 69 | 70 | # plane defined by a list of three len 3 lists of points in R3 71 | def angleBetweenPlanes(p1, p2): 72 | n1 = planeNormal(p1) 73 | n2 = planeNormal(p2) 74 | n1.normalize() 75 | n2.normalize() 76 | # print(n1.asTripple()) 77 | # print(n2.asTripple()) 78 | s = n1.scalarProduct(n2) 79 | # print(s) 80 | if (s > 1): 81 | s = 1 82 | if (s < -1): 83 | s = -1 84 | return acos(s) 85 | -------------------------------------------------------------------------------- /solid/examples/basic_geometry.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | from solid import * 9 | from solid.utils import * 10 | 11 | SEGMENTS = 48 12 | 13 | 14 | def basic_geometry(): 15 | # SolidPython code can look a lot like OpenSCAD code. It also has 16 | # some syntactic sugar built in that can make it look more pythonic. 17 | # Here are two identical pieces of geometry, one left and one right. 18 | 19 | # left_piece uses standard OpenSCAD grammar (note the commas between 20 | # block elements; OpenSCAD doesn't require this) 21 | left_piece = union()( 22 | translate([-15, 0, 0])( 23 | cube([10, 5, 3], center=True) 24 | ), 25 | translate([-10, 0, 0])( 26 | difference()( 27 | cylinder(r=5, h=15, center=True), 28 | cylinder(r=4, h=16, center=True) 29 | ) 30 | ) 31 | ) 32 | 33 | # Right piece uses a more Pythonic grammar. + (plus) is equivalent to union(), 34 | # - (minus) is equivalent to difference() and * (star) is equivalent to intersection 35 | # solid.utils also defines up(), down(), left(), right(), forward(), and back() 36 | # for common transforms. 37 | right_piece = right(15)(cube([10, 5, 3], center=True)) 38 | cyl = cylinder(r=5, h=15, center=True) - cylinder(r=4, h=16, center=True) 39 | right_piece += right(10)(cyl) 40 | 41 | return union()(left_piece, right_piece) 42 | 43 | if __name__ == '__main__': 44 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 45 | file_out = os.path.join(out_dir, 'basic_geometry.scad') 46 | 47 | a = basic_geometry() 48 | 49 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 50 | 51 | # Adding the file_header argument as shown allows you to change 52 | # the detail of arcs by changing the SEGMENTS variable. This can 53 | # be expensive when making lots of small curves, but is otherwise 54 | # useful. 55 | scad_render_to_file(a, file_out, file_header='$fn = %s;' % SEGMENTS) 56 | -------------------------------------------------------------------------------- /keyboard-layout.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "Esc", 4 | { 5 | "x": 1 6 | }, 7 | "F1", 8 | "F2", 9 | "F3", 10 | "F4", 11 | { 12 | "x": 0.5 13 | }, 14 | "F5", 15 | "F6", 16 | "F7", 17 | "F8", 18 | { 19 | "x": 0.5 20 | }, 21 | "F9", 22 | "F10", 23 | "F11", 24 | "F12", 25 | { 26 | "x": 0.25 27 | }, 28 | "PrtSc", 29 | "Scroll Lock", 30 | "Pause\nBreak" 31 | ], 32 | [ 33 | { 34 | "y": 0.5 35 | }, 36 | "~\n`", 37 | "!\n1", 38 | "@\n2", 39 | "#\n3", 40 | "$\n4", 41 | "%\n5", 42 | "^\n6", 43 | "&\n7", 44 | "*\n8", 45 | "(\n9", 46 | ")\n0", 47 | "_\n-", 48 | "+\n=", 49 | { 50 | "w": 2 51 | }, 52 | "Backspace", 53 | { 54 | "x": 0.25 55 | }, 56 | "Insert", 57 | "Home", 58 | "PgUp" 59 | ], 60 | [ 61 | { 62 | "w": 1.5 63 | }, 64 | "Tab", 65 | "Q", 66 | "W", 67 | "E", 68 | "R", 69 | "T", 70 | "Y", 71 | "U", 72 | "I", 73 | "O", 74 | "P", 75 | "{\n[", 76 | "}\n]", 77 | { 78 | "w": 1.5 79 | }, 80 | "|\n\\", 81 | { 82 | "x": 0.25 83 | }, 84 | "Delete", 85 | "End", 86 | "PgDn" 87 | ], 88 | [ 89 | { 90 | "w": 1.75 91 | }, 92 | "Caps Lock", 93 | "A", 94 | "S", 95 | "D", 96 | "F", 97 | "G", 98 | "H", 99 | "J", 100 | "K", 101 | "L", 102 | ":\n;", 103 | "\"\n'", 104 | { 105 | "w": 2.25 106 | }, 107 | "Enter" 108 | ], 109 | [ 110 | { 111 | "w": 2.25 112 | }, 113 | "Shift", 114 | "Z", 115 | "X", 116 | "C", 117 | "V", 118 | "B", 119 | "N", 120 | "M", 121 | "<\n,", 122 | ">\n.", 123 | "?\n/", 124 | { 125 | "w": 2.75 126 | }, 127 | "Shift", 128 | { 129 | "x": 1.25 130 | }, 131 | "↑" 132 | ], 133 | [ 134 | { 135 | "w": 1.25 136 | }, 137 | "Ctrl", 138 | { 139 | "w": 1.25 140 | }, 141 | "Win", 142 | { 143 | "w": 1.25 144 | }, 145 | "Alt", 146 | { 147 | "a": 7, 148 | "w": 6.25 149 | }, 150 | "", 151 | { 152 | "a": 4, 153 | "w": 1.25 154 | }, 155 | "Alt", 156 | { 157 | "w": 1.25 158 | }, 159 | "Win", 160 | { 161 | "w": 1.25 162 | }, 163 | "Menu", 164 | { 165 | "w": 1.25 166 | }, 167 | "Ctrl", 168 | { 169 | "x": 0.25 170 | }, 171 | "←", 172 | "↓", 173 | "→" 174 | ] 175 | ] -------------------------------------------------------------------------------- /keyboard-layout-104.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "Esc", 4 | { 5 | "x": 1 6 | }, 7 | "F1", 8 | "F2", 9 | "F3", 10 | "F4", 11 | { 12 | "x": 0.5 13 | }, 14 | "F5", 15 | "F6", 16 | "F7", 17 | "F8", 18 | { 19 | "x": 0.5 20 | }, 21 | "F9", 22 | "F10", 23 | "F11", 24 | "F12", 25 | { 26 | "x": 0.25 27 | }, 28 | "PrtSc", 29 | "Scroll Lock", 30 | "Pause\nBreak" 31 | ], 32 | [ 33 | { 34 | "y": 0.5 35 | }, 36 | "~\n`", 37 | "!\n1", 38 | "@\n2", 39 | "#\n3", 40 | "$\n4", 41 | "%\n5", 42 | "^\n6", 43 | "&\n7", 44 | "*\n8", 45 | "(\n9", 46 | ")\n0", 47 | "_\n-", 48 | "+\n=", 49 | { 50 | "w": 2 51 | }, 52 | "Backspace", 53 | { 54 | "x": 0.25 55 | }, 56 | "Insert", 57 | "Home", 58 | "PgUp", 59 | { 60 | "x": 0.25 61 | }, 62 | "Num Lock", 63 | "/", 64 | "*", 65 | "-" 66 | ], 67 | [ 68 | { 69 | "w": 1.5 70 | }, 71 | "Tab", 72 | "Q", 73 | "W", 74 | "E", 75 | "R", 76 | "T", 77 | "Y", 78 | "U", 79 | "I", 80 | "O", 81 | "P", 82 | "{\n[", 83 | "}\n]", 84 | { 85 | "w": 1.5 86 | }, 87 | "|\n\\", 88 | { 89 | "x": 0.25 90 | }, 91 | "Delete", 92 | "End", 93 | "PgDn", 94 | { 95 | "x": 0.25 96 | }, 97 | "7\nHome", 98 | "8\n↑", 99 | "9\nPgUp", 100 | { 101 | "h": 2 102 | }, 103 | "+" 104 | ], 105 | [ 106 | { 107 | "w": 1.75 108 | }, 109 | "Caps Lock", 110 | "A", 111 | "S", 112 | "D", 113 | "F", 114 | "G", 115 | "H", 116 | "J", 117 | "K", 118 | "L", 119 | ":\n;", 120 | "\"\n'", 121 | { 122 | "w": 2.25 123 | }, 124 | "Enter", 125 | { 126 | "x": 3.5 127 | }, 128 | "4\n←", 129 | "5", 130 | "6\n→" 131 | ], 132 | [ 133 | { 134 | "w": 2.25 135 | }, 136 | "Shift", 137 | "Z", 138 | "X", 139 | "C", 140 | "V", 141 | "B", 142 | "N", 143 | "M", 144 | "<\n,", 145 | ">\n.", 146 | "?\n/", 147 | { 148 | "w": 2.75 149 | }, 150 | "Shift", 151 | { 152 | "x": 1.25 153 | }, 154 | "↑", 155 | { 156 | "x": 1.25 157 | }, 158 | "1\nEnd", 159 | "2\n↓", 160 | "3\nPgDn", 161 | { 162 | "h": 2 163 | }, 164 | "Enter" 165 | ], 166 | [ 167 | { 168 | "w": 1.25 169 | }, 170 | "Ctrl", 171 | { 172 | "w": 1.25 173 | }, 174 | "Win", 175 | { 176 | "w": 1.25 177 | }, 178 | "Alt", 179 | { 180 | "a": 7, 181 | "w": 6.25 182 | }, 183 | "", 184 | { 185 | "a": 4, 186 | "w": 1.25 187 | }, 188 | "Alt", 189 | { 190 | "w": 1.25 191 | }, 192 | "Win", 193 | { 194 | "w": 1.25 195 | }, 196 | "Menu", 197 | { 198 | "w": 1.25 199 | }, 200 | "Ctrl", 201 | { 202 | "x": 0.25 203 | }, 204 | "←", 205 | "↓", 206 | "→", 207 | { 208 | "x": 0.25, 209 | "w": 2 210 | }, 211 | "0\nIns", 212 | ".\nDel" 213 | ] 214 | ] -------------------------------------------------------------------------------- /solid/examples/bom_scad.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Basic shape with several repeated parts, demonstrating the use of 5 | # solid.utils.bill_of_materials() 6 | # 7 | # Basically: 8 | # -- Define every part you want in the Bill of Materials in a function 9 | # -- Use the 'bom_part' decorator ahead of the definition of each part's function 10 | # e.g.: 11 | # @bom_part() 12 | # def my_part(): 13 | # pass 14 | # -- Optionally, you can add a description and a per-unit cost to the 15 | # decorator invocations. 16 | # 17 | # -- To generate the bill of materials, call solid.utils.bill_of_materials() 18 | # 19 | # -ETJ 08 Mar 2011 20 | 21 | import os 22 | import sys 23 | 24 | from solid import * 25 | from solid.utils import * 26 | 27 | head_rad = 2.65 28 | head_height = 2.8 29 | 30 | nut_height = 2.3 31 | nut_rad = 3 32 | 33 | m3_rad = 1.4 34 | 35 | doohickey_h = 5 36 | 37 | set_bom_headers("link", "leftover") 38 | 39 | def head(): 40 | return cylinder(h=head_height, r=head_rad) 41 | 42 | 43 | @bom_part("M3x16 Bolt", 0.12, currency="€", link="http://example.io/M3x16", leftover=0) 44 | def m3_16(a=3): 45 | bolt_height = 16 46 | m = union()( 47 | head(), 48 | translate([0, 0, -bolt_height])( 49 | cylinder(r=m3_rad, h=bolt_height) 50 | ) 51 | ) 52 | return m 53 | 54 | 55 | @bom_part("M3x12 Bolt", 0.09, leftover=0) 56 | def m3_12(): 57 | bolt_height = 12 58 | m = union()( 59 | head(), 60 | translate([0, 0, -bolt_height])( 61 | cylinder(r=m3_rad, h=bolt_height) 62 | ) 63 | ) 64 | return m 65 | 66 | 67 | @bom_part("M3 Nut", 0.04, currency="R$") 68 | def m3_nut(): 69 | hx = cylinder(r=nut_rad, h=nut_height) 70 | hx.add_param('$fn', 6) # make the nut hexagonal 71 | n = difference()( 72 | hx, 73 | translate([0, 0, -EPSILON])( 74 | cylinder(r=m3_rad, h=nut_height + 2 * EPSILON) 75 | ) 76 | ) 77 | return n 78 | 79 | 80 | @bom_part() 81 | def doohickey(): 82 | hole_cyl = translate([0, 0, -EPSILON])( 83 | cylinder(r=m3_rad, h=doohickey_h + 2 * EPSILON) 84 | ) 85 | d = difference()( 86 | cube([30, 10, doohickey_h], center=True), 87 | translate([-10, 0, 0])(hole_cyl), 88 | hole_cyl, 89 | translate([10, 0, 0])(hole_cyl) 90 | ) 91 | return d 92 | 93 | 94 | def assemble(): 95 | return union()( 96 | doohickey(), 97 | translate([-10, 0, doohickey_h / 2])(m3_12()), 98 | translate([ 0, 0, doohickey_h / 2])(m3_16()), 99 | translate([ 10, 0, doohickey_h / 2])(m3_12()), 100 | # Nuts 101 | translate([-10, 0, -nut_height - doohickey_h / 2])(m3_nut()), 102 | translate([ 0, 0, -nut_height - doohickey_h / 2])(m3_nut()), 103 | translate([ 10, 0, -nut_height - doohickey_h / 2])(m3_nut()), 104 | ) 105 | 106 | if __name__ == '__main__': 107 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 108 | file_out = os.path.join(out_dir, 'BOM_example.scad') 109 | 110 | a = assemble() 111 | 112 | bom = bill_of_materials() 113 | 114 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 115 | print(bom) 116 | 117 | print("Or, Spreadsheet-ready TSV:\n\n") 118 | bom = bill_of_materials(csv=True) 119 | print(bom) 120 | 121 | 122 | scad_render_to_file(a, file_out) 123 | -------------------------------------------------------------------------------- /solid/examples/hole_example.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | # Assumes SolidPython is in site-packages or elsewhwere in sys.path 9 | from solid import * 10 | from solid.utils import * 11 | 12 | SEGMENTS = 120 13 | 14 | 15 | def pipe_intersection_hole(): 16 | pipe_od = 12 17 | pipe_id = 10 18 | seg_length = 30 19 | 20 | outer = cylinder(r=pipe_od, h=seg_length, center=True) 21 | inner = cylinder(r=pipe_id, h=seg_length + 2, center=True) 22 | 23 | # By declaring that the internal void of pipe_a should 24 | # explicitly remain empty, the combination of both pipes 25 | # is empty all the way through. 26 | 27 | # Any OpenSCAD / SolidPython object can be declared a hole(), 28 | # and after that will always be empty 29 | pipe_a = outer + hole()(inner) 30 | # Note that "pipe_a = outer - hole()(inner)" would work identically; 31 | # inner will always be subtracted now that it's a hole 32 | 33 | pipe_b = rotate(a=90, v=FORWARD_VEC)(pipe_a) 34 | return pipe_a + pipe_b 35 | 36 | 37 | def pipe_intersection_no_hole(): 38 | pipe_od = 12 39 | pipe_id = 10 40 | seg_length = 30 41 | 42 | outer = cylinder(r=pipe_od, h=seg_length, center=True) 43 | inner = cylinder(r=pipe_id, h=seg_length + 2, center=True) 44 | pipe_a = outer - inner 45 | 46 | pipe_b = rotate(a=90, v=FORWARD_VEC)(pipe_a) 47 | # pipe_a and pipe_b are both hollow, but because 48 | # their central voids aren't explicitly holes, 49 | # the union of both pipes has unwanted internal walls 50 | 51 | return pipe_a + pipe_b 52 | 53 | 54 | def multipart_hole(): 55 | # It's good to be able to keep holes empty, but often we want to put 56 | # things (bolts, etc.) in them. The way to do this is to declare the 57 | # object containing the hole a "part". Then, the hole will remain 58 | # empty no matter what you add to the 'part'. But if you put an object 59 | # that is NOT part of the 'part' into the hole, it will still appear. 60 | 61 | # On the left (not_part), here's what happens if we try to put an object 62 | # into an explicit hole: the object gets erased by the hole. 63 | 64 | # On the right (is_part), we mark the cube-with-hole as a "part", 65 | # and then insert the same 'bolt' cylinder into it. The entire 66 | # bolt rematins. 67 | 68 | b = cube(10, center=True) 69 | c = cylinder(r=2, h=12, center=True) 70 | 71 | # A cube with an explicit hole 72 | not_part = b - hole()(c) 73 | 74 | # Mark this cube-with-hole as a separate part from the cylinder 75 | is_part = part()(not_part.copy()) 76 | 77 | # This fits in the holes 78 | bolt = cylinder(r=1.5, h=14, center=True) + up(8)(cylinder(r=2.5, h=2.5, center=True)) 79 | 80 | # The section of the bolt inside not_part disappears. The section 81 | # of the bolt inside is_part is still there. 82 | a = not_part + bolt + right(45)(is_part + bolt) 83 | 84 | return a 85 | 86 | if __name__ == '__main__': 87 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 88 | file_out = os.path.join(out_dir, 'hole_example.scad') 89 | 90 | # On the left, pipes with no explicit holes, which can give 91 | # unexpected walls where we don't want them. 92 | # On the right, we use the hole() function to fix the problem 93 | a = pipe_intersection_no_hole() + right(45)(pipe_intersection_hole()) 94 | 95 | # Below is an example of how to put objects into holes and have them 96 | # still appear 97 | b = up(40)(multipart_hole()) 98 | a += b 99 | 100 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 101 | scad_render_to_file(a, file_out, file_header='$fn = %s;' % SEGMENTS, include_orig_code=True) 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BoardBuilder 2 | ===================== 3 | 4 | Copyright (c) 2016-2019, Jim Thoenen 5 | All rights reserved. See license below. 6 | 7 | This is a python script that processes keyboard-layout-editor.com JSON files and 8 | generates OpenSCAD .scad files that can be further modified and exported to .dxf 9 | or other formats as desired within OpenSCAD. 10 | 11 | Usage 12 | ===== 13 | 14 | BoardBuilder may be invoked directly on the command line, or it may be included as 15 | a module from another Python script. Please invoke with `--help` to see the 16 | accepted arguments. 17 | 18 | Hints and Notes 19 | =============== 20 | 21 | This script generates fairly simple plate drawings which are intended to be 22 | a reasonable starting point for a keyboard. Once generated, consider 23 | `include`ing them into hand-crafted .scad files where you can further modify 24 | the final drawing for additional features (e.g. screw holes for feet, cutouts 25 | for LCDs, etc). This provides a clean separation of hand-crafted and generated 26 | content, and works nicely with OpenSCAD's automatic reloading of externally- 27 | modified files. Also, remember you can often cut down costs by combining 28 | multiple parts of identical material and thickness onto a single drawing. 29 | 30 | BoardBuilder also produces a `holes.scad` file that contains only the 31 | "negative space" switch and stabilizer cutouts. This allows you to 32 | independently design your own plates and then CSG-difference out the holes. 33 | 34 | While the script allows combined Cherry + Costar stabilizer cutouts, their 35 | use is discouraged, particularly if the resulting board will actually use 36 | Cherry stabilizers. This is because the Costar-compatibility cutouts 37 | literally undermine the Cherry stabilizer's "top" clips, hurting their ability 38 | to securely grab onto the plate. 39 | 40 | The script adjusts the vertical displacement of the Costar stab cutouts WRT 41 | the switch cutouts from -0.75mm to -0.55mm, which eliminates sticking on most 42 | plates. Be warned, however, that plates thicker than 1.5mm can distort 43 | Costar stabs such that a displacement of -0.45mm might be needed. This can 44 | be commanded by specifying `--stab_vertical_adjustment -0.1`. The downside to 45 | doing so is that the Costar wire can *just barely* make contact with switch 46 | itself in the fully-depressed position. I don't know if that's enough to 47 | matter, though, or if `-0.05` would address the issue. Please do let me know 48 | if you try it out. :) 49 | 50 | License 51 | ===================== 52 | 53 | BoardBuilder 54 | Copyright (c) 2016-2019, Jim Thoenen 55 | All rights reserved. 56 | 57 | Redistribution and use in source and binary forms, with or without 58 | modification, are permitted provided that the following conditions are met: 59 | * Redistributions of source code must retain the above copyright 60 | notice, this list of conditions and the following disclaimer. 61 | * Redistributions in binary form must reproduce the above copyright 62 | notice, this list of conditions and the following disclaimer in the 63 | documentation and/or other materials provided with the distribution. 64 | * Neither the name of Jim Thoenen nor the names of any contributors may 65 | be used to endorse or promote products derived from this software 66 | without specific prior written permission. 67 | 68 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 69 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 70 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 71 | DISCLAIMED. IN NO EVENT SHALL JIM THOENEN BE LIABLE FOR ANY 72 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 73 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 74 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 75 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 76 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 77 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 78 | -------------------------------------------------------------------------------- /keyboard-layout-atreus.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | "r": 10, 5 | "rx": 1, 6 | "y": -0.1, 7 | "x": 2 8 | }, 9 | "E" 10 | ], 11 | [ 12 | { 13 | "y": -0.65, 14 | "x": 1 15 | }, 16 | "W", 17 | { 18 | "x": 1 19 | }, 20 | "R" 21 | ], 22 | [ 23 | { 24 | "y": -0.75 25 | }, 26 | "Q" 27 | ], 28 | [ 29 | { 30 | "y": -0.9, 31 | "x": 4 32 | }, 33 | "T" 34 | ], 35 | [ 36 | { 37 | "y": -0.7, 38 | "x": 2 39 | }, 40 | "D" 41 | ], 42 | [ 43 | { 44 | "y": -0.6500000000000001, 45 | "x": 1 46 | }, 47 | "S", 48 | { 49 | "x": 1 50 | }, 51 | "F" 52 | ], 53 | [ 54 | { 55 | "y": -0.75 56 | }, 57 | "A" 58 | ], 59 | [ 60 | { 61 | "y": -0.8999999999999999, 62 | "x": 4 63 | }, 64 | "G" 65 | ], 66 | [ 67 | { 68 | "y": -0.7, 69 | "x": 2 70 | }, 71 | "C" 72 | ], 73 | [ 74 | { 75 | "y": -0.6499999999999999, 76 | "x": 1 77 | }, 78 | "X", 79 | { 80 | "x": 1 81 | }, 82 | "V" 83 | ], 84 | [ 85 | { 86 | "y": -0.75 87 | }, 88 | "Z" 89 | ], 90 | [ 91 | { 92 | "y": -0.8999999999999999, 93 | "x": 4 94 | }, 95 | "B" 96 | ], 97 | [ 98 | { 99 | "y": -0.75, 100 | "x": 5, 101 | "h": 1.5 102 | }, 103 | "Ctrl" 104 | ], 105 | [ 106 | { 107 | "y": -0.9500000000000002, 108 | "x": 2 109 | }, 110 | "super" 111 | ], 112 | [ 113 | { 114 | "y": -0.6499999999999999, 115 | "x": 1 116 | }, 117 | "Tab", 118 | { 119 | "x": 1 120 | }, 121 | "Shift" 122 | ], 123 | [ 124 | { 125 | "y": -0.75 126 | }, 127 | "Esc" 128 | ], 129 | [ 130 | { 131 | "y": -0.8999999999999999, 132 | "x": 4 133 | }, 134 | "Bksp" 135 | ], 136 | [ 137 | { 138 | "r": -10, 139 | "rx": 7, 140 | "ry": 0.965, 141 | "y": -0.20000000000000007, 142 | "x": 2 143 | }, 144 | "I" 145 | ], 146 | [ 147 | { 148 | "y": -0.6500000000000001, 149 | "x": 1 150 | }, 151 | "U", 152 | { 153 | "x": 1 154 | }, 155 | "O" 156 | ], 157 | [ 158 | { 159 | "y": -0.75, 160 | "x": 4 161 | }, 162 | "P" 163 | ], 164 | [ 165 | { 166 | "y": -0.8999999999999999 167 | }, 168 | "Y" 169 | ], 170 | [ 171 | { 172 | "y": -0.7, 173 | "x": 2 174 | }, 175 | "K" 176 | ], 177 | [ 178 | { 179 | "y": -0.6499999999999999, 180 | "x": 1 181 | }, 182 | "J", 183 | { 184 | "x": 1 185 | }, 186 | "L" 187 | ], 188 | [ 189 | { 190 | "y": -0.75, 191 | "x": 4 192 | }, 193 | ":\n;" 194 | ], 195 | [ 196 | { 197 | "y": -0.8999999999999999 198 | }, 199 | "H" 200 | ], 201 | [ 202 | { 203 | "y": -0.7000000000000002, 204 | "x": 2 205 | }, 206 | "<\n," 207 | ], 208 | [ 209 | { 210 | "y": -0.6499999999999999, 211 | "x": 1 212 | }, 213 | "M", 214 | { 215 | "x": 1 216 | }, 217 | ">\n." 218 | ], 219 | [ 220 | { 221 | "y": -0.75, 222 | "x": 4 223 | }, 224 | "?\n/" 225 | ], 226 | [ 227 | { 228 | "y": -0.8999999999999999 229 | }, 230 | "N" 231 | ], 232 | [ 233 | { 234 | "y": -0.75, 235 | "x": -1, 236 | "h": 1.5 237 | }, 238 | "Alt" 239 | ], 240 | [ 241 | { 242 | "y": -0.9500000000000002, 243 | "x": 2 244 | }, 245 | "_\n-" 246 | ], 247 | [ 248 | { 249 | "y": -0.6500000000000004, 250 | "x": 1 251 | }, 252 | "fn", 253 | { 254 | "x": 1 255 | }, 256 | "\"\n'" 257 | ], 258 | [ 259 | { 260 | "y": -0.75, 261 | "x": 4 262 | }, 263 | "Enter" 264 | ], 265 | [ 266 | { 267 | "y": -0.9000000000000004 268 | }, 269 | "Space" 270 | ] 271 | ] -------------------------------------------------------------------------------- /solid/examples/sierpinski.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | 6 | from solid import * 7 | from solid.utils import * 8 | 9 | import random 10 | import math 11 | 12 | # ========================================================= 13 | # = A basic recursive Sierpinski's gasket implementation, 14 | # outputting a file 'gasket_x.scad' to the argv[1] or $PWD 15 | # ========================================================= 16 | 17 | 18 | class SierpinskiTetrahedron(object): 19 | 20 | def __init__(self, four_points): 21 | self.points = four_points 22 | 23 | def segments(self): 24 | indices = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)] 25 | return [(self.points[a], self.points[b]) for a, b in indices] 26 | 27 | def next_gen(self, midpoint_weight=0.5, jitter_range_vec=None): 28 | midpoints = [weighted_midpoint(s[0], s[1], weight=midpoint_weight, jitter_range_vec=jitter_range_vec) for s in self.segments()] 29 | all_points = self.points + midpoints 30 | new_tet_indices = [(0, 4, 5, 6), 31 | (4, 1, 7, 8), 32 | (5, 2, 9, 7), 33 | (6, 3, 8, 9), ] 34 | new_tets = [] 35 | for four_ind in new_tet_indices: 36 | tet_points = [all_points[i] for i in four_ind] 37 | new_tets.append(SierpinskiTetrahedron(tet_points)) 38 | 39 | return new_tets 40 | 41 | def scale(self, factor): 42 | 43 | self.points = [[factor * d for d in p] for p in self.points] 44 | 45 | def scad_code(self): 46 | faces = [[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 3, 2]] 47 | return polyhedron(points=self.points, faces=faces, convexity=1) 48 | 49 | 50 | def distance(a, b): 51 | return math.sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2])) 52 | 53 | 54 | def weighted_midpoint(a, b, weight=0.5, jitter_range_vec=None): 55 | # ignoring jitter_range_vec for now 56 | x = weight * a[0] + (1 - weight) * b[0] 57 | y = weight * a[1] + (1 - weight) * b[1] 58 | z = weight * a[2] + (1 - weight) * b[2] 59 | 60 | dist = distance(a, b) 61 | 62 | if jitter_range_vec: 63 | x += (random.random() - .5) * dist * jitter_range_vec[0] 64 | y += (random.random() - .5) * dist * jitter_range_vec[1] 65 | z += (random.random() - .5) * dist * jitter_range_vec[2] 66 | 67 | return [x, y, z] 68 | 69 | 70 | def sierpinski_3d(generation, scale=1, midpoint_weight=0.5, jitter_range_vec=None): 71 | orig_tet = SierpinskiTetrahedron([[ 1.0, 1.0, 1.0], 72 | [-1.0, -1.0, 1.0], 73 | [-1.0, 1.0, -1.0], 74 | [ 1.0, -1.0, -1.0]]) 75 | all_tets = [orig_tet] 76 | for i in range(generation): 77 | all_tets = [subtet for tet in all_tets for subtet in tet.next_gen(midpoint_weight, jitter_range_vec)] 78 | 79 | if scale != 1: 80 | for tet in all_tets: 81 | tet.scale(scale) 82 | return all_tets 83 | 84 | 85 | if __name__ == '__main__': 86 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 87 | 88 | generations = 3 89 | midpoint_weight = 0.5 90 | # jitter_range_vec adds some randomness to the generated shape, 91 | # making it more interesting. Try: 92 | # jitter_range_vec = [0.5,0, 0] 93 | jitter_range_vec = None 94 | all_tets = sierpinski_3d(generations, scale=100, midpoint_weight=midpoint_weight, jitter_range_vec=jitter_range_vec) 95 | 96 | t = union() 97 | for tet in all_tets: 98 | # Create the scad code for all tetrahedra 99 | t.add(tet.scad_code()) 100 | # Draw cubes at all intersections to make the shape manifold. 101 | for p in tet.points: 102 | t.add(translate(p).add(cube(5, center=True))) 103 | 104 | file_out = os.path.join(out_dir, 'gasket_%s_gen.scad' % generations) 105 | print("%(__file__)s: SCAD file written to: \n%(file_out)s" % vars()) 106 | scad_render_to_file(t, file_out) 107 | -------------------------------------------------------------------------------- /keyboard-layout-symbolics.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "backcolor": "#B1B1A3" 4 | }, 5 | [ 6 | { 7 | "c": "#93928A", 8 | "t": "#CCCCB7", 9 | "p": "SA", 10 | "a": 7, 11 | "w": 2 12 | }, 13 | "FUNCTION", 14 | { 15 | "w": 2 16 | }, 17 | "ESCAPE", 18 | { 19 | "w": 2 20 | }, 21 | "REFRESH", 22 | { 23 | "f": 8, 24 | "w": 2 25 | }, 26 | "■", 27 | { 28 | "w": 2 29 | }, 30 | "●", 31 | { 32 | "w": 2 33 | }, 34 | "▲", 35 | { 36 | "f": 3, 37 | "w": 2 38 | }, 39 | "CLEAR INPUT", 40 | { 41 | "w": 2 42 | }, 43 | "SUSPEND", 44 | { 45 | "w": 2 46 | }, 47 | "RESUME", 48 | { 49 | "w": 2 50 | }, 51 | "ABORT" 52 | ], 53 | [ 54 | { 55 | "w": 2 56 | }, 57 | "NETWORK", 58 | { 59 | "c": "#474644", 60 | "f": 9 61 | }, 62 | ":", 63 | { 64 | "a": 5, 65 | "f": 7 66 | }, 67 | "!\n1", 68 | "@\n2", 69 | "#\n3", 70 | "$\n4", 71 | "%\n5", 72 | "^\n6", 73 | "&\n7", 74 | "*\n8", 75 | "(\n9", 76 | ")\n0", 77 | { 78 | "f": 6 79 | }, 80 | "—\n–", 81 | "+\n=", 82 | "~\n'", 83 | "{\n\\", 84 | "}\n|", 85 | { 86 | "c": "#93928A", 87 | "a": 7, 88 | "f": 3, 89 | "w": 2 90 | }, 91 | "HELP" 92 | ], 93 | [ 94 | { 95 | "w": 2 96 | }, 97 | "LOCAL", 98 | { 99 | "w": 1.5 100 | }, 101 | "TAB", 102 | { 103 | "c": "#474644", 104 | "f": 9 105 | }, 106 | "Q", 107 | "W", 108 | "E", 109 | "R", 110 | "T", 111 | "Y", 112 | "U", 113 | "I", 114 | "O", 115 | "P", 116 | { 117 | "a": 5, 118 | "f": 6 119 | }, 120 | "[\n(", 121 | "]\n)", 122 | { 123 | "c": "#93928A", 124 | "a": 7, 125 | "f": 3 126 | }, 127 | "BACK SPACE", 128 | { 129 | "w": 1.5 130 | }, 131 | "PAGE", 132 | { 133 | "w": 2 134 | }, 135 | "COMPLETE" 136 | ], 137 | [ 138 | { 139 | "w": 2 140 | }, 141 | "SELECT", 142 | { 143 | "w": 1.75 144 | }, 145 | "RUB OUT", 146 | { 147 | "c": "#474644", 148 | "f": 9 149 | }, 150 | "A", 151 | "S", 152 | "D", 153 | "F", 154 | "G", 155 | "H", 156 | "J", 157 | "K", 158 | "L", 159 | { 160 | "a": 5, 161 | "f": 6 162 | }, 163 | ":\n;", 164 | "\"\n'", 165 | { 166 | "c": "#93928A", 167 | "a": 7, 168 | "f": 3, 169 | "w": 2 170 | }, 171 | "RETURN", 172 | { 173 | "w": 1.25 174 | }, 175 | "LINE", 176 | { 177 | "w": 2 178 | }, 179 | "END" 180 | ], 181 | [ 182 | { 183 | "t": "#474644" 184 | }, 185 | "CAPS LOCK", 186 | { 187 | "w": 1.25 188 | }, 189 | "SYMBOL", 190 | { 191 | "w": 2 192 | }, 193 | "SHIFT", 194 | { 195 | "c": "#474644", 196 | "t": "#CCCCB7", 197 | "f": 9 198 | }, 199 | "Z", 200 | "X", 201 | "C", 202 | "V", 203 | "B", 204 | "N", 205 | "M", 206 | { 207 | "a": 5, 208 | "f": 6 209 | }, 210 | "<\n,", 211 | ">\n.", 212 | "?\n/", 213 | { 214 | "c": "#93928A", 215 | "t": "#474644", 216 | "a": 7, 217 | "f": 3, 218 | "w": 2 219 | }, 220 | "SHIFT", 221 | { 222 | "w": 1.25 223 | }, 224 | "SYMBOL", 225 | { 226 | "w": 1.25 227 | }, 228 | "REPEAT", 229 | { 230 | "w": 1.25 231 | }, 232 | "MODE LOCK" 233 | ], 234 | [ 235 | "HYPER", 236 | "SUPER", 237 | "META", 238 | { 239 | "w": 1.75 240 | }, 241 | "CONTROL", 242 | { 243 | "t": "#CCCCB7", 244 | "p": "SA SPACE", 245 | "w": 9 246 | }, 247 | "", 248 | { 249 | "t": "#474644", 250 | "p": "SA", 251 | "w": 1.75 252 | }, 253 | "CONTROL", 254 | "META", 255 | "SUPER", 256 | "HYPER", 257 | { 258 | "t": "#CCCCB7", 259 | "w": 1.5 260 | }, 261 | "SCROLL" 262 | ] 263 | ] -------------------------------------------------------------------------------- /solid/test/test_screw_thread.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | # Assumes SolidPython is in site-packages or elsewhwere in sys.path 9 | import unittest 10 | from solid.test.ExpandedTestCase import DiffOutput 11 | from solid import * 12 | from solid.screw_thread import thread, default_thread_section 13 | 14 | SEGMENTS = 8 15 | 16 | 17 | class TestScrewThread(DiffOutput): 18 | 19 | def test_thread(self): 20 | tooth_height = 10 21 | tooth_depth = 5 22 | outline = default_thread_section(tooth_height=tooth_height, tooth_depth=tooth_depth) 23 | actual_obj = thread(outline_pts=outline, inner_rad=20, pitch=tooth_height, 24 | length=0.75 * tooth_height, segments_per_rot=SEGMENTS, 25 | neck_in_degrees=45, neck_out_degrees=45) 26 | actual = scad_render(actual_obj) 27 | expected = '\n\nrender() {\n\tintersection() {\n\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], [16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [0, 2, 1], [18, 19, 20]], points = [[14.9900000000, 0.0000000000, -5.0000000000], [19.9900000000, 0.0000000000, 0.0000000000], [14.9900000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -3.7500000000], [17.6776695297, 17.6776695297, 1.2500000000], [14.1421356237, 14.1421356237, 6.2500000000], [0.0000000000, 20.0000000000, -2.5000000000], [0.0000000000, 25.0000000000, 2.5000000000], [0.0000000000, 20.0000000000, 7.5000000000], [-14.1421356237, 14.1421356237, -1.2500000000], [-17.6776695297, 17.6776695297, 3.7500000000], [-14.1421356237, 14.1421356237, 8.7500000000], [-20.0000000000, 0.0000000000, 0.0000000000], [-25.0000000000, 0.0000000000, 5.0000000000], [-20.0000000000, 0.0000000000, 10.0000000000], [-14.1421356237, -14.1421356237, 1.2500000000], [-17.6776695297, -17.6776695297, 6.2500000000], [-14.1421356237, -14.1421356237, 11.2500000000], [-0.0000000000, -14.9900000000, 2.5000000000], [-0.0000000000, -19.9900000000, 7.5000000000], [-0.0000000000, -14.9900000000, 12.5000000000]]);\n\t\tdifference() {\n\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 25.0100000000);\n\t\t\tcylinder($fn = 8, h = 7.5000000000, r = 20);\n\t\t}\n\t}\n}' 28 | self.assertEqual(expected, actual) 29 | 30 | def test_thread_internal(self): 31 | tooth_height = 10 32 | tooth_depth = 5 33 | outline = default_thread_section(tooth_height=tooth_height, tooth_depth=tooth_depth) 34 | actual_obj = thread(outline_pts=outline, inner_rad=20, pitch=2 * tooth_height, 35 | length=2 * tooth_height, segments_per_rot=SEGMENTS, 36 | neck_in_degrees=45, neck_out_degrees=45, 37 | external=False) 38 | actual = scad_render(actual_obj) 39 | expected = '\n\nrender() {\n\tintersection() {\n\t\tpolyhedron(faces = [[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4], [0, 5, 2], [0, 3, 5], [3, 4, 6], [4, 7, 6], [4, 5, 7], [5, 8, 7], [3, 8, 5], [3, 6, 8], [6, 7, 9], [7, 10, 9], [7, 8, 10], [8, 11, 10], [6, 11, 8], [6, 9, 11], [9, 10, 12], [10, 13, 12], [10, 11, 13], [11, 14, 13], [9, 14, 11], [9, 12, 14], [12, 13, 15], [13, 16, 15], [13, 14, 16], [14, 17, 16], [12, 17, 14], [12, 15, 17], [15, 16, 18], [16, 19, 18], [16, 17, 19], [17, 20, 19], [15, 20, 17], [15, 18, 20], [18, 19, 21], [19, 22, 21], [19, 20, 22], [20, 23, 22], [18, 23, 20], [18, 21, 23], [21, 22, 24], [22, 25, 24], [22, 23, 25], [23, 26, 25], [21, 26, 23], [21, 24, 26], [0, 2, 1], [24, 25, 26]], points = [[25.0100000000, 0.0000000000, -5.0000000000], [20.0100000000, 0.0000000000, 0.0000000000], [25.0100000000, 0.0000000000, 5.0000000000], [14.1421356237, 14.1421356237, -2.5000000000], [10.6066017178, 10.6066017178, 2.5000000000], [14.1421356237, 14.1421356237, 7.5000000000], [0.0000000000, 20.0000000000, 0.0000000000], [0.0000000000, 15.0000000000, 5.0000000000], [0.0000000000, 20.0000000000, 10.0000000000], [-14.1421356237, 14.1421356237, 2.5000000000], [-10.6066017178, 10.6066017178, 7.5000000000], [-14.1421356237, 14.1421356237, 12.5000000000], [-20.0000000000, 0.0000000000, 5.0000000000], [-15.0000000000, 0.0000000000, 10.0000000000], [-20.0000000000, 0.0000000000, 15.0000000000], [-14.1421356237, -14.1421356237, 7.5000000000], [-10.6066017178, -10.6066017178, 12.5000000000], [-14.1421356237, -14.1421356237, 17.5000000000], [-0.0000000000, -20.0000000000, 10.0000000000], [-0.0000000000, -15.0000000000, 15.0000000000], [-0.0000000000, -20.0000000000, 20.0000000000], [14.1421356237, -14.1421356237, 12.5000000000], [10.6066017178, -10.6066017178, 17.5000000000], [14.1421356237, -14.1421356237, 22.5000000000], [25.0100000000, -0.0000000000, 15.0000000000], [20.0100000000, -0.0000000000, 20.0000000000], [25.0100000000, -0.0000000000, 25.0000000000]]);\n\t\tcylinder($fn = 8, h = 20, r = 20);\n\t}\n}' 40 | self.assertEqual(expected, actual) 41 | 42 | def test_default_thread_section(self): 43 | expected = [[0, -5], [5, 0], [0, 5]] 44 | actual = default_thread_section(tooth_height=10, tooth_depth=5) 45 | self.assertEqual(expected, actual) 46 | 47 | 48 | if __name__ == '__main__': 49 | unittest.main() 50 | -------------------------------------------------------------------------------- /keyboard-layout-ergodox.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | { 4 | "x": 3.5 5 | }, 6 | "#\n3", 7 | { 8 | "x": 10.5 9 | }, 10 | "*\n8" 11 | ], 12 | [ 13 | { 14 | "y": -0.875, 15 | "x": 2.5 16 | }, 17 | "@\n2", 18 | { 19 | "x": 1 20 | }, 21 | "$\n4", 22 | { 23 | "x": 8.5 24 | }, 25 | "&\n7", 26 | { 27 | "x": 1 28 | }, 29 | "(\n9" 30 | ], 31 | [ 32 | { 33 | "y": -0.875, 34 | "x": 5.5 35 | }, 36 | "%\n5", 37 | { 38 | "a": 7 39 | }, 40 | "", 41 | { 42 | "x": 4.5 43 | }, 44 | "", 45 | { 46 | "a": 4 47 | }, 48 | "^\n6" 49 | ], 50 | [ 51 | { 52 | "y": -0.875, 53 | "a": 7, 54 | "w": 1.5 55 | }, 56 | "", 57 | { 58 | "a": 4 59 | }, 60 | "!\n1", 61 | { 62 | "x": 14.5 63 | }, 64 | ")\n0", 65 | { 66 | "a": 7, 67 | "w": 1.5 68 | }, 69 | "" 70 | ], 71 | [ 72 | { 73 | "y": -0.375, 74 | "x": 3.5, 75 | "a": 4 76 | }, 77 | "E", 78 | { 79 | "x": 10.5 80 | }, 81 | "I" 82 | ], 83 | [ 84 | { 85 | "y": -0.875, 86 | "x": 2.5 87 | }, 88 | "W", 89 | { 90 | "x": 1 91 | }, 92 | "R", 93 | { 94 | "x": 8.5 95 | }, 96 | "U", 97 | { 98 | "x": 1 99 | }, 100 | "O" 101 | ], 102 | [ 103 | { 104 | "y": -0.875, 105 | "x": 5.5 106 | }, 107 | "T", 108 | { 109 | "a": 7, 110 | "h": 1.5 111 | }, 112 | "", 113 | { 114 | "x": 4.5, 115 | "h": 1.5 116 | }, 117 | "", 118 | { 119 | "a": 4 120 | }, 121 | "Y" 122 | ], 123 | [ 124 | { 125 | "y": -0.875, 126 | "a": 7, 127 | "w": 1.5 128 | }, 129 | "", 130 | { 131 | "a": 4 132 | }, 133 | "Q", 134 | { 135 | "x": 14.5 136 | }, 137 | "P", 138 | { 139 | "a": 7, 140 | "w": 1.5 141 | }, 142 | "" 143 | ], 144 | [ 145 | { 146 | "y": -0.375, 147 | "x": 3.5, 148 | "a": 4 149 | }, 150 | "D", 151 | { 152 | "x": 10.5 153 | }, 154 | "K" 155 | ], 156 | [ 157 | { 158 | "y": -0.875, 159 | "x": 2.5 160 | }, 161 | "S", 162 | { 163 | "x": 1 164 | }, 165 | "F", 166 | { 167 | "x": 8.5 168 | }, 169 | "J", 170 | { 171 | "x": 1 172 | }, 173 | "L" 174 | ], 175 | [ 176 | { 177 | "y": -0.875, 178 | "x": 5.5 179 | }, 180 | "G", 181 | { 182 | "x": 6.5 183 | }, 184 | "H" 185 | ], 186 | [ 187 | { 188 | "y": -0.875, 189 | "a": 7, 190 | "w": 1.5 191 | }, 192 | "", 193 | { 194 | "a": 4 195 | }, 196 | "A", 197 | { 198 | "x": 14.5 199 | }, 200 | ":\n;", 201 | { 202 | "a": 7, 203 | "w": 1.5 204 | }, 205 | "" 206 | ], 207 | [ 208 | { 209 | "y": -0.625, 210 | "x": 6.5, 211 | "h": 1.5 212 | }, 213 | "", 214 | { 215 | "x": 4.5, 216 | "h": 1.5 217 | }, 218 | "" 219 | ], 220 | [ 221 | { 222 | "y": -0.75, 223 | "x": 3.5, 224 | "a": 4 225 | }, 226 | "C", 227 | { 228 | "x": 10.5 229 | }, 230 | "<\n," 231 | ], 232 | [ 233 | { 234 | "y": -0.875, 235 | "x": 2.5 236 | }, 237 | "X", 238 | { 239 | "x": 1 240 | }, 241 | "V", 242 | { 243 | "x": 8.5 244 | }, 245 | "M", 246 | { 247 | "x": 1 248 | }, 249 | ">\n." 250 | ], 251 | [ 252 | { 253 | "y": -0.875, 254 | "x": 5.5 255 | }, 256 | "B", 257 | { 258 | "x": 6.5 259 | }, 260 | "N" 261 | ], 262 | [ 263 | { 264 | "y": -0.875, 265 | "a": 7, 266 | "w": 1.5 267 | }, 268 | "", 269 | { 270 | "a": 4 271 | }, 272 | "Z", 273 | { 274 | "x": 14.5 275 | }, 276 | "?\n/", 277 | { 278 | "a": 7, 279 | "w": 1.5 280 | }, 281 | "" 282 | ], 283 | [ 284 | { 285 | "y": -0.375, 286 | "x": 3.5 287 | }, 288 | "", 289 | { 290 | "x": 10.5 291 | }, 292 | "" 293 | ], 294 | [ 295 | { 296 | "y": -0.875, 297 | "x": 2.5 298 | }, 299 | "", 300 | { 301 | "x": 1 302 | }, 303 | "", 304 | { 305 | "x": 8.5 306 | }, 307 | "", 308 | { 309 | "x": 1 310 | }, 311 | "" 312 | ], 313 | [ 314 | { 315 | "y": -0.75, 316 | "x": 0.5 317 | }, 318 | "", 319 | "", 320 | { 321 | "x": 14.5 322 | }, 323 | "", 324 | "" 325 | ], 326 | [ 327 | { 328 | "r": 30, 329 | "rx": 6.5, 330 | "ry": 4.25, 331 | "y": -1, 332 | "x": 1 333 | }, 334 | "", 335 | "" 336 | ], 337 | [ 338 | { 339 | "h": 2 340 | }, 341 | "", 342 | { 343 | "h": 2 344 | }, 345 | "", 346 | "" 347 | ], 348 | [ 349 | { 350 | "x": 2 351 | }, 352 | "" 353 | ], 354 | [ 355 | { 356 | "r": -30, 357 | "rx": 13, 358 | "y": -1, 359 | "x": -3 360 | }, 361 | "", 362 | "" 363 | ], 364 | [ 365 | { 366 | "x": -3 367 | }, 368 | "", 369 | { 370 | "h": 2 371 | }, 372 | "", 373 | { 374 | "h": 2 375 | }, 376 | "" 377 | ], 378 | [ 379 | { 380 | "x": -3 381 | }, 382 | "" 383 | ] 384 | ] 385 | -------------------------------------------------------------------------------- /solid/examples/mazebox/inset.py: -------------------------------------------------------------------------------- 1 | from math import * 2 | from trianglemath import * 3 | 4 | 5 | class Vec2D: 6 | 7 | def __init__(self, x, y): 8 | self.set(x, y) 9 | 10 | def set(self, x, y): 11 | self.x = x 12 | self.y = y 13 | 14 | def times(self, t): 15 | return Vec2D(self.x * t, self.y * t) 16 | 17 | def add(self, v): 18 | self.x += v.x 19 | self.y += v.y 20 | 21 | def plus(self, v): 22 | return Vec2D(self.x + v.x, self.y + v.y) 23 | 24 | def minus(self, v): 25 | return Vec2D(self.x - v.x, self.y - v.y) 26 | 27 | def len(self): 28 | return sqrt(self.x * self.x + self.y * self.y) 29 | 30 | def normalize(self): 31 | l = self.len() 32 | self.x /= l 33 | self.y /= l 34 | 35 | def asTripple(self, z): 36 | return [self.x, self.y, z] 37 | 38 | def scalarProduct(self, v): 39 | return self.x * v.x + self.y * v.y 40 | 41 | def interpolate(self, v, t): 42 | return Vec2D(self.x * t + v.x * (1.0 - t), self.y * t + v.y * (1.0 - t)) 43 | 44 | 45 | class MetaCADLine: 46 | 47 | def __init__(self, s, e): 48 | self.start = Vec2D(s.x, s.y) 49 | self.end = Vec2D(e.x, e.y) 50 | self.dir = self.end.minus(self.start) 51 | self.normal = Vec2D(self.dir.x, self.dir.y) 52 | self.normal.normalize() 53 | self.normal.set(-self.normal.y, self.normal.x) 54 | 55 | def parallelMove(self, d): 56 | move = self.normal.times(d) 57 | self.start.add(move) 58 | self.end.add(move) 59 | 60 | def intersect(self, l): 61 | solve = LinearSolve2(l.dir.x, -self.dir.x, l.dir.y, -self.dir.y, 62 | self.start.x - l.start.x, self.start.y - l.start.y) 63 | if (solve.error): 64 | return None 65 | else: 66 | point = self.start.plus(self.dir.times(solve.x2)) 67 | return Vec2D(point.x, point.y) 68 | 69 | 70 | # matrix looks like this 71 | # a b 72 | # c d 73 | def det(a, b, c, d): 74 | return a * d - b * c 75 | 76 | # solves system of 2 linear equations in 2 unknown 77 | 78 | 79 | class LinearSolve2: 80 | # the equations look like thsi looks like this 81 | # x1*a + x2*b = r1 82 | # x1*c + x2*d = r2 83 | 84 | def __init__(self, a, b, c, d, r1, r2): 85 | q = det(a, b, c, d) 86 | if (abs(q) < 0.000000001): 87 | self.error = True 88 | else: 89 | self.error = False 90 | self.x1 = det(r1, b, r2, d) / q 91 | self.x2 = det(a, r1, c, r2) / q 92 | 93 | 94 | def asVec2D(l): 95 | return Vec2D(l[0], l[1]) 96 | 97 | 98 | def insetPoly(poly, inset): 99 | points = [] 100 | inverted = [] 101 | for i in range(0, len(poly)): 102 | iprev = (i + len(poly) - 1) % len(poly) 103 | inext = (i + 1) % len(poly) 104 | 105 | prev = MetaCADLine(asVec2D(poly[iprev]), asVec2D(poly[i])) 106 | oldnorm = Vec2D(prev.normal.x, prev.normal.y) 107 | next = MetaCADLine(asVec2D(poly[i]), asVec2D(poly[inext])) 108 | 109 | prev.parallelMove(inset) 110 | next.parallelMove(inset) 111 | 112 | intersect = prev.intersect(next) 113 | if intersect == None: 114 | # take parallel moved poly[i] 115 | # from the line thats longer (in case we have a degenerate line 116 | # in there) 117 | if (prev.dir.length() < next.dir.length()): 118 | intersect = Vec2D(next.start.x, next.start.y) 119 | else: 120 | intersect = Vec2D(prev.end.x, prev.end.y) 121 | points.append(intersect.asTripple(poly[i][2])) 122 | if (len(points) >= 2): 123 | newLine = MetaCADLine(asVec2D(points[iprev]), asVec2D(points[i])) 124 | diff = newLine.normal.minus(oldnorm).len() 125 | if (diff > 0.1): 126 | pass 127 | #print("error inverting") 128 | # exit() 129 | else: 130 | pass 131 | # print("ok") 132 | istart = -1 133 | ilen = 0 134 | for i in range(0, len(poly)): 135 | iprev = (i + len(poly) - 1) % len(poly) 136 | inext = (i + 1) % len(poly) 137 | prev = MetaCADLine(asVec2D(poly[iprev]), asVec2D(poly[i])) 138 | oldnorm = Vec2D(prev.normal.x, prev.normal.y) 139 | newLine = MetaCADLine(asVec2D(points[iprev]), asVec2D(points[i])) 140 | diff = newLine.normal.minus(oldnorm).len() 141 | if (diff > 0.1): 142 | #print("wrong dir detected") 143 | if (istart == -1): 144 | istart = i 145 | ilen = 1 146 | else: 147 | ilen += 1 148 | else: 149 | if (ilen > 0): 150 | if (istart == 0): 151 | pass 152 | #print("oh noes") 153 | # exit() 154 | else: 155 | #print("trying to save: ", istart, i) 156 | idxs = (len(poly) + istart - 1) % len(poly) 157 | idxe = (i) % len(poly) 158 | p1 = points[idxs] 159 | p2 = points[idxe] 160 | #points[idxs] = p2 161 | #points[idxe] = p1 162 | for j in range(istart, i): 163 | t = float(1 + j - istart) / (1 + i - istart) 164 | # print(t) 165 | points[j] = [ 166 | p2[0] * t + p1[0] * (1 - t), p2[1] * t + p1[1] * (1 - t), p2[2] * t + p1[2] * (1 - t)] 167 | istart = -1 168 | ilen = 0 169 | 170 | iprev = (i + len(poly) - 1) % len(poly) 171 | inext = (i + 1) % len(poly) 172 | 173 | return points 174 | -------------------------------------------------------------------------------- /solid/examples/mazebox/mazebox_clean2_stable.py: -------------------------------------------------------------------------------- 1 | # A-Mazing Box, http://www.thingiverse.com/thing:1481 2 | # Copyright (C) 2009 Philipp Tiefenbacher 3 | # With very minor changes for SolidPython compatibility, 8 March 2011 4 | # 5 | 6 | # Make sure we can import the OpenScad translation module 7 | import sys 8 | import os 9 | 10 | from math import * 11 | from solid import * 12 | # Requires pypng module, which can be found with 'pip install pypng', 13 | # 'easy_install pypng', or at http://code.google.com/p/pypng/ 14 | from testpng import * 15 | from inset import * 16 | from trianglemath import * 17 | 18 | rn = 3 * 64 19 | #r = 10 20 | innerR = 25 21 | gap = 0.5 22 | wall = 1.50 23 | baseH = 2 24 | gripH = 9 25 | hn = 90 26 | s = 0.775 27 | 28 | 29 | h = hn * s 30 | hone = h / hn 31 | 32 | toph = (h - gripH) + 3 33 | 34 | depth = [] 35 | 36 | 37 | def flip(img): 38 | # for l in img: 39 | # l.reverse() 40 | img.reverse() 41 | return img 42 | 43 | 44 | for i in range(0, hn): 45 | depth.append([]) 46 | for j in range(0, rn): 47 | depth[i].append(0.0) 48 | 49 | 50 | depth = getPNG('playground/maze7.png') 51 | depth = flip(depth) 52 | 53 | 54 | def getPx(x, y, default): 55 | x = int(x) 56 | y = int(y) 57 | x = x % len(depth[0]) 58 | if (y >= len(depth)): 59 | y = len(depth) - 1 60 | if (x >= 0 and x < len(depth[0]) and y >= 0 and y < len(depth)): 61 | return depth[y][x] 62 | return default 63 | 64 | 65 | def myComp(x, y): 66 | d = Tripple2Vec3D(y).angle2D() - Tripple2Vec3D(x).angle2D() 67 | if (d < 0): 68 | return -1 69 | elif (d == 0): 70 | return 0 71 | else: 72 | return 1 73 | 74 | 75 | def bumpMapCylinder(theR, hn, inset, default): 76 | pts = [] 77 | trls = [] 78 | for i in xrange(0, hn): 79 | circ = [] 80 | for j in xrange(0, rn): 81 | a = j * 2 * pi / rn 82 | r = theR - ((255 - getPx(j, i, default)) / 150.0) 83 | p = [r * cos(a), r * sin(a), i * hone] 84 | circ.append(p) 85 | circ = insetPoly(circ, inset) 86 | #circ.sort(lambda x, y: -1 if (Tripple2Vec3D(y).angle2D() - Tripple2Vec3D(x).angle2D() < 0) else 1) 87 | aold = Tripple2Vec3D(circ[0]).angle2D() 88 | for c in circ: 89 | a = Tripple2Vec3D(c).angle2D() 90 | # print(a) 91 | if (a > aold and (abs(a - aold) < 1 * pi)): 92 | #print(a, aold) 93 | # exit() 94 | pass 95 | aold = a 96 | pts.append(c) 97 | 98 | pts.append([0, 0, 0]) 99 | pts.append([0, 0, i * hone]) 100 | 101 | for j in range(0, rn): 102 | t = [j, (j + 1) % rn, rn * hn] 103 | trls.append(t) 104 | t = [(rn * hn - 1) - j, (rn * hn - 1) - ((j + 1) % rn), rn * hn + 1] 105 | trls.append(t) 106 | for i in range(0, hn - 1): 107 | p1 = i * rn + ((j + 1) % rn) 108 | p2 = i * rn + j 109 | p3 = (i + 1) * rn + j 110 | p4 = (i + 1) * rn + ((j + 1) % rn) 111 | a1 = angleBetweenPlanes([pts[p1], pts[p2], pts[p3]], [pts[p4], pts[p1], pts[p3]]) 112 | a1 = min(a1, pi - a1) 113 | a2 = angleBetweenPlanes([pts[p2], pts[p1], pts[p4]], [pts[p2], pts[p3], pts[p4]]) 114 | a2 = min(a2, pi - a2) 115 | #print(a1, a2) 116 | if (a1 < a2): 117 | t = [p1, p2, p3] 118 | trls.append(t) 119 | t = [p4, p1, p3] 120 | trls.append(t) 121 | else: 122 | t = [p2, p4, p1] 123 | trls.append(t) 124 | t = [p2, p3, p4] 125 | trls.append(t) 126 | 127 | return polyhedron(pts, trls, 6) 128 | 129 | # to generate the top part 130 | part = 1 131 | 132 | # to generate the bottom part 133 | # part = 2 134 | 135 | if part == 1: 136 | d = difference() 137 | u = union() 138 | u.add(bumpMapCylinder(innerR, hn, 0, 255)) 139 | u.add(cylinder(r=innerR + wall + gap, h=gripH)) 140 | d.add(u) 141 | #u.add(translate([80,0,0]).add(bumpMapCylinder(innerR, wall))) 142 | d.add(intersection().add(bumpMapCylinder(innerR, hn + 2, wall, 0).set_modifier("") 143 | ).add(translate([0, 0, baseH]).add(cylinder(r=innerR + 2 * wall, h=h * 1.1).set_modifier("")))) 144 | # u.add() 145 | print("$fa=2; $fs=0.5;\n") 146 | print(d._render()) 147 | elif part == 2: 148 | top = difference() 149 | u = union() 150 | u2 = union() 151 | top.add(u) 152 | d = difference() 153 | d.add(cylinder(r=innerR + wall + gap, h=toph)) 154 | d.add(translate([0, 0, baseH]).add(cylinder(r=innerR + gap, h=toph))) 155 | u.add(d) 156 | top.add(u2) 157 | for i in range(0, 3): 158 | a = i * 2 * pi / 3.0 159 | r = innerR + gap + wall / 2 160 | u.add(translate([(r - 0.3) * cos(a), (r - 0.3) * sin(a), toph - 6]).add(sphere(r=2.4))) 161 | u2.add(translate([(r + wall - 0.3) * cos(a), (r + wall - 0.3) * sin(a), toph - 6]).add(sphere(r=2.4))) 162 | #top.add(cylinder(r = innerR+wall+gap, h=h)) 163 | print("$fa=2; $fs=0.5;\n") 164 | print(top._render()) 165 | else: 166 | top = difference() 167 | u = union() 168 | u2 = union() 169 | top.add(u) 170 | d = difference() 171 | d.add(cylinder(r=innerR + wall + gap, h=6)) 172 | d.add(translate([0, 0, -baseH]).add(cylinder(r=innerR + gap, h=h))) 173 | u.add(d) 174 | top.add(u2) 175 | for i in range(0, 3): 176 | a = i * 2 * pi / 3.0 177 | r = innerR + gap + wall / 2 178 | u.add(translate([r * cos(a), r * sin(a), 4]).add(sphere(r=2.3))) 179 | u2.add(translate([(r + wall) * cos(a), (r + wall) * sin(a), 4]).add(sphere(r=2.3))) 180 | #top.add(cylinder(r = innerR+wall+gap, h=h)) 181 | print("//$fn=20;\n") 182 | print(top._render()) 183 | -------------------------------------------------------------------------------- /solid/t_slots.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import division 4 | import os 5 | import sys 6 | import re 7 | 8 | # Assumes SolidPython is in site-packages or elsewhwere in sys.path 9 | from solid import * 10 | from solid.utils import * 11 | 12 | SEGMENTS = 24 13 | 14 | # FIXME: ought to be 5 15 | DFM = 5 # Default Material thickness 16 | 17 | tab_width = 5 18 | tab_offset = 4 19 | tab_curve_rad = .35 20 | 21 | # TODO: Slots & tabs make it kind of difficult to align pieces, since we 22 | # always need the slot piece to overlap the tab piece by a certain amount. 23 | # It might be easier to have the edges NOT overlap at all and then have tabs 24 | # for the slots added programmatically. -ETJ 06 Mar 2013 25 | 26 | 27 | def t_slot_holes(poly, point=None, edge_vec=RIGHT_VEC, screw_vec=DOWN_VEC, screw_type='m3', screw_length=16, material_thickness=DFM, kerf=0): 28 | ''' 29 | Cuts a screw hole and two notches in poly so they'll 30 | interface with the features cut by t_slot() 31 | 32 | Returns a copy of poly with holes removed 33 | 34 | -- material_thickness is the thickness of the material *that will 35 | be attached** to the t-slot, NOT necessarily the material that poly 36 | will be cut on. 37 | 38 | -- screw_vec is the direction the screw through poly will face; normal to poly 39 | -- edge_vec orients the holes to the edge they run parallel to 40 | 41 | TODO: add kerf calculations 42 | ''' 43 | point = point if point else ORIGIN 44 | point = euclidify(point, Point3) 45 | screw_vec = euclidify(screw_vec, Vector3) 46 | edge_vec = euclidify(edge_vec, Vector3) 47 | 48 | src_up = screw_vec.cross(edge_vec) 49 | 50 | a_hole = square([tab_width, material_thickness], center=True) 51 | move_hole = tab_offset + tab_width / 2 52 | tab_holes = left(move_hole)(a_hole) + right(move_hole)(a_hole) 53 | 54 | # Only valid for m3-m5 screws now 55 | screw_dict = screw_dimensions.get(screw_type.lower()) 56 | if screw_dict: 57 | screw_w = screw_dict['screw_outer_diam'] 58 | else: 59 | raise ValueError( 60 | "Don't have screw dimensions for requested screw size %s" % screw_type) 61 | 62 | # add the screw hole 63 | tab_holes += circle(screw_w / 2) # NOTE: needs any extra space? 64 | 65 | tab_holes = transform_to_point( 66 | tab_holes, point, dest_normal=screw_vec, src_normal=UP_VEC, src_up=src_up) 67 | 68 | return poly - tab_holes 69 | 70 | 71 | def t_slot(poly, point=None, screw_vec=DOWN_VEC, face_normal=UP_VEC, screw_type='m3', screw_length=16, material_thickness=DFM, kerf=0): 72 | ''' 73 | Cuts a t-shaped shot in poly and adds two tabs 74 | on the outside edge of poly. 75 | 76 | Needs to be combined with t_slot_holes() on another 77 | poly to make a valid t-slot connection 78 | 79 | -- material_thickness is the thickness of the material *that will 80 | be attached** to the t-slot, NOT necessarily the material that poly 81 | will be cut on. 82 | 83 | -- This method will align the t-slots where you tell them to go, 84 | using point, screw_vec (the direction the screw will be inserted), and 85 | face_normal, a vector normal to the face being altered. To avoid confusion, 86 | it's often easiest to work on the XY plane. 87 | 88 | 89 | TODO: include kerf in calculations 90 | ''' 91 | point = point if point else ORIGIN 92 | point = euclidify(point, Point3) 93 | screw_vec = euclidify(screw_vec, Vector3) 94 | face_normal = euclidify(face_normal, Vector3) 95 | 96 | tab = tab_poly(material_thickness=material_thickness) 97 | slot = nut_trap_slot( 98 | screw_type, screw_length, material_thickness=material_thickness) 99 | 100 | # NOTE: dest_normal & src_normal are the same. This should matter, right? 101 | tab = transform_to_point( 102 | tab, point, dest_normal=face_normal, src_normal=face_normal, src_up=-screw_vec) 103 | slot = transform_to_point( 104 | slot, point, dest_normal=face_normal, src_normal=face_normal, src_up=-screw_vec) 105 | 106 | return poly + tab - slot 107 | 108 | 109 | def tab_poly(material_thickness=DFM): 110 | 111 | r = [[tab_width + tab_offset, -EPSILON], 112 | [tab_offset, -EPSILON], 113 | [tab_offset, material_thickness], 114 | [tab_width + tab_offset, material_thickness], ] 115 | 116 | l = [[-rp[0], rp[1]] for rp in r] 117 | tab_pts = l + r 118 | 119 | tab_faces = [[0, 1, 2, 3], [4, 5, 6, 7]] 120 | tab = polygon(tab_pts, tab_faces) 121 | 122 | # Round off the top points so tabs slide in more easily 123 | round_tabs = False 124 | if round_tabs: 125 | points_to_round = [[r[1], r[2], r[3]], 126 | [r[2], r[3], r[0]], 127 | [l[1], l[2], l[3]], 128 | [l[2], l[3], l[0]], 129 | ] 130 | tab = fillet_2d(three_point_sets=points_to_round, orig_poly=tab, 131 | fillet_rad=1, remove_material=True) 132 | 133 | return tab 134 | 135 | 136 | def nut_trap_slot(screw_type='m3', screw_length=16, material_thickness=DFM): 137 | # This shape has a couple uses. 138 | # 1) Right angle joint between two pieces of material. 139 | # A bolt goes through the second piece and into the first. 140 | 141 | # 2) Set-screw for attaching to motor spindles. 142 | # Bolt goes full length into a sheet of material. Set material_thickness 143 | # to something small (1-2 mm) to make sure there's adequate room to 144 | # tighten onto the shaft 145 | 146 | # Only valid for m3-m5 screws now 147 | screw_dict = screw_dimensions.get(screw_type.lower()) 148 | if screw_dict: 149 | screw_w = screw_dict['screw_outer_diam'] 150 | screw_w2 = screw_w / 2 151 | # NOTE: How are these tolerances? 152 | nut_hole_x = (screw_dict['nut_inner_diam'] + 0.2) / 2 153 | nut_hole_h = screw_dict['nut_thickness'] + 0.5 154 | slot_depth = material_thickness - screw_length - 0.5 155 | # If a nut isn't far enough into the material, the sections 156 | # that hold the nut in may break off. Make sure it's at least 157 | # half a centimeter. More would be better, actually 158 | nut_loc = -5 159 | else: 160 | raise ValueError( 161 | "Don't have screw dimensions for requested screw size %s" % screw_type) 162 | 163 | slot_pts = [[screw_w2, EPSILON], 164 | [screw_w2, nut_loc], 165 | [nut_hole_x, nut_loc], 166 | [nut_hole_x, nut_loc - nut_hole_h], 167 | [screw_w2, nut_loc - nut_hole_h], 168 | [screw_w2, slot_depth], 169 | ] 170 | # mirror the slot points on the left 171 | slot_pts += [[-x, y] for x, y in slot_pts][-1::-1] 172 | 173 | # TODO: round off top corners of slot 174 | 175 | # Add circles around t edges to prevent acrylic breakage 176 | slot = polygon(slot_pts) 177 | slot = union()( 178 | slot, 179 | translate([nut_hole_x, nut_loc])(circle(tab_curve_rad)), 180 | translate([-nut_hole_x, nut_loc])(circle(tab_curve_rad)) 181 | ) 182 | return render()(slot) 183 | 184 | 185 | def assembly(): 186 | a = union() 187 | 188 | return a 189 | 190 | if __name__ == '__main__': 191 | a = assembly() 192 | scad_render_to_file(a, file_header='$fn = %s;' % 193 | SEGMENTS, include_orig_code=True) 194 | -------------------------------------------------------------------------------- /solid/screw_thread.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | import re 6 | 7 | from solid import * 8 | from solid.utils import * 9 | from euclid3 import * 10 | # NOTE: The PyEuclid on PyPi doesn't include several elements added to 11 | # the module as of 13 Feb 2013. Add them here until euclid supports them 12 | # TODO: when euclid updates, remove this cruft. -ETJ 13 Feb 2013 13 | import solid.patch_euclid 14 | solid.patch_euclid.run_patch() 15 | 16 | 17 | def thread(outline_pts, inner_rad, pitch, length, external=True, segments_per_rot=32, neck_in_degrees=0, neck_out_degrees=0): 18 | '''Sweeps outline_pts (an array of points describing a closed polygon in XY) 19 | through a spiral. 20 | 21 | :param outline_pts: a list of points (NOT an OpenSCAD polygon) that define the cross section of the thread 22 | :type outline_pts: list 23 | 24 | :param inner_rad: radius of cylinder the screw will wrap around 25 | :type inner_rad: number 26 | 27 | :param pitch: height for one revolution; must be <= the height of outline_pts bounding box to avoid self-intersection 28 | :type pitch: number 29 | 30 | :param length: distance from bottom-most point of screw to topmost 31 | :type length: number 32 | 33 | :param external: if True, the cross-section is external to a cylinder. If False,the segment is internal to it, and outline_pts will be mirrored right-to-left 34 | :type external: bool 35 | 36 | :param segments_per_rot: segments per rotation 37 | :type segments_per_rot: int 38 | 39 | :param neck_in_degrees: degrees through which the outer edge of the screw thread will move from a thickness of zero (inner_rad) to its full thickness 40 | :type neck_in_degrees: number 41 | 42 | :param neck_out_degrees: degrees through which outer edge of the screw thread will move from full thickness back to zero 43 | :type neck_out_degrees: number 44 | 45 | NOTE: This functions works by creating and returning one huge polyhedron, with 46 | potentially thousands of faces. An alternate approach would make one single 47 | polyhedron,then repeat it over and over in the spiral shape, unioning them 48 | all together. This would create a similar number of SCAD objects and 49 | operations, but still require a lot of transforms and unions to be done 50 | in the SCAD code rather than in the python, as here. Also would take some 51 | doing to make the neck-in work as well. Not sure how the two approaches 52 | compare in terms of render-time. -ETJ 16 Mar 2011 53 | 54 | NOTE: if pitch is less than the or equal to the height of each tooth (outline_pts), 55 | OpenSCAD will likely crash, since the resulting screw would self-intersect 56 | all over the place. For screws with essentially no space between 57 | threads, (i.e., pitch=tooth_height), I use pitch= tooth_height+EPSILON, 58 | since pitch=tooth_height will self-intersect for rotations >=1 59 | ''' 60 | a = union() 61 | rotations = float(length) / pitch 62 | 63 | total_angle = 360.0 * rotations 64 | up_step = float(length) / (rotations * segments_per_rot) 65 | # Add one to total_steps so we have total_steps *segments* 66 | total_steps = int(ceil(rotations * segments_per_rot)) + 1 67 | step_angle = total_angle / (total_steps - 1) 68 | 69 | all_points = [] 70 | all_tris = [] 71 | euc_up = Vector3(*UP_VEC) 72 | poly_sides = len(outline_pts) 73 | 74 | # Figure out how wide the tooth profile is 75 | min_bb, max_bb = bounding_box(outline_pts) 76 | outline_w = max_bb[0] - min_bb[0] 77 | outline_h = max_bb[1] - min_bb[1] 78 | 79 | min_rad = max(0, inner_rad - outline_w - EPSILON) 80 | max_rad = inner_rad + outline_w + EPSILON 81 | 82 | # outline_pts, since they were created in 2D , are in the XY plane. 83 | # But spirals move a profile in XZ around the Z-axis. So swap Y and Z 84 | # co-ords... and hope users know about this 85 | # Also add inner_rad to the profile 86 | euc_points = [] 87 | for p in outline_pts: 88 | # If p is in [x, y] format, make it [x, y, 0] 89 | if len(p) == 2: 90 | p.append(0) 91 | # [x, y, z] => [ x+inner_rad, z, y] 92 | external_mult = 1 if external else -1 93 | # adding inner_rad, swapping Y & Z 94 | s = Point3(external_mult * p[0], p[2], p[1]) 95 | euc_points.append(s) 96 | 97 | for i in range(total_steps): 98 | angle = i * step_angle 99 | 100 | elevation = i * up_step 101 | if angle > total_angle: 102 | angle = total_angle 103 | elevation = length 104 | 105 | # Handle the neck-in radius for internal and external threads 106 | rad = inner_rad 107 | int_ext_mult = 1 if external else -1 108 | neck_in_rad = min_rad if external else max_rad 109 | 110 | if angle < neck_in_degrees: 111 | rad = neck_in_rad + int_ext_mult * angle / neck_in_degrees * outline_w 112 | elif angle > total_angle - neck_in_degrees: 113 | rad = neck_in_rad + int_ext_mult * (total_angle - angle) / neck_out_degrees * outline_w 114 | 115 | elev_vec = Vector3(rad, 0, elevation) 116 | 117 | # create new points 118 | for p in euc_points: 119 | pt = (p + elev_vec).rotate_around(axis=euc_up, theta=radians(angle)) 120 | all_points.append(pt.as_arr()) 121 | 122 | # Add the connectivity information 123 | if i < total_steps - 1: 124 | ind = i * poly_sides 125 | for j in range(ind, ind + poly_sides - 1): 126 | all_tris.append([j, j + 1, j + poly_sides]) 127 | all_tris.append([j + 1, j + poly_sides + 1, j + poly_sides]) 128 | all_tris.append([ind, ind + poly_sides - 1 + poly_sides, ind + poly_sides - 1]) 129 | all_tris.append([ind, ind + poly_sides, ind + poly_sides - 1 + poly_sides]) 130 | 131 | # End triangle fans for beginning and end 132 | last_loop = len(all_points) - poly_sides 133 | for i in range(poly_sides - 2): 134 | all_tris.append([0, i + 2, i + 1]) 135 | all_tris.append([last_loop, last_loop + i + 1, last_loop + i + 2]) 136 | 137 | # Make the polyhedron 138 | a = polyhedron(points=all_points, faces=all_tris) 139 | 140 | if external: 141 | # Intersect with a cylindrical tube to make sure we fit into 142 | # the correct dimensions 143 | tube = cylinder(r=inner_rad + outline_w + EPSILON, h=length, segments=segments_per_rot) 144 | tube -= cylinder(r=inner_rad, h=length, segments=segments_per_rot) 145 | else: 146 | # If the threading is internal, intersect with a central cylinder 147 | # to make sure nothing else remains 148 | tube = cylinder(r=inner_rad, h=length, segments=segments_per_rot) 149 | a *= tube 150 | return render()(a) 151 | 152 | 153 | def default_thread_section(tooth_height, tooth_depth): 154 | # An isoceles triangle, tooth_height vertically, tooth_depth wide: 155 | res = [[0, -tooth_height / 2], 156 | [tooth_depth, 0], 157 | [0, tooth_height / 2] 158 | ] 159 | return res 160 | 161 | 162 | def assembly(): 163 | # Scad code here 164 | a = union() 165 | 166 | rad = 5 167 | pts = [[0, -1, 0], 168 | [1, 0, 0], 169 | [0, 1, 0], 170 | [-1, 0, 0], 171 | [-1, -1, 0]] 172 | 173 | a = thread(pts, inner_rad=10, pitch=6, length=2, segments_per_rot=31, 174 | neck_in_degrees=30, neck_out_degrees=30) 175 | 176 | return a + cylinder(10 + EPSILON, 2) 177 | 178 | if __name__ == '__main__': 179 | a = assembly() 180 | scad_render_to_file(a) 181 | -------------------------------------------------------------------------------- /solid/examples/koch.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | import re 6 | 7 | from solid import * 8 | from solid.utils import * 9 | 10 | from euclid3 import * 11 | 12 | ONE_THIRD = 1 / 3.0 13 | 14 | 15 | def affine_combination(a, b, weight=0.5): 16 | ''' 17 | Note that weight is a fraction of the distance between self and other. 18 | So... 0.33 is a point .33 of the way between self and other. 19 | ''' 20 | if hasattr(a, 'z'): 21 | return Point3((1 - weight) * a.x + weight * b.x, 22 | (1 - weight) * a.y + weight * b.y, 23 | (1 - weight) * a.z + weight * b.z, 24 | ) 25 | else: 26 | return Point2((1 - weight) * a.x + weight * b.x, 27 | (1 - weight) * a.y + weight * b.y, 28 | ) 29 | 30 | 31 | def kochify_3d(a, b, c, 32 | ab_weight=0.5, bc_weight=0.5, ca_weight=0.5, 33 | pyr_a_weight=ONE_THIRD, pyr_b_weight=ONE_THIRD, pyr_c_weight=ONE_THIRD, 34 | pyr_height_weight=ONE_THIRD 35 | ): 36 | ''' 37 | Point3s a, b, and c must be coplanar and define a face 38 | ab_weight, etc define the subdivision of the original face 39 | pyr_a_weight, etc define where the point of the new pyramid face will go 40 | pyr_height determines how far from the face the new pyramid's point will be 41 | ''' 42 | triangles = [] 43 | new_a = affine_combination(a, b, ab_weight) 44 | new_b = affine_combination(b, c, bc_weight) 45 | new_c = affine_combination(c, a, ca_weight) 46 | 47 | triangles.extend([[a, new_a, new_c], [b, new_b, new_a], [c, new_c, new_b]]) 48 | 49 | avg_pt_x = a.x * pyr_a_weight + b.x * pyr_b_weight + c.x * pyr_c_weight 50 | avg_pt_y = a.y * pyr_a_weight + b.y * pyr_b_weight + c.y * pyr_c_weight 51 | avg_pt_z = a.z * pyr_a_weight + b.z * pyr_b_weight + c.z * pyr_c_weight 52 | 53 | center_pt = Point3(avg_pt_x, avg_pt_y, avg_pt_z) 54 | 55 | # The top of the pyramid will be on a normal 56 | ab_vec = b - a 57 | bc_vec = c - b 58 | ca_vec = a - c 59 | normal = ab_vec.cross(bc_vec).normalized() 60 | avg_side_length = (abs(ab_vec) + abs(bc_vec) + abs(ca_vec)) / 3 61 | pyr_h = avg_side_length * pyr_height_weight 62 | pyr_pt = LineSegment3(center_pt, normal, pyr_h).p2 63 | 64 | triangles.extend([[new_a, pyr_pt, new_c], [new_b, pyr_pt, new_a], [new_c, pyr_pt, new_b]]) 65 | 66 | return triangles 67 | 68 | 69 | def kochify(seg, height_ratio=0.33, left_loc=0.33, midpoint_loc=0.5, right_loc=0.66): 70 | a, b = seg.p1, seg.p2 71 | l = affine_combination(a, b, left_loc) 72 | c = affine_combination(a, b, midpoint_loc) 73 | r = affine_combination(a, b, right_loc) 74 | # The point of the new triangle will be height_ratio * abs(seg) long, 75 | # and run perpendicular to seg, through c. 76 | perp = seg.v.cross().normalized() 77 | 78 | c_height = height_ratio * abs(seg) 79 | perp_pt = LineSegment2(c, perp, -c_height).p2 80 | 81 | # For the moment, assume perp_pt is on the right side of seg. 82 | # Will confirm this later if needed 83 | return [LineSegment2(a, l), 84 | LineSegment2(l, perp_pt), 85 | LineSegment2(perp_pt, r), 86 | LineSegment2(r, b)] 87 | 88 | 89 | def main_3d(out_dir): 90 | gens = 4 91 | 92 | # Parameters 93 | ab_weight = 0.5 94 | bc_weight = 0.5 95 | ca_weight = 0.5 96 | pyr_a_weight = ONE_THIRD 97 | pyr_b_weight = ONE_THIRD 98 | pyr_c_weight = ONE_THIRD 99 | pyr_height_weight = ONE_THIRD 100 | pyr_height_weight = ONE_THIRD 101 | # pyr_height_weight = .25 102 | 103 | all_polys = union() 104 | 105 | # setup 106 | ax, ay, az = 100, -100, 100 107 | bx, by, bz = 100, 100, -100 108 | cx, cy, cz = -100, 100, 100 109 | dx, dy, dz = -100, -100, -100 110 | generations = [[[Point3(ax, ay, az), Point3(bx, by, bz), Point3(cx, cy, cz)], 111 | [Point3(bx, by, bz), Point3(ax, ay, az), Point3(dx, dy, dz)], 112 | [Point3(ax, ay, az), Point3(cx, cy, cz), Point3(dx, dy, dz)], 113 | [Point3(cx, cy, cz), Point3(bx, by, bz), Point3(dx, dy, dz)], 114 | ] 115 | ] 116 | 117 | # Recursively generate snowflake segments 118 | for g in range(1, gens): 119 | generations.append([]) 120 | for a, b, c in generations[g - 1]: 121 | new_tris = kochify_3d(a, b, c, 122 | ab_weight, bc_weight, ca_weight, 123 | pyr_a_weight, pyr_b_weight, pyr_c_weight, 124 | pyr_height_weight) 125 | # new_tris = kochify_3d( a, b, c) 126 | generations[g].extend(new_tris) 127 | 128 | # Put all generations into SCAD 129 | orig_length = abs(generations[0][0][1] - generations[0][0][0]) 130 | for g, a_gen in enumerate(generations): 131 | # Move each generation up in y so it doesn't overlap the others 132 | h = orig_length * 1.5 * g 133 | 134 | # Build the points and triangles arrays that SCAD needs 135 | faces = [] 136 | points = [] 137 | for a, b, c in a_gen: 138 | points.extend([[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z]]) 139 | t = len(points) 140 | faces.append([t - 3, t - 2, t - 1]) 141 | 142 | # Do the SCAD 143 | edges = [list(range(len(points)))] 144 | all_polys.add(up(h)( 145 | polyhedron(points=points, faces=faces) 146 | ) 147 | ) 148 | 149 | file_out = os.path.join(out_dir, 'koch_3d.scad') 150 | cur_file = __file__ 151 | print("%(cur_file)s: SCAD file written to: %(file_out)s" % vars()) 152 | scad_render_to_file(all_polys, file_out, include_orig_code=True) 153 | 154 | 155 | def main(out_dir): 156 | # Parameters 157 | midpoint_weight = 0.5 158 | height_ratio = 0.25 159 | left_loc = ONE_THIRD 160 | midpoint_loc = 0.5 161 | right_loc = 2 * ONE_THIRD 162 | gens = 5 163 | 164 | # Results 165 | all_polys = union() 166 | 167 | # setup 168 | ax, ay = 0, 0 169 | bx, by = 100, 0 170 | cx, cy = 50, 86.6 171 | base_seg1 = LineSegment2(Point2(ax, ay), Point2(cx, cy)) 172 | base_seg2 = LineSegment2(Point2(cx, cy), Point2(bx, by)) 173 | base_seg3 = LineSegment2(Point2(bx, by), Point2(ax, ay)) 174 | generations = [[base_seg1, base_seg2, base_seg3]] 175 | 176 | # Recursively generate snowflake segments 177 | for g in range(1, gens): 178 | generations.append([]) 179 | for seg in generations[g - 1]: 180 | generations[g].extend(kochify(seg, height_ratio, left_loc, midpoint_loc, right_loc)) 181 | # generations[g].extend(kochify(seg)) 182 | 183 | # # Put all generations into SCAD 184 | orig_length = abs(generations[0][0]) 185 | for g, a_gen in enumerate(generations): 186 | points = [s.p1 for s in a_gen] 187 | # points.append(a_gen[-1].p2) # add the last point 188 | 189 | rect_offset = 10 190 | 191 | # Just use arrays for points so SCAD understands 192 | points = [[p.x, p.y] for p in points] 193 | 194 | # Move each generation up in y so it doesn't overlap the others 195 | h = orig_length * 1.5 * g 196 | 197 | # Do the SCAD 198 | edges = [list(range(len(points)))] 199 | all_polys.add(forward(h)(polygon(points=points, paths=edges))) 200 | 201 | file_out = os.path.join(out_dir, 'koch.scad') 202 | cur_file = __file__ 203 | print("%(cur_file)s: SCAD file written to: %(file_out)s " % vars()) 204 | scad_render_to_file(all_polys, file_out, include_orig_code=True) 205 | 206 | if __name__ == '__main__': 207 | out_dir = sys.argv[1] if len(sys.argv) > 1 else os.curdir 208 | main_3d(out_dir) 209 | main(out_dir) 210 | -------------------------------------------------------------------------------- /solid/test/test_utils.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | import os 4 | import sys 5 | import re 6 | 7 | import unittest 8 | 9 | from solid import * 10 | from solid.utils import * 11 | from euclid3 import * 12 | import difflib 13 | from solid.test.ExpandedTestCase import DiffOutput 14 | 15 | 16 | tri = [Point3(0, 0, 0), Point3(10, 0, 0), Point3(0, 10, 0)] 17 | scad_test_cases = [ 18 | ( up, [2], '\n\ntranslate(v = [0, 0, 2]);'), 19 | ( down, [2], '\n\ntranslate(v = [0, 0, -2]);'), 20 | ( left, [2], '\n\ntranslate(v = [-2, 0, 0]);'), 21 | ( right, [2], '\n\ntranslate(v = [2, 0, 0]);'), 22 | ( forward, [2], '\n\ntranslate(v = [0, 2, 0]);'), 23 | ( back, [2], '\n\ntranslate(v = [0, -2, 0]);'), 24 | ( 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}'), 25 | ( arc_inverted, [10, 0, 90, 24], '\n\ndifference() {\n\tintersection() {\n\t\trotate(a = 0) {\n\t\t\ttranslate(v = [-990, 0]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t\trotate(a = 90) {\n\t\t\ttranslate(v = [-990, -1000]) {\n\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t}\n\t\t}\n\t}\n\tcircle($fn = 24, r = 10);\n}'), 26 | ( '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}'), 27 | ( '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]]);'), 28 | ( '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]]);'), 29 | 30 | ] 31 | 32 | other_test_cases = [ 33 | ( euclidify, [[0,0,0]], 'Vector3(0.00, 0.00, 0.00)'), 34 | ( 'euclidify_recursive', euclidify, [[[0,0,0], [1,0,0]]], '[Vector3(0.00, 0.00, 0.00), Vector3(1.00, 0.00, 0.00)]'), 35 | ( 'euclidify_Vector', euclidify, [Vector3(0,0,0)], 'Vector3(0.00, 0.00, 0.00)'), 36 | ( '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)]'), 37 | ( euc_to_arr, [Vector3(0,0,0)], '[0, 0, 0]'), 38 | ( 'euc_to_arr_recursive', euc_to_arr, [[Vector3(0,0,0), Vector3(0,0,1)]], '[[0, 0, 0], [0, 0, 1]]'), 39 | ( 'euc_to_arr_arr', euc_to_arr, [[0,0,0]], '[0, 0, 0]'), 40 | ( 'euc_to_arr_arr_recursive', euc_to_arr, [[[0,0,0], [1,0,0]]], '[[0, 0, 0], [1, 0, 0]]'), 41 | ( is_scad, [cube(2)], 'True'), 42 | ( 'is_scad_false', is_scad, [2], 'False'), 43 | ( 'transform_to_point_single_arr', transform_to_point, [[1,0,0], [2,2,2], [3,3,1]], 'Point3(2.71, 1.29, 2.00)'), 44 | ( '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)'), 45 | ( '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)]'), 46 | ( '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)]') , 47 | ( '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)]'), 48 | ( '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)]'), 49 | ( '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)]'), 50 | ( '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)]'), 51 | ] 52 | 53 | 54 | class TestSPUtils(DiffOutput): 55 | # Test cases will be dynamically added to this instance 56 | # using the test case arrays above 57 | 58 | def test_split_body_planar(self): 59 | offset = [10, 10, 10] 60 | body = translate(offset)(sphere(20)) 61 | body_bb = BoundingBox([40, 40, 40], offset) 62 | actual = [] 63 | for split_dir in [RIGHT_VEC, FORWARD_VEC, UP_VEC]: 64 | actual_tuple = split_body_planar(body, body_bb, cutting_plane_normal=split_dir, cut_proportion=0.25) 65 | actual.append(actual_tuple) 66 | 67 | # Ignore the bounding box object that come back, taking only the SCAD 68 | # objects 69 | actual = [scad_render(a) for splits in actual for a in splits[::2]] 70 | 71 | 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}', 72 | '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [15.0000000000, 10, 10]) {\n\t\tcube(center = true, size = [30.0000000000, 40, 40]);\n\t}\n}', 73 | '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, -5.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 10.0000000000, 40]);\n\t}\n}', 74 | '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 15.0000000000, 10]) {\n\t\tcube(center = true, size = [40, 30.0000000000, 40]);\n\t}\n}', 75 | '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, -5.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 10.0000000000]);\n\t}\n}', 76 | '\n\nintersection() {\n\ttranslate(v = [10, 10, 10]) {\n\t\tsphere(r = 20);\n\t}\n\ttranslate(v = [10, 10, 15.0000000000]) {\n\t\tcube(center = true, size = [40, 40, 30.0000000000]);\n\t}\n}' 77 | ] 78 | self.assertEqual(actual, expected) 79 | 80 | def test_fillet_2d_add(self): 81 | pts = [[0, 5], [5, 5], [5, 0], [10, 0], [10, 10], [0, 10], ] 82 | p = polygon(pts) 83 | newp = fillet_2d(euclidify(pts[0:3], Point3), orig_poly=p, fillet_rad=2, remove_material=False) 84 | expected = '\n\nunion() {\n\tpolygon(paths = [[0, 1, 2, 3, 4, 5]], points = [[0, 5], [5, 5], [5, 0], [10, 0], [10, 10], [0, 10]]);\n\ttranslate(v = [3.0000000000, 3.0000000000, 0.0000000000]) {\n\t\tdifference() {\n\t\t\tintersection() {\n\t\t\t\trotate(a = 358.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, 0]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trotate(a = 452.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, -1000]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcircle(r = 2);\n\t\t}\n\t}\n}' 85 | actual = scad_render(newp) 86 | self.assertEqual(expected, actual) 87 | 88 | def test_fillet_2d_remove(self): 89 | pts = tri 90 | poly = polygon(euc_to_arr(tri)) 91 | 92 | newp = fillet_2d(tri, orig_poly=poly, fillet_rad=2, remove_material=True) 93 | expected = '\n\ndifference() {\n\tpolygon(paths = [[0, 1, 2]], points = [[0, 0, 0], [10, 0, 0], [0, 10, 0]]);\n\ttranslate(v = [5.1715728753, 2.0000000000, 0.0000000000]) {\n\t\tdifference() {\n\t\t\tintersection() {\n\t\t\t\trotate(a = 268.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, 0]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trotate(a = 407.0000000000) {\n\t\t\t\t\ttranslate(v = [-998, -1000]) {\n\t\t\t\t\t\tsquare(center = false, size = [1000, 1000]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcircle(r = 2);\n\t\t}\n\t}\n}' 94 | actual = scad_render(newp) 95 | if expected != actual: 96 | print(''.join(difflib.unified_diff(expected, actual))) 97 | self.assertEqual(expected, actual) 98 | 99 | 100 | def test_generator_scad(func, args, expected): 101 | def test_scad(self): 102 | scad_obj = func(*args) 103 | actual = scad_render(scad_obj) 104 | self.assertEqual(expected, actual) 105 | 106 | return test_scad 107 | 108 | 109 | def test_generator_no_scad(func, args, expected): 110 | def test_no_scad(self): 111 | actual = str(func(*args)) 112 | self.assertEqual(expected, actual) 113 | 114 | return test_no_scad 115 | 116 | 117 | def read_test_tuple(test_tuple): 118 | if len(test_tuple) == 3: 119 | # If test name not supplied, create it programmatically 120 | func, args, expected = test_tuple 121 | test_name = 'test_%s' % func.__name__ 122 | elif len(test_tuple) == 4: 123 | test_name, func, args, expected = test_tuple 124 | test_name = 'test_%s' % test_name 125 | else: 126 | print("test_tuple has %d args :%s" % (len(test_tuple), test_tuple)) 127 | return test_name, func, args, expected 128 | 129 | 130 | def create_tests(): 131 | for test_tuple in scad_test_cases: 132 | test_name, func, args, expected = read_test_tuple(test_tuple) 133 | test = test_generator_scad(func, args, expected) 134 | setattr(TestSPUtils, test_name, test) 135 | 136 | for test_tuple in other_test_cases: 137 | test_name, func, args, expected = read_test_tuple(test_tuple) 138 | test = test_generator_no_scad(func, args, expected) 139 | setattr(TestSPUtils, test_name, test) 140 | 141 | if __name__ == '__main__': 142 | create_tests() 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /keyboard-layout-prog.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Programmer's Keyboard 1.99", 4 | "author": "Ian Douglas", 5 | "background": { 6 | "name": "Mahogany Red", 7 | "style": "background-image: url('/bg/wood/Red_Mahogany_Wood.jpg');" 8 | }, 9 | "radii": "30px 30px 50% 50%", 10 | "switchMount": "cherry", 11 | "switchBrand": "cherry", 12 | "switchType": "MX1A-G1xx", 13 | "plate": true, 14 | "pcb": false 15 | }, 16 | [ 17 | { 18 | "x": 23, 19 | "a": 7, 20 | "d": true 21 | }, 22 | "" 23 | ], 24 | [ 25 | { 26 | "y": -0.62, 27 | "x": 1.25, 28 | "c": "#857eb1", 29 | "a": 4, 30 | "f": 6 31 | }, 32 | "\n", 33 | { 34 | "c": "#b81b24", 35 | "a": 5, 36 | "f": 5, 37 | "f2": 3 38 | }, 39 | "\n ", 40 | "\n", 41 | { 42 | "x": 0.75, 43 | "c": "#d9dae0", 44 | "a": 4, 45 | "f": 3 46 | }, 47 | "\nF1 ", 48 | "\nF2", 49 | "\nF3", 50 | "\nF4", 51 | { 52 | "x": 1 53 | }, 54 | "\nF5", 55 | "\nF6", 56 | "\nF7", 57 | "\nF8", 58 | { 59 | "x": 1 60 | }, 61 | "\nF9", 62 | "\nF10", 63 | "\nF11", 64 | "\nF12", 65 | { 66 | "x": 0.75, 67 | "c": "#857eb1", 68 | "t": "#000000\n\n\n\n\n\n\n#0000ff", 69 | "f": 5 70 | }, 71 | "\n⎙\n\n\n\n\n\n", 72 | { 73 | "c": "#c4c8c5", 74 | "t": "#000000", 75 | "f": 3, 76 | "f2": 6 77 | }, 78 | "\n⇳", 79 | { 80 | "c": "#857eb1", 81 | "t": "#000000\n\n\n\n\n\n\n#0000ff", 82 | "fa": [ 83 | 0, 84 | 0, 85 | 6, 86 | 6, 87 | 6, 88 | 6, 89 | 6, 90 | 6 91 | ] 92 | }, 93 | "\n\n\n\n\n\n\n" 94 | ], 95 | [ 96 | { 97 | "y": 0.07000000000000006, 98 | "x": 10, 99 | "c": "#cccccc", 100 | "t": "#B22222", 101 | "a": 5, 102 | "d": true 103 | }, 104 | "\n\n\n\n\n\n" 105 | ], 106 | [ 107 | { 108 | "y": -0.9700000000000002, 109 | "x": 11, 110 | "c": "#d9dae0", 111 | "t": "#32CD32\n\n\n\n\n\n#fdd017", 112 | "d": true 113 | }, 114 | "\n\n\n\n\n\n", 115 | { 116 | "t": "#ffffff\n\n\n\n\n\n#fdd017", 117 | "fa": [ 118 | 0, 119 | 0, 120 | 6, 121 | 6, 122 | 6, 123 | 6, 124 | 4 125 | ], 126 | "d": true 127 | }, 128 | "\n\n\n\n\n\n⇳", 129 | { 130 | "t": "#0000CD\n\n\n\n\n\n#fdd017", 131 | "d": true 132 | }, 133 | "\n\n\n\n\n\n▮" 134 | ], 135 | [ 136 | { 137 | "y": -0.3500000000000001, 138 | "x": 9.5, 139 | "c": "#c7c3b4", 140 | "t": "#000000", 141 | "a": 4, 142 | "f2": 5 143 | }, 144 | "\n[", 145 | { 146 | "c": "#95bfe8" 147 | }, 148 | "\n7", 149 | "\n8", 150 | "\n9", 151 | { 152 | "c": "#c7c3b4" 153 | }, 154 | "\n]" 155 | ], 156 | [ 157 | { 158 | "x": 9.5 159 | }, 160 | "\n(", 161 | { 162 | "c": "#95bfe8" 163 | }, 164 | "\n4", 165 | { 166 | "n": true 167 | }, 168 | "\n5", 169 | "\n6", 170 | { 171 | "c": "#c7c3b4" 172 | }, 173 | "\n)" 174 | ], 175 | [ 176 | { 177 | "x": 9.5 178 | }, 179 | "\n{", 180 | { 181 | "c": "#95bfe8" 182 | }, 183 | "\n1", 184 | "\n2", 185 | "\n3", 186 | { 187 | "c": "#c7c3b4" 188 | }, 189 | "\n}" 190 | ], 191 | [ 192 | { 193 | "x": 9.5 194 | }, 195 | "\n<", 196 | { 197 | "c": "#95bfe8", 198 | "t": "#7f007f\n#000", 199 | "f": 2, 200 | "f2": 3 201 | }, 202 | "\n0", 203 | "\n00", 204 | "\n000", 205 | { 206 | "c": "#c7c3b4", 207 | "t": "#000000", 208 | "f2": 5 209 | }, 210 | "\n>" 211 | ], 212 | [ 213 | { 214 | "x": 9.5, 215 | "c": "#95bfe8", 216 | "t": "#7f007f\n#000\n\n\n#f00\n#f00", 217 | "a": 0, 218 | "f": 3, 219 | "f2": 5 220 | }, 221 | "\n-\n\n\n-\n_", 222 | { 223 | "t": "#7f007f\n#000", 224 | "a": 4 225 | }, 226 | "\n+", 227 | { 228 | "f": 2, 229 | "f2": 5 230 | }, 231 | "\n=", 232 | "\n*", 233 | "\n/" 234 | ], 235 | [ 236 | { 237 | "y": 0.25, 238 | "x": 9.5, 239 | "c": "#c4c8c5", 240 | "t": "#000000" 241 | }, 242 | "\n⇞", 243 | "\n↶", 244 | { 245 | "f2": 4 246 | }, 247 | "\n", 248 | { 249 | "f2": 5 250 | }, 251 | "\n↷", 252 | { 253 | "f": 3 254 | }, 255 | "\n⌦" 256 | ], 257 | [ 258 | { 259 | "y": 1.7763568394002505e-15, 260 | "x": 9.5, 261 | "f2": 5 262 | }, 263 | "\n⇟", 264 | "\n", 265 | "\n", 266 | "\n", 267 | { 268 | "f": 5 269 | }, 270 | "\n⌶ ▮" 271 | ], 272 | [ 273 | { 274 | "x": 9.5, 275 | "a": 7, 276 | "w": 5, 277 | "h": 0.5, 278 | "d": true 279 | }, 280 | "" 281 | ], 282 | [ 283 | { 284 | "y": 0.9199999999999999, 285 | "x": 10.75, 286 | "c": "#cccccc", 287 | "a": 4, 288 | "f": 3, 289 | "d": true 290 | }, 291 | "" 292 | ], 293 | [ 294 | { 295 | "r": 15, 296 | "y": -11.450000000000001, 297 | "x": 3, 298 | "c": "#c7c3b4", 299 | "f2": 5 300 | }, 301 | "0\n`" 302 | ], 303 | [ 304 | { 305 | "y": -0.9999999999999997, 306 | "x": 4 307 | }, 308 | "1\n!", 309 | "2\n@", 310 | { 311 | "t": "#000000\n\n\n\n\n\n\n#0000ff" 312 | }, 313 | "3\n#\n\n\n\n\n\n₤", 314 | "4\n$\n\n\n\n\n\n¥", 315 | "5\n%\n\n\n\n\n\n€" 316 | ], 317 | [ 318 | { 319 | "x": 2.5, 320 | "c": "#c4c8c5", 321 | "t": "#000000", 322 | "f": 3, 323 | "w": 1.5 324 | }, 325 | "⌫\n⌦", 326 | { 327 | "c": "#e5dbca", 328 | "t": "#000000\n\n\n\n#f00", 329 | "a": 0, 330 | "fa": [ 331 | 0, 332 | 0, 333 | 5, 334 | 5, 335 | 5 336 | ] 337 | }, 338 | "Q\nq\n\n\nQ", 339 | "D\nd\n\n\nW", 340 | "R\nr\n\n\nE", 341 | "W\nw\n\n\nR", 342 | "B\nb\n\n\nT", 343 | { 344 | "c": "#c7c3b4", 345 | "t": "#000000", 346 | "a": 4, 347 | "f2": 5 348 | }, 349 | "\n'" 350 | ], 351 | [ 352 | { 353 | "x": 2.5, 354 | "c": "#c4c8c5", 355 | "f": 9, 356 | "w": 1.5 357 | }, 358 | "←\n→", 359 | { 360 | "c": "#e5dbca", 361 | "t": "#000000\n\n\n\n#f00\n\n\n#0000ff", 362 | "a": 0, 363 | "f": 3 364 | }, 365 | "A\na\n\n\nA\n\n\n", 366 | { 367 | "t": "#000000\n\n\n\n#f00" 368 | }, 369 | "S\ns\n\n\nS", 370 | "H\nh\n\n\nD", 371 | { 372 | "n": true 373 | }, 374 | "T\nt\n\n\nF", 375 | "G\ng\n\n\nG", 376 | { 377 | "c": "#c7c3b4", 378 | "t": "#000000", 379 | "a": 4, 380 | "f2": 9 381 | }, 382 | "\n," 383 | ], 384 | [ 385 | { 386 | "x": 2.5, 387 | "c": "#c4c8c5", 388 | "f2": 6, 389 | "w": 1.5 390 | }, 391 | " \n⇧", 392 | { 393 | "c": "#e5dbca", 394 | "t": "#000000\n\n#0000ff\n\n#f00\n\n\n#0000ff", 395 | "a": 0, 396 | "fa": [ 397 | 0, 398 | 0, 399 | 0, 400 | 6, 401 | 6 402 | ] 403 | }, 404 | "Z\nz\n↷\n\nZ\n\n\n↶", 405 | { 406 | "t": "#000000\n\n\n\n#f00\n\n\n#0000ff" 407 | }, 408 | "X\nx\n\n\nX\n\n\n", 409 | { 410 | "t": "#000000\n\n\n\n#f00" 411 | }, 412 | "M\nm\n\n\nC", 413 | { 414 | "t": "#000000\n\n\n\n#f00\n\n\n#0000ff" 415 | }, 416 | "C\nc\n\n\nV\n\n\n", 417 | "V\nv\n\n\nB\n\n\n", 418 | { 419 | "c": "#00833e", 420 | "t": "#00000", 421 | "a": 4, 422 | "f2": 9, 423 | "h": 2 424 | }, 425 | "\n⏎" 426 | ], 427 | [ 428 | { 429 | "x": 2.5, 430 | "c": "#c4c8c5", 431 | "t": "#000000\n#0000ff", 432 | "w": 1.5 433 | }, 434 | "\n⎈", 435 | { 436 | "c": "#857eb1", 437 | "t": "#000000", 438 | "f2": 5 439 | }, 440 | "\n", 441 | { 442 | "c": "#45b866" 443 | }, 444 | "\n⇱", 445 | { 446 | "c": "#857eb1", 447 | "f": 5, 448 | "w": 1.5 449 | }, 450 | "☰\n", 451 | { 452 | "c": "#c4c8c5", 453 | "f": 3, 454 | "f2": 7, 455 | "w": 1.5 456 | }, 457 | "\n⎇" 458 | ], 459 | [ 460 | { 461 | "x": 4, 462 | "c": "#45b866", 463 | "f2": 5 464 | }, 465 | "\n⇤", 466 | "\n⇲", 467 | "\n⇥", 468 | { 469 | "x": 0.25, 470 | "c": "#e5dbca", 471 | "a": 7, 472 | "w": 2.75 473 | }, 474 | "" 475 | ], 476 | [ 477 | { 478 | "r": -15, 479 | "y": 0.1800000000000006, 480 | "x": 14.15, 481 | "c": "#c7c3b4", 482 | "a": 4 483 | }, 484 | "6\n^", 485 | "7\n&", 486 | { 487 | "t": "#000000\n\n\n\n\n\n\n#0000ff" 488 | }, 489 | "8\n\\\n\n\n\n\n\nɃ", 490 | { 491 | "t": "#000000" 492 | }, 493 | "9\n_", 494 | "0\n~", 495 | { 496 | "c": "#857eb1" 497 | }, 498 | "\nf(x)" 499 | ], 500 | [ 501 | { 502 | "y": -1.7763568394002505e-15, 503 | "x": 13.15, 504 | "c": "#c7c3b4" 505 | }, 506 | "\n\"", 507 | { 508 | "c": "#e5dbca", 509 | "t": "#000000\n\n\n\n#f00", 510 | "a": 0, 511 | "fa": [ 512 | 0, 513 | 0, 514 | 5, 515 | 5, 516 | 5 517 | ] 518 | }, 519 | "J\nj\n\n\nY", 520 | "F\nf\n\n\nU", 521 | "U\nu\n\n\nI", 522 | "P\np\n\n\nO", 523 | { 524 | "c": "#c7c3b4" 525 | }, 526 | "\n|\n\n\nP", 527 | { 528 | "c": "#c4c8c5", 529 | "t": "#000000", 530 | "a": 4, 531 | "w": 1.5 532 | }, 533 | "⌦\n⌫" 534 | ], 535 | [ 536 | { 537 | "x": 13.15, 538 | "c": "#c7c3b4", 539 | "f2": 9 540 | }, 541 | "\n.", 542 | { 543 | "c": "#e5dbca", 544 | "t": "#000000\n\n\n\n#f00", 545 | "a": 0, 546 | "fa": [ 547 | 0, 548 | 0, 549 | 9, 550 | 9, 551 | 9 552 | ] 553 | }, 554 | "Y\ny\n\n\nH", 555 | { 556 | "n": true 557 | }, 558 | "N\nn\n\n\nJ", 559 | "E\ne\n\n\nK", 560 | "O\no\n\n\nL", 561 | { 562 | "t": "#000000\n\n\n\n#f00\n#f00", 563 | "fa": [ 564 | 0, 565 | 0, 566 | 9, 567 | 9, 568 | 9, 569 | 9 570 | ] 571 | }, 572 | "I\ni\n\n\n;\n:", 573 | { 574 | "c": "#c4c8c5", 575 | "t": "#000000", 576 | "a": 4, 577 | "f": 9, 578 | "w": 1.5 579 | }, 580 | "→\n←" 581 | ], 582 | [ 583 | { 584 | "x": 13.15, 585 | "c": "#00833e", 586 | "f": 5, 587 | "f2": 9, 588 | "h": 2 589 | }, 590 | "\n⏎", 591 | { 592 | "c": "#e5dbca", 593 | "t": "#000000\n\n\n\n#f00", 594 | "a": 0, 595 | "f": 3 596 | }, 597 | "K\nk\n\n\nN", 598 | "L\nl\n\n\nM", 599 | { 600 | "c": "#c7c3b4", 601 | "t": "#000000\n\n\n\n#f00\n#f00", 602 | "fa": [ 603 | 0, 604 | 5, 605 | 0, 606 | 0, 607 | 5, 608 | 5 609 | ] 610 | }, 611 | "\n:\n\n\n,\n<", 612 | "\n?\n\n\n.\n>", 613 | "\n;\n\n\n/\n?", 614 | { 615 | "c": "#c4c8c5", 616 | "t": "#000000", 617 | "a": 4, 618 | "f2": 6, 619 | "w": 1.5 620 | }, 621 | " \n⇧" 622 | ], 623 | [ 624 | { 625 | "x": 14.15, 626 | "c": "#909596", 627 | "f2": 7, 628 | "w": 1.5 629 | }, 630 | "\n⎇", 631 | { 632 | "c": "#857eb1", 633 | "f": 5, 634 | "w": 1.5 635 | }, 636 | "☰\n", 637 | { 638 | "c": "#45b866", 639 | "f": 3, 640 | "f2": 6 641 | }, 642 | "\n↑", 643 | { 644 | "c": "#857eb1", 645 | "f2": 5 646 | }, 647 | "\n", 648 | { 649 | "c": "#c4c8c5", 650 | "t": "#000000\n#0000ff", 651 | "f2": 9, 652 | "w": 1.5 653 | }, 654 | "\n⎈" 655 | ], 656 | [ 657 | { 658 | "x": 13.13, 659 | "c": "#e5dbca", 660 | "t": "#000000", 661 | "a": 7, 662 | "w": 2.75 663 | }, 664 | "", 665 | { 666 | "x": 0.2699999999999978, 667 | "c": "#45b866", 668 | "a": 4, 669 | "f2": 6 670 | }, 671 | "\n←", 672 | "\n↓", 673 | "\n→" 674 | ] 675 | ] -------------------------------------------------------------------------------- /solid/test/test_solidpython.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | import os 4 | import sys 5 | import re 6 | 7 | import unittest 8 | import tempfile 9 | from solid.test.ExpandedTestCase import DiffOutput 10 | from solid import * 11 | 12 | scad_test_case_templates = [ 13 | {'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]]}, }, 14 | {'name': 'circle', 'kwargs': {'segments': 12, 'r': 1}, 'expected': '\n\ncircle($fn = 12, r = 1);', 'args': {}, }, 15 | {'name': 'circle', 'kwargs': {'segments': 12, 'd': 1}, 'expected': '\n\ncircle($fn = 12, d = 1);', 'args': {}, }, 16 | {'name': 'square', 'kwargs': {'center': False, 'size': 1}, 'expected': '\n\nsquare(center = false, size = 1);', 'args': {}, }, 17 | {'name': 'sphere', 'kwargs': {'segments': 12, 'r': 1}, 'expected': '\n\nsphere($fn = 12, r = 1);', 'args': {}, }, 18 | {'name': 'sphere', 'kwargs': {'segments': 12, 'd': 1}, 'expected': '\n\nsphere($fn = 12, d = 1);', 'args': {}, }, 19 | {'name': 'cube', 'kwargs': {'center': False, 'size': 1}, 'expected': '\n\ncube(center = false, size = 1);', 'args': {}, }, 20 | {'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': {}, }, 21 | {'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': {}, }, 22 | {'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]]}, }, 23 | {'name': 'union', 'kwargs': {}, 'expected': '\n\nunion();', 'args': {}, }, 24 | {'name': 'intersection','kwargs': {}, 'expected': '\n\nintersection();', 'args': {}, }, 25 | {'name': 'difference', 'kwargs': {}, 'expected': '\n\ndifference();', 'args': {}, }, 26 | {'name': 'translate', 'kwargs': {'v': [1, 0, 0]}, 'expected': '\n\ntranslate(v = [1, 0, 0]);', 'args': {}, }, 27 | {'name': 'scale', 'kwargs': {'v': 0.5}, 'expected': '\n\nscale(v = 0.5000000000);', 'args': {}, }, 28 | {'name': 'rotate', 'kwargs': {'a': 45, 'v': [0, 0, 1]}, 'expected': '\n\nrotate(a = 45, v = [0, 0, 1]);', 'args': {}, }, 29 | {'name': 'mirror', 'kwargs': {}, 'expected': '\n\nmirror(v = [0, 0, 1]);', 'args': {'v': [0, 0, 1]}, }, 30 | {'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]]}, }, 31 | {'name': 'color', 'kwargs': {}, 'expected': '\n\ncolor(c = [1, 0, 0]);', 'args': {'c': [1, 0, 0]}, }, 32 | {'name': 'minkowski', 'kwargs': {}, 'expected': '\n\nminkowski();', 'args': {}, }, 33 | {'name': 'offset', 'kwargs': {'r': 1}, 'expected': '\n\noffset(r = 1);', 'args': {}, }, 34 | {'name': 'offset', 'kwargs': {'delta': 1}, 'expected': '\n\noffset(chamfer = false, delta = 1);', 'args': {}, }, 35 | {'name': 'hull', 'kwargs': {}, 'expected': '\n\nhull();', 'args': {}, }, 36 | {'name': 'render', 'kwargs': {'convexity': None}, 'expected': '\n\nrender();', 'args': {}, }, 37 | {'name': 'projection', 'kwargs': {'cut': None}, 'expected': '\n\nprojection();', 'args': {}, }, 38 | {'name': 'surface', 'kwargs': {'center': False, 'convexity': None}, 'expected': '\n\nsurface(center = false, file = "/Path/to/dummy.dxf");', 'args': {'file': "'/Path/to/dummy.dxf'"}, }, 39 | {'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'"}, }, 40 | {'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'"}, }, 41 | {'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'"}, }, 42 | {'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': {}, }, 43 | {'name': 'rotate_extrude', 'kwargs': {'convexity': None}, 'expected': '\n\nrotate_extrude();', 'args': {}, }, 44 | {'name': 'intersection_for', 'kwargs': {}, 'expected': '\n\nintersection_for(n = [0, 1, 2]);', 'args': {'n': [0, 1, 2]}, }, 45 | ] 46 | 47 | class TemporaryFileBuffer(object): 48 | name = None 49 | contents = None 50 | def __enter__(self): 51 | f = tempfile.NamedTemporaryFile(delete=False) 52 | self.name = f.name 53 | try: 54 | f.close() 55 | except: 56 | self._cleanup() 57 | raise 58 | return self 59 | 60 | def __exit__(self, exc_type, exc_val, exc_tb): 61 | try: 62 | with open(self.name, 'r') as f: 63 | self.contents = f.read() 64 | finally: 65 | self._cleanup() 66 | 67 | def _cleanup(self): 68 | try: 69 | os.unlink(self.name) 70 | except: 71 | pass 72 | 73 | 74 | class TestSolidPython(DiffOutput): 75 | # test cases will be dynamically added to this instance 76 | 77 | def expand_scad_path(self, filename): 78 | path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../') 79 | return os.path.join(path, filename) 80 | 81 | def test_infix_union(self): 82 | a = cube(2) 83 | b = sphere(2) 84 | expected = '\n\nunion() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' 85 | actual = scad_render(a + b) 86 | self.assertEqual(expected, actual) 87 | 88 | def test_infix_difference(self): 89 | a = cube(2) 90 | b = sphere(2) 91 | expected = '\n\ndifference() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' 92 | actual = scad_render(a - b) 93 | self.assertEqual(expected, actual) 94 | 95 | def test_infix_intersection(self): 96 | a = cube(2) 97 | b = sphere(2) 98 | expected = '\n\nintersection() {\n\tcube(size = 2);\n\tsphere(r = 2);\n}' 99 | actual = scad_render(a * b) 100 | self.assertEqual(expected, actual) 101 | 102 | def test_parse_scad_callables(self): 103 | test_str = ("" 104 | "module hex (width=10, height=10, \n" 105 | " flats= true, center=false){}\n" 106 | "function righty (angle=90) = 1;\n" 107 | "function lefty(avar) = 2;\n" 108 | "module more(a=[something, other]) {}\n" 109 | "module pyramid(side=10, height=-1, square=false, centerHorizontal=true, centerVertical=false){}\n" 110 | "module no_comments(arg=10, //test comment\n" 111 | "other_arg=2, /* some extra comments\n" 112 | "on empty lines */\n" 113 | "last_arg=4){}\n" 114 | "module float_arg(arg=1.0){}\n") 115 | expected = [{'args': [], 'name': 'hex', 'kwargs': ['width', 'height', 'flats', 'center']}, {'args': [], 'name': 'righty', 'kwargs': ['angle']}, {'args': ['avar'], 'name': 'lefty', 'kwargs': []}, {'args': [], 'name': 'more', 'kwargs': ['a']}, { 116 | 'args': [], 'name': 'pyramid', 'kwargs': ['side', 'height', 'square', 'centerHorizontal', 'centerVertical']}, {'args': [], 'name': 'no_comments', 'kwargs': ['arg', 'other_arg', 'last_arg']}, {'args': [], 'name': 'float_arg', 'kwargs': ['arg']}] 117 | from solid.solidpython import parse_scad_callables 118 | actual = parse_scad_callables(test_str) 119 | self.assertEqual(expected, actual) 120 | 121 | def test_use(self): 122 | include_file = self.expand_scad_path("examples/scad_to_include.scad") 123 | use(include_file) 124 | a = steps(3) 125 | actual = scad_render(a) 126 | 127 | abs_path = a._get_include_path(include_file) 128 | expected = "use <%s>\n\n\nsteps(howmany = 3);" % abs_path 129 | self.assertEqual(expected, actual) 130 | 131 | def test_include(self): 132 | include_file = self.expand_scad_path("examples/scad_to_include.scad") 133 | self.assertIsNotNone(include_file, 'examples/scad_to_include.scad not found') 134 | include(include_file) 135 | a = steps(3) 136 | 137 | actual = scad_render(a) 138 | abs_path = a._get_include_path(include_file) 139 | expected = "include <%s>\n\n\nsteps(howmany = 3);" % abs_path 140 | self.assertEqual(expected, actual) 141 | 142 | def test_extra_args_to_included_scad(self): 143 | include_file = self.expand_scad_path("examples/scad_to_include.scad") 144 | use(include_file) 145 | a = steps(3, external_var=True) 146 | actual = scad_render(a) 147 | 148 | abs_path = a._get_include_path(include_file) 149 | expected = "use <%s>\n\n\nsteps(external_var = true, howmany = 3);" % abs_path 150 | self.assertEqual(expected, actual) 151 | 152 | def test_background(self): 153 | a = cube(10) 154 | expected = '\n\n%cube(size = 10);' 155 | actual = scad_render(background(a)) 156 | self.assertEqual(expected, actual) 157 | 158 | def test_debug(self): 159 | a = cube(10) 160 | expected = '\n\n#cube(size = 10);' 161 | actual = scad_render(debug(a)) 162 | self.assertEqual(expected, actual) 163 | 164 | def test_disable(self): 165 | a = cube(10) 166 | expected = '\n\n*cube(size = 10);' 167 | actual = scad_render(disable(a)) 168 | self.assertEqual(expected, actual) 169 | 170 | def test_root(self): 171 | a = cube(10) 172 | expected = '\n\n!cube(size = 10);' 173 | actual = scad_render(root(a)) 174 | self.assertEqual(expected, actual) 175 | 176 | def test_explicit_hole(self): 177 | a = cube(10, center=True) + hole()(cylinder(2, 20, center=True)) 178 | 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}' 179 | actual = scad_render(a) 180 | self.assertEqual(expected, actual) 181 | 182 | def test_hole_transform_propagation(self): 183 | # earlier versions of holes had problems where a hole 184 | # that was used a couple places wouldn't propagate correctly. 185 | # Confirm that's still happening as it's supposed to 186 | h = hole()( 187 | rotate(a=90, v=[0, 1, 0])( 188 | cylinder(2, 20, center=True) 189 | ) 190 | ) 191 | 192 | h_vert = rotate(a=-90, v=[0, 1, 0])( 193 | h 194 | ) 195 | 196 | a = cube(10, center=True) + h + h_vert 197 | expected = '\n\ndifference(){\n\tunion() {\n\t\tunion() {\n\t\t\tcube(center = true, size = 10);\n\t\t}\n\t\trotate(a = -90, v = [0, 1, 0]) {\n\t\t}\n\t}\n\t/* Holes Below*/\n\tunion(){\n\t\tunion(){\n\t\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t\t}\n\t\t}\n\t\trotate(a = -90, v = [0, 1, 0]){\n\t\t\trotate(a = 90, v = [0, 1, 0]) {\n\t\t\t\tcylinder(center = true, h = 20, r = 2);\n\t\t\t}\n\t\t}\n\t} /* End Holes */ \n}' 198 | actual = scad_render(a) 199 | self.assertEqual(expected, actual) 200 | 201 | def test_separate_part_hole(self): 202 | # Make two parts, a block with hole, and a cylinder that 203 | # fits inside it. Make them separate parts, meaning 204 | # holes will be defined at the level of the part_root node, 205 | # not the overall node. This allows us to preserve holes as 206 | # first class space, but then to actually fill them in with 207 | # the parts intended to fit in them. 208 | b = cube(10, center=True) 209 | c = cylinder(r=2, h=12, center=True) 210 | p1 = b - hole()(c) 211 | 212 | # Mark this cube-with-hole as a separate part from the cylinder 213 | p1 = part()(p1) 214 | 215 | # This fits in the hole. If p1 is set as a part_root, it will all appear. 216 | # If not, the portion of the cylinder inside the cube will not appear, 217 | # since it would have been removed by the hole in p1 218 | p2 = cylinder(r=1.5, h=14, center=True) 219 | 220 | a = p1 + p2 221 | 222 | expected = '\n\nunion() {\n\tdifference(){\n\t\tdifference() {\n\t\t\tcube(center = true, size = 10);\n\t\t}\n\t\t/* Holes Below*/\n\t\tdifference(){\n\t\t\tcylinder(center = true, h = 12, r = 2);\n\t\t} /* End Holes */ \n\t}\n\tcylinder(center = true, h = 14, r = 1.5000000000);\n}' 223 | actual = scad_render(a) 224 | self.assertEqual(expected, actual) 225 | 226 | def test_scad_render_animated_file(self): 227 | def my_animate(_time=0): 228 | import math 229 | # _time will range from 0 to 1, not including 1 230 | rads = _time * 2 * math.pi 231 | rad = 15 232 | c = translate([rad * math.cos(rads), rad * math.sin(rads)])(square(10)) 233 | return c 234 | with TemporaryFileBuffer() as tmp: 235 | scad_render_animated_file(my_animate, steps=2, back_and_forth=False, 236 | filepath=tmp.name, include_orig_code=False) 237 | 238 | actual = tmp.contents 239 | expected = '\nif ($t >= 0.0 && $t < 0.5){ \n\ttranslate(v = [15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\nif ($t >= 0.5 && $t < 1.0){ \n\ttranslate(v = [-15.0000000000, 0.0000000000]) {\n\t\tsquare(size = 10);\n\t}\n}\n' 240 | 241 | self.assertEqual(expected, actual) 242 | 243 | def test_scad_render_to_file(self): 244 | a = circle(10) 245 | 246 | # No header, no included original code 247 | with TemporaryFileBuffer() as tmp: 248 | scad_render_to_file(a, filepath=tmp.name, include_orig_code=False) 249 | 250 | actual = tmp.contents 251 | expected = '\n\ncircle(r = 10);' 252 | 253 | self.assertEqual(expected, actual) 254 | 255 | # Header 256 | with TemporaryFileBuffer() as tmp: 257 | scad_render_to_file(a, filepath=tmp.name, include_orig_code=False, 258 | file_header='$fn = 24;') 259 | 260 | actual = tmp.contents 261 | expected = '$fn = 24;\n\ncircle(r = 10);' 262 | 263 | self.assertEqual(expected, actual) 264 | 265 | # TODO: test include_orig_code=True, but that would have to 266 | # be done from a separate file, or include everything in this one 267 | 268 | 269 | def single_test(test_dict): 270 | name, args, kwargs, expected = test_dict['name'], test_dict['args'], test_dict['kwargs'], test_dict['expected'] 271 | 272 | def test(self): 273 | call_str = name + "(" 274 | for k, v in args.items(): 275 | call_str += "%s=%s, " % (k, v) 276 | for k, v in kwargs.items(): 277 | call_str += "%s=%s, " % (k, v) 278 | call_str += ')' 279 | 280 | scad_obj = eval(call_str) 281 | actual = scad_render(scad_obj) 282 | 283 | self.assertEqual(expected, actual) 284 | 285 | return test 286 | 287 | 288 | def generate_cases_from_templates(): 289 | for test_dict in scad_test_case_templates: 290 | test = single_test(test_dict) 291 | test_name = "test_%(name)s" % test_dict 292 | setattr(TestSolidPython, test_name, test) 293 | 294 | 295 | if __name__ == '__main__': 296 | generate_cases_from_templates() 297 | unittest.main() 298 | -------------------------------------------------------------------------------- /solid/solidpython.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Simple Python OpenSCAD Code Generator 5 | # Copyright (C) 2009 Philipp Tiefenbacher 6 | # Amendments & additions, (C) 2011 Evan Jones 7 | # 8 | # License: LGPL 2.1 or later 9 | # 10 | 11 | 12 | import os, sys, re 13 | import inspect 14 | import subprocess 15 | import tempfile 16 | 17 | # These are features added to SolidPython but NOT in OpenSCAD. 18 | # Mark them for special treatment 19 | non_rendered_classes = ['hole', 'part'] 20 | 21 | # ========================================= 22 | # = Rendering Python code to OpenSCAD code= 23 | # ========================================= 24 | def _find_include_strings(obj): 25 | include_strings = set() 26 | if isinstance(obj, IncludedOpenSCADObject): 27 | include_strings.add(obj.include_string) 28 | for child in obj.children: 29 | include_strings.update(_find_include_strings(child)) 30 | return include_strings 31 | 32 | 33 | def scad_render(scad_object, file_header=''): 34 | # Make this object the root of the tree 35 | root = scad_object 36 | 37 | # Scan the tree for all instances of 38 | # IncludedOpenSCADObject, storing their strings 39 | include_strings = _find_include_strings(root) 40 | 41 | # and render the string 42 | includes = ''.join(include_strings) + "\n" 43 | scad_body = root._render() 44 | return file_header + includes + scad_body 45 | 46 | 47 | def scad_render_animated(func_to_animate, steps=20, back_and_forth=True, filepath=None, file_header=''): 48 | # func_to_animate takes a single float argument, _time in [0, 1), and 49 | # returns an OpenSCADObject instance. 50 | # 51 | # Outputs an OpenSCAD file with func_to_animate() evaluated at "steps" 52 | # points between 0 & 1, with time never evaluated at exactly 1 53 | 54 | # If back_and_forth is True, smoothly animate the full extent of the motion 55 | # and then reverse it to the beginning; this avoids skipping between beginning 56 | # and end of the animated motion 57 | 58 | # NOTE: This is a hacky way to solve a simple problem. To use OpenSCAD's 59 | # animation feature, our code needs to respond to changes in the value 60 | # of the OpenSCAD variable $t, but I can't think of a way to get a 61 | # float variable from our code and put it into the actual SCAD code. 62 | # Instead, we just evaluate our code at each desired step, and write it 63 | # all out in the SCAD code for each case, with an if/else tree. Depending 64 | # on the number of steps, this could create hundreds of times more SCAD 65 | # code than is needed. But... it does work, with minimal Python code, so 66 | # here it is. Better solutions welcome. -ETJ 28 Mar 2013 67 | 68 | # NOTE: information on the OpenSCAD manual wiki as of November 2012 implies 69 | # that the OpenSCAD app does its animation irregularly; sometimes it animates 70 | # one loop in steps iterations, and sometimes in (steps + 1). Do it here 71 | # in steps iterations, meaning that we won't officially reach $t =1. 72 | 73 | # Note also that we check for ranges of time rather than equality; this 74 | # should avoid any rounding error problems, and doesn't require the file 75 | # to be animated with an identical number of steps to the way it was 76 | # created. -ETJ 28 Mar 2013 77 | scad_obj = func_to_animate() 78 | include_strings = _find_include_strings(scad_obj) 79 | # and render the string 80 | includes = ''.join(include_strings) + "\n" 81 | 82 | rendered_string = file_header + includes 83 | 84 | if back_and_forth: 85 | steps *= 2 86 | 87 | for i in range(steps): 88 | time = i * 1.0 / steps 89 | end_time = (i + 1) * 1.0 / steps 90 | eval_time = time 91 | # Looping back and forth means there's no jump between the start and 92 | # end position 93 | if back_and_forth: 94 | if time < 0.5: 95 | eval_time = time * 2 96 | else: 97 | eval_time = 2 - 2 * time 98 | scad_obj = func_to_animate(_time=eval_time) 99 | 100 | scad_str = indent(scad_obj._render()) 101 | rendered_string += ("if ($t >= %(time)s && $t < %(end_time)s){" 102 | " %(scad_str)s\n" 103 | "}\n" % vars()) 104 | return rendered_string 105 | 106 | 107 | def scad_render_animated_file(func_to_animate, steps=20, back_and_forth=True, 108 | filepath=None, file_header='', include_orig_code=True): 109 | rendered_string = scad_render_animated(func_to_animate, steps, 110 | back_and_forth, file_header) 111 | return _write_code_to_file(rendered_string, filepath, include_orig_code) 112 | 113 | 114 | def scad_render_to_file(scad_object, filepath=None, file_header='', include_orig_code=True): 115 | rendered_string = scad_render(scad_object, file_header) 116 | return _write_code_to_file(rendered_string, filepath, include_orig_code) 117 | 118 | 119 | def _write_code_to_file(rendered_string, filepath=None, include_orig_code=True): 120 | try: 121 | calling_file = os.path.abspath(calling_module(stack_depth=3).__file__) 122 | 123 | if include_orig_code: 124 | rendered_string += sp_code_in_scad_comment(calling_file) 125 | 126 | # This write is destructive, and ought to do some checks that the write 127 | # was successful. 128 | # If filepath isn't supplied, place a .scad file with the same name 129 | # as the calling module next to it 130 | if not filepath: 131 | filepath = os.path.splitext(calling_file)[0] + '.scad' 132 | except AttributeError as e: 133 | # If no calling_file was found, this is being called from the terminal. 134 | # We can't read original code from a file, so don't try, 135 | # and can't read filename from the calling file either, so just save to 136 | # solid.scad. 137 | if not filepath: 138 | filepath = os.path.abspath('.') + "/solid.scad" 139 | 140 | f = open(filepath, "w") 141 | f.write(rendered_string) 142 | f.close() 143 | return True 144 | 145 | 146 | def sp_code_in_scad_comment(calling_file): 147 | # Once a SCAD file has been created, it's difficult to reconstruct 148 | # how it got there, since it has no variables, modules, etc. So, include 149 | # the Python code that generated the scad code as comments at the end of 150 | # the SCAD code 151 | pyopenscad_str = open(calling_file, 'r').read() 152 | 153 | # TODO: optimally, this would also include a version number and 154 | # git hash (& date & github URL?) for the version of solidpython used 155 | # to create a given file; That would future-proof any given SP-created 156 | # code because it would point to the relevant dependencies as well as 157 | # the actual code 158 | pyopenscad_str = ("\n" 159 | "/***********************************************\n" 160 | "********* SolidPython code: **********\n" 161 | "************************************************\n" 162 | " \n" 163 | "%(pyopenscad_str)s \n" 164 | " \n" 165 | "************************************************/\n") % vars() 166 | return pyopenscad_str 167 | 168 | # =========== 169 | # = Parsing = 170 | # =========== 171 | def extract_callable_signatures(scad_file_path): 172 | with open(scad_file_path) as f: 173 | scad_code_str = f.read() 174 | return parse_scad_callables(scad_code_str) 175 | 176 | def parse_scad_callables(scad_code_str): 177 | callables = [] 178 | 179 | # Note that this isn't comprehensive; tuples or nested data structures in 180 | # a module definition will defeat it. 181 | 182 | # Current implementation would throw an error if you tried to call a(x, y) 183 | # since Python would expect a(x); OpenSCAD itself ignores extra arguments, 184 | # but that's not really preferable behavior 185 | 186 | # TODO: write a pyparsing grammar for OpenSCAD, or, even better, use the yacc parse grammar 187 | # used by the language itself. -ETJ 06 Feb 2011 188 | 189 | no_comments_re = r'(?mxs)(//.*?\n|/\*.*?\*/)' 190 | 191 | # Also note: this accepts: 'module x(arg) =' and 'function y(arg) {', both 192 | # of which are incorrect syntax 193 | mod_re = r'(?mxs)^\s*(?:module|function)\s+(?P\w+)\s*\((?P.*?)\)\s*(?:{|=)' 194 | 195 | # This is brittle. To get a generally applicable expression for all arguments, 196 | # we'd need a real parser to handle nested-list default args or parenthesized statements. 197 | # For the moment, assume a maximum of one square-bracket-delimited list 198 | args_re = r'(?mxs)(?P\w+)(?:\s*=\s*(?P[\w.-]+|\[.*\]))?(?:,|$)' 199 | 200 | # remove all comments from SCAD code 201 | scad_code_str = re.sub(no_comments_re, '', scad_code_str) 202 | # get all SCAD callables 203 | mod_matches = re.finditer(mod_re, scad_code_str) 204 | 205 | for m in mod_matches: 206 | callable_name = m.group('callable_name') 207 | args = [] 208 | kwargs = [] 209 | all_args = m.group('all_args') 210 | if all_args: 211 | arg_matches = re.finditer(args_re, all_args) 212 | for am in arg_matches: 213 | arg_name = am.group('arg_name') 214 | if am.group('default_val'): 215 | kwargs.append(arg_name) 216 | else: 217 | args.append(arg_name) 218 | 219 | callables.append({'name': callable_name, 'args': args, 'kwargs': kwargs}) 220 | 221 | return callables 222 | 223 | def calling_module(stack_depth=2): 224 | ''' 225 | Returns the module *2* back in the frame stack. That means: 226 | code in module A calls code in module B, which asks calling_module() 227 | for module A. 228 | 229 | This means that we have to know exactly how far back in the stack 230 | our desired module is; if code in module B calls another function in 231 | module B, we have to increase the stack_depth argument to account for 232 | this. 233 | 234 | Got that? 235 | ''' 236 | frm = inspect.stack()[stack_depth] 237 | calling_mod = inspect.getmodule(frm[0]) 238 | # If calling_mod is None, this is being called from an interactive session. 239 | # Return that module. (Note that __main__ doesn't have a __file__ attr, 240 | # but that's caught elsewhere.) 241 | if not calling_mod: 242 | import __main__ as calling_mod 243 | return calling_mod 244 | 245 | def new_openscad_class_str(class_name, args=[], kwargs=[], include_file_path=None, use_not_include=True): 246 | args_str = '' 247 | args_pairs = '' 248 | 249 | for arg in args: 250 | args_str += ', ' + arg 251 | args_pairs += "'%(arg)s':%(arg)s, " % vars() 252 | 253 | # kwargs have a default value defined in their SCAD versions. We don't 254 | # care what that default value will be (SCAD will take care of that), just 255 | # that one is defined. 256 | for kwarg in kwargs: 257 | args_str += ', %(kwarg)s=None' % vars() 258 | args_pairs += "'%(kwarg)s':%(kwarg)s, " % vars() 259 | 260 | if include_file_path: 261 | # include_file_path may include backslashes on Windows; escape them 262 | # again here so any backslashes don't get used as escape characters 263 | # themselves 264 | include_file_path = include_file_path.replace('\\', '\\\\') 265 | 266 | # NOTE the explicit import of 'solid' below. This is a fix for: 267 | # https://github.com/SolidCode/SolidPython/issues/20 -ETJ 16 Jan 2014 268 | result = ("import solid\n" 269 | "class %(class_name)s(solid.IncludedOpenSCADObject):\n" 270 | " def __init__(self%(args_str)s, **kwargs):\n" 271 | " solid.IncludedOpenSCADObject.__init__(self, '%(class_name)s', {%(args_pairs)s }, include_file_path='%(include_file_path)s', use_not_include=%(use_not_include)s, **kwargs )\n" 272 | " \n" 273 | "\n" % vars()) 274 | else: 275 | result = ("class %(class_name)s(OpenSCADObject):\n" 276 | " def __init__(self%(args_str)s):\n" 277 | " OpenSCADObject.__init__(self, '%(class_name)s', {%(args_pairs)s })\n" 278 | " \n" 279 | "\n" % vars()) 280 | 281 | return result 282 | 283 | # ========================= 284 | # = Internal Utilities = 285 | # ========================= 286 | class OpenSCADObject(object): 287 | 288 | def __init__(self, name, params): 289 | self.name = name 290 | self.params = params 291 | self.children = [] 292 | self.modifier = "" 293 | self.parent = None 294 | self.is_hole = False 295 | self.has_hole_children = False 296 | self.is_part_root = False 297 | 298 | def set_hole(self, is_hole=True): 299 | self.is_hole = is_hole 300 | return self 301 | 302 | def set_part_root(self, is_root=True): 303 | self.is_part_root = is_root 304 | return self 305 | 306 | def find_hole_children(self, path=None): 307 | # Because we don't force a copy every time we re-use a node 308 | # (e.g a = cylinder(2, 6); b = right(10) (a) 309 | # the identical 'a' object appears in the tree twice), 310 | # we can't count on an object's 'parent' field to trace its 311 | # path to the root. Instead, keep track explicitly 312 | path = path if path else [self] 313 | hole_kids = [] 314 | 315 | for child in self.children: 316 | path.append(child) 317 | if child.is_hole: 318 | hole_kids.append(child) 319 | # Mark all parents as having a hole child 320 | for p in path: 321 | p.has_hole_children = True 322 | # Don't append holes from separate parts below us 323 | elif child.is_part_root: 324 | continue 325 | # Otherwise, look below us for children 326 | else: 327 | hole_kids += child.find_hole_children(path) 328 | path.pop() 329 | 330 | return hole_kids 331 | 332 | def set_modifier(self, m): 333 | # Used to add one of the 4 single-character modifiers: 334 | # #(debug) !(root) %(background) or *(disable) 335 | string_vals = {'disable': '*', 336 | 'debug': '#', 337 | 'background': '%', 338 | 'root': '!', 339 | '*': '*', 340 | '#': '#', 341 | '%': '%', 342 | '!': '!'} 343 | 344 | self.modifier = string_vals.get(m.lower(), '') 345 | return self 346 | 347 | def _render(self, render_holes=False): 348 | ''' 349 | NOTE: In general, you won't want to call this method. For most purposes, 350 | you really want scad_render(), 351 | Calling obj._render won't include necessary 'use' or 'include' statements 352 | ''' 353 | # First, render all children 354 | s = "" 355 | for child in self.children: 356 | # Don't immediately render hole children. 357 | # Add them to the parent's hole list, 358 | # And render after everything else 359 | if not render_holes and child.is_hole: 360 | continue 361 | s += child._render(render_holes) 362 | 363 | # Then render self and prepend/wrap it around the children 364 | # I've added designated parts and explicit holes to SolidPython. 365 | # OpenSCAD has neither, so don't render anything from these objects 366 | if self.name in non_rendered_classes: 367 | pass 368 | elif not self.children: 369 | s = self._render_str_no_children() + ";" 370 | else: 371 | s = self._render_str_no_children() + " {" + indent(s) + "\n}" 372 | 373 | # If this is the root object or the top of a separate part, 374 | # find all holes and subtract them after all positive geometry 375 | # is rendered 376 | if (not self.parent) or self.is_part_root: 377 | hole_children = self.find_hole_children() 378 | 379 | if len(hole_children) > 0: 380 | s += "\n/* Holes Below*/" 381 | s += self._render_hole_children() 382 | 383 | # wrap everything in the difference 384 | s = "\ndifference(){" + indent(s) + " /* End Holes */ \n}" 385 | return s 386 | 387 | def _render_str_no_children(self): 388 | s = "\n" + self.modifier + self.name + "(" 389 | first = True 390 | 391 | # OpenSCAD doesn't have a 'segments' argument, but it does 392 | # have '$fn'. Swap one for the other 393 | if 'segments' in self.params: 394 | self.params['$fn'] = self.params.pop('segments') 395 | 396 | valid_keys = self.params.keys() 397 | 398 | # intkeys are the positional parameters 399 | intkeys = list(filter(lambda x: type(x) == int, valid_keys)) 400 | intkeys.sort() 401 | 402 | # named parameters 403 | nonintkeys = list(filter(lambda x: not type(x) == int, valid_keys)) 404 | all_params_sorted = intkeys + nonintkeys 405 | if all_params_sorted: 406 | all_params_sorted = sorted(all_params_sorted) 407 | 408 | for k in all_params_sorted: 409 | v = self.params[k] 410 | if v == None: 411 | continue 412 | 413 | if not first: 414 | s += ", " 415 | first = False 416 | 417 | if type(k) == int: 418 | s += py2openscad(v) 419 | else: 420 | s += k + " = " + py2openscad(v) 421 | 422 | s += ")" 423 | return s 424 | 425 | def _render_hole_children(self): 426 | # Run down the tree, rendering only those nodes 427 | # that are holes or have holes beneath them 428 | if not self.has_hole_children: 429 | return "" 430 | s = "" 431 | for child in self.children: 432 | if child.is_hole: 433 | s += child._render(render_holes=True) 434 | elif child.has_hole_children: 435 | # Holes exist in the compiled tree in two pieces: 436 | # The shapes of the holes themselves, (an object for which 437 | # obj.is_hole is True, and all its children) and the 438 | # transforms necessary to put that hole in place, which 439 | # are inherited from non-hole geometry. 440 | 441 | # Non-hole Intersections & differences can change (shrink) 442 | # the size of holes, and that shouldn't happen: an 443 | # intersection/difference with an empty space should be the 444 | # entirety of the empty space. 445 | # In fact, the intersection of two empty spaces should be 446 | # everything contained in both of them: their union. 447 | # So... replace all super-hole intersection/diff transforms 448 | # with union in the hole segment of the compiled tree. 449 | # And if you figure out a better way to explain this, 450 | # please, please do... because I think this works, but I 451 | # also think my rationale is shaky and imprecise. 452 | # -ETJ 19 Feb 2013 453 | s = s.replace("intersection", "union") 454 | s = s.replace("difference", "union") 455 | s += child._render_hole_children() 456 | if self.name in non_rendered_classes: 457 | pass 458 | else: 459 | s = self._render_str_no_children() + "{" + indent(s) + "\n}" 460 | return s 461 | 462 | def add(self, child): 463 | ''' 464 | if child is a single object, assume it's an OpenSCADObject and 465 | add it to self.children 466 | 467 | if child is a list, assume its members are all OpenSCADObjects and 468 | add them all to self.children 469 | ''' 470 | if isinstance(child, (list, tuple)): 471 | # __call__ passes us a list inside a tuple, but we only care 472 | # about the list, so skip single-member tuples containing lists 473 | if len(child) == 1 and isinstance(child[0], (list, tuple)): 474 | child = child[0] 475 | [self.add(c) for c in child] 476 | else: 477 | self.children.append(child) 478 | child.set_parent(self) 479 | return self 480 | 481 | def set_parent(self, parent): 482 | self.parent = parent 483 | 484 | def add_param(self, k, v): 485 | if k == '$fn': 486 | k = 'segments' 487 | self.params[k] = v 488 | return self 489 | 490 | def copy(self): 491 | ''' 492 | Provides a copy of this object and all children, 493 | but doesn't copy self.parent, meaning the new object belongs 494 | to a different tree 495 | Initialize an instance of this class with the same params 496 | that created self, the object being copied. 497 | ''' 498 | 499 | # Python can't handle an '$fn' argument, while openSCAD only wants 500 | # '$fn'. Swap back and forth as needed; the final renderer will 501 | # sort this out. 502 | if '$fn' in self.params: 503 | self.params['segments'] = self.params.pop('$fn') 504 | 505 | other = type(self)(**self.params) 506 | other.set_modifier(self.modifier) 507 | other.set_hole(self.is_hole) 508 | other.set_part_root(self.is_part_root) 509 | other.has_hole_children = self.has_hole_children 510 | for c in self.children: 511 | other.add(c.copy()) 512 | return other 513 | 514 | def __call__(self, *args): 515 | ''' 516 | Adds all objects in args to self. This enables OpenSCAD-like syntax, 517 | e.g.: 518 | union()( 519 | cube(), 520 | sphere() 521 | ) 522 | ''' 523 | return self.add(args) 524 | 525 | def __add__(self, x): 526 | ''' 527 | This makes u = a+b identical to: 528 | u = union()(a, b ) 529 | ''' 530 | return objects.union()(self, x) 531 | 532 | def __sub__(self, x): 533 | ''' 534 | This makes u = a - b identical to: 535 | u = difference()(a, b ) 536 | ''' 537 | return objects.difference()(self, x) 538 | 539 | def __mul__(self, x): 540 | ''' 541 | This makes u = a * b identical to: 542 | u = intersection()(a, b ) 543 | ''' 544 | return objects.intersection()(self, x) 545 | 546 | def _repr_png_(self): 547 | ''' 548 | Allow rich clients such as the IPython Notebook, to display the current 549 | OpenSCAD rendering of this object. 550 | ''' 551 | png_data = None 552 | tmp = tempfile.NamedTemporaryFile(suffix=".scad", delete=False) 553 | tmp_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False) 554 | try: 555 | scad_text = scad_render(self).encode("utf-8") 556 | tmp.write(scad_text) 557 | tmp.close() 558 | tmp_png.close() 559 | subprocess.Popen([ 560 | "openscad", 561 | "--preview", 562 | "-o", tmp_png.name, 563 | tmp.name 564 | ]).communicate() 565 | 566 | with open(tmp_png.name, "rb") as png: 567 | png_data = png.read() 568 | finally: 569 | os.unlink(tmp.name) 570 | os.unlink(tmp_png.name) 571 | 572 | return png_data 573 | 574 | 575 | class IncludedOpenSCADObject(OpenSCADObject): 576 | # Identical to OpenSCADObject, but each subclass of IncludedOpenSCADObject 577 | # represents imported scad code, so each instance needs to store the path 578 | # to the scad file it's included from. 579 | 580 | def __init__(self, name, params, include_file_path, use_not_include=False, **kwargs): 581 | self.include_file_path = self._get_include_path(include_file_path) 582 | 583 | if use_not_include: 584 | self.include_string = 'use <%s>\n' % self.include_file_path 585 | else: 586 | self.include_string = 'include <%s>\n' % self.include_file_path 587 | 588 | # Just pass any extra arguments straight on to OpenSCAD; it'll accept 589 | # them 590 | if kwargs: 591 | params.update(kwargs) 592 | 593 | OpenSCADObject.__init__(self, name, params) 594 | 595 | def _get_include_path(self, include_file_path): 596 | # Look through sys.path for anyplace we can find a valid file ending 597 | # in include_file_path. Return that absolute path 598 | if os.path.isabs(include_file_path) and os.path.isfile(include_file_path): 599 | return include_file_path 600 | else: 601 | for p in sys.path: 602 | whole_path = os.path.join(p, include_file_path) 603 | if os.path.isfile(whole_path): 604 | return os.path.abspath(whole_path) 605 | 606 | # No loadable SCAD file was found in sys.path. Raise an error 607 | raise ValueError("Unable to find included SCAD file: " 608 | "%(include_file_path)s in sys.path" % vars()) 609 | 610 | # now that we have the base class defined, we can do a circular import 611 | from . import objects 612 | 613 | def py2openscad(o): 614 | if type(o) == bool: 615 | return str(o).lower() 616 | if type(o) == float: 617 | return "%.10f" % o 618 | if type(o) == list or type(o) == tuple: 619 | s = "[" 620 | first = True 621 | for i in o: 622 | if not first: 623 | s += ", " 624 | first = False 625 | s += py2openscad(i) 626 | s += "]" 627 | return s 628 | if type(o) == str: 629 | return '"' + o + '"' 630 | return str(o) 631 | 632 | 633 | def indent(s): 634 | return s.replace("\n", "\n\t") 635 | -------------------------------------------------------------------------------- /solid/objects.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes for OpenSCAD builtins 3 | """ 4 | from .solidpython import OpenSCADObject 5 | from .solidpython import IncludedOpenSCADObject 6 | 7 | class polygon(OpenSCADObject): 8 | ''' 9 | Create a polygon with the specified points and paths. 10 | 11 | :param points: the list of points of the polygon 12 | :type points: sequence of 2 element sequences 13 | 14 | :param paths: Either a single vector, enumerating the point list, ie. the order to traverse the points, or, a vector of vectors, ie a list of point lists for each separate curve of the polygon. The latter is required if the polygon has holes. The parameter is optional and if omitted the points are assumed in order. (The 'pN' components of the *paths* vector are 0-indexed references to the elements of the *points* vector.) 15 | ''' 16 | def __init__(self, points, paths=None): 17 | if not paths: 18 | paths = [list(range(len(points)))] 19 | OpenSCADObject.__init__(self, 'polygon', 20 | {'points': points, 'paths': paths}) 21 | 22 | 23 | class circle(OpenSCADObject): 24 | ''' 25 | Creates a circle at the origin of the coordinate system. The argument 26 | name is optional. 27 | 28 | :param r: This is the radius of the circle. Default value is 1. 29 | :type r: number 30 | 31 | :param d: This is the diameter of the circle. Default value is 1. 32 | :type d: number 33 | 34 | :param segments: Number of fragments in 360 degrees. 35 | :type segments: int 36 | ''' 37 | def __init__(self, r=None, d=None, segments=None): 38 | OpenSCADObject.__init__(self, 'circle', 39 | {'r': r, 'd': d, 'segments': segments}) 40 | 41 | 42 | class square(OpenSCADObject): 43 | ''' 44 | Creates a square at the origin of the coordinate system. When center is 45 | True the square will be centered on the origin, otherwise it is created 46 | in the first quadrant. The argument names are optional if the arguments 47 | are given in the same order as specified in the parameters 48 | 49 | :param size: If a single number is given, the result will be a square with sides of that length. If a 2 value sequence is given, then the values will correspond to the lengths of the X and Y sides. Default value is 1. 50 | :type size: number or 2 value sequence 51 | 52 | :param center: This determines the positioning of the object. If True, object is centered at (0,0). Otherwise, the square is placed in the positive quadrant with one corner at (0,0). Defaults to False. 53 | :type center: boolean 54 | ''' 55 | def __init__(self, size=None, center=None): 56 | OpenSCADObject.__init__(self, 'square', 57 | {'size': size, 'center': center}) 58 | 59 | 60 | class sphere(OpenSCADObject): 61 | ''' 62 | Creates a sphere at the origin of the coordinate system. The argument 63 | name is optional. 64 | 65 | :param r: Radius of the sphere. 66 | :type r: number 67 | 68 | :param d: Diameter of the sphere. 69 | :type d: number 70 | 71 | :param segments: Resolution of the sphere 72 | :type segments: int 73 | ''' 74 | def __init__(self, r=None, d=None, segments=None): 75 | OpenSCADObject.__init__(self, 'sphere', 76 | {'r': r, 'd': d, 'segments': segments}) 77 | 78 | 79 | class cube(OpenSCADObject): 80 | ''' 81 | Creates a cube at the origin of the coordinate system. When center is 82 | True the cube will be centered on the origin, otherwise it is created in 83 | the first octant. The argument names are optional if the arguments are 84 | given in the same order as specified in the parameters 85 | 86 | :param size: If a single number is given, the result will be a cube with sides of that length. If a 3 value sequence is given, then the values will correspond to the lengths of the X, Y, and Z sides. Default value is 1. 87 | :type size: number or 3 value sequence 88 | 89 | :param center: This determines the positioning of the object. If True, object is centered at (0,0,0). Otherwise, the cube is placed in the positive quadrant with one corner at (0,0,0). Defaults to False 90 | :type center: boolean 91 | ''' 92 | def __init__(self, size=None, center=None): 93 | OpenSCADObject.__init__(self, 'cube', 94 | {'size': size, 'center': center}) 95 | 96 | 97 | class cylinder(OpenSCADObject): 98 | ''' 99 | Creates a cylinder or cone at the origin of the coordinate system. A 100 | single radius (r) makes a cylinder, two different radi (r1, r2) make a 101 | cone. 102 | 103 | :param h: This is the height of the cylinder. Default value is 1. 104 | :type h: number 105 | 106 | :param r: The radius of both top and bottom ends of the cylinder. Use this parameter if you want plain cylinder. Default value is 1. 107 | :type r: number 108 | 109 | :param r1: This is the radius of the cone on bottom end. Default value is 1. 110 | :type r1: number 111 | 112 | :param r2: This is the radius of the cone on top end. Default value is 1. 113 | :type r2: number 114 | 115 | :param d: The diameter of both top and bottom ends of the cylinder. Use this parameter if you want plain cylinder. Default value is 1. 116 | :type d: number 117 | 118 | :param d1: This is the diameter of the cone on bottom end. Default value is 1. 119 | :type d1: number 120 | 121 | :param d2: This is the diameter of the cone on top end. Default value is 1. 122 | :type d2: number 123 | 124 | :param center: If True will center the height of the cone/cylinder around the origin. Default is False, placing the base of the cylinder or r1 radius of cone at the origin. 125 | :type center: boolean 126 | 127 | :param segments: The fixed number of fragments to use. 128 | :type segments: int 129 | ''' 130 | def __init__(self, r=None, h=None, r1=None, r2=None, d=None, d1=None, 131 | d2=None, center=None, segments=None): 132 | OpenSCADObject.__init__(self, 'cylinder', 133 | {'r': r, 'h': h, 'r1': r1, 'r2': r2, 'd': d, 134 | 'd1': d1, 'd2': d2, 'center': center, 135 | 'segments': segments}) 136 | 137 | 138 | class polyhedron(OpenSCADObject): 139 | ''' 140 | Create a polyhedron with a list of points and a list of faces. The point 141 | list is all the vertices of the shape, the faces list is how the points 142 | relate to the surfaces of the polyhedron. 143 | 144 | *note: if your version of OpenSCAD is lower than 2014.03 replace "faces" 145 | with "triangles" in the below examples* 146 | 147 | :param points: sequence of points or vertices (each a 3 number sequence). 148 | 149 | :param triangles: (*deprecated in version 2014.03, use faces*) vector of point triplets (each a 3 number sequence). Each number is the 0-indexed point number from the point vector. 150 | 151 | :param faces: (*introduced in version 2014.03*) vector of point n-tuples with n >= 3. Each number is the 0-indexed point number from the point vector. That is, faces=[[0,1,4]] specifies a triangle made from the first, second, and fifth point listed in points. When referencing more than 3 points in a single tuple, the points must all be on the same plane. 152 | 153 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 154 | :type convexity: int 155 | ''' 156 | def __init__(self, points, faces, convexity=None, triangles=None): 157 | OpenSCADObject.__init__(self, 'polyhedron', 158 | {'points': points, 'faces': faces, 159 | 'convexity': convexity, 160 | 'triangles': triangles}) 161 | 162 | 163 | class union(OpenSCADObject): 164 | ''' 165 | Creates a union of all its child nodes. This is the **sum** of all 166 | children. 167 | ''' 168 | def __init__(self): 169 | OpenSCADObject.__init__(self, 'union', {}) 170 | 171 | 172 | class intersection(OpenSCADObject): 173 | ''' 174 | Creates the intersection of all child nodes. This keeps the 175 | **overlapping** portion 176 | ''' 177 | def __init__(self): 178 | OpenSCADObject.__init__(self, 'intersection', {}) 179 | 180 | 181 | class difference(OpenSCADObject): 182 | ''' 183 | Subtracts the 2nd (and all further) child nodes from the first one. 184 | ''' 185 | def __init__(self): 186 | OpenSCADObject.__init__(self, 'difference', {}) 187 | 188 | 189 | class hole(OpenSCADObject): 190 | def __init__(self): 191 | OpenSCADObject.__init__(self, 'hole', {}) 192 | self.set_hole(True) 193 | 194 | 195 | class part(OpenSCADObject): 196 | def __init__(self): 197 | OpenSCADObject.__init__(self, 'part', {}) 198 | self.set_part_root(True) 199 | 200 | 201 | class translate(OpenSCADObject): 202 | ''' 203 | Translates (moves) its child elements along the specified vector. 204 | 205 | :param v: X, Y and Z translation 206 | :type v: 3 value sequence 207 | ''' 208 | def __init__(self, v=None): 209 | OpenSCADObject.__init__(self, 'translate', {'v': v}) 210 | 211 | 212 | class scale(OpenSCADObject): 213 | ''' 214 | Scales its child elements using the specified vector. 215 | 216 | :param v: X, Y and Z scale factor 217 | :type v: 3 value sequence 218 | ''' 219 | def __init__(self, v=None): 220 | OpenSCADObject.__init__(self, 'scale', {'v': v}) 221 | 222 | 223 | class rotate(OpenSCADObject): 224 | ''' 225 | Rotates its child 'a' degrees about the origin of the coordinate system 226 | or around an arbitrary axis. 227 | 228 | :param a: degrees of rotation, or sequence for degrees of rotation in each of the X, Y and Z axis. 229 | :type a: number or 3 value sequence 230 | 231 | :param v: sequence specifying 0 or 1 to indicate which axis to rotate by 'a' degrees. Ignored if 'a' is a sequence. 232 | :type v: 3 value sequence 233 | ''' 234 | def __init__(self, a=None, v=None): 235 | OpenSCADObject.__init__(self, 'rotate', {'a': a, 'v': v}) 236 | 237 | 238 | class mirror(OpenSCADObject): 239 | ''' 240 | Mirrors the child element on a plane through the origin. 241 | 242 | :param v: the normal vector of a plane intersecting the origin through which to mirror the object. 243 | :type v: 3 number sequence 244 | 245 | ''' 246 | def __init__(self, v): 247 | OpenSCADObject.__init__(self, 'mirror', {'v': v}) 248 | 249 | 250 | class resize(OpenSCADObject): 251 | ''' 252 | Modify the size of the child object to match the given new size. 253 | 254 | :param newsize: X, Y and Z values 255 | :type newsize: 3 value sequence 256 | ''' 257 | def __init__(self, newsize): 258 | OpenSCADObject.__init__(self, 'resize', {'newsize': newsize}) 259 | 260 | 261 | class multmatrix(OpenSCADObject): 262 | ''' 263 | Multiplies the geometry of all child elements with the given 4x4 264 | transformation matrix. 265 | 266 | :param m: transformation matrix 267 | :type m: sequence of 4 sequences, each containing 4 numbers. 268 | ''' 269 | def __init__(self, m): 270 | OpenSCADObject.__init__(self, 'multmatrix', {'m': m}) 271 | 272 | 273 | class color(OpenSCADObject): 274 | ''' 275 | Displays the child elements using the specified RGB color + alpha value. 276 | This is only used for the F5 preview as CGAL and STL (F6) do not 277 | currently support color. The alpha value will default to 1.0 (opaque) if 278 | not specified. 279 | 280 | :param c: RGB color + alpha value. 281 | :type c: sequence of 3 or 4 numbers between 0 and 1 282 | ''' 283 | def __init__(self, c): 284 | OpenSCADObject.__init__(self, 'color', {'c': c}) 285 | 286 | 287 | class minkowski(OpenSCADObject): 288 | ''' 289 | Renders the `minkowski 290 | sum `__ 291 | of child nodes. 292 | ''' 293 | def __init__(self): 294 | OpenSCADObject.__init__(self, 'minkowski', {}) 295 | 296 | class offset(OpenSCADObject): 297 | ''' 298 | 299 | :param r: Amount to offset the polygon (rounded corners). When negative, 300 | the polygon is offset inwards. The parameter r specifies the radius 301 | that is used to generate rounded corners, using delta gives straight edges. 302 | :type r: number 303 | 304 | :param delta: Amount to offset the polygon (sharp corners). When negative, 305 | the polygon is offset inwards. The parameter r specifies the radius 306 | that is used to generate rounded corners, using delta gives straight edges. 307 | :type delta: number 308 | 309 | :param chamfer: When using the delta parameter, this flag defines if edges 310 | should be chamfered (cut off with a straight line) or not (extended to 311 | their intersection). 312 | :type chamfer: bool 313 | ''' 314 | def __init__(self, r=None, delta=None, chamfer=False): 315 | if r: 316 | kwargs = {'r':r} 317 | elif delta: 318 | kwargs = {'delta':delta, 'chamfer':chamfer} 319 | else: 320 | raise ValueError("offset(): Must supply r or delta") 321 | OpenSCADObject.__init__(self, 'offset', kwargs) 322 | 323 | class hull(OpenSCADObject): 324 | ''' 325 | Renders the `convex 326 | hull `__ 327 | of child nodes. 328 | ''' 329 | def __init__(self): 330 | OpenSCADObject.__init__(self, 'hull', {}) 331 | 332 | 333 | class render(OpenSCADObject): 334 | ''' 335 | Always calculate the CSG model for this tree (even in OpenCSG preview 336 | mode). 337 | 338 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 339 | :type convexity: int 340 | ''' 341 | def __init__(self, convexity=None): 342 | OpenSCADObject.__init__(self, 'render', {'convexity': convexity}) 343 | 344 | 345 | class linear_extrude(OpenSCADObject): 346 | ''' 347 | Linear Extrusion is a modeling operation that takes a 2D polygon as 348 | input and extends it in the third dimension. This way a 3D shape is 349 | created. 350 | 351 | :param height: the extrusion height. 352 | :type height: number 353 | 354 | :param center: determines if the object is centered on the Z-axis after extrusion. 355 | :type center: boolean 356 | 357 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 358 | :type convexity: int 359 | 360 | :param twist: Twist is the number of degrees of through which the shape is extruded. Setting to 360 will extrude through one revolution. The twist direction follows the left hand rule. 361 | :type twist: number 362 | 363 | :param slices: number of slices to extrude. Can be used to improve the output. 364 | :type slices: int 365 | 366 | :param scale: relative size of the top of the extrusion compared to the start 367 | :type scale: number 368 | 369 | ''' 370 | def __init__(self, height=None, center=None, convexity=None, twist=None, 371 | slices=None, scale=None): 372 | OpenSCADObject.__init__(self, 'linear_extrude', 373 | {'height': height, 'center': center, 374 | 'convexity': convexity, 'twist': twist, 375 | 'slices': slices, 'scale':scale}) 376 | 377 | 378 | class rotate_extrude(OpenSCADObject): 379 | ''' 380 | A rotational extrusion is a Linear Extrusion with a twist, literally. 381 | Unfortunately, it can not be used to produce a helix for screw threads 382 | as the 2D outline must be normal to the axis of rotation, ie they need 383 | to be flat in 2D space. 384 | 385 | The 2D shape needs to be either completely on the positive, or negative 386 | side (not recommended), of the X axis. It can touch the axis, i.e. zero, 387 | however if the shape crosses the X axis a warning will be shown in the 388 | console windows and the rotate\_extrude() will be ignored. If the shape 389 | is in the negative axis the faces will be inside-out, you probably don't 390 | want to do that; it may be fixed in the future. 391 | 392 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 393 | :type convexity: int 394 | 395 | :param segments: The fixed number of fragments to use. 396 | :type segments: int 397 | 398 | ''' 399 | def __init__(self, convexity=None, segments=None): 400 | OpenSCADObject.__init__(self, 'rotate_extrude', 401 | {'convexity': convexity, 'segments': segments}) 402 | 403 | 404 | class dxf_linear_extrude(OpenSCADObject): 405 | def __init__(self, file, layer=None, height=None, center=None, 406 | convexity=None, twist=None, slices=None): 407 | OpenSCADObject.__init__(self, 'dxf_linear_extrude', 408 | {'file': file, 'layer': layer, 409 | 'height': height, 'center': center, 410 | 'convexity': convexity, 'twist': twist, 411 | 'slices': slices}) 412 | 413 | 414 | class projection(OpenSCADObject): 415 | ''' 416 | Creates 2d shapes from 3d models, and export them to the dxf format. 417 | It works by projecting a 3D model to the (x,y) plane, with z at 0. 418 | 419 | :param cut: when True only points with z=0 will be considered (effectively cutting the object) When False points above and below the plane will be considered as well (creating a proper projection). 420 | :type cut: boolean 421 | ''' 422 | def __init__(self, cut=None): 423 | OpenSCADObject.__init__(self, 'projection', {'cut': cut}) 424 | 425 | 426 | class surface(OpenSCADObject): 427 | ''' 428 | Surface reads information from text or image files. 429 | 430 | :param file: The path to the file containing the heightmap data. 431 | :type file: string 432 | 433 | :param center: This determines the positioning of the generated object. If True, object is centered in X- and Y-axis. Otherwise, the object is placed in the positive quadrant. Defaults to False. 434 | :type center: boolean 435 | 436 | :param invert: Inverts how the color values of imported images are translated into height values. This has no effect when importing text data files. Defaults to False. 437 | :type invert: boolean 438 | 439 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 440 | :type convexity: int 441 | ''' 442 | def __init__(self, file, center=None, convexity=None, invert=None): 443 | OpenSCADObject.__init__(self, 'surface', 444 | {'file': file, 'center': center, 445 | 'convexity': convexity, 'invert': invert}) 446 | 447 | 448 | class text(OpenSCADObject): 449 | ''' 450 | Create text using fonts installed on the local system or provided as separate font file. 451 | 452 | :param text: The text to generate. 453 | :type text: string 454 | 455 | :param size: The generated text will have approximately an ascent of the given value (height above the baseline). Default is 10. Note that specific fonts will vary somewhat and may not fill the size specified exactly, usually slightly smaller. 456 | :type size: number 457 | 458 | :param font: The name of the font that should be used. This is not the name of the font file, but the logical font name (internally handled by the fontconfig library). A list of installed fonts can be obtained using the font list dialog (Help -> Font List). 459 | :type font: string 460 | 461 | :param halign: The horizontal alignment for the text. Possible values are "left", "center" and "right". Default is "left". 462 | :type halign: string 463 | 464 | :param valign: The vertical alignment for the text. Possible values are "top", "center", "baseline" and "bottom". Default is "baseline". 465 | :type valign: string 466 | 467 | :param spacing: Factor to increase/decrease the character spacing. The default value of 1 will result in the normal spacing for the font, giving a value greater than 1 will cause the letters to be spaced further apart. 468 | :type spacing: number 469 | 470 | :param direction: Direction of the text flow. Possible values are "ltr" (left-to-right), "rtl" (right-to-left), "ttb" (top-to-bottom) and "btt" (bottom-to-top). Default is "ltr". 471 | :type direction: string 472 | 473 | :param language: The language of the text. Default is "en". 474 | :type language: string 475 | 476 | :param script: The script of the text. Default is "latin". 477 | :type script: string 478 | 479 | :param segments: used for subdividing the curved path segments provided by freetype 480 | :type segments: int 481 | ''' 482 | def __init__(self, text, size=None, font=None, halign=None, valign=None, 483 | spacing=None, direction=None, language=None, script=None, 484 | segments=None): 485 | OpenSCADObject.__init__(self, 'text', 486 | {'text': text, 'size': size, 'font': font, 487 | 'halign': halign, 'valign': valign, 488 | 'spacing': spacing, 'direction': direction, 489 | 'language': language, 'script': script, 490 | 'segments': segments}) 491 | 492 | 493 | class child(OpenSCADObject): 494 | def __init__(self, index=None, vector=None, range=None): 495 | OpenSCADObject.__init__(self, 'child', 496 | {'index': index, 'vector': vector, 497 | 'range': range}) 498 | 499 | 500 | class children(OpenSCADObject): 501 | ''' 502 | The child nodes of the module instantiation can be accessed using the 503 | children() statement within the module. The number of module children 504 | can be accessed using the $children variable. 505 | 506 | :param index: select one child, at index value. Index start at 0 and should be less than or equal to $children-1. 507 | :type index: int 508 | 509 | :param vector: select children with index in vector. Index should be between 0 and $children-1. 510 | :type vector: sequence of int 511 | 512 | :param range: [:] or [::]. select children between to , incremented by (default 1). 513 | ''' 514 | def __init__(self, index=None, vector=None, range=None): 515 | OpenSCADObject.__init__(self, 'children', 516 | {'index': index, 'vector': vector, 517 | 'range': range}) 518 | 519 | 520 | class import_stl(OpenSCADObject): 521 | def __init__(self, file, origin=(0, 0), layer=None): 522 | OpenSCADObject.__init__(self, 'import', 523 | {'file': file, 'origin': origin, 524 | 'layer': layer}) 525 | 526 | 527 | class import_dxf(OpenSCADObject): 528 | def __init__(self, file, origin=(0, 0), layer=None): 529 | OpenSCADObject.__init__(self, 'import', 530 | {'file': file, 'origin': origin, 531 | 'layer': layer}) 532 | 533 | 534 | class import_(OpenSCADObject): 535 | ''' 536 | Imports a file for use in the current OpenSCAD model. OpenSCAD currently 537 | supports import of DXF and STL (both ASCII and Binary) files. 538 | 539 | :param file: path to the STL or DXF file. 540 | :type file: string 541 | 542 | :param convexity: The convexity parameter specifies the maximum number of front sides (back sides) a ray intersecting the object might penetrate. This parameter is only needed for correctly displaying the object in OpenCSG preview mode and has no effect on the polyhedron rendering. 543 | :type convexity: int 544 | ''' 545 | def __init__(self, file, origin=(0, 0), layer=None): 546 | OpenSCADObject.__init__(self, 'import', 547 | {'file': file, 'origin': origin, 548 | 'layer': layer}) 549 | 550 | 551 | class intersection_for(OpenSCADObject): 552 | ''' 553 | Iterate over the values in a vector or range and take an 554 | intersection of the contents. 555 | ''' 556 | def __init__(self, n): 557 | OpenSCADObject.__init__(self, 'intersection_for', {'n': n}) 558 | 559 | 560 | class assign(OpenSCADObject): 561 | def __init__(self): 562 | OpenSCADObject.__init__(self, 'assign', {}) 563 | 564 | # ================================ 565 | # = Modifier Convenience Methods = 566 | # ================================ 567 | def debug(openscad_obj): 568 | openscad_obj.set_modifier("#") 569 | return openscad_obj 570 | 571 | 572 | def background(openscad_obj): 573 | openscad_obj.set_modifier("%") 574 | return openscad_obj 575 | 576 | 577 | def root(openscad_obj): 578 | openscad_obj.set_modifier("!") 579 | return openscad_obj 580 | 581 | 582 | def disable(openscad_obj): 583 | openscad_obj.set_modifier("*") 584 | return openscad_obj 585 | 586 | 587 | # =============== 588 | # = Including OpenSCAD code = 589 | # =============== 590 | 591 | # use() & include() mimic OpenSCAD's use/include mechanics. 592 | # -- use() makes methods in scad_file_path.scad available to 593 | # be called. 594 | # --include() makes those methods available AND executes all code in 595 | # scad_file_path.scad, which may have side effects. 596 | # Unless you have a specific need, call use(). 597 | def use(scad_file_path, use_not_include=True): 598 | ''' 599 | Opens scad_file_path, parses it for all usable calls, 600 | and adds them to caller's namespace. 601 | ''' 602 | # These functions in solidpython are used here and only here; don't pollute 603 | # the global namespace with them 604 | from .solidpython import extract_callable_signatures 605 | from .solidpython import new_openscad_class_str 606 | from .solidpython import calling_module 607 | 608 | try: 609 | with open(scad_file_path) as module: 610 | contents = module.read() 611 | except Exception as e: 612 | raise Exception("Failed to import SCAD module '%(scad_file_path)s' " 613 | "with error: %(e)s " % vars()) 614 | 615 | # Once we have a list of all callables and arguments, dynamically 616 | # add OpenSCADObject subclasses for all callables to the calling module's 617 | # namespace. 618 | symbols_dicts = extract_callable_signatures(scad_file_path) 619 | 620 | for sd in symbols_dicts: 621 | class_str = new_openscad_class_str(sd['name'], sd['args'], sd['kwargs'], 622 | scad_file_path, use_not_include) 623 | # If this is called from 'include', we have to look deeper in the stack 624 | # to find the right module to add the new class to. 625 | stack_depth = 2 if use_not_include else 3 626 | exec(class_str, calling_module(stack_depth).__dict__) 627 | 628 | return True 629 | 630 | 631 | def include(scad_file_path): 632 | return use(scad_file_path, use_not_include=False) 633 | -------------------------------------------------------------------------------- /BoardBuilder.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/python 2 | 3 | import argparse 4 | import json 5 | import math 6 | import os 7 | import sys 8 | 9 | from solid import * 10 | from solid.utils import * 11 | 12 | class BoardBuilder: 13 | def __init__(self, kle_json, 14 | horizontal_pad = 0.0, 15 | vertical_pad = 0.0, 16 | corner_radius = 0.0, 17 | num_holes = 0, 18 | hole_diameter = 0.0, 19 | show_points = False, 20 | stabs = 'cherry', 21 | max_wall = 10.0, 22 | hole_side_count = -1, 23 | stab_vertical_adjustment = 0.0, 24 | stab_height_adjustment = 0.0): 25 | 26 | with open(kle_json, encoding='utf-8') as f: 27 | self.layout = json.loads(f.read()) 28 | 29 | self.min_x = 10000 30 | self.max_x = 0 31 | self.min_y = 10000 32 | self.max_y = 0 33 | 34 | self.show_points = show_points 35 | self.stabs = stabs 36 | self.stab_vertical_adjustment = stab_vertical_adjustment 37 | self.stab_height_adjustment = stab_height_adjustment 38 | 39 | self.corner_radius = corner_radius 40 | 41 | self.num_holes = num_holes 42 | self.hole_diameter = hole_diameter 43 | 44 | # Determine the left and right padding 45 | try: 46 | left_padding, right_padding = horizontal_pad.split(',') 47 | self.left_pad = float(left_padding) 48 | self.right_pad = float(right_padding) 49 | except: 50 | self.left_pad = self.right_pad = float(horizontal_pad) 51 | 52 | # Determine the top and bottom padding 53 | try: 54 | top_padding, bottom_padding = vertical_pad.split(',') 55 | self.top_pad = float(top_padding) 56 | self.bottom_pad = float(bottom_padding) 57 | except: 58 | self.top_pad = self.bottom_pad = float(vertical_pad) 59 | 60 | #Determine the number of sides to use for the screw holes 61 | if hole_side_count < 3: 62 | hole_side_count = 20 63 | self.hole_side_count = hole_side_count 64 | 65 | # Calculate mid-layer wall thicknesses 66 | max_wall_thickness = 10.0 67 | try: 68 | max_wall_thickness = float(max_wall) 69 | except: 70 | 71 | pads = [ self.left_pad, self.right_pad, self.top_pad, self.bottom_pad ] 72 | walls = filter(lambda x: x > 0.0, pads) 73 | 74 | if max_wall == 'min_pad': 75 | max_wall_thickness = min(walls) 76 | 77 | elif max_wall == 'max_pad': 78 | max_wall_thickness = max(walls) 79 | 80 | else: 81 | print("WARNING: Unknown value for max_wall parameter: {0}. Defaulting to {1}".format( 82 | max_wall, 83 | max_wall_thickness)) 84 | 85 | self.left_wall_thickness = min(max_wall_thickness, self.left_pad) 86 | self.right_wall_thickness = min(max_wall_thickness, self.right_pad) 87 | self.top_wall_thickness = min(max_wall_thickness, self.top_pad) 88 | self.bottom_wall_thickness = min(max_wall_thickness, self.bottom_pad) 89 | 90 | # Build the bottom plate after the top one because the plate dimensions are calculated 91 | # while building the top plate. 92 | # 93 | # TODO: Break out the top plate construction, and add parameters so that the order 94 | # of construction doesn't have any hidden dependencies and we don't have to 95 | # explicitly apply corners and such more than once. 96 | self.base_top_plate = self.build_base_top_plate() 97 | self.base_bottom_plate = self.build_base_bottom_plate() 98 | 99 | if (corner_radius > 0): 100 | self.base_top_plate = self.apply_corners(self.base_top_plate) 101 | self.base_bottom_plate = self.apply_corners(self.base_bottom_plate) 102 | 103 | if (self.num_holes > 3 and self.hole_diameter > 0): 104 | self.base_top_plate = self.apply_screw_holes(self.base_top_plate) 105 | self.base_bottom_plate = self.apply_screw_holes(self.base_bottom_plate) 106 | 107 | # Create any mid layers by subtracting stuff from the bottom plate. 108 | self.mid_layer_closed = self.build_mid_layers(self.base_bottom_plate) 109 | 110 | # TODO: Option to generate an open mid-layer? Consider: 111 | # 1. Default opening placement (middle of the top, etc?) 112 | # 2. Default size (USB recepticle sizes?) 113 | # 3. Args to control the above. 114 | 115 | # Create an optional material space-optimized representation of the mid-plate to give 116 | # the user the option of tightly packing multiple mid layers on a single drawing, if 117 | # they want to go to that level. 118 | self.mid_layer_closed_sectioned = self.build_sectioned_mid_layer(self.mid_layer_closed) 119 | 120 | def update_mins_maxes(self, points): 121 | for point in points: 122 | if self.min_x > point[0]: 123 | self.min_x = point[0] 124 | if self.max_x < point[0]: 125 | self.max_x = point[0] 126 | if self.min_y > point[1]: 127 | self.min_y = point[1] 128 | if self.max_y < point[1]: 129 | self.max_y = point[1] 130 | 131 | def apply_corners(self, plate): 132 | 133 | def build_corner(): 134 | return difference()( 135 | square(self.corner_radius*2, center=True), 136 | translate( [ self.corner_radius, self.corner_radius, 0 ] )( 137 | circle(r = self.corner_radius, segments = 80) 138 | ) 139 | ) 140 | 141 | return difference()( 142 | plate, 143 | build_corner(), 144 | translate( [ self.exterior_width, 0, 0 ] )( 145 | mirror( [ 1, 0, 0 ] )( 146 | build_corner() 147 | ) 148 | ), 149 | translate( [ self.exterior_width, self.exterior_height, 0 ] )( 150 | mirror( [ 1, 1, 0 ] )( 151 | build_corner() 152 | ) 153 | ), 154 | translate( [ 0, self.exterior_height, 0 ] )( 155 | mirror( [ 0, 1, 0 ] )( 156 | build_corner() 157 | ) 158 | ) 159 | ) 160 | 161 | def apply_screw_holes(self, plate): 162 | 163 | def build_screw_hole_row(y, row_wall_thickness, row_is_top): 164 | 165 | num_holes_in_row = int(self.num_holes / 2) 166 | holes = [] 167 | 168 | # Adjust the corner holes' so that their horizontal positions are dominated by the 169 | # thickest wall. 170 | start_x = max(self.left_wall_thickness, row_wall_thickness) / 2 171 | end_x_adjustment = max(self.right_wall_thickness, row_wall_thickness) / 2 172 | end_x = self.exterior_width - end_x_adjustment 173 | step_x = (end_x - start_x) / (num_holes_in_row - 1) 174 | 175 | # Get the hole radius 176 | hole_radius = self.hole_diameter / 2. 177 | 178 | # Also adjust the corner holes' vertical positions similarly. 179 | # TODO: Do something better than passing in a boolean to determine adjustment direction. 180 | start_y_adjustment = start_x if self.left_wall_thickness > row_wall_thickness else 0 181 | end_y_adjustment = end_x_adjustment if self.right_wall_thickness > row_wall_thickness else 0 182 | 183 | if row_is_top: 184 | start_y_adjustment = -start_y_adjustment 185 | end_y_adjustment = -end_y_adjustment 186 | 187 | # Manually add the adjusted start and end holes. 188 | holes.append( 189 | translate([ start_x, y + start_y_adjustment, 0 ])( 190 | circle(r = hole_radius, segments=self.hole_side_count) 191 | ) 192 | ) 193 | holes.append( 194 | translate([ end_x, y + end_y_adjustment, 0 ])( 195 | circle(r = hole_radius, segments=self.hole_side_count) 196 | ) 197 | ) 198 | 199 | # Now add all the others in between. 200 | x = start_x + step_x 201 | for h in range(num_holes_in_row - 2): 202 | holes.append( 203 | translate( [ x, y, 0 ] )( 204 | circle(r = hole_radius, segments=self.hole_side_count) 205 | ) 206 | ) 207 | 208 | x = x + step_x 209 | 210 | return holes 211 | 212 | # The caller already validated this, but let's check again just for maintenance insurance. 213 | if self.num_holes > 3: 214 | return difference()( 215 | plate, 216 | build_screw_hole_row( 217 | y = self.bottom_wall_thickness / 2, 218 | row_wall_thickness = self.bottom_wall_thickness, 219 | row_is_top = False 220 | ), 221 | build_screw_hole_row( 222 | y = self.exterior_height - self.top_wall_thickness / 2, 223 | row_wall_thickness = self.top_wall_thickness, 224 | row_is_top = True 225 | ) 226 | ) 227 | else: 228 | return plate 229 | 230 | def switch_hole(self, width_factor, height_factor, stab_style): 231 | 232 | def stab_geometry(): 233 | 234 | # Basic combined stab hole. Designed from Cherry specs: 235 | # 236 | # Metric: (offline as of 2018Nov25...) 237 | # http://cherryamericas.com/wp-content/uploads/2014/12/mx_cat.pdf 238 | # 239 | # Linked to from: http://cherryamericas.com/product/mx-series/ 240 | # 241 | # Imperial: 242 | # https://media.digikey.com/pdf/Data%20Sheets/Cherry%20PDFs/MX%20Series.pdf 243 | # 244 | # One real nice thing about CSG is that you can just "follow the lines" on a spec drawing 245 | # with ordinary translations. 246 | # 247 | def cherry_stab(): 248 | # Cut out the main rectangle of the Cherry stab frame. From its center: 249 | stab_hole = union()( 250 | translate( [ 0, -6.77, 0 ] )( 251 | translate( [ -3.325, 0, 0 ] )( 252 | square(size=[6.65, 12.3 ] ) 253 | ), 254 | # Bottom notch. 255 | translate( [-1.5, -1.2, 0] )( 256 | square(size=[3.0, 2 ]) 257 | ), 258 | ), 259 | # Side notch. Please note that the imperial spec drawing has an error. The side notch 260 | # guide line on the 1x2 doesn't actually go out to the side notch: It traces down to 261 | # the main stab rectangle corner instead. 262 | translate( [ 0, -0.5, 0 ] )( 263 | square(size=[4.2, 2.8 ] ) 264 | ) 265 | ) 266 | 267 | # But wait! There's a difference between the metric and imperial drawings for the cherry stab 268 | # cutout. In the metric drawing, the offset between the tops of the switch hole and the stab hole 269 | # comes out to 1.47mm. In imperial, it comes out to 1.2954mm. When installed in the metric hole, 270 | # the sliders are visibly closer to the top of the housing than they are when we reduce the offset 271 | # by 0.1mm... Particularly when DSA PBT and GMK caps are installed. This offset reduction puts us 272 | # roughly in the middle between the metric and imperial drawings. We're within tolerance for both 273 | # drawings, but we give ourselves some breathing room. 274 | return translate( [ 0, 0.1, 0 ] )( stab_hole ) 275 | 276 | # Fudging parameters allow for us to tweak the costar cutout. These parameters are a leftover feature 277 | # that I used while trying to figure out the following: 278 | # 279 | # The sticking and general lack of tactility for stabilized keys on previous boards made with this 280 | # script are because of the Costar inserts rubbing too hard against the "tops" of the clips. Most 281 | # Costar references call for the top of the clip cutout to have a vertical offset of -0.75 mm WRT the 282 | # top of the switch cutout, but that's also the point where keys seem to firmly stick... 283 | # 284 | # After much tuning and testing, dropping the offset to -0.55 produced the arguably "best" result. 285 | # The stickiness is completely gone, tactile response is good, though the typical Costar rattliness also 286 | # comes through. It's overall much better than a stabilizer that needs tons of tweaking, bending, and 287 | # reshaping to keep from sticking. 288 | # 289 | # Those numbers are preserved below for reference sake. Also, we expose the tuning parameters as 290 | # options in case there's a desire to override them again. 291 | 292 | def costar_stab(height_fudge=0, bottom_fudge=0): 293 | return translate( [ -1.65, -7.75 + 0.2 + bottom_fudge, 0 ] )( 294 | square(size=[3.3, 14 + height_fudge]) 295 | ) 296 | 297 | bottom_fudge = stab_style['bottom_fudge'] if 'bottom_fudge' in stab_style else self.stab_vertical_adjustment 298 | height_fudge = stab_style['height_fudge'] if 'height_fudge' in stab_style else self.stab_height_adjustment 299 | 300 | if stab_style['type'] == 'cherry': 301 | return cherry_stab() 302 | 303 | elif stab_style['type'] == 'costar': 304 | 305 | return costar_stab(bottom_fudge = bottom_fudge, 306 | height_fudge = height_fudge) 307 | 308 | elif stab_style['type'] == 'both': 309 | 310 | # Research results: combined stabs can be okay for costar, but are bad for Cherry because they 311 | # remove gripping surface for the built-in clip. Their use should be discouraged. 312 | 313 | bottom_fudge += -0.22 # This lowers the costar cutout to align with the bottom cherry notch. 314 | # Use height_fudge to adjust the costar clips' top offsets. 315 | # Or a Dremel. 316 | 317 | return union()( 318 | cherry_stab(), 319 | costar_stab(bottom_fudge = bottom_fudge, 320 | height_fudge = height_fudge) 321 | ) 322 | 323 | def build_stab(a, left=None, right=None): 324 | 325 | combined_stab = stab_geometry() 326 | 327 | if left == None and right == None: 328 | left = a / 2 329 | right = a / 2 330 | 331 | # The stab is a union of the right cutout, the left cutout, and a connecting rectangle, for Cherry- 332 | # style stabs. 333 | stab = union()( 334 | translate( [ right, 0, 0 ] )(combined_stab), 335 | translate( [ -left, 0, 0 ] )(mirror( [ 1, 0, 0 ] )(combined_stab)) 336 | ) 337 | 338 | if stab_style['type'] in ('both', 'cherry'): 339 | stab = union()( 340 | stab, 341 | translate( [ (right - left) / 2, 0, 0 ] )( 342 | square(size=[right+left, 4.6], center=True) 343 | ) 344 | ) 345 | 346 | # The 2u stab has an extra cutout. 347 | # See http://cherryamericas.com/wp-content/uploads/2014/12/mx_cat.pdf 348 | 349 | if a < 24: 350 | stab = union()( 351 | stab, 352 | translate( [ -11.9, -5.97, 0 ] )( 353 | square(size=[23.8, 10.7 ] ) 354 | ) 355 | ) 356 | 357 | return stab 358 | 359 | hole = square(size=[14, 14], center=True) 360 | 361 | if width_factor >= 2.0 or height_factor >= 2.0: 362 | 363 | # The spacebar stab spacing numbers came from: 364 | # https://deskthority.net/wiki/Space_bar_dimensions 365 | # 366 | # TODO: Add some means of specifying and generating spacebar mount style variants. 367 | 368 | if width_factor >= 8.0 or height_factor >= 8.0: 369 | 370 | stab = build_stab(133.35) # == 5.25 inches. Metric and imperial specs match. 371 | 372 | elif width_factor >= 7.0 or height_factor >= 7.0: 373 | 374 | stab = build_stab(114.3) # == 4.5 inches. Not given in metric spec. 375 | 376 | elif width_factor >= 6.25 or height_factor >= 6.25: 377 | 378 | stab = build_stab(100.0) 379 | 380 | #elif width_factor >= 6.0 or height_factor >= 6.0: 381 | 382 | # TODO: Deal with this asymmetrical case. 383 | 384 | elif width_factor >= 3.0 or height_factor >= 3.0: 385 | 386 | stab = build_stab(38.1) # == 1.5 inches. Metric and imperial specs match here... 387 | 388 | else: 389 | 390 | # Watch out! 391 | # The metric spec calls for 23.8. But in practice, that makes the inserts rub against 392 | # the housing, and even catch the clips and possibly stick with some caps! (I experienced 393 | # this with DSA, and GMK to a lesser extent) 394 | # stab = build_stab(23.8) 395 | # 396 | # The imperial spec calls for 0.94 inches, which comes out to 23.876, possibly large 397 | # enough to avoid the rubbing and clip catch. Here we go with 23.88 for margin, within 398 | # tolerance for both. 399 | # 400 | # NOTE: Speaking of the imperial spec, there's an error on the 1x2 drawing. See the 401 | # description in build_stab(). 402 | stab = build_stab(23.88) 403 | 404 | # Important note: Because the plate gets flipped at the end, we have to build 405 | # the geometry upside down! i.e. notice the mirror call 406 | 407 | hole = union()( 408 | hole, 409 | mirror( [ 0, 1, 0 ] )(stab) 410 | ) 411 | 412 | if height_factor > width_factor: 413 | 414 | hole = rotate( [ 0, 0, 90 ] )(hole) 415 | 416 | return hole 417 | 418 | def build_base_top_plate(self): 419 | key_hole_squares = [] 420 | 421 | rx = 0 422 | ry = 0 423 | r = 0 424 | 425 | cursor_x = 0 426 | cursor_y = 0 427 | 428 | standard_key_spacing = 19.05 # From an older Cherry spec. 429 | key_space_points = [] 430 | 431 | for row in self.layout: 432 | 433 | cursor_x = rx 434 | 435 | height_increment = standard_key_spacing 436 | next_key_width_factor = 1.0 437 | next_key_height_factor = 1.0 438 | skip_next = False 439 | next_stab = { 'type' : self.stabs } 440 | 441 | if type(row) == list: 442 | for e in row: 443 | 444 | # All this handling is from keyboard-layout-editor.com's generated JSON. 445 | # 446 | if type(e) == dict: 447 | if 'r' in e: # Rotation angle 448 | r = e['r'] 449 | if 'w' in e: # Forcing next key's unit width 450 | next_key_width_factor = e['w'] 451 | if 'h' in e: # Forcing next key's unit height 452 | next_key_height_factor = e['h'] 453 | if 'rx' in e: # Redefining the cursor's "reset" x coordinate. 454 | rx = e['rx'] * standard_key_spacing 455 | cursor_x = rx 456 | cursor_y = ry 457 | if 'ry' in e: # Redefining the cursor's "reset" y coordinate. 458 | ry = e['ry'] * standard_key_spacing 459 | cursor_x = rx 460 | cursor_y = ry 461 | if 'x' in e: # Forcing the cursor's x positioning relative to "here." 462 | cursor_x = cursor_x + (standard_key_spacing * e['x' ] ) 463 | if 'y' in e: # Forcing the cursor's y positioning relative to "here." 464 | cursor_y = cursor_y + (standard_key_spacing * e['y' ] ) 465 | if 'd' in e: # Next "key" is really a decal. Skip it. 466 | skip_next = True 467 | if 'bb_stab' in e: # BoardBuilder-specific wrinkle: force stabilizer style. 468 | next_stab = e['bb_stab'] 469 | 470 | elif skip_next: 471 | skip_next = False 472 | 473 | else: 474 | 475 | space_width = standard_key_spacing * next_key_width_factor 476 | space_height = standard_key_spacing * next_key_height_factor 477 | 478 | space_relative_x_center = space_width / 2 479 | space_relative_y_center = space_height / 2 480 | 481 | def trans2d(translation, point): 482 | return [translation[0] + point[0], 483 | translation[1] + point[1]] 484 | 485 | def rotate2d(degrees, point): 486 | rads = degrees * 3.14159 / 180.0 487 | return [point[0] * math.cos(rads) - point[1] * math.sin(rads), 488 | point[0] * math.sin(rads) + point[1] * math.cos(rads)] 489 | 490 | def apply_square(): 491 | # Apply the transformation on an origin-centered rectangle representing the whole key space. 492 | return [ apply_transform( [ -space_relative_x_center, -space_relative_y_center ] ), 493 | apply_transform( [ space_relative_x_center, -space_relative_y_center ] ), 494 | apply_transform( [ space_relative_x_center, space_relative_y_center ] ), 495 | apply_transform( [ -space_relative_x_center, space_relative_y_center ] )] 496 | 497 | if r != 0.0: 498 | 499 | def apply_transform(point): 500 | return trans2d( [ rx, ry], 501 | rotate2d(r, 502 | trans2d( [ -rx, -ry], 503 | trans2d( [ cursor_x + space_relative_x_center, cursor_y + space_relative_y_center], 504 | point 505 | ) 506 | ) 507 | ) 508 | ) 509 | 510 | transformed_square = apply_square() 511 | 512 | key_space_points.extend(transformed_square) 513 | 514 | self.update_mins_maxes(transformed_square) 515 | 516 | key_hole_squares.append( 517 | translate( [ rx, ry, 0 ] )( 518 | rotate( [ 0, 0, r ] )( 519 | translate( [ -rx, -ry, 0 ] )( 520 | translate( [ cursor_x + space_relative_x_center, cursor_y + space_relative_y_center, 0 ] )( 521 | self.switch_hole(next_key_width_factor, next_key_height_factor, next_stab) 522 | ) 523 | ) 524 | ) 525 | ) 526 | ) 527 | 528 | else: 529 | 530 | # Support a simplied, non-rotated set of transformations just to cut down on the size of 531 | # the generated scad file. 532 | 533 | def apply_transform(point): 534 | return trans2d( [ cursor_x + space_relative_x_center, cursor_y + space_relative_y_center], point) 535 | 536 | transformed_square = apply_square() 537 | 538 | key_space_points.extend(transformed_square) 539 | 540 | self.update_mins_maxes(transformed_square) 541 | 542 | key_hole_squares.append( 543 | translate( [ cursor_x + space_relative_x_center, cursor_y + space_relative_y_center, 0 ] )( 544 | self.switch_hole(next_key_width_factor, next_key_height_factor, next_stab) 545 | ) 546 | ) 547 | 548 | cursor_x = cursor_x + space_width 549 | 550 | next_key_width_factor = 1.0 551 | next_key_height_factor = 1.0 552 | next_stab = { 'type' : self.stabs } 553 | 554 | # In KLE, the per-row Y increment seems to be a constant 1u, unlike the X increment, which is the key's entire width 555 | cursor_y = cursor_y + height_increment 556 | 557 | self.interior_width = self.max_x - self.min_x 558 | self.interior_height = self.max_y - self.min_y 559 | 560 | self.exterior_width = self.interior_width + self.left_pad + self.right_pad 561 | self.exterior_height = self.interior_height + self.bottom_pad + self.top_pad 562 | 563 | # The KLE format assumes origin at upper-left, whereas OpenSCAD is origin at lower-left. The resulting geometry 564 | # thus needs to be flipped. Define a function to do that, as well as justify the geometry so that even with 565 | # rotated holes, keyspaces are justified onto the x and y axes. 566 | def transform_from_kle_geometry(geometry): 567 | return translate( [ 0, self.interior_height, 0 ] )( 568 | mirror( [ 0, 1, 0 ])( 569 | translate( [ -self.min_x, -self.min_y, 0 ] )( 570 | geometry 571 | ) 572 | ) 573 | ) 574 | 575 | # Save off the justified keyholes as a member to be rendered as a separate drawing, convenient for subtraction 576 | # from a custom plate designed elsewhere. 577 | self.holes = transform_from_kle_geometry( 578 | key_hole_squares 579 | ) 580 | 581 | # Take the padding into account when actually making the top plate itself. 582 | plate = difference()( 583 | square(size=[self.exterior_width, self.exterior_height ] ), 584 | translate([self.left_pad, self.bottom_pad, 0])( 585 | self.holes 586 | ) 587 | ) 588 | 589 | if self.show_points: 590 | point_collection = [ translate( [ p[0],p[1],1 ] )(circle(r=1, segments=20)) for p in key_space_points ] 591 | plate = union()( 592 | plate, 593 | color("red")( 594 | translate([self.left_pad, self.bottom_pad, 0])( 595 | transform_from_kle_geometry( 596 | point_collection 597 | ) 598 | ) 599 | ) 600 | ) 601 | 602 | return plate 603 | 604 | def build_base_bottom_plate(self): 605 | 606 | return square(size=[self.exterior_width, self.exterior_height ] ) 607 | 608 | def build_mid_layers(self, plate): 609 | 610 | # Interior rectangle is the rectangular bounding box of the all key holes. 611 | # For a conventional keyboard, this guarantees enough space for the switches. 612 | interior_rectangle = square(size=[ self.interior_width, self.interior_height ]) 613 | 614 | padding_rectangle = square( 615 | size=[ 616 | self.exterior_width - (self.left_wall_thickness + self.right_wall_thickness), 617 | self.exterior_height - (self.top_wall_thickness + self.bottom_wall_thickness) 618 | ] 619 | ) 620 | 621 | return difference()( 622 | plate, 623 | translate([ self.left_pad, self.bottom_pad, 0])( 624 | interior_rectangle 625 | ), 626 | translate([ self.left_wall_thickness, self.bottom_wall_thickness, 0])( 627 | padding_rectangle 628 | ) 629 | ) 630 | 631 | def build_sectioned_mid_layer(self, mid_layer): 632 | if mid_layer: 633 | 634 | half_height = self.exterior_height / 2 635 | half_width = self.exterior_width / 2 636 | 637 | # Now generate an optional space-optimized drawing for the mid-layer. This is meant as a cost-savings 638 | # convenience for packing as many parts as possible onto a single drawing. Because the resulting mid 639 | # layer is sectioned, ideally it should only be used with materials that are unlikely to warp. 640 | # 641 | # We could go off the deep end with smartly slicing up the mid-layer for space. Let's go with something 642 | # just good enough for now until we really must to go deeper. 643 | # 644 | # Slice off the given quadrant, translated to center, with the plate corner in the lower-left. 645 | def mid_layer_quadrant(horizontal_quadrant, vertical_quadrant): 646 | horizontal_offset = horizontal_quadrant * half_width 647 | vertical_offset = vertical_quadrant * half_height 648 | 649 | # Slice off the given quadrant, then translate centered to origin. 650 | section = translate([ -horizontal_offset - half_width/2, -vertical_offset - half_height/2, 0])( 651 | intersection()( 652 | mid_layer, 653 | translate([ horizontal_offset, vertical_offset, 0 ])( 654 | square(size=[ half_width, half_height ]) 655 | ) 656 | ) 657 | ) 658 | 659 | if horizontal_quadrant: 660 | section = mirror([1, 0, 0])(section) 661 | 662 | if vertical_quadrant: 663 | section = mirror([0, 1, 0])(section) 664 | 665 | return section 666 | 667 | mid_section_lower_left = mid_layer_quadrant(0, 0) 668 | mid_section_lower_right = mid_layer_quadrant(1, 0) 669 | mid_section_upper_left = mid_layer_quadrant(0, 1) 670 | mid_section_upper_right = mid_layer_quadrant(1, 1) 671 | 672 | space_optimized_mid_layer = union()( 673 | mid_section_lower_left, 674 | translate([ self.left_wall_thickness + 3, self.bottom_wall_thickness + 3 , 0])( 675 | mid_section_lower_right, 676 | translate([ self.right_wall_thickness + 3, self.bottom_wall_thickness + 3, 0])( 677 | mid_section_upper_left, 678 | translate([ self.left_wall_thickness + 3, self.top_wall_thickness + 3, 0])( 679 | mid_section_upper_right 680 | ) 681 | ) 682 | ) 683 | ) 684 | 685 | return space_optimized_mid_layer 686 | 687 | def render_top_plate(self, output_dir): 688 | scad_render_to_file(self.base_top_plate, os.path.join(output_dir, "top.scad"), include_orig_code=False) 689 | scad_render_to_file(self.holes, os.path.join(output_dir, "holes.scad"), include_orig_code=False) 690 | 691 | def render_bottom_plate(self, output_dir): 692 | scad_render_to_file(self.base_bottom_plate, os.path.join(output_dir, "bottom.scad"), include_orig_code=False) 693 | 694 | def render_mid_layers(self, output_dir): 695 | if self.mid_layer_closed: 696 | scad_render_to_file(self.mid_layer_closed, os.path.join(output_dir, "mid_closed.scad"), include_orig_code=False) 697 | 698 | if self.mid_layer_closed_sectioned: 699 | scad_render_to_file(self.mid_layer_closed_sectioned, os.path.join(output_dir, "mid_closed_sectioned.scad"), include_orig_code=False) 700 | 701 | 702 | #------------------------------------------------------------------------------ 703 | if __name__ == "__main__": 704 | 705 | parser = argparse.ArgumentParser( 706 | description = 'Generate OpenSCAD drawings from a keyboard-layout-editor JSON file.', 707 | formatter_class = argparse.ArgumentDefaultsHelpFormatter 708 | ) 709 | 710 | parser.add_argument('-j', '--json', type=str, default='', required=True, help="JSON file to load. Raw data download from keyboard-layout-editor.com.") 711 | parser.add_argument('-o', '--output_dir', type=str, default='.', help="Directory into which the resulting .scad files will be generated.") 712 | parser.add_argument('-s', '--stabs', choices=['both', 'cherry', 'costar'], default='cherry', help="Specify the style of stabilizers to generate.") 713 | parser.add_argument('-hp', '--horizontal_pad', type=str, default='0.0', help="Horizontal padding per side. Can also define left,right padding.") 714 | parser.add_argument('-vp', '--vertical_pad', type=str, default='0.0', help="Vertical padding per side. Can also define top,bottom padding.") 715 | parser.add_argument('-mw', '--max_wall', type=str, default='10.0', help="Max mid-layer wall thickness. Can also be 'min_pad' or 'max_pad'") 716 | parser.add_argument('-c', '--corner_radius', type=float, default=0.0, help="Corner radius.") 717 | parser.add_argument('-n', '--num_holes', type=int, default=0, help="Number of screw holes.") 718 | parser.add_argument('-hd', '--hole_diameter', type=float, default=0.0, help="Screw hole diameter.") 719 | parser.add_argument('-sp', '--show_points', action="store_true", help="Debug aid. Add floating red points for key space rectangles.") 720 | parser.add_argument('-hsc', '--hole_side_count', type=int, default=20, help="How many sides to put on the screw holes. 20 is good for circles, 6 would be hex.") 721 | 722 | parser.add_argument('-sva','--stab_vertical_adjustment', type=float, default=0.0, help="Adjust the vertical positioning of Costar stabs.") 723 | parser.add_argument('-sha','--stab_height_adjustment', type=float, default=0.0, help="Adjust the vertical size of Costar stabs.") 724 | 725 | args = parser.parse_args() 726 | 727 | board = BoardBuilder(args.json, 728 | args.horizontal_pad, 729 | args.vertical_pad, 730 | args.corner_radius, 731 | args.num_holes, 732 | args.hole_diameter, 733 | args.show_points, 734 | args.stabs, 735 | args.max_wall, 736 | args.hole_side_count, 737 | args.stab_vertical_adjustment, 738 | args.stab_height_adjustment) 739 | 740 | board.render_top_plate(args.output_dir) 741 | board.render_bottom_plate(args.output_dir) 742 | board.render_mid_layers(args.output_dir) 743 | --------------------------------------------------------------------------------