commit 683697604a42b0d72d45ec4cfb179b4e2de5f723 Author: Nick Estes Date: Wed Jul 29 11:51:51 2026 -0700 Initial parametric Feather case generator diff --git a/.bundle/config b/.bundle/config new file mode 100644 index 0000000..8cb1a68 --- /dev/null +++ b/.bundle/config @@ -0,0 +1,3 @@ +--- +BUNDLE_PATH: "vendor/bundle" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cc4d61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Generated CAD and preview output +/build/ +*.stl +*.3mf +*.amf +*.off +*.png +/cases/*/exports/ +/cases/*/previews/ +/cases/*/history/ +/cases/*/.rebuild-*/ +/cases/.* + +# Local Ruby dependencies and test output +/vendor/bundle/ +/coverage/ +/tmp/ +*.log + +# Python/Blender caches and crash dumps +__pycache__/ +*.pyc +core +core.* + +# Local environment and editor files +.env +.env.* +.DS_Store +*.swp +*.swo +*~ +/.idea/ +/.vscode/ +/.ruby-lsp/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..825513c --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "highline", "~> 3.1" + +group :development do + gem "minitest", "~> 5.22" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..ecfe941 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,19 @@ +GEM + remote: https://rubygems.org/ + specs: + highline (3.1.2) + reline + io-console (0.8.2) + minitest (5.27.0) + reline (0.6.3) + io-console (~> 0.5) + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + highline (~> 3.1) + minitest (~> 5.22) + +BUNDLED WITH + 2.4.20 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b0b701 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Parametric Feather Case + +An OpenSCAD enclosure generator for the Adafruit ESP32 Feather V2, stacked +FeatherWings, an optional protected 18650 pack, and an optional 1/4-20 camera +mount. It produces a body, removable lid, OLED button plungers, and fit +calibration coupons. + +## Interactive Ruby wizard + +The HighLine wizard asks the project-specific questions, saves the answers in +YAML, and builds a self-contained case directory with OpenSCAD source, STL +and/or 3MF geometry, and rendered previews. + +Install the Ruby dependency into this repository: + +```bash +bundle install +``` + +Then run: + +```bash +bundle exec bin/feather-case +``` + +The menu can create a case, reopen one for dimensional tuning after a test +print, or rebuild an existing case. Generated definitions live under +`cases//`; exports, previews, revision history, and `vendor/bundle` are +ignored by Git. + +OpenSCAD and Blender must be available on `PATH`. On Ubuntu: + +```bash +sudo apt-get install openscad blender +``` + +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. + +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 +fine-tuning pass cannot silently pick up later geometry changes. + +OpenSCAD's 3MF output contains portable model geometry. It does not contain a +Bambu printer, filament, plate, or process profile; select those settings after +opening the files in Bambu Studio. + +The design targets support-free FDM printing with a 0.6 mm nozzle. PLA and PETG +are suitable for general use; ASA is the preferred starting material for +outdoor exposure. The enclosure is not weather-sealed. + +## Reference geometry + +All dimensions are millimetres. Board coordinates come from Adafruit's source +files: + +- [ESP32 Feather V2 PCB](https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB) +- [ESP32 Feather V2 CAD](https://github.com/adafruit/Adafruit_CAD_Parts/tree/main/5400%20ESP32%20Feather%20V2) +- [128x64 OLED FeatherWing PCB](https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB) +- [128x64 OLED FeatherWing CAD](https://github.com/adafruit/Adafruit_CAD_Parts/tree/main/4650%20OLED%20FeatherWing) +- [Adafruit 69 x 18 mm protected 18650 pack](https://www.adafruit.com/product/1781) + +The board origin is its USB edge at `(0, 0)`. The case uses X from the USB wall +toward the rear, Y from the plain side toward the battery/JST side, and Z up +from the exterior bottom. + +## Generate parts + +Open [feather_case.scad](feather_case.scad) in OpenSCAD and use the Customizer, +or override values from the command line: + +```bash +# Generic one-Wing case +openscad -o body.stl -D 'part="body"' feather_case.scad +openscad -o lid.stl -D 'part="lid"' feather_case.scad + +# Three-Wing GPS case with battery and camera mount +openscad -o gps-body.stl \ + -D 'part="body"' \ + -D 'top_profile="gps"' \ + -D 'wing_count=3' \ + -D 'battery_enabled=true' \ + -D 'tripod_insert_enabled=true' \ + feather_case.scad +openscad -o gps-lid.stl \ + -D 'part="lid"' \ + -D 'top_profile="gps"' \ + -D 'wing_count=3' \ + -D 'battery_enabled=true' \ + -D 'tripod_insert_enabled=true' \ + feather_case.scad + +# 128x64 OLED lid and all four captive buttons +openscad -o oled-lid.stl \ + -D 'part="lid"' \ + -D 'top_profile="oled128x64"' \ + -D 'button_reset=true' \ + feather_case.scad +openscad -o oled-buttons.stl \ + -D 'part="buttons"' \ + -D 'top_profile="oled128x64"' \ + -D 'button_reset=true' \ + 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. + +## Custom wire egress + +`extra_cutouts` accepts any number of entries: + +```openscad +// [face, shape, u, z, width, height, radius] +extra_cutouts = [ + ["left", "circle", 35, 10, 8, 8, 4], + ["rear", "roundrect", 20, 12, 14, 7, 2], + ["right", "slot", 28, 15, 18, 5, 2.5] +]; +``` + +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. + +## Calibration + +Heat-set insert holes vary significantly by insert brand, filament, nozzle, +temperature, and printer calibration. Treat the supplied values as starting +points. + +1. Export and print `part="insert_coupon"` in the intended material. +2. Test both M2.5 and 1/4-20 inserts in the five stepped holes. +3. Set `m25_insert_hole_diameter` and `tripod_insert_hole_diameter` to the best + coupon values. +4. Print `part="switch_coupon"` and choose the best hole for the actual DMWD + switch batch. +5. Print `part="fit_coupon"` and tune `fit_clearance` before committing to the + complete lid and body. + +For ASA, tune dimensional compensation with coupons rather than applying an +assumed global shrink percentage. Start with four or more walls, good chamber +temperature control, and the same extrusion settings intended for the case. + +## Assembly + +1. Heat-set the optional 1/4-20 insert from inside the case. Its threaded + opening faces the exterior bottom. +2. Heat-set four M2.5 inserts into the board standoffs. +3. Fasten the ESP32 Feather V2, then install the stacking headers and Wings. +4. For battery builds, place the protected 69 x 18 mm pack in the cradle, route + its lead through the partition notch, and mount the latching switch in the + battery-side wall. +5. Heat-set the four lid inserts downward into the corner bosses. +6. For OLED builds, insert each enabled printed plunger through the lid from + the inside. The elongated flange stays in the internal recess. +7. Engage the lid rim and fasten four standard M2.5 socket-head screws into the + 2.5 mm-deep counterbores. + +Confirm the USB plug, switch, battery, insert, and lid fits without force before +leaving a lithium-ion battery installed. The battery cradle is intended for +Adafruit's protected pack, not an unprotected loose cell. diff --git a/bin/feather-case b/bin/feather-case new file mode 100755 index 0000000..76b0d7f --- /dev/null +++ b/bin/feather-case @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/feather_case_wizard" + +exit FeatherCaseWizard::CLI.new.run + diff --git a/cases/gps/README.md b/cases/gps/README.md new file mode 100644 index 0000000..c8686db --- /dev/null +++ b/cases/gps/README.md @@ -0,0 +1,25 @@ +# GPS + +Generated by the Feather Case Wizard. + +- Top profile: `gps` +- FeatherWings: 1 +- Battery compartment: yes +- 1/4-20 insert: yes +- Approximate exterior: 93.9 × 57.3 × 38.6 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 gps 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/gps/build.rb b/cases/gps/build.rb new file mode 100755 index 0000000..3deb8ab --- /dev/null +++ b/cases/gps/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/gps/case.yml b/cases/gps/case.yml new file mode 100644 index 0000000..b962017 --- /dev/null +++ b/cases/gps/case.yml @@ -0,0 +1,81 @@ +--- +schema_version: 1 +name: GPS +slug: gps +generator: + sha256: a265164909faa0c364792224601af36381744a129b7cd7cbd6b6293815953f63 + created_with: feather-case-wizard +parameters: + top_profile: gps + wing_count: 1 + wing_pitch: 11.0 + stack_height_override: 0.0 + generic_component_height: 3.0 + generic_top_gap: 2.0 + gps_antenna_height: 4.0 + gps_top_gap: 5.0 + oled_component_height: 2.4 + 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/gps/parameters.json b/cases/gps/parameters.json new file mode 100644 index 0000000..35861fc --- /dev/null +++ b/cases/gps/parameters.json @@ -0,0 +1,68 @@ +{ + "parameterSets": { + "gps": { + "top_profile": "\"gps\"", + "wing_count": "1", + "wing_pitch": "11.0", + "stack_height_override": "0.0", + "generic_component_height": "3.0", + "generic_top_gap": "2.0", + "gps_antenna_height": "4.0", + "gps_top_gap": "5.0", + "oled_component_height": "2.4", + "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/gps/source/feather_case.scad b/cases/gps/source/feather_case.scad new file mode 100644 index 0000000..742172a --- /dev/null +++ b/cases/gps/source/feather_case.scad @@ -0,0 +1,871 @@ +/* + * 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 = 11; // [7:0.1:16] +stack_height_override = 0; // [0:0.1:100] +generic_component_height = 3; // [0:0.1:30] +generic_top_gap = 2; // [0.5:0.1:10] +gps_antenna_height = 4; +gps_top_gap = 5; +oled_component_height = 2.4; +oled_face_gap = 0.8; + +/* [OLED controls] */ +button_a = true; +button_b = true; +button_c = true; +button_reset = false; +button_projection = 1.5; // [0.5:0.1:3] +button_cap_size = [5, 3.2]; +button_flange_size = [6.6, 3.6]; +button_flange_thickness = 0.9; +button_travel = 0.5; +button_contact_extension = 0.7; + +/* [Battery and switch] */ +battery_enabled = false; +battery_length = 69; +battery_diameter = 18; +battery_radial_clearance = 0.6; +battery_end_clearance = 1; +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_component_height + ]); + color([0.6, 0.6, 0.6, 0.9]) + for (xy = [ + oled_button_a_xy, + oled_button_b_xy, + oled_button_c_xy, + oled_button_reset_xy + ]) + translate([ + board_origin_x + xy[0], + board_origin_y + xy[1], + top_pcb_z + ]) + cylinder(h = 1.6, d = 2.1); + } else + color([0.3, 0.65, 0.35, 0.6]) + translate([ + board_origin_x + 10, + board_origin_y + 4, + top_pcb_z + ]) + cube([ + feather_length - 20, + feather_width - 8, + generic_component_height + ]); +} + +module reference_battery_and_switch() { + if (battery_enabled) { + color([0.35, 0.18, 0.55, 0.75]) + translate([battery_start_x, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder(h = battery_length, d = battery_diameter); + color([0.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/gps/source/render_stl_previews.py b/cases/gps/source/render_stl_previews.py new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/gps/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 new file mode 100644 index 0000000..742172a --- /dev/null +++ b/feather_case.scad @@ -0,0 +1,871 @@ +/* + * 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 = 11; // [7:0.1:16] +stack_height_override = 0; // [0:0.1:100] +generic_component_height = 3; // [0:0.1:30] +generic_top_gap = 2; // [0.5:0.1:10] +gps_antenna_height = 4; +gps_top_gap = 5; +oled_component_height = 2.4; +oled_face_gap = 0.8; + +/* [OLED controls] */ +button_a = true; +button_b = true; +button_c = true; +button_reset = false; +button_projection = 1.5; // [0.5:0.1:3] +button_cap_size = [5, 3.2]; +button_flange_size = [6.6, 3.6]; +button_flange_thickness = 0.9; +button_travel = 0.5; +button_contact_extension = 0.7; + +/* [Battery and switch] */ +battery_enabled = false; +battery_length = 69; +battery_diameter = 18; +battery_radial_clearance = 0.6; +battery_end_clearance = 1; +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_component_height + ]); + color([0.6, 0.6, 0.6, 0.9]) + for (xy = [ + oled_button_a_xy, + oled_button_b_xy, + oled_button_c_xy, + oled_button_reset_xy + ]) + translate([ + board_origin_x + xy[0], + board_origin_y + xy[1], + top_pcb_z + ]) + cylinder(h = 1.6, d = 2.1); + } else + color([0.3, 0.65, 0.35, 0.6]) + translate([ + board_origin_x + 10, + board_origin_y + 4, + top_pcb_z + ]) + cube([ + feather_length - 20, + feather_width - 8, + generic_component_height + ]); +} + +module reference_battery_and_switch() { + if (battery_enabled) { + color([0.35, 0.18, 0.55, 0.75]) + translate([battery_start_x, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder(h = battery_length, d = battery_diameter); + color([0.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/lib/feather_case_wizard.rb b/lib/feather_case_wizard.rb new file mode 100644 index 0000000..23cdc29 --- /dev/null +++ b/lib/feather_case_wizard.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" +require "open3" +require "pathname" +require "time" +require "tmpdir" +require "yaml" + +require "highline" + +require_relative "feather_case_wizard/config" +require_relative "feather_case_wizard/toolchain" +require_relative "feather_case_wizard/package_builder" +require_relative "feather_case_wizard/wizard" +require_relative "feather_case_wizard/cli" + +module FeatherCaseWizard + ROOT = Pathname(__dir__).join("..").expand_path.freeze + CASES_ROOT = ROOT.join("cases").freeze + 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 new file mode 100644 index 0000000..f3d65f0 --- /dev/null +++ b/lib/feather_case_wizard/cli.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class CLI + def initialize(ui: HighLine.new, cases_root: CASES_ROOT, + model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, + toolchain: Toolchain.new) + @ui = ui + @cases_root = Pathname(cases_root) + @model_source = Pathname(model_source) + @renderer_source = Pathname(renderer_source) + @toolchain = toolchain + @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| + menu.choice("Create a new case") { :create } + menu.choice("Edit an existing case") { :edit } + menu.choice("Rebuild an existing case") { :rebuild } + menu.choice("Exit") { :exit } + end + return 0 if action == :exit + + case action + when :create then create_case + when :edit then edit_case + when :rebuild then rebuild_case + end + rescue ConfigError, ToolError, Errno::EACCES => e + @ui.say(@ui.color("\n#{e.message}", :red)) + rescue Interrupt + @ui.say("\nCancelled.") + return 130 + end + end + + private + + def create_case + config = @wizard.create + destination = config.package_dir(@cases_root) + if destination.exist? && + !@ui.agree("#{destination} already exists. Replace it? (y/n) ") { |q| q.default = "no" } + return + end + build(config, @model_source, @renderer_source) if @wizard.confirm_build(config) + end + + def edit_case + path = choose_case + return unless path + + config = Config.load(path) + package = path.dirname + source = package.join("source/feather_case.scad") + renderer = package.join("source/render_stl_previews.py") + source, renderer = choose_generator(config, source, renderer) + config = @wizard.edit(config) + build(config, source, renderer) if @wizard.confirm_build(config) + end + + def rebuild_case + path = choose_case + return unless path + + config = Config.load(path) + return unless @wizard.confirm_build(config) + + package = path.dirname + build(config, package.join("source/feather_case.scad"), + package.join("source/render_stl_previews.py")) + end + + def choose_case + paths = Dir.glob(@cases_root.join("*/case.yml")).sort.map { |item| Pathname(item) } + if paths.empty? + @ui.say("No saved cases found under #{@cases_root}.") + return nil + end + @ui.choose("Choose a saved case") do |menu| + paths.each do |path| + label = Config.load(path).name + menu.choice("#{label} (#{path.dirname.basename})") { path } + rescue ConfigError + menu.choice("Invalid configuration (#{path.dirname.basename})") { path } + end + end + end + + def choose_generator(config, snapshot, renderer) + return [@model_source, @renderer_source] unless snapshot.file? + + snapshot_sha = Digest::SHA256.file(snapshot).hexdigest + current_sha = Digest::SHA256.file(@model_source).hexdigest + return [snapshot, renderer] if snapshot_sha == current_sha + + @ui.say("This case uses a different generator snapshot.") + upgrade = @ui.agree("Upgrade it to the current generator? (y/n) ") do |question| + question.default = "no" + end + upgrade ? [@model_source, @renderer_source] : [snapshot, renderer] + end + + def build(config, source, renderer) + destination = PackageBuilder.new( + config, root: @cases_root, toolchain: @toolchain, + model_source: source, renderer_source: renderer, + reporter: ->(message) { @ui.say(" #{message}…") } + ).build! + @ui.say(@ui.color("\nGenerated #{destination}", :green)) + end + end +end diff --git a/lib/feather_case_wizard/config.rb b/lib/feather_case_wizard/config.rb new file mode 100644 index 0000000..a0c7eb4 --- /dev/null +++ b/lib/feather_case_wizard/config.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class ConfigError < StandardError; end + + class Config + SCHEMA_VERSION = 1 + PROFILES = %w[generic gps oled128x64].freeze + FORMATS = %w[stl 3mf].freeze + FACES = %w[front rear left right].freeze + SHAPES = %w[circle rect roundrect slot].freeze + COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze + + DEFAULT_PARAMETERS = { + "top_profile" => "generic", + "wing_count" => 1, + "wing_pitch" => 11.0, + "stack_height_override" => 0.0, + "generic_component_height" => 3.0, + "generic_top_gap" => 2.0, + "gps_antenna_height" => 4.0, + "gps_top_gap" => 5.0, + "oled_component_height" => 2.4, + "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" => false, + "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 + }.freeze + + ADVANCED_FIELDS = { + "Stack" => %w[wing_pitch generic_component_height generic_top_gap + gps_antenna_height gps_top_gap oled_component_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], + "M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter + m25_screw_clearance m25_boss_diameter + insert_entry_chamfer insert_melt_relief], + "Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter + tripod_thread_clearance_diameter tripod_boss_diameter], + "Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance + battery_end_clearance switch_hole_diameter + switch_hole_compensation switch_cap_diameter + 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], + "OLED buttons" => %w[button_projection button_flange_thickness + button_travel button_contact_extension] + }.freeze + + attr_reader :data + + def self.create(name:, slug:) + new( + "schema_version" => SCHEMA_VERSION, + "name" => name, + "slug" => slug, + "generator" => {}, + "parameters" => deep_copy(DEFAULT_PARAMETERS), + "cutouts" => [], + "outputs" => { + "formats" => ["3mf"], + "coupons" => [], + "assembly_exploded_view" => 8 + } + ) + end + + def self.load(path) + raw = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], + aliases: false) + raise ConfigError, "Configuration must be a YAML mapping" unless raw.is_a?(Hash) + + new(raw) + rescue Psych::Exception => e + raise ConfigError, "Cannot read configuration: #{e.message}" + end + + def self.deep_copy(value) + Marshal.load(Marshal.dump(value)) + end + + def initialize(data) + @data = stringify_keys(data) + normalize! + validate! + end + + def name = data.fetch("name") + def slug = data.fetch("slug") + def parameters = data.fetch("parameters") + def cutouts = data.fetch("cutouts") + def outputs = data.fetch("outputs") + def generator = data.fetch("generator") + + def package_dir(root = CASES_ROOT) + root.join(slug) + end + + def printable_parts + parts = %w[body lid] + if parameters["top_profile"] == "oled128x64" && + %w[button_a button_b button_c button_reset].any? { |key| parameters[key] } + parts << "buttons" + end + parts + outputs["coupons"] + end + + def model_parameters + parameters.merge("extra_cutouts" => scad_cutouts) + end + + def dimensions + 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_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"] + board_origin_y = p["wall_thickness"] + p["board_side_clearance"] + board_top_z = effective_floor + effective_board_clearance + 1.6 + top_pcb_z = board_top_z + ( + p["stack_height_override"].positive? ? + p["stack_height_override"] : p["wing_count"] * p["wing_pitch"] + ) + component_height, top_gap = case p["top_profile"] + when "gps" + [p["gps_antenna_height"], p["gps_top_gap"]] + when "oled128x64" + [p["oled_component_height"], p["oled_face_gap"]] + else + [p["generic_component_height"], p["generic_top_gap"]] + end + plain_length = board_origin_x + 50.8 + p["board_rear_clearance"] + + p["wall_thickness"] + plain_width = board_origin_y + 22.86 + p["board_side_clearance"] + + p["wall_thickness"] + if p["battery_enabled"] + partition_y = board_origin_y + 22.86 + 1 + battery_y = partition_y + p["wall_thickness"] + + p["battery_radial_clearance"] + p["battery_diameter"] / 2 + battery_start_x = board_origin_x + p["battery_end_clearance"] + battery_end_x = battery_start_x + p["battery_length"] + switch_x = battery_end_x + [ + p["switch_body_diameter"] / 2 + 0.8, + p["switch_flange_diameter"] / 2 + 0.5 + ].max + length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 + + 1.2 + p["wall_thickness"]].max + width = [plain_width, battery_y + p["battery_diameter"] / 2 + + p["battery_radial_clearance"] + p["wall_thickness"]].max + battery_z = effective_floor + p["battery_radial_clearance"] + + p["battery_diameter"] / 2 + switch_z = [ + effective_floor + p["switch_flange_diameter"] / 2 + 1, + battery_z + ].max + battery_ceiling = [ + battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"], + switch_z + p["switch_flange_diameter"] / 2 + 1 + ].max + else + length = plain_length + width = plain_width + battery_ceiling = 0 + end + { + "length" => length, + "width" => width, + "height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max, + "floor" => effective_floor, + "lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max - + p["lid_lip_depth"] + } + end + + def scad_cutouts + dims = dimensions + cutouts.map do |cutout| + face = cutout.fetch("face") + horizontal = cutout.fetch("horizontal") + u = case face + when "front" then dims["width"] - horizontal + when "rear", "left" then horizontal + when "right" then dims["length"] - horizontal + end + width = cutout.fetch("width") + height = cutout.fetch("height", width) + radius = cutout.fetch("radius", [width, height].min / 2) + [face, cutout.fetch("shape"), u, cutout.fetch("z"), width, height, radius] + end + end + + def to_yaml + data["resolved"] = {"extra_cutouts" => scad_cutouts} + data.to_yaml(line_width: -1) + end + + def validate! + raise ConfigError, "Unsupported schema_version" unless data["schema_version"] == SCHEMA_VERSION + raise ConfigError, "Project name cannot be empty" if name.to_s.strip.empty? + unless slug.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) + raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens" + end + raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"]) + raise ConfigError, "wing_count must be between 1 and 8" unless (1..8).cover?(parameters["wing_count"]) + raise ConfigError, "At least one export format is required" if outputs["formats"].empty? + unless (outputs["formats"] - FORMATS).empty? + raise ConfigError, "Unsupported export format" + end + unless (outputs["coupons"] - COUPONS).empty? + raise ConfigError, "Unsupported calibration coupon" + end + + positive = DEFAULT_PARAMETERS.keys - %w[usb_vertical_offset stack_height_override] + positive.each do |key| + value = parameters[key] + next if value == true || value == false + next if value.is_a?(Array) && value.all? { |item| item.to_f.positive? } + next unless value.is_a?(Numeric) + raise ConfigError, "#{key} must be positive" unless value.to_f.positive? + end + raise ConfigError, "stack_height_override cannot be negative" if + parameters["stack_height_override"].negative? + raise ConfigError, "wall_thickness must be at least 1.2 mm" if + parameters["wall_thickness"] < 1.2 + raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if + parameters["lid_lip_thickness"] < 1.2 + + validate_cutouts! + self + end + + private + + 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"] || {}) + data["cutouts"] ||= [] + data["outputs"] ||= {} + data["outputs"]["formats"] ||= ["3mf"] + data["outputs"]["coupons"] ||= [] + data["outputs"]["assembly_exploded_view"] ||= 8 + rescue ArgumentError, TypeError => e + raise ConfigError, "Invalid configuration value: #{e.message}" + end + + def validate_cutouts! + dims = dimensions + names = {} + cutouts.each do |cutout| + name = cutout["name"].to_s.strip + raise ConfigError, "Every cutout needs a name" if name.empty? + raise ConfigError, "Duplicate cutout name: #{name}" if names[name] + names[name] = true + face = cutout["face"] + shape = cutout["shape"] + raise ConfigError, "Invalid face for #{name}" unless FACES.include?(face) + raise ConfigError, "Invalid shape for #{name}" unless SHAPES.include?(shape) + width = Float(cutout["width"]) + height = Float(cutout.fetch("height", width)) + horizontal = Float(cutout["horizontal"]) + z = Float(cutout["z"]) + wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"] + unless horizontal - width / 2 >= 0 && horizontal + width / 2 <= wall_width + raise ConfigError, "#{name} extends beyond the #{face} wall" + end + if z - height / 2 < dims["floor"] + raise ConfigError, "#{name} intersects the case floor" + end + if z + height / 2 > dims["lip_bottom"] + raise ConfigError, "#{name} intersects the lid locating lip" + end + radius = Float(cutout.fetch("radius", [width, height].min / 2)) + raise ConfigError, "#{name} dimensions must be positive" unless + width.positive? && height.positive? && radius.positive? + rescue ArgumentError, TypeError, KeyError => e + raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}" + end + end + + def stringify_keys(value) + case value + when Hash + value.to_h { |key, item| [key.to_s, stringify_keys(item)] } + when Array + value.map { |item| stringify_keys(item) } + else + value + end + end + end +end diff --git a/lib/feather_case_wizard/package_builder.rb b/lib/feather_case_wizard/package_builder.rb new file mode 100644 index 0000000..04c563b --- /dev/null +++ b/lib/feather_case_wizard/package_builder.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class PackageBuilder + attr_reader :config, :root, :toolchain, :model_source, :renderer_source + + def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new, + model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, + reporter: nil) + @config = config + @root = Pathname(root) + @toolchain = toolchain + @model_source = Pathname(model_source) + @renderer_source = Pathname(renderer_source) + @reporter = reporter || ->(_message) {} + end + + def build! + toolchain.check! + verify_sources! + config.validate! + config.generator["sha256"] = Digest::SHA256.file(model_source).hexdigest + config.generator["created_with"] ||= "feather-case-wizard" + + FileUtils.mkdir_p(root) + destination = config.package_dir(root) + stage = Pathname(Dir.mktmpdir(".#{config.slug}-stage-", root.to_s)) + prepare_package(stage, destination) + install_atomically(stage, destination) + destination + ensure + FileUtils.rm_rf(stage) if stage&.exist? + end + + private + + def verify_sources! + raise ToolError, "Missing model source: #{model_source}" unless model_source.file? + raise ToolError, "Missing preview renderer: #{renderer_source}" unless renderer_source.file? + end + + def prepare_package(stage, previous) + source_dir = stage.join("source") + FileUtils.mkdir_p(source_dir) + FileUtils.cp(model_source, source_dir.join("feather_case.scad")) + FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py")) + FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb")) + + copy_history(previous, stage) + write_configuration(stage) + generate_exports(stage) + generate_previews(stage) + write_summary(stage) + end + + def copy_history(previous, stage) + history = stage.join("history") + if previous.directory? + old_history = previous.join("history") + FileUtils.cp_r(old_history, history) if old_history.directory? + FileUtils.mkdir_p(history) + stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ") + FileUtils.cp(previous.join("case.yml"), history.join("case-#{stamp}.yml")) if + previous.join("case.yml").file? + old_source = previous.join("source/feather_case.scad") + if old_source.file? && + Digest::SHA256.file(old_source).hexdigest != config.generator["sha256"] + FileUtils.cp(old_source, history.join("feather_case-#{stamp}.scad")) + end + end + end + + def write_configuration(stage) + File.write(stage.join("case.yml"), config.to_yaml) + values = config.parameters.to_h do |key, value| + [key, openscad_literal(value)] + end + customizer = { + "parameterSets" => {config.slug => values}, + "fileFormatVersion" => "1" + } + File.write(stage.join("parameters.json"), JSON.pretty_generate(customizer) + "\n") + FileUtils.chmod(0o755, stage.join("build.rb")) + end + + def generate_exports(stage) + config.outputs["formats"].each do |format| + directory = stage.join("exports", format) + FileUtils.mkdir_p(directory) + config.printable_parts.each do |part| + report("Exporting #{part}.#{format}") + export_model(stage, directory.join("#{part}.#{format}"), part) + end + end + end + + def generate_previews(stage) + previews = stage.join("previews") + scratch = stage.join(".preview-meshes") + FileUtils.mkdir_p([previews, scratch]) + assembly_stl = scratch.join("assembly.stl") + report("Preparing assembly preview") + export_model(stage, assembly_stl, "assembly", + "exploded_view" => config.outputs["assembly_exploded_view"], + "show_references" => false) + render(stage, assembly_stl, previews, "assembly") + + config.printable_parts.each do |part| + report("Rendering #{part} preview") + mesh = scratch.join("#{part}.stl") + export_model(stage, mesh, part) unless mesh.file? + render(stage, mesh, previews, part, ["iso"]) + end + FileUtils.rm_rf(scratch) + end + + def export_model(stage, output, part, overrides = {}) + model = stage.join("source/feather_case.scad") + parameters = config.model_parameters.merge(overrides).merge("part" => part) + command = [toolchain.openscad, "-o", output.to_s] + parameters.each do |name, value| + command.concat(["-D", "#{name}=#{openscad_literal(value)}"]) + end + command << model.to_s + toolchain.run!(*command) + raise ToolError, "OpenSCAD did not create #{output}" unless output.file? && output.size.positive? + end + + def render(stage, mesh, output_dir, basename, views = nil) + renderer = stage.join("source/render_stl_previews.py") + command = [ + toolchain.blender, "--background", "-noaudio", "--python", renderer.to_s, "--", + mesh.to_s, output_dir.to_s, basename + ] + command << views.join(",") if views + toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}) + end + + def write_summary(stage) + dims = config.dimensions + parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ") + text = <<~MARKDOWN + # #{config.name} + + Generated by the Feather Case Wizard. + + - Top profile: `#{config.parameters["top_profile"]}` + - FeatherWings: #{config.parameters["wing_count"]} + - Battery compartment: #{config.parameters["battery_enabled"] ? "yes" : "no"} + - 1/4-20 insert: #{config.parameters["tripod_insert_enabled"] ? "yes" : "no"} + - Approximate exterior: #{format("%.1f", dims["length"])} × #{format("%.1f", dims["width"])} × #{format("%.1f", dims["height"] + config.parameters["lid_thickness"])} mm + - Formats: #{config.outputs["formats"].join(", ")} + - Parts: #{parts} + - Custom cutouts: #{config.cutouts.length} + + 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 #{config.slug} source/feather_case.scad + ``` + + The 3MF files contain portable geometry only. Choose printer, filament, + plate, and slicing settings in Bambu Studio. + MARKDOWN + File.write(stage.join("README.md"), text) + end + + def install_atomically(stage, destination) + old = root.join(".#{config.slug}-old-#{Process.pid}") + if destination.exist? + FileUtils.mv(destination, old) + end + FileUtils.mv(stage, destination) + FileUtils.rm_rf(old) if old.exist? + rescue StandardError + FileUtils.mv(old, destination) if old&.exist? && !destination.exist? + raise + end + + def openscad_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| openscad_literal(item) }.join(",")}]" + else + raise ConfigError, "Cannot convert #{value.inspect} to OpenSCAD" + end + end + + def report(message) + @reporter.call(message) + end + end +end diff --git a/lib/feather_case_wizard/toolchain.rb b/lib/feather_case_wizard/toolchain.rb new file mode 100644 index 0000000..59de338 --- /dev/null +++ b/lib/feather_case_wizard/toolchain.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class ToolError < StandardError; end + + class Toolchain + attr_reader :openscad, :blender + + def initialize(env: ENV) + @env = env + @openscad = discover("OPENSCAD", "openscad") + @blender = discover("BLENDER", "blender") + end + + def check! + missing = [] + missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad + missing << "Blender (`sudo apt-get install blender` or set BLENDER)" unless blender + return self if missing.empty? + + raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}" + end + + def run!(*command, chdir: nil, env: {}) + options = chdir ? {chdir: chdir} : {} + stdout, stderr, status = Open3.capture3(env, *command, **options) + return [stdout, stderr] if status.success? + + detail = [stdout, stderr].reject(&:empty?).join("\n").strip + result = status.exitstatus || "signal #{status.termsig}" + raise ToolError, "Command failed (#{result}): #{command.first}\n#{detail}" + rescue Errno::ENOENT => e + raise ToolError, "Cannot run #{command.first}: #{e.message}" + end + + private + + def discover(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 + end +end diff --git a/lib/feather_case_wizard/wizard.rb b/lib/feather_case_wizard/wizard.rb new file mode 100644 index 0000000..ad94dc1 --- /dev/null +++ b/lib/feather_case_wizard/wizard.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +module FeatherCaseWizard + class Wizard + attr_reader :ui + + def initialize(ui: HighLine.new) + @ui = ui + end + + def create + name = required_text("Case name") + slug = ask_slug(slugify(name)) + configure(Config.create(name: name, slug: slug), new_case: true) + end + + def edit(config) + ui.say("\nEditing #{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(" Case: #{config.name} (#{config.slug})") + ui.say(" Profile: #{config.parameters["top_profile"]}") + ui.say(" Wings: #{config.parameters["wing_count"]}") + ui.say(" Battery: #{yes_no(config.parameters["battery_enabled"])}") + ui.say(" Tripod insert: #{yes_no(config.parameters["tripod_insert_enabled"])}") + ui.say(" Cutouts: #{config.cutouts.length}") + ui.say(" Size: #{format("%.1f × %.1f × %.1f mm", dims["length"], dims["width"], + dims["height"] + config.parameters["lid_thickness"])}") + ui.say(" Parts: #{config.printable_parts.join(", ")}") + ui.say(" Formats: #{config.outputs["formats"].join(", ")}") + ui.agree("Generate this case? (y/n) ") { |question| question.default = "yes" } + end + + private + + def configure(config, new_case:) + choose_formats(config) + choose_profile(config) + choose_stack(config) + choose_features(config) + manage_cutouts(config) + choose_coupons(config) + advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question| + question.default = "no" + end + config.validate! + config + rescue ConfigError => e + ui.say(ui.color("Configuration error: #{e.message}", :red)) + retry unless new_case && !ui.agree("Return to the questions? (y/n) ") { |q| q.default = "yes" } + raise + end + + def choose_formats(config) + current = config.outputs["formats"].sort + label = current == %w[3mf stl] ? "both" : current.first + selected = ui.choose("Export format (current: #{label})") do |menu| + 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) + end + config.outputs["formats"] = selected + end + + def choose_profile(config) + current = config.parameters["top_profile"] + config.parameters["top_profile"] = ui.choose("Top FeatherWing profile") do |menu| + 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) + end + end + + def choose_stack(config) + 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| + question.default = measured ? "yes" : "no" + end + p["stack_height_override"] = number( + "Measured height in mm", measured ? p["stack_height_override"] : + p["wing_count"] * p["wing_pitch"], min: 0.1 + ) + else + p["stack_height_override"] = 0.0 + end + end + + def choose_features(config) + p = config.parameters + 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") + 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 manage_cutouts(config) + loop do + ui.say("\nCustom cable cutouts: #{config.cutouts.empty? ? "none" : + config.cutouts.map { |cutout| cutout["name"] }.join(", ")}") + action = ui.choose("Cable cutouts") do |menu| + menu.choice("Done") { :done } + menu.choice("Add cutout") { :add } + menu.choice("Edit cutout") { :edit } unless config.cutouts.empty? + menu.choice("Remove cutout") { :remove } unless config.cutouts.empty? + end + break if action == :done + + case action + when :add + config.cutouts << ask_cutout(config) + when :edit + index = choose_cutout(config, "Edit which cutout?") + old = config.cutouts.delete_at(index) + config.cutouts.insert(index, ask_cutout(config, old)) + when :remove + config.cutouts.delete_at(choose_cutout(config, "Remove which cutout?")) + end + rescue ConfigError => e + ui.say(ui.color("Cutout not accepted: #{e.message}", :red)) + end + end + + def ask_cutout(config, existing = nil) + existing ||= {} + 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"] + 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"] + 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; " \ + "usable vertical centers lie between the floor and lid lip.") + width = number(shape == "circle" ? "Diameter (mm)" : "Width (mm)", + existing["width"] || 8.0, min: 0.1) + height = shape == "circle" ? width : + number("Height (mm)", existing["height"] || 6.0, min: 0.1) + horizontal = number("Center from the wall's left edge (mm)", + existing["horizontal"] || wall_width / 2, min: width / 2, + max: wall_width - width / 2) + min_z = dims["floor"] + height / 2 + max_z = dims["lip_bottom"] - height / 2 + z = number("Center height from exterior bottom (mm)", + existing["z"] || (min_z + max_z) / 2, min: min_z, max: max_z) + radius = if shape == "roundrect" + number("Corner radius (mm)", existing["radius"] || + [width, height].min / 4, min: 0.01, max: [width, height].min / 2) + else + [width, height].min / 2 + end + candidate = { + "name" => name, "face" => face, "shape" => shape, + "horizontal" => horizontal, "z" => z, "width" => width, + "height" => height, "radius" => radius + } + temporary = Config.new(Config.deep_copy(config.data).tap do |data| + data["cutouts"] = config.cutouts + [candidate] + end) + temporary.validate! + candidate + end + + def choose_cutout(config, prompt) + ui.choose(prompt) do |menu| + config.cutouts.each_with_index do |cutout, index| + menu.choice(cutout["name"]) { index } + end + end + end + + def choose_coupons(config) + selected = [] + selected << "insert_coupon" if agreement( + "Export heat-set insert calibration coupon?", + config.outputs["coupons"].include?("insert_coupon") + ) + selected << "switch_coupon" if agreement( + "Export switch-hole calibration coupon?", + config.outputs["coupons"].include?("switch_coupon") + ) + selected << "fit_coupon" if agreement( + "Export lid-fit calibration coupon?", + config.outputs["coupons"].include?("fit_coupon") + ) + config.outputs["coupons"] = selected + end + + def advanced(config) + Config::ADVANCED_FIELDS.each do |section, fields| + next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" } + + ui.say("\n#{section}") + fields.each do |field| + config.parameters[field] = number(field.tr("_", " "), config.parameters[field], + min: field == "usb_vertical_offset" ? nil : 0.01) + end + next unless section == "OLED buttons" + + config.parameters["button_cap_size"] = pair( + "button cap width,length", config.parameters["button_cap_size"] + ) + config.parameters["button_flange_size"] = pair( + "button flange width,length", config.parameters["button_flange_size"] + ) + end + end + + def agreement(prompt, default) + ui.agree("#{prompt} (y/n) ") { |question| question.default = default ? "yes" : "no" } + end + + def required_text(prompt) + loop do + value = ui.ask("#{prompt}: ").to_s.strip + return value unless value.empty? + ui.say(ui.color("A value is required.", :red)) + end + end + + def text(prompt, default) + ui.ask("#{prompt}: ") { |question| question.default = default }.to_s.strip + end + + def ask_slug(default) + loop do + value = text("Directory slug", default) + return value if value.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) + ui.say(ui.color("Use lowercase letters, numbers, and single hyphens.", :red)) + end + end + + def integer(prompt, default, range) + loop do + value = ui.ask("#{prompt}: ", Integer) { |question| question.default = default } + return value if range.cover?(value) + ui.say(ui.color("Enter a value from #{range.begin} through #{range.end}.", :red)) + end + end + + def number(prompt, default, min: nil, max: nil) + loop do + value = ui.ask("#{prompt}: ", Float) { |question| question.default = default }.to_f + return value if (min.nil? || value >= min) && (max.nil? || value <= max) + bounds = [min && "at least #{min}", max && "at most #{max}"].compact.join(" and ") + ui.say(ui.color("Enter a value #{bounds}.", :red)) + end + end + + def pair(prompt, default) + loop do + raw = text(prompt, default.join(",")) + values = raw.split(",").map { |item| Float(item.strip) } + return values if values.length == 2 && values.all?(&:positive?) + ui.say(ui.color("Enter two positive comma-separated dimensions.", :red)) + rescue ArgumentError + ui.say(ui.color("Enter two numeric comma-separated dimensions.", :red)) + end + end + + def slugify(name) + name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-?\z/, "").yield_self do |slug| + slug.empty? ? "feather-case" : slug + end + end + + def yes_no(value) = value ? "yes" : "no" + end +end + diff --git a/templates/standalone_build.rb b/templates/standalone_build.rb new file mode 100644 index 0000000..3deb8ab --- /dev/null +++ b/templates/standalone_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/test/config_test.rb b/test/config_test.rb new file mode 100644 index 0000000..60a5c44 --- /dev/null +++ b/test/config_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class ConfigTest < Minitest::Test + def config + FeatherCaseWizard::Config.create(name: "Bench GPS", slug: "bench-gps") + end + + 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 41.66, config.dimensions["width"], 0.01 + assert_equal ["3mf"], config.outputs["formats"] + end + + def test_oled_buttons_and_selected_coupons_are_printable + subject = config + subject.parameters["top_profile"] = "oled128x64" + subject.parameters["button_a"] = false + subject.parameters["button_b"] = true + subject.outputs["coupons"] = %w[fit_coupon] + + assert_equal %w[body lid buttons fit_coupon], subject.printable_parts + end + + def test_wall_local_cutouts_map_to_model_coordinates + subject = config + length = subject.dimensions["length"] + width = subject.dimensions["width"] + subject.data["cutouts"] = [ + cutout("front", 12.0), + cutout("rear", 13.0), + cutout("left", 14.0), + cutout("right", 15.0) + ] + result = subject.scad_cutouts + + assert_in_delta width - 12.0, result[0][2], 0.001 + assert_in_delta 13.0, result[1][2], 0.001 + assert_in_delta 14.0, result[2][2], 0.001 + assert_in_delta length - 15.0, result[3][2], 0.001 + end + + def test_cutout_cannot_intersect_floor + subject = config + subject.data["cutouts"] = [cutout("rear", 12.0).merge("z" => 2.0)] + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/floor/, error.message) + end + + def test_yaml_round_trip_preserves_resolved_cutouts + subject = config + subject.data["cutouts"] = [cutout("left", 20.0)] + + Dir.mktmpdir do |directory| + path = File.join(directory, "case.yml") + File.write(path, subject.to_yaml) + loaded = FeatherCaseWizard::Config.load(path) + assert_equal subject.data["cutouts"], loaded.data["cutouts"] + assert_equal subject.scad_cutouts, loaded.data.dig("resolved", "extra_cutouts") + end + end + + def test_unsafe_yaml_is_rejected + Dir.mktmpdir do |directory| + path = File.join(directory, "case.yml") + File.write(path, "--- !ruby/object:Object {}\n") + assert_raises(FeatherCaseWizard::ConfigError) do + FeatherCaseWizard::Config.load(path) + end + end + end + + private + + def cutout(face, horizontal) + { + "name" => "#{face}-cable", + "face" => face, + "shape" => "roundrect", + "horizontal" => horizontal, + "z" => 12.0, + "width" => 6.0, + "height" => 4.0, + "radius" => 1.0 + } + end +end diff --git a/test/package_builder_test.rb b/test/package_builder_test.rb new file mode 100644 index 0000000..b8d627b --- /dev/null +++ b/test/package_builder_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class PackageBuilderTest < Minitest::Test + def test_builds_self_contained_package_and_preserves_history + Dir.mktmpdir do |directory| + root = Pathname(directory) + tools = create_fake_tools(root) + model = root.join("model.scad") + renderer = root.join("renderer.py") + File.write(model, "cube([1,1,1]);\n") + File.write(renderer, "# test renderer\n") + config = FeatherCaseWizard::Config.create(name: "OLED Demo", slug: "oled-demo") + config.parameters["top_profile"] = "oled128x64" + config.outputs["formats"] = %w[3mf stl] + config.outputs["coupons"] = %w[fit_coupon] + + destination = build(config, root.join("cases"), tools, model, renderer) + + assert destination.join("case.yml").file? + assert destination.join("parameters.json").file? + assert destination.join("build.rb").executable? + %w[body lid buttons fit_coupon].each do |part| + assert destination.join("exports/stl/#{part}.stl").file? + assert destination.join("exports/3mf/#{part}.3mf").file? + assert destination.join("previews/#{part}_iso.png").file? + end + %w[iso front back left right top].each do |view| + assert destination.join("previews/assembly_#{view}.png").file? + end + + config.parameters["button_reset"] = true + build(config, root.join("cases"), tools, model, renderer) + assert_equal 1, Dir.glob(destination.join("history/case-*.yml")).length + end + end + + def test_failed_build_leaves_existing_package_untouched + Dir.mktmpdir do |directory| + root = Pathname(directory) + destination = root.join("cases/demo") + FileUtils.mkdir_p(destination) + File.write(destination.join("sentinel"), "keep") + model = root.join("model.scad") + renderer = root.join("renderer.py") + File.write(model, "cube(1);\n") + File.write(renderer, "# renderer\n") + failing = Class.new do + attr_reader :openscad, :blender + def initialize + @openscad = @blender = "/fake" + end + def check! = self + def run!(*) + raise FeatherCaseWizard::ToolError, "planned failure" + end + end.new + config = FeatherCaseWizard::Config.create(name: "Demo", slug: "demo") + + assert_raises(FeatherCaseWizard::ToolError) do + FeatherCaseWizard::PackageBuilder.new( + config, root: root.join("cases"), toolchain: failing, + model_source: model, renderer_source: renderer + ).build! + end + assert_equal "keep", destination.join("sentinel").read + end + end + + private + + def build(config, root, tools, model, renderer) + FeatherCaseWizard::PackageBuilder.new( + config, root: root, toolchain: tools, + model_source: model, renderer_source: renderer + ).build! + end + + def create_fake_tools(root) + bin = root.join("bin") + FileUtils.mkdir_p(bin) + openscad = bin.join("openscad") + blender = bin.join("blender") + File.write(openscad, <<~RUBY) + #!/usr/bin/env ruby + output = ARGV[ARGV.index("-o") + 1] + require "fileutils" + FileUtils.mkdir_p(File.dirname(output)) + File.binwrite(output, "mesh") + RUBY + File.write(blender, <<~RUBY) + #!/usr/bin/env ruby + require "fileutils" + args = ARGV[(ARGV.index("--") + 1)..] + _mesh, output, basename, selected = args + views = selected ? selected.split(",") : %w[iso front back left right top] + FileUtils.mkdir_p(output) + views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") } + RUBY + FileUtils.chmod(0o755, [openscad, blender]) + FeatherCaseWizard::Toolchain.new( + env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, "BLENDER" => blender.to_s} + ) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..8229956 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" + +require_relative "../lib/feather_case_wizard" + diff --git a/test/toolchain_test.rb b/test/toolchain_test.rb new file mode 100644 index 0000000..8849e7f --- /dev/null +++ b/test/toolchain_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class ToolchainTest < Minitest::Test + def test_reports_both_missing_dependencies + toolchain = FeatherCaseWizard::Toolchain.new(env: {"PATH" => ""}) + error = assert_raises(FeatherCaseWizard::ToolError) { toolchain.check! } + + assert_match(/OpenSCAD/, error.message) + assert_match(/Blender/, error.message) + assert_match(/apt-get/, error.message) + end + + def test_environment_overrides_are_used + Dir.mktmpdir do |directory| + executable = File.join(directory, "tool") + File.write(executable, "#!/bin/sh\nexit 0\n") + FileUtils.chmod(0o755, executable) + toolchain = FeatherCaseWizard::Toolchain.new( + env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable} + ) + + assert_equal executable, toolchain.openscad + assert_equal executable, toolchain.blender + assert_same toolchain, toolchain.check! + end + end +end + diff --git a/test/wizard_test.rb b/test/wizard_test.rb new file mode 100644 index 0000000..9865138 --- /dev/null +++ b/test/wizard_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "stringio" +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" + ) + output = StringIO.new + wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output)) + + config = wizard.create + + assert wizard.confirm_build(config) + assert_equal "demo-case", config.slug + assert_equal ["3mf"], config.outputs["formats"] + assert_equal %w[body lid], config.printable_parts + end +end diff --git a/tools/render_stl_previews.py b/tools/render_stl_previews.py new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/tools/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()