Initial commit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Hermes robot enclosure
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+22598
File diff suppressed because it is too large
Load Diff
+102846
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+32202
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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user