Skip to content

About

A 62-key split columnar ergonomic keyboard

Topics

Resources

Stars

7 stars

Watchers

1 watching

Forks

Repository files navigation

Splinter keyboard

Test License: MIT

A 62-key split columnar ergonomic keyboard.

This repo holds the hardware design files; firmware lives in a separate repo. Design pipeline: Keyboard Layout Editor -> Ergogen -> KiCad -> fabrication -> Onshape -> OrcaSlicer -> QMK (see Developing).

Related notes: 3D printing, electronics, keyboards.

Versions

Version MCU Changes from previous Firmware Photo
v4 splitkb Liatris (RP2040) Added USB VBUS detection and TRRS ESD protection splinter Pending
v3 Adafruit KB2040 (RP2040) Switched from AVR to RP2040 splinter-v3.0 v3
v2 SparkFun Pro Micro (ATmega32U4) Symmetrical enclosures; added a key (62 keys) splinter-2.0 v2
v1 SparkFun Pro Micro (ATmega32U4) Initial version: 61 keys, columnar layout, asymmetrical enclosures splinter-1.0 v1

Repository layout

Every version directory (v1/ .. v4/) follows the same shape, though not every version has every part:

Path Contents
ergogen/config.yaml Keyboard definition. The source of truth for everything downstream.
ergogen/footprints/ Symlink to the shared footprints below, so every version builds against one pinned set. Do not edit.
keyboard-layout-editor/ Layout prototypes.
kicad/unrouted/ Working boards. Ergogen generates into these; you route here.
kicad/routed/ Routed masters. The fab source.
kicad/unrouted-backups/ Timestamped copies of the unrouted/ boards, taken each time fresh ones overwrite them. Gitignored, and only the last few per board are kept.
kicad/jlcpcb-parts.json LCSC part numbers, kept outside the .kicad_pcb so they survive regeneration. v4 only, and what makes assembly files appear.
onshape/ Case design and its exported STEP. v4 adds the build sheet, the rationale, and a script that builds the same design as geometry and validates the export against it.
orcaslicer/ Slicer projects. v2 and v3 only.

Shared across versions:

Path Contents
ergogen/ Footprints and helpers.
scripts/ Build steps.
dist/ Build output. Generated, not committed.

A _ prefix on a PCB filename excludes it from every step.

Installation

Tool Needed for
KiCad 10 Routing, and kicad-cli for gerber export and headless DRC
Node.js Ergogen and the npm scripts
Python 3 The build steps (stdlib only; pcbnew comes from KiCad)
OrcaSlicer Slicing the printed case
FreeCAD freecadcmd, for validating the case export
Freerouting Optional, for autorouting
KiKit Optional, for panelization; needs the git-master build
# Include submodules when cloning
git clone --recursive git@github.com:andornaut/splinter-keyboard.git
cd splinter-keyboard

# Install the Node version from .nvmrc, then the deps (including Ergogen)
nvm install
npm install

# Install KiCad 10 (provides kicad-cli, used for fab file generation)
sudo add-apt-repository ppa:kicad/kicad-10.0-releases
sudo apt install kicad

# Fallback only if you cloned without --recursive
git submodule update --init --recursive

OrcaSlicer, FreeCAD, KiCad, Freerouting and KiKit also install from these Ansible tasks (tags orcaslicer, freecad and kicad; Freerouting and KiKit come with kicad).

Updating footprint submodules. npm run ergogen uses ceoloide and infused-kim at their pinned revision and never advances them, so builds stay reproducible. To re-pin:

git submodule update --remote ergogen/footprints/ceoloide ergogen/footprints/infused-kim
git add ergogen/footprints/ceoloide ergogen/footprints/infused-kim
git commit -m "Bump footprint submodules"

Commands

Run everything through npm run from the repo root: the scripts read the active version from config.VERSION in package.json.

