#!/usr/bin/env python3 # type: ignore # ************************************************************************** # Copyright (C) 2025, Paul Lutus * # * # This program is free software; you can redistribute it and/or modify * # it under the terms of the GNU General Public License as published by * # the Free Software Foundation; either version 2 of the License, or * # (at your option) any later version. * # * # This program is distributed in the hope that it will be useful, * # but WITHOUT ANY WARRANTY; without even the implied warranty of * # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # GNU General Public License for more details. * # * # You should have received a copy of the GNU General Public License * # along with this program; if not, write to the * # Free Software Foundation, Inc., * # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * # ************************************************************************** # this script displays results using # CQ-Editor if launched within it import cadquery as cq import copy import math, sys, os # a classic interpolation algorithm def ntrp(x, xa, xb, ya, yb): return (x - xa) * (yb - ya) / (xb - xa) + ya # create perforations like those in the classic # Roman dodecahedron, with decorative rings def make_perf(assembly, scale, N): # generate variery of radii circ_radius = ntrp(N, 0, 11, 17, 12) * scale assembly -= cq.Workplane("XY").circle(circ_radius).extrude(16 * scale) rings = cq.Workplane("XZ") for r in (2, 4, 6): rings += ( cq.Workplane("XZ") .moveTo((circ_radius) + r * scale) .circle(0.5 * scale) .revolve() ) return assembly.cut(rings) # build_poly: R = radius center to vertex, # scale: sets all polygon and peg proportions # S = sides, # N = dodecahedron number (0,11) # M sets perforation size def build_poly(show=True, single = False,pegs=True, R=45, scale=1,wall_val=2, sides=5,N=0): radians = math.pi / 180.0 degrees = 1.0 / radians wall = wall_val * scale # wall thickness mm # xv = polygon horizontal coordinate # yv = polygon vertical coordinate # R = circumscribed circle and vertex radius xv = R * math.sin(math.pi / sides) # diagonal length, x coordinate yv = R * math.cos(math.pi / sides) # y coordinate for polygon # ya = math.sqrt(1 + s * s) # construct primary polygon polygon_points = ( (0, 0), (xv, yv), (-xv, yv), (0, 0), ) section = cq.Workplane("XY").polyline(polygon_points).close().extrude(wall) # perforate polygon canter, add decorative rings section = make_perf(section, scale, N) da = 116.565 # outer angle between dodecahedron sides db = da/2 # half angle: 58.2825 # create mating-surface angled edge profile # with angle of 116.565 / 2 = 58.2825 rs = math.cos(db * radians) q = wall * 3 edge_points = ( (yv, 0), # initial +Y (yv, q), # +Y +Z (yv - q * rs,q), # -Y +Z (yv, 0), # initial +Y ) edge = ( cq.Workplane("YZ") .polyline(edge_points) .close() .extrude(xv * 2) .translate((-xv, 0,0)) ) section = section.cut(edge) #iw = yv - wall * math.cos(db * radians) # inside wall if pegs: # create ports at polygon corners to receive pegs tr = 4 * scale port_a = ( cq.Workplane("XY") .transformed( offset=(xv * 0.97, yv * 0.975, wall * 0.7), rotate=(da * 0.26, -20, 0) ) .cylinder(height=16, radius=tr) ) section = section.cut(port_a) port_b = ( cq.Workplane("XY") .transformed( offset=(-xv * 0.97, yv * 0.975, wall * 0.7), rotate=(da * 0.26, 20, 0) ) .cylinder(height=16, radius=tr) ) section = section.cut(port_b) center = 0.28 # absolute distance between support centers sphere_offset = xv * 0.105 # .05 #ph = wall * 3 junct_height = wall + scale * 4 iw = yv - junct_height * rs # Y arg ib = yv - wall * rs # surface height # create core of "male" connection side plug_box = ( cq.Workplane("XY") .box(xv * 0.21, scale * 5, scale * 7) .translate((-xv * center, iw - scale * 0.5, junct_height * 0.8)) .edges("|Z") .fillet(1 * scale) ) section.add(plug_box) #yval = iw - rs * 3 # create two "male" connection pivots pivot_sphere_a = ( cq.Workplane("XY") .sphere(scale * 1.4) .translate((-xv * center+sphere_offset,iw, junct_height)) ) section.add(pivot_sphere_a) # duplicate existing part pivot_sphere_b = pivot_sphere_a.translate((-sphere_offset * 2, 0, 0)) section.add(pivot_sphere_b) tang_separation = 1.3 y_off = scale * 15 # create two "female" support tangs left_tang = ( cq.Workplane("XY") .box(xv * 0.05, y_off, scale * 6.5) .translate( (-xv * -center+sphere_offset * tang_separation, ib - y_off * .5, wall + scale * 3.5) ) .edges("|X") .fillet( .8) ) section.add(left_tang) # duplicate existing part right_tang = left_tang.translate((-sphere_offset * tang_separation * 2, 0, 0)) section.add(right_tang) # this connects the free-turning # tangs to the polygon body rear_supp = ( cq.Workplane("XY") .box(xv * 0.34, yv * 0.1, scale * 8) .translate((xv * center, iw - y_off * 0.92, wall + scale * 3)) ) section.add(rear_supp) testing = False if testing: # alignment testing, do not delete spherec = ( cq.Workplane("XY") .sphere(scale * 1.4) .translate((s * 0.045, yv * 0.86, wall * 2)) ) section = section.add(spherec) sphered = spherec.translate((s * 0.21, 0, 0)) section = section.add(sphered) else: # create a cylindrical opening between the tangs cylinder = ( cq.Workplane("YZ") .circle(scale * 1.4) .extrude(24 * scale) .translate((-xv * center+sphere_offset * 2, iw, junct_height)) ) section = section.cut(cylinder) # workplane for results polygon = cq.Workplane("XY") # generate N polygons # sides = N angle = 0 step = 360 / sides top = (sides,1)[single] for i in range(top): polygon.add(section.rotate((0, 0, 0), (0, 0, 1), angle)) angle += step if show: show_results(polygon) else: print(f" rendering polygon {N:3}") sys.stdout.flush() return polygon # this creates a decorative peg for the vertex ports # a dodecahedron requires 20 # warning: this is a difficult fit. # The dimensions require a lot of tuning # to snugly fit into the dodecahedron's ports def build_peg_array(R, scale, wall): # shaft_radius may require some tuning # to acquire a snug fit # and these need to be printed upside-down # to prevent separation from the print bed shaft_radius = 4.25 * scale sphere_radius = 8 * scale stem1 = (5+wall) * scale stem2 = stem1 + sphere_radius shaft = cq.Workplane("XY").circle(shaft_radius).extrude(stem2) sphere = cq.Workplane("XY").sphere(sphere_radius).translate((0, 0, stem2)) # this provides a way to lock the peg in place bump = ( cq.Workplane("XY") .circle(4.7 * scale) .extrude(4 * scale) .edges() .fillet(1) .translate((0, 0, 1)) ) divider = cq.Workplane("XY").box(1 * scale, shaft_radius * 4, stem2 * 1.4) # peg = (sphere + shaft + torus - divider).translate((0,0,0)) peg = sphere + shaft + bump - divider #show_results(peg) # now duplicate pegs 20x on base for printing base = cq.Workplane("XY").box(scale * 120, scale * 100, scale * 0.5) # post = cq.Workplane("XY").circle(1.5 * scale).extrude(6 * scale) for y in range(4): yp = (y - 1.5) * 24 * scale for x in range(5): xp = (x - 2) * 24 * scale # posta = post + peg.translate((0, 0,0)) base = base + peg.translate((xp, yp, 0)) show_results(base) return peg, base # return peg def show_results(item): try: show_object(item) except: print(" The show_object() function only works in CQ-Editor.") def main(mode): # option to provide vertex openings for decorative pegs pegs = False show = True # this applies to CQ-editor # radius: distance between center and vertex, mm R = 45 # default value # maintain overall scale proportional to R, # the center to vertex distance in mm scale = R / 45 # sides sides = 5 wall = 2 # wall thickness mm match mode: case 0: # create test items print("Creating test items:") polygon = build_poly(show,False,pegs, R, scale,wall, sides,N=0) polygon.export(f"polygon_full_{sides}_sides.stl") polygon = build_poly(show, True,pegs, R, scale,wall, sides,N=0) polygon.export(f"polygon_slice_{sides}_sides.stl") case 1: # create the full set 12 polygons print("Creating full dodecahedron set:") dest_dir = "printables" if not os.path.isdir(dest_dir): os.mkdir(dest_dir) for N in range(12): polygon = build_poly(show, False,pegs, R, scale,wall, sides,N) print(f"Saving item {N} ...") polygon.export(f"{dest_dir}/dodecahedron_{N:03}.stl") case 2: # create array of decorative pegs if pegs: print("Creating peg array:") peg, base = build_peg_array(R, scale, wall) peg.export(f"peg_prototype.stl") base.export(f"peg_base_array.stl") print("Done.") # if __name__ == "__main__": # this doesn't work in CQ-editor main(0) # create test items #main(1) # create a full set of 12 polygons #main(2) # create peg prototype and array for printing