This file provides guidance to Claude Code (claude.ai/code) when working in the
PolyKybd repo: the KiCad boards under poly_kybd/, the OpenSCAD case and
printed parts under parts/.
For the firmware see ../qmk_firmware/CLAUDE.md;
for the host app ../PolyKybdHost/CLAUDE.md. The
code-review conventions and branching rules in the qmk file apply to
every PolyKybd repo, this one included — in particular: start each piece of work
on a fresh branch cut from the updated default (master here), and never
keep committing to a branch whose PR has merged.
One folder per part group under parts/, and every generated mesh under
parts/export/<same folder name>/. So a part's sources and its build/verify
scripts sit together, and nothing generated is ever mixed in with a source.
| Path | What |
|---|---|
poly_kybd/*.kicad_pcb |
the boards; the authoritative source for hole positions and rotations |
parts/<group>/ |
the CAD sources for one part group, plus the scripts that build and verify it |
parts/export/<group>/ |
everything generated: .stl, .step, .3mf. Never hand-edited |
parts/models/ |
reference meshes (display glass, FFC cable) and plate.scad — visualisation only, never printed |
parts/README.md |
the index: which part, which source, what to print |
The groups are case (every case variant: the FDM split72 left/right, the
metal/CNC one, POM, right2 and the right-side case, plus the spacer they
share and the STEP pipeline under case/step/), diffuser,
keycap_stem, display_holder, cirque_insert, cover_insert,
rotary_enc_insert, legs.
case/ — they SHARE the imported KiCad SVG
outlines. right_side.scad and case_polykybd_split72_lr.scad both
import("poly_kb_wave_right_case-*.svg"), and an import() path resolves
relative to the .scad file just as use <> does, so splitting the variants
into sibling folders silently breaks whichever one loses the SVGs.
.scad beside the sources
and use <> it, because use <> resolves relative to the .scad file, not
the cwd (see the trap below). parts/diffuser/check_frame.py writes its temp
driver into parts/case/ for the same reason — it needs
case_polykybd_split72_lr.scad's right_spacer() and reaches the frame as
use <../diffuser/diffuser_frame_left.scad>. Both are gitignored as
_build_*.scad / _check_tmp_*.scad; moving either script to another folder
breaks the resolution silently, into an empty object.
Read geometry from the .kicad_pcb, not from the SVG exports — the SVGs
carry a DPI-scaling risk and lose the per-part rotations (the split72 thumb keys
run up to 20° off axis). parts/diffuser/gen_diffuser_frame.py is the worked
example; the investigate-kicad-pcb skill covers reading boards generally.
OpenSCAD Model, not solid), because they were exported from the GUI, whose
default is binary. Neither format diffs usefully, so the rule is match the
sibling files in the same export folder, which is what the two build scripts
already do (--export-format asciistl for the frames, binstl for the stems).
It matters beyond taste: check_frame.py's load_stl() parses ASCII only and
refuses a binary file outright, so an ASCII→binary slip on a frame turns a
verification into an error.
The investigate-kicad-pcb skill is the entry point for reading a board with Python —
it carries the tool choices and the geometry pitfalls. What follows is board-level fact
that outlives any one investigation.
-
⚠️ *.kicad_schis authoritative.poly_kb.net,poly_kb.xmland the KiCad-5.schfiles are NOT. Those are generated artifacts last touched 2024-02-17 and they describe a design two board revisions old — they still name the LED asWS2812B-Mini/C527089, which the BOM history shows was replaced byXL-3030RGBC/C5349958between v3 and v3.2. Nothing labels them stale, so they read as current: an AI reviewer read one in 2026-08 and filed a confident finding that the live LED was the 2024 part. Either regenerate them or delete them; until then, treat any answer sourced from them as two revisions out of date. -
⚠️ Grep for the PART, not the reference designator. Symbol instances store onlyreferenceandunit;Value,FootprintandMPNare symbol-level, shared by every instance of a hierarchical sheet. Sogrep '"U24"' *.kicad_schfinds nothing whileU24sits happily on the board — it is one of 14 instances ofni_buffer2.kicad_sch(2 symbols → 28 SOT-353 buffers). This produced a false "orphan footprints declared by no schematic" alarm in 2026-08, and it is the same mistake shape as theNexperia595one already recorded inPolyKybdHost/CLAUDE.md— searching a designator instead of the thing itself. Once was evidently not enough; search by value,lib_id, or footprint name. -
MPNhere usually holds an LCSC CATALOG ID, not a manufacturer part number — that is the convention, not a defect. 39 of the 46MPNproperties inrp_pico.kicad_schareC\d+values identical to the part's ownLCSCproperty; only five carry a real part number, and the true manufacturer p/n normally lives inValue(e.g.USB1isMPN C165948/Value TYPE-C-31-M-12). An AI reviewer flagged this as a fault introduced by a PR that had only changed that symbol'sFootprint(2026-08). Before filing it as a finding, check whether the symbol's MPN differs from the merge base at all — and note the fix, if one is ever wanted, is a board-wide sweep of ~39 symbols, not a one-part edit. -
A shared sheet cannot vary a part per board. All four boards include
rp_pico.kicad_sch, so aValue/Footprintchange to U9 lands on split72 and split42 whether you want it or not. There is no per-instance override. If a variant genuinely needs a different part, extract just that part into its own small sub-sheet and fork that — forkingrp_pico.kicad_schduplicates 45 unrelated symbols (46 placed symbols, 14.5k lines) to vary one, and they will drift. -
⚠️ The JLC fab exporter builds the BOM from the BOARD's footprint properties, not the schematic — and Update PCB from Schematic does not refresh them by default. So a correct schematic still exports a wrong BOM: after the U9 flash swap theLCSC Part #column (the only column JLC orders from) still named the superseded WinbondC5440778while the footprint andValuewere the new Boya part — a DFN 4x4 ordered onto an LGA-8 land. Sweep board-vs-schematicMPN/LCSC/JLC/Manufacturerafter every export.⚠️ Also: the 36 hot-swap sockets sit onF.Cuin the board but mount physically on the BOTTOM, so every CPL export writestopfor them and has to be flipped by hand; the as-fabricated v3.2 CPL is the reference to diff against. Full detail, plus the per-part decisions and the open D2 RoHS row, inpoly_kybd/Gerber/PCB/FABRICATION-NOTES.md— read it before re-exporting. -
A footprint the boards use but no library provides gets EXTRACTED from the board, not hunted upstream. Three now live in
poly_kb.prettythis way: the USB-C connector (HRO-TYPE-C-31-M-12-Assembly, upstreamEnvUSBabsent),RP2040-QFN-56(upstreamkeebioabsent) andCP_EIA-3216-18_Kemet-A— the last for a different reason worth remembering: the stock KiCad tantalum footprint has since grown ~0.05 mm, enough to produce clearance violations against routing already on the boards, so the as-built copy is kept deliberately and must not be "updated" from the stock library without re-running DRC. All three stay on B.Cu: every instance of each, on every board, is bottom-mounted, so there is no front-side instance to copy and converting would need a front/back mirror with nothing to verify against. The mechanics (local x,y but absolute pad angles, which instance junk to strip, and comparing pad layers as a set) are in theinvestigate-kicad-pcbskill. -
Land-pattern changes: the target is solder THICKNESS, not solder volume. When extending a pad (e.g. the LED toe, 0.9 → 1.25 mm in 2026-08), paste must scale with the copper to hold the same ~0.050 mm³ of solder per mm² of pad. Insetting the paste to "limit added solder" spreads roughly the same solder over more copper and yields a joint thinner than the one you started with — the opposite of the intent. A long bare copper toe with paste held well back is worse still: bare copper adjacent to a joint robs solder outward and thins the fillet. Two corollaries worth keeping:
- Tombstoning is not a mechanism on a 4-pad part. Drawbridging needs a two-terminal component able to rotate about one end; four symmetric corner pads constrain both axes, and extending all four equally preserves self-centering. The real volume risk is float/tilt, which scales with how much you added (+39 % was accepted, +111 % was not).
- Extend the toe only, never the heel. Keeping the heel fixed means nothing under the package moves, so the paste over the terminal is unchanged and the addition lands on bare toe copper where the fillet forms.
-
Measure the re-route cost before picking a pad size. Rectangle-to-segment against every
F.Cutrack and via, own-net excluded, per board. For the 2026-08 LED toe that turned "1 mm each?" into a table (1.40 → 21 pads/15 overlaps on the right board; 1.25 → 19/9) and picked the size.⚠️ A 0.15 mm threshold is stricter than the board's own rule — the negative-clearance overlaps are the real count, not the flagged total. Field-confirmed: 5 flagged on the left board, 0 overlaps, and exactly 1 trace actually needed moving.
The CLI is 2021.01 (CGAL backend only — there is no Manifold backend here, so advice that starts "switch to Manifold" does not apply).
# export geometry -- no display needed
openscad -o out.stl --export-format asciistl part.scad
# render a PNG -- display IS needed
xvfb-run -a openscad -o view.png --imgsize=1000,700 --camera=… --render=cgal part.scad.claude/skills/explain-geometry-figure/scad_view.sh takes out.png model.scad [top|front|iso|<camera>], handles the missing-display and empty-PNG traps, and
documents both --camera forms (7 numbers = gimbal transx,y,z, rotx,y,z, dist;
6 = eye, centre). It was written for that skill's figures but is a general
viewer. This is worth stating because it did not get used: a session spent ~12
hand-built --camera/--imgsize/--render invocations, and five of those were
wasted purely on camera geometry, while the wrapper sat one directory away in
this repo (2026-08-17). Same lesson as the control-server deadlock in
PolyKybdHost/CLAUDE.md — the remedy was already in the tree and the failure
was search, not design. Search the skills for a helper before writing one —
recursively, since each skill is its own directory:
grep -RIn '<what you need>' .claude/skills/.
Four traps, each of which has cost real time:
⚠️ use <x.scad>resolves relative to the .scad FILE, not the cwd. A helper written in the wrong directory silently finds no modules, so the top-level object is empty — and for an intersection test that is indistinguishable from "no collision". It is a false PASS, not an error. Always pair a clearance/collision test with a positive control that displaces the part a couple of mm and confirms the test still reports an overlap;parts/diffuser/check_frame.pydoes exactly this.⚠️ openscad exits1for an EMPTY result and1for a syntax error alike (both verified). So the return code cannot classify the outcome: test for theCurrent top level object is emptymarker first, then treat any remaining non-zero exit as a failure. Getting this backwards makes every clean no-collision result raise.- An empty top-level object writes no output file, so a script that reuses one output path across several runs will silently re-read the previous run's mesh. Give each invocation its own output file.
$fnset at your top level does NOT override a$fn=hard-coded inside a module's primitives. The facets you see are the facets the STL has.⚠️ Framing a render:--viewallis loose, androtzdecides which model axis runs across the screen. Two separate camera traps, both of which read as a broken model rather than a bad camera:--viewall --autocenterfits the bounding SPHERE, so a wide, shallow subject (a row of parts) comes out small in a sea of margin — ~60% dead space on a 5-stem lineup. Give an explicitdistinstead and tune it; that is also the ortho scale in the 7-number form.- The axis you
translate()the row along must match the camera'srotz— the model's own layout, not the camera'stransx,y,z. Atrotz=0the model's X runs horizontally on screen, atrotz=90it is Y. Lay a row out along the wrong one and the parts stack in DEPTH — they overlap into a single blob, which looks like a geometry failure, not a viewpoint. Cost three renders before it was obvious. - Useful
rotxvalues, gimbal form:0top,90pure side,180straight up at the underside (what "from the backside" usually means for a keycap),70–80a 3/4 that still shows the top face.
- STL export is not byte-reproducible. Facets come out in a different order
run to run, so re-exporting an unchanged design still rewrites the whole file
(23k lines of diff on one frame), burying any real change. Compare meshes as a
sorted facet multiset, not with
cmp— and when only the order moved, put the committed bytes back.parts/diffuser/build_frame.shdoes this automatically; the same trick is what proves a refactor left the solid alone.
use <> imports a file's modules and ignores its top-level geometry, which is
how parts/diffuser/diffuser.scad can render a whole print plate on its own while
diffuser_frame_*.scad pulls just diffuser() out of it. led_caps.scad
(the superseded earlier generation, kept beside it) defines diffuser(),
diffuser_cluster() and torus() under the SAME names — so a file that
use <>s both silently gets one set of definitions.
parts/diffuser/build_frame.sh is the whole loop — regenerate the .scad from
the board, export every STL (both frames plus both stacked ones), then verify.
Run it after any edit to diffuser.scad or the generator; --no-4x skips the
slow stacked exports for a quick iteration, --check verifies without exporting.
Doing the steps by hand is where they get missed: a diffuser.scad change alters
the stacked pair too, and forgetting them leaves those a revision behind.
parts/diffuser/check_frame.py is the verifier it calls: watertight, minimum wall,
left/right symmetry, plate trap, and spacer clearance in both flip orientations.
It exits non-zero on failure. Extend it rather than re-deriving these by hand —
and note it deliberately reports FAIL rather than raising, because a gating script
that crashes on a malformed input tells you nothing about the design.
Picking a wall-thickness metric is itself the hard part — three of them were wrong before the fourth answered the question:
- Inward-normal ray-cast on the STL (what
min_wall()does) is correct where two walls are parallel, which covers a plate-like part in z. It is wrong near a corner: the normal runs oblique to the far face and overestimates — it read 3.0 mm across a wedge whose real wall was 1.55 mm. - Distance to the boundary is not thickness. Every point near any edge scores low, so the "thin area" comes out enormous and meaningless.
- For in-plane features, rasterise the profile and apply a morphological opening (a disc of radius t/2 must fit). That is the test a print service runs, and it is what finally ranked an axis-aligned trim against a 45° one.
- Report the AREA below a threshold, not the infimum. Every polygon corner tapers to zero thickness at its apex, so the minimum is always ~0 and tells you nothing; how much material is thin is the number that decides anything.
case_ins_r2.stl /
case_ins_leg_v0.stl were grouped as a "case insert" on the strength of that
prefix, and separately guessed to be the plate-to-PCB spacer (they are 3.8 mm
thick, the same as right_spacer(), so the guess was reasonable). Re-exporting
legs.scad settled it in one command: same 32202 facets, same 5263.0 mm³, same
bounding box, 100 % of facets equal at 3 dp -- they are the tenting legs
(connected_8p(), 8 legs in 4 mirrored pairs). Now export/legs/legs_r2_8p.stl.
Float noise between OpenSCAD builds means an exact facet-set compare returns
False, so compare rounded, or on count+volume+bbox.
One .scad per plate in parts/keycap_stem/variants/, over a library that has
NO top-level geometry — R1..R5 curved and S1/S/S5 stepped, each in both
1U and 1U25, sixteen files. Each includes ../keycap_stem.scad and makes a
single call, so a variant renders on its own: what you open in the GUI is exactly
what gets exported, and variants/<x>.scad → export/keycap_stem/<x>.stl with no
name munging. parts/keycap_stem/build_stems.sh walks the directory and holds no
table of its own, so adding a plate is adding a file.
-
include, notuse, in a variant.useimports modules but not variables, and the engravedrevisionis a variable. That is also why the library must stay free of top-level geometry:includeexecutes it, so anything left there would appear in all sixteen plates. The photo arrangements that used to sit at the bottom of the library live inpreview_stems.scad(aview=selector), which the build script does not export. -
The profile set previously existed only as commented-out calls at the bottom of
keycap_stem.scad, exported by uncommenting one line at a time — exactly how revAlpha shipped the stepped plates while the curvedR2..R5were missing for a whole revision. -
⚠️ Verify a refactor here by re-exporting all sixteen — but expect only the plates YOUR machine last exported to reportunchanged. The library/variant split was checked this way and came back 8unchanged/ 8CHANGED, split exactly along who exported what: the eight R2–R5 plates (exported in this container) were byte-identical, while R1 and the three S plates (exported by the author on their own machine) differed. That is not a refactor failure — each of the eight was confirmed to have an identical bounding box and a volume within 0.04%, i.e. only the engraving is tessellated differently, per the Noto note above. Restore them (git checkout) rather than committing the rewrite, or you trade ~16 MB of diff for a re-tessellatedα. A single plate is not a sufficient check either way, since the two widths and the two profile families take different code paths. -
⚠️ The engraved revision silently renders in the WRONG FACE when Noto is absent.keycap_stem.scadasks fortext_font = "Noto:style=Bold", and fontconfig substitutes (DejaVu Sans Bold in a bare container) rather than failing — the plate exports fine and nothing in the output mentions it. In a fresh container:apt-get install fonts-noto-core, orbuild_stems.sh --fetch-font(per-user, no root).build_stems.shwarns viafc-match, which is the only reason this is visible at all. -
⚠️ WHICH Noto also matters —--fetch-fonton a machine that already has one will make every later re-export reportCHANGED. The engraving is tessellated from whatever file fontconfig resolves, and the downloaded variable NotoSans and a distro static NotoSans-Bold do not agree: measured 46192 vs 44912 facets on the same plate, at identical volume and bounding box. That is a real difference in the glyph outlines, well above what the settle rounding absorbs, so it is not a bug in the comparison — it is the comparison working. Use--fetch-fontto acquire a Noto where there is none, not to "refresh" one. -
R1andS1are deliberately identical geometry (angle 5, extra_len 0.5) and differ only in the engraving. That is what the source says — don't "fix" it. -
The engraved label matches the FILENAME's profile token, in a 5-character field.
txt = str("R5 ", revision)→ the keycap readsR5 α. The field is padded to 5 so the profile sits at one corner of the top face and the revision at the other; keep that width when adding a profile ("S ","R3 ","S1 "are all 5). It was not always so: the curved plates engraved a bare digit (3 α) while only the stepped ones carried a letter, so a printed stem could not be matched to the file that made it and "is the flat one R3?" was a question the part itself could not answer (2026-08-17). Note the consequence that survives: flat IS R3 — same parameters, same mesh, same engraving — so a flat stem and a curved set's R3 are one interchangeable part, not two. -
⚠️ A fullbuild_stems.shrun exceeds a two-minute tool timeout (16 plates, CGAL each). Pass name filters (build_stems.sh R1 R2 R3) or run it in the background. A run killed part-way is not harmless: it leaves the plates it did reach re-exported, which then have to be told apart from a real change by bbox and volume before being reverted. -
⚠️ The README profile pictures draw the coordinate AXES on purpose — the horizontal axis line is the REFERENCE the cap angle is read against, and without it the images are five tilted caps with nothing to measure against. The originals were GUI screenshots with the axes visible; a first scripted version dropped them as chrome and lost the one thing that made the pictures informative (field, 2026-08-17). Two rules follow, and they pull in opposite directions from the obvious instinct:- Never rotate the row to make the profile read better. A view tilt adds
itself to every cap angle without moving the axes, so the picture reports
the wrong profile: a
rotate([8,0,0])inprofile_row()made R3, which is flat by definition, sit 8° nose-up on the axis. Tilt the camera instead (CAMinrender_profiles.sh) — that moves the axes with it, so the reading stays honest. - Render with
--view=axes(2021.01 supportsaxes,scales,crosshairs,edges,wireframe).scalesadds tick labels that render rotated and unreadable at this camera, soaxesalone is the useful one. - The four views must share one camera or they cannot be compared —
a difference in elevation reads as a difference in profile. That is the
entire reason
render_profiles.shexists rather than a note about which camera to use.
- Never rotate the row to make the profile read better. A view tilt adds
itself to every cap angle without moving the axes, so the picture reports
the wrong profile: a
-
Judge a regenerated plate by bbox + volume, not by facet count. Text tessellation depends on the installed font version, so the triangle count moves between machines while the part is unchanged: regenerating the committed revAlpha R1 here reproduced its bounding box to 0.01 mm and its volume to 0.01 % while the facet count differed by 2240. That comparison is what proved the commented "Curved Profile" parameters really are the alpha R-set.
A print service will quote a 0.8 mm minimum / 1.5 mm recommended wall and refuse the part if it measures below. Three ways this bit the diffuser frame:
⚠️ A tapered rim beside passing geometry leaves a wafer. Alinear_extrude(scale=)rim sweeps its radius over its height, so any neighbouring wall whose edge lands anywhere in that band runs tangent to the slope at some height and leaves a near-zero-thickness sliver. On the frame this measured 0.043 mm where a web stem passed a diffuser's bottom cap — an order of magnitude thinner than what the vendor complained about, and invisible until measured. Use a vertical rim wherever other geometry passes close: one radius means a neighbour can only clear it or merge with it.⚠️ linear_extrude(scale=)chamfers change ANGLE if you change their height. The inward step is proportional to the profile, not the height, so thickening a flange by raising the chamfer layer lays the chamfer down (34.9° → 21.8° when a flange went 1.0 → 1.5 mm). Keep the chamfer layer at its original height and put the extra thickness in the straight layer.⚠️ A minor circular segment ends in a knife edge.circle(d)cut by a chord above centre runs out to nothing; the last fraction of a millimetre is what a vendor measures and rejects. Square the end off — and prefer a cut that leans in plan over an axis-aligned one: leaning opens the corner against the chord from 90° to 135° and spends the cut on the shallow strip, which measured half the sub-0.8 mm area while keeping 4.4% more material.
Engraved text always leaves sub-0.8 mm relief between glyph strokes — no pad size fixes it, because legible text at any size that fits has strokes closer together than the threshold. It is surface relief, not a wall (the full web runs continuous underneath). Say so explicitly: exclude the engraving zone from a wall check and assert the residual material separately, rather than reporting a flattering number, and tell the vendor the same when they flag it.
When a measurement needs to convince someone who cannot run the script — a print
service, most obviously — use the explain-geometry-figure skill. It produces
a dimensioned SVG/PNG with the numbers drawn on the shape.