966 lines
32 KiB
OpenSCAD
966 lines
32 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,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;
|
|
switch_hole_diameter = 12;
|
|
switch_hole_compensation = 0.3;
|
|
switch_cap_diameter = 8;
|
|
switch_flange_diameter = 16.2;
|
|
switch_body_length = 24;
|
|
switch_body_diameter = 12;
|
|
switch_wire_allowance = 3;
|
|
|
|
/* [Tripod mount] */
|
|
tripod_insert_enabled = false;
|
|
tripod_insert_length = 6.4;
|
|
tripod_insert_hole_diameter = 8.8;
|
|
tripod_thread_clearance_diameter = 6.8;
|
|
tripod_boss_diameter = 14;
|
|
|
|
/* [M2.5 inserts] */
|
|
m25_insert_length = 4;
|
|
m25_insert_hole_diameter = 3.7;
|
|
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;
|
|
|
|
/* [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 = 12.5;
|
|
usb_opening_height = 7;
|
|
usb_opening_radius = 1.5;
|
|
usb_vertical_offset = 0;
|
|
|
|
/* [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];
|
|
|
|
$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;
|
|
|
|
// 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
|
|
);
|
|
|
|
board_origin_x = wall_thickness + usb_board_wall_gap;
|
|
board_origin_y = wall_thickness + board_side_clearance;
|
|
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 + 1;
|
|
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
|
|
+ battery_diameter / 2;
|
|
battery_start_x = board_origin_x + battery_end_clearance;
|
|
battery_end_x = battery_start_x + battery_length;
|
|
switch_center_x = battery_end_x + max(
|
|
switch_body_diameter / 2 + 0.8,
|
|
switch_flange_diameter / 2 + 0.5
|
|
);
|
|
|
|
case_length_plain = board_origin_x + feather_length
|
|
+ board_rear_clearance + wall_thickness;
|
|
case_length_battery = switch_center_x + switch_flange_diameter / 2
|
|
+ 1.2 + wall_thickness;
|
|
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;
|
|
|
|
battery_center_z = effective_floor + battery_radial_clearance
|
|
+ battery_diameter / 2;
|
|
switch_center_z = max(
|
|
effective_floor + switch_flange_diameter / 2 + 1,
|
|
battery_center_z
|
|
);
|
|
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
|
|
battery_ceiling_z = battery_enabled
|
|
? max(
|
|
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
|
|
switch_center_z + switch_flange_diameter / 2 + 1
|
|
)
|
|
: 0;
|
|
body_height = max(electronics_ceiling_z, battery_ceiling_z);
|
|
|
|
lid_boss_r = m25_boss_diameter / 2;
|
|
lid_boss_wall_overlap = 0.6;
|
|
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
|
|
]
|
|
];
|
|
|
|
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(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
|
|
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
|
|
"M2.5 boss does not have enough radial material");
|
|
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");
|
|
|
|
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, depth, entry_z, downward = true) {
|
|
bore_d = diameter + hole_compensation;
|
|
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 = bore_d + 2 * insert_entry_chamfer
|
|
);
|
|
} 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 = bore_d + 2 * insert_entry_chamfer,
|
|
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
|
|
];
|
|
|
|
// Board/battery partition, with a lead-routing notch near the JST end.
|
|
difference() {
|
|
translate([wall_thickness, partition_y, effective_floor - eps])
|
|
cube([
|
|
battery_end_x - wall_thickness + battery_end_clearance,
|
|
wall_thickness,
|
|
partition_height - effective_floor + eps
|
|
]);
|
|
translate([
|
|
board_origin_x + 6.5,
|
|
partition_y - eps,
|
|
effective_floor + 1.5
|
|
])
|
|
cube([9, wall_thickness + 2 * eps, 6]);
|
|
}
|
|
|
|
// 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,
|
|
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
|
|
effective_floor - eps
|
|
])
|
|
cube([
|
|
wall_thickness,
|
|
battery_diameter + 2 * battery_radial_clearance,
|
|
battery_diameter / 2 + 1 + eps
|
|
]);
|
|
|
|
// Rear cross-strip separates the side-mounted switch body from the cell.
|
|
translate([
|
|
battery_end_x + battery_end_clearance,
|
|
partition_y,
|
|
effective_floor - eps
|
|
])
|
|
cube([
|
|
case_length - wall_thickness - battery_end_x - battery_end_clearance,
|
|
case_width - wall_thickness - partition_y,
|
|
min(body_height - effective_floor, switch_center_z
|
|
+ switch_body_diameter / 2 + switch_wire_allowance
|
|
- effective_floor) + 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([case_length / 2, case_width / 2, 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_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_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 (battery_enabled) {
|
|
face_circle_cutout(
|
|
"right",
|
|
switch_center_x,
|
|
switch_center_z,
|
|
switch_hole_diameter + switch_hole_compensation
|
|
);
|
|
// Clear the switch body and its prewired terminals through the rear bay.
|
|
translate([
|
|
switch_center_x,
|
|
case_width - wall_thickness + eps,
|
|
switch_center_z
|
|
])
|
|
rotate([90, 0, 0])
|
|
cylinder(
|
|
h = switch_body_length + switch_wire_allowance
|
|
+ wall_thickness + eps,
|
|
d = switch_body_diameter + 2 * fit_clearance
|
|
);
|
|
}
|
|
|
|
if (tripod_insert_enabled) {
|
|
translate([case_length / 2, case_width / 2, 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_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) {
|
|
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 (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);
|
|
}
|
|
}
|
|
|
|
module lid() {
|
|
difference() {
|
|
lid_plate_positive();
|
|
lid_cutouts();
|
|
}
|
|
}
|
|
|
|
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.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.8, 0.15, 0.1, 0.65])
|
|
translate([
|
|
switch_center_x,
|
|
case_width - wall_thickness,
|
|
switch_center_z
|
|
])
|
|
rotate([90, 0, 0]) {
|
|
cylinder(h = switch_body_length, d = switch_body_diameter);
|
|
translate([0, 0, -1.5])
|
|
cylinder(h = 1.5, d = switch_flange_diameter);
|
|
translate([0, 0, -3])
|
|
cylinder(h = 3, d = switch_cap_diameter);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 coupon_hole(x, y, diameter, depth) {
|
|
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
|
|
}
|
|
|
|
module insert_coupon() {
|
|
spacing = 15;
|
|
coupon_length = spacing * len(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(coupon_steps) - 1]) {
|
|
x = 7 + i * spacing;
|
|
coupon_hole(
|
|
x, 8,
|
|
m25_insert_hole_diameter + coupon_steps[i],
|
|
m25_insert_length + insert_melt_relief
|
|
);
|
|
coupon_hole(
|
|
x, 23,
|
|
tripod_insert_hole_diameter + coupon_steps[i],
|
|
tripod_insert_length + insert_melt_relief
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
module switch_coupon() {
|
|
spacing = 18;
|
|
coupon_length = spacing * len(coupon_steps) + 6;
|
|
coupon_width = 24;
|
|
coupon_height = wall_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])
|
|
cylinder(
|
|
h = coupon_height + 2 * eps,
|
|
d = switch_hole_diameter + 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 == "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));
|