Command Does
pipeline The full build, every gate in order. The normal entry point. Add -- -v for the full log.
ergogen Generate outlines and PCBs into dist/${VERSION}/ergogen/
watch / watch:sync-unrouted Re-run ergogen on every config.yaml change, the second also copying into unrouted/
copy:dist-to-unrouted dist/ -> unrouted/ (backs the old boards up to unrouted-backups/ first)
copy:traces-to-unrouted Traces and teardrops from routed/ back into unrouted/
copy:unrouted-to-routed unrouted/ -> routed/ (see Saving to routed/)
fab Gerbers, drill, and assembly BOM/CPL via kicad-cli
panelize Combine both halves into one JLCPCB panel (optional)
route Autoroute unrouted/ via Freerouting (optional)
validate:provenance / validate:symmetry / validate:firmware / validate:fab The four gates (see Validation)
clean Remove dist/
lint / format Prettier and ESLint, checking or fixing. lint is a CI gate

Developing

Step 1. Configure

Set config.VERSION in package.json to v1, v2, v3, or v4, by editing the file or with npm pkg set config.VERSION=v4.

Keyboard Layout preview

Prototype a layout, export it to keyboard-layout-editor.json so you can re-import and iterate, then use it as the basis for the Ergogen design. To load it into the Raw data tab, paste it without its outer [ and ]: the tab takes the layout's rows directly, while the downloaded JSON wraps them in an array.

Step 3. Ergogen

Ergogen preview

  1. docker compose up starts the Ergogen GUI (it builds on first run); open http://ergogen.internal (needs docker_etc_hosts for the /etc/hosts entry).
  2. Paste in, edit, then download ergogen/config.yaml.
  3. npm run ergogen, then npm run copy:dist-to-unrouted. Or just npm run watch:sync-unrouted.
  • The GUI renders no PCBs and is client-side only. Edit there, copy back to config.yaml (the source of truth), build with npm run ergogen.
  • The browser cannot load footprints from disk, so the Dockerfile bakes this repo's custom footprints into the GUI image. An unregistered what: shows up as unknown; after adding one, docker compose build --no-cache.

Step 4. KiCad

KiCad preview

Open a board from kicad/unrouted/ and route it. Before saving:

  • Add teardrops (Edit > Edit Teardrops, nothing selected for board-wide): stronger pad and via joints. Re-run after any reroute.
  • Run DRC (Inspect > Design Rules Checker, "Refill all zones" checked): clear every violation and unrouted net. npm run fab re-runs it headlessly, but fixing it here beats reading the JSON.
  • Check copper and silk visually: no isolated GND islands or stranded pads; silk clear of pads and the board edge.

After regenerating with Ergogen, npm run copy:traces-to-unrouted brings the traces and teardrops from routed/ back into unrouted/ (then File > Revert in KiCad).

Saving to routed/

npm run copy:unrouted-to-routed writes the masters' .kicad_pro and .kicad_dru first, since the stages that move copper measure against the clearances those hold. Then, one master at a time, it copies the working board onto its master in kicad/routed/ and runs:

Stage What it does
GND pour Floods a ground plane on whichever side costs less (scored per board, F.Cu vs B.Cu)
Cleanup Strips copper no route uses: dangling tracks and the vias they strand, tracks buried in pads, redundant vias, split segments
Pattern snap Pulls strays onto the repeated shape they belong to, within a per-endpoint cap
Sliver tidy Collapses any segment left shorter than it is wide, within a per-endpoint cap

One master at a time, copy included, because any stage can stop the build: a master this run has not reached yet is still the routed board it was, rather than an un-poured working copy that nothing downstream would catch.

The last three stages feed each other: a collapsed sliver can leave a run the next snap recognises, and a snap leaves fragments for the next cleanup to merge and can leave a sliver of its own. So they repeat until a pass changes nothing, and a board still changing on the last allowed pass stops the build instead of shipping a master its own tooling has not finished with.

The working boards keep the stripped copper, mainly the footprints' unused include_traces_vias stubs, since a later reroute may pick it up.

