diff --git a/cases/attitude-imu-battery/README.md b/cases/attitude-imu-battery/README.md deleted file mode 100644 index 65e6a9f..0000000 --- a/cases/attitude-imu-battery/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Attitude IMU - Battery - -Generated by the Feather Case Wizard. - -- Top profile: `generic` -- FeatherWings: 1 -- Battery compartment: yes -- 1/4-20 insert: yes -- Approximate exterior: 93.9 × 57.3 × 35.8 mm -- Formats: 3mf -- Parts: `body`, `lid` -- Custom cutouts: 0 - -Run `ruby build.rb` in this directory to regenerate exports and previews. -OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to -override their executable paths. - -To open the saved Customizer values manually: - -```bash -openscad -p parameters.json -P attitude-imu-battery source/feather_case.scad -``` - -The 3MF files contain portable geometry only. Choose printer, filament, -plate, and slicing settings in Bambu Studio. diff --git a/cases/attitude-imu-battery/build.rb b/cases/attitude-imu-battery/build.rb deleted file mode 100755 index 3deb8ab..0000000 --- a/cases/attitude-imu-battery/build.rb +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "fileutils" -require "json" -require "open3" -require "pathname" -require "tmpdir" -require "yaml" - -ROOT = Pathname(__dir__).expand_path - -def executable(env_name, command) - override = ENV[env_name] - return File.expand_path(override) if override && File.executable?(override) - - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory| - candidate = File.join(directory, command) - return candidate if File.file?(candidate) && File.executable?(candidate) - end - nil -end - -def literal(value) - case value - when String then JSON.generate(value) - when TrueClass then "true" - when FalseClass then "false" - when Numeric then value.to_s - when Array then "[#{value.map { |item| literal(item) }.join(",")}]" - else raise "Cannot convert #{value.inspect} to OpenSCAD" - end -end - -def run!(*command, env: {}) - stdout, stderr, status = Open3.capture3(env, *command) - return if status.success? - - warn [stdout, stderr].reject(&:empty?).join("\n") - raise "Command failed (#{status.exitstatus}): #{command.first}" -end - -config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [], - permitted_symbols: [], aliases: false) -openscad = executable("OPENSCAD", "openscad") -blender = executable("BLENDER", "blender") -abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad -abort "Blender not found; install it or set BLENDER" unless blender - -parameters = config.fetch("parameters").merge( - "extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") -) -parts = %w[body lid] + config.fetch("outputs").fetch("coupons") -buttons = %w[button_a button_b button_c button_reset] -if parameters["top_profile"] == "oled128x64" && - buttons.any? { |name| parameters[name] } - parts << "buttons" -end - -stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s)) -begin - exports = stage.join("exports") - previews = stage.join("previews") - scratch = stage.join("meshes") - FileUtils.mkdir_p([exports, previews, scratch]) - model = ROOT.join("source/feather_case.scad") - renderer = ROOT.join("source/render_stl_previews.py") - - export = lambda do |output, part, overrides = {}| - values = parameters.merge(overrides).merge("part" => part) - command = [openscad, "-o", output.to_s] - values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) } - run!(*command, model.to_s) - end - - config.fetch("outputs").fetch("formats").each do |format| - directory = exports.join(format) - FileUtils.mkdir_p(directory) - parts.each do |part| - puts "Exporting #{part}.#{format}…" - export.call(directory.join("#{part}.#{format}"), part) - end - end - - assembly = scratch.join("assembly.stl") - puts "Preparing assembly preview…" - export.call(assembly, "assembly", - "exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"), - "show_references" => false) - render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil} - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - assembly.to_s, previews.to_s, "assembly", env: render_env) - parts.each do |part| - puts "Rendering #{part} preview…" - mesh = scratch.join("#{part}.stl") - export.call(mesh, part) unless mesh.file? - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - mesh.to_s, previews.to_s, part, "iso", env: render_env) - end - - backup = ROOT.join(".build-old-#{Process.pid}") - FileUtils.mkdir_p(backup) - %w[exports previews].each do |name| - current = ROOT.join(name) - FileUtils.mv(current, backup.join(name)) if current.exist? - end - begin - FileUtils.mv(exports, ROOT.join("exports")) - FileUtils.mv(previews, ROOT.join("previews")) - FileUtils.rm_rf(backup) - rescue StandardError - FileUtils.rm_rf(ROOT.join("exports")) - FileUtils.rm_rf(ROOT.join("previews")) - %w[exports previews].each do |name| - saved = backup.join(name) - FileUtils.mv(saved, ROOT.join(name)) if saved.exist? - end - raise - end - puts "Rebuilt #{ROOT}" -ensure - FileUtils.rm_rf(stage) -end diff --git a/cases/attitude-imu-battery/case.yml b/cases/attitude-imu-battery/case.yml deleted file mode 100644 index 2607b21..0000000 --- a/cases/attitude-imu-battery/case.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -schema_version: 1 -name: Attitude IMU - Battery -slug: attitude-imu-battery -generator: - sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 - created_with: feather-case-wizard -parameters: - top_profile: generic - wing_count: 1 - wing_pitch: 12.25 - stack_height_override: 0.0 - generic_component_height: 3.0 - generic_top_gap: 2.0 - gps_antenna_height: 6.0 - gps_top_gap: 5.0 - oled_component_height: 2.4 - oled_display_height: 1.5 - oled_face_gap: 0.8 - button_a: true - button_b: true - button_c: true - button_reset: false - button_projection: 1.5 - button_cap_size: - - 5.0 - - 3.2 - button_flange_size: - - 6.6 - - 3.6 - button_flange_thickness: 0.9 - button_travel: 0.5 - button_contact_extension: 0.7 - battery_enabled: true - battery_length: 69.0 - battery_diameter: 18.0 - battery_radial_clearance: 0.6 - battery_end_clearance: 1.0 - switch_hole_diameter: 12.0 - switch_hole_compensation: 0.3 - switch_cap_diameter: 8.0 - switch_flange_diameter: 16.2 - switch_body_length: 24.0 - switch_body_diameter: 12.0 - switch_wire_allowance: 3.0 - tripod_insert_enabled: true - tripod_insert_length: 6.4 - tripod_insert_hole_diameter: 8.8 - tripod_thread_clearance_diameter: 6.8 - tripod_boss_diameter: 14.0 - m25_insert_length: 4.0 - m25_insert_hole_diameter: 3.7 - m25_screw_clearance: 2.8 - m25_boss_diameter: 7.0 - insert_entry_chamfer: 0.6 - insert_melt_relief: 0.8 - wall_thickness: 2.4 - floor_thickness: 2.4 - lid_thickness: 3.2 - corner_radius: 4.0 - board_bottom_clearance: 5.4 - board_side_clearance: 7.0 - board_rear_clearance: 4.0 - 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 - usb_opening_width: 12.5 - usb_opening_height: 7.0 - usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 -cutouts: [] -outputs: - formats: - - 3mf - coupons: [] - assembly_exploded_view: 8 -resolved: - extra_cutouts: [] diff --git a/cases/attitude-imu-battery/parameters.json b/cases/attitude-imu-battery/parameters.json deleted file mode 100644 index 696327c..0000000 --- a/cases/attitude-imu-battery/parameters.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "parameterSets": { - "attitude-imu-battery": { - "top_profile": "\"generic\"", - "wing_count": "1", - "wing_pitch": "12.25", - "stack_height_override": "0.0", - "generic_component_height": "3.0", - "generic_top_gap": "2.0", - "gps_antenna_height": "6.0", - "gps_top_gap": "5.0", - "oled_component_height": "2.4", - "oled_display_height": "1.5", - "oled_face_gap": "0.8", - "button_a": "true", - "button_b": "true", - "button_c": "true", - "button_reset": "false", - "button_projection": "1.5", - "button_cap_size": "[5.0,3.2]", - "button_flange_size": "[6.6,3.6]", - "button_flange_thickness": "0.9", - "button_travel": "0.5", - "button_contact_extension": "0.7", - "battery_enabled": "true", - "battery_length": "69.0", - "battery_diameter": "18.0", - "battery_radial_clearance": "0.6", - "battery_end_clearance": "1.0", - "switch_hole_diameter": "12.0", - "switch_hole_compensation": "0.3", - "switch_cap_diameter": "8.0", - "switch_flange_diameter": "16.2", - "switch_body_length": "24.0", - "switch_body_diameter": "12.0", - "switch_wire_allowance": "3.0", - "tripod_insert_enabled": "true", - "tripod_insert_length": "6.4", - "tripod_insert_hole_diameter": "8.8", - "tripod_thread_clearance_diameter": "6.8", - "tripod_boss_diameter": "14.0", - "m25_insert_length": "4.0", - "m25_insert_hole_diameter": "3.7", - "m25_screw_clearance": "2.8", - "m25_boss_diameter": "7.0", - "insert_entry_chamfer": "0.6", - "insert_melt_relief": "0.8", - "wall_thickness": "2.4", - "floor_thickness": "2.4", - "lid_thickness": "3.2", - "corner_radius": "4.0", - "board_bottom_clearance": "5.4", - "board_side_clearance": "7.0", - "board_rear_clearance": "4.0", - "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", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", - "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" - } - }, - "fileFormatVersion": "1" -} diff --git a/cases/attitude-imu-battery/source/feather_case.scad b/cases/attitude-imu-battery/source/feather_case.scad deleted file mode 100644 index 5a26c7b..0000000 --- a/cases/attitude-imu-battery/source/feather_case.scad +++ /dev/null @@ -1,872 +0,0 @@ -/* - * 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 = 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; - -/* [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]; - -$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; - -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"); - -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, 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 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); - } -} - -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. - 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 (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); - } - } -} - -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)); diff --git a/cases/attitude-imu-battery/source/render_stl_previews.py b/cases/attitude-imu-battery/source/render_stl_previews.py deleted file mode 100644 index c8675ac..0000000 --- a/cases/attitude-imu-battery/source/render_stl_previews.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Render six inspection views of an STL using Blender. - -Usage: - blender --background --python tools/render_stl_previews.py -- \ - input.stl output-directory basename [comma-separated-views] -""" - -import os -import sys - -import bpy -from mathutils import Vector - - -def arguments(): - args = sys.argv[sys.argv.index("--") + 1 :] - if len(args) not in (3, 4): - raise SystemExit( - "expected: input.stl output-directory basename [comma-separated-views]" - ) - requested_views = args[3].split(",") if len(args) == 4 else None - return args[0], args[1], args[2], requested_views - - -def look_at(camera, target): - direction = Vector(target) - camera.location - camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() - - -def render_view(camera, center, span, name, direction, output_dir, basename): - distance = span * 2.2 - camera.location = Vector(center) + Vector(direction).normalized() * distance - look_at(camera, center) - camera.data.type = "ORTHO" - camera.data.ortho_scale = span * 1.35 - bpy.context.scene.render.filepath = os.path.join( - output_dir, f"{basename}_{name}.png" - ) - bpy.ops.render.render(write_still=True) - - -def main(): - input_path, output_dir, basename, requested_views = arguments() - os.makedirs(output_dir, exist_ok=True) - - bpy.ops.wm.read_factory_settings(use_empty=True) - bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path)) - model = bpy.context.selected_objects[0] - model.name = "Feather case" - - material = bpy.data.materials.new("Case material") - material.diffuse_color = (0.16, 0.42, 0.72, 1.0) - material.metallic = 0.0 - material.roughness = 0.45 - model.data.materials.append(material) - - corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box] - mins = Vector([min(v[i] for v in corners) for i in range(3)]) - maxs = Vector([max(v[i] for v in corners) for i in range(3)]) - center = (mins + maxs) / 2 - span = max(maxs - mins) - - world = bpy.data.worlds.new("Inspection world") - world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = ( - 0.035, - 0.04, - 0.05, - 1, - ) - world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5 - bpy.context.scene.world = world - - camera_data = bpy.data.cameras.new("Camera") - camera = bpy.data.objects.new("Camera", camera_data) - bpy.context.collection.objects.link(camera) - bpy.context.scene.camera = camera - - key_data = bpy.data.lights.new("Key", type="AREA") - key_data.energy = 900 - key_data.shape = "DISK" - key_data.size = span - key = bpy.data.objects.new("Key", key_data) - key.location = center + Vector((span, -span, span * 1.5)) - bpy.context.collection.objects.link(key) - - fill_data = bpy.data.lights.new("Fill", type="AREA") - fill_data.energy = 500 - fill_data.size = span - fill = bpy.data.objects.new("Fill", fill_data) - fill.location = center + Vector((-span, span, span)) - bpy.context.collection.objects.link(fill) - - scene = bpy.context.scene - scene.render.engine = "BLENDER_WORKBENCH" - scene.render.resolution_x = 800 - scene.render.resolution_y = 600 - scene.render.resolution_percentage = 100 - scene.render.image_settings.file_format = "PNG" - scene.render.film_transparent = False - scene.display.shading.light = "STUDIO" - scene.display.shading.color_type = "MATERIAL" - scene.display.shading.show_shadows = True - scene.display.shading.show_cavity = True - scene.display.shading.cavity_type = "BOTH" - scene.display.shading.curvature_ridge_factor = 1.5 - scene.display.shading.curvature_valley_factor = 1.2 - - views = { - "iso": (1, -1, 0.8), - "front": (-1, 0, 0), - "back": (1, 0, 0), - "left": (0, -1, 0), - "right": (0, 1, 0), - "top": (0, 0, 1), - } - selected = requested_views or list(views) - unknown = sorted(set(selected) - set(views)) - if unknown: - raise SystemExit(f"unknown views: {', '.join(unknown)}") - for name in selected: - direction = views[name] - render_view(camera, center, span, name, direction, output_dir, basename) - - -if __name__ == "__main__": - main() diff --git a/cases/attitude-imu/README.md b/cases/attitude-imu/README.md deleted file mode 100644 index 27c8bce..0000000 --- a/cases/attitude-imu/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Attitude IMU - -Generated by the Feather Case Wizard. - -- Top profile: `generic` -- FeatherWings: 1 -- Battery compartment: no -- 1/4-20 insert: yes -- Approximate exterior: 60.8 × 41.7 × 35.8 mm -- Formats: 3mf -- Parts: `body`, `lid` -- Custom cutouts: 0 - -Run `ruby build.rb` in this directory to regenerate exports and previews. -OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to -override their executable paths. - -To open the saved Customizer values manually: - -```bash -openscad -p parameters.json -P attitude-imu source/feather_case.scad -``` - -The 3MF files contain portable geometry only. Choose printer, filament, -plate, and slicing settings in Bambu Studio. diff --git a/cases/attitude-imu/build.rb b/cases/attitude-imu/build.rb deleted file mode 100755 index 3deb8ab..0000000 --- a/cases/attitude-imu/build.rb +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "fileutils" -require "json" -require "open3" -require "pathname" -require "tmpdir" -require "yaml" - -ROOT = Pathname(__dir__).expand_path - -def executable(env_name, command) - override = ENV[env_name] - return File.expand_path(override) if override && File.executable?(override) - - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory| - candidate = File.join(directory, command) - return candidate if File.file?(candidate) && File.executable?(candidate) - end - nil -end - -def literal(value) - case value - when String then JSON.generate(value) - when TrueClass then "true" - when FalseClass then "false" - when Numeric then value.to_s - when Array then "[#{value.map { |item| literal(item) }.join(",")}]" - else raise "Cannot convert #{value.inspect} to OpenSCAD" - end -end - -def run!(*command, env: {}) - stdout, stderr, status = Open3.capture3(env, *command) - return if status.success? - - warn [stdout, stderr].reject(&:empty?).join("\n") - raise "Command failed (#{status.exitstatus}): #{command.first}" -end - -config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [], - permitted_symbols: [], aliases: false) -openscad = executable("OPENSCAD", "openscad") -blender = executable("BLENDER", "blender") -abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad -abort "Blender not found; install it or set BLENDER" unless blender - -parameters = config.fetch("parameters").merge( - "extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") -) -parts = %w[body lid] + config.fetch("outputs").fetch("coupons") -buttons = %w[button_a button_b button_c button_reset] -if parameters["top_profile"] == "oled128x64" && - buttons.any? { |name| parameters[name] } - parts << "buttons" -end - -stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s)) -begin - exports = stage.join("exports") - previews = stage.join("previews") - scratch = stage.join("meshes") - FileUtils.mkdir_p([exports, previews, scratch]) - model = ROOT.join("source/feather_case.scad") - renderer = ROOT.join("source/render_stl_previews.py") - - export = lambda do |output, part, overrides = {}| - values = parameters.merge(overrides).merge("part" => part) - command = [openscad, "-o", output.to_s] - values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) } - run!(*command, model.to_s) - end - - config.fetch("outputs").fetch("formats").each do |format| - directory = exports.join(format) - FileUtils.mkdir_p(directory) - parts.each do |part| - puts "Exporting #{part}.#{format}…" - export.call(directory.join("#{part}.#{format}"), part) - end - end - - assembly = scratch.join("assembly.stl") - puts "Preparing assembly preview…" - export.call(assembly, "assembly", - "exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"), - "show_references" => false) - render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil} - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - assembly.to_s, previews.to_s, "assembly", env: render_env) - parts.each do |part| - puts "Rendering #{part} preview…" - mesh = scratch.join("#{part}.stl") - export.call(mesh, part) unless mesh.file? - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - mesh.to_s, previews.to_s, part, "iso", env: render_env) - end - - backup = ROOT.join(".build-old-#{Process.pid}") - FileUtils.mkdir_p(backup) - %w[exports previews].each do |name| - current = ROOT.join(name) - FileUtils.mv(current, backup.join(name)) if current.exist? - end - begin - FileUtils.mv(exports, ROOT.join("exports")) - FileUtils.mv(previews, ROOT.join("previews")) - FileUtils.rm_rf(backup) - rescue StandardError - FileUtils.rm_rf(ROOT.join("exports")) - FileUtils.rm_rf(ROOT.join("previews")) - %w[exports previews].each do |name| - saved = backup.join(name) - FileUtils.mv(saved, ROOT.join(name)) if saved.exist? - end - raise - end - puts "Rebuilt #{ROOT}" -ensure - FileUtils.rm_rf(stage) -end diff --git a/cases/attitude-imu/case.yml b/cases/attitude-imu/case.yml deleted file mode 100644 index 6a63849..0000000 --- a/cases/attitude-imu/case.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -schema_version: 1 -name: Attitude IMU -slug: attitude-imu -generator: - sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 - created_with: feather-case-wizard -parameters: - top_profile: generic - wing_count: 1 - wing_pitch: 12.25 - stack_height_override: 0.0 - generic_component_height: 3.0 - generic_top_gap: 2.0 - gps_antenna_height: 6.0 - gps_top_gap: 5.0 - oled_component_height: 2.4 - oled_display_height: 1.5 - oled_face_gap: 0.8 - button_a: true - button_b: true - button_c: true - button_reset: false - button_projection: 1.5 - button_cap_size: - - 5.0 - - 3.2 - button_flange_size: - - 6.6 - - 3.6 - button_flange_thickness: 0.9 - button_travel: 0.5 - button_contact_extension: 0.7 - battery_enabled: false - battery_length: 69.0 - battery_diameter: 18.0 - battery_radial_clearance: 0.6 - battery_end_clearance: 1.0 - switch_hole_diameter: 12.0 - switch_hole_compensation: 0.3 - switch_cap_diameter: 8.0 - switch_flange_diameter: 16.2 - switch_body_length: 24.0 - switch_body_diameter: 12.0 - switch_wire_allowance: 3.0 - tripod_insert_enabled: true - tripod_insert_length: 6.4 - tripod_insert_hole_diameter: 8.8 - tripod_thread_clearance_diameter: 6.8 - tripod_boss_diameter: 14.0 - m25_insert_length: 4.0 - m25_insert_hole_diameter: 3.7 - m25_screw_clearance: 2.8 - m25_boss_diameter: 7.0 - insert_entry_chamfer: 0.6 - insert_melt_relief: 0.8 - wall_thickness: 2.4 - floor_thickness: 2.4 - lid_thickness: 3.2 - corner_radius: 4.0 - board_bottom_clearance: 5.4 - board_side_clearance: 7.0 - board_rear_clearance: 4.0 - 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 - usb_opening_width: 12.5 - usb_opening_height: 7.0 - usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 -cutouts: [] -outputs: - formats: - - 3mf - coupons: [] - assembly_exploded_view: 8 -resolved: - extra_cutouts: [] diff --git a/cases/attitude-imu/parameters.json b/cases/attitude-imu/parameters.json deleted file mode 100644 index 32b6f11..0000000 --- a/cases/attitude-imu/parameters.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "parameterSets": { - "attitude-imu": { - "top_profile": "\"generic\"", - "wing_count": "1", - "wing_pitch": "12.25", - "stack_height_override": "0.0", - "generic_component_height": "3.0", - "generic_top_gap": "2.0", - "gps_antenna_height": "6.0", - "gps_top_gap": "5.0", - "oled_component_height": "2.4", - "oled_display_height": "1.5", - "oled_face_gap": "0.8", - "button_a": "true", - "button_b": "true", - "button_c": "true", - "button_reset": "false", - "button_projection": "1.5", - "button_cap_size": "[5.0,3.2]", - "button_flange_size": "[6.6,3.6]", - "button_flange_thickness": "0.9", - "button_travel": "0.5", - "button_contact_extension": "0.7", - "battery_enabled": "false", - "battery_length": "69.0", - "battery_diameter": "18.0", - "battery_radial_clearance": "0.6", - "battery_end_clearance": "1.0", - "switch_hole_diameter": "12.0", - "switch_hole_compensation": "0.3", - "switch_cap_diameter": "8.0", - "switch_flange_diameter": "16.2", - "switch_body_length": "24.0", - "switch_body_diameter": "12.0", - "switch_wire_allowance": "3.0", - "tripod_insert_enabled": "true", - "tripod_insert_length": "6.4", - "tripod_insert_hole_diameter": "8.8", - "tripod_thread_clearance_diameter": "6.8", - "tripod_boss_diameter": "14.0", - "m25_insert_length": "4.0", - "m25_insert_hole_diameter": "3.7", - "m25_screw_clearance": "2.8", - "m25_boss_diameter": "7.0", - "insert_entry_chamfer": "0.6", - "insert_melt_relief": "0.8", - "wall_thickness": "2.4", - "floor_thickness": "2.4", - "lid_thickness": "3.2", - "corner_radius": "4.0", - "board_bottom_clearance": "5.4", - "board_side_clearance": "7.0", - "board_rear_clearance": "4.0", - "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", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", - "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" - } - }, - "fileFormatVersion": "1" -} diff --git a/cases/attitude-imu/source/feather_case.scad b/cases/attitude-imu/source/feather_case.scad deleted file mode 100644 index 5a26c7b..0000000 --- a/cases/attitude-imu/source/feather_case.scad +++ /dev/null @@ -1,872 +0,0 @@ -/* - * 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 = 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; - -/* [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]; - -$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; - -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"); - -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, 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 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); - } -} - -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. - 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 (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); - } - } -} - -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)); diff --git a/cases/attitude-imu/source/render_stl_previews.py b/cases/attitude-imu/source/render_stl_previews.py deleted file mode 100644 index c8675ac..0000000 --- a/cases/attitude-imu/source/render_stl_previews.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Render six inspection views of an STL using Blender. - -Usage: - blender --background --python tools/render_stl_previews.py -- \ - input.stl output-directory basename [comma-separated-views] -""" - -import os -import sys - -import bpy -from mathutils import Vector - - -def arguments(): - args = sys.argv[sys.argv.index("--") + 1 :] - if len(args) not in (3, 4): - raise SystemExit( - "expected: input.stl output-directory basename [comma-separated-views]" - ) - requested_views = args[3].split(",") if len(args) == 4 else None - return args[0], args[1], args[2], requested_views - - -def look_at(camera, target): - direction = Vector(target) - camera.location - camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() - - -def render_view(camera, center, span, name, direction, output_dir, basename): - distance = span * 2.2 - camera.location = Vector(center) + Vector(direction).normalized() * distance - look_at(camera, center) - camera.data.type = "ORTHO" - camera.data.ortho_scale = span * 1.35 - bpy.context.scene.render.filepath = os.path.join( - output_dir, f"{basename}_{name}.png" - ) - bpy.ops.render.render(write_still=True) - - -def main(): - input_path, output_dir, basename, requested_views = arguments() - os.makedirs(output_dir, exist_ok=True) - - bpy.ops.wm.read_factory_settings(use_empty=True) - bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path)) - model = bpy.context.selected_objects[0] - model.name = "Feather case" - - material = bpy.data.materials.new("Case material") - material.diffuse_color = (0.16, 0.42, 0.72, 1.0) - material.metallic = 0.0 - material.roughness = 0.45 - model.data.materials.append(material) - - corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box] - mins = Vector([min(v[i] for v in corners) for i in range(3)]) - maxs = Vector([max(v[i] for v in corners) for i in range(3)]) - center = (mins + maxs) / 2 - span = max(maxs - mins) - - world = bpy.data.worlds.new("Inspection world") - world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = ( - 0.035, - 0.04, - 0.05, - 1, - ) - world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5 - bpy.context.scene.world = world - - camera_data = bpy.data.cameras.new("Camera") - camera = bpy.data.objects.new("Camera", camera_data) - bpy.context.collection.objects.link(camera) - bpy.context.scene.camera = camera - - key_data = bpy.data.lights.new("Key", type="AREA") - key_data.energy = 900 - key_data.shape = "DISK" - key_data.size = span - key = bpy.data.objects.new("Key", key_data) - key.location = center + Vector((span, -span, span * 1.5)) - bpy.context.collection.objects.link(key) - - fill_data = bpy.data.lights.new("Fill", type="AREA") - fill_data.energy = 500 - fill_data.size = span - fill = bpy.data.objects.new("Fill", fill_data) - fill.location = center + Vector((-span, span, span)) - bpy.context.collection.objects.link(fill) - - scene = bpy.context.scene - scene.render.engine = "BLENDER_WORKBENCH" - scene.render.resolution_x = 800 - scene.render.resolution_y = 600 - scene.render.resolution_percentage = 100 - scene.render.image_settings.file_format = "PNG" - scene.render.film_transparent = False - scene.display.shading.light = "STUDIO" - scene.display.shading.color_type = "MATERIAL" - scene.display.shading.show_shadows = True - scene.display.shading.show_cavity = True - scene.display.shading.cavity_type = "BOTH" - scene.display.shading.curvature_ridge_factor = 1.5 - scene.display.shading.curvature_valley_factor = 1.2 - - views = { - "iso": (1, -1, 0.8), - "front": (-1, 0, 0), - "back": (1, 0, 0), - "left": (0, -1, 0), - "right": (0, 1, 0), - "top": (0, 0, 1), - } - selected = requested_views or list(views) - unknown = sorted(set(selected) - set(views)) - if unknown: - raise SystemExit(f"unknown views: {', '.join(unknown)}") - for name in selected: - direction = views[name] - render_view(camera, center, span, name, direction, output_dir, basename) - - -if __name__ == "__main__": - main() diff --git a/cases/attitude-no-battery/README.md b/cases/attitude-no-battery/README.md index 816c351..8c279d6 100644 --- a/cases/attitude-no-battery/README.md +++ b/cases/attitude-no-battery/README.md @@ -6,7 +6,7 @@ Generated by the Feather Case Wizard. - FeatherWings: 1 - Battery compartment: no - 1/4-20 insert: yes -- Approximate exterior: 60.8 × 41.7 × 33.2 mm +- Approximate exterior: 61.6 × 41.7 × 34.4 mm - Formats: 3mf - Parts: `body`, `lid`, `lid_base`, `lid_accent` - Custom cutouts: 0 diff --git a/cases/attitude-no-battery/case.yml b/cases/attitude-no-battery/case.yml index e0b2c8c..d70f869 100644 --- a/cases/attitude-no-battery/case.yml +++ b/cases/attitude-no-battery/case.yml @@ -3,18 +3,19 @@ schema_version: 1 name: Attitude no battery slug: attitude-no-battery generator: - sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 + sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13 created_with: feather-case-wizard parameters: top_profile: generic wing_count: 1 - wing_pitch: 11.0 + wing_pitch: 12.25 stack_height_override: 0.0 generic_component_height: 3.0 generic_top_gap: 2.0 - gps_antenna_height: 4.0 + gps_antenna_height: 6.0 gps_top_gap: 5.0 oled_component_height: 2.4 + oled_display_height: 1.5 oled_face_gap: 0.8 button_a: true button_b: true @@ -36,6 +37,7 @@ parameters: battery_radial_clearance: 0.6 battery_end_clearance: 1.0 battery_partition_thickness: 1.8 + battery_board_clearance: 3.0 rocker_cutout_size: - 12.9 - 19.4 @@ -45,6 +47,10 @@ parameters: - 21.0 - 21.0 rocker_artwork_gap: 2.0 + rocker_partition_offset: 2.0 + rocker_tab_panel_thickness: 2.0 + rocker_tab_land: 2.0 + rocker_tab_taper_length: 0.6 tripod_insert_enabled: true tripod_insert_length: 5.0 tripod_insert_hole_diameter: 7.2 @@ -65,13 +71,19 @@ parameters: board_bottom_clearance: 5.4 board_side_clearance: 7.0 board_rear_clearance: 4.0 - usb_board_wall_gap: 1.2 + usb_board_wall_gap: 2.0 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_opening_enabled: true + usb_guide_enabled: true + usb_connector_overhang: 2.0 + usb_guide_wall_thickness: 1.2 + usb_guide_end_gap: 1.0 lid_label: IMU lid_logo_enabled: true lid_art_orientation: usb_front @@ -80,10 +92,10 @@ parameters: lid_label_size: 5.0 lid_label_logo_gap: 2.0 lid_art_margin: 8.5 - usb_opening_width: 12.5 - usb_opening_height: 7.0 + usb_opening_width: 13.5 + usb_opening_height: 8.0 usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 + usb_vertical_offset: 0.5 cutouts: [] outputs: formats: diff --git a/cases/attitude-no-battery/parameters.json b/cases/attitude-no-battery/parameters.json index a4ded58..d12777d 100644 --- a/cases/attitude-no-battery/parameters.json +++ b/cases/attitude-no-battery/parameters.json @@ -3,13 +3,14 @@ "attitude-no-battery": { "top_profile": "\"generic\"", "wing_count": "1", - "wing_pitch": "11.0", + "wing_pitch": "12.25", "stack_height_override": "0.0", "generic_component_height": "3.0", "generic_top_gap": "2.0", - "gps_antenna_height": "4.0", + "gps_antenna_height": "6.0", "gps_top_gap": "5.0", "oled_component_height": "2.4", + "oled_display_height": "1.5", "oled_face_gap": "0.8", "button_a": "true", "button_b": "true", @@ -27,10 +28,15 @@ "battery_radial_clearance": "0.6", "battery_end_clearance": "1.0", "battery_partition_thickness": "1.8", + "battery_board_clearance": "3.0", "rocker_cutout_size": "[12.9,19.4]", "rocker_corner_radius": "0.8", "rocker_body_size": "[15.0,21.0,21.0]", "rocker_artwork_gap": "2.0", + "rocker_partition_offset": "2.0", + "rocker_tab_panel_thickness": "2.0", + "rocker_tab_land": "2.0", + "rocker_tab_taper_length": "0.6", "tripod_insert_enabled": "true", "tripod_insert_length": "5.0", "tripod_insert_hole_diameter": "7.2", @@ -51,13 +57,19 @@ "board_bottom_clearance": "5.4", "board_side_clearance": "7.0", "board_rear_clearance": "4.0", - "usb_board_wall_gap": "1.2", + "usb_board_wall_gap": "2.0", "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_opening_enabled": "true", + "usb_guide_enabled": "true", + "usb_connector_overhang": "2.0", + "usb_guide_wall_thickness": "1.2", + "usb_guide_end_gap": "1.0", "lid_label": "\"IMU\"", "lid_logo_enabled": "true", "lid_art_orientation": "\"usb_front\"", @@ -66,10 +78,10 @@ "lid_label_size": "5.0", "lid_label_logo_gap": "2.0", "lid_art_margin": "8.5", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", + "usb_opening_width": "13.5", + "usb_opening_height": "8.0", "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" + "usb_vertical_offset": "0.5" } }, "fileFormatVersion": "1" diff --git a/cases/attitude-no-battery/source/feather_case.scad b/cases/attitude-no-battery/source/feather_case.scad index 3a33f30..92aab0f 100644 --- a/cases/attitude-no-battery/source/feather_case.scad +++ b/cases/attitude-no-battery/source/feather_case.scad @@ -19,13 +19,14 @@ show_references = true; /* [Stack] */ top_profile = "generic"; // [generic,gps,oled128x64] wing_count = 1; // [1:1:8] -wing_pitch = 11; // [7:0.1:16] +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 = 4; +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] */ @@ -47,10 +48,15 @@ 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; @@ -77,10 +83,11 @@ corner_radius = 4; board_bottom_clearance = 5.4; board_side_clearance = 7; board_rear_clearance = 4; -usb_board_wall_gap = 1.2; +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; @@ -97,10 +104,15 @@ 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_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; +usb_vertical_offset = 0.5; /* [Custom wall cutouts] */ // Entry format: [face, shape, u, z, width, height, radius] @@ -156,6 +168,20 @@ 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 @@ -170,7 +196,7 @@ profile_top_gap = top_profile == "oled128x64" ? oled_face_gap : generic_top_gap; -partition_y = board_origin_y + feather_width + 1; +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 @@ -191,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; -rocker_center_x = case_length - wall_thickness - fit_clearance +rocker_installed_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; +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 = @@ -202,7 +232,7 @@ lid_art_rotation = 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 + ? 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, @@ -273,6 +303,34 @@ lid_boss_xy = [ 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" @@ -280,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps" 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" @@ -302,6 +368,15 @@ assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, 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"); @@ -311,18 +386,73 @@ 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 + || 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_center_y + rocker_body_size[1] / 2 + || 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); @@ -406,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) { 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]; @@ -554,6 +718,8 @@ module body_positive() { 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(); } } @@ -579,15 +745,15 @@ module body_cutouts() { ); // 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 (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]) { @@ -711,13 +877,18 @@ module lid_cutouts() { ); } - if (battery_enabled) - translate([rocker_center_x, rocker_center_y, -eps]) + 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(); @@ -728,6 +899,41 @@ module lid_cutouts() { } } +// 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) @@ -854,7 +1060,7 @@ module reference_top_feature() { cube([ oled_visible_size[0], oled_visible_size[1], - oled_component_height + oled_display_height ]); color([0.6, 0.6, 0.6, 0.9]) for (xy = [ @@ -891,15 +1097,15 @@ module reference_battery_and_switch() { 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, + 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_center_x - rocker_cutout_size[0] / 2, - rocker_center_y - rocker_cutout_size[1] / 2, + 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]); @@ -957,7 +1163,7 @@ module switch_coupon() { coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) + for (i = [0 : len(coupon_steps) - 1]) { translate([6 + i * spacing, coupon_width / 2, -eps]) centered_rounded_prism( rocker_cutout_size @@ -965,6 +1171,11 @@ module switch_coupon() { 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]] + ); + } } } @@ -1002,6 +1213,8 @@ module fit_coupon() { } } +validate_cutout_spacing(); + if (part == "assembly") assembly(); else if (part == "body") diff --git a/cases/attitude-w-battery/README.md b/cases/attitude-w-battery/README.md index 13b3e51..84b4030 100644 --- a/cases/attitude-w-battery/README.md +++ b/cases/attitude-w-battery/README.md @@ -6,7 +6,7 @@ Generated by the Feather Case Wizard. - FeatherWings: 1 - Battery compartment: yes - 1/4-20 insert: yes -- Approximate exterior: 88.6 × 59.1 × 33.2 mm +- Approximate exterior: 88.6 × 61.1 × 34.4 mm - Formats: 3mf - Parts: `body`, `lid` - Custom cutouts: 0 diff --git a/cases/attitude-w-battery/case.yml b/cases/attitude-w-battery/case.yml index f7b02d0..846486f 100644 --- a/cases/attitude-w-battery/case.yml +++ b/cases/attitude-w-battery/case.yml @@ -3,18 +3,19 @@ schema_version: 1 name: Attitude w/battery slug: attitude-w-battery generator: - sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 + sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13 created_with: feather-case-wizard parameters: top_profile: generic wing_count: 1 - wing_pitch: 11.0 + wing_pitch: 12.25 stack_height_override: 0.0 generic_component_height: 3.0 generic_top_gap: 2.0 - gps_antenna_height: 4.0 + gps_antenna_height: 6.0 gps_top_gap: 5.0 oled_component_height: 2.4 + oled_display_height: 1.5 oled_face_gap: 0.8 button_a: true button_b: true @@ -36,6 +37,7 @@ parameters: battery_radial_clearance: 0.6 battery_end_clearance: 1.0 battery_partition_thickness: 1.8 + battery_board_clearance: 3.0 rocker_cutout_size: - 12.9 - 19.4 @@ -45,6 +47,10 @@ parameters: - 21.0 - 21.0 rocker_artwork_gap: 2.0 + rocker_partition_offset: 2.0 + rocker_tab_panel_thickness: 2.0 + rocker_tab_land: 2.0 + rocker_tab_taper_length: 0.6 tripod_insert_enabled: true tripod_insert_length: 5.0 tripod_insert_hole_diameter: 7.2 @@ -65,13 +71,19 @@ parameters: board_bottom_clearance: 5.4 board_side_clearance: 7.0 board_rear_clearance: 4.0 - usb_board_wall_gap: 1.2 + usb_board_wall_gap: 2.0 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_opening_enabled: true + usb_guide_enabled: true + usb_connector_overhang: 2.0 + usb_guide_wall_thickness: 1.2 + usb_guide_end_gap: 1.0 lid_label: '' lid_logo_enabled: false lid_art_orientation: usb_left @@ -80,10 +92,10 @@ parameters: lid_label_size: 5.0 lid_label_logo_gap: 2.0 lid_art_margin: 8.5 - usb_opening_width: 12.5 - usb_opening_height: 7.0 + usb_opening_width: 13.5 + usb_opening_height: 8.0 usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 + usb_vertical_offset: 0.5 cutouts: [] outputs: formats: diff --git a/cases/attitude-w-battery/parameters.json b/cases/attitude-w-battery/parameters.json index ac4febe..803e336 100644 --- a/cases/attitude-w-battery/parameters.json +++ b/cases/attitude-w-battery/parameters.json @@ -3,13 +3,14 @@ "attitude-w-battery": { "top_profile": "\"generic\"", "wing_count": "1", - "wing_pitch": "11.0", + "wing_pitch": "12.25", "stack_height_override": "0.0", "generic_component_height": "3.0", "generic_top_gap": "2.0", - "gps_antenna_height": "4.0", + "gps_antenna_height": "6.0", "gps_top_gap": "5.0", "oled_component_height": "2.4", + "oled_display_height": "1.5", "oled_face_gap": "0.8", "button_a": "true", "button_b": "true", @@ -27,10 +28,15 @@ "battery_radial_clearance": "0.6", "battery_end_clearance": "1.0", "battery_partition_thickness": "1.8", + "battery_board_clearance": "3.0", "rocker_cutout_size": "[12.9,19.4]", "rocker_corner_radius": "0.8", "rocker_body_size": "[15.0,21.0,21.0]", "rocker_artwork_gap": "2.0", + "rocker_partition_offset": "2.0", + "rocker_tab_panel_thickness": "2.0", + "rocker_tab_land": "2.0", + "rocker_tab_taper_length": "0.6", "tripod_insert_enabled": "true", "tripod_insert_length": "5.0", "tripod_insert_hole_diameter": "7.2", @@ -51,13 +57,19 @@ "board_bottom_clearance": "5.4", "board_side_clearance": "7.0", "board_rear_clearance": "4.0", - "usb_board_wall_gap": "1.2", + "usb_board_wall_gap": "2.0", "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_opening_enabled": "true", + "usb_guide_enabled": "true", + "usb_connector_overhang": "2.0", + "usb_guide_wall_thickness": "1.2", + "usb_guide_end_gap": "1.0", "lid_label": "\"\"", "lid_logo_enabled": "false", "lid_art_orientation": "\"usb_left\"", @@ -66,10 +78,10 @@ "lid_label_size": "5.0", "lid_label_logo_gap": "2.0", "lid_art_margin": "8.5", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", + "usb_opening_width": "13.5", + "usb_opening_height": "8.0", "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" + "usb_vertical_offset": "0.5" } }, "fileFormatVersion": "1" diff --git a/cases/attitude-w-battery/source/feather_case.scad b/cases/attitude-w-battery/source/feather_case.scad index 3a33f30..92aab0f 100644 --- a/cases/attitude-w-battery/source/feather_case.scad +++ b/cases/attitude-w-battery/source/feather_case.scad @@ -19,13 +19,14 @@ show_references = true; /* [Stack] */ top_profile = "generic"; // [generic,gps,oled128x64] wing_count = 1; // [1:1:8] -wing_pitch = 11; // [7:0.1:16] +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 = 4; +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] */ @@ -47,10 +48,15 @@ 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; @@ -77,10 +83,11 @@ corner_radius = 4; board_bottom_clearance = 5.4; board_side_clearance = 7; board_rear_clearance = 4; -usb_board_wall_gap = 1.2; +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; @@ -97,10 +104,15 @@ 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_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; +usb_vertical_offset = 0.5; /* [Custom wall cutouts] */ // Entry format: [face, shape, u, z, width, height, radius] @@ -156,6 +168,20 @@ 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 @@ -170,7 +196,7 @@ profile_top_gap = top_profile == "oled128x64" ? oled_face_gap : generic_top_gap; -partition_y = board_origin_y + feather_width + 1; +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 @@ -191,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; -rocker_center_x = case_length - wall_thickness - fit_clearance +rocker_installed_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; +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 = @@ -202,7 +232,7 @@ lid_art_rotation = 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 + ? 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, @@ -273,6 +303,34 @@ lid_boss_xy = [ 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" @@ -280,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps" 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" @@ -302,6 +368,15 @@ assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, 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"); @@ -311,18 +386,73 @@ 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 + || 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_center_y + rocker_body_size[1] / 2 + || 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); @@ -406,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) { 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]; @@ -554,6 +718,8 @@ module body_positive() { 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(); } } @@ -579,15 +745,15 @@ module body_cutouts() { ); // 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 (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]) { @@ -711,13 +877,18 @@ module lid_cutouts() { ); } - if (battery_enabled) - translate([rocker_center_x, rocker_center_y, -eps]) + 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(); @@ -728,6 +899,41 @@ module lid_cutouts() { } } +// 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) @@ -854,7 +1060,7 @@ module reference_top_feature() { cube([ oled_visible_size[0], oled_visible_size[1], - oled_component_height + oled_display_height ]); color([0.6, 0.6, 0.6, 0.9]) for (xy = [ @@ -891,15 +1097,15 @@ module reference_battery_and_switch() { 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, + 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_center_x - rocker_cutout_size[0] / 2, - rocker_center_y - rocker_cutout_size[1] / 2, + 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]); @@ -957,7 +1163,7 @@ module switch_coupon() { coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) + for (i = [0 : len(coupon_steps) - 1]) { translate([6 + i * spacing, coupon_width / 2, -eps]) centered_rounded_prism( rocker_cutout_size @@ -965,6 +1171,11 @@ module switch_coupon() { 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]] + ); + } } } @@ -1002,6 +1213,8 @@ module fit_coupon() { } } +validate_cutout_spacing(); + if (part == "assembly") assembly(); else if (part == "body") diff --git a/cases/gps/README.md b/cases/gps/README.md index 33d81f0..8ea7e8d 100644 --- a/cases/gps/README.md +++ b/cases/gps/README.md @@ -6,7 +6,7 @@ Generated by the Feather Case Wizard. - FeatherWings: 1 - Battery compartment: yes - 1/4-20 insert: yes -- Approximate exterior: 88.6 × 59.1 × 40.4 mm +- Approximate exterior: 88.6 × 61.1 × 40.4 mm - Formats: 3mf - Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon` - Custom cutouts: 0 diff --git a/cases/gps/case.yml b/cases/gps/case.yml index 94f04e1..9a7cd8f 100644 --- a/cases/gps/case.yml +++ b/cases/gps/case.yml @@ -3,7 +3,7 @@ schema_version: 1 name: GPS slug: gps generator: - sha256: 168dd3c04ca4015b07fe6c0295e7d4bb43969671f5f3552ffab31d8deffc3024 + sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13 created_with: feather-case-wizard parameters: top_profile: gps @@ -37,6 +37,7 @@ parameters: battery_radial_clearance: 0.6 battery_end_clearance: 1.0 battery_partition_thickness: 1.8 + battery_board_clearance: 3.0 rocker_cutout_size: - 12.9 - 19.4 @@ -46,6 +47,10 @@ parameters: - 21.0 - 21.0 rocker_artwork_gap: 2.0 + rocker_partition_offset: 2.0 + rocker_tab_panel_thickness: 2.0 + rocker_tab_land: 2.0 + rocker_tab_taper_length: 0.6 tripod_insert_enabled: true tripod_insert_length: 5.0 tripod_insert_hole_diameter: 7.2 @@ -87,10 +92,10 @@ parameters: lid_label_size: 5.0 lid_label_logo_gap: 2.0 lid_art_margin: 8.5 - usb_opening_width: 12.5 - usb_opening_height: 7.0 + usb_opening_width: 13.5 + usb_opening_height: 8.0 usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 + usb_vertical_offset: 0.5 cutouts: [] outputs: formats: diff --git a/cases/gps/parameters.json b/cases/gps/parameters.json index 6d275d7..d292717 100644 --- a/cases/gps/parameters.json +++ b/cases/gps/parameters.json @@ -28,10 +28,15 @@ "battery_radial_clearance": "0.6", "battery_end_clearance": "1.0", "battery_partition_thickness": "1.8", + "battery_board_clearance": "3.0", "rocker_cutout_size": "[12.9,19.4]", "rocker_corner_radius": "0.8", "rocker_body_size": "[15.0,21.0,21.0]", "rocker_artwork_gap": "2.0", + "rocker_partition_offset": "2.0", + "rocker_tab_panel_thickness": "2.0", + "rocker_tab_land": "2.0", + "rocker_tab_taper_length": "0.6", "tripod_insert_enabled": "true", "tripod_insert_length": "5.0", "tripod_insert_hole_diameter": "7.2", @@ -73,10 +78,10 @@ "lid_label_size": "5.0", "lid_label_logo_gap": "2.0", "lid_art_margin": "8.5", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", + "usb_opening_width": "13.5", + "usb_opening_height": "8.0", "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" + "usb_vertical_offset": "0.5" } }, "fileFormatVersion": "1" diff --git a/cases/gps/source/feather_case.scad b/cases/gps/source/feather_case.scad index 7948af5..92aab0f 100644 --- a/cases/gps/source/feather_case.scad +++ b/cases/gps/source/feather_case.scad @@ -48,10 +48,15 @@ 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; @@ -104,10 +109,10 @@ 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_width = 13.5; +usb_opening_height = 8; usb_opening_radius = 1.5; -usb_vertical_offset = 0; +usb_vertical_offset = 0.5; /* [Custom wall cutouts] */ // Entry format: [face, shape, u, z, width, height, radius] @@ -191,7 +196,7 @@ profile_top_gap = top_profile == "oled128x64" ? oled_face_gap : generic_top_gap; -partition_y = board_origin_y + feather_width + 1; +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 @@ -212,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; -rocker_center_x = case_length - wall_thickness - fit_clearance +rocker_installed_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; +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 = @@ -223,7 +232,7 @@ lid_art_rotation = 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 + ? 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, @@ -294,6 +303,34 @@ lid_boss_xy = [ 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" @@ -301,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps" 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" @@ -325,6 +370,13 @@ assert(!tripod_insert_enabled "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"); @@ -334,13 +386,35 @@ 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 + || 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_center_y + rocker_body_size[1] / 2 + || 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"); @@ -803,13 +877,18 @@ module lid_cutouts() { ); } - if (battery_enabled) - translate([rocker_center_x, rocker_center_y, -eps]) + 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(); @@ -820,6 +899,41 @@ module lid_cutouts() { } } +// 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) @@ -983,15 +1097,15 @@ module reference_battery_and_switch() { 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, + 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_center_x - rocker_cutout_size[0] / 2, - rocker_center_y - rocker_cutout_size[1] / 2, + 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]); @@ -1049,7 +1163,7 @@ module switch_coupon() { coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) + for (i = [0 : len(coupon_steps) - 1]) { translate([6 + i * spacing, coupon_width / 2, -eps]) centered_rounded_prism( rocker_cutout_size @@ -1057,6 +1171,11 @@ module switch_coupon() { 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]] + ); + } } } diff --git a/cases/pan-tilt-head/README.md b/cases/pan-tilt-head/README.md deleted file mode 100644 index a486bfb..0000000 --- a/cases/pan-tilt-head/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Pan/Tilt Head - -Generated by the Feather Case Wizard. - -- Top profile: `oled128x64` -- FeatherWings: 3 -- Battery compartment: no -- 1/4-20 insert: yes -- Approximate exterior: 95.6 × 41.7 × 59.8 mm -- Formats: 3mf -- Parts: `body`, `lid`, `buttons` -- Custom cutouts: 4 - -Run `ruby build.rb` in this directory to regenerate exports and previews. -OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to -override their executable paths. - -To open the saved Customizer values manually: - -```bash -openscad -p parameters.json -P pan-tilt-head source/feather_case.scad -``` - -The 3MF files contain portable geometry only. Choose printer, filament, -plate, and slicing settings in Bambu Studio. diff --git a/cases/pan-tilt-head/build.rb b/cases/pan-tilt-head/build.rb deleted file mode 100755 index 3deb8ab..0000000 --- a/cases/pan-tilt-head/build.rb +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "fileutils" -require "json" -require "open3" -require "pathname" -require "tmpdir" -require "yaml" - -ROOT = Pathname(__dir__).expand_path - -def executable(env_name, command) - override = ENV[env_name] - return File.expand_path(override) if override && File.executable?(override) - - ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory| - candidate = File.join(directory, command) - return candidate if File.file?(candidate) && File.executable?(candidate) - end - nil -end - -def literal(value) - case value - when String then JSON.generate(value) - when TrueClass then "true" - when FalseClass then "false" - when Numeric then value.to_s - when Array then "[#{value.map { |item| literal(item) }.join(",")}]" - else raise "Cannot convert #{value.inspect} to OpenSCAD" - end -end - -def run!(*command, env: {}) - stdout, stderr, status = Open3.capture3(env, *command) - return if status.success? - - warn [stdout, stderr].reject(&:empty?).join("\n") - raise "Command failed (#{status.exitstatus}): #{command.first}" -end - -config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [], - permitted_symbols: [], aliases: false) -openscad = executable("OPENSCAD", "openscad") -blender = executable("BLENDER", "blender") -abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad -abort "Blender not found; install it or set BLENDER" unless blender - -parameters = config.fetch("parameters").merge( - "extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") -) -parts = %w[body lid] + config.fetch("outputs").fetch("coupons") -buttons = %w[button_a button_b button_c button_reset] -if parameters["top_profile"] == "oled128x64" && - buttons.any? { |name| parameters[name] } - parts << "buttons" -end - -stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s)) -begin - exports = stage.join("exports") - previews = stage.join("previews") - scratch = stage.join("meshes") - FileUtils.mkdir_p([exports, previews, scratch]) - model = ROOT.join("source/feather_case.scad") - renderer = ROOT.join("source/render_stl_previews.py") - - export = lambda do |output, part, overrides = {}| - values = parameters.merge(overrides).merge("part" => part) - command = [openscad, "-o", output.to_s] - values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) } - run!(*command, model.to_s) - end - - config.fetch("outputs").fetch("formats").each do |format| - directory = exports.join(format) - FileUtils.mkdir_p(directory) - parts.each do |part| - puts "Exporting #{part}.#{format}…" - export.call(directory.join("#{part}.#{format}"), part) - end - end - - assembly = scratch.join("assembly.stl") - puts "Preparing assembly preview…" - export.call(assembly, "assembly", - "exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"), - "show_references" => false) - render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil} - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - assembly.to_s, previews.to_s, "assembly", env: render_env) - parts.each do |part| - puts "Rendering #{part} preview…" - mesh = scratch.join("#{part}.stl") - export.call(mesh, part) unless mesh.file? - run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", - mesh.to_s, previews.to_s, part, "iso", env: render_env) - end - - backup = ROOT.join(".build-old-#{Process.pid}") - FileUtils.mkdir_p(backup) - %w[exports previews].each do |name| - current = ROOT.join(name) - FileUtils.mv(current, backup.join(name)) if current.exist? - end - begin - FileUtils.mv(exports, ROOT.join("exports")) - FileUtils.mv(previews, ROOT.join("previews")) - FileUtils.rm_rf(backup) - rescue StandardError - FileUtils.rm_rf(ROOT.join("exports")) - FileUtils.rm_rf(ROOT.join("previews")) - %w[exports previews].each do |name| - saved = backup.join(name) - FileUtils.mv(saved, ROOT.join(name)) if saved.exist? - end - raise - end - puts "Rebuilt #{ROOT}" -ensure - FileUtils.rm_rf(stage) -end diff --git a/cases/pan-tilt-head/case.yml b/cases/pan-tilt-head/case.yml deleted file mode 100644 index 5ed9bea..0000000 --- a/cases/pan-tilt-head/case.yml +++ /dev/null @@ -1,148 +0,0 @@ ---- -schema_version: 1 -name: Pan/Tilt Head -slug: pan-tilt-head -generator: - sha256: 965260d4d402fd8894f30aa863224e2c9b42dc61a1496f01e2f00b509f9624db - created_with: feather-case-wizard -parameters: - top_profile: oled128x64 - wing_count: 3 - wing_pitch: 12.25 - stack_height_override: 38.0 - generic_component_height: 3.0 - generic_top_gap: 2.0 - gps_antenna_height: 6.0 - gps_top_gap: 5.0 - oled_component_height: 2.4 - oled_display_height: 1.5 - oled_face_gap: 0.8 - button_a: true - button_b: true - button_c: true - button_reset: false - button_projection: 1.5 - button_cap_size: - - 5.0 - - 3.2 - button_flange_size: - - 6.6 - - 3.6 - button_flange_thickness: 0.9 - button_travel: 0.5 - button_contact_extension: 0.7 - battery_enabled: false - battery_length: 69.0 - battery_diameter: 18.0 - battery_radial_clearance: 0.6 - battery_end_clearance: 1.0 - switch_hole_diameter: 12.0 - switch_hole_compensation: 0.3 - switch_cap_diameter: 8.0 - switch_flange_diameter: 16.2 - switch_body_length: 24.0 - switch_body_diameter: 12.0 - switch_wire_allowance: 3.0 - tripod_insert_enabled: true - tripod_insert_length: 6.4 - tripod_insert_hole_diameter: 8.8 - tripod_thread_clearance_diameter: 6.8 - tripod_boss_diameter: 14.0 - m25_insert_length: 4.0 - m25_insert_hole_diameter: 3.7 - m25_screw_clearance: 2.8 - m25_boss_diameter: 7.0 - insert_entry_chamfer: 0.6 - insert_melt_relief: 0.8 - wall_thickness: 2.4 - floor_thickness: 2.4 - lid_thickness: 3.2 - corner_radius: 4.0 - board_bottom_clearance: 5.4 - board_side_clearance: 7.0 - board_rear_clearance: 20.0 - usb_board_wall_gap: 20.0 - 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_opening_enabled: true - usb_guide_enabled: true - usb_connector_overhang: 2.0 - usb_guide_wall_thickness: 1.2 - usb_guide_end_gap: 1.0 - usb_opening_width: 12.5 - usb_opening_height: 7.0 - usb_opening_radius: 1.5 - usb_vertical_offset: 0.0 -cutouts: -- name: Antenna - face: left - shape: circle - horizontal: 30.4 - z: 31.299999999999997 - width: 6.5 - height: 6.5 - radius: 3.25 -- name: Servo - face: rear - shape: roundrect - horizontal: 20.83 - z: 35.0 - width: 10.0 - height: 5.0 - radius: 1.25 -- name: Release - face: rear - shape: circle - horizontal: 20.83 - z: 20.0 - width: 6.0 - height: 6.0 - radius: 3.0 -- name: Power - face: front - shape: circle - horizontal: 20.83 - z: 31.65 - width: 8.0 - height: 8.0 - radius: 4.0 -outputs: - formats: - - 3mf - coupons: [] - assembly_exploded_view: 8 -resolved: - extra_cutouts: - - - left - - circle - - 30.4 - - 31.299999999999997 - - 6.5 - - 6.5 - - 3.25 - - - rear - - roundrect - - 20.83 - - 35.0 - - 10.0 - - 5.0 - - 1.25 - - - rear - - circle - - 20.83 - - 20.0 - - 6.0 - - 6.0 - - 3.0 - - - front - - circle - - 20.83 - - 31.65 - - 8.0 - - 8.0 - - 4.0 diff --git a/cases/pan-tilt-head/parameters.json b/cases/pan-tilt-head/parameters.json deleted file mode 100644 index 2078285..0000000 --- a/cases/pan-tilt-head/parameters.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "parameterSets": { - "pan-tilt-head": { - "top_profile": "\"oled128x64\"", - "wing_count": "3", - "wing_pitch": "12.25", - "stack_height_override": "38.0", - "generic_component_height": "3.0", - "generic_top_gap": "2.0", - "gps_antenna_height": "6.0", - "gps_top_gap": "5.0", - "oled_component_height": "2.4", - "oled_display_height": "1.5", - "oled_face_gap": "0.8", - "button_a": "true", - "button_b": "true", - "button_c": "true", - "button_reset": "false", - "button_projection": "1.5", - "button_cap_size": "[5.0,3.2]", - "button_flange_size": "[6.6,3.6]", - "button_flange_thickness": "0.9", - "button_travel": "0.5", - "button_contact_extension": "0.7", - "battery_enabled": "false", - "battery_length": "69.0", - "battery_diameter": "18.0", - "battery_radial_clearance": "0.6", - "battery_end_clearance": "1.0", - "switch_hole_diameter": "12.0", - "switch_hole_compensation": "0.3", - "switch_cap_diameter": "8.0", - "switch_flange_diameter": "16.2", - "switch_body_length": "24.0", - "switch_body_diameter": "12.0", - "switch_wire_allowance": "3.0", - "tripod_insert_enabled": "true", - "tripod_insert_length": "6.4", - "tripod_insert_hole_diameter": "8.8", - "tripod_thread_clearance_diameter": "6.8", - "tripod_boss_diameter": "14.0", - "m25_insert_length": "4.0", - "m25_insert_hole_diameter": "3.7", - "m25_screw_clearance": "2.8", - "m25_boss_diameter": "7.0", - "insert_entry_chamfer": "0.6", - "insert_melt_relief": "0.8", - "wall_thickness": "2.4", - "floor_thickness": "2.4", - "lid_thickness": "3.2", - "corner_radius": "4.0", - "board_bottom_clearance": "5.4", - "board_side_clearance": "7.0", - "board_rear_clearance": "20.0", - "usb_board_wall_gap": "20.0", - "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_opening_enabled": "true", - "usb_guide_enabled": "true", - "usb_connector_overhang": "2.0", - "usb_guide_wall_thickness": "1.2", - "usb_guide_end_gap": "1.0", - "usb_opening_width": "12.5", - "usb_opening_height": "7.0", - "usb_opening_radius": "1.5", - "usb_vertical_offset": "0.0" - } - }, - "fileFormatVersion": "1" -} diff --git a/cases/pan-tilt-head/source/feather_case.scad b/cases/pan-tilt-head/source/feather_case.scad deleted file mode 100644 index 30ac230..0000000 --- a/cases/pan-tilt-head/source/feather_case.scad +++ /dev/null @@ -1,965 +0,0 @@ -/* - * 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)); diff --git a/cases/pan-tilt-head/source/render_stl_previews.py b/cases/pan-tilt-head/source/render_stl_previews.py deleted file mode 100644 index c8675ac..0000000 --- a/cases/pan-tilt-head/source/render_stl_previews.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Render six inspection views of an STL using Blender. - -Usage: - blender --background --python tools/render_stl_previews.py -- \ - input.stl output-directory basename [comma-separated-views] -""" - -import os -import sys - -import bpy -from mathutils import Vector - - -def arguments(): - args = sys.argv[sys.argv.index("--") + 1 :] - if len(args) not in (3, 4): - raise SystemExit( - "expected: input.stl output-directory basename [comma-separated-views]" - ) - requested_views = args[3].split(",") if len(args) == 4 else None - return args[0], args[1], args[2], requested_views - - -def look_at(camera, target): - direction = Vector(target) - camera.location - camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() - - -def render_view(camera, center, span, name, direction, output_dir, basename): - distance = span * 2.2 - camera.location = Vector(center) + Vector(direction).normalized() * distance - look_at(camera, center) - camera.data.type = "ORTHO" - camera.data.ortho_scale = span * 1.35 - bpy.context.scene.render.filepath = os.path.join( - output_dir, f"{basename}_{name}.png" - ) - bpy.ops.render.render(write_still=True) - - -def main(): - input_path, output_dir, basename, requested_views = arguments() - os.makedirs(output_dir, exist_ok=True) - - bpy.ops.wm.read_factory_settings(use_empty=True) - bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path)) - model = bpy.context.selected_objects[0] - model.name = "Feather case" - - material = bpy.data.materials.new("Case material") - material.diffuse_color = (0.16, 0.42, 0.72, 1.0) - material.metallic = 0.0 - material.roughness = 0.45 - model.data.materials.append(material) - - corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box] - mins = Vector([min(v[i] for v in corners) for i in range(3)]) - maxs = Vector([max(v[i] for v in corners) for i in range(3)]) - center = (mins + maxs) / 2 - span = max(maxs - mins) - - world = bpy.data.worlds.new("Inspection world") - world.use_nodes = True - world.node_tree.nodes["Background"].inputs["Color"].default_value = ( - 0.035, - 0.04, - 0.05, - 1, - ) - world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5 - bpy.context.scene.world = world - - camera_data = bpy.data.cameras.new("Camera") - camera = bpy.data.objects.new("Camera", camera_data) - bpy.context.collection.objects.link(camera) - bpy.context.scene.camera = camera - - key_data = bpy.data.lights.new("Key", type="AREA") - key_data.energy = 900 - key_data.shape = "DISK" - key_data.size = span - key = bpy.data.objects.new("Key", key_data) - key.location = center + Vector((span, -span, span * 1.5)) - bpy.context.collection.objects.link(key) - - fill_data = bpy.data.lights.new("Fill", type="AREA") - fill_data.energy = 500 - fill_data.size = span - fill = bpy.data.objects.new("Fill", fill_data) - fill.location = center + Vector((-span, span, span)) - bpy.context.collection.objects.link(fill) - - scene = bpy.context.scene - scene.render.engine = "BLENDER_WORKBENCH" - scene.render.resolution_x = 800 - scene.render.resolution_y = 600 - scene.render.resolution_percentage = 100 - scene.render.image_settings.file_format = "PNG" - scene.render.film_transparent = False - scene.display.shading.light = "STUDIO" - scene.display.shading.color_type = "MATERIAL" - scene.display.shading.show_shadows = True - scene.display.shading.show_cavity = True - scene.display.shading.cavity_type = "BOTH" - scene.display.shading.curvature_ridge_factor = 1.5 - scene.display.shading.curvature_valley_factor = 1.2 - - views = { - "iso": (1, -1, 0.8), - "front": (-1, 0, 0), - "back": (1, 0, 0), - "left": (0, -1, 0), - "right": (0, 1, 0), - "top": (0, 0, 1), - } - selected = requested_views or list(views) - unknown = sorted(set(selected) - set(views)) - if unknown: - raise SystemExit(f"unknown views: {', '.join(unknown)}") - for name in selected: - direction = views[name] - render_view(camera, center, span, name, direction, output_dir, basename) - - -if __name__ == "__main__": - main() diff --git a/feather_case.scad b/feather_case.scad index 7948af5..92aab0f 100644 --- a/feather_case.scad +++ b/feather_case.scad @@ -48,10 +48,15 @@ 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; @@ -104,10 +109,10 @@ 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_width = 13.5; +usb_opening_height = 8; usb_opening_radius = 1.5; -usb_vertical_offset = 0; +usb_vertical_offset = 0.5; /* [Custom wall cutouts] */ // Entry format: [face, shape, u, z, width, height, radius] @@ -191,7 +196,7 @@ profile_top_gap = top_profile == "oled128x64" ? oled_face_gap : generic_top_gap; -partition_y = board_origin_y + feather_width + 1; +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 @@ -212,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; -rocker_center_x = case_length - wall_thickness - fit_clearance +rocker_installed_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; +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 = @@ -223,7 +232,7 @@ lid_art_rotation = 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 + ? 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, @@ -294,6 +303,34 @@ lid_boss_xy = [ 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" @@ -301,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps" 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" @@ -325,6 +370,13 @@ assert(!tripod_insert_enabled "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"); @@ -334,13 +386,35 @@ 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 + || 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_center_y + rocker_body_size[1] / 2 + || 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"); @@ -803,13 +877,18 @@ module lid_cutouts() { ); } - if (battery_enabled) - translate([rocker_center_x, rocker_center_y, -eps]) + 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(); @@ -820,6 +899,41 @@ module lid_cutouts() { } } +// 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) @@ -983,15 +1097,15 @@ module reference_battery_and_switch() { 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, + 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_center_x - rocker_cutout_size[0] / 2, - rocker_center_y - rocker_cutout_size[1] / 2, + 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]); @@ -1049,7 +1163,7 @@ module switch_coupon() { coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) + for (i = [0 : len(coupon_steps) - 1]) { translate([6 + i * spacing, coupon_width / 2, -eps]) centered_rounded_prism( rocker_cutout_size @@ -1057,6 +1171,11 @@ module switch_coupon() { 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]] + ); + } } } diff --git a/lib/feather_case_wizard/config.rb b/lib/feather_case_wizard/config.rb index 37ada96..7621cb8 100644 --- a/lib/feather_case_wizard/config.rb +++ b/lib/feather_case_wizard/config.rb @@ -44,10 +44,15 @@ module FeatherCaseWizard "battery_radial_clearance" => 0.6, "battery_end_clearance" => 1.0, "battery_partition_thickness" => 1.8, + "battery_board_clearance" => 3.0, "rocker_cutout_size" => [12.9, 19.4], "rocker_corner_radius" => 0.8, "rocker_body_size" => [15.0, 21.0, 21.0], "rocker_artwork_gap" => 2.0, + "rocker_partition_offset" => 2.0, + "rocker_tab_panel_thickness" => 2.0, + "rocker_tab_land" => 2.0, + "rocker_tab_taper_length" => 0.6, "tripod_insert_enabled" => false, "tripod_insert_length" => 5.0, "tripod_insert_hole_diameter" => 7.2, @@ -89,10 +94,10 @@ module FeatherCaseWizard "lid_label_size" => 5.0, "lid_label_logo_gap" => 2.0, "lid_art_margin" => 8.5, - "usb_opening_width" => 12.5, - "usb_opening_height" => 7.0, + "usb_opening_width" => 13.5, + "usb_opening_height" => 8.0, "usb_opening_radius" => 1.5, - "usb_vertical_offset" => 0.0 + "usb_vertical_offset" => 0.5 }.freeze ADVANCED_FIELDS = { @@ -115,8 +120,12 @@ module FeatherCaseWizard tripod_thread_clearance_diameter tripod_boss_diameter], "Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance battery_end_clearance battery_partition_thickness + battery_board_clearance rocker_cutout_size rocker_corner_radius - rocker_body_size rocker_artwork_gap], + rocker_body_size rocker_artwork_gap + rocker_partition_offset + rocker_tab_panel_thickness rocker_tab_land + rocker_tab_taper_length], "USB-C opening" => %w[usb_opening_width usb_opening_height usb_opening_radius usb_vertical_offset usb_connector_overhang usb_guide_wall_thickness usb_guide_end_gap], @@ -223,7 +232,7 @@ module FeatherCaseWizard plain_width = board_origin_y + 22.86 + p["board_side_clearance"] + p["wall_thickness"] if p["battery_enabled"] - partition_y = board_origin_y + 22.86 + 1 + partition_y = board_origin_y + 22.86 + p["battery_board_clearance"] battery_y = partition_y + p["battery_partition_thickness"] + p["battery_radial_clearance"] + p["battery_diameter"] / 2 lid_boss_wall_overlap = 0.6 @@ -325,6 +334,11 @@ module FeatherCaseWizard parameters["wall_thickness"] < 1.2 raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if parameters["battery_partition_thickness"] < 1.2 + raise ConfigError, "rocker_tab_panel_thickness must be at least 1.2 mm" if + parameters["rocker_tab_panel_thickness"] < 1.2 + if parameters["rocker_tab_panel_thickness"] >= parameters["lid_thickness"] + raise ConfigError, "rocker_tab_panel_thickness must be less than lid_thickness" + end raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if parameters["lid_lip_thickness"] < 1.2 raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if @@ -338,6 +352,7 @@ module FeatherCaseWizard "tripod_insert_outer_diameter must not be smaller than the pilot" end + validate_usb_opening! validate_cutouts! self end @@ -402,6 +417,19 @@ module FeatherCaseWizard validate_cutout_spacing!(bounds, dims) end + def validate_usb_opening! + return unless parameters["usb_opening_enabled"] + + dims = dimensions + bounds = usb_wall_bounds(dims) + if bounds["bottom"] < dims["floor"] + raise ConfigError, "USB opening or guide intersects the case floor" + end + if bounds["top"] > dims["lip_bottom"] + raise ConfigError, "USB opening or guide intersects the lid lip" + end + end + def validate_cutout_spacing!(bounds, dims) web = parameters["cutout_min_web"] bounds.combination(2) do |first, second| diff --git a/lib/feather_case_wizard/package_builder.rb b/lib/feather_case_wizard/package_builder.rb index c1fa290..e84c227 100644 --- a/lib/feather_case_wizard/package_builder.rb +++ b/lib/feather_case_wizard/package_builder.rb @@ -225,7 +225,7 @@ module FeatherCaseWizard plate, and slicing settings in Bambu Studio. #{artwork} MARKDOWN - File.write(stage.join("README.md"), text) + File.write(stage.join("README.md"), text.rstrip + "\n") end def install_atomically(stage, destination) diff --git a/test/config_test.rb b/test/config_test.rb index 3b1c129..5aaa3b5 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -72,6 +72,26 @@ class ConfigTest < Minitest::Test assert_match(/guide requires/, error.message) end + def test_usb_opening_expands_upward_without_moving_bottom_edge + parameters = config.parameters + compensation = parameters["hole_compensation"] + board_top = config.dimensions["floor"] + + parameters["board_bottom_clearance"] + 1.6 + old_center = board_top + 1.6 + old_width = 12.5 + compensation + old_height = 7.0 + compensation + new_center = old_center + parameters["usb_vertical_offset"] + new_width = parameters["usb_opening_width"] + compensation + new_height = parameters["usb_opening_height"] + compensation + + assert_in_delta 1.0, new_width - old_width, 0.001 + assert_in_delta old_center - old_height / 2, + new_center - new_height / 2, 0.001 + assert_in_delta 1.0, + (new_center + new_height / 2) - + (old_center + old_height / 2), 0.001 + end + def test_end_clearances_expand_case_length subject = config subject.parameters["usb_board_wall_gap"] = 20.0 @@ -109,6 +129,19 @@ class ConfigTest < Minitest::Test refute_includes source, "bore_d = diameter + hole_compensation;" end + def test_lid_rocker_mirrors_to_installed_feather_side + source = FeatherCaseWizard::MODEL_SOURCE.read + + assert_includes source, + "rocker_lid_center_y = case_width - rocker_installed_center_y;" + assert_includes source, + "translate([rocker_lid_center_x, rocker_lid_center_y, -eps])" + assert_includes source, + "rocker_installed_center_y - rocker_body_size[1] / 2" + assert_includes source, + "+ rocker_body_size[1] / 2 + rocker_partition_offset;" + end + def test_oled_buttons_and_selected_coupons_are_printable subject = config subject.parameters["top_profile"] = "oled128x64" @@ -125,10 +158,50 @@ class ConfigTest < Minitest::Test subject.parameters["tripod_insert_enabled"] = true assert_in_delta 88.6, subject.dimensions["length"], 0.01 - assert_in_delta 59.06, subject.dimensions["width"], 0.01 + assert_in_delta 61.06, subject.dimensions["width"], 0.01 assert_in_delta 31.25, subject.dimensions["height"], 0.01 end + def test_battery_board_clearance_moves_the_whole_bay_outward + subject = config + subject.parameters["battery_enabled"] = true + original_width = subject.dimensions["width"] + + subject.parameters["battery_board_clearance"] += 2.0 + + assert_in_delta 2.0, subject.dimensions["width"] - original_width, 0.001 + end + + def test_switch_tab_relief_uses_two_mm_lands_in_lid_and_coupon + source = FeatherCaseWizard::MODEL_SOURCE.read + + assert_in_delta 2.0, config.parameters["rocker_tab_panel_thickness"], 0.001 + assert_in_delta 2.0, config.parameters["rocker_tab_land"], 0.001 + assert_includes source, "module rocker_tab_reliefs(center, opening_size)" + assert_equal 2, source.scan(/rocker_tab_reliefs\(/).length - 1 + assert_includes source, + "edge_y = center[1] + side * opening_size[1] / 2;" + assert_includes source, + "opening_overlap = rocker_corner_radius;" + assert_includes source, + "cube([opening_size[0], rocker_tab_land + opening_overlap," + refute_includes source, + "edge_x = center[0] + side * opening_size[0] / 2;" + end + + def test_switch_moves_two_mm_toward_partition_on_installed_lid + assert_in_delta 2.0, config.parameters["rocker_partition_offset"], 0.001 + end + + def test_switch_tab_panel_must_be_thinner_than_lid + subject = config + subject.parameters["rocker_tab_panel_thickness"] = + subject.parameters["lid_thickness"] + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/rocker_tab_panel_thickness/, error.message) + end + def test_legacy_round_switch_fields_migrate_to_rocker_defaults data = config.data data["parameters"]["switch_hole_diameter"] = 12.0 @@ -232,7 +305,7 @@ class ConfigTest < Minitest::Test def test_front_cutout_can_clear_usb_reservation subject = config - subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)] + subject.data["cutouts"] = [cutout("front", 8.0).merge("z" => 11.0)] assert_same subject, subject.validate! end diff --git a/test/package_builder_test.rb b/test/package_builder_test.rb index 7febef5..ff8201b 100644 --- a/test/package_builder_test.rb +++ b/test/package_builder_test.rb @@ -29,6 +29,7 @@ class PackageBuilderTest < Minitest::Test %w[iso front back left right top].each do |view| assert destination.join("previews/assembly_#{view}.png").file? end + refute_match(/\n\n\z/, destination.join("README.md").read) config.parameters["button_reset"] = true build(config, root.join("cases"), tools, model, renderer)