Files
feather-case/feather_case.scad
T
2026-08-06 18:42:21 -07:00

1238 lines
43 KiB
OpenSCAD

/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 12.25; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 6;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5;
usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation =
lid_art_orientation == "usb_left" ? 0 :
lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled
? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2,
"Tripod insert entry is too close to a Feather standoff");
assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = entry_diameter,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
if (usb_opening_enabled)
face_rounded_cutout(
"front",
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
if (tripod_insert_enabled) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (battery_enabled) {
translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_display_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.08, 0.08, 0.08, 0.85])
translate([
rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps])
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
validate_cutout_spacing();
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));