Pattern snap and sliver tidy move copper rather than only removing it, so both are capped and neither guesses: pattern snap reports a stray it may not move and leaves it, while sliver tidy stops the build:

  • Pattern snap. The matrix is a grid, so most of the routing is one motif repeated, and hand-drawn copies land a fraction of a millimetre apart. DRC never sees it, because each copy is individually legal. Anything beyond the cap is a routing decision rather than a stray, so it is reported and left alone.
  • Sliver tidy. Moving one end of a run pivots the whole run, so a collapse is refused if it exceeds the cap or would swing copper into another net's clearance or a keepout. The refusal names the sliver and the reason; close it in KiCad by dragging the two runs together, or by re-routing clear of the named area.

Autorouting (optional)

KiCad has no built-in autorouter. npm run route routes the unrouted/ boards in place via Freerouting, leaving routed/ untouched; expect to hand-clean the result, then File > Revert. Raising via cost trades vias for unrouted nets, so it cannot beat hand-routing on via count.

Env var Default Values
FREEROUTING_PASSES 100
FREEROUTING_STRATEGY greedy greedy, global, hybrid
FREEROUTING_SELECTION prioritized prioritized, random, sequential
FREEROUTING_VIA_COST 50 higher = fewer vias
FREEROUTING_UNDESIRED_DIR_COST unset cost of routing against a layer's preferred direction
FREEROUTING_LOG_LEVEL WARN ERROR, WARN, INFO, DEBUG, TRACE

Step 5. Fabrication (JLCPCB)

npm run fab exports from routed/ into dist/${VERSION}/kicad/jlcpcb/<name>/:

Output Contents
<name>-gerber.zip Gerbers and drill: the bare PCB
<name>-BOM.csv, <name>-CPL.csv Assembly files, written only when jlcpcb-parts.json is present
<name>-drc.json Headless DRC report, written per board before that board's export. Any error-level violation or unrouted net stops the run there, so a failing board never overwrites its own last-good gerbers.

A provenance check gates the whole run before any board is touched, so a master that drifted from config.yaml produces nothing at all.

Which parts JLCPCB places and which you hand-solder is version-specific; see the v4 notes.

Ordering from JLCPCB: upload each <name>-gerber.zip, plus the matching BOM and CPL for assembly. Check placement in the DFM viewer; fix a mis-oriented part via its rotation in jlcpcb-parts.json and re-run.

Panelization (optional, for PCBA cost)

npm run panelize combines left and right into one panel so JLCPCB's per-order assembly setup and stencil fees are paid once instead of twice. The panel is not orderable yet: its frame is not tabbed to the boards, and its fiducials sit on its tooling holes at the rail's inner edge. Order the halves separately until scripts/panelize.py is fixed. Outputs to dist/${VERSION}/kicad/jlcpcb/panel/; the per-half fab remains the strict DRC gate. Requires KiKit (git-master build for KiCad 10); point it at an interpreter with KIKIT_PYTHON.

Step 6. Onshape

Onshape preview

  1. Create a document and start a sketch.
  2. Select "Insert a DXF or DWG file" > "Import ..." (bottom of the dialog) > dist/${VERSION}/ergogen/outlines/full_unfilleted.dxf. That is the nominal hull rather than the fabricated edge: the fillet only removes material, so a pocket cut to the hull can never come out undersized.
  3. Design the case to onshape/BUILD.md, which carries every dimension and the feature-by-feature recipe, then export it to dist/${VERSION}/onshape/.
  4. Validate both halves against the reference, FC_HALF=left FC_COMPARE=dist/${VERSION}/onshape/<export>.step freecadcmd v4/onshape/gen-case.py and again with FC_HALF=right, and decide every difference it reports: move the model to the reference, record it in v4/onshape/differences.json as intentional, or fix it as an error. See Validating your model.
  5. Once both halves validate, copy the export over v4/onshape/Splinter keyboard v4 - Filament.step. Validate the aluminium configuration's export the same way with FC_VARIANT=aluminium added, then copy it over Splinter keyboard v4 - Aluminium.step.
  6. Export the aluminium configuration again one part per file, validate each half's pair with FC_VARIANT=aluminium FC_COMPARE=<shell>.step,<plate>.step, and copy them over v4/onshape/Splinter keyboard v4 - Aluminium - {Left,Right} {shell,plate}.step.

