1 #!/usr/bin/env python2.7
2 #KeyboardCAD by Nicholas Stamplecoskie 2015-01-11
3 #
4 #KeyboardCAD is a tool for making FreeCAD files for custom keyboards.
5 #It uses the raw data generated by http://www.keyboard-layout-editor.com/ to design a switch mounting plate for any keyboard imaginable.
6 #
7 #Thanks to Ian Prest for keyboard-layout-editor and to
8 #Juergen Riegel, Werner Mayer, Yorik van Havre and everyone else involved with FreeCAD
9
10 ###########################################################################
11 #USER PARAMETERS
12 ###########################################################################
13 fileName = 'custom_01' #name of the FreeCAD file which will be the result of this script. An extension will be added if not present
14 savePath = '/home/iso/keyboard/' #path indicating where you want to save the file
15 layoutPath = '/home/iso/keyboard/layout.txt' # path to a text file containing the raw data from http://www.keyboard-layout-editor.com/
16 plateXDim = 285.75 #overall length of the plate to be made, in mm.
17 plateYDim = 95.25 #overall width of the plate to be made, in mm.
18 xStart = 0 #How far from the left edge the keyboard will start to be drawn. Not the distance to the first hole, but to the first key.
19 yStart = 0 #How far from the top edge the keyboard will start to be drawn.
20 plateThickness = 1.5 #plate thickness in mm.
21 includeCutOuts = True #include four cutouts on the plate around each switch for the disassembly of switches while they are installed
22 rotateSwitches = False #rotate all switches with cutouts by 90 degrees (so that cutouts are on the top and bottom)
23 includeStabilizers = "both" #make cutouts for stabilizers? Possible values: False, "costar", "cherry", "both"
24
25 ###########################################################################
26 #LABEL PARAMETERS
27 ###########################################################################
28 #Include these strings in the labels per key in the layout editor
29 #!r! for rotating a switch with cutouts by 90 degrees (so that cutouts are on the top and bottom)
30 #!c! for toggling the presence of a cutout on a switch
31
32 ###########################################################################
33 #MEASUREMENTS
34 ###########################################################################
35 #SWITCH
36 KEYUNIT = 19.05 #the value of one unit
37 SWITCHSIZE = 13.9954 #in mm, each switch cut-out is a square with sides of this length. 13.9954 is for Cherry MX switches.
38 #CUTOUTS
39 CUTOUTLENGTH = 3.81 #each switch can have four cut-outs around it for switch disassembly.
40 CUTOUTWIDTH = 1.016
41 CUTOUTSEPARATION = 5.3594 #distance between two cutouts on the same side of a switch
42 CUTOUTDST = (SWITCHSIZE - (2*CUTOUTLENGTH + CUTOUTSEPARATION))/2 #distance from top side of switch to the top of the first cutout. 0.508
43 #STABILIZERS
44 MINIMUMLONGSTABLENGTH = 3 #If key is this wide or wider, use the long stabilizer. Since keys between 3 and units 6 units are unusual, this value can vary without changing anything.
45 COSTARLENGTH = 13.97 #Length (or height) of the cutout for costar stabilizers
46 COSTARWIDTH = 3.3 #Width of the cutout for costar stabilizers
47 COSTARSHORTSEPARATION = 20.57 #Distance between the two stabilizer cutouts that are on either side of the switch. NOT the distance between the stabilizer's stems.
48 COSTARLONGSEPARATION = 96.774 #Short is for 2 - 3 unit wide keys. Long is for a spacebar.
49 COSTARLONGSEPARATION2 = False # If you want two possible spacebar stabilizer positions to be cut out, add a value here.
50 COSTARDST = 0.76 #Distance from the top of the switch and the top of the stabilizer cutout. Make negative to have the stabilizers 'above' the switch.
51 CHERRYLENGTH = 12.294 #Length (or height) of the cutout for cherry stabilizers
52 CHERRYWIDTH = 6.655 #Width of the cutout for cherry stabilizers
53 CHERRYSHORTSEPARATION = 17.22 #Distance between the two stabilizer cutouts that are on either side of the switch. NOT the distance between the stabilizer's stems.
54 CHERRYLONGSEPARATION = 93.421 #Short is for 2 - 3 unit wide keys. Long is for a spacebar.
55 CHERRYLONGSEPARATION2 = False # If you want two possible spacebar stabilizer positions to be cut out, add a value here.
56 CHERRYDST = 1.3 #Distance from the top of the switch and the top of the stabilizer cutout. Make negative to have the stabilizers 'above' the switch.
57 WIREWIDTH = 2.794 #for cherry only. Width of the cutout that connects the switch cutout to the stab cutout for wire installation.
58 WIREDST = 4.7 #cherry only. distance from top of switch cutout to top of the wire cutout.
59 WIREADDLENGTH = 0.89 #cherry only. Distance that the wire cutout extends past the stab cutout.
60 ADDCUTFORSHORT = True #cherry only. more area beside the switch will be cut for short cherry stabilizers with cutouts present, if this is true. May not be desired for different switch/cutout sizes
61
62 ###########################################################################
63 #PATHS
64 ###########################################################################
65 #Assign values to these two variables if you are not running the script from the python in the FreeCAD folder
66 FREECADPATH = "/usr/bin/freecad"
67 FREECADPATH2 = "/usr/lib/freecad/"
68
69 ###########################################################################
70 #(OPTIONAL) SCREW HOLES
71 ###########################################################################
72 #This list of holes must contain tuples (x,y) for the coordinates for where each screw hole will be cut
73 #x is the distance from the left edge of the plate to the center of the screw hole.
74 #y is the distance from the TOP edge of the plate to the center of the hole.
75 # screws = [(20,40),(100,40),(20,100),(100,100)] This is the expected format.
76 screws = []
77 screwHoleRadius = 1 #the radius of each hole in mm
78
79
80 def main():
81 getLayoutData()
82 initializeCAD()
83 drawSwitches()
84 drawStabilizers()
85 drawScrewHoles()
86 save()
87
88
89 #FreeCAD methods
90
91 def initializeCAD():
92 global doc
93 doc = FreeCAD.newDocument() #initialize the document
94
95 pad(sketchRectangle(0, 0, plateXDim, plateYDim, False)) #draw the plate
96
97 def sketchRectangle(posX, posY, xDim, yDim, rotation):
98 global doc
99 global sketchCount
100
101 rectangle = doc.addObject('Sketcher::SketchObject','Sketch' + str(sketchCount))
102
103 if not sketchCount == 0: #if it is the first sketch, then the pad doesn't exist yet
104 rectangle.Support = (doc.Pad,["Face6"])
105
106 sketchCount = sketchCount + 1
107
108 # 0_______________3
109 # | |
110 # | |
111 # | |
112 # | |
113 # | |
114 # | |
115 # 1|_______________|2
116
117 x = [0]*4
118 y = [0]*4
119
120 x[0] = posX
121 y[0] = -posY
122 x[2] = posX + xDim
123 y[2] = -posY - yDim
124
125 x[1] = x[0]
126 y[1] = y[2]
127 x[3] = x[2]
128 y[3] = y[0]
129
130 if rotation:
131 for n in range(4):
132 x[n], y[n] = rotatePoint((rotation[0],rotation[1]), (x[n],y[n]), rotation[2])
133
134 for i in range(3):
135 rectangle.addGeometry(Part.Line(App.Vector(x[i],y[i],0),App.Vector(x[i+1],y[i+1],0)))
136 rectangle.addGeometry(Part.Line(App.Vector(x[3],y[3],0),App.Vector(x[0],y[0],0)))
137
138 for j in range(3):
139 rectangle.addConstraint(Sketcher.Constraint('Coincident',j,2,j+1,1))
140 rectangle.addConstraint(Sketcher.Constraint('Coincident',3,2,0,1))
141
142 for k in range(4):
143 rectangle.addConstraint(Sketcher.Constraint('DistanceX',k,1,x[k]))
144 rectangle.addConstraint(Sketcher.Constraint('DistanceY',k,1,y[k]))
145
146 doc.recompute()
147
148 return rectangle
149
150 def sketchSwitchWithCutOuts(posX, posY, rotation, rotate90):
151 global doc
152 global sketchCount
153
154 rectangle = doc.addObject('Sketcher::SketchObject','Sketch' + str(sketchCount))
155
156 rectangle.Support = (doc.Pad,["Face6"])
157
158 sketchCount = sketchCount + 1
159
160 # 0_______________ 19
161 #2 _|1 18|_ 17
162 # | |
163 #3|_ 4 15 _| 16
164 #6 _|5 14|_ 13
165 # | |
166 #7|_ 8 11 _| 12
167 # 9 |_______________| 10
168
169 x = [0]*20
170 y = [0]*20
171
172 x[0] = posX
173 y[0] = -posY
174 x[1] = x[0]
175 y[1] = y[0] - CUTOUTDST
176 x[2] = x[0] - CUTOUTWIDTH
177 y[2] = y[1]
178 x[3] = x[2]
179 y[3] = y[2] - CUTOUTLENGTH
180 x[4] = x[0]
181 y[4] = y[3]
182 x[5] = x[0]
183 y[5] = y[4] - CUTOUTSEPARATION
184 x[6] = x[2]
185 y[6] = y[5]
186 x[7] = x[2]
187 y[7] = y[6] - CUTOUTLENGTH
188 x[8] = x[0]
189 y[8] = y[7]
190 x[9] = x[0]
191 y[9] = y[8] - CUTOUTDST # -posY - SWITCHSIZE
192 x[10] = posX + SWITCHSIZE
193 y[10] = -posY - SWITCHSIZE
194 x[11] = x[10]
195 y[11] = y[8]
196 x[12] = x[10] + CUTOUTWIDTH
197 y[12] = y[11]
198 x[13] = x[12]
199 y[13] = y[6]
200 x[14] = x[10]
201 y[14] = y[13]
202 x[15] = x[10]
203 y[15] = y[4]
204 x[16] = x[12]
205 y[16] = y[15]
206 x[17] = x[12]
207 y[17] = y[2]
208 x[18] = x[10]
209 y[18] = y[1]
210 x[19] = x[10]
211 y[19] = y[0]
212
213 if rotate90:
214 centerPoint = (x[0] + SWITCHSIZE/2, y[0] - SWITCHSIZE/2)
215 for n in range(20):
216 x[n], y[n] = rotatePoint(centerPoint, (x[n],y[n]), 90)
217
218 if rotation:
219 for n in range(20):
220 x[n], y[n] = rotatePoint((rotation[0],rotation[1]), (x[n],y[n]), rotation[2])
221
222 for i in range(19):
223 rectangle.addGeometry(Part.Line(App.Vector(x[i],y[i],0),App.Vector(x[i+1],y[i+1],0)))
224 rectangle.addGeometry(Part.Line(App.Vector(x[19],y[19],0),App.Vector(x[0],y[0],0)))
225
226 for j in range(19):
227 rectangle.addConstraint(Sketcher.Constraint('Coincident',j,2,j+1,1))
228 rectangle.addConstraint(Sketcher.Constraint('Coincident',19,2,0,1))
229
230 for k in range(20):
231 rectangle.addConstraint(Sketcher.Constraint('DistanceX',k,1,x[k]))
232 rectangle.addConstraint(Sketcher.Constraint('DistanceY',k,1,y[k]))
233 doc.recompute()
234 return rectangle
235
236 def sketchCircle(posX, posY, radius):
237 global doc
238 global sketchCount
239 circle = doc.addObject('Sketcher::SketchObject','Sketch' + str(sketchCount))
240 circle.Support = (doc.Pad,["Face6"])
241 sketchCount = sketchCount + 1
242 posY = -posY
243 circle.addGeometry(Part.Circle(App.Vector(posX,posY,0),App.Vector(0,0,1),radius))
244 circle.addConstraint(Sketcher.Constraint('DistanceX',0,3,posX))
245 circle.addConstraint(Sketcher.Constraint('DistanceY',0,3,posY))
246 circle.addConstraint(Sketcher.Constraint('Radius',0,radius))
247 doc.recompute()
248 return circle
249
250 def pocket(sketch):
251 global doc
252 pocket = doc.addObject("PartDesign::Pocket","Pocket" + str(sketchCount - 1))
253 pocket.Sketch = sketch
254 pocket.Length = 5.0
255 pocket.Type = 1
256 pocket.UpToFace = None
257 doc.recompute()
258
259 def pad(sketch):
260 global doc
261 pad = doc.addObject("PartDesign::Pad","Pad")
262 pad.Sketch = sketch
263 pad.Length = plateThickness
264 pad.Reversed = 0
265 pad.Midplane = 0
266 pad.Length2 = 100.000000
267 pad.Type = 0
268 pad.UpToFace = None
269 doc.recompute()
270
271 #Drawing methods
272 def drawSwitches():
273 for prop in props:
274 coord = findCoord(prop[0], prop[1], prop[2], prop[3])
275 rotation = prop[4]
276 if "!c!" in labels[props.index(prop)]:
277 drawSwitchWithCutOuts(coord[0], coord[1], rotation, "!r!" in labels[props.index(prop)])
278 else:
279 drawSwitch(coord[0], coord[1], rotation)
280
281 def drawStabilizers():
282 if includeStabilizers:
283 if includeStabilizers == "cherry":
284 drawStabilizersHelper(True)
285 elif includeStabilizers == "costar":
286 drawStabilizersHelper(False)
287 elif includeStabilizers == "both":
288 drawStabilizersHelper(True)
289 drawStabilizersHelper(False)
290 else:
291 print(includeStabilizers + " is not a valid value for includeStabilizers")
292 return
293
294 def drawScrewHoles():
295 for screw in screws:
296 pocket(sketchCircle(screw[0], screw[1], screwHoleRadius))
297
298 def drawSwitch(x, y, rotation):
299 pocket(sketchRectangle(x, y, SWITCHSIZE, SWITCHSIZE, rotation))
300
301 def drawSwitchWithCutOuts(x, y, rotation, rotate90):
302 pocket(sketchSwitchWithCutOuts(x, y, rotation, rotate90))
303
304 def drawStabilizersHelper(cherry):
305 for prop in props:
306 if prop[2] >= 2 or prop[3] >= 2:
307 coord = findCoord(prop[0], prop[1], prop[2], prop[3])
308 cutout = "!c!" in labels[props.index(prop)]
309 rotated90 = "!r!" in labels[props.index(prop)]
310 rotation = prop[4]
311 if prop[2] >= MINIMUMLONGSTABLENGTH:#spacebar
312 drawHorizontalStabilizer(coord[0], coord[1], False, cherry, cutout, rotated90, rotation)
313 global CHERRYLONGSEPARATION
314 global COSTARLONGSEPARATION
315 global CHERRYLONGSEPARATION2
316 global COSTARLONGSEPARATION2
317 if cherry and CHERRYLONGSEPARATION2 or not(cherry) and COSTARLONGSEPARATION2: #if a second value is given, swap and then draw again.
318 tmpCherry = CHERRYLONGSEPARATION
319 tmpCostar = COSTARLONGSEPARATION
320 CHERRYLONGSEPARATION = CHERRYLONGSEPARATION2
321 COSTARLONGSEPARATION = COSTARLONGSEPARATION2
322 CHERRYLONGSEPARATION2 = tmpCherry
323 COSTARLONGSEPARATION2 = tmpCostar
324 drawHorizontalStabilizer(coord[0], coord[1], False, cherry, cutout, rotated90, rotation)
325 elif prop[3] >= 2: #if taller than 2, stab will be vertical, for iso, big-ass enter, + on numpad, etc..
326 drawVerticalStabilizer(coord[0], coord[1], cherry, cutout, rotated90, rotation)
327 else: #standard wide key
328 drawHorizontalStabilizer(coord[0], coord[1], True, cherry, cutout, rotated90, rotation)
329
330 def drawHorizontalStabilizer(x, y, short, cherry, cutout, rotated90, rotation):
331 if cherry:
332 width = CHERRYWIDTH
333 length = CHERRYLENGTH
334 if short:
335 separation = CHERRYSHORTSEPARATION
336 else:
337 separation = CHERRYLONGSEPARATION
338 dst = CHERRYDST
339 else:
340 width = COSTARWIDTH
341 length = COSTARLENGTH
342 if short:
343 separation = COSTARSHORTSEPARATION
344 else:
345 separation = COSTARLONGSEPARATION
346 separation2 = COSTARLONGSEPARATION2
347 dst = COSTARDST
348
349 y = y + dst
350 xLeft = x + SWITCHSIZE/2 - separation/2 - width
351 xRight = x + SWITCHSIZE/2 + separation/2
352
353 pocket(sketchRectangle(xLeft, y, width, length, rotation))
354 pocket(sketchRectangle(xRight, y, width, length, rotation))
355
356 if cherry:
357 xWire = xLeft - WIREADDLENGTH
358 yWire = y + WIREDST
359 wWire = separation + 2*width + 2*WIREADDLENGTH
360 pocket(sketchRectangle(xWire, yWire, wWire, WIREWIDTH, rotation))
361 if cutout and not(rotated90) and short and ADDCUTFORSHORT: #Another cut will be performed since the remaining plate will be very narrow in this area, if cutouts.
362 pocket(sketchRectangle(xLeft + width, y, separation, length, rotation))
363
364 def drawVerticalStabilizer(x, y, cherry, cutout, rotated90, rotation):
365 if cherry:
366 width = CHERRYWIDTH
367 length = CHERRYLENGTH
368 separation = CHERRYSHORTSEPARATION
369 dst = CHERRYDST
370 else:
371 width = COSTARWIDTH
372 length = COSTARLENGTH
373 separation = COSTARSHORTSEPARATION
374 dst = COSTARDST
375
376 x = x + dst
377
378 yTop = y + SWITCHSIZE/2 - separation/2 - width
379 pocket(sketchRectangle(x, yTop, length, width, rotation))
380
381 yBottom = y + SWITCHSIZE/2 + separation/2
382 pocket(sketchRectangle(x, yBottom, length, width, rotation))
383
384 if cherry:
385 yWire = yTop - WIREADDLENGTH
386 xWire = x + WIREDST
387 hWire = separation + 2*width + 2*WIREADDLENGTH
388 pocket(sketchRectangle(xWire, yWire, WIREWIDTH, hWire, rotation))
389 if cutout and rotated90 and ADDCUTFORSHORT: #Another cut will be performed since the remaining plate will be very narrow in this area. only if rotated and cutouts.
390 pocket(sketchRectangle(x, yTop + width, length, separation, rotation))
391
392 #Calculation methods
393 def findCoord(x, y, w, h): #calculates where the top left corner of the switch is, given that each switch will be in the exact middle of the key
394 x = x*KEYUNIT
395 y = y*KEYUNIT
396 w = w*KEYUNIT
397 h = h*KEYUNIT
398 xPos = x + w/2 - SWITCHSIZE/2
399 yPos = y + h/2 - SWITCHSIZE/2
400 xPos = xPos + xStart
401 yPos = yPos + yStart
402 return (xPos, yPos)
403
404
405 def rotatePoint(centerPoint, point, angle):
406 tempPoint = (point[0]-centerPoint[0], point[1]-centerPoint[1])
407 if angle == 10:
408 tempPoint = (-tempPoint[1], tempPoint[0])
409 else:
410 angle = math.radians(angle)
411 tempPoint = (tempPoint[0]*math.cos(angle)-tempPoint[1]*math.sin(angle), tempPoint[0]*math.sin(angle)+tempPoint[1]*math.cos(angle))
412 tempPoint = (tempPoint[0]+centerPoint[0], tempPoint[1]+centerPoint[1])
413 return tempPoint
414
415 #Input data methods
416 def getLayoutData():
417 parseLayout(readFile())
418 fixRotations()
419 modifyLabels()
420
421 def readFile():
422 return open(layoutPath, 'r').readlines()
423
424 def parseLayout(layoutList):
425 global labels
426 for row in layoutList:
427 newRow = True
428 row = row.rstrip()
429 if row[-1:] == ',':
430 row = row[1:-2]
431 else:
432 row = row[1:-1]
433 values = row.split(",")
434 tmp = ''
435 for value in values:
436 if not tmp == '':
437 value = tmp + "," + value
438 if value[:1] == '{' and value[-1:] == '}': #value is a prop
439 makeProp(value[1:-1], newRow)
440 newRow = False
441 tmp = ''
442 elif value[:1] == '"' and value[-1:] == '"' and not len(value) == 1: #value is a label
443 labels.append(value[1:-1])
444 if len(props) < len(labels): #if no prop exists for this label, makes one
445 makeProp('', newRow)
446 newRow = False
447 tmp = ''
448 else: #splitting of the row into values didn't work right because a value contained a comma
449 tmp = value
450
451 def makeProp(values, newRow):
452 global props
453 if props:
454 prevProp = props[-1]
455 else:
456 prevProp = (0,-1,0,0,(0,0,0))
457 x = 0
458 y = 0
459 w = 1
460 h = 1
461 rx = prevProp[4][0]
462 ry = prevProp[4][1]
463 r = prevProp[4][2]
464 if not values == '':
465 for value in values.split(","):
466 colon = value.find(":")
467 if value[:colon] == 'x':
468 x = float(value[colon + 1:])
469 elif value[:colon] == 'y':
470 y = float(value[colon + 1:])
471 elif value[:colon] == 'w':
472 w = float(value[colon + 1:])
473 elif value[:colon] == 'h':
474 h = float(value[colon + 1:])
475 elif value[:colon] == 'r':
476 r = float(value[colon + 1:])
477 elif value[:colon] == 'rx':
478 rx = float(value[colon + 1:])
479 elif value[:colon] == 'ry':
480 ry = float(value[colon + 1:])
481 else:
482 pass #the value does not contain relative information
483 newRotation = (rx,ry,r)
484 if newRow:
485 x = rx + x
486 if newRotation != prevProp[4]:
487 y = ry + y
488 else:
489 y = prevProp[1] + 1 + y
490 else:
491 x = x + prevProp[0] + prevProp[2]
492 y = prevProp[1]
493
494 props.append((x,y,w,h,newRotation))
495
496 def modifyLabels(): #adds or removes label parameters based on the value of rotateSwitches and includeCutouts
497 global labels
498 result = []
499 for label in labels:
500 newLabel = label
501 if includeCutOuts:
502 if "!c!" in label:
503 c = newLabel.index("!c!")
504 newLabel = newLabel[:c] + newLabel[c+3:]
505 else:
506 newLabel = newLabel + "!c!"
507 if rotateSwitches:
508 if "!r!" in label:
509 r = newLabel.index("!r!")
510 newLabel = newLabel[:r] + newLabel[r+3:]
511 else:
512 newLabel = newLabel + "!r!"
513 result.append(newLabel)
514 labels = result
515
516 def fixRotations(): #changes rotation data to actual coordinates from keyunit values
517 global props
518 result = []
519 for prop in props:
520 if prop[4][2] == 0:
521 newRotation = False
522 else:
523 newRotation = (prop[4][0]*KEYUNIT, -prop[4][1]*KEYUNIT, -prop[4][2])
524 result.append((prop[0], prop[1], prop[2], prop[3], newRotation))
525 props = result
526
527 #Output file methods
528 def save(saveAttempt=1):
529 global doc
530 global fileName
531 if fileName[-6:] != ".FCStd":
532 fileName = fileName + ".FCStd"
533 saveAs = savePath + fileName
534 if os.path.exists(saveAs):
535 if saveAttempt == 1:
536 fileName = fileName[:-6] + "(2).FCStd"
537 else:
538 i = fileName.find("("+ str(saveAttempt) +")")
539 fileName = "{}({}).FCStd".format(fileName[:i], str(saveAttempt + 1))
540 print("File already exists. Saving as " + fileName)
541 save(saveAttempt + 1)
542 else:
543 doc.saveAs(saveAs)
544 print("Successfully saved to " + saveAs)
545
546 #global variables
547 doc = None
548 sketchCount = 0
549 props = [] #tuple for each switch, (x,y,w,h,(rx,ry,r))
550 labels = [] #labels for each switch from the layout editor
551 #################
552 import sys
553 import os
554 import math
555 if FREECADPATH and FREECADPATH2:
556 sys.path.append(FREECADPATH)
557 sys.path.append(FREECADPATH2)
558 ################
559 try:
560 import FreeCAD
561 import Sketcher
562 except Exception:
563 print("error finding FreeCAD")
564 else:
565 main()