Hand-placing every key on a keyboard case gets old fast, especially once you start tenting or column-staggering a layout — every key needs its own x, y, z, and rotation, and changing one column means redoing the math on every key after it. So instead of placing keys, I describe the layout as a formula in terms of column and row, and let a script place every key for me.
The config
A keyboard is a JSON file with a size and six expressions — one each for
x, y, z position and x, y, z rotation, written in terms of x (column),
y (row), width, height, and key_1u:
{
"name": "6x4_staggered",
"width": 6,
"height": 4,
"x_algo": "x*key_1u",
"y_algo": "-y*key_1u",
"z_algo": "abs(y-2)*4 + 10",
"x_rot_algo": "(y-2)*-5",
"z_rot_algo": "0"
}
That z_algo alone gives every row a different height based on its
distance from row 2, and x_rot_algo tilts each row by 5° per row away
from the center — a basic dish/tent, from two expressions instead of 24
hand-tuned key positions. keylist.py evaluates all six for every
(x, y) in the grid and writes out a fully expanded keylist — one entry
per key with a concrete position and rotation — which is what the actual
model generator reads.
A parser, not an eval()
The expressions are user-authored text I then execute, which normally
means either writing a real parser or reaching for Python’s eval() and
hoping nobody (including future me) puts something dangerous in a config
file. I did the former, but the cheap way: parse the expression with
Python’s own ast module, walk the tree, and reject anything that isn’t
on an explicit allow-list — arithmetic, comparisons, ternaries, and a
handful of functions (abs, min, max, floor, ceil, round) —
before ever calling eval() on it:
def _check(node):
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in allowed_funcs:
raise ValueError("Disallowed function")
for a in node.args:
_check(a)
return
if isinstance(node, ast.Name):
if node.id not in names:
raise ValueError("Disallowed name")
return
# ...similar checks for BinOp, Compare, BoolOp, UnaryOp, Constant
Only once every node in the tree passes does it get compiled and
evaluated with an empty __builtins__. It’s maybe 40 lines, and it’s
the difference between “config file” and “arbitrary code execution
waiting to happen.”
Config to OpenSCAD
keyboard.py takes that expanded keylist and writes OpenSCAD, split
into three files per keyboard: _keys (switch cutouts and per-key
geometry), _base (the plate itself, as a difference() of the outline
and every hole), and _switches. Keeping them separate means I can
re-render just the plate while iterating on baseplate shape, instead of
waiting on every switch cutout every time.
The plate also generates its own heat-set insert bosses — cylindrical
posts with a configurable inner/outer diameter and an angular sweep
(start_angle to end_angle) rather than a plain hole, so mounting
posts can hug a curved edge instead of always pointing straight down.
Assembling split boards
The part I like most: a keyboard config can be an assembly of other already-generated keyboards instead of a grid of keys —
{
"name": "6abcCombined",
"items": [
{ "name": "6abcLeft", "pos": [-100, 0, 0] },
{ "name": "6abcRight", "pos": [100, 0, 0], "mirror": [1, 0, 0] }
]
}
Each item is translate()d, rotate()d, and optionally mirror()d
into place in pure SCAD, referencing the halves’ own already-rendered
files. The one wrinkle: every keyboard’s OpenSCAD modules are named
base() and inserts() by default, so combining two would collide —
keyboard.py scopes every module to {name}_base() /
{name}_inserts() per keyboard, so a left and right half (or three, or
five) can share one file without stepping on each other.
Running it
main.sh strings the whole pipeline together for a given config —
keylist.py to expand it, keyboard.py to write the SCAD, then
OpenSCAD’s CLI (with the manifold backend, which is a lot faster than
the default CGAL backend for boolean-heavy plates like this) to render
STLs for the base, the keys, and the full assembly.
The keyboards/imadeathingie/ folder currently has about fifteen
variants in it — staggered on x, staggered on y, gappy versions of both,
a standalone thumb cluster, a couple of abandoned _2/_3 attempts —
which is really just what iterating on a layout by editing six
expressions and re-running a script looks like in practice.