diff --git a/README.md b/README.md index 3b0b701..af41c4b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ bundle install Then run: ```bash -bundle exec bin/feather-case +bin/feather-case ``` The menu can create a case, reopen one for dimensional tuning after a test @@ -38,6 +38,18 @@ Set `OPENSCAD=/path/to/openscad` or `BLENDER=/path/to/blender` when either program is installed elsewhere. The wizard checks both tools before replacing an existing package. +The wizard can add clearance beyond either short end of the Feather PCB for +right-angle headers, plug housings, and wire bends. Front clearance moves the +board and its standoffs away from the USB-C wall; rear clearance extends the +opposite end of the case. + +USB programming access can be omitted for a closed front wall or paired with +an enclosed plug guide. The guide runs from the wall toward the connector and +automatically stops short based on the configured connector overhang and end +gap. New cases default to a 2 mm USB connector overhang, an enabled wall +opening, and an enabled guide; with the standard 2 mm front clearance the guide +has no inward length and behaves as the normal wall opening. + Each generated directory contains a dependency-free `build.rb`. After copying that directory to another Ubuntu machine, run `ruby build.rb` to reproduce its exports and previews. The saved generator source is used by default so a @@ -105,10 +117,20 @@ openscad -o oled-buttons.stl \ feather_case.scad ``` -The standard stack height is `wing_count * wing_pitch`. Set -`stack_height_override` to the measured distance from the Feather PCB top to -the top FeatherWing PCB when using unusual headers. It takes precedence over -the computed height. +The standard stack height is `wing_count * wing_pitch`, with a 12.25 mm default +pitch. Set `stack_height_override` to the measured distance from the top surface +of the Feather PCB to the top surface of the uppermost FeatherWing PCB when +using unusual headers. Do not include components on the Wing; profile-specific +component heights and top gaps are added separately. The measured override +takes precedence over the computed height. + +For the 128×64 OLED profile, `oled_component_height` is the height of the +tallest board feature (the switch tops), while `oled_display_height` controls +the display-face reference shown in the assembly preview. The defaults are +2.4 mm and 1.5 mm above the Wing PCB, respectively. The enclosure ceiling +remains based on the taller switches plus `oled_face_gap`, placing the display +face about 1.7 mm below the lid's inner plane. The default 0.7 mm captive-button +contact extension spans most of the 0.8 mm switch-to-lid gap. ## Custom wire egress @@ -123,11 +145,15 @@ extra_cutouts = [ ]; ``` -Faces are `front`, `rear`, `left`, and `right`. For front/rear faces, `u` is -the global Y coordinate; for left/right faces it is global X. The Z value is -the opening centre. Shapes are `circle`, `rect`, `roundrect`, and `slot`. -Cutouts that enter the floor or lid-lip keepout raise an assertion instead of -silently weakening those features. +Faces are `front` (the USB-C short wall), `rear` (the opposite short wall), +`left` (the plain/non-JST long wall), and `right` (the JST/battery long wall). +For front/rear faces, `u` is the global Y coordinate; for left/right faces it +is global X. The wizard presents this as distance from the selected wall's +left edge while viewing it from outside. The Z value is the opening centre. +Shapes are `circle`, `rect`, `roundrect`, and `slot`. Cutouts that enter the +floor or lid-lip keepout, approach another opening too closely, or overlap the +USB opening or guide keepout raise an assertion instead of silently weakening +those features. ## Calibration diff --git a/bin/feather-case b/bin/feather-case index 76b0d7f..42f1153 100755 --- a/bin/feather-case +++ b/bin/feather-case @@ -1,7 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" + require_relative "../lib/feather_case_wizard" exit FeatherCaseWizard::CLI.new.run - diff --git a/cases/attitude-imu-battery/README.md b/cases/attitude-imu-battery/README.md new file mode 100644 index 0000000..65e6a9f --- /dev/null +++ b/cases/attitude-imu-battery/README.md @@ -0,0 +1,25 @@ +# 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 new file mode 100755 index 0000000..3deb8ab --- /dev/null +++ b/cases/attitude-imu-battery/build.rb @@ -0,0 +1,123 @@ +#!/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 new file mode 100644 index 0000000..2607b21 --- /dev/null +++ b/cases/attitude-imu-battery/case.yml @@ -0,0 +1,82 @@ +--- +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 new file mode 100644 index 0000000..696327c --- /dev/null +++ b/cases/attitude-imu-battery/parameters.json @@ -0,0 +1,69 @@ +{ + "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 new file mode 100644 index 0000000..5a26c7b --- /dev/null +++ b/cases/attitude-imu-battery/source/feather_case.scad @@ -0,0 +1,872 @@ +/* + * 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 new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/attitude-imu-battery/source/render_stl_previews.py @@ -0,0 +1,127 @@ +"""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 new file mode 100644 index 0000000..27c8bce --- /dev/null +++ b/cases/attitude-imu/README.md @@ -0,0 +1,25 @@ +# 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 new file mode 100755 index 0000000..3deb8ab --- /dev/null +++ b/cases/attitude-imu/build.rb @@ -0,0 +1,123 @@ +#!/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 new file mode 100644 index 0000000..6a63849 --- /dev/null +++ b/cases/attitude-imu/case.yml @@ -0,0 +1,82 @@ +--- +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 new file mode 100644 index 0000000..32b6f11 --- /dev/null +++ b/cases/attitude-imu/parameters.json @@ -0,0 +1,69 @@ +{ + "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 new file mode 100644 index 0000000..5a26c7b --- /dev/null +++ b/cases/attitude-imu/source/feather_case.scad @@ -0,0 +1,872 @@ +/* + * 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 new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/attitude-imu/source/render_stl_previews.py @@ -0,0 +1,127 @@ +"""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/gps/README.md b/cases/gps/README.md index c8686db..0ec88be 100644 --- a/cases/gps/README.md +++ b/cases/gps/README.md @@ -6,9 +6,9 @@ Generated by the Feather Case Wizard. - FeatherWings: 1 - Battery compartment: yes - 1/4-20 insert: yes -- Approximate exterior: 93.9 × 57.3 × 38.6 mm +- Approximate exterior: 93.9 × 57.3 × 41.8 mm - Formats: 3mf -- Parts: `body`, `lid` +- Parts: `body`, `lid`, `insert_coupon`, `switch_coupon`, `fit_coupon` - Custom cutouts: 0 Run `ruby build.rb` in this directory to regenerate exports and previews. diff --git a/cases/gps/case.yml b/cases/gps/case.yml index b962017..a0d623b 100644 --- a/cases/gps/case.yml +++ b/cases/gps/case.yml @@ -3,18 +3,19 @@ schema_version: 1 name: GPS slug: gps generator: - sha256: a265164909faa0c364792224601af36381744a129b7cd7cbd6b6293815953f63 + sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 created_with: feather-case-wizard parameters: top_profile: gps wing_count: 1 - wing_pitch: 11.0 - stack_height_override: 0.0 + wing_pitch: 12.25 + stack_height_override: 12.25 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 @@ -75,7 +76,10 @@ cutouts: [] outputs: formats: - 3mf - coupons: [] + coupons: + - insert_coupon + - switch_coupon + - fit_coupon assembly_exploded_view: 8 resolved: extra_cutouts: [] diff --git a/cases/gps/parameters.json b/cases/gps/parameters.json index 35861fc..56144f6 100644 --- a/cases/gps/parameters.json +++ b/cases/gps/parameters.json @@ -3,13 +3,14 @@ "gps": { "top_profile": "\"gps\"", "wing_count": "1", - "wing_pitch": "11.0", - "stack_height_override": "0.0", + "wing_pitch": "12.25", + "stack_height_override": "12.25", "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", diff --git a/cases/gps/source/feather_case.scad b/cases/gps/source/feather_case.scad index 742172a..5a26c7b 100644 --- a/cases/gps/source/feather_case.scad +++ b/cases/gps/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] */ @@ -708,7 +709,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 = [ diff --git a/cases/pan-tilt-head/README.md b/cases/pan-tilt-head/README.md new file mode 100644 index 0000000..a486bfb --- /dev/null +++ b/cases/pan-tilt-head/README.md @@ -0,0 +1,25 @@ +# 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 new file mode 100755 index 0000000..3deb8ab --- /dev/null +++ b/cases/pan-tilt-head/build.rb @@ -0,0 +1,123 @@ +#!/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 new file mode 100644 index 0000000..5ed9bea --- /dev/null +++ b/cases/pan-tilt-head/case.yml @@ -0,0 +1,148 @@ +--- +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 new file mode 100644 index 0000000..2078285 --- /dev/null +++ b/cases/pan-tilt-head/parameters.json @@ -0,0 +1,75 @@ +{ + "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 new file mode 100644 index 0000000..30ac230 --- /dev/null +++ b/cases/pan-tilt-head/source/feather_case.scad @@ -0,0 +1,965 @@ +/* + * 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 new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/pan-tilt-head/source/render_stl_previews.py @@ -0,0 +1,127 @@ +"""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 742172a..30ac230 100644 --- a/feather_case.scad +++ b/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] */ @@ -77,15 +78,21 @@ 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; /* [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; @@ -131,6 +138,20 @@ 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 @@ -215,8 +236,43 @@ assert(m25_boss_diameter > m25_insert_hole_diameter + 2, 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); @@ -300,6 +356,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]; @@ -454,6 +544,8 @@ module body_positive() { 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(); } } @@ -477,15 +569,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 (battery_enabled) { face_circle_cutout( @@ -708,7 +800,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 = [ @@ -853,6 +945,8 @@ module fit_coupon() { } } +validate_cutout_spacing(); + if (part == "assembly") assembly(); else if (part == "body") diff --git a/lib/feather_case_wizard.rb b/lib/feather_case_wizard.rb index 23cdc29..99b74f9 100644 --- a/lib/feather_case_wizard.rb +++ b/lib/feather_case_wizard.rb @@ -11,6 +11,7 @@ require "yaml" require "highline" +require_relative "feather_case_wizard/prompting_ui" require_relative "feather_case_wizard/config" require_relative "feather_case_wizard/toolchain" require_relative "feather_case_wizard/package_builder" @@ -23,4 +24,3 @@ module FeatherCaseWizard MODEL_SOURCE = ROOT.join("feather_case.scad").freeze RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze end - diff --git a/lib/feather_case_wizard/cli.rb b/lib/feather_case_wizard/cli.rb index f3d65f0..ac0e947 100644 --- a/lib/feather_case_wizard/cli.rb +++ b/lib/feather_case_wizard/cli.rb @@ -5,18 +5,18 @@ module FeatherCaseWizard def initialize(ui: HighLine.new, cases_root: CASES_ROOT, model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, toolchain: Toolchain.new) - @ui = ui + @ui = PromptingUI.wrap(ui) @cases_root = Pathname(cases_root) @model_source = Pathname(model_source) @renderer_source = Pathname(renderer_source) @toolchain = toolchain - @wizard = Wizard.new(ui: ui) + @wizard = Wizard.new(ui: @ui) end def run @ui.say(@ui.color("Feather Case Wizard", :bold)) loop do - action = @ui.choose("\nWhat would you like to do?") do |menu| + action = @ui.choose("What would you like to do?") do |menu| menu.choice("Create a new case") { :create } menu.choice("Edit an existing case") { :edit } menu.choice("Rebuild an existing case") { :rebuild } diff --git a/lib/feather_case_wizard/config.rb b/lib/feather_case_wizard/config.rb index a0c7eb4..c7b28ef 100644 --- a/lib/feather_case_wizard/config.rb +++ b/lib/feather_case_wizard/config.rb @@ -14,13 +14,14 @@ module FeatherCaseWizard DEFAULT_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, @@ -62,13 +63,19 @@ module FeatherCaseWizard "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, "usb_opening_width" => 12.5, "usb_opening_height" => 7.0, "usb_opening_radius" => 1.5, @@ -77,11 +84,12 @@ module FeatherCaseWizard ADVANCED_FIELDS = { "Stack" => %w[wing_pitch generic_component_height generic_top_gap - gps_antenna_height gps_top_gap oled_component_height oled_face_gap], + gps_antenna_height gps_top_gap oled_component_height + oled_display_height oled_face_gap], "Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius - board_bottom_clearance board_side_clearance board_rear_clearance - usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance - hole_compensation lid_screw_head_diameter lid_screw_head_height], + board_bottom_clearance board_side_clearance lid_lip_depth + lid_lip_thickness fit_clearance cutout_min_web hole_compensation + lid_screw_head_diameter lid_screw_head_height], "M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter m25_screw_clearance m25_boss_diameter insert_entry_chamfer insert_melt_relief], @@ -93,7 +101,8 @@ module FeatherCaseWizard switch_flange_diameter switch_body_length switch_body_diameter switch_wire_allowance], "USB-C opening" => %w[usb_opening_width usb_opening_height - usb_opening_radius usb_vertical_offset], + usb_opening_radius usb_vertical_offset usb_connector_overhang + usb_guide_wall_thickness usb_guide_end_gap], "OLED buttons" => %w[button_projection button_flange_thickness button_travel button_contact_extension] }.freeze @@ -264,6 +273,9 @@ module FeatherCaseWizard unless (outputs["coupons"] - COUPONS).empty? raise ConfigError, "Unsupported calibration coupon" end + if parameters["usb_guide_enabled"] && !parameters["usb_opening_enabled"] + raise ConfigError, "USB guide requires the USB opening" + end positive = DEFAULT_PARAMETERS.keys - %w[usb_vertical_offset stack_height_override] positive.each do |key| @@ -289,7 +301,10 @@ module FeatherCaseWizard def normalize! data["schema_version"] = Integer(data.fetch("schema_version", SCHEMA_VERSION)) data["generator"] ||= {} - data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(data["parameters"] || {}) + supplied_parameters = data["parameters"] || {} + legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled") + data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters) + data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide data["cutouts"] ||= [] data["outputs"] ||= {} data["outputs"]["formats"] ||= ["3mf"] @@ -302,6 +317,7 @@ module FeatherCaseWizard def validate_cutouts! dims = dimensions names = {} + bounds = [] cutouts.each do |cutout| name = cutout["name"].to_s.strip raise ConfigError, "Every cutout needs a name" if name.empty? @@ -328,9 +344,68 @@ module FeatherCaseWizard radius = Float(cutout.fetch("radius", [width, height].min / 2)) raise ConfigError, "#{name} dimensions must be positive" unless width.positive? && height.positive? && radius.positive? + bounds << { + "name" => name, "face" => face, + "left" => horizontal - width / 2, "right" => horizontal + width / 2, + "bottom" => z - height / 2, "top" => z + height / 2 + } rescue ArgumentError, TypeError, KeyError => e raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}" end + validate_cutout_spacing!(bounds, dims) + end + + def validate_cutout_spacing!(bounds, dims) + web = parameters["cutout_min_web"] + bounds.combination(2) do |first, second| + next unless first["face"] == second["face"] + next if bounds_separated?(first, second, web) + + raise ConfigError, + "#{first["name"]} and #{second["name"]} are too close on the #{first["face"]} wall" + end + return unless parameters["usb_opening_enabled"] + + usb = usb_wall_bounds(dims) + bounds.select { |item| item["face"] == "front" }.each do |cutout| + next if bounds_separated?(cutout, usb, web) + + raise ConfigError, "#{cutout["name"]} is too close to the USB opening or guide" + end + end + + def bounds_separated?(first, second, web) + first["right"] + web <= second["left"] || + second["right"] + web <= first["left"] || + first["top"] + web <= second["bottom"] || + second["top"] + web <= first["bottom"] + end + + def usb_wall_bounds(dims) + p = parameters + effective_floor = p["tripod_insert_enabled"] ? + [p["floor_thickness"], p["tripod_insert_length"] + p["insert_melt_relief"] + 1.2].max : + p["floor_thickness"] + effective_board_clearance = [ + p["board_bottom_clearance"], + p["m25_insert_length"] + p["insert_melt_relief"] + 0.6 + ].max + board_top_z = effective_floor + effective_board_clearance + 1.6 + global_y = p["wall_thickness"] + p["board_side_clearance"] + 11.43 + horizontal = dims["width"] - global_y + z = board_top_z + 1.6 + p["usb_vertical_offset"] + guide_length = [ + p["usb_board_wall_gap"] - p["usb_connector_overhang"] - p["usb_guide_end_gap"], 0 + ].max + guide_active = p["usb_guide_enabled"] && guide_length.positive? + shell = guide_active ? 2 * p["usb_guide_wall_thickness"] : 0 + width = p["usb_opening_width"] + p["hole_compensation"] + shell + height = p["usb_opening_height"] + p["hole_compensation"] + shell + { + "name" => "USB opening", "face" => "front", + "left" => horizontal - width / 2, "right" => horizontal + width / 2, + "bottom" => z - height / 2, "top" => z + height / 2 + } end def stringify_keys(value) diff --git a/lib/feather_case_wizard/prompting_ui.rb b/lib/feather_case_wizard/prompting_ui.rb new file mode 100644 index 0000000..cc0531e --- /dev/null +++ b/lib/feather_case_wizard/prompting_ui.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class PromptingUI + def self.wrap(ui) + ui.is_a?(self) ? ui : new(ui) + end + + def initialize(ui) + @ui = ui + end + + def ask(*arguments, &block) + separate_after { @ui.ask(*arguments, &block) } + end + + def agree(*arguments, &block) + separate_after { @ui.agree(*arguments, &block) } + end + + def choose(prompt, &block) + separate_after do + @ui.choose do |menu| + menu.header = prompt.to_s.sub(/[?:]\s*\z/, "") + block.call(menu) + end + end + end + + def say(*arguments, &block) + @ui.say(*arguments, &block) + end + + def color(*arguments, &block) + @ui.color(*arguments, &block) + end + + private + + def separate_after + result = yield + @ui.say("\n") + result + end + end +end diff --git a/lib/feather_case_wizard/wizard.rb b/lib/feather_case_wizard/wizard.rb index ad94dc1..ee54b21 100644 --- a/lib/feather_case_wizard/wizard.rb +++ b/lib/feather_case_wizard/wizard.rb @@ -5,7 +5,7 @@ module FeatherCaseWizard attr_reader :ui def initialize(ui: HighLine.new) - @ui = ui + @ui = PromptingUI.wrap(ui) end def create @@ -15,14 +15,14 @@ module FeatherCaseWizard end def edit(config) - ui.say("\nEditing #{config.name} (`#{config.slug}`)") + ui.say("Editing #{config.name} (`#{config.slug}`)") config.data["name"] = text("Case name", config.name) configure(config, new_case: false) end def confirm_build(config) dims = config.dimensions - ui.say("\n#{ui.color("Build summary", :bold)}") + ui.say(ui.color("Build summary", :bold)) ui.say(" Case: #{config.name} (#{config.slug})") ui.say(" Profile: #{config.parameters["top_profile"]}") ui.say(" Wings: #{config.parameters["wing_count"]}") @@ -42,6 +42,7 @@ module FeatherCaseWizard choose_formats(config) choose_profile(config) choose_stack(config) + choose_end_clearance(config) choose_features(config) manage_cutouts(config) choose_coupons(config) @@ -63,7 +64,7 @@ module FeatherCaseWizard menu.choice("3MF") { ["3mf"] } menu.choice("STL") { ["stl"] } menu.choice("Both 3MF and STL") { %w[3mf stl] } - menu.default = label == "both" ? 3 : (label == "stl" ? 2 : 1) + menu.default = (label == "both" ? 3 : (label == "stl" ? 2 : 1)).to_s end config.outputs["formats"] = selected end @@ -74,7 +75,7 @@ module FeatherCaseWizard menu.choice("Generic / other") { "generic" } menu.choice("GPS") { "gps" } menu.choice("128×64 OLED") { "oled128x64" } - menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current) + menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current).to_s end end @@ -82,11 +83,11 @@ module FeatherCaseWizard p = config.parameters p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8) measured = p["stack_height_override"].positive? - if ui.agree("Use a measured board-to-top-PCB stack height? (y/n) ") do |question| + if ui.agree("Use a measured Feather-PCB-top to top-Wing-PCB stack height? (y/n) ") do |question| question.default = measured ? "yes" : "no" end p["stack_height_override"] = number( - "Measured height in mm", measured ? p["stack_height_override"] : + "PCB-top to PCB-top height in mm", measured ? p["stack_height_override"] : p["wing_count"] * p["wing_pitch"], min: 0.1 ) else @@ -96,22 +97,55 @@ module FeatherCaseWizard def choose_features(config) p = config.parameters + p["usb_opening_enabled"] = agreement( + "Include front USB-C programming opening?", p["usb_opening_enabled"] + ) + p["usb_guide_enabled"] = if p["usb_opening_enabled"] + agreement("Include inward USB plug guide?", + p["usb_guide_enabled"]) + else + false + end p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?", p["battery_enabled"]) p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?", p["tripod_insert_enabled"]) return unless p["top_profile"] == "oled128x64" - ui.say("\nOLED captive buttons") + ui.say("OLED captive buttons") p["button_a"] = agreement("Enable button A?", p["button_a"]) p["button_b"] = agreement("Enable button B?", p["button_b"]) p["button_c"] = agreement("Enable button C?", p["button_c"]) p["button_reset"] = agreement("Enable reset button?", p["button_reset"]) end + def choose_end_clearance(config) + p = config.parameters + defaults = Config::DEFAULT_PARAMETERS + custom = p["usb_board_wall_gap"] != defaults["usb_board_wall_gap"] || + p["board_rear_clearance"] != defaults["board_rear_clearance"] + enabled = ui.agree("Use extra clearance beyond the Feather PCB ends? (y/n) ") do |question| + question.default = custom ? "yes" : "no" + end + unless enabled + p["usb_board_wall_gap"] = defaults["usb_board_wall_gap"] + p["board_rear_clearance"] = defaults["board_rear_clearance"] + return + end + + p["usb_board_wall_gap"] = number( + "USB/front PCB-edge to inner-wall clearance (mm)", + p["usb_board_wall_gap"], min: 0.1 + ) + p["board_rear_clearance"] = number( + "Rear PCB-edge to inner-wall clearance (mm)", + p["board_rear_clearance"], min: 0.1 + ) + end + def manage_cutouts(config) loop do - ui.say("\nCustom cable cutouts: #{config.cutouts.empty? ? "none" : + ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" : config.cutouts.map { |cutout| cutout["name"] }.join(", ")}") action = ui.choose("Cable cutouts") do |menu| menu.choice("Done") { :done } @@ -141,12 +175,15 @@ module FeatherCaseWizard dims = config.dimensions name = text("Cutout name", existing["name"] || "cable") face = ui.choose("Wall") do |menu| - Config::FACES.each { |item| menu.choice(item) { item } } - menu.default = Config::FACES.index(existing["face"]).to_i + 1 if existing["face"] + menu.choice("Front — USB-C short wall") { "front" } + menu.choice("Rear — opposite short wall") { "rear" } + menu.choice("Left — plain/non-JST long wall") { "left" } + menu.choice("Right — JST/battery long wall") { "right" } + menu.default = (Config::FACES.index(existing["face"]).to_i + 1).to_s if existing["face"] end shape = ui.choose("Shape") do |menu| Config::SHAPES.each { |item| menu.choice(item) { item } } - menu.default = Config::SHAPES.index(existing["shape"]).to_i + 1 if existing["shape"] + menu.default = (Config::SHAPES.index(existing["shape"]).to_i + 1).to_s if existing["shape"] end wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"] ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \ @@ -209,7 +246,7 @@ module FeatherCaseWizard Config::ADVANCED_FIELDS.each do |section, fields| next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" } - ui.say("\n#{section}") + ui.say(section) fields.each do |field| config.parameters[field] = number(field.tr("_", " "), config.parameters[field], min: field == "usb_vertical_offset" ? nil : 0.01) @@ -286,4 +323,3 @@ module FeatherCaseWizard def yes_no(value) = value ? "yes" : "no" end end - diff --git a/test/cli_test.rb b/test/cli_test.rb new file mode 100644 index 0000000..70e8c19 --- /dev/null +++ b/test/cli_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "stringio" +require_relative "test_helper" + +class CLITest < Minitest::Test + def test_main_prompt_is_not_an_extra_choice + output = StringIO.new + cli = FeatherCaseWizard::CLI.new(ui: HighLine.new(StringIO.new("4\n"), output)) + + assert_equal 0, cli.run + assert_includes output.string, "What would you like to do:\n" + assert_includes output.string, "4. Exit\n" + refute_includes output.string, "5. What would you like to do?" + end +end diff --git a/test/config_test.rb b/test/config_test.rb index 60a5c44..a699fcb 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -9,11 +9,77 @@ class ConfigTest < Minitest::Test def test_default_case_has_expected_parts_and_dimensions assert_equal %w[body lid], config.printable_parts - assert_in_delta 60.8, config.dimensions["length"], 0.01 + assert_in_delta 61.6, config.dimensions["length"], 0.01 assert_in_delta 41.66, config.dimensions["width"], 0.01 + assert_in_delta 12.25, config.parameters["wing_pitch"], 0.001 + assert_in_delta 6.0, config.parameters["gps_antenna_height"], 0.001 + assert_in_delta 2.4, config.parameters["oled_component_height"], 0.001 + assert_in_delta 1.5, config.parameters["oled_display_height"], 0.001 + assert_in_delta 2.0, config.parameters["usb_board_wall_gap"], 0.001 + assert config.parameters["usb_opening_enabled"] + assert config.parameters["usb_guide_enabled"] assert_equal ["3mf"], config.outputs["formats"] end + def test_gps_height_uses_stack_pitch_antenna_height_and_top_gap + subject = config + subject.parameters["top_profile"] = "gps" + parameters = subject.parameters + + expected_electronics_height = + parameters["wing_pitch"] + parameters["gps_antenna_height"] + + parameters["gps_top_gap"] + + assert_in_delta 23.25, expected_electronics_height, 0.001 + assert_in_delta 35.85, subject.dimensions["height"] + parameters["lid_thickness"], 0.001 + end + + def test_oled_height_uses_tallest_component_not_display_reference + subject = config + subject.parameters["top_profile"] = "oled128x64" + original_height = subject.dimensions["height"] + + subject.parameters["oled_display_height"] = 10.0 + + assert_in_delta original_height, subject.dimensions["height"], 0.001 + end + + def test_older_config_receives_default_oled_display_height + data = FeatherCaseWizard::Config.deep_copy(config.data) + data["parameters"].delete("oled_display_height") + + loaded = FeatherCaseWizard::Config.new(data) + + assert_in_delta 1.5, loaded.parameters["oled_display_height"], 0.001 + end + + def test_older_config_preserves_opening_without_enabling_guide + data = FeatherCaseWizard::Config.deep_copy(config.data) + data["parameters"].delete("usb_opening_enabled") + data["parameters"].delete("usb_guide_enabled") + + loaded = FeatherCaseWizard::Config.new(data) + + assert loaded.parameters["usb_opening_enabled"] + refute loaded.parameters["usb_guide_enabled"] + end + + def test_usb_guide_requires_opening + subject = config + subject.parameters["usb_opening_enabled"] = false + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/guide requires/, error.message) + end + + def test_end_clearances_expand_case_length + subject = config + subject.parameters["usb_board_wall_gap"] = 20.0 + subject.parameters["board_rear_clearance"] = 20.0 + + assert_in_delta 95.6, subject.dimensions["length"], 0.01 + end + def test_oled_buttons_and_selected_coupons_are_printable subject = config subject.parameters["top_profile"] = "oled128x64" @@ -50,6 +116,33 @@ class ConfigTest < Minitest::Test assert_match(/floor/, error.message) end + def test_cutouts_on_same_wall_require_minimum_web + subject = config + first = cutout("rear", 18.0).merge("name" => "first") + second = cutout("rear", 24.0).merge("name" => "second") + subject.data["cutouts"] = [first, second] + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/too close/, error.message) + end + + def test_front_cutout_cannot_overlap_usb_opening + subject = config + subject.data["cutouts"] = [ + cutout("front", subject.dimensions["width"] / 2).merge("z" => 11.0) + ] + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/USB opening or guide/, error.message) + end + + def test_front_cutout_can_clear_usb_reservation + subject = config + subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)] + + assert_same subject, subject.validate! + end + def test_yaml_round_trip_preserves_resolved_cutouts subject = config subject.data["cutouts"] = [cutout("left", 20.0)] diff --git a/test/prompting_ui_test.rb b/test/prompting_ui_test.rb new file mode 100644 index 0000000..997e7d4 --- /dev/null +++ b/test/prompting_ui_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "stringio" +require_relative "test_helper" + +class PromptingUITest < Minitest::Test + def test_menu_prompt_is_a_header_and_answers_are_separated + output = StringIO.new + ui = FeatherCaseWizard::PromptingUI.wrap( + HighLine.new(StringIO.new("1\nDemo\n"), output) + ) + + selected = ui.choose("Export format (current: 3mf)") do |menu| + menu.choice("3MF") { "3mf" } + menu.choice("STL") { "stl" } + end + name = ui.ask("Case name: ") + + assert_equal "3mf", selected + assert_equal "Demo", name + assert_includes output.string, "Export format (current: 3mf):\n1. 3MF\n2. STL\n? \n" + refute_includes output.string, "3. Export format" + assert_match(/\? \nCase name: /, output.string) + end + + def test_wrap_is_idempotent + ui = FeatherCaseWizard::PromptingUI.wrap(HighLine.new) + + assert_same ui, FeatherCaseWizard::PromptingUI.wrap(ui) + end +end diff --git a/test/wizard_test.rb b/test/wizard_test.rb index 9865138..0972461 100644 --- a/test/wizard_test.rb +++ b/test/wizard_test.rb @@ -6,20 +6,8 @@ require_relative "test_helper" class WizardTest < Minitest::Test def test_minimal_new_case_flow_and_confirmation input = StringIO.new( - "Demo Case\n" \ - "\n" \ - "1\n" \ - "1\n" \ - "\n" \ - "n\n" \ - "n\n" \ - "n\n" \ - "1\n" \ - "n\n" \ - "n\n" \ - "n\n" \ - "n\n" \ - "y\n" + ["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "1", + "n", "n", "n", "n", "y"].join("\n") + "\n" ) output = StringIO.new wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output)) @@ -31,4 +19,55 @@ class WizardTest < Minitest::Test assert_equal ["3mf"], config.outputs["formats"] assert_equal %w[body lid], config.printable_parts end + + def test_edit_accepts_current_menu_choices_as_defaults + config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps") + config.parameters["top_profile"] = "gps" + input = StringIO.new( + ["", "", "", "", "n", "n", "", "", "n", "n", "1", "n", "n", "n", + "n"].join("\n") + "\n" + ) + wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, StringIO.new)) + + edited = wizard.edit(config) + + assert_equal ["3mf"], edited.outputs["formats"] + assert_equal "gps", edited.parameters["top_profile"] + end + + def test_edit_cutout_accepts_current_wall_and_shape_as_defaults + config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps") + cutout = { + "name" => "cable", + "face" => "left", + "shape" => "roundrect", + "horizontal" => 30.0, + "z" => 12.0, + "width" => 8.0, + "height" => 6.0, + "radius" => 1.5 + } + output = StringIO.new + wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(StringIO.new("\n" * 8), output)) + + edited = wizard.send(:ask_cutout, config, cutout) + + assert_equal "left", edited["face"] + assert_equal "roundrect", edited["shape"] + assert_includes output.string, "Wall:\n1. Front — USB-C short wall\n" + assert_includes output.string, "3. Left — plain/non-JST long wall\n" + refute_includes output.string, "5. Wall" + end + + def test_end_clearance_step_sets_both_board_end_distances + config = FeatherCaseWizard::Config.create(name: "Servo", slug: "servo") + wizard = FeatherCaseWizard::Wizard.new( + ui: HighLine.new(StringIO.new("y\n20\n20\n"), StringIO.new) + ) + + wizard.send(:choose_end_clearance, config) + + assert_in_delta 20.0, config.parameters["usb_board_wall_gap"], 0.001 + assert_in_delta 20.0, config.parameters["board_rear_clearance"], 0.001 + end end