Step 7. OrcaSlicer

  1. Open or create a project and import Splinter keyboard v4 - Filament.step.
  2. Slice and print the case.
  3. Install an M2.5 heat-set insert into each mounting boss with a soldering iron, then clamp the PCB with the M2.5 screws.

Alternative: machined aluminium case (JLCCNC)

Upload the model's aluminium configuration (tapped holes, see Aluminium variant) to JLCCNC as four files, one part each, since JLCCNC quotes per file: Splinter keyboard v4 - Aluminium - Left shell.step, Splinter keyboard v4 - Aluminium - Left plate.step, Splinter keyboard v4 - Aluminium - Right shell.step and Splinter keyboard v4 - Aluminium - Right plate.step. Attach each part's drawing to that part, and quote each part once:

Setting Value
Material 6061 aluminium (JLCCNC's standard alloy)
Surface finish Bead blasting + anodizing, dark grey. Order all four parts together so they anodize in one batch and match
Tolerance Select +-0.1 on all four parts, and mark the mating dimensions +-0.1 on each part's drawing: JLCCNC applies a selected tolerance only to dimensions annotated on the 2D drawing, and everything else defaults to ISO 2768 medium (+-0.5 above 120mm, more than the plate's 0.40 total clearance in its cavity). The drawings dimension between straight edges: the shell cavity 161.00 wide and 102.25 tall at its outer side, the plate 160.60 and 101.85. Holes are placed from mating edges, listed outer / centre / inner hole. The shells: from the cavity's outer side (38.13, 77.18, 114.33), and from the cavity edge opposite the thumb (63.65, 37.83, 59.95). The plates: from the plate's outer side (37.93, 76.98, 114.13) and its thumb-side edge (38.40, 64.23, 42.10)
Threaded holes Threads YES on each shell: 3 x M2.5x0.45. Threads NO on the plates. Attach every part's PDF by hand: the PDFs are not named like the STEPs. The heat-set inserts are for the printed case only
Order remarks Shells: "Threads: 3 x M2.5x0.45 blind, 2.05 drill 4.00 deep, full thread to 2.65 required (screw reaches 2.53). If you can't hold 2.65, contact me before machining." Plates: "Fits into the matching shell's cavity with 0.20/side clearance; its three 2.90 holes must line up with the shell's tapped holes."
Edges JLCCNC breaks external edges by about 0.2 by default. The drawings' title block says only "REMOVE BURRS", so that default applies to the switch recess rims too, which the model leaves square

A STEP file cannot carry threads, so model each hole at the ~2.05mm tap-drill diameter and upload a PDF with an M2.5x0.45 callout, 4.00 drill and full thread to 2.65: the drill leaves 1.35 of run-out below the full thread, more than half the diameter, so a standard tap reaches it. Every hole shares the thread spec and the depth, so one callout covers all three; take both from the build sheet. Splinter v4 left shell.pdf and Splinter v4 right shell.pdf carry the thread callouts and the +-0.1 annotations above; Splinter v4 left plate.pdf and Splinter v4 right plate.pdf carry the annotations only. To redraw one, make an Onshape Drawing of the Aluminium configuration and use its Hole callout tool on a tapped hole's edge; it reads the spec from the Hole feature.

Step 8. QMK firmware

Install the custom QMK firmware.

Automation

One-command pipeline

npm run pipeline re-syncs already-routed boards after a config change:

# Step Does
1 ergogen Rebuild the boards from config.yaml
2 copy:dist-to-unrouted Fresh boards into unrouted/, old ones backed up
3 copy:traces-to-unrouted Replay the masters' routing onto them
4 copy:unrouted-to-routed Save back to routed/ (details)
5 validate:provenance Stamps match config.yaml
6 validate:symmetry The halves are exact mirror images
7 validate:firmware Boards match the QMK matrix
8 fab Export gerbers and assembly files
9 validate:fab Audit those outputs

Every step is a hard gate. panelize runs last and is the only optional one, skipped with a note when KiKit is absent.

A step reports what it changed and what wants reading; lines that only confirm nothing needed doing are held back, since each step's closing OK: line already carries the count they would have added up to. npm run pipeline -- -v shows them all, plus Ergogen's own narration and the artifact listing file by file.

