/* * 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 = 11; // [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 = 4; gps_top_gap = 5; oled_component_height = 2.4; 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; rocker_cutout_size = [12.9, 19.4]; rocker_corner_radius = 0.8; rocker_body_size = [15, 21, 21]; rocker_artwork_gap = 2; /* [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 = 1.2; lid_lip_depth = 2.4; lid_lip_thickness = 1.2; fit_clearance = 0.35; 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_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]; 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; 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 + 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_center_x = case_length - wall_thickness - fit_clearance - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance + rocker_body_size[1] / 2; 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_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 ] ]; 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(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(!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_center_x - rocker_body_size[0] / 2 >= board_origin_x + feather_length + fit_clearance, "Rocker body overlaps the Feather PCB"); assert(!battery_enabled || rocker_center_y + rocker_body_size[1] / 2 <= partition_y - fit_clearance, "Rocker body overlaps the battery partition"); 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); 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 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); } } 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. usb_center_z = board_top_z + 1.6 + usb_vertical_offset; face_rounded_cutout( "front", board_origin_y + usb_center_y, usb_center_z, usb_opening_width + hole_compensation, usb_opening_height + hole_compensation, 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_center_x, rocker_center_y, -eps]) centered_rounded_prism( rocker_cutout_size, lid_thickness + lid_lip_depth + 2 * eps, rocker_corner_radius ); 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_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_component_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_center_x - rocker_body_size[0] / 2, rocker_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_center_x - rocker_cutout_size[0] / 2, rocker_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 ); } } 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); } } } 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));