Skip to the content.

Parts as code: from a parameter to a spinning preview

OpenSCAD Prusa k3s

The mechanical layer of the estate. Every part is source: an OpenSCAD file or a standard-library Python generator, with its bolt patterns and clearances as named parameters. The mesh, the G-code and the previews are build output, rebuilt from the source. The G-code is never committed. The rover’s meshes and previews are, since 2026-10-07, so they are available offline, and the previews on this page are copies committed to this public site repository. The source repository, 79 commits as of 2026-10-27, is private. This page is published from it by one command that renders the previews on the workstation, not on the cluster. Only the internal gallery renders on the cluster, in a pod.

The design idea is the one in the first decision record: buy the interfaces, print everything between them. Motors, boards and batteries come off a shelf; the brackets, decks and docks that join them are a parameter change and a few hours away. That moves mechanics into the same fast-iteration loop as the software.

The chain

flowchart LR
  SRC[".scad / .py<br/>private repo"] -->|"clone · read-only key"| POD["gallery pod<br/>OpenSCAD · ffmpeg"]
  POD --> STL[".stl"]
  STL --> GIF["turntable .gif<br/>36 frames"]
  GIF --> WEB["internal gallery"]
  STL -->|PrusaSlicer| GC[".gcode"]
  GC -->|PrusaLink| PR["printer"]
  SRC -->|"make publish · renders locally"| WS["workstation render"]
  WS --> PAGES["this page"]

What was wrong first

Most of the work here was finding out that the first answer was wrong. In order:

1. The renderer’s animation mode could not animate. The Debian OpenSCAD (2021.01, the version the pod ran when I checked on 2026-10-09) fails under a virtual display once asked for eight or more animation frames, and its viewport variables are ignored outside animation mode. Every one of the 19 parts failed in the container while all of them worked on the workstation, which runs a newer build. The fix is dull: render one process per frame with an explicit camera, in parallel.

2. You cannot judge a GIF by looking at its frames. The first guard against a broken render counted distinct frames in the GIF. Palette dithering makes that meaningless: one input image became four distinct GIF frames, and a camera that never moved scored more frame-to-frame difference (3.9) than a part that was spinning (2.5). A threshold on top of that then failed genuine parts for being symmetric. A round pencil pot turning on its axis looks exactly like a still picture of one. The guard that survived works on the raw PNG frames by exact hash:

It has a unit test that feeds it a still sequence and a blank one and requires it to refuse both. A guard that has only ever passed tells you nothing.

3. “It clones fine”, on a machine that was logged in. The repository is private. The workstation cloned it without complaint because the workstation had credentials. From a clean container, the same clone failed. The fix is a deploy key scoped to this one repository: generated by a make target, registered through the API, and then asserted read-only by reading it back, rather than assumed from the flag that was passed. The pod checks the host key against a pinned copy of GitHub’s published keys, and the access test proves two things: the key can read the repository, and a connection to a host with an unknown key is refused. A pinning configuration that has never refused anything looks exactly like no pinning at all.

4. No user exists for uid 1000. The image ran as a numeric non-root user with no entry in /etc/passwd. HTTPS never cared. SSH refuses to start. The access test found it before the pod did, which is the only reason that test exists.

5. Two gates that could not fail. The lint step and the manifest validation both ended in || true. They printed their findings and let the build continue, which is worse than having no gate, because it looks like one. Both are real now, and the lint gate has since caught a sourced script that declared no shell.

6. A push that logged an error and worked. The image push reported a timeout against the registry, retried, and succeeded. Neither the error line nor the exit code settles which of the two happened; the registry’s own tag list and a manifest fetch did. This one is from memory: I cannot point to a record of it, so it is the only lesson here you cannot check.

7. A poll loop that worked exactly once. The gallery built its first render at 2026-10-09 04:59 UTC and has not managed a clone since: every fifteen-minute poll logs clone FAILED; still serving cab74a3, with getcwd() failed among the errors. It is serving a correct but frozen gallery. My reading of the script, not yet a fix, is that the loop deletes its working directory while standing in it. The container test runs a single pass, so it could not see a second one. The fix needs a test that runs two iterations first.

Re-read 2026-10-27: unchanged, and now demonstrated twice. The pod was redeployed on about 20 October, rendered once at start (it now serves a newer revision), and has failed every poll since with the same error. The bug is a property of the loop, not of that pod, and it is still unfixed.

8. A gate broken by the file it protects. Tracking the rover’s meshes made the smoke test’s expected count wrong: it counts every .stl under models, which is 28 files here (7 of them build output, plus one untracked third-party model), while the gallery serves 20 parts. A check that cannot pass is as useless as one that cannot fail. I have not run make smoke to watch it say so; that is from reading the script and counting the files.

The parts

Every part in the repository, rendered from the committed source. The list below was generated on 2026-10-27 from aa1c78e: 29 parts in 14 models. A model that exists only on the workstation, such as a third-party download, is never published; the generator now skips anything git does not track. The camera tilt mount is modelled but not yet printed, and its horn and shaft dimensions are assumed. The drone frame and its floats are finished work, not roadmap: a later decision narrowed the scope to ground machines, because an aircraft is a different platform rather than another profile of the same one.

29 parts in 14 models, rendered from aa1c78e.

bracket — gusseted L-bracket

bracket
bracket
l_bracket
l_bracket

cube — 20 mm calibration cube

calibration_cube_20mm
calibration_cube_20mm

cupholder — boat / bulkhead cup holder

cupholder
cupholder

drone_frame — printable 5” quad frame

arm
arm
frame_plate
frame_plate
motor_mount
motor_mount

keychain_tag — custom name tag

keychain_tag
keychain_tag

pencil_pot — round cup / small plant pot

pencil_pot
pencil_pot

phone_stand — angled phone / tablet stand

phone_stand
phone_stand

PRIVAT / INDKØRSEL FORBUDT: a no-entry sign with a PRIVAT bar

private_sign
private_sign

quad_floats — bolt-on water landing gear

float_lid
float_lid
float_pod
float_pod

rover_deck — printable payload deck for theVideoRover

battery_tray
battery_tray
bumper
bumper
camera_mast
camera_mast
deck
deck
pi_tray
pi_tray

rover_dock — charge + alignment dock for theVideoRover

dock
dock

sg90_pan_mount: the camera pan servo, screwed to the LEGO baseplate

cal_post
cal_post
cal_post_left
cal_post_left
cal_rail
cal_rail
cam_tilt_mount
cam_tilt_mount
cam_tilt_spacer
cam_tilt_spacer
fit_gauge
fit_gauge
pan_anchor
pan_anchor
pan_mount
pan_mount

split_demo — when the part is bigger than the printer

split_beam
split_beam

technic_baseplate — a big LEGO-pin baseplate, in two halves

technic_baseplate
technic_baseplate

Written for publication. No hostnames, addresses or credential locations appear here, and a guard fails the build if they ever do. The parts section is regenerated by the source repository’s make publish, which runs that same guard before it commits.