It requires existing routed masters and does not route for you: step 3 replays their traces onto the fresh boards and aborts if a master carries no human routing. For a first route, or when geometry moves enough that the old traces no longer fit, route by hand in KiCad (Step 4).

Validation

Gate Checks
validate:provenance Every board's stamp still matches config.yaml, and the boards in a stage were all built in the same run, so neither a stale master nor a half restored on its own can reach fab
validate:symmetry The two halves are exact mirror images: outline, parts, pads, rule areas and silk, compared in each board's own frame so the per-board recentering is not read as asymmetry
validate:firmware Both halves wire the MCU header identically, and the matrix they imply equals the QMK keyboard.json
validate:fab The exported artifacts: a board-spanning GND plane on both master and gerbers, a complete gerber set, a non-empty BOM and CPL with every assembled footprint appearing in the CPL exactly once, outputs no older than their sources, comparable teardrop counts across the halves

validate:fab also warns, without failing, when a master was built from an uncommitted tree: the board is fine, but its recorded commit means nothing, so the warning is expected during ordinary in-progress work.

validate:symmetry holds the halves to being exact mirrors of each other. The licensed exception is the keys of the outer pinky columns, which differ by design (the left pinky is 1.5u; the right is 1u plus an extra inner column). Drift here is otherwise invisible: both halves still route, pass DRC, fab and assemble, and it surfaces only as a case that fits one half and rocks on the other, or a clearance that is comfortable on one half and marginal on the other. Both halves are generated from one set of mirrored anchors in config.yaml, so a failure is a config change to undo rather than a board to edit.

Four kinds of part are placed in the same orientation on both halves rather than mirrored, because the parts themselves cannot be: an MX switch and its Kailh socket have an asymmetric pin pattern, the matrix diode wires straight to a switch pin and follows it, the MCU is a module that plugs in one way up, and the TRRS jack mounts on the back of both halves with the same pin pattern. Their pads therefore land at mirrored positions carrying the opposite pin at each. Key placements still mirror exactly, and so does every clearance measured from key geometry; only pad-to-edge distances within a key differ between the halves. Hand routing is not compared at all.

validate:firmware reads the keyboard.json at config.FIRMWARE in package.json, which ships as a path to a sibling qmk_firmware checkout so the check covers what you are about to flash rather than what is pushed. Without that checkout the step fails and no gerbers are produced; pass --firmware <url|path> or set $SPLINTER_FIRMWARE_JSON instead.

Provenance stamp

The copy steps and manual routing let routed/ drift from config.yaml, so you could fab a stale board. npm run ergogen stamps each board with a hash of config.yaml; fab refuses a drifted or unstamped master, and validate:provenance checks without fabbing. Clear a mismatch by re-running the pipeline, re-routing if needed.

Restoring or rebuilding one half on its own is invisible to the hash, since identical config bytes hash the same across builds, so validate:provenance also requires every board in a stage to carry the same stamp. The two stages are compared separately: unrouted/ is expected to be newer than routed/ between an ergogen run and the save back to the masters.

A panel inherits its masters' stamp rather than getting one of its own, so its commit= names the commit the panelled copper came from. Masters whose stamps disagree stop panelize, since a panel merged from two generations of board has no single provenance to report.

Only config.yaml is hashed, so a footprint .js or Ergogen-version change can move geometry without tripping the check, while a comment-only config edit trips a false "stale".

Reproducing a previous build

A stamp names the commit the build's inputs came from, never the commit that contains the stamped board: the stamp is written into the board, so it can only name its own parent. To get the boards a stamp describes, check that commit out and rebuild.

git checkout <the commit= from the stamp>
npm run pipeline

Taking kicad/routed/*.kicad_pcb straight from that commit instead gives you the previous build's masters. Rebuilding reproduces copper, drill, mask, paste, outline and the assembly files identically; only the built= timestamp drawn on the silk and a few microns of teardrop fill tessellation differ.

About

A 62-key split columnar ergonomic keyboard

Topics

Resources

Stars

7 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages