// m5stackfp enclosure — AtomS3R + Unit Fingerprint2 in one slab.
//
// A FRAME, not a box. The short ends are OPEN: no bar of plastic across them,
// only the rim turning the corner. So the AtomS3R's USB-C plugs straight in
// with nothing above it, and at the far end the sensor's Grove cable leaves
// through the void and drops away. Both devices sit hard against their end,
// working faces flush with the top.
//
// The rim is ONE uniform width everywhere — down the sides, around the window
// corners, and where it turns onto the end faces. At each end the rim turns the
// corner and runs `arm` mm along the end face before the opening starts.
//
// Each device gets its OWN rounded pocket, divided by a 2 mm wall. A single
// shared cavity has no wall where the devices meet, so there is nothing there to
// round. Below the pockets the opening runs through, so the base plate passes
// under the divider as one piece.
//
// No screws. The base plate press-fits into the bottom of the cavity, flush with
// the shell's underside, and the devices sit on it. Screw bosses had to live in
// the divider, which forced it to 7 mm — far too fat next to the original.
//
// Device sizes are exact, from M5Stack's own structure files:
// AtomS3R 24.00 x 24.00 x 12.90
// Fingerprint2 24.00 x 40.00 x 8.00
//
// Printing: shell top-face-down — best finish on the face you look at, and the
// windows need no support. The base carries the pad that lifts the sensor, so
// it grows up off the bed rather than hanging at the end of a shell print.
// Tuned for an Elegoo Centauri Carbon in PLA.
//
// openscad -o shell.stl -D 'part="shell"' enclosure.scad
// openscad -o base.stl -D 'part="base"' enclosure.scad
part = "all"; // "shell" | "base" | "all" | "preview"
/* [Fit] */
clear = 0.25; // per-side slack around each device in X/Y
fit = 0.15; // press-fit slack for the base plate in the cavity
wall = 2.4;
top = 2.0; // material above the devices; windows cut through it
floor_t = 2.0; // base plate, which sits INSIDE the cavity bottom
corner_r = 4.0; // outer corners
// Corner radii measured off M5Stack's own structure files. The Fingerprint2 is
// stadium-shaped: ~3 mm where its connector is, ~12 mm at the other end — and
// 12 mm on a 24 mm-wide module is a full semicircle. The AtomS3R is just a
// rounded square. Pockets add `clear` to these.
atom_r = 5.3;
fp2_r_out = 3.0; // connector end, faces the open right end of the case
fp2_r_in = 12.0; // semicircular end, faces the divider
lip = 1.2; // top overlap retaining each device
/* [Devices] */
atom = [24.0, 24.0, 12.90];
fp2 = [24.0, 40.0, 8.00]; // 24 across the case, 40 along it
/* [Layout] */
// The gap between the devices is a CABLE CHAMBER, not just a divider: the Grove
// ribbon joining the two needs somewhere to sit. It is deliberately wider than
// the visible divider bar — the windows overhang into it from both sides, so
// the top still reads as a thin `div_top` rib while the space below it is open
// for the ribbon and its plug.
gap = 7.0; // cable chamber, full depth
div_top = 2.0; // visible divider bar on the top face
/* [Windows] */
end_r = 3.0; // rounding where the end opening meets the window
end_w = 13.0; // open width at each end; the rest becomes the corner
// arms, which need real length to grip the devices.
// Deriving this from the rim made the opening exactly as
// wide as the window, leaving no corner at all.
/* [Derived] */
rim = wall + lip; // uniform rim width, sides and corner turns
ax = clear;
fx = clear + atom[0] + gap;
inner = [atom[0] + gap + fp2[1] + 2*clear, atom[1] + 2*clear, atom[2] + floor_t];
outer = [inner[0] + 2*wall, inner[1] + 2*wall, inner[2] + top];
fp_rise = atom[2] - fp2[2]; // pad height so the sensor face sits flush
over = (gap - div_top)/2; // window overhang into the cable chamber
// The pockets start fractionally BELOW the base-plate slice rather than exactly
// on it. Cut faces meeting on a shared plane leave a scatter of non-manifold
// edges; overlapping downward avoids that without changing how the plate seats,
// since the plate still fills the slice from 0 to floor_t.
ov = 0.5;
// Corner arm: `end_w` sets its length, and its thickness is the distance from
// the outer face to where the window starts, which is `rim` by construction.
arm = (outer[1] - end_w)/2;
module rrect(sz, r) {
hull() for (x = [r, sz[0]-r], y = [r, sz[1]-r])
translate([x, y, 0]) cylinder(r=r, h=sz[2], $fn=48);
}
module window(x, y, w, d, h, r) {
translate([x, y, 0]) hull()
for (i = [r, w-r], j = [r, d-r])
translate([i, j, 0]) cylinder(r=r, h=h, $fn=40);
}
// Stadium: a different radius at each end along X. Used for the Fingerprint2,
// whose two ends are genuinely different shapes.
module stadium(x, y, w, d, h, r_lo, r_hi) {
translate([x, y, 0]) hull() {
for (j = [r_lo, d - r_lo]) translate([r_lo, j, 0]) cylinder(r=r_lo, h=h, $fn=56);
for (j = [r_hi, d - r_hi]) translate([w - r_hi, j, 0]) cylinder(r=r_hi, h=h, $fn=56);
}
}
module shell() {
difference() {
rrect(outer, corner_r);
// Bottom slice: one continuous opening for the base plate, which
// therefore passes under the divider as a single piece.
translate([wall, wall, -0.1])
rrect([inner[0], inner[1], floor_t + 0.1], atom_r);
// Above that, a SEPARATE rounded pocket per device, so each is properly
// bounded — including where it meets the divider. A single shared
// cavity has no wall there to round.
translate([wall, wall, floor_t - ov])
rrect([atom[0] + 2*clear, inner[1], inner[2] - floor_t + 1 + ov],
atom_r + clear);
stadium(wall + atom[0] + 2*clear + gap, wall,
fp2[1] + 2*clear, inner[1], inner[2] - floor_t + 1 + ov,
fp2_r_in + clear, fp2_r_out + clear);
// Cable chamber between the pockets, full depth.
translate([wall + atom[0] + 2*clear, wall + 2, floor_t - ov])
cube([gap, inner[1] - 4, inner[2] - floor_t + 1 + ov]);
// Top windows.
window(wall + ax + lip, wall + clear + lip,
atom[0] - lip + over, atom[1] - 2*lip, outer[2] + 1, atom_r - lip);
stadium(wall + fx + lip - over, wall + clear + lip,
fp2[1] - lip + over, fp2[0] - 2*lip, outer[2] + 1,
fp2_r_in - lip, fp2_r_out);
// Open ends, full height, centred. What survives at each corner is an
// arm `arm` long whose thickness is the rim, so the rim reads as turning
// through 90 degrees onto the end face.
window(-2, arm, wall + 6, end_w, outer[2] + 1, end_r);
window(outer[0] - wall - 4, arm, wall + 6, end_w, outer[2] + 1, end_r);
}
}
module base() {
// Press-fits into the bottom of the cavity, flush with the shell's
// underside. The devices then sit on it.
translate([wall + fit, wall + fit, 0]) {
rrect([inner[0] - 2*fit, inner[1] - 2*fit, floor_t], atom_r);
// Pad lifting the sensor so its face finishes flush with the top.
// Hollowed out: the ribbon needs to reach the sensor's connector, and
// the space under a raised sensor is the obvious place for it to run.
translate([fx - fit, clear - fit, floor_t])
difference() {
cube([fp2[1], fp2[0], fp_rise]);
translate([4, 4, -0.1]) cube([fp2[1] - 8, fp2[0] - 8, fp_rise + 0.2]);
}
}
}
module devices() { // preview only
color("#4477cc") translate([wall + ax, wall + clear, floor_t]) cube(atom);
color("#cc7744") translate([wall + fx, wall + clear, floor_t + fp_rise])
cube([fp2[1], fp2[0], fp2[2]]);
}
if (part == "shell") shell();
else if (part == "base") base();
else if (part == "preview") { %shell(); devices(); }
else { shell(); translate([0, outer[1] + 6, 0]) base(); }
echo(str("outer: ", outer[0], " x ", outer[1], " x ", outer[2], " mm"));
echo(str("rim: ", rim, " mm everywhere; end opening ", end_w, " mm"));
echo(str("divider: ", gap, " mm"));
Comments