#! /usr/bin/env python ''' Generates Inkscape SVG file containing box components needed to laser cut a tabbed construction box taking kerf and clearance into account Original Author -- 2011 elliot white elliot@twot.eu Forked -- 2013 Reid Borsuk reid.borsuk@live.com Updated for 0.91 2016 Maren Hachmann marenhachmann@yahoo.com 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 3 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, see . ''' __version__ = "0.8rb" import sys,inkex,simplestyle,gettext,math _ = gettext.gettext def drawS(XYstring): # Draw lines from a list name='part' style = { 'stroke': '#000000', 'fill': 'none' } drw = {'style':simplestyle.formatStyle(style),inkex.addNS('label','inkscape'):name,'d':XYstring} inkex.etree.SubElement(parent, inkex.addNS('path','svg'), drw ) return def draw_SVG_ellipse((centerx, centery), (radiusx, radiusy), start_end): style = { 'stroke' : '#000000', 'fill' : 'none' } ell_attribs = {'style':simplestyle.formatStyle(style), inkex.addNS('cx','sodipodi') :str(centerx), inkex.addNS('cy','sodipodi') :str(centery), inkex.addNS('rx','sodipodi') :str(radiusx), inkex.addNS('ry','sodipodi') :str(radiusy), inkex.addNS('start','sodipodi') :str(start_end[0]), inkex.addNS('end','sodipodi') :str(start_end[1]), inkex.addNS('open','sodipodi') :'true', #all ellipse sectors we will draw are open inkex.addNS('type','sodipodi') :'arc', 'transform' :'' } ell = inkex.etree.SubElement(parent, inkex.addNS('path','svg'), ell_attribs ) #draw an SVG line segment between the given (raw) points def draw_SVG_line( (x1, y1), (x2, y2), parent): style = { 'stroke': '#000000', 'fill': 'none' } line_attribs = {'style' : simplestyle.formatStyle(style), 'd' : 'M '+str(x1)+','+str(y1)+' L '+str(x2)+','+str(y2)} line = inkex.etree.SubElement(parent, inkex.addNS('path','svg'), line_attribs ) def EllipseCircumference(a, b): """ Compute the circumference of an ellipse with semi-axes a and b. Require a >= 0 and b >= 0. Relative accuracy is about 0.5^53. """ import math x, y = max(a, b), min(a, b) digits = 53; tol = math.sqrt(math.pow(0.5, digits)) if digits * y < tol * x: return 4 * x s = 0; m = 1 while x - y > tol * y: x, y = 0.5 * (x + y), math.sqrt(x * y) m *= 2; s += m * math.pow(x - y, 2) return math.pi * (math.pow(a + b, 2) - s) / (x + y) """ Gives you a list of points that make up a box. Returns string suitable for input to drawS """ def box((sx, sy),(ex, ey), leaveLeftSideOpen = False): s=[] s='M '+str(sx)+','+str(sy)+' ' s+='L '+str(ex)+','+str(sy)+' ' s+='L '+str(ex)+','+str(ey)+' ' s+='L '+str(sx)+','+str(ey)+' ' if not leaveLeftSideOpen: s+='L '+str(sx)+','+str(sy)+' ' return s """ Side function is used to render any of the sides so needs all this functionality: isLongSide -- long sides without tabs (for cover), truncate -- partial sides for the elipse gap -- extend the tabs on the curved side for ease of movement thumbTab -- Render individual boxes for slots instead of one continuous line isTab is used to specify the male/female designation for a side so they mesh properly. Otherwise the tabs would be in the same spot for opposing sides, instead of interleaved. Returns a list of lines to draw. """ def side((rx,ry),(sox,soy),(eox,eoy),tabVec,length,(dirx,diry),isTab,isLongSide,truncate = False, gap = False, thumbTab = False): # root startOffset endOffset tabVec length direction isTab #Long side length= length+((inkex.pi*(length/2))/4 tmpLength = 0 correctionLocal = correction if gap: correctionLocal = (correction) if isLongSide > 0: tmpLength = length length = isLongSide divs=int(length/nomTab) # divisions if not divs%2: divs-=1 # make divs odd if isLongSide < 0: divs = 1 divs=float(divs) tabs=(divs-1)/2 # tabs for side if isLongSide < 0: divs = 1 tabWidth = length gapWidth = 0 elif equalTabs: gapWidth=tabWidth=length/divs else: tabWidth=nomTab gapWidth=(length-tabs*nomTab)/(divs-tabs) if isTab: # kerf correction gapWidth-=correctionLocal tabWidth+=correctionLocal first=correctionLocal/2 else: gapWidth+=correctionLocal tabWidth-=correctionLocal first=-correctionLocal/2 s=[] firstVec=0; secondVec=tabVec if gap: secondVec *= 2 dirxN=0 if dirx else 1 # used to select operation on x or y diryN=0 if diry else 1 (Vx,Vy)=(rx+sox*thickness,ry+soy*thickness) s='M '+str(Vx)+','+str(Vy)+' ' if dirxN: Vy=ry # set correct line start if diryN: Vx=rx if isLongSide > 0: #LongSide is a side without tabs for a portion. length = tmpLength divs=int((Z/2)/nomTab) if not divs%2: divs-=1 divs = float(divs) # generate line as tab or hole using: # last co-ord:Vx,Vy ; tab dir:tabVec ; direction:dirx,diry ; thickness:thickness # divisions:divs ; gap width:gapWidth ; tab width:tabWidth for n in range(1,int(divs)): if n%2: Vx=Vx+dirx*gapWidth+dirxN*firstVec+first*dirx Vy=Vy+diry*gapWidth+diryN*firstVec+first*diry s+='L '+str(Vx)+','+str(Vy)+' ' Vx=Vx+dirxN*secondVec Vy=Vy+diryN*secondVec s+='L '+str(Vx)+','+str(Vy)+' ' else: Vxs = Vx Vys = Vy Vx=Vx+dirx*tabWidth+dirxN*firstVec Vy=Vy+diry*tabWidth+diryN*firstVec s+='L '+str(Vx)+','+str(Vy)+' ' Vx=Vx+dirxN*secondVec Vy=Vy+diryN*secondVec s+='L '+str(Vx)+','+str(Vy)+' ' if thumbTab: drawS(box((Vxs,Vys),(Vx,Vy))) (secondVec,firstVec)=(-secondVec,-firstVec) # swap tab direction first=0 if not truncate: s+='L '+str(rx+eox*thickness+dirx*length)+','+str(ry+eoy*thickness+diry*length)+' ' else: #Truncate specifies that a side is incomplete in preperation for a curve s+='L '+str(rx+eox*thickness+dirx*(length/2))+','+str(ry+eoy*thickness+diry*(length/2))+' ' return s #God class. Makes poor design, but not much object oriented in this guy... class LivingHinge(inkex.Effect): def __init__(self): # Call the base class constructor. inkex.Effect.__init__(self) # Define options self.OptionParser.add_option('--unit',action='store',type='string', dest='unit',default='mm',help='Measure Units') self.OptionParser.add_option('--inside',action='store',type='int', dest='inside',default=0,help='Int/Ext Dimension') self.OptionParser.add_option('--length',action='store',type='float', dest='length',default=100,help='Length of Box') self.OptionParser.add_option('--width',action='store',type='float', dest='width',default=100,help='Width of Box') self.OptionParser.add_option('--depth',action='store',type='float', dest='height',default=100,help='Height of Box') self.OptionParser.add_option('--tab',action='store',type='float', dest='tab',default=25,help='Nominal Tab Width') self.OptionParser.add_option('--equal',action='store',type='int', dest='equal',default=0,help='Equal/Prop Tabs') self.OptionParser.add_option('--thickness',action='store',type='float', dest='thickness',default=10,help='Thickness of Material') self.OptionParser.add_option('--kerf',action='store',type='float', dest='kerf',default=0.5,help='Kerf (width) of cut') self.OptionParser.add_option('--clearance',action='store',type='float', dest='clearance',default=0.01,help='Clearance of joints') self.OptionParser.add_option('--style',action='store',type='int', dest='style',default=25,help='Layout/Style') self.OptionParser.add_option('--spacing',action='store',type='float', dest='spacing',default=25,help='Part Spacing') self.OptionParser.add_option('--hingeOpt',action='store',type='int', dest='hingeOpt',default=0,help='Hinge type') self.OptionParser.add_option('--hingeThick',action='store',type='float', dest='hingeThick',default=0,help='Hinge thickness') self.OptionParser.add_option('--thumbTab',action='store',type='string', dest='thumbTab',default=0,help='Add a thumb tab') """ Traditional multi-slit design. Sx, Sy : Start X, Y (pixels, not user units) Ex, Ey : End X, Y (pixels, not user units) space : gap between slots in the X direction, in user specified units (IE: wood between two rows of slots) solidGap : gap between slots in the Y direction, in user specified units (IE: how much wood is left between 2 or 3 cuts) """ def livingHinge2(self, (Sx, Sy), (Ex, Ey), space = 2, solidGap = 4): space = self.unittouu( str(space) + unit ) solidGap = self.unittouu( str(solidGap) + unit ) Sy += thickness Ey -= thickness height = Ey - Sy width = Ex - Sx # inkex.debug(width) horizontalSlots = int(round(width / space)) # inkex.debug(horizontalSlots) if horizontalSlots % 2 and horizontalSlots != 1: horizontalSlots-=1 # make it even so you end with an interior slot # inkex.debug(horizontalSlots) space = width / horizontalSlots grp_name = 'Living Hinge' grp_attribs = {inkex.addNS('label','inkscape'):grp_name } grp = inkex.etree.SubElement(parent, 'g', grp_attribs)#the group to put everything in for n in range(0,horizontalSlots+1): if n%2: #odd, exterior slot (slot should go all the way to the part edge) draw_SVG_line((Sx + (space * n), Sy), (Sx + (space * n), Sy+(height/4)-(solidGap/2)), grp) draw_SVG_line((Sx + (space * n), Sy+(height/4)+(solidGap/2)), (Sx + (space * n), Ey-(height/4)-(solidGap/2)), grp) draw_SVG_line((Sx + (space * n), Ey-(height/4)+(solidGap/2)), (Sx + (space * n), Ey), grp) else: #even, interior slot (slot shoud not touch edge of part) draw_SVG_line((Sx + (space * n), Sy+solidGap), (Sx + (space * n), Sy+(height/2)-(solidGap/2)), grp) draw_SVG_line((Sx + (space * n), Ey-(height/2)+(solidGap/2)), (Sx + (space * n), (Ey-solidGap)), grp) """ The sprial based designs are built from multiple calls of this function. Sx, Sy : Start X, Y (pixels, not user units) Ex, Ey : End X, Y (pixels, not user units) reverse : specifies the spin of the spiral (1 = outer spiral is counterclockwise, -1 otherwise) space : gap between slots, in user specified units (IE: how thick the wood remainder is) """ def livingHinge3(self, (Sx, Sy), (Ex, Ey), reverse = 1, space = 2): space = self.unittouu( str(space) + unit ) height = (Ey - Sy) width = (Ex - Sx) horizontalSlots = int(math.floor(height / (space))) if not horizontalSlots%2: horizontalSlots-=1 # make it odd otherwise the below division will result in an outer cut too thin space = (height / horizontalSlots) horizontalSlots = int(round(horizontalSlots * 1/2)) #We do 2 passes per render, so divide slots requirement in half grp_name = 'Living Hinge' grp_attribs = {inkex.addNS('label','inkscape'):grp_name } grp = inkex.etree.SubElement(parent, 'g', grp_attribs)#the group to put everything in centerX = Sx + (width/2) centerY = Sy + (height/2) for n in range(0,horizontalSlots): newX = (((space/2) + (space*n)) * reverse) draw_SVG_line(((centerX - newX), centerY + (space/2) + (space * n)), ((centerX - newX ), centerY - (space * 1.5) - (space * n)), grp) if horizontalSlots - 1 != n: #Last line in center should be omited draw_SVG_line(((centerX - (space + (space/2 * -reverse)) - (space*n) ), centerY - (space * 1.5) - (space * n)), ((centerX + (space + (space/2 * reverse)) + (space*n) ), centerY - (space * 1.5) - (space * n)), grp) draw_SVG_line(((centerX + newX ), centerY - (space/2) - (space * n)), ((centerX + newX ), centerY + (space * 1.5) + (space * n)), grp) if horizontalSlots - 1 != n: #Last line in center should be omited draw_SVG_line(((centerX + (space + (space/2 * -reverse)) + (space*n) ), centerY + (space * 1.5) + (space * n)), ((centerX - (space + (space/2 * reverse)) - (space*n) ), centerY + (space * 1.5) + (space * n)), grp) """ The snake based designs are built from multiple calls of this function. Sx, Sy : Start X, Y (pixels, not user units) Ex, Ey : End X, Y (pixels, not user units) rotate : False means the traditional flexable design (cuts are prependuclar to long sides). True rotates 90 degrees. mirror : mirror inverts the left and right slots, used for inverting during double design space : gap between adjecent slots, in user specified units (IE: wood between two rows of slots, X if rotate is false, Y if true) solidGap : gap between slot and edge, in user specified units (IE: how much wood is left between cut and edge, Y if rotate is false, X if true) """ def livingHinge4(self, (Sx, Sy), (Ex, Ey), rotate = False, mirror = 0, space = 2, solidGap = 5): space = self.unittouu( str(space) + unit ) solidGap = self.unittouu( str(solidGap) + unit ) Sy += thickness Ey -= thickness height = Ey - Sy width = Ex - Sx if not rotate: horizontalSlots = int(round(width / space)) space = width / horizontalSlots skew = 1 #Paint extra lines at the start and end because in this direction there are no existing lines already else: horizontalSlots = int(round(height / space)) if not horizontalSlots%2: horizontalSlots-=1 #make sure we always end on the same side, otherwise we'll cut off the last tooh space = height / horizontalSlots skew = 0 #Don't paint the first and last lines, as they're on the cut already, and double cuts on a laser are messy grp_name = 'Living Hinge' grp_attribs = {inkex.addNS('label','inkscape'):grp_name } grp = inkex.etree.SubElement(parent, 'g', grp_attribs)#the group to put everything in for n in range(1 - skew,horizontalSlots + skew): if not rotate: if (n+mirror)%2: draw_SVG_line((Sx + (space * n), Sy + solidGap), (Sx + (space * n), Ey), grp) else: draw_SVG_line((Sx + (space * n), Sy), (Sx + (space * n), Ey - solidGap), grp) else: if (n+mirror)%2: draw_SVG_line((Sx + solidGap, Sy + (space * n)), (Ex, Sy + (space * n)), grp) else: draw_SVG_line((Sx, Sy + (space * n)), (Ex - solidGap, Sy + (space * n)), grp) if rotate and not mirror: draw_SVG_line((Sx, Sy), (Sx, Ey - space), grp) draw_SVG_line((Ex, Sy + space), (Ex, Ey), grp) elif mirror: draw_SVG_line((Sx, Sy + space), (Sx, Ey), grp) draw_SVG_line((Ex, Sy), (Ex, Ey - space), grp) def effect(self): global parent,nomTab,equalTabs,thickness,correction, Z, unit # Get access to main SVG document element and get its dimensions. svg = self.document.getroot() # Get the attibutes: widthDoc = self.unittouu(svg.get('width')) heightDoc = self.unittouu(svg.get('height')) # Create a new layer. layer = inkex.etree.SubElement(svg, 'g') layer.set(inkex.addNS('label', 'inkscape'), 'newlayer') layer.set(inkex.addNS('groupmode', 'inkscape'), 'layer') parent=self.current_layer # Get script's option values. unit=self.options.unit inside=self.options.inside X = self.unittouu( str(self.options.length) + unit ) Y = self.unittouu( str(self.options.width) + unit ) Z = self.unittouu( str(self.options.height) + unit ) thickness = self.unittouu( str(self.options.thickness) + unit ) nomTab = self.unittouu( str(self.options.tab) + unit ) equalTabs=self.options.equal kerf = self.unittouu( str(self.options.kerf) + unit ) clearance = self.unittouu( str(self.options.clearance) + unit ) layout=self.options.style spacing = self.unittouu( str(self.options.spacing) + unit ) ring = 1 hingeOpt = self.options.hingeOpt hingeThick = self.options.hingeThick thumbTab = self.options.thumbTab if inside: # if inside dimension selected correct values to outside dimension X+=thickness*2 Y+=thickness*2 Z+=thickness*2 correction=kerf-clearance # check input values mainly to avoid python errors # TODO restrict values to *correct* solutions # TODO -- Do what the origial author suggested I do. QUALITY! error=0 if min(X,Y,Z)==0: inkex.errormsg(_('Error: Dimensions must be non zero')) error=1 if max(X,Y,Z)>max(widthDoc,heightDoc)*10: # crude test inkex.errormsg(_('Error: Dimensions Too Large')) error=1 if min(X,Y,Z)<3*nomTab: inkex.errormsg(_('Error: Tab size too large')) error=1 if nomTabmin(X,Y,Z)/3: # crude test inkex.errormsg(_('Error: Material too thick')) error=1 if correction>min(X,Y,Z)/3: # crude test inkex.errormsg(_('Error: Kerf/Clearence too large')) error=1 if spacing>max(X,Y,Z)*10: # crude test inkex.errormsg(_('Error: Spacing too large')) error=1 if spacing 0=holes 1=tabs if layout==0: # Diagramatic Layout TRBL pieces=[ #center low row [(2,0,0,1),(3,0,1,1),X,Z,0b1000,-2], #left middle row [(1,0,0,0),(2,0,0,1),Z,Y,0b1111,0], #center middle row [(2,0,0,1),(2,0,0,1),X,Y,0b0000,0], #right middle row [(3,1,0,1),(2,0,0,1),Z+(EllipseCircumference(X/2, Z/2)/4)+thickness,Y,0b1011,1], #center top row [(2,0,0,1),(1,0,0,0),X,Z,0b0010,-1]] elif layout==1: # Inline(compact) Layout pieces=[#Base [(1,0,0,0),(1,0,0,0),X,Y,0b0000,0], #Front panel [(2,1,0,0),(1,0,0,0),Z,Y,0b1111,0], #Sides with curves [(3,1,0,1),(1,0,0,0),X,Z,0b1000,-2], [(4,2,0,1),(1,0,0,0),X,Z,0b0010,-1], #Long piece w/ hinge [(5,3,0,1),(1,0,0,0),Z+(EllipseCircumference(X/2, Z/2)/4)+thickness,Y,0b1011,1] ] for piece in pieces: # generate and draw each piece of the box (xs,xx,xy,xz)=piece[0] (ys,yx,yy,yz)=piece[1] x=xs*spacing+xx*X+xy*Y+xz*Z # root x co-ord for piece y=ys*spacing+yx*X+yy*Y+yz*Z # root y co-ord for piece dx=piece[2] dy=piece[3] tabs=piece[4] a=tabs>>3&1; b=tabs>>2&1; c=tabs>>1&1; d=tabs&1 # extract tab status for each side. It's a nasty packed binary flag format, but I'm not fixing it now. longSide = 0 shortSide = 0 skew = 0 if piece[5] == 1: longSide = Z elif piece[5] < 0: shortSide = Z # generate and draw the sides of each piece if piece[5] != -1: drawS(side((x,y),(d,a),(-b,a),-thickness if a else thickness,dx,(1,0),a,longSide)) # side a (top) else: drawS(side((x,y),(d,a),(-b,a),-thickness if a else thickness,dx/2,(1,0),a,-1)) # side a (top) when the top participates in a curve if piece[5] != -1 and piece[5] != 1: drawS(side((x+dx+skew,y),(-b,a),(-b,-c),thickness if b else -thickness,dy,(0,1),b,shortSide, False if piece[5] != -2 else True, False if piece[5] != 1 else True)) # side b (right) except for side with living hinge or curves elif piece[5] == -1: drawS(side((x+dx+skew,y+dy),(-b,-c),(-b,a),thickness if b else -thickness,dy,(0,-1),b,shortSide, True)) # side b (right) when the right side participates in a curve else: #It is a cardnal sin to compare floats, so assume <0.0005 is 0 since the front end only gives you 3 digits of precision if float(0.0005) <= float(self.options.thumbTab): side((x+dx+skew,y),(-b,a),(-b,-c),thickness if b else -thickness,dy,(0,1),b,shortSide, False, True, True) #The one call to side that doesn't actually draw. Instead, side draws boxes on its own drawS(box((x+dx+skew,y+thickness),(x+dx+skew+self.unittouu( thumbTab + unit ),y+dy-thickness), True)) else: drawS(side((x+dx+skew,y),(-b,a),(-b,-c),thickness if b else -thickness,dy,(0,1),b,shortSide, False, True)) #side b (right) on the right side of a living hinge if piece[5] != -2: drawS(side((x,y+dy),(d,-c),(-b,-c),thickness if c else -thickness,dx,(1,0),c,longSide)) # side c (bottom) else: drawS(side((x,y+dy),(d,-c),(-b,-c),thickness if c else -thickness,dx/2,(1,0),c,-1)) # side c (bottom) when the bottom participates in a curve drawS(side((x,y+dy),(d,-c),(d,a),-thickness if d else thickness,dy,(0,-1),d,0)) # side d (left) if piece[5] < 0: draw_SVG_ellipse((x+(dx/2), y+(dy/2)), ((dx/2), (dy/2)), [(1.5*inkex.pi), 0] if piece[5] == -1 else [0, 0.5*inkex.pi]) #draw the curve if piece[5] == 1: #Piece should contain a living hinge if hingeOpt == 0: #Traditional parallel slit self.livingHinge2((x+(Z/2), y), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + (dy)), hingeThick) elif hingeOpt == 1: #Single spiral if not inside: self.livingHinge3((x+(Z/2), y+thickness), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + dy - thickness), 1, hingeThick) else: self.livingHinge3((x+(Z/2), y + 2*thickness), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + dy - 2*thickness), 1, hingeThick) elif hingeOpt == 2: #Double spiral self.livingHinge3((x+(Z/2), y+thickness), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + (dy/2)), 1, hingeThick) self.livingHinge3((x+(Z/2), y+(dy/2)), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + dy - thickness), -1, hingeThick) elif hingeOpt == 3 or hingeOpt == 4: #Both snake-based designs self.livingHinge4((x+(Z/2), y), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + (dy)), False if hingeOpt == 3 else True, 0, hingeThick) elif hingeOpt == 5: #Double snake design self.livingHinge4((x+(Z/2), y), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + (dy/2) + thickness), True, 0, hingeThick) #Add thickness as a cheat so design 4 doesn't have to know if it's a short or long variant self.livingHinge4((x+(Z/2), y + (dy/2) - thickness), ((x+(Z/2)+(EllipseCircumference(X/2, Z/2)/4)), y + dy), True, 1, hingeThick) # Create effect instance and apply it. effect = LivingHinge() effect.affect()