Initial commit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:28:01 -07:00
co-authored by Claude Sonnet 5.5
commit 4bd4729b46
44 changed files with 519803 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
# Hermes robot enclosure
![Assembly](renders/assembly.png)
An upright cabinet for the Raspberry Pi 4 and BrainCraft HAT. The display is the face, and the
speaker sits behind a grille below it. The whole cabinet pans on a bearing turntable, and a small
yoke on top tilts only the camera. Everything is in one parametric OpenSCAD file,
`hermes_robot.scad`. Ready-made STLs are in `stl/`, but **don't print the large parts until the test
pieces fit**: most dimensions were taken from photos and datasheets.
Overall size: about 86 × 40 × 146 mm for the cabinet, on a 90 mm base. Total height is about 225 mm
including the camera.
## Parts to buy
| Part | Notes |
|---|---|
| 2 × MG90S metal-gear micro servos | Pan (in the base) and tilt (camera). Use the double-arm horns they come with. |
| 6810-2RS ball bearing, 50 × 65 × 7 mm | The turntable. The 50 mm bore leaves room for the servo and cables. |
| Adafruit 5986 speaker (or the CQRobot 8 Ω JST-PH equivalent) | 70 × 30 × 17 mm box. |
| 30 × 30 × 10 mm 5 V fan | Optional; plugs into the HAT's fan header. Set `fan = false` to drop the mount. |
| Right-angle USB-C cable | The left side has about 13 mm beside the board for the plug. |
| M3 screws: 4 × 8 mm (back cover), 4 × 8 mm (turntable), 2 × 12 mm + nuts (yoke), 1 × 16 mm (tilt axle) + washer | Self-tapping into PETG where the model has 2.6 mm pilots. |
| 4 × M2 × 6 mm screws | Camera board to cradle. |
| 4 small rubber feet, 10 mm | Optional, in the base's recesses. |
The servos plug into the HAT's GPIO12 (pan) and GPIO13 (tilt) ports. Those ports are factory-set to
5 V (checked by continuity on the "3pin Volt" jumper), so no soldering is needed.
## Measure first
Every value marked `MEASURE` in the `.scad` file is an estimate. With calipers, check at least:
1. **The stack:** the gap between the Pi and HAT boards (`stack_gap`), the tallest part on the HAT's
front (`hat_clear`), and the screw-head size (`head_d`, `head_h`).
2. **HAT features:** the display glass position (`glass_px`, `glass_py`), the mics, the audio switch
and the LEDs. All are in Pi coordinates: `px` runs down the board from the microSD end, `py`
across it from the USB-C edge.
3. **The speaker box** (`spk`).
4. **The servo** (`srv_*`) and its **double-arm horn** (`horn_*`): arm length from shaft centre to
tip, widths at hub and tip, thickness, and the radii of its screw holes.
5. **The bearing** (`brg_*`), if yours isn't a 6810.
6. **The camera board** (`cam_*`): the hole positions and lens position.
Change a value, then re-export: `openscad -D 'PART="body"' -o stl/body.stl hermes_robot.scad`.
## Print the test pieces first
| Piece | What it checks | Rough time |
|---|---|---|
| `test_front` | The front wall over the HAT. Press it onto the stack: the cups should seat on the screw heads, with the window over the display, the mic holes over the mics, the slot over the audio switch, and the thin LED spots over the LEDs. | ~40 min |
| `test_bearing` | The bearing seat in the base and the hub on the plate. The bearing should press in firmly by hand; adjust `press` if it's loose or won't go. | ~1 h |
| `test_servo` | The servo posts in the base: the screw holes should line up with the servo's tabs. | ~25 min |
| `test_yoke` | The tilt servo's mount plate: the case should slide through and the tabs line up. | ~15 min |
## Printing (Adventurer 5M Pro, PETG)
| Part | Orientation (as exported) | Notes |
|---|---|---|
| `body` | Front face on the bed | No supports. The front face takes the plate's texture. |
| `back` | Outside face on the bed | No supports. |
| `base` | Upright | No supports. |
| `plate` | Top face on the bed, hub up | No supports. |
| `yoke` | Upright | No supports. |
| `cradle` | Horn flange on the bed | No supports. |
Suggested settings: 0.2 mm layers, 3 walls, 20% gyroid infill. Use 40% infill for `plate` and
`cradle`, which carry the servo horns. Print the bearing seat and hub slowly on their outer walls for
accuracy.
## Assembly
1. **Base:** set the pan servo on its posts, shaft up, and screw it through the tabs. Press the
bearing into the seat. Feed the USB-C cable in through the slot at the back of the base, up through
the bearing.
2. **Turntable:** screw a double-arm horn into the pocket under the plate. Centre the servo (run the
software's `turn_head(0, 0)`, or use a servo tester), press the plate's hub into the bearing so the
horn meets the spline, and fit the horn's centre screw through the hole in the plate.
3. **Body onto the plate:** pass the power cable and the pan servo's lead up through the floor hole.
Then screw the plate on from inside the body with 4 × M3, before the speaker goes in.
4. **Speaker:** route its lead up the left-hand channel, and set the box on its feet, facing the grille.
5. **Stack:** plug in the speaker, the servo leads and the fan. Lower the Pi and HAT stack face-first
onto the locating cups, then connect the right-angle USB-C plug on the left.
6. **Back cover:** its cups clamp the stack, and its bars hold the speaker against the grille. Use
4 × M3.
7. **Camera:** screw the board to the cradle (M2). Fit the second horn into the flange pocket. Centre
the tilt servo, mount it in the yoke, push the flange onto its spline, and add the M3 axle on the
other side. Feed the ribbon down through the slot. Bolt the yoke to the top with 2 × M3 and nuts
from inside.
The camera ribbon drops straight from the cradle into the gap between the Pi and the HAT, then down
to the camera connector. A 15–20 cm ribbon should be enough. Leave a gentle loop above the slot for
the tilt.
## Known compromises
- The tilt servo hangs about 5 mm past the left side of the cabinet. Set `cam_x_offset = 5` to move
the camera and yoke right and bring it flush, at the cost of an off-centre camera.
- The front's controls (joystick, button) aren't exposed. They sit about 7 mm behind the front wall.
The LEDs glow through 0.6 mm of PETG, so light-coloured filament works best for that.
- There's no hard stop on the pan. Software limits (±80°) and the slack in the power cable are what
keep it from over-rotating.
+512
View File
@@ -0,0 +1,512 @@
// Hermes robot enclosure — Raspberry Pi 4 + Adafruit BrainCraft HAT
//
// Layout: an upright cabinet holding the Pi/HAT stack portrait-wise, the display as the face,
// a speaker behind a grille below it, all turning on a bearing turntable (pan). A small yoke on
// top tilts only the camera.
//
// Coordinates (body): X = width, viewer's left to right; Y = depth, front face at Y=0 going back;
// Z = height, up. Pi coordinates: px along the board's length from the microSD end (0..85),
// py across it from the USB-C edge (0..56). The stack stands with px pointing down, py to the right.
//
// EVERY value marked MEASURE was estimated from photos or datasheets. Check it with calipers and
// print the test pieces (PART = "test_*") before printing the big parts.
//
// Export one part: openscad -D 'PART="body"' -o body.stl hermes_robot.scad
/* [Part] */
PART = "assembly"; // [assembly, exploded, body, back, plate, base, yoke, cradle, test_front, test_bearing, test_servo, test_yoke]
$fn = 72;
eps = 0.01;
wall = 2.4; // walls and front face
tol = 0.3; // general sliding clearance
// ============================================================ MEASURE: Pi 4 + BrainCraft stack
pi_len = 85; pi_wid = 56; pcb = 1.6;
pi_holes = [[3.5,3.5],[61.5,3.5],[3.5,52.5],[61.5,52.5]]; // [px, py], same on Pi and HAT
port_over = 2.5; // USB/Ethernet past the board end
stack_gap = 11; // Pi top surface to HAT underside (standoff length) MEASURE
hat_clear = 6.5; // tallest part on the HAT front, display excluded MEASURE
pi_back_clear = 3; // solder pins / screw heads under the Pi MEASURE
head_d = 5.6; head_h = 2.2; // M2.5 screw heads holding the stack MEASURE
// HAT front features, in Pi coordinates [px, py] MEASURE ALL
glass_px = [6.9, 40.7]; glass_py = [10.9, 44.7]; // display glass extents
win_w = 28.6; win_h = 28.6; win_r = 1.5; // visible window (active area 27.7 sq)
win_shift = [0, 0]; // nudge window [dpx, dpy]
mic_l = [4.9, 21.7]; mic_r = [62.2, 20.2];
sw_c = [5.5, 40.0]; sw_len = 9; sw_wid = 4.5; // audio switch slot (length along py)
leds = [[44.6,41.8],[53.4,41.8],[61.8,41.8]];
soc = [29, 32]; // Pi SoC, for the fan
sd_py = 28; // microSD centre
// ============================================================ MEASURE: speaker (Adafruit 5986 box)
spk = [70, 17.2, 30.2]; // X width, Y depth, Z height
// ============================================================ MEASURE: MG90S servo + double-arm horn
srv_l = 22.8; srv_w = 12.2; // case length (along tabs) and width
srv_tab_z = 16.0; srv_tab_t = 2.5; srv_tab_len = 32.2; srv_hole_sp = 27.6;
srv_shaft_off = 5.9; // shaft centre from the near end of the case
srv_tip_h = 32.0; // case bottom to spline tip
srv_bump_h = 26.0; // case bottom to top of the gear bump
horn_len = 16; // shaft centre to arm tip
horn_hub_w = 7.5; horn_tip_w = 4.2; horn_t = 1.8; // arm widths and thickness
horn_seat = 0.5; // spline tip sits this far below the horn's top face
horn_holes = [9, 13]; // radii of the screw holes along each arm
// ============================================================ MEASURE: bearing 6810-2RS (50 x 65 x 7)
brg_id = 50; brg_od = 65; brg_h = 7;
brg_in_shoulder = 55; // inner ring outer diameter (plate shoulder must stay inside)
brg_out_shoulder = 59.5; // outer ring inner diameter
press = 0.15; // press-fit interference; tune with test_bearing
// ============================================================ MEASURE: camera (Arducam UC-346 / Pi cam v1 size)
cam = [25, 24]; cam_pcb = 1.0;
cam_holes = [[2,2],[23,2],[2,14.5],[23,14.5]]; // from top-left, x right, y down
cam_lens_top = 9.46; // lens centre below the board's top edge
cam_conn_h = 6; // ribbon connector band at the bottom of the board's back
cam_standoff = 3; // board back to cradle plate
// ============================================================ layout choices
W = 86; // body width
top_clear = 11; // above the board end (JST overhang + plug)
plenum = 8; // cable space under the speaker
shelf_t = 2;
rear_air = 11; // behind the Pi: airflow and a 30 x 30 x 10 mm fan
R_out = 7; // corner radius seen from the front
label = "HERMES"; // engraved under the grille; "" for none
fan = true; // 30 mm fan mount in the back cover
fan_sz = 30; fan_hole_sp = 24;
plate_d = 76; plate_t = 4; gap_turn = 0.8;
tilt_axis_h = 26; // tilt axis above the body top (horn flange must clear the yoke base)
cam_x_offset = 0; // shift camera + yoke sideways if the servo overhang bothers you
// ============================================================ derived
disp_px_c = (glass_px[0]+glass_px[1])/2 + win_shift[0];
disp_py_c = (glass_py[0]+glass_py[1])/2 + win_shift[1];
x0 = W/2 - disp_py_c; // body X of the board's py=0 edge
z_spk0 = wall + plenum;
z_shelf = z_spk0 + spk[2] + 0.6;
z_pi_end = z_shelf + shelf_t + 2;
z_top_board = z_pi_end + port_over + pi_len; // body Z of px=0
H = z_top_board + top_clear + wall;
y_in = wall;
y_hat_f = y_in + 0.5 + hat_clear;
y_hat_b = y_hat_f + pcb;
y_pi_t = y_hat_b + stack_gap;
y_pi_b = y_pi_t + pcb;
y_back_in = y_pi_b + pi_back_clear + rear_air;
D = y_back_in + wall;
axis = [W/2, D/2]; // pan axis in body XY
spk_x1 = W - wall - 0.6; spk_x0 = spk_x1 - spk[0];
spk_y0 = y_in + 0.5;
function BX(py) = x0 + py;
function BZ(px) = z_top_board - px;
echo(str("BODY W=", W, " D=", D, " H=", H));
echo(str("left clearance beside board = ", x0 - wall, " mm (USB-C plug side)"));
echo(str("speaker cable channel = ", spk_x0 - wall, " mm"));
assert(x0 - wall >= 11, "not enough room for a right-angle USB-C plug on the left");
// servo geometry along its own axis (0 = case bottom, + toward the spline tip)
srv_case_c = srv_l/2 - srv_shaft_off; // case centre relative to shaft, along its length
// base stack: everything follows from where the spline tip must be
base_floor = 2.4;
z_srv_bottom = base_floor + 1.5;
base_h = z_srv_bottom + srv_tip_h - gap_turn - horn_t + horn_seat; // bearing top flush with base top
z_brg0 = base_h - brg_h;
echo(str("BASE height=", base_h));
// tilt yoke geometry (local: origin on tilt axis at cradle centre, X along axis, camera looks -Y)
cb_r = 17; // right boss outer face
cb_l = 13.6; // left flange inner face (just clear of the camera board)
fl_t = horn_t + 1.6; // left flange thickness
x_tip = -cb_l - fl_t + horn_t - horn_seat; // tilt servo spline tip
x_tabtop = x_tip - (srv_tip_h - srv_tab_z - srv_tab_t);
x_srv_bottom = x_tip - srv_tip_h;
y_tilt = 10.5; // tilt axis in body Y
mp_t = 6; // servo mount plate thickness
yb_t = 4; // yoke base plate thickness
yoke_y = [-10.5, 22.5];
yoke_bolts = [[-15, 20], [17, 20]]; // local [X, Y]
echo(str("tilt servo bottom overhangs body side by ", -(W/2 + cam_x_offset + x_srv_bottom), " mm"));
// ============================================================ helpers
module rrect(w, h, r) {
r2 = max(0.01, min(r, w/2 - 0.01, h/2 - 0.01));
hull() for (x = [r2, w - r2], y = [r2, h - r2]) translate([x, y]) circle(r = r2);
}
// rounded rectangle in the XZ plane at corner (x, z), spanning y0..y1
module xz_rr(x, z, w, h, r, y0, y1) {
translate([x, y1, z]) rotate([90, 0, 0]) linear_extrude(y1 - y0) rrect(w, h, r);
}
module xz_rr_c(cx, cz, w, h, r, y0, y1) xz_rr(cx - w/2, cz - h/2, w, h, r, y0, y1);
// cylinder along +Y
module ycyl(x, y0, z, d, len) translate([x, y0, z]) rotate([-90, 0, 0]) cylinder(d = d, h = len);
// through the front wall, with a 45 degree chamfer on the outside
module front_cut(cx, cz, w, h, r, ch) {
xz_rr_c(cx, cz, w, h, r, -1, wall + 1);
if (ch > 0) hull() {
xz_rr_c(cx, cz, w + 2*ch, h + 2*ch, r + ch, -1, 0);
xz_rr_c(cx, cz, w, h, r, ch, ch + eps);
}
}
// ============================================================ BODY SHELL (print front face down)
module boss_corner(xc, zc, side) { // screw boss in a corner, 45 degree underside for printing
bs = 7; L = 10;
x = side < 0 ? wall : W - wall - bs;
wx = side < 0 ? wall : W - wall - eps;
hull() {
translate([x, y_back_in - L, zc - bs/2]) cube([bs, L, bs]);
translate([wx, y_back_in - L - bs, zc - bs/2]) cube([eps, eps, bs]);
}
}
boss_z = [H - wall - 3.5, z_shelf + shelf_t + 3.5];
function boss_pts() = [for (z = boss_z, s = [-1, 1]) [s < 0 ? wall + 3.5 : W - wall - 3.5, z, s]];
plate_screws = [[-31,-10],[31,-10],[-31,10],[31,10]]; // relative to the pan axis
module body() {
difference() {
union() {
difference() {
xz_rr(0, 0, W, H, R_out, 0, D);
xz_rr(wall, wall, W - 2*wall, H - 2*wall, R_out - wall, y_in, D + 1);
}
// shelf over the speaker; the left strip stays open as the cable channel
translate([spk_x0, y_in - eps, z_shelf]) cube([W - wall - spk_x0 + eps, y_back_in - y_in, shelf_t]);
// speaker feet
for (x = [spk_x0 + 12, spk_x1 - 17], y = [spk_y0 + 1, spk_y0 + spk[1] - 6])
translate([x, y, wall - eps]) cube([5, 5, plenum + eps]);
// locating cups for the HAT's front screw heads
for (h = pi_holes) difference() {
ycyl(BX(h[1]), y_in - 0.5, BZ(h[0]), 7, (y_hat_f - 0.4) - (y_in - 0.5));
ycyl(BX(h[1]), y_hat_f - head_h, BZ(h[0]), head_d + 0.4, 5);
}
for (b = boss_pts()) boss_corner(b[0], b[1], b[2]);
// ledge that stops the back cover at the floor
translate([wall + R_out, y_back_in - 2.5, wall - eps]) cube([W - 2*wall - 2*R_out, 2.5, 2.5]);
}
// ---- front face
cx = BX(disp_py_c); cz = BZ(disp_px_c);
front_cut(cx, cz, win_w, win_h, win_r, 1.8); // deep chamfer: a recessed "tube face"
for (m = [mic_l, mic_r]) {
ycyl(BX(m[1]), -1, BZ(m[0]), 2.0, wall + 2);
translate([BX(m[1]), -eps, BZ(m[0])]) rotate([-90, 0, 0]) cylinder(d1 = 3.6, d2 = 2.0, h = 0.8);
}
front_cut(BX(sw_c[1]), BZ(sw_c[0]), sw_len, sw_wid, 1.2, 0.6);
for (l = leds) ycyl(BX(l[1]), 0.6, BZ(l[0]), 5, wall); // thin skin: LEDs glow through PETG
// speaker grille
gcx = (spk_x0 + spk_x1)/2; gcz = z_spk0 + spk[2]/2;
for (i = [-2.5 : 1 : 2.5]) front_cut(gcx, gcz + i*4.2, 54, 2.2, 1.1, 0.6);
if (label != "")
translate([gcx, 0.6, wall + plenum/2 + 0.3]) rotate([90, 0, 0]) linear_extrude(2)
text(label, size = 4.2, halign = "center", valign = "center", font = "Liberation Sans:style=Bold", spacing = 1.25);
// ---- top
tx = axis[0] + cam_x_offset;
translate([tx - 9.5, y_tilt + 0.5, H - wall - 1]) cube([19, 3.5, wall + 2]); // camera ribbon, drops into the Pi-HAT gap
translate([max(R_out + 5, tx + x_srv_bottom + 7), y_tilt + 18, H - wall - 1]) cylinder(d = 7, h = wall + 2); // tilt servo lead
for (b = yoke_bolts) translate([tx + b[0], y_tilt + b[1], H - wall - 1]) cylinder(d = 3.4, h = wall + 2);
translate([BX(sd_py) - 8, y_pi_b - 1, H - wall - 1]) cube([16, 5, wall + 2]); // microSD access
// ---- sides: vents high and low
for (s = [0, 1], zc = [z_top_board - 14, z_pi_end + 14], i = [0 : 3])
translate([s ? W - wall - 1 : -1, y_hat_b + 1 + i*5.5, zc - 9]) cube([wall + 2, 2.2, 18]);
// ---- floor: cable hole and turntable plate screws
translate([axis[0], axis[1], -1]) cylinder(d = 26, h = wall + 2);
for (p = plate_screws) translate([axis[0] + p[0], axis[1] + p[1], -1]) cylinder(d = 3.4, h = wall + 2);
// ---- back screw pilots
for (b = boss_pts()) translate([b[0], y_back_in - 9, b[1]]) rotate([-90, 0, 0]) cylinder(d = 2.6, h = 10);
}
}
// ============================================================ BACK COVER (print flat, outside down)
module back() {
cw = W - 2*wall - 2*tol; ch = H - 2*wall - 2*tol;
difference() {
union() {
xz_rr(wall + tol, wall + tol, cw, ch, R_out - wall - tol, y_back_in, D);
// clamp the stack: cups press the Pi's rear screw heads
for (h = pi_holes) difference() {
ycyl(BX(h[1]), y_pi_b + 0.4, BZ(h[0]), 7, y_back_in - (y_pi_b + 0.4) + eps);
ycyl(BX(h[1]), y_pi_b - 1, BZ(h[0]), head_d + 0.4, head_h + 1);
}
// two bars hold the speaker against the front wall
for (x = [spk_x0 + 8, spk_x1 - 14])
translate([x, spk_y0 + spk[1] + 0.2, z_spk0 + 6]) cube([6, y_back_in - (spk_y0 + spk[1] + 0.2) + eps, spk[2] - 12]);
}
for (b = boss_pts()) { // countersunk M3
translate([b[0], y_back_in - 1, b[1]]) rotate([-90, 0, 0]) cylinder(d = 3.4, h = wall + 2);
translate([b[0], D - 1.8 + eps, b[1]]) rotate([-90, 0, 0]) cylinder(d1 = 3.4, d2 = 6.6, h = 1.8);
}
fx = BX(soc[1]); fz = BZ(soc[0]);
if (fan) {
ycyl(fx, y_back_in - 1, fz, fan_sz - 2, wall + 2);
for (i = [-1, 1], j = [-1, 1]) ycyl(fx + i*fan_hole_sp/2, y_back_in - 1, fz + j*fan_hole_sp/2, 3.0, wall + 2);
}
// intake slots low over the Pi's port end and the speaker shelf; exhaust high
for (zc = [z_pi_end + 10, z_top_board - 6], i = [-3 : 3])
if (!fan || abs(zc - fz) > fan_sz/2 + 4)
xz_rr_c(W/2 + i*7.5, zc, 2.6, 14, 1.3, y_back_in - 1, D + 1);
}
}
// ============================================================ TURNTABLE PLATE (print top face down)
// local: pan axis at origin, z = 0 is the plate's top (screwed under the body floor)
module horn_2d(extra = 0) {
hull() { circle(d = horn_hub_w + 2*extra); translate([0, horn_len]) circle(d = horn_tip_w + 2*extra); }
hull() { circle(d = horn_hub_w + 2*extra); translate([0, -horn_len]) circle(d = horn_tip_w + 2*extra); }
}
module plate() {
hub_od = brg_id - press;
difference() {
union() {
difference() {
translate([0, 0, -plate_t]) cylinder(d = plate_d, h = plate_t);
translate([0, 0, -plate_t - 1]) cylinder(d = hub_od - 6, h = plate_t + 2);
}
// bridge across the bore along Y carries the servo horn
intersection() {
translate([0, 0, -plate_t]) cylinder(d = hub_od - 1, h = plate_t);
translate([0, 0, -plate_t]) linear_extrude(plate_t) union() {
offset(r = 2.5) horn_2d();
square([horn_tip_w + 5, brg_id], center = true); // spokes reach the ring
}
}
translate([0, 0, -plate_t - gap_turn]) difference() { // shoulder on the inner ring
cylinder(d = brg_in_shoulder - 1, h = gap_turn + eps);
translate([0, 0, -1]) cylinder(d = hub_od - 6, h = gap_turn + 2);
}
translate([0, 0, -plate_t - gap_turn - brg_h]) difference() { // hub pressed into the bore
cylinder(d = hub_od, h = brg_h + eps);
translate([0, 0, -1]) cylinder(d = hub_od - 6, h = brg_h + 2);
cylinder(d1 = hub_od - 1.2, d2 = hub_od - 6, h = 1.2); // lead-in
}
}
// horn pocket and screw holes
translate([0, 0, -plate_t - eps]) linear_extrude(horn_t + 0.2) offset(r = 0.2) horn_2d();
translate([0, 0, -plate_t - 1]) cylinder(d = 6, h = plate_t + 2); // horn centre screw
for (r = horn_holes, s = [-1, 1]) translate([0, s*r, -plate_t - 1]) cylinder(d = 1.5, h = plate_t + 2);
// blind pilots for the body screws (driven from inside the body)
for (p = plate_screws) translate([p[0], p[1], -plate_t + 0.6]) cylinder(d = 2.6, h = plate_t);
}
}
// ============================================================ BASE (print upright)
// local: pan axis at origin, z = 0 on the table
module base() {
r_top = brg_od/2 + 5; r_bot = 45;
difference() {
union() {
cylinder(r1 = r_bot, r2 = r_bot, h = 3);
translate([0, 0, 3]) cylinder(r1 = r_bot, r2 = r_top, h = 9);
translate([0, 0, 12 - eps]) cylinder(r = r_top, h = base_h - 12 + eps);
}
translate([0, 0, base_floor]) cylinder(r = brg_out_shoulder/2 + 0.3, h = base_h); // cavity
translate([0, 0, z_brg0]) cylinder(d = brg_od + 0.1, h = brg_h + 1); // bearing seat
translate([0, 0, base_h - 0.6]) cylinder(d1 = brg_od + 0.1, d2 = brg_od + 1.3, h = 0.61); // lead-in
translate([-8, 25, base_floor]) cube([16, 30, 10]); // cable exit, back
for (a = [45 : 90 : 315]) rotate(a) translate([37, 0, -eps]) cylinder(d = 10.5, h = 1.2); // feet
}
// servo posts under the tabs (servo length along X, shaft on the axis)
for (hx = [srv_case_c - srv_hole_sp/2, srv_case_c + srv_hole_sp/2]) {
pw = (srv_tab_len - srv_l)/2 - 0.6;
px = hx < srv_case_c ? srv_case_c - srv_l/2 - 0.3 - pw : srv_case_c + srv_l/2 + 0.3;
difference() {
translate([px, -5, base_floor - eps]) cube([pw, 10, z_srv_bottom + srv_tab_z - base_floor + eps]);
translate([hx, 0, z_srv_bottom + srv_tab_z - 8]) cylinder(d = 1.8, h = 9);
}
}
}
// ============================================================ TILT YOKE (print upright)
// local: tilt axis = X axis, origin at cradle centre; base plate underside at z = -tilt_axis_h
module yoke() {
zb = -tilt_axis_h;
difference() {
union() {
// base plate
translate([x_tabtop, yoke_y[0], zb]) cube([cb_r + 7 - x_tabtop, yoke_y[1] - yoke_y[0], yb_t]);
// servo mount plate: tabs bear on its outer (-X) face
translate([x_tabtop, srv_case_c - srv_tab_len/2 - 2.5, zb]) cube([mp_t, srv_tab_len + 5, tilt_axis_h + srv_w/2 + 3]);
// right upright carrying the M3 axle
hull() {
translate([cb_r + 1, -7, zb]) cube([6, 14, yb_t]);
translate([cb_r + 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 12, h = 6);
}
}
// servo case through the mount plate
translate([x_tabtop - 1, srv_case_c - srv_l/2 - 0.3, -srv_w/2 - 0.3]) cube([mp_t + 2, srv_l + 0.6, srv_w + 0.6]);
for (s = [-1, 1]) translate([x_tabtop - 1, srv_case_c + s*srv_hole_sp/2, 0]) rotate([0, 90, 0]) cylinder(d = 1.8, h = mp_t + 2);
// axle
translate([cb_r - 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 3.3, h = 10);
// ribbon slot, microSD access, bolts
translate([-9.5, 0.5, zb - 1]) cube([19, 3.5, yb_t + 2]);
translate([BX(sd_py) - axis[0] - cam_x_offset - 8, y_pi_b - 1 - y_tilt, zb - 1]) cube([16, 5, yb_t + 2]);
for (b = yoke_bolts) translate([b[0], b[1], zb - 1]) cylinder(d = 3.4, h = yb_t + 2);
// lighten
translate([-4, 6, zb - 1]) cube([12, 10, yb_t + 2]);
}
}
// ============================================================ CAMERA CRADLE (print flat, plate down)
module cradle() {
pz0 = -cam_lens_top - (cam[1] - cam_lens_top) + cam_conn_h + 0.5; // plate bottom (clears connector)
pz1 = cam_lens_top + 2;
py0 = cam_standoff - 1.5; pt = 3; // plate in Y
difference() {
union() {
translate([-cam[0]/2 - 2, py0, pz0]) cube([cam[0] + 4, pt, pz1 - pz0]);
for (h = cam_holes) translate([h[0] - cam[0]/2, py0 + eps, cam_lens_top - h[1]]) rotate([90, 0, 0]) cylinder(d = 4.6, h = cam_standoff);
// right boss to the axle
hull() {
translate([cam[0]/2 + 2 - eps, py0, -4.5]) cube([eps, pt, 9]);
translate([cb_r - 4, 0, 0]) rotate([0, 90, 0]) cylinder(d = 9, h = 4);
}
// left: boss to the horn flange
hull() {
translate([-cam[0]/2 - 2, py0, -4.5]) cube([eps, pt, 9]);
translate([-cb_l, 0, 0]) rotate([0, 90, 0]) cylinder(d = 9, h = 1);
}
translate([-cb_l - fl_t, 0, 0]) rotate([0, 90, 0]) linear_extrude(fl_t) rotate(90) offset(r = 2.5) horn_2d();
}
for (h = cam_holes) translate([h[0] - cam[0]/2, py0 + pt + 1, cam_lens_top - h[1]]) rotate([90, 0, 0]) cylinder(d = 1.7, h = pt + cam_standoff + 2);
translate([-cb_l - fl_t - eps, 0, 0]) rotate([0, 90, 0]) linear_extrude(horn_t + 0.2) rotate(90) offset(r = 0.2) horn_2d();
translate([-cb_l - fl_t - 1, 0, 0]) rotate([0, 90, 0]) cylinder(d = 6, h = fl_t + 2);
for (r = horn_holes, s = [-1, 1]) translate([-cb_l - fl_t - 1, 0, s*r]) rotate([0, 90, 0]) cylinder(d = 1.5, h = fl_t + 2);
translate([cb_r + eps, 0, 0]) rotate([0, -90, 0]) cylinder(d = 2.6, h = 9); // M3 axle pilot
}
}
// ============================================================ dummy hardware for the assembly view
module dummy_stack() {
color("darkgreen") translate([BX(0), y_pi_t, BZ(pi_len)]) cube([pi_wid, pcb, pi_len]);
color("silver") translate([BX(10), y_pi_t - 16, BZ(pi_len + port_over)]) cube([36, 16, 20]);
color("black") translate([BX(0), y_hat_f, BZ(65)]) cube([pi_wid, pcb, 65]);
color("dimgray") translate([BX(glass_py[0]), y_hat_f - 4, BZ(glass_px[1])]) cube([glass_py[1]-glass_py[0], 4, glass_px[1]-glass_px[0]]);
color("gold") for (h = pi_holes) ycyl(BX(h[1]), y_hat_f - head_h, BZ(h[0]), head_d, head_h);
}
module dummy_speaker() color("dimgray") translate([spk_x0, spk_y0, z_spk0]) cube(spk);
module dummy_servo() color("royalblue") { // standing: shaft up at origin
translate([srv_case_c - srv_l/2, -srv_w/2, -srv_tip_h]) cube([srv_l, srv_w, srv_bump_h - 3]);
translate([srv_case_c - srv_tab_len/2, -srv_w/2, -srv_tip_h + srv_tab_z]) cube([srv_tab_len, srv_w, srv_tab_t]);
translate([0, 0, -srv_tip_h]) cylinder(d = 11.5, h = srv_bump_h);
translate([0, 0, -srv_tip_h]) cylinder(d = 4.8, h = srv_tip_h);
}
module dummy_bearing() color("lightsteelblue") difference() { cylinder(d = brg_od, h = brg_h); translate([0,0,-1]) cylinder(d = brg_id, h = brg_h + 2); }
module dummy_camera() color("forestgreen") {
translate([-cam[0]/2, -cam_standoff + 1.5 - cam_pcb, cam_lens_top - cam[1]]) cube([cam[0], cam_pcb, cam[1]]);
translate([-4, -cam_standoff + 1.5 - cam_pcb - 5, -4]) cube([8, 5, 8]);
}
// ============================================================ placement
z_body0 = base_h + gap_turn + plate_t; // body bottom above the table
module placed(exploded = 0) {
e = exploded;
translate([-axis[0], -axis[1], 0]) {
translate([axis[0], axis[1], 0]) base();
translate([axis[0], axis[1], z_brg0]) dummy_bearing();
translate([axis[0], axis[1], base_h + gap_turn + horn_t - horn_seat]) dummy_servo();
translate([axis[0], axis[1], z_body0 + e*0.6]) plate();
translate([0, 0, z_body0 + e]) {
color("tan", 0.55) body();
color("burlywood") translate([0, e*1.2, 0]) back();
dummy_stack(); dummy_speaker();
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h + e*0.6]) {
color("peru") yoke();
color("sandybrown") translate([0, 0, e*0.3]) cradle();
translate([0, 0, e*0.3]) dummy_camera();
translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
}
}
}
}
// ============================================================ beauty render (opaque, display keyed in magenta)
module beauty() {
translate([-axis[0], -axis[1], 0]) {
color([0.20, 0.21, 0.23]) translate([axis[0], axis[1], 0]) base();
color([0.30, 0.31, 0.33]) translate([axis[0], axis[1], z_body0]) plate();
translate([0, 0, z_body0]) {
color([0.50, 0.50, 0.49]) body();
color([1, 0, 1]) translate([BX(glass_py[0]) - 3, y_in + 0.6, BZ(glass_px[1]) - 3])
cube([glass_py[1] - glass_py[0] + 6, 0.5, glass_px[1] - glass_px[0] + 6]);
color([0.05, 0.05, 0.06]) translate([wall + 1, y_in + 2, wall + 1]) cube([W - 2*wall - 2, 2, H - 2*wall - 2]); // dark interior behind slots
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h]) rotate([8, 0, 0]) {
color([0.36, 0.33, 0.30]) rotate([-8, 0, 0]) yoke();
color([0.33, 0.31, 0.29]) cradle();
color([0.12, 0.18, 0.14]) translate([-cam[0]/2, -cam_standoff + 1.5 - cam_pcb, cam_lens_top - cam[1]]) cube([cam[0], cam_pcb, cam[1]]);
color([0.05, 0.05, 0.05]) translate([-4, -cam_standoff + 1.5 - cam_pcb - 5, -4]) cube([8, 5, 8]);
color([0.15, 0.3, 0.35]) translate([0, -cam_standoff + 1.5 - cam_pcb - 5.2, 0]) rotate([90, 0, 0]) cylinder(d = 5, h = 0.4);
color([0.15, 0.17, 0.22]) rotate([-8, 0, 0]) translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
}
}
}
}
// ============================================================ posed parts for external rendering
pose_pan = -12; // body turned toward a viewer at front-left
pose_tilt = 8; // camera dipped slightly
module pose(n) {
if (n == "base") base();
if (n == "servo_pan") translate([0, 0, base_h + gap_turn + horn_t - horn_seat]) dummy_servo();
rotate([0, 0, pose_pan]) {
if (n == "plate") translate([0, 0, z_body0]) plate();
translate([-axis[0], -axis[1], z_body0]) {
if (n == "body") body();
translate([axis[0] + cam_x_offset, y_tilt, H + tilt_axis_h]) {
if (n == "yoke") yoke();
if (n == "servo_tilt") translate([x_tip, 0, 0]) rotate([0, 90, 0]) rotate([0, 0, 90]) dummy_servo();
rotate([pose_tilt, 0, 0]) {
if (n == "cradle") cradle();
if (n == "camera") dummy_camera();
}
}
}
}
}
// window and LED positions in the same posed frame, for compositing
function posed(p) = let(a = pose_pan, q = [p[0] - axis[0], p[1] - axis[1], p[2] + z_body0])
[q[0]*cos(a) - q[1]*sin(a), q[0]*sin(a) + q[1]*cos(a), q[2]];
if (PART == "pose_info") {
cx = BX(disp_py_c); cz = BZ(disp_px_c);
for (c = [[-1, 1], [1, 1], [1, -1], [-1, -1]])
echo(str("WIN ", posed([cx + c[0]*win_w/2, wall * 0.6, cz + c[1]*win_h/2])));
for (l = leds) echo(str("LED ", posed([BX(l[1]), 0.6, BZ(l[0])])));
echo(str("NORMAL ", posed([0, -1, 0]) - posed([0, 0, 0])));
}
if (len(PART) > 5 && PART[0] == "p" && PART[1] == "o" && PART[2] == "s" && PART[3] == "e" && PART[4] == "_" && PART != "pose_info")
pose(chr([for (i = [5 : len(PART) - 1]) ord(PART[i])]));
// ============================================================ test pieces
module test_front() intersection() { // front wall over the HAT, with the locating cups
body();
translate([BX(-3), -1, BZ(68)]) cube([pi_wid + 6, y_hat_f + 1, 72]);
}
module test_bearing() {
intersection() { base(); translate([0, 0, z_brg0 - 2]) cylinder(r = 50, h = 20); }
translate([80, 0, 0]) rotate([180, 0, 0]) intersection() { // hub ring, plate face down as printed
plate(); translate([0, 0, -30]) cylinder(d = brg_in_shoulder + 2, h = 30);
}
}
module test_servo() intersection() { base(); cylinder(r = 26, h = z_srv_bottom + srv_tab_z + 1); }
module test_yoke() intersection() {
yoke();
translate([x_tabtop - 1, -40, -tilt_axis_h - 1]) cube([mp_t + 2, 80, 80]);
}
// ============================================================ output
if (PART == "assembly") placed(0);
else if (PART == "exploded") placed(28);
else if (PART == "beauty") beauty();
else if (PART == "body") rotate([90, 0, 0]) body(); // front face on the bed
else if (PART == "back") translate([0, 0, D]) rotate([-90, 0, 0]) back(); // outside face on the bed
else if (PART == "plate") rotate([180, 0, 0]) plate(); // top face on the bed
else if (PART == "base") base();
else if (PART == "yoke") translate([0, 0, tilt_axis_h]) yoke();
else if (PART == "cradle") translate([0, 0, cb_l + fl_t]) rotate([0, -90, 0]) cradle(); // horn flange on the bed
else if (PART == "test_front") rotate([90, 0, 0]) test_front();
else if (PART == "test_bearing") test_bearing();
else if (PART == "test_servo") test_servo();
else if (PART == "test_yoke") translate([0, 0, tilt_axis_h]) test_yoke();
+20
View File
@@ -0,0 +1,20 @@
# Poster
`hermes_poster.png` (1600 × 2400) is rendered from the real enclosure model: hidden-line wireframe
of the feature edges and silhouettes, 6 mm contour slices on the shells, the flat P7 face lit on
the screen, a mirrored pass for the wet floor, then haze, bloom and CRT scanlines.
To regenerate after changing the model:
```bash
cd enclosure
for n in base servo_pan plate body yoke servo_tilt cradle camera; do
openscad -o poster/pose/$n.stl -D "PART=\"pose_$n\"" -D '$fn=48' hermes_robot.scad
done
openscad -o /dev/null -D 'PART="pose_info"' hermes_robot.scad # prints WIN / LED coordinates
python3 poster/render_poster.py
```
If the window or LED positions change, copy the printed `WIN` and `LED` values into
`render_poster.py`. `face.png` is a frame captured from `docs/face-prototype.html` (flat shape,
P7, speaking). Requires `numpy`, `trimesh` and `Pillow`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
solid OpenSCAD_Model
facet normal 0.97815 -0.207899 3.31478e-06
outer loop
vertex 9.46323 -15.6007 219.72
vertex 10.3636 -11.3649 196.093
vertex 9.66912 -14.632 219.859
endloop
endfacet
facet normal 0.978144 -0.207929 -2.20173e-06
outer loop
vertex 10.3636 -11.3649 196.093
vertex 9.46323 -15.6007 219.72
vertex 10.1577 -12.3335 195.954
endloop
endfacet
facet normal -0.0288981 -0.135956 0.990293
outer loop
vertex -14.7846 -9.43426 219.859
vertex 9.46323 -15.6007 219.72
vertex 9.66912 -14.632 219.859
endloop
endfacet
facet normal -0.0289 -0.135964 0.990292
outer loop
vertex 9.46323 -15.6007 219.72
vertex -14.7846 -9.43426 219.859
vertex -14.9905 -10.4029 219.72
endloop
endfacet
facet normal 0.0289012 0.135969 -0.990291
outer loop
vertex 10.1577 -12.3335 195.954
vertex -14.0901 -6.16709 196.093
vertex 10.3636 -11.3649 196.093
endloop
endfacet
facet normal 0.0289002 0.135965 -0.990292
outer loop
vertex -14.0901 -6.16709 196.093
vertex 10.1577 -12.3335 195.954
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal -0.978145 0.207923 -4.88768e-08
outer loop
vertex -14.9905 -10.4029 219.72
vertex -14.0901 -6.16709 196.093
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal -0.978146 0.207921 -4.24054e-07
outer loop
vertex -14.0901 -6.16709 196.093
vertex -14.9905 -10.4029 219.72
vertex -14.7846 -9.43426 219.859
endloop
endfacet
facet normal 0.205889 0.968628 0.139173
outer loop
vertex -14.0901 -6.16709 196.093
vertex 9.66912 -14.632 219.859
vertex 10.3636 -11.3649 196.093
endloop
endfacet
facet normal 0.205886 0.968628 0.139176
outer loop
vertex 9.66912 -14.632 219.859
vertex -14.0901 -6.16709 196.093
vertex -14.7846 -9.43426 219.859
endloop
endfacet
facet normal -0.205888 -0.968629 -0.139166
outer loop
vertex -14.9905 -10.4029 219.72
vertex -6.51822 -11.4269 214.313
vertex 9.46323 -15.6007 219.72
endloop
endfacet
facet normal -0.205898 -0.968625 -0.139181
outer loop
vertex -14.9905 -10.4029 219.72
vertex -6.28673 -10.3378 206.391
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139178
outer loop
vertex -6.28673 -10.3378 206.391
vertex -14.296 -7.13572 195.954
vertex 1.53845 -12.0011 206.391
endloop
endfacet
facet normal -0.205891 -0.968627 -0.139177
outer loop
vertex -14.296 -7.13572 195.954
vertex -6.28673 -10.3378 206.391
vertex -14.9905 -10.4029 219.72
endloop
endfacet
facet normal -0.205876 -0.968627 -0.1392
outer loop
vertex 1.30697 -13.0901 214.313
vertex 9.46323 -15.6007 219.72
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205897 -0.968627 -0.139169
outer loop
vertex 1.53845 -12.0011 206.391
vertex 9.46323 -15.6007 219.72
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal -0.205884 -0.968629 -0.139177
outer loop
vertex 1.53845 -12.0011 206.391
vertex 10.1577 -12.3335 195.954
vertex 9.46323 -15.6007 219.72
endloop
endfacet
facet normal -0.205888 -0.968627 -0.13918
outer loop
vertex 10.1577 -12.3335 195.954
vertex 1.53845 -12.0011 206.391
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal 0.978148 -0.20791 9.31053e-07
outer loop
vertex 0.277524 -17.9333 213.617
vertex 1.53845 -12.0011 206.391
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal 0.978147 -0.207913 -1.33146e-06
outer loop
vertex 1.53845 -12.0011 206.391
vertex 0.277524 -17.9333 213.617
vertex 0.50901 -16.8442 205.695
endloop
endfacet
facet normal -0.0289393 -0.136156 0.990265
outer loop
vertex -6.51822 -11.4269 214.313
vertex 0.277524 -17.9333 213.617
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal -0.0289415 -0.136159 0.990264
outer loop
vertex 0.277524 -17.9333 213.617
vertex -6.51822 -11.4269 214.313
vertex -7.54766 -16.27 213.617
endloop
endfacet
facet normal 0.0289415 0.136159 -0.990264
outer loop
vertex 0.50901 -16.8442 205.695
vertex -6.28673 -10.3378 206.391
vertex 1.53845 -12.0011 206.391
endloop
endfacet
facet normal 0.0289415 0.136159 -0.990264
outer loop
vertex -6.28673 -10.3378 206.391
vertex 0.50901 -16.8442 205.695
vertex -7.31617 -15.1809 205.695
endloop
endfacet
facet normal -0.978147 0.207913 8.47143e-07
outer loop
vertex -7.54766 -16.27 213.617
vertex -6.28673 -10.3378 206.391
vertex -7.31617 -15.1809 205.695
endloop
endfacet
facet normal -0.978147 0.207913 8.47143e-07
outer loop
vertex -6.28673 -10.3378 206.391
vertex -7.54766 -16.27 213.617
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139181
outer loop
vertex -7.31617 -15.1809 205.695
vertex 0.277524 -17.9333 213.617
vertex -7.54766 -16.27 213.617
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139181
outer loop
vertex 0.277524 -17.9333 213.617
vertex -7.31617 -15.1809 205.695
vertex 0.50901 -16.8442 205.695
endloop
endfacet
endsolid OpenSCAD_Model
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
Advertisement poster for the Hermes robot: a hidden-line wireframe render of the real enclosure
model, the scope face lit on its screen, on a wet floor in a hazy, Blade Runner-ish palette,
finished with CRT scanlines.
Inputs (made by the commands in poster/README.md):
pose/*.stl each part exported in its assembled pose (PART="pose_<name>")
face.png a frame of the face from docs/face-prototype.html (flat, P3 amber)
Output: hermes_poster.png
"""
import math
import sys
from pathlib import Path
import numpy as np
import trimesh
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops
HERE = Path(__file__).resolve().parent
POSE = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "pose"
FACE = Path(sys.argv[2]) if len(sys.argv) > 2 else HERE / "face.png"
OUT = Path(sys.argv[3]) if len(sys.argv) > 3 else HERE / "hermes_poster.png"
PW, PH = 1600, 2400
rng = np.random.default_rng(7)
# ------------------------------------------------------------------ palette (linear-ish 0..1)
INK = np.array([0.020, 0.028, 0.036]) # night
HAZE = np.array([0.10, 0.20, 0.22]) # wet teal smoke
AMBER = np.array([1.00, 0.66, 0.24]) # phosphor / sodium light
WARM = np.array([0.95, 0.86, 0.70]) # edge highlight
TEAL = np.array([0.30, 0.66, 0.70]) # contours, grid
SCREEN = np.array([0.66, 0.82, 1.00]) # P7 phosphor: blue-white
FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
FONT_L = "/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf"
FONT_C = "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf"
FONT_M = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
# ------------------------------------------------------------------ camera
target = np.array([0.0, 0.0, 112.0])
direction = np.array([-0.40, -0.91, -0.10])
direction /= np.linalg.norm(direction)
eye = target + direction * 950.0
fov = math.radians(24.0)
F = (PH / 2) / math.tan(fov / 2)
CX, CY = PW * 0.5, PH * 0.565
fwd = target - eye; fwd /= np.linalg.norm(fwd)
right = np.cross(fwd, [0, 0, 1.0]); right /= np.linalg.norm(right)
up = np.cross(right, fwd)
def project(p):
v = p - eye
zc = v @ fwd
return np.stack([CX + F * (v @ right) / zc, CY - F * (v @ up) / zc, zc], axis=-1)
# ------------------------------------------------------------------ geometry
parts = {}
for f in sorted(POSE.glob("*.stl")):
m = trimesh.load(f)
parts[f.stem] = m
print("parts:", {k: len(v.faces) for k, v in parts.items()})
def render_pass(mirror=False):
"""Depth buffer, shading and line buffers for one pass (mirror=True: reflection in the floor)."""
zbuf = Image.new("F", (PW, PH), 1e9)
shade = Image.new("RGB", (PW, PH), (0, 0, 0))
dz, ds = ImageDraw.Draw(zbuf), ImageDraw.Draw(shade)
key = np.array([-0.55, -0.35, 0.75]); key /= np.linalg.norm(key) # amber key, front-left high
rim = np.array([0.75, 0.55, 0.35]); rim /= np.linalg.norm(rim) # teal rim from behind right
tris_all = []
for name, m in parts.items():
mm = m.copy()
if mirror:
mm.vertices[:, 2] *= -1
mm.invert()
sub = mm.subdivide_to_size(max_edge=1.1 if name == "body" else 2.0, max_iter=10)
v = sub.vertices[sub.faces] # (n, 3, 3)
n = sub.face_normals
p = project(v.reshape(-1, 3)).reshape(-1, 3, 3)
view = (eye - v.mean(axis=1)); view /= np.linalg.norm(view, axis=1, keepdims=True)
front = (n * view).sum(1) > 0
tris_all.append((p[front], n[front], view[front]))
p = np.concatenate([t[0] for t in tris_all]); n = np.concatenate([t[1] for t in tris_all])
view = np.concatenate([t[2] for t in tris_all])
order = np.argsort(-p[:, :, 2].mean(1)) # far to near
lk = np.clip(n @ key, 0, 1); lr = np.clip(n @ rim, 0, 1)
fres = (1 - np.abs((n * view).sum(1))) ** 2
for i in order:
pts = [tuple(xy) for xy in p[i, :, :2]]
dz.polygon(pts, fill=float(p[i, :, 2].mean()))
c = 0.035 + 0.10 * lk[i] * AMBER + 0.08 * lr[i] * TEAL + 0.10 * fres[i] * TEAL
ds.polygon(pts, fill=tuple(int(min(255, 255 * x)) for x in c))
z = np.asarray(zbuf).copy()
# widen with a max filter so lines lying on a surface pass the depth test
zmax = np.asarray(zbuf.filter(ImageFilter.MaxFilter(3)))
mask = (z < 1e8).astype(np.float32)
# lines: feature edges, silhouettes, horizontal contour slices
edge_buf = np.zeros((PH, PW), np.float32)
cont_buf = np.zeros((PH, PW), np.float32)
def splat(buf, a3, b3, weight, spacing=0.6):
pa, pb = project(a3), project(b3)
L = np.hypot(pb[:, 0] - pa[:, 0], pb[:, 1] - pa[:, 1])
k = np.maximum(1, np.ceil(L / spacing)).astype(int)
idx = np.repeat(np.arange(len(k)), k)
t = (np.arange(k.sum()) - np.repeat(np.cumsum(k) - k, k)) / np.repeat(k, k)
P = a3[idx] + (b3[idx] - a3[idx]) * t[:, None]
q = project(P)
x, y, zc = q[:, 0], q[:, 1], q[:, 2]
ok = (x >= 0) & (x < PW - 1) & (y >= 0) & (y < PH - 1)
x, y, zc = x[ok], y[ok], zc[ok]
xi, yi = x.astype(int), y.astype(int)
vis = zc <= zmax[yi, xi] + 0.8
x, y, xi, yi = x[vis], y[vis], xi[vis], yi[vis]
fx, fy = x - xi, y - yi
w = weight
np.add.at(buf, (yi, xi), w * (1 - fx) * (1 - fy))
np.add.at(buf, (yi, xi + 1), w * fx * (1 - fy))
np.add.at(buf, (yi + 1, xi), w * (1 - fx) * fy)
np.add.at(buf, (yi + 1, xi + 1), w * fx * fy)
for name, m in parts.items():
mm = m.copy()
if mirror:
mm.vertices[:, 2] *= -1
mm.invert()
adj = mm.face_adjacency
ang = mm.face_adjacency_angles
e = mm.face_adjacency_edges
fn = mm.face_normals
mid = mm.vertices[e].mean(1)
vv = eye - mid
s1 = (fn[adj[:, 0]] * vv).sum(1) > 0
s2 = (fn[adj[:, 1]] * vv).sum(1) > 0
crease = ang > math.radians(28)
silhouette = s1 != s2
sel = crease | silhouette
V = mm.vertices
splat(edge_buf, V[e[sel, 0]], V[e[sel, 1]], 0.8)
# contour slices every 6 mm on the big shells: the "scan" look
if name in ("body", "base", "plate", "yoke"):
zlo, zhi = mm.bounds[0][2], mm.bounds[1][2]
for h in np.arange(math.ceil(zlo / 6) * 6, zhi, 6.0):
try:
seg = trimesh.intersections.mesh_plane(mm, [0, 0, 1], [0, 0, h])
except Exception:
continue
if len(seg):
splat(cont_buf, seg[:, 0], seg[:, 1], 0.32)
return z, mask, np.asarray(shade).astype(np.float32) / 255, edge_buf, cont_buf
print("main pass…")
z, mask, shade, edges, conts = render_pass(False)
print("reflection pass…")
zr, maskr, shader, edgesr, contsr = render_pass(True)
def blur(a, r):
im = Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8))
return np.asarray(im.filter(ImageFilter.GaussianBlur(r))).astype(np.float32) / 255
def blur3(rgb, r):
return np.stack([blur(rgb[..., c], r) for c in range(3)], -1)
# ------------------------------------------------------------------ background
yy, xx = np.mgrid[0:PH, 0:PW].astype(np.float32)
horizon = project(np.array([[0, 2000.0, 0]]))[0, 1] # far floor
floor_y = project(np.array([[0, 0, 0.0]]))[0, 1]
bg = np.zeros((PH, PW, 3), np.float32) + INK
g = np.clip(1 - np.abs(yy - horizon) / 900, 0, 1) ** 1.6 # band of haze at the horizon
bg += g[..., None] * HAZE * 0.55
# sodium glow low behind the robot, smoke plume, a cold light from upper right
glow = np.exp(-(((xx - PW * 0.42) / 520) ** 2 + ((yy - horizon - 40) / 260) ** 2))
bg += glow[..., None] * AMBER * 0.16
plume = np.exp(-(((xx - PW * 0.55) / 380) ** 2 + ((yy - PH * 0.45) / 700) ** 2))
bg += plume[..., None] * HAZE * 0.22
# light shafts
for k, (x0, wdt, a) in enumerate([(1250, 70, 0.05), (1380, 40, 0.035), (1100, 120, 0.03)]):
d = (xx - (x0 - (yy - 0) * 0.42)) / wdt
shaft = np.exp(-d ** 2) * np.clip(1 - yy / (PH * 0.75), 0, 1)
bg += shaft[..., None] * HAZE * a * 4
# rain
rain = Image.new("L", (PW, PH), 0); dr = ImageDraw.Draw(rain)
for _ in range(900):
x = rng.uniform(-200, PW); y = rng.uniform(-100, PH * 0.78); L = rng.uniform(30, 120)
dr.line([(x, y), (x + L * 0.18, y + L)], fill=int(rng.uniform(25, 80)), width=1)
rain = np.asarray(rain.filter(ImageFilter.GaussianBlur(0.7))).astype(np.float32) / 255
bg += rain[..., None] * np.array([0.55, 0.65, 0.70]) * 0.45
# perspective floor grid
grid = Image.new("L", (PW, PH), 0); dg = ImageDraw.Draw(grid)
for gx in np.arange(-1200, 1201, 50.0):
a, b = project(np.array([[gx, -700, 0], [gx, 2400, 0]]))
if a[2] > 0 and b[2] > 0:
dg.line([tuple(a[:2]), tuple(b[:2])], fill=60, width=1)
for gy in np.arange(-700, 2401, 50.0):
a, b = project(np.array([[-1500, gy, 0], [1500, gy, 0]]))
fade = int(70 * math.exp(-max(0, gy) / 900))
dg.line([tuple(a[:2]), tuple(b[:2])], fill=fade, width=1)
gridf = np.asarray(grid).astype(np.float32) / 255
gridf *= (yy > horizon + 10)
# fade the grid toward the horizon
gridf *= np.clip((yy - horizon) / 500, 0, 1)
bg += gridf[..., None] * TEAL * 0.55
# wet floor sheen
floor = (yy > horizon).astype(np.float32) * np.clip((yy - horizon) / 300, 0, 1)
bg *= 1 - 0.25 * floor[..., None]
# ------------------------------------------------------------------ robot layers
def robot_rgb(shade, edges, conts, mask):
e = 1 - np.exp(-edges * 1.6)
c = 1 - np.exp(-conts * 1.4)
rgb = shade * 0.9
rgb = rgb * (1 - 0.35 * c[..., None]) + c[..., None] * TEAL * 0.55
rgb = rgb + e[..., None] * WARM * 0.95
return rgb, e
robot, e_main = robot_rgb(shade, edges, conts, mask)
refl, _ = robot_rgb(shader, edgesr, contsr, maskr)
# ------------------------------------------------------------------ the face on the screen
def persp_coeffs(src, dst):
"""Coefficients for Image.transform(PERSPECTIVE): maps output (dst) pixels back to src."""
A, B = [], []
for (x, y), (u, v) in zip(dst, src):
A.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); B.append(u)
A.append([0, 0, 0, x, y, 1, -v * x, -v * y]); B.append(v)
return np.linalg.solve(np.array(A, float), np.array(B, float))
WIN = np.array([[-17.8464, -15.1813, 161.8], [10.1287, -21.1276, 161.8],
[10.1287, -21.1276, 133.2], [-17.8464, -15.1813, 133.2]])
LEDS = np.array([[9.66058, -21.8868, 126.7], [9.66058, -21.8868, 117.9], [9.66058, -21.8868, 109.5]])
def face_layer(win3):
face = Image.open(FACE).convert("RGB")
fw, fh = face.size
q = project(win3)[:, :2]
coeffs = persp_coeffs([(0, 0), (fw, 0), (fw, fh), (0, fh)], [tuple(p) for p in q])
warped = face.transform((PW, PH), Image.PERSPECTIVE, tuple(coeffs), Image.BILINEAR)
poly = Image.new("L", (PW, PH), 0); ImageDraw.Draw(poly).polygon([tuple(p) for p in q], fill=255)
a = np.asarray(warped).astype(np.float32) / 255 * (np.asarray(poly)[..., None] / 255)
glass = (np.asarray(poly).astype(np.float32) / 255)[..., None] * np.array([0.05, 0.035, 0.02])
return a + glass, np.asarray(poly).astype(np.float32) / 255
face, facepoly = face_layer(WIN)
mirror = lambda p: p * np.array([1, 1, -1])
facer, facepolyr = face_layer(mirror(WIN))
# LEDs glowing through the PETG skin: amber "speaking", the others dark
led = np.zeros((PH, PW), np.float32)
for i, p in enumerate(project(LEDS)):
if i == 0:
led += np.exp(-(((xx - p[0]) / 5) ** 2 + ((yy - p[1]) / 5) ** 2))
ledrgb = led[..., None] * AMBER * 0.9
# ------------------------------------------------------------------ composite
img = bg.copy()
# reflection: dimmed, blurred, fading with distance below the base
refl_all = refl + facer * 1.2
fadeR = np.clip(1 - (yy - floor_y) / 520, 0, 1) ** 1.5 * (yy > floor_y - 4)
refl_all = blur3(refl_all, 2.2) * (0.42 * fadeR)[..., None]
mr = blur(maskr, 2.0) * fadeR
img = img * (1 - 0.55 * mr[..., None]) + refl_all
# robot
img = img * (1 - mask[..., None]) + robot * mask[..., None]
img = img + face * 1.15 + ledrgb
# bloom from the screen and the brightest edges
bright = face * 1.2 + e_main[..., None] * WARM * 0.25 + ledrgb
img += blur3(bright, 9) * 0.9 + blur3(bright, 40) * 0.55
# amber spill from the screen onto the body front
scr = project(WIN).mean(0)
spill = np.exp(-(((xx - scr[0]) / 170) ** 2 + ((yy - scr[1]) / 210) ** 2)) * mask
img += spill[..., None] * SCREEN * 0.07
# ------------------------------------------------------------------ typography and callouts
ui = Image.new("RGBA", (PW, PH), (0, 0, 0, 0))
du = ImageDraw.Draw(ui)
def tracked(draw, xy, text, font, fill, tracking, anchor_center=False):
widths = [draw.textlength(ch, font=font) for ch in text]
total = sum(widths) + tracking * (len(text) - 1)
x, y = xy
if anchor_center:
x -= total / 2
for ch, w in zip(text, widths):
draw.text((x, y), ch, font=font, fill=fill)
x += w + tracking
return total
warm_t = (242, 224, 196, 255)
amber_t = (255, 176, 82, 255)
teal_t = (110, 182, 188, 255)
dim_t = (150, 165, 168, 210)
f_kick = ImageFont.truetype(FONT_M, 26)
f_head = ImageFont.truetype(FONT_B, 210)
f_tag = ImageFont.truetype(FONT_L, 58)
f_call_h = ImageFont.truetype(FONT_C, 30)
f_call = ImageFont.truetype(FONT_M, 22)
f_spec = ImageFont.truetype(FONT_M, 24)
f_fine = ImageFont.truetype(FONT_M, 19)
M = 96
tracked(du, (M, 92), "PERSONAL AGENT UNIT // MODEL H-1", f_kick, teal_t, 6)
du.line([(M, 138), (PW - M, 138)], fill=(110, 182, 188, 90), width=1)
head_layer = Image.new("RGBA", (PW, PH), (0, 0, 0, 0))
dh = ImageDraw.Draw(head_layer)
tracked(dh, (PW / 2, 150), "HERMES", f_head, warm_t, 46, anchor_center=True)
glow_h = head_layer.filter(ImageFilter.GaussianBlur(18))
tracked(du, (PW / 2, 400), "A face for the mind on your machine.", f_tag, (232, 214, 186, 235), 3, anchor_center=True)
# callouts: anchor on the model, label off to the side
def callout(p3, label, sub, side, y_label):
p = project(np.array([p3]))[0]
x_lab = M + 10 if side < 0 else PW - M - 10
wl = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1)
ws = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1)
span = max(wl, ws) + 18
elbow = (x_lab + (span if side < 0 else -span), y_label)
du.ellipse([p[0] - 7, p[1] - 7, p[0] + 7, p[1] + 7], outline=teal_t, width=2)
du.line([(p[0], p[1]), elbow, (x_lab, y_label)], fill=(110, 182, 188, 200), width=2)
if side < 0:
tracked(du, (x_lab, y_label - 44), label, f_call_h, warm_t, 4)
tracked(du, (x_lab, y_label + 10), sub, f_call, dim_t, 2)
else:
w = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1)
tracked(du, (x_lab - w, y_label - 44), label, f_call_h, warm_t, 4)
w2 = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1)
tracked(du, (x_lab - w2, y_label + 10), sub, f_call, dim_t, 2)
a = math.radians(-12)
def posed(X, Y, Z): # body coordinates -> posed frame (matches pose() in the .scad)
qx, qy, qz = X - 43, Y - 20, Z + 38.6
return [qx * math.cos(a) - qy * math.sin(a), qx * math.sin(a) + qy * math.cos(a), qz]
lens = posed(43, 3.0, 146.1 + 26 - 1.0)
grille = posed(48, 0, 25.5)
win_c = WIN.mean(0)
plate_edge = [38 * math.sin(math.radians(-150)), 38 * math.cos(math.radians(-150)), 36.6]
callout(lens, "EYE", "5 MP OPTICAL · TILT ±30°", 1, 640)
callout(win_c, "FACE", "240 × 240 PHOSPHOR", 1, 1020)
callout(grille, "VOICE", "8 Ω SPEAKER · STEREO EARS", -1, 1560)
callout(plate_edge, "NECK", "PAN ±80° ON A BALL RACE", 1, 1880)
# footer
du.line([(M, PH - 250), (PW - M, PH - 250)], fill=(110, 182, 188, 90), width=1)
tracked(du, (PW / 2, PH - 222), "LISTENS. LOOKS. ANSWERS.", ImageFont.truetype(FONT_B, 40), amber_t, 14, anchor_center=True)
tracked(du, (PW / 2, PH - 150), "THE THINKING STAYS ON YOUR OWN COMPUTER.", f_spec, dim_t, 5, anchor_center=True)
tracked(du, (M, PH - 78), "RUNS WITH HERMES AGENT · SOME ASSEMBLY REQUIRED · PRINTED IN PETG", f_fine, (120, 135, 138, 200), 3)
tracked(du, (PW - M - 150, PH - 78), "H-1 / 2026", f_fine, (120, 135, 138, 200), 3)
# crop marks
for (x, y, sx, sy) in [(40, 40, 1, 1), (PW - 40, 40, -1, 1), (40, PH - 40, 1, -1), (PW - 40, PH - 40, -1, -1)]:
du.line([(x, y), (x + 34 * sx, y)], fill=(110, 182, 188, 140), width=2)
du.line([(x, y), (x, y + 34 * sy)], fill=(110, 182, 188, 140), width=2)
def over(base, layer, gain=1.0):
a = np.asarray(layer).astype(np.float32) / 255
return base * (1 - a[..., 3:4] * gain) + a[..., :3] * a[..., 3:4] * gain
img += np.asarray(glow_h).astype(np.float32)[..., :3] / 255 * (np.asarray(glow_h)[..., 3:4] / 255) * np.array([1.0, 0.62, 0.22]) * 0.9
img = over(img, head_layer)
img = over(img, ui)
# ------------------------------------------------------------------ CRT finish
# gentle tone curve and split toning: teal shadows, amber highlights
lum = img.mean(-1, keepdims=True)
img = img + (0.03 * (1 - lum) * np.array([-0.3, 0.2, 0.35])) + 0.04 * lum * np.array([0.4, 0.1, -0.3])
img = np.clip(img, 0, None)
img = img / (1 + img * 0.18) # soft shoulder
# chromatic fringe
r = np.roll(img[..., 0], 2, axis=1); b = np.roll(img[..., 2], -2, axis=1)
img = np.stack([r, img[..., 1], b], -1)
# scanlines: 3 px period, plus a slow brighter roll band
row = yy.astype(int) % 3
scan = np.where(row == 0, 0.58, np.where(row == 1, 0.92, 1.0))
band = 1 + 0.06 * np.exp(-((yy - PH * 0.33) / 90) ** 2)
img *= (scan * band)[..., None]
# grain and vignette
img += rng.normal(0, 0.018, img.shape).astype(np.float32)
vig = 1 - 0.55 * (((xx - PW / 2) / (PW * 0.75)) ** 2 + ((yy - PH * 0.52) / (PH * 0.68)) ** 2)
img *= np.clip(vig, 0.25, 1)[..., None]
out = (np.clip(img, 0, 1) ** (1 / 1.08) * 255).astype(np.uint8)
Image.fromarray(out).save(OUT)
print("wrote", OUT)
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+75406
View File
File diff suppressed because it is too large Load Diff
+22598
View File
File diff suppressed because it is too large Load Diff
+102846
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11986
View File
File diff suppressed because it is too large Load Diff