diff --git a/CLAUDE.md b/CLAUDE.md
index d25c68d..6d96ec2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,6 +20,10 @@ server, so `type: git` with a URL, not `github://`). The layout must stay
- `docs/20L203DA12-datasheet.pdf`: the command set (section 5.4), jumpers,
wiring and the CG-ROM tables (section 6). Check it before changing the
protocol.
+- `case/vfd-case.scad`: parametric OpenSCAD desk case (body, bezel, base) for
+ the display and a WEMOS D1 R1. After any change, run all the `check_*` parts
+ (each must be empty or zero-volume) and re-export `case/stl/*.stl`. On
+ Windows, call `openscad.com` for console output.
- `examples/samsung-vfd.yaml`: the full reference device config. It pulls the
component from git; for local work, switch to the commented
`type: local, path: ../components` source.
diff --git a/README.md b/README.md
index 6e3a604..a3511af 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ components/samsung_vfd/ the component (Python schema + C++)
examples/samsung-vfd.yaml full example device config
examples/secrets.yaml.example
docs/ photos and the 20L203DA12 datasheet
+case/ 3D-printable desk case (OpenSCAD + STL), see case/README.md
tools/gen_charmaps.py regenerates components/samsung_vfd/charmaps.h
```
diff --git a/case/README.md b/case/README.md
new file mode 100644
index 0000000..b95b313
--- /dev/null
+++ b/case/README.md
@@ -0,0 +1,88 @@
+# Desk case
+
+A 3D-printable desk case for the Samsung 20L203 VFD and a WEMOS D1 R1
+(UNO-sized ESP8266 board). The display is tilted back 15° and bolted to the
+front with M3 screws. The DC jack and the USB port are reachable through the
+back wall.
+
+
+
+
+
+
+Outer size: about 208 × 112 × 86 mm (W × D × H). The parts need a print bed of
+at least 210 × 115 mm.
+
+## Parts
+
+| File | Print orientation | Notes |
+|------|-------------------|-------|
+| [stl/body.stl](stl/body.stl) | upside down, top on the bed (as exported) | Sides, top and back wall. No supports needed. |
+| [stl/bezel.stl](stl/bezel.stl) | face down (as exported) | Front panel with the window and the display bosses. |
+| [stl/base.stl](stl/base.stl) | flat (as exported) | Floor with the board standoffs and the screw holes. |
+
+PETG is a good choice: the display warms the inside by a few degrees. PLA
+works if the case stays out of the sun.
+
+## Hardware
+
+- 10 × M3 heat-set inserts (about 4.6 mm OD, 5.7 mm long; the holes are 4.0 mm
+ and 6 mm deep):
+ - 4 in the bezel's display bosses
+ - 2 in the bezel's feet
+ - 2 in the body's rear corners
+ - 4 on the base's board standoffs
+- 4 × M3×8 to bolt the display to the bezel
+- 4 × M3×6 to hold the D1 board on its standoffs
+- 4 × M3×8 to hold the base on, from below
+
+## Assembly
+
+1. Melt the inserts in.
+2. Bolt the display to the bezel from behind, glass facing the window.
+3. Screw the D1 board onto the base, with the DC jack and USB edge at the back.
+4. Slide the bezel, with the display on it, into the body from below. Its edges
+ run in the grooves in the side walls until it stops against the top.
+5. Connect the display cable. The MAX3232 sits inline in the cable; tie it to
+ the anchor on the base.
+6. Put the body on the base and screw it together from below. The base holds
+ the bezel in place.
+
+## Measure before printing
+
+The dimensions come from the 20L203**DA12** datasheet. Your board is a DA5, so
+check these against yours and change the values at the top of
+[vfd-case.scad](vfd-case.scad) if needed:
+
+- `disp_hole_inset`, `disp_w`, `disp_h`: the display's mounting holes, 182 ×
+ 56 mm apart.
+- `glass_x0`, `glass_y0`, `glass_w`, `glass_h`, `glass_depth`: the glass
+ position and the distance from its front face to the PCB.
+- `usb_pos`: where the micro-USB port is along the D1's back edge (38 mm from
+ the jack side is a guess based on the UNO layout). Also check `jack_pos`.
+
+If only the display is off, it's enough to reprint the bezel.
+
+## Changing the design
+
+Open [vfd-case.scad](vfd-case.scad) in OpenSCAD. Every dimension is a variable
+at the top, including the tilt, wall thickness and clearances. Export a part
+with:
+
+```
+openscad -D 'part="body"' --export-format binstl -o stl/body.stl vfd-case.scad
+openscad -D 'part="bezel"' --export-format binstl -o stl/bezel.stl vfd-case.scad
+openscad -D 'part="base"' --export-format binstl -o stl/base.stl vfd-case.scad
+```
+
+`part="assembly"` shows everything with dummy display and board models, and
+`part="section"` shows a cut-away. The `check_*` parts are collision checks;
+each one must render as an empty object:
+
+- `check_body_bezel`, `check_body_display`, `check_bezel_display`
+- `check_body_base`, `check_bezel_base`, `check_display_base`
+- `check_board`
+- `check_insertion`: sweeps the bezel and display along their slide-in path
+ through the body.
+
+Faces that only touch can show up as a zero-volume result, which is fine.
diff --git a/case/preview.png b/case/preview.png
new file mode 100644
index 0000000..a342b59
Binary files /dev/null and b/case/preview.png differ
diff --git a/case/section.png b/case/section.png
new file mode 100644
index 0000000..c0e6f28
Binary files /dev/null and b/case/section.png differ
diff --git a/case/stl/base.stl b/case/stl/base.stl
new file mode 100644
index 0000000..c39433a
Binary files /dev/null and b/case/stl/base.stl differ
diff --git a/case/stl/bezel.stl b/case/stl/bezel.stl
new file mode 100644
index 0000000..3267851
Binary files /dev/null and b/case/stl/bezel.stl differ
diff --git a/case/stl/body.stl b/case/stl/body.stl
new file mode 100644
index 0000000..ee116a6
Binary files /dev/null and b/case/stl/body.stl differ
diff --git a/case/vfd-case.scad b/case/vfd-case.scad
new file mode 100644
index 0000000..f657615
--- /dev/null
+++ b/case/vfd-case.scad
@@ -0,0 +1,295 @@
+// Desk case for the Samsung 20L203 2x20 VFD and a WEMOS D1 R1 (UNO-sized ESP8266).
+//
+// Three parts, all printable without supports:
+// body sides, top and back wall (DC jack + USB cut-outs). Print upside down, top on the bed.
+// bezel 15 degree front panel with the window and the display bosses. Print face down.
+// base floor with the board standoffs. Print flat.
+//
+// Assembly:
+// 1. Melt M3 heat-set inserts into: 4 display bosses + 2 feet on the bezel,
+// 2 rear corner bosses in the body, 4 board standoffs on the base.
+// 2. Bolt the display to the bezel from behind (4x M3x8).
+// 3. Screw the D1 board onto the base (4x M3x6).
+// 4. Slide the bezel (with display) up into the body's side grooves from below.
+// 5. Wire up, put the body on the base and screw it together from below (4x M3x8).
+//
+// Render one part: openscad -D 'part="body"' -o body.stl vfd-case.scad
+// Parts: "assembly" (preview with dummy display + board), "body", "bezel", "base",
+// "check_*" (collision checks, see the end of this file).
+
+part = "assembly";
+section_x = 130; // cut position for part = "section"
+
+/* [Case] */
+tilt = 15; // display tilt back from upright, degrees
+wall = 3; // side, top and back wall thickness
+lip = 1.5; // how far the bezel sits behind the front edge of the body
+bezel_t = 3; // bezel thickness
+groove = 1.5; // how deep the bezel edges sit in the side walls
+clr = 0.3; // fit clearance for sliding / mating parts
+base_t = 3.5; // base plate thickness
+depth = 112; // outer depth (front bottom edge to back)
+top_margin = 4; // bezel above the display's top edge (along the panel)
+
+/* [Display module, datasheet 4.10] */
+disp_w = 190;
+disp_h = 64;
+disp_hole_inset = 4; // hole centres from the PCB edges (182 x 56 spacing)
+glass_x0 = 3.0; // glass left edge from PCB left edge (front view)
+glass_y0 = 7.85; // glass bottom edge from PCB bottom edge
+glass_w = 175.2;
+glass_h = 48.3;
+glass_depth = 12.0; // glass front face to PCB front face
+glass_gap = 0.5; // air gap between bezel and glass (datasheet asks for one)
+pcb_t = 1.6;
+disp_back = 11; // components behind the display PCB
+disp_raise = 14; // PCB bottom edge, measured along the panel from the bottom
+
+/* [Window] */
+win_w = 156;
+win_h = 36;
+win_r = 2;
+win_chamfer = 1.5;
+
+/* [Heat-set inserts, M3] */
+insert_d = 4.0; // hole for the insert
+insert_depth = 6;
+boss_d = 7; // display bosses (larger would touch the glass)
+screw_d = 3.4; // M3 clearance
+head_d = 6.2; // M3 head counterbore in the base
+head_depth = 2;
+
+/* [WEMOS D1 R1 (Arduino UNO footprint)] */
+board_l = 68.58; // along the case depth, connector edge at the back
+board_w = 53.34;
+board_gap = 0.5; // board edge to back wall
+standoff_h = 6;
+standoff_d = 7;
+// UNO hole positions: (distance from the connector edge, distance from the jack side)
+board_holes = [[13.97, 2.54], [15.24, 50.8], [66.04, 7.62], [66.04, 35.56]];
+jack_pos = 7.6; // DC jack centre from the board's jack-side edge
+jack_cut = [12, 13]; // width, height (from 0.5 below the PCB top)
+usb_pos = 38.1; // micro-USB centre from the jack-side edge -- measure yours!
+usb_cut = [12, 8]; // width, height (centred 1.6 above the PCB top)
+board_comp_h = 10; // tallest part on the board (dummy for the collision check)
+
+$fn = 48;
+
+// ---------- derived ----------
+gx_c = glass_x0 + glass_w / 2; // glass centre from PCB left
+inner_w = 2 * (max(gx_c, disp_w - gx_c) + 1.5); // window centred, 1.5 mm side clearance
+outer_w = inner_w + 2 * wall;
+disp_x0 = inner_w / 2 - gx_c; // PCB left edge, x
+z_top_in = (disp_raise + disp_h + top_margin) * cos(tilt); // top wall underside
+height = z_top_in + wall;
+n_bezel_in = lip + bezel_t; // bezel inner face (local N)
+n_glass = n_bezel_in + glass_gap;
+n_pcb = n_glass + glass_depth;
+win_s = disp_raise + glass_y0 + glass_h / 2; // window centre along the panel
+board_x0 = inner_w / 2 - board_w / 2;
+board_conn_y = depth - wall - board_gap; // y of the board's connector edge
+pcb_top_z = standoff_h + pcb_t;
+foot_n = n_bezel_in + 6; // insert axis in the bezel feet
+foot_y = foot_n / cos(tilt);
+foot_xs = [inner_w * 0.25, inner_w * 0.75];
+rear_boss = 9; // rear corner boss block size
+rear_screws = [[rear_boss / 2, depth - wall - rear_boss / 2],
+ [inner_w - rear_boss / 2, depth - wall - rear_boss / 2]];
+base_screws = concat([for (x = foot_xs) [x, foot_y]], rear_screws);
+
+echo(str("Outer size: ", outer_w, " x ", depth, " x ", height + base_t, " mm (W x D x H)"));
+
+// Panel-local frame: X = across, S = up the panel from the bottom front edge,
+// N = into the case from the body's front plane. The matrix is its own inverse.
+M = [[1, 0, 0, 0],
+ [0, sin(tilt), cos(tilt), 0],
+ [0, cos(tilt), -sin(tilt), 0],
+ [0, 0, 0, 1]];
+module panel() multmatrix(M) children();
+
+module rrect(w, h, r) {
+ offset(r) square([w - 2 * r, h - 2 * r], center = true);
+}
+
+// 2D side profile (y, z) extruded along x.
+module side_extrude(w) rotate([90, 0, 90]) linear_extrude(w) children();
+
+// ---------- body ----------
+module body() {
+ difference() {
+ union() {
+ translate([-wall, 0, 0]) side_extrude(outer_w)
+ polygon([[0, 0], [depth, 0], [depth, height], [height * tan(tilt), height]]);
+ }
+ // cavity, open at the front and bottom
+ translate([0, -50, -1]) cube([inner_w, depth - wall + 50, z_top_in + 1]);
+ // bezel grooves in the side walls
+ intersection() {
+ panel() translate([-groove - clr, -50, lip]) cube([inner_w + 2 * (groove + clr), 300, bezel_t + clr]);
+ translate([-50, -50, -1]) cube([inner_w + 100, depth + 100, z_top_in + 1]);
+ }
+ // DC jack and USB
+ for (c = [[jack_pos, jack_cut, pcb_top_z - 0.5 + jack_cut[1] / 2],
+ [usb_pos, usb_cut, pcb_top_z + 1.6]])
+ translate([board_x0 + c[0], depth - wall / 2, c[2]])
+ rotate([90, 0, 0]) linear_extrude(wall + 2, center = true) rrect(c[1][0], c[1][1], 1.5);
+ // top vents over the back half
+ for (i = [0 : 3]) translate([inner_w / 2, depth - wall - 14 - i * 8, height - wall / 2])
+ linear_extrude(wall + 2, center = true) rrect(inner_w - 50, 3, 1.4);
+ }
+ // rear corner bosses for the base screws, chamfered up into the walls
+ for (p = rear_screws) {
+ x0 = p[0] < inner_w / 2 ? 0 : inner_w - rear_boss;
+ xw = p[0] < inner_w / 2 ? 0 : inner_w - 0.01;
+ difference() {
+ hull() {
+ translate([x0, depth - wall - rear_boss, 0]) cube([rear_boss, rear_boss, 10]);
+ translate([xw, depth - wall - 0.01, 0]) cube([0.01, 0.01, 10 + rear_boss]);
+ translate([x0, depth - wall - 0.01, 0]) cube([rear_boss, 0.01, 10 + rear_boss]);
+ translate([xw, depth - wall - rear_boss, 0]) cube([0.01, rear_boss, 10 + rear_boss]);
+ }
+ translate([p[0], p[1], -1]) cylinder(d = insert_d, h = insert_depth + 1);
+ }
+ }
+}
+
+// ---------- bezel ----------
+display_holes = [for (hx = [disp_hole_inset, disp_w - disp_hole_inset])
+ for (hy = [disp_hole_inset, disp_h - disp_hole_inset])
+ [disp_x0 + hx, disp_raise + hy]];
+
+module bezel() {
+ difference() {
+ intersection() {
+ panel() union() {
+ // plate
+ translate([-groove + clr, -50, lip]) cube([inner_w + 2 * (groove - clr), 300, bezel_t]);
+ // display bosses, from the bezel to the PCB front face
+ for (h = display_holes) translate([h[0], h[1], n_bezel_in - 0.01])
+ cylinder(d = boss_d, h = n_pcb - n_bezel_in + 0.01);
+ // feet that take the front base screws
+ for (x = foot_xs) translate([x - 6, -10, n_bezel_in - 0.01]) cube([12, 22, 12]);
+ }
+ // flat bottom on the base, flat top against the top wall
+ translate([-50, -50, 0]) cube([inner_w + 100, depth + 100, z_top_in - clr]);
+ }
+ // window, chamfered on the front
+ panel() translate([inner_w / 2, win_s, 0]) {
+ translate([0, 0, lip - 1]) linear_extrude(bezel_t + 2) rrect(win_w, win_h, win_r);
+ hull() {
+ translate([0, 0, lip - 0.01]) linear_extrude(0.01) rrect(win_w + 2 * win_chamfer, win_h + 2 * win_chamfer, win_r + win_chamfer);
+ translate([0, 0, lip + win_chamfer]) linear_extrude(0.01) rrect(win_w, win_h, win_r);
+ }
+ }
+ // inserts in the display bosses
+ panel() for (h = display_holes) translate([h[0], h[1], n_pcb - insert_depth])
+ cylinder(d = insert_d, h = insert_depth + 1);
+ // inserts in the feet (vertical)
+ for (x = foot_xs) translate([x, foot_y, -1]) cylinder(d = insert_d, h = insert_depth + 1);
+ }
+}
+
+// ---------- base ----------
+module base() {
+ difference() {
+ union() {
+ translate([-wall, 0, -base_t]) cube([outer_w, depth, base_t]);
+ // locating rim inside the side and back walls (stops short of the bezel and bosses)
+ rim_y0 = 20;
+ for (x = [clr, inner_w - clr - 1.6])
+ translate([x, rim_y0, -0.01]) cube([1.6, depth - wall - rear_boss - clr - rim_y0, 2]);
+ translate([rear_boss + clr, depth - wall - clr - 1.6, -0.01])
+ cube([inner_w - 2 * (rear_boss + clr), 1.6, 2]);
+ // board standoffs
+ for (h = board_holes) translate(board_xy(h)) cylinder(d = standoff_d, h = standoff_h);
+ // cable-tie anchor for the MAX3232 / display cable
+ translate([inner_w / 2, board_conn_y - board_l - 4.5, 0]) tie_anchor();
+ }
+ // screws into the body / bezel inserts, counterbored from below
+ for (p = base_screws) translate([p[0], p[1], -base_t - 1]) {
+ cylinder(d = screw_d, h = base_t + 2);
+ cylinder(d = head_d, h = head_depth + 1);
+ }
+ // board standoff inserts
+ for (h = board_holes) translate(board_xy(h)) translate([0, 0, standoff_h - insert_depth + 0.5])
+ cylinder(d = insert_d, h = insert_depth);
+ // vents under the display
+ for (i = [0 : 2]) translate([inner_w / 2, 20 + i * 7, -base_t / 2])
+ linear_extrude(base_t + 2, center = true) rrect(inner_w - 70, 3, 1.4);
+ }
+}
+
+function board_xy(h) = [board_x0 + h[1], board_conn_y - h[0], 0];
+
+module tie_anchor() {
+ difference() {
+ translate([-5, -3, 0]) cube([10, 6, 5]);
+ translate([-2.5, -4, -0.01]) cube([5, 8, 3.2]);
+ }
+}
+
+// ---------- dummies for the preview and the collision checks ----------
+module display_dummy() {
+ panel() {
+ translate([disp_x0 + glass_x0, disp_raise + glass_y0, n_glass + 0.01])
+ cube([glass_w, glass_h, glass_depth - 0.02]);
+ translate([disp_x0, disp_raise, n_pcb + 0.01]) difference() {
+ cube([disp_w, disp_h, pcb_t + disp_back]);
+ for (hx = [disp_hole_inset, disp_w - disp_hole_inset])
+ for (hy = [disp_hole_inset, disp_h - disp_hole_inset])
+ translate([hx, hy, -1]) cylinder(d = 3.5, h = pcb_t + 2);
+ }
+ }
+}
+
+module board_dummy() {
+ translate([board_x0, board_conn_y - board_l, standoff_h + 0.01]) difference() {
+ union() {
+ cube([board_w, board_l, pcb_t]);
+ translate([2, 2, pcb_t]) cube([board_w - 4, board_l - 4, board_comp_h]);
+ // DC jack, sticking out 1 mm past the board edge
+ translate([jack_pos - 4.5, board_l - 14, pcb_t]) cube([9, 15, 11]);
+ }
+ for (h = board_holes) translate([h[1], board_l - h[0], -1]) cylinder(d = 3.2, h = pcb_t + 2);
+ }
+}
+
+// ---------- output ----------
+if (part == "assembly") {
+ color("SlateGray", 0.5) body();
+ color("DimGray") bezel();
+ color("Gainsboro") base();
+ color("SeaGreen") display_dummy();
+ color("SteelBlue") board_dummy();
+} else if (part == "section") {
+ // assembly cut at x = section_x, for checking the inside
+ intersection() {
+ union() {
+ color("SlateGray") body();
+ color("DimGray") bezel();
+ color("Gainsboro") base();
+ color("SeaGreen") display_dummy();
+ color("SteelBlue") board_dummy();
+ }
+ translate([section_x, -50, -50]) cube([500, 500, 500]);
+ }
+} else if (part == "body") {
+ translate([0, depth, height]) rotate([180, 0, 0]) body(); // top on the bed
+} else if (part == "bezel") {
+ translate([0, 0, -lip]) multmatrix(M) bezel(); // front face on the bed
+} else if (part == "base") {
+ translate([0, 0, base_t]) base();
+}
+// Collision checks: each must render as an empty object.
+else if (part == "check_body_bezel") intersection() { body(); bezel(); }
+else if (part == "check_body_display") intersection() { body(); display_dummy(); }
+else if (part == "check_bezel_display") intersection() { bezel(); display_dummy(); }
+else if (part == "check_body_base") intersection() { body(); base(); }
+else if (part == "check_bezel_base") intersection() { bezel(); base(); }
+else if (part == "check_board") intersection() { union() { body(); bezel(); base(); display_dummy(); } board_dummy(); }
+else if (part == "check_display_base") intersection() { base(); display_dummy(); }
+// The bezel slides in from below along the panel: sweep it (with the display) down and check.
+else if (part == "check_insertion") intersection() {
+ body();
+ for (k = [5 : 5 : 80]) translate(-k * [0, sin(tilt), cos(tilt)]) { bezel(); display_dummy(); }
+}