From 69d9b956e60e715a3896d632e8940c1542f2c906 Mon Sep 17 00:00:00 2001 From: Nick Estes Date: Tue, 4 Aug 2026 12:10:15 -0700 Subject: [PATCH] tweak the fit of the parts --- README.md | 52 +- assets/NME-fixed.svg | 82 ++ cases/attitude-no-battery/README.md | 31 + cases/attitude-no-battery/build.rb | 148 +++ cases/attitude-no-battery/case.yml | 94 ++ cases/attitude-no-battery/parameters.json | 76 ++ .../source/assets/NME-fixed.svg | 82 ++ .../source/feather_case.scad | 1024 +++++++++++++++++ .../source/render_multicolor_lid.py | 101 ++ .../source/render_stl_previews.py | 127 ++ .../tools/package_multimaterial_3mf.py | 138 +++ cases/attitude-w-battery/README.md | 25 + cases/attitude-w-battery/build.rb | 148 +++ cases/attitude-w-battery/case.yml | 94 ++ cases/attitude-w-battery/parameters.json | 76 ++ .../source/assets/NME-fixed.svg | 82 ++ .../source/feather_case.scad | 1024 +++++++++++++++++ .../source/render_multicolor_lid.py | 101 ++ .../source/render_stl_previews.py | 127 ++ .../tools/package_multimaterial_3mf.py | 138 +++ cases/gps/README.md | 14 +- cases/gps/build.rb | 25 + cases/gps/case.yml | 43 +- cases/gps/parameters.json | 36 +- cases/gps/source/assets/NME-fixed.svg | 82 ++ cases/gps/source/feather_case.scad | 486 ++++++-- cases/gps/source/render_multicolor_lid.py | 101 ++ cases/gps/tools/package_multimaterial_3mf.py | 138 +++ feather_case.scad | 375 ++++-- lib/feather_case_wizard.rb | 3 + lib/feather_case_wizard/config.rb | 121 +- lib/feather_case_wizard/package_builder.rb | 67 +- lib/feather_case_wizard/toolchain.rb | 4 +- lib/feather_case_wizard/wizard.rb | 21 + templates/standalone_build.rb | 25 + test/config_test.rb | 94 ++ test/package_builder_test.rb | 44 +- test/toolchain_test.rb | 6 +- test/wizard_test.rb | 8 +- tools/package_multimaterial_3mf.py | 138 +++ tools/render_multicolor_lid.py | 101 ++ 41 files changed, 5378 insertions(+), 324 deletions(-) create mode 100644 assets/NME-fixed.svg create mode 100644 cases/attitude-no-battery/README.md create mode 100755 cases/attitude-no-battery/build.rb create mode 100644 cases/attitude-no-battery/case.yml create mode 100644 cases/attitude-no-battery/parameters.json create mode 100644 cases/attitude-no-battery/source/assets/NME-fixed.svg create mode 100644 cases/attitude-no-battery/source/feather_case.scad create mode 100644 cases/attitude-no-battery/source/render_multicolor_lid.py create mode 100644 cases/attitude-no-battery/source/render_stl_previews.py create mode 100644 cases/attitude-no-battery/tools/package_multimaterial_3mf.py create mode 100644 cases/attitude-w-battery/README.md create mode 100755 cases/attitude-w-battery/build.rb create mode 100644 cases/attitude-w-battery/case.yml create mode 100644 cases/attitude-w-battery/parameters.json create mode 100644 cases/attitude-w-battery/source/assets/NME-fixed.svg create mode 100644 cases/attitude-w-battery/source/feather_case.scad create mode 100644 cases/attitude-w-battery/source/render_multicolor_lid.py create mode 100644 cases/attitude-w-battery/source/render_stl_previews.py create mode 100644 cases/attitude-w-battery/tools/package_multimaterial_3mf.py create mode 100644 cases/gps/source/assets/NME-fixed.svg create mode 100644 cases/gps/source/render_multicolor_lid.py create mode 100644 cases/gps/tools/package_multimaterial_3mf.py create mode 100644 tools/package_multimaterial_3mf.py create mode 100644 tools/render_multicolor_lid.py diff --git a/README.md b/README.md index af41c4b..e100ca1 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,15 @@ 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: +OpenSCAD, Blender, and Python 3 must be available on `PATH`. On Ubuntu: ```bash -sudo apt-get install openscad blender +sudo apt-get install openscad blender python3 ``` -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. +Set `OPENSCAD=/path/to/openscad`, `BLENDER=/path/to/blender`, or +`PYTHON=/path/to/python3` when a program is installed elsewhere. The wizard +checks all three tools before replacing an existing package. The wizard can add clearance beyond either short end of the Feather PCB for right-angle headers, plug housings, and wire bends. Front clearance moves the @@ -132,6 +132,25 @@ remains based on the taller switches plus `oled_face_gap`, placing the display face about 1.7 mm below the lid's inner plane. The default 0.7 mm captive-button contact extension spans most of the 0.8 mm switch-to-lid gap. +When the battery compartment and tripod insert are both enabled, the generator +adds board-side clearance to keep the insert centered across the case width and +moves it 5 mm toward the USB wall to clear the nearest Feather standoff. The +battery partition defaults to three 0.6 mm extrusion lines and can be adjusted +with `battery_partition_thickness`. + +## Two-color lid artwork + +Set `lid_label` to a device name and/or enable `lid_logo_enabled` to add flush +second-color artwork to non-OLED lids. Choose `lid_art_orientation` from +`usb_left`, `usb_front`, `usb_right`, or `usb_rear`; it describes where the USB +edge sits when the exterior artwork is upright. OLED profiles reject lid +artwork until a window/button-safe layout is defined. + +Generated artwork packages include separate `lid_base` and `lid_accent` meshes, +a complete single-color `lid`, and `lid_multicolor.3mf`. The multicolor file is +one aligned multipart object with its base assigned to filament 1 and its logo +and title assigned to filament 2. Print the lid exterior-face-down. + ## Custom wire egress `extra_cutouts` accepts any number of entries: @@ -164,9 +183,11 @@ 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. + coupon values. These parameters are the finished pilot diameters in the + model; `hole_compensation` is not added to heat-set pockets. The coupon uses + the same tapered pocket geometry as the cases. +4. Print `part="switch_coupon"` and choose the best rounded-rectangle opening + for the actual KCD1-101 rocker-switch batch. 5. Print `part="fit_coupon"` and tune `fit_clearance` before committing to the complete lid and body. @@ -180,13 +201,16 @@ temperature control, and the same extrusion settings intended for the case. 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 +4. For battery builds, place the protected 69 x 18 mm pack in the cradle and + route its lead through the combined front partition/JST opening and beneath + the Feather PCB. +5. Snap the KCD1-101 rocker switch into the rear lid opening, connect its bent + quick-connect terminals or low-profile wiring, and lower the wired lid onto + the case. +6. Heat-set the four lid inserts downward into the corner bosses. +7. 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 +8. 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 diff --git a/assets/NME-fixed.svg b/assets/NME-fixed.svg new file mode 100644 index 0000000..450d6e3 --- /dev/null +++ b/assets/NME-fixed.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cases/attitude-no-battery/README.md b/cases/attitude-no-battery/README.md new file mode 100644 index 0000000..816c351 --- /dev/null +++ b/cases/attitude-no-battery/README.md @@ -0,0 +1,31 @@ +# Attitude no battery + +Generated by the Feather Case Wizard. + +- Top profile: `generic` +- FeatherWings: 1 +- Battery compartment: no +- 1/4-20 insert: yes +- Approximate exterior: 60.8 × 41.7 × 33.2 mm +- Formats: 3mf +- Parts: `body`, `lid`, `lid_base`, `lid_accent` +- Custom cutouts: 0 + +Run `ruby build.rb` in this directory to regenerate exports and previews. +OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`, +`BLENDER`, or `PYTHON` to override their executable paths. + +To open the saved Customizer values manually: + +```bash +openscad -p parameters.json -P attitude-no-battery source/feather_case.scad +``` + +The 3MF files contain portable geometry only. Choose printer, filament, +plate, and slicing settings in Bambu Studio. + +For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one +multipart object. Assign “Lid base — Filament 1” and “Accent inlays — +Filament 2” to the desired filaments. `lid.3mf` is the complete +single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also +provided separately. diff --git a/cases/attitude-no-battery/build.rb b/cases/attitude-no-battery/build.rb new file mode 100755 index 0000000..01a7e8b --- /dev/null +++ b/cases/attitude-no-battery/build.rb @@ -0,0 +1,148 @@ +#!/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") +python = executable("PYTHON", "python3") +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") +) +artwork = !parameters.fetch("lid_label", "").empty? || + parameters.fetch("lid_logo_enabled", false) +abort "Python 3 not found; install it or set PYTHON" if artwork && !python +parts = %w[body lid] + config.fetch("outputs").fetch("coupons") +parts.concat(%w[lid_base lid_accent]) if artwork +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 + if artwork && format == "3mf" + puts "Packaging lid_multicolor.3mf…" + run!( + python, ROOT.join("tools/package_multimaterial_3mf.py").to_s, + directory.join("lid_base.3mf").to_s, + directory.join("lid_accent.3mf").to_s, + directory.join("lid_multicolor.3mf").to_s, + "--title", "#{config.fetch("name")} Lid" + ) + 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 + if artwork + puts "Rendering multicolor lid preview…" + run!( + blender, "--background", "-noaudio", "--python", + ROOT.join("source/render_multicolor_lid.py").to_s, "--", + scratch.join("lid_base.stl").to_s, + scratch.join("lid_accent.stl").to_s, + previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env + ) + end + + backup = ROOT.join(".build-old-#{Process.pid}") + FileUtils.mkdir_p(backup) + %w[exports previews].each do |name| + current = ROOT.join(name) + FileUtils.mv(current, backup.join(name)) if current.exist? + end + begin + FileUtils.mv(exports, ROOT.join("exports")) + FileUtils.mv(previews, ROOT.join("previews")) + FileUtils.rm_rf(backup) + rescue StandardError + FileUtils.rm_rf(ROOT.join("exports")) + FileUtils.rm_rf(ROOT.join("previews")) + %w[exports previews].each do |name| + saved = backup.join(name) + FileUtils.mv(saved, ROOT.join(name)) if saved.exist? + end + raise + end + puts "Rebuilt #{ROOT}" +ensure + FileUtils.rm_rf(stage) +end diff --git a/cases/attitude-no-battery/case.yml b/cases/attitude-no-battery/case.yml new file mode 100644 index 0000000..e0b2c8c --- /dev/null +++ b/cases/attitude-no-battery/case.yml @@ -0,0 +1,94 @@ +--- +schema_version: 1 +name: Attitude no battery +slug: attitude-no-battery +generator: + sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 + created_with: feather-case-wizard +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 + battery_partition_thickness: 1.8 + rocker_cutout_size: + - 12.9 + - 19.4 + rocker_corner_radius: 0.8 + rocker_body_size: + - 15.0 + - 21.0 + - 21.0 + rocker_artwork_gap: 2.0 + tripod_insert_enabled: true + tripod_insert_length: 5.0 + tripod_insert_hole_diameter: 7.2 + tripod_insert_outer_diameter: 8.0 + tripod_thread_clearance_diameter: 6.8 + tripod_boss_diameter: 14.0 + m25_insert_length: 4.0 + m25_insert_hole_diameter: 3.1 + m25_insert_outer_diameter: 3.5 + 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 + lid_label: IMU + lid_logo_enabled: true + lid_art_orientation: usb_front + lid_inlay_depth: 0.6 + lid_inlay_fit_clearance: 0.08 + lid_label_size: 5.0 + lid_label_logo_gap: 2.0 + lid_art_margin: 8.5 + usb_opening_width: 12.5 + usb_opening_height: 7.0 + usb_opening_radius: 1.5 + usb_vertical_offset: 0.0 +cutouts: [] +outputs: + formats: + - 3mf + coupons: [] + assembly_exploded_view: 8 +resolved: + extra_cutouts: [] diff --git a/cases/attitude-no-battery/parameters.json b/cases/attitude-no-battery/parameters.json new file mode 100644 index 0000000..a4ded58 --- /dev/null +++ b/cases/attitude-no-battery/parameters.json @@ -0,0 +1,76 @@ +{ + "parameterSets": { + "attitude-no-battery": { + "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", + "battery_partition_thickness": "1.8", + "rocker_cutout_size": "[12.9,19.4]", + "rocker_corner_radius": "0.8", + "rocker_body_size": "[15.0,21.0,21.0]", + "rocker_artwork_gap": "2.0", + "tripod_insert_enabled": "true", + "tripod_insert_length": "5.0", + "tripod_insert_hole_diameter": "7.2", + "tripod_insert_outer_diameter": "8.0", + "tripod_thread_clearance_diameter": "6.8", + "tripod_boss_diameter": "14.0", + "m25_insert_length": "4.0", + "m25_insert_hole_diameter": "3.1", + "m25_insert_outer_diameter": "3.5", + "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", + "lid_label": "\"IMU\"", + "lid_logo_enabled": "true", + "lid_art_orientation": "\"usb_front\"", + "lid_inlay_depth": "0.6", + "lid_inlay_fit_clearance": "0.08", + "lid_label_size": "5.0", + "lid_label_logo_gap": "2.0", + "lid_art_margin": "8.5", + "usb_opening_width": "12.5", + "usb_opening_height": "7.0", + "usb_opening_radius": "1.5", + "usb_vertical_offset": "0.0" + } + }, + "fileFormatVersion": "1" +} diff --git a/cases/attitude-no-battery/source/assets/NME-fixed.svg b/cases/attitude-no-battery/source/assets/NME-fixed.svg new file mode 100644 index 0000000..450d6e3 --- /dev/null +++ b/cases/attitude-no-battery/source/assets/NME-fixed.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cases/attitude-no-battery/source/feather_case.scad b/cases/attitude-no-battery/source/feather_case.scad new file mode 100644 index 0000000..3a33f30 --- /dev/null +++ b/cases/attitude-no-battery/source/feather_case.scad @@ -0,0 +1,1024 @@ +/* + * Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings. + * + * Coordinate system: + * X: USB-C wall (0) toward the rear of the case + * Y: non-battery side (0) toward the battery/JST side + * Z: outside bottom (0) upward + * + * Board coordinates are derived from Adafruit's Eagle PCB files: + * https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB + * https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB + */ + +/* [Output] */ +part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon] +exploded_view = 8; // [0:1:20] +show_references = true; + +/* [Stack] */ +top_profile = "generic"; // [generic,gps,oled128x64] +wing_count = 1; // [1:1:8] +wing_pitch = 11; // [7:0.1:16] +stack_height_override = 0; // [0:0.1:100] +generic_component_height = 3; // [0:0.1:30] +generic_top_gap = 2; // [0.5:0.1:10] +gps_antenna_height = 4; +gps_top_gap = 5; +oled_component_height = 2.4; +oled_face_gap = 0.8; + +/* [OLED controls] */ +button_a = true; +button_b = true; +button_c = true; +button_reset = false; +button_projection = 1.5; // [0.5:0.1:3] +button_cap_size = [5, 3.2]; +button_flange_size = [6.6, 3.6]; +button_flange_thickness = 0.9; +button_travel = 0.5; +button_contact_extension = 0.7; + +/* [Battery and switch] */ +battery_enabled = false; +battery_length = 69; +battery_diameter = 18; +battery_radial_clearance = 0.6; +battery_end_clearance = 1; +battery_partition_thickness = 1.8; +rocker_cutout_size = [12.9, 19.4]; +rocker_corner_radius = 0.8; +rocker_body_size = [15, 21, 21]; +rocker_artwork_gap = 2; + +/* [Tripod mount] */ +tripod_insert_enabled = false; +tripod_insert_length = 5; +tripod_insert_hole_diameter = 7.2; +tripod_insert_outer_diameter = 8; +tripod_thread_clearance_diameter = 6.8; +tripod_boss_diameter = 14; + +/* [M2.5 inserts] */ +m25_insert_length = 4; +m25_insert_hole_diameter = 3.1; +m25_insert_outer_diameter = 3.5; +m25_screw_clearance = 2.8; +m25_boss_diameter = 7; +insert_entry_chamfer = 0.6; +insert_melt_relief = 0.8; + +/* [Shell and fit] */ +wall_thickness = 2.4; +floor_thickness = 2.4; +lid_thickness = 3.2; +corner_radius = 4; +board_bottom_clearance = 5.4; +board_side_clearance = 7; +board_rear_clearance = 4; +usb_board_wall_gap = 1.2; +lid_lip_depth = 2.4; +lid_lip_thickness = 1.2; +fit_clearance = 0.35; +hole_compensation = 0.2; +lid_screw_head_diameter = 5.2; +lid_screw_head_height = 2.5; + +/* [Two-color lid artwork] */ +lid_label = ""; +lid_logo_enabled = false; +lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear] +lid_inlay_depth = 0.6; // [0.4:0.2:1] +lid_inlay_fit_clearance = 0.08; +lid_label_size = 5; +lid_label_logo_gap = 2; +lid_art_margin = 8.5; +logo_file = "assets/NME-fixed.svg"; + +/* [USB-C opening] */ +usb_opening_width = 12.5; +usb_opening_height = 7; +usb_opening_radius = 1.5; +usb_vertical_offset = 0; + +/* [Custom wall cutouts] */ +// Entry format: [face, shape, u, z, width, height, radius] +// face: front, rear, left, right. u is Y on front/rear and X on left/right. +// shape: circle, rect, roundrect, slot. Circle uses width as its diameter. +extra_cutouts = []; + +/* [Calibration] */ +coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; +m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15]; +tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; + +$fn = $preview ? 36 : 72; +eps = 0.02; + +// Adafruit Feather mechanical reference, millimetres. +feather_length = 50.8; +feather_width = 22.86; +board_thickness = 1.6; +mount_x = [2.54, 48.26]; +mount_y = [2.54, 20.32]; +usb_center_y = 11.43; +jst_center_xy = [10.9855, 19.304]; +jst_body_size = [8, 7.5, 4.5]; +jst_relief_clearance = 0.5; + +// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates. +oled_visible_min = [11.42, 3.78]; +oled_visible_size = [30, 15.3]; +oled_button_a_xy = [4.318, 15.875]; +oled_button_b_xy = [4.318, 11.43]; +oled_button_c_xy = [4.318, 6.985]; +oled_button_reset_xy = [7.366, 20.828]; + +effective_floor = tripod_insert_enabled + ? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2) + : floor_thickness; +effective_board_clearance = max( + board_bottom_clearance, + m25_insert_length + insert_melt_relief + 0.6 +); +lid_boss_r = m25_boss_diameter / 2; +lid_boss_wall_overlap = 0.6; +front_lid_boss_center = wall_thickness + lid_boss_r + - lid_boss_wall_overlap; + +// Combined battery/tripod builds need room around the centered insert entry. +combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0; +combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0; + +board_origin_x = wall_thickness + usb_board_wall_gap; +board_origin_y = wall_thickness + board_side_clearance + + combined_tripod_board_gutter; +board_bottom_z = effective_floor + effective_board_clearance; +board_top_z = board_bottom_z + board_thickness; + +computed_top_pcb_z = board_top_z + wing_count * wing_pitch; +top_pcb_z = stack_height_override > 0 + ? board_top_z + stack_height_override + : computed_top_pcb_z; +profile_component_height = + top_profile == "gps" ? gps_antenna_height : + top_profile == "oled128x64" ? oled_component_height : + generic_component_height; +profile_top_gap = + top_profile == "gps" ? gps_top_gap : + top_profile == "oled128x64" ? oled_face_gap : + generic_top_gap; + +partition_y = board_origin_y + feather_width + 1; +battery_center_y = partition_y + battery_partition_thickness + + battery_radial_clearance + battery_diameter / 2; +battery_start_x = front_lid_boss_center + lid_boss_r + + battery_end_clearance; +battery_end_x = battery_start_x + battery_length; + +case_length_plain = board_origin_x + feather_length + + board_rear_clearance + wall_thickness; +case_length_battery = battery_end_x + battery_end_clearance + + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap; +case_length = battery_enabled ? max(case_length_plain, case_length_battery) + : case_length_plain; + +case_width_plain = board_origin_y + feather_width + + board_side_clearance + wall_thickness; +case_width_battery = battery_center_y + battery_diameter / 2 + + battery_radial_clearance + wall_thickness; +case_width = battery_enabled ? max(case_width_plain, case_width_battery) + : case_width_plain; + +rocker_center_x = case_length - wall_thickness - fit_clearance + - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; +rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance + + rocker_body_size[1] / 2; + +lid_artwork_enabled = lid_label != "" || lid_logo_enabled; +lid_art_rotation = + lid_art_orientation == "usb_left" ? 0 : + lid_art_orientation == "usb_front" ? 90 : + lid_art_orientation == "usb_right" ? 180 : 270; +lid_art_safe_max_x = battery_enabled + ? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap + : case_length - lid_art_margin; +lid_art_physical_size = [ + lid_art_safe_max_x - lid_art_margin, + case_width - 2 * lid_art_margin +]; +lid_art_center = [ + lid_art_margin + lid_art_physical_size[0] / 2, + case_width / 2 +]; +lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180 + ? lid_art_physical_size + : [lid_art_physical_size[1], lid_art_physical_size[0]]; +lid_art_available_size = lid_art_canvas_size; +lid_label_height = lid_label == "" ? 0 : lid_label_size; +lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0; +logo_native_size = [30, 25.75]; +logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap; +logo_scale = lid_logo_enabled ? min( + lid_art_available_size[0] / logo_native_size[0], + logo_max_height / logo_native_size[1] +) : 0; +logo_size = [logo_native_size[0] * logo_scale, + logo_native_size[1] * logo_scale]; +lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height; +lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2; +lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height + + lid_art_gap + logo_size[1] / 2; + +tripod_center_x = case_length / 2 - combined_tripod_x_shift; +tripod_center_y = case_width / 2; +tripod_entry_radius = tripod_insert_outer_diameter / 2; +tripod_standoff_clearance = min([ + for (mx = mount_x, my = mount_y) + sqrt( + pow(board_origin_x + mx - tripod_center_x, 2) + + pow(board_origin_y + my - tripod_center_y, 2) + ) - tripod_entry_radius - m25_boss_diameter / 2 +]); +tripod_partition_clearance = partition_y + - (tripod_center_y + tripod_entry_radius); + +battery_center_z = effective_floor + battery_radial_clearance + + battery_diameter / 2; +electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap; +battery_ceiling_z = battery_enabled + ? battery_center_z + battery_diameter / 2 + battery_radial_clearance + : 0; +rocker_ceiling_z = battery_enabled + ? effective_floor + rocker_body_size[2] + fit_clearance + : 0; +body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z); + +lid_boss_xy = [ + [ + wall_thickness + lid_boss_r - lid_boss_wall_overlap, + wall_thickness + lid_boss_r - lid_boss_wall_overlap + ], + [ + case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap, + wall_thickness + lid_boss_r - lid_boss_wall_overlap + ], + [ + wall_thickness + lid_boss_r - lid_boss_wall_overlap, + case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap + ], + [ + case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap, + case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap + ] +]; + +assert(wing_count >= 1, "wing_count must be at least one"); +assert(top_profile == "generic" || top_profile == "gps" + || top_profile == "oled128x64", "Unsupported top_profile"); +assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); +assert(battery_partition_thickness >= 1.2, + "battery_partition_thickness is too small for 0.6 mm FDM"); +assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); +assert(lid_art_orientation == "usb_left" + || lid_art_orientation == "usb_front" + || lid_art_orientation == "usb_right" + || lid_art_orientation == "usb_rear", + "Unsupported lid_art_orientation"); +assert(!lid_artwork_enabled || top_profile != "oled128x64", + "Lid artwork is not supported with the OLED profile"); +assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness, + "Lid inlay must be shallower than the lid"); +assert(!lid_artwork_enabled + || (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0), + "Lid artwork margin leaves no printable area"); +assert(!lid_logo_enabled || logo_scale > 0, + "Lid logo has no printable area"); +assert(m25_boss_diameter > m25_insert_hole_diameter + 2, + "M2.5 boss does not have enough radial material"); +assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, + "M2.5 insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled + || tripod_boss_diameter > tripod_insert_hole_diameter + 3, + "Tripod insert boss does not have enough radial material"); +assert(!tripod_insert_enabled + || tripod_insert_outer_diameter >= tripod_insert_hole_diameter, + "Tripod insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2, + "Tripod insert entry is too close to a Feather standoff"); +assert(!battery_enabled || !tripod_insert_enabled + || tripod_partition_clearance >= 1, + "Tripod insert entry is too close to the battery partition"); +assert(!battery_enabled + || rocker_center_x - rocker_body_size[0] / 2 + >= board_origin_x + feather_length + fit_clearance, + "Rocker body overlaps the Feather PCB"); +assert(!battery_enabled + || rocker_center_y + rocker_body_size[1] / 2 + <= partition_y - fit_clearance, + "Rocker body overlaps the battery partition"); +assert(!battery_enabled + || body_height - rocker_body_size[2] >= effective_floor, + "Rocker body intersects the case floor"); + +function clamp(v, lo, hi) = min(max(v, lo), hi); + +module rounded_rect_2d(size, radius) { + r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); + hull() { + for (x = [r, size[0] - r], y = [r, size[1] - r]) + translate([x, y]) circle(r = r); + } +} + +module centered_rounded_rect_2d(size, radius) { + translate([-size[0] / 2, -size[1] / 2]) + rounded_rect_2d(size, radius); +} + +module centered_rounded_prism(size, height, radius) { + linear_extrude(height = height) + centered_rounded_rect_2d(size, radius); +} + +module rounded_box(size, radius) { + linear_extrude(height = size[2]) + rounded_rect_2d([size[0], size[1]], radius); +} + +module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) { + bore_d = diameter; + if (downward) { + translate([0, 0, entry_z - depth]) + cylinder(h = depth + eps, d = bore_d); + translate([0, 0, entry_z - insert_entry_chamfer]) + cylinder( + h = insert_entry_chamfer + eps, + d1 = bore_d, + d2 = entry_diameter + ); + } else { + translate([0, 0, entry_z - eps]) + cylinder(h = depth + eps, d = bore_d); + translate([0, 0, entry_z - eps]) + cylinder( + h = insert_entry_chamfer + eps, + d1 = entry_diameter, + d2 = bore_d + ); + } +} + +module face_rounded_cutout(face, u, z, width, height, radius) { + r = clamp(radius, 0.01, min(width, height) / 2); + depth = wall_thickness + 2 * eps; + if (face == "front" || face == "rear") { + x0 = face == "front" ? -eps : case_length - wall_thickness - eps; + hull() + for (yy = [u - width / 2 + r, u + width / 2 - r], + zz = [z - height / 2 + r, z + height / 2 - r]) + translate([x0, yy, zz]) + rotate([0, 90, 0]) cylinder(h = depth, r = r); + } else if (face == "left" || face == "right") { + y0 = face == "left" ? -eps : case_width - wall_thickness - eps; + hull() + for (xx = [u - width / 2 + r, u + width / 2 - r], + zz = [z - height / 2 + r, z + height / 2 - r]) + translate([xx, y0, zz]) + rotate([-90, 0, 0]) cylinder(h = depth, r = r); + } +} + +module face_circle_cutout(face, u, z, diameter) { + depth = wall_thickness + 2 * eps; + if (face == "front") + translate([-eps, u, z]) + rotate([0, 90, 0]) cylinder(h = depth, d = diameter); + else if (face == "rear") + translate([case_length - wall_thickness - eps, u, z]) + rotate([0, 90, 0]) cylinder(h = depth, d = diameter); + else if (face == "left") + translate([u, -eps, z]) + rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); + else if (face == "right") + translate([u, case_width - wall_thickness - eps, z]) + rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); +} + +module custom_wall_cutout(entry) { + face = entry[0]; + shape = entry[1]; + u = entry[2]; + z = entry[3]; + width = entry[4]; + height = len(entry) > 5 ? entry[5] : width; + radius = len(entry) > 6 ? entry[6] : min(width, height) / 2; + assert(face == "front" || face == "rear" + || face == "left" || face == "right", "Invalid cutout face"); + assert(shape == "circle" || shape == "rect" + || shape == "roundrect" || shape == "slot", "Invalid cutout shape"); + assert(z - height / 2 >= effective_floor, + "Cutout intersects the floor"); + assert(z + height / 2 <= body_height - lid_lip_depth, + "Cutout intersects the lid locating lip"); + assert( + (face == "front" || face == "rear") + ? u - width / 2 >= 0 && u + width / 2 <= case_width + : u - width / 2 >= 0 && u + width / 2 <= case_length, + "Cutout extends beyond its wall" + ); + + if (shape == "circle") + face_circle_cutout(face, u, z, width); + else + face_rounded_cutout( + face, u, z, width, height, + shape == "rect" ? 0.02 : + shape == "slot" ? min(width, height) / 2 : radius + ); +} + +module board_standoffs() { + for (mx = mount_x, my = mount_y) + translate([ + board_origin_x + mx, + board_origin_y + my, + effective_floor - eps + ]) + cylinder( + h = effective_board_clearance + eps, + d = m25_boss_diameter + ); +} + +module lid_bosses() { + for (p = lid_boss_xy) + translate([p[0], p[1], effective_floor - eps]) + cylinder( + h = body_height - effective_floor + eps, + d = m25_boss_diameter + ); +} + +module battery_cradle() { + partition_height = min( + body_height - lid_lip_depth - 0.5, + battery_center_z + battery_diameter / 2 + 1.5 + ); + rib_width = 2.4; + rib_positions = [ + battery_start_x + 4, + (battery_start_x + battery_end_x) / 2, + battery_end_x - 4 + ]; + + // One open-topped front passage provides JST plug access and enough room + // to route the battery lead beneath the Feather PCB beside the lid boss. + difference() { + translate([wall_thickness, partition_y, effective_floor - eps]) + cube([ + battery_end_x - wall_thickness + battery_end_clearance, + battery_partition_thickness, + partition_height - effective_floor + eps + ]); + translate([ + wall_thickness - eps, + partition_y - eps, + effective_floor + 1.5 + ]) + cube([ + board_origin_x + jst_center_xy[0] + + (jst_body_size[0] + 2 * jst_relief_clearance) / 2 + - wall_thickness + 2 * eps, + battery_partition_thickness + 2 * eps, + partition_height - effective_floor - 1.5 + eps + ]); + } + + // Three low curved ribs cradle the protected cylindrical pack. + for (x = rib_positions) + difference() { + translate([ + x - rib_width / 2, + battery_center_y - battery_diameter / 2 + - battery_radial_clearance, + effective_floor - eps + ]) + cube([ + rib_width, + battery_diameter + 2 * battery_radial_clearance, + battery_diameter / 2 + battery_radial_clearance + eps + ]); + translate([x - rib_width, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder( + h = rib_width * 2, + d = battery_diameter + 2 * battery_radial_clearance + ); + } + + // End stops remain below the cell center so the lid can remove vertically. + for (x = [ + battery_start_x - battery_end_clearance - wall_thickness, + battery_end_x + battery_end_clearance + ]) + translate([ + x - eps, + battery_center_y - battery_diameter / 2 - battery_radial_clearance, + effective_floor - eps + ]) + cube([ + wall_thickness + 2 * eps, + battery_diameter + 2 * battery_radial_clearance, + battery_diameter / 2 + 1 + eps + ]); + +} + +module body_positive() { + union() { + difference() { + rounded_box([case_length, case_width, body_height], corner_radius); + translate([wall_thickness, wall_thickness, effective_floor]) + rounded_box([ + case_length - 2 * wall_thickness, + case_width - 2 * wall_thickness, + body_height - effective_floor + eps + ], max(0.6, corner_radius - wall_thickness)); + } + board_standoffs(); + lid_bosses(); + if (battery_enabled) + battery_cradle(); + if (tripod_insert_enabled) + translate([tripod_center_x, tripod_center_y, 0]) + cylinder(h = effective_floor, d = tripod_boss_diameter); + } +} + +module body_cutouts() { + // Board mounting insert pockets, loaded from above before the Feather. + for (mx = mount_x, my = mount_y) + translate([board_origin_x + mx, board_origin_y + my, 0]) + insert_pocket( + m25_insert_hole_diameter, + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + board_bottom_z + ); + + // Lid insert pockets, loaded from the top of the body. + for (p = lid_boss_xy) + translate([p[0], p[1], 0]) + insert_pocket( + m25_insert_hole_diameter, + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + body_height + ); + + // USB-C cable opening. Connector center is from the official ESP32 V2 PCB. + usb_center_z = board_top_z + 1.6 + usb_vertical_offset; + face_rounded_cutout( + "front", + board_origin_y + usb_center_y, + usb_center_z, + usb_opening_width + hole_compensation, + usb_opening_height + hole_compensation, + usb_opening_radius + ); + + if (tripod_insert_enabled) { + translate([tripod_center_x, tripod_center_y, 0]) { + translate([0, 0, -eps]) + cylinder( + h = effective_floor + 2 * eps, + d = tripod_thread_clearance_diameter + hole_compensation + ); + insert_pocket( + tripod_insert_hole_diameter, + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + effective_floor + ); + } + } + + for (entry = extra_cutouts) + custom_wall_cutout(entry); +} + +module body() { + difference() { + body_positive(); + body_cutouts(); + } +} + +module lid_plate_positive() { + union() { + rounded_box([case_length, case_width, lid_thickness], corner_radius); + // Inset rim: exported pointing upward so the outer lid face prints flat. + translate([ + wall_thickness + fit_clearance, + wall_thickness + fit_clearance, + lid_thickness + ]) + linear_extrude(height = lid_lip_depth) + difference() { + rounded_rect_2d([ + case_length - 2 * (wall_thickness + fit_clearance), + case_width - 2 * (wall_thickness + fit_clearance) + ], max(0.6, corner_radius - wall_thickness)); + translate([lid_lip_thickness, lid_lip_thickness]) + rounded_rect_2d([ + case_length - 2 * ( + wall_thickness + fit_clearance + + lid_lip_thickness + ), + case_width - 2 * ( + wall_thickness + fit_clearance + + lid_lip_thickness + ) + ], max(0.5, corner_radius - wall_thickness + - lid_lip_thickness)); + } + } +} + +module beveled_oled_window() { + inner_size = oled_visible_size; + outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3]; + center = [ + board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2, + board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2 + ]; + hull() { + translate([center[0], center[1], -eps]) + linear_extrude(height = eps) + centered_rounded_rect_2d(outer_size, 1.5); + translate([center[0], center[1], lid_thickness - eps]) + linear_extrude(height = 2 * eps) + centered_rounded_rect_2d(inner_size, 0.8); + } +} + +module oled_button_lid_cutout(xy) { + center = [board_origin_x + xy[0], board_origin_y + xy[1]]; + shaft_size = [ + button_cap_size[0] + fit_clearance * 2, + button_cap_size[1] + fit_clearance * 2 + ]; + recess_size = [ + button_flange_size[0] + fit_clearance * 2, + button_flange_size[1] + fit_clearance * 2 + ]; + recess_depth = button_flange_thickness + button_travel; + translate([center[0], center[1], -eps]) + centered_rounded_prism( + shaft_size, + lid_thickness + 2 * eps, + shaft_size[1] / 2 + ); + translate([center[0], center[1], lid_thickness - recess_depth]) + centered_rounded_prism( + recess_size, + recess_depth + lid_lip_depth + eps, + recess_size[1] / 2 + ); +} + +module lid_cutouts() { + // Through holes plus shallow counterbores for flush M2.5 socket heads. + for (p = lid_boss_xy) { + // Scallop only the locating lip so it can pass over the full-height + // insert boss while leaving the lid plate supported by the boss top. + translate([p[0], p[1], lid_thickness - eps]) + cylinder( + h = lid_lip_depth + 2 * eps, + d = m25_boss_diameter + 2 * fit_clearance + ); + translate([p[0], p[1], -eps]) + cylinder( + h = lid_thickness + lid_lip_depth + 2 * eps, + d = m25_screw_clearance + hole_compensation + ); + translate([p[0], p[1], -eps]) + cylinder( + h = lid_screw_head_height + eps, + d = lid_screw_head_diameter + hole_compensation + ); + } + + if (battery_enabled) + translate([rocker_center_x, rocker_center_y, -eps]) + centered_rounded_prism( + rocker_cutout_size, + lid_thickness + lid_lip_depth + 2 * eps, + rocker_corner_radius + ); + + if (top_profile == "oled128x64") { + beveled_oled_window(); + if (button_a) oled_button_lid_cutout(oled_button_a_xy); + if (button_b) oled_button_lid_cutout(oled_button_b_xy); + if (button_c) oled_button_lid_cutout(oled_button_c_xy); + if (button_reset) oled_button_lid_cutout(oled_button_reset_xy); + } +} + +module lid_artwork_nominal_2d() { + translate(lid_art_center) + rotate(lid_art_rotation) + // The lid is modeled exterior-face-down; mirror the artwork so it + // reads normally after the lid is turned into service position. + mirror([0, 1, 0]) { + if (lid_logo_enabled) + translate([0, lid_logo_center_y]) + resize(logo_size) + import(logo_file, center = true); + if (lid_label != "") + translate([0, lid_label_center_y]) + text( + lid_label, + size = lid_label_size, + halign = "center", + valign = "center", + font = "DejaVu Sans:style=Bold" + ); + } +} + +module lid_artwork_2d(inset = 0) { + offset(delta = inset) + lid_artwork_nominal_2d(); +} + +module lid_base() { + difference() { + lid_plate_positive(); + lid_cutouts(); + if (lid_artwork_enabled) + translate([0, 0, -eps]) + linear_extrude(height = lid_inlay_depth + eps) + lid_artwork_2d(lid_inlay_fit_clearance); + } +} + +module lid_accent() { + if (lid_artwork_enabled) + linear_extrude(height = lid_inlay_depth) + lid_artwork_2d(); +} + +module lid() { + color([0.92, 0.92, 0.95]) lid_base(); + color([0.95, 0.42, 0.08]) lid_accent(); +} + +module one_oled_button() { + recess_depth = button_flange_thickness + button_travel; + stem_height = lid_thickness - recess_depth + button_projection; + // Print outer cap face down. The flange and contact nub build upward. + centered_rounded_prism( + button_cap_size, + stem_height, + button_cap_size[1] / 2 + ); + translate([0, 0, stem_height]) + centered_rounded_prism( + button_flange_size, + button_flange_thickness, + button_flange_size[1] / 2 + ); + translate([0, 0, stem_height + button_flange_thickness]) + cylinder(h = button_contact_extension, d = 3); +} + +module buttons() { + enabled = [ + button_a, + button_b, + button_c, + button_reset + ]; + count_enabled = len([for (v = enabled) if (v) 1]); + if (count_enabled == 0) + echo("No OLED buttons are enabled."); + for (i = [0 : len(enabled) - 1]) + if (enabled[i]) + translate([i * (button_flange_size[0] + 3), 0, 0]) + one_oled_button(); +} + +module reference_board() { + color([0.05, 0.25, 0.55, 0.75]) + translate([board_origin_x, board_origin_y, board_bottom_z]) + rounded_box([feather_length, feather_width, board_thickness], 2.54); + color([0.92, 0.92, 0.86, 0.9]) + translate([ + board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2, + board_origin_y + jst_center_xy[1] - 4.5, + board_top_z + ]) + cube(jst_body_size); + color([0.12, 0.45, 0.75, 0.45]) + for (i = [1 : wing_count]) + translate([ + board_origin_x, + board_origin_y, + board_top_z + i * wing_pitch - board_thickness + ]) + rounded_box( + [feather_length, feather_width, board_thickness], 2.54 + ); +} + +module reference_top_feature() { + if (top_profile == "gps") + color([0.92, 0.78, 0.35, 0.8]) + translate([ + board_origin_x + 31, + board_origin_y + (feather_width - 15) / 2, + top_pcb_z + ]) + cube([15, 15, gps_antenna_height]); + else if (top_profile == "oled128x64") { + color([0.12, 0.12, 0.12, 0.9]) + translate([ + board_origin_x + oled_visible_min[0], + board_origin_y + oled_visible_min[1], + top_pcb_z + ]) + cube([ + oled_visible_size[0], + oled_visible_size[1], + oled_component_height + ]); + color([0.6, 0.6, 0.6, 0.9]) + for (xy = [ + oled_button_a_xy, + oled_button_b_xy, + oled_button_c_xy, + oled_button_reset_xy + ]) + translate([ + board_origin_x + xy[0], + board_origin_y + xy[1], + top_pcb_z + ]) + cylinder(h = 1.6, d = 2.1); + } else + color([0.3, 0.65, 0.35, 0.6]) + translate([ + board_origin_x + 10, + board_origin_y + 4, + top_pcb_z + ]) + cube([ + feather_length - 20, + feather_width - 8, + generic_component_height + ]); +} + +module reference_battery_and_switch() { + if (battery_enabled) { + color([0.35, 0.18, 0.55, 0.75]) + translate([battery_start_x, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder(h = battery_length, d = battery_diameter); + color([0.08, 0.08, 0.08, 0.85]) + translate([ + rocker_center_x - rocker_body_size[0] / 2, + rocker_center_y - rocker_body_size[1] / 2, + body_height + exploded_view - rocker_body_size[2] + ]) + cube(rocker_body_size); + color([0.12, 0.12, 0.12, 0.95]) + translate([ + rocker_center_x - rocker_cutout_size[0] / 2, + rocker_center_y - rocker_cutout_size[1] / 2, + body_height + exploded_view + lid_thickness + ]) + cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); + } +} + +module assembly() { + color([0.82, 0.84, 0.88, 1]) body(); + if ($preview && show_references) { + reference_board(); + reference_top_feature(); + reference_battery_and_switch(); + } + // Printable lid is mirrored into its installed orientation and exploded up. + color([0.92, 0.92, 0.95, 0.88]) + translate([0, case_width, body_height + lid_thickness + exploded_view]) + rotate([180, 0, 0]) + lid(); +} + +module insert_coupon() { + spacing = 15; + coupon_length = spacing * len(tripod_coupon_steps) + 8; + coupon_width = 32; + coupon_height = max( + m25_insert_length + insert_melt_relief + 1.2, + tripod_insert_length + insert_melt_relief + 1.2 + ); + difference() { + rounded_box([coupon_length, coupon_width, coupon_height], 3); + for (i = [0 : len(tripod_coupon_steps) - 1]) { + x = 7 + i * spacing; + translate([x, 8, 0]) + insert_pocket( + m25_insert_hole_diameter + m25_coupon_steps[i], + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + coupon_height + ); + translate([x, 23, 0]) + insert_pocket( + tripod_insert_hole_diameter + tripod_coupon_steps[i], + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + coupon_height + ); + } + } +} + +module switch_coupon() { + spacing = 18; + coupon_length = spacing * len(coupon_steps) + 6; + coupon_width = rocker_cutout_size[1] + 6; + coupon_height = lid_thickness; + difference() { + rounded_box([coupon_length, coupon_width, coupon_height], 3); + for (i = [0 : len(coupon_steps) - 1]) + translate([6 + i * spacing, coupon_width / 2, -eps]) + centered_rounded_prism( + rocker_cutout_size + + [coupon_steps[i], coupon_steps[i]], + coupon_height + 2 * eps, + rocker_corner_radius + ); + } +} + +module fit_coupon() { + segment_length = 34; + base_width = 16; + base_height = 6; + cavity_size = [segment_length - 8, base_width - 8]; + male_size = [ + cavity_size[0] - 2 * fit_clearance, + cavity_size[1] - 2 * fit_clearance + ]; + difference() { + rounded_box([segment_length, base_width, base_height], 2); + translate([4, 4, 2]) + cube([segment_length - 8, base_width - 8, base_height]); + } + translate([segment_length + 5, 0, 0]) + union() { + cube([segment_length, base_width, lid_thickness]); + translate([ + 4 + fit_clearance, + 4 + fit_clearance, + lid_thickness - eps + ]) + linear_extrude(height = lid_lip_depth + eps) + difference() { + rounded_rect_2d(male_size, 1); + translate([lid_lip_thickness, lid_lip_thickness]) + rounded_rect_2d([ + male_size[0] - 2 * lid_lip_thickness, + male_size[1] - 2 * lid_lip_thickness + ], 0.5); + } + } +} + +if (part == "assembly") + assembly(); +else if (part == "body") + body(); +else if (part == "lid") + lid(); +else if (part == "lid_base") + lid_base(); +else if (part == "lid_accent") + lid_accent(); +else if (part == "buttons") + buttons(); +else if (part == "insert_coupon") + insert_coupon(); +else if (part == "switch_coupon") + switch_coupon(); +else if (part == "fit_coupon") + fit_coupon(); +else + assert(false, str("Unknown part: ", part)); diff --git a/cases/attitude-no-battery/source/render_multicolor_lid.py b/cases/attitude-no-battery/source/render_multicolor_lid.py new file mode 100644 index 0000000..05c8a58 --- /dev/null +++ b/cases/attitude-no-battery/source/render_multicolor_lid.py @@ -0,0 +1,101 @@ +"""Render aligned lid-base and accent STLs with distinct materials.""" + +import os +import sys +import math + +import bpy +from mathutils import Matrix, Vector + + +def import_mesh(path, name, color): + bpy.ops.wm.stl_import(filepath=os.path.abspath(path)) + model = bpy.context.selected_objects[0] + model.name = name + material = bpy.data.materials.new(f"{name} material") + material.diffuse_color = color + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + return model + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def main(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (4, 5): + raise SystemExit( + "expected: lid-base.stl lid-accent.stl output-directory basename [orientation]" + ) + base_path, accent_path, output_dir, basename = args[:4] + orientation = args[4] if len(args) == 5 else "usb_left" + angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270} + if orientation not in angles: + raise SystemExit(f"unknown lid orientation: {orientation}") + os.makedirs(output_dir, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + models = [ + import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)), + import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)), + ] + # Lid exports are print-oriented with the decorated exterior face down. + # Turn both aligned parts over and present the configured USB edge in its + # named position so previews inspect the finished face as the user will. + for model in models: + model.matrix_world = ( + Matrix.Rotation(math.radians(angles[orientation]), 4, "Z") + @ Matrix.Rotation(math.pi, 4, "X") + ) + bpy.context.view_layer.update() + + corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.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 + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + 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) + + 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.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" + + for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items(): + camera.location = center + Vector(direction).normalized() * span * 2.2 + look_at(camera, center) + scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png") + bpy.ops.render.render(write_still=True) + + +if __name__ == "__main__": + main() diff --git a/cases/attitude-no-battery/source/render_stl_previews.py b/cases/attitude-no-battery/source/render_stl_previews.py new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/attitude-no-battery/source/render_stl_previews.py @@ -0,0 +1,127 @@ +"""Render six inspection views of an STL using Blender. + +Usage: + blender --background --python tools/render_stl_previews.py -- \ + input.stl output-directory basename [comma-separated-views] +""" + +import os +import sys + +import bpy +from mathutils import Vector + + +def arguments(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (3, 4): + raise SystemExit( + "expected: input.stl output-directory basename [comma-separated-views]" + ) + requested_views = args[3].split(",") if len(args) == 4 else None + return args[0], args[1], args[2], requested_views + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def render_view(camera, center, span, name, direction, output_dir, basename): + distance = span * 2.2 + camera.location = Vector(center) + Vector(direction).normalized() * distance + look_at(camera, center) + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + bpy.context.scene.render.filepath = os.path.join( + output_dir, f"{basename}_{name}.png" + ) + bpy.ops.render.render(write_still=True) + + +def main(): + input_path, output_dir, basename, requested_views = arguments() + os.makedirs(output_dir, exist_ok=True) + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path)) + model = bpy.context.selected_objects[0] + model.name = "Feather case" + + material = bpy.data.materials.new("Case material") + material.diffuse_color = (0.16, 0.42, 0.72, 1.0) + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + + corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box] + mins = Vector([min(v[i] for v in corners) for i in range(3)]) + maxs = Vector([max(v[i] for v in corners) for i in range(3)]) + center = (mins + maxs) / 2 + span = max(maxs - mins) + + world = bpy.data.worlds.new("Inspection world") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.035, + 0.04, + 0.05, + 1, + ) + world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5 + bpy.context.scene.world = world + + camera_data = bpy.data.cameras.new("Camera") + camera = bpy.data.objects.new("Camera", camera_data) + bpy.context.collection.objects.link(camera) + bpy.context.scene.camera = camera + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + key_data.shape = "DISK" + key_data.size = span + key = bpy.data.objects.new("Key", key_data) + key.location = center + Vector((span, -span, span * 1.5)) + bpy.context.collection.objects.link(key) + + fill_data = bpy.data.lights.new("Fill", type="AREA") + fill_data.energy = 500 + fill_data.size = span + fill = bpy.data.objects.new("Fill", fill_data) + fill.location = center + Vector((-span, span, span)) + bpy.context.collection.objects.link(fill) + + scene = bpy.context.scene + scene.render.engine = "BLENDER_WORKBENCH" + scene.render.resolution_x = 800 + scene.render.resolution_y = 600 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.film_transparent = False + scene.display.shading.light = "STUDIO" + scene.display.shading.color_type = "MATERIAL" + scene.display.shading.show_shadows = True + scene.display.shading.show_cavity = True + scene.display.shading.cavity_type = "BOTH" + scene.display.shading.curvature_ridge_factor = 1.5 + scene.display.shading.curvature_valley_factor = 1.2 + + views = { + "iso": (1, -1, 0.8), + "front": (-1, 0, 0), + "back": (1, 0, 0), + "left": (0, -1, 0), + "right": (0, 1, 0), + "top": (0, 0, 1), + } + selected = requested_views or list(views) + unknown = sorted(set(selected) - set(views)) + if unknown: + raise SystemExit(f"unknown views: {', '.join(unknown)}") + for name in selected: + direction = views[name] + render_view(camera, center, span, name, direction, output_dir, basename) + + +if __name__ == "__main__": + main() diff --git a/cases/attitude-no-battery/tools/package_multimaterial_3mf.py b/cases/attitude-no-battery/tools/package_multimaterial_3mf.py new file mode 100644 index 0000000..7d8978a --- /dev/null +++ b/cases/attitude-no-battery/tools/package_multimaterial_3mf.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Combine aligned lid meshes into one slicer-friendly multipart 3MF.""" + +from __future__ import annotations + +import argparse +import copy +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + + +CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +ET.register_namespace("", CORE) + +CONTENT_TYPES_XML = b''' + + + + +''' + +RELATIONSHIPS_XML = b''' + + + +''' + + +def load_mesh(path: Path) -> ET.Element: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("3D/3dmodel.model")) + mesh = root.find(f".//{{{CORE}}}mesh") + if mesh is None: + raise ValueError(f"{path} does not contain a 3MF mesh") + return copy.deepcopy(mesh) + + +def model_xml(base: Path, accent: Path, title: str) -> bytes: + root = ET.Element( + f"{{{CORE}}}model", + { + "unit": "millimeter", + "{http://www.w3.org/XML/1998/namespace}lang": "en-US", + }, + ) + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = ( + "feather-case-wizard" + ) + resources = ET.SubElement(root, f"{{{CORE}}}resources") + materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"}) + ET.SubElement( + materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"} + ) + ET.SubElement( + materials, + f"{{{CORE}}}base", + {"name": "Accent inlays", "displaycolor": "#F97316"}, + ) + + for object_id, name, source in ( + (2, "Lid base — Filament 1", base), + (3, "Accent inlays — Filament 2", accent), + ): + obj = ET.SubElement( + resources, + f"{{{CORE}}}object", + { + "id": str(object_id), + "name": name, + "type": "model", + "pid": "1", + "pindex": str(object_id - 2), + }, + ) + obj.append(load_mesh(source)) + + assembly = ET.SubElement( + resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"} + ) + components = ET.SubElement(assembly, f"{{{CORE}}}components") + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"}) + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"}) + build = ET.SubElement(root, f"{{{CORE}}}build") + ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def bambu_model_settings_xml(title: str) -> bytes: + root = ET.Element("config") + obj = ET.SubElement(root, "object", {"id": "4"}) + ET.SubElement(obj, "metadata", {"key": "name", "value": title}) + for part_id, name, extruder in ( + (2, "Lid base — Filament 1", 1), + (3, "Accent inlays — Filament 2", 2), + ): + part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"}) + ET.SubElement(part, "metadata", {"key": "name", "value": name}) + ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)}) + ET.SubElement(root, "assemble") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("base", type=Path) + parser.add_argument("accent", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--title", required=True) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive: + write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML) + write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML) + write_entry( + archive, + "3D/3dmodel.model", + model_xml(args.base, args.accent, args.title), + ) + write_entry( + archive, + "Metadata/model_settings.config", + bambu_model_settings_xml(args.title), + ) + + +if __name__ == "__main__": + main() diff --git a/cases/attitude-w-battery/README.md b/cases/attitude-w-battery/README.md new file mode 100644 index 0000000..13b3e51 --- /dev/null +++ b/cases/attitude-w-battery/README.md @@ -0,0 +1,25 @@ +# Attitude w/battery + +Generated by the Feather Case Wizard. + +- Top profile: `generic` +- FeatherWings: 1 +- Battery compartment: yes +- 1/4-20 insert: yes +- Approximate exterior: 88.6 × 59.1 × 33.2 mm +- Formats: 3mf +- Parts: `body`, `lid` +- Custom cutouts: 0 + +Run `ruby build.rb` in this directory to regenerate exports and previews. +OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`, +`BLENDER`, or `PYTHON` to override their executable paths. + +To open the saved Customizer values manually: + +```bash +openscad -p parameters.json -P attitude-w-battery source/feather_case.scad +``` + +The 3MF files contain portable geometry only. Choose printer, filament, +plate, and slicing settings in Bambu Studio. diff --git a/cases/attitude-w-battery/build.rb b/cases/attitude-w-battery/build.rb new file mode 100755 index 0000000..01a7e8b --- /dev/null +++ b/cases/attitude-w-battery/build.rb @@ -0,0 +1,148 @@ +#!/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") +python = executable("PYTHON", "python3") +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") +) +artwork = !parameters.fetch("lid_label", "").empty? || + parameters.fetch("lid_logo_enabled", false) +abort "Python 3 not found; install it or set PYTHON" if artwork && !python +parts = %w[body lid] + config.fetch("outputs").fetch("coupons") +parts.concat(%w[lid_base lid_accent]) if artwork +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 + if artwork && format == "3mf" + puts "Packaging lid_multicolor.3mf…" + run!( + python, ROOT.join("tools/package_multimaterial_3mf.py").to_s, + directory.join("lid_base.3mf").to_s, + directory.join("lid_accent.3mf").to_s, + directory.join("lid_multicolor.3mf").to_s, + "--title", "#{config.fetch("name")} Lid" + ) + 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 + if artwork + puts "Rendering multicolor lid preview…" + run!( + blender, "--background", "-noaudio", "--python", + ROOT.join("source/render_multicolor_lid.py").to_s, "--", + scratch.join("lid_base.stl").to_s, + scratch.join("lid_accent.stl").to_s, + previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env + ) + end + + backup = ROOT.join(".build-old-#{Process.pid}") + FileUtils.mkdir_p(backup) + %w[exports previews].each do |name| + current = ROOT.join(name) + FileUtils.mv(current, backup.join(name)) if current.exist? + end + begin + FileUtils.mv(exports, ROOT.join("exports")) + FileUtils.mv(previews, ROOT.join("previews")) + FileUtils.rm_rf(backup) + rescue StandardError + FileUtils.rm_rf(ROOT.join("exports")) + FileUtils.rm_rf(ROOT.join("previews")) + %w[exports previews].each do |name| + saved = backup.join(name) + FileUtils.mv(saved, ROOT.join(name)) if saved.exist? + end + raise + end + puts "Rebuilt #{ROOT}" +ensure + FileUtils.rm_rf(stage) +end diff --git a/cases/attitude-w-battery/case.yml b/cases/attitude-w-battery/case.yml new file mode 100644 index 0000000..f7b02d0 --- /dev/null +++ b/cases/attitude-w-battery/case.yml @@ -0,0 +1,94 @@ +--- +schema_version: 1 +name: Attitude w/battery +slug: attitude-w-battery +generator: + sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 + created_with: feather-case-wizard +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: true + battery_length: 69.0 + battery_diameter: 18.0 + battery_radial_clearance: 0.6 + battery_end_clearance: 1.0 + battery_partition_thickness: 1.8 + rocker_cutout_size: + - 12.9 + - 19.4 + rocker_corner_radius: 0.8 + rocker_body_size: + - 15.0 + - 21.0 + - 21.0 + rocker_artwork_gap: 2.0 + tripod_insert_enabled: true + tripod_insert_length: 5.0 + tripod_insert_hole_diameter: 7.2 + tripod_insert_outer_diameter: 8.0 + tripod_thread_clearance_diameter: 6.8 + tripod_boss_diameter: 14.0 + m25_insert_length: 4.0 + m25_insert_hole_diameter: 3.1 + m25_insert_outer_diameter: 3.5 + 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 + lid_label: '' + lid_logo_enabled: false + lid_art_orientation: usb_left + lid_inlay_depth: 0.6 + lid_inlay_fit_clearance: 0.08 + lid_label_size: 5.0 + lid_label_logo_gap: 2.0 + lid_art_margin: 8.5 + usb_opening_width: 12.5 + usb_opening_height: 7.0 + usb_opening_radius: 1.5 + usb_vertical_offset: 0.0 +cutouts: [] +outputs: + formats: + - 3mf + coupons: [] + assembly_exploded_view: 8 +resolved: + extra_cutouts: [] diff --git a/cases/attitude-w-battery/parameters.json b/cases/attitude-w-battery/parameters.json new file mode 100644 index 0000000..ac4febe --- /dev/null +++ b/cases/attitude-w-battery/parameters.json @@ -0,0 +1,76 @@ +{ + "parameterSets": { + "attitude-w-battery": { + "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": "true", + "battery_length": "69.0", + "battery_diameter": "18.0", + "battery_radial_clearance": "0.6", + "battery_end_clearance": "1.0", + "battery_partition_thickness": "1.8", + "rocker_cutout_size": "[12.9,19.4]", + "rocker_corner_radius": "0.8", + "rocker_body_size": "[15.0,21.0,21.0]", + "rocker_artwork_gap": "2.0", + "tripod_insert_enabled": "true", + "tripod_insert_length": "5.0", + "tripod_insert_hole_diameter": "7.2", + "tripod_insert_outer_diameter": "8.0", + "tripod_thread_clearance_diameter": "6.8", + "tripod_boss_diameter": "14.0", + "m25_insert_length": "4.0", + "m25_insert_hole_diameter": "3.1", + "m25_insert_outer_diameter": "3.5", + "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", + "lid_label": "\"\"", + "lid_logo_enabled": "false", + "lid_art_orientation": "\"usb_left\"", + "lid_inlay_depth": "0.6", + "lid_inlay_fit_clearance": "0.08", + "lid_label_size": "5.0", + "lid_label_logo_gap": "2.0", + "lid_art_margin": "8.5", + "usb_opening_width": "12.5", + "usb_opening_height": "7.0", + "usb_opening_radius": "1.5", + "usb_vertical_offset": "0.0" + } + }, + "fileFormatVersion": "1" +} diff --git a/cases/attitude-w-battery/source/assets/NME-fixed.svg b/cases/attitude-w-battery/source/assets/NME-fixed.svg new file mode 100644 index 0000000..450d6e3 --- /dev/null +++ b/cases/attitude-w-battery/source/assets/NME-fixed.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cases/attitude-w-battery/source/feather_case.scad b/cases/attitude-w-battery/source/feather_case.scad new file mode 100644 index 0000000..3a33f30 --- /dev/null +++ b/cases/attitude-w-battery/source/feather_case.scad @@ -0,0 +1,1024 @@ +/* + * Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings. + * + * Coordinate system: + * X: USB-C wall (0) toward the rear of the case + * Y: non-battery side (0) toward the battery/JST side + * Z: outside bottom (0) upward + * + * Board coordinates are derived from Adafruit's Eagle PCB files: + * https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB + * https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB + */ + +/* [Output] */ +part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon] +exploded_view = 8; // [0:1:20] +show_references = true; + +/* [Stack] */ +top_profile = "generic"; // [generic,gps,oled128x64] +wing_count = 1; // [1:1:8] +wing_pitch = 11; // [7:0.1:16] +stack_height_override = 0; // [0:0.1:100] +generic_component_height = 3; // [0:0.1:30] +generic_top_gap = 2; // [0.5:0.1:10] +gps_antenna_height = 4; +gps_top_gap = 5; +oled_component_height = 2.4; +oled_face_gap = 0.8; + +/* [OLED controls] */ +button_a = true; +button_b = true; +button_c = true; +button_reset = false; +button_projection = 1.5; // [0.5:0.1:3] +button_cap_size = [5, 3.2]; +button_flange_size = [6.6, 3.6]; +button_flange_thickness = 0.9; +button_travel = 0.5; +button_contact_extension = 0.7; + +/* [Battery and switch] */ +battery_enabled = false; +battery_length = 69; +battery_diameter = 18; +battery_radial_clearance = 0.6; +battery_end_clearance = 1; +battery_partition_thickness = 1.8; +rocker_cutout_size = [12.9, 19.4]; +rocker_corner_radius = 0.8; +rocker_body_size = [15, 21, 21]; +rocker_artwork_gap = 2; + +/* [Tripod mount] */ +tripod_insert_enabled = false; +tripod_insert_length = 5; +tripod_insert_hole_diameter = 7.2; +tripod_insert_outer_diameter = 8; +tripod_thread_clearance_diameter = 6.8; +tripod_boss_diameter = 14; + +/* [M2.5 inserts] */ +m25_insert_length = 4; +m25_insert_hole_diameter = 3.1; +m25_insert_outer_diameter = 3.5; +m25_screw_clearance = 2.8; +m25_boss_diameter = 7; +insert_entry_chamfer = 0.6; +insert_melt_relief = 0.8; + +/* [Shell and fit] */ +wall_thickness = 2.4; +floor_thickness = 2.4; +lid_thickness = 3.2; +corner_radius = 4; +board_bottom_clearance = 5.4; +board_side_clearance = 7; +board_rear_clearance = 4; +usb_board_wall_gap = 1.2; +lid_lip_depth = 2.4; +lid_lip_thickness = 1.2; +fit_clearance = 0.35; +hole_compensation = 0.2; +lid_screw_head_diameter = 5.2; +lid_screw_head_height = 2.5; + +/* [Two-color lid artwork] */ +lid_label = ""; +lid_logo_enabled = false; +lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear] +lid_inlay_depth = 0.6; // [0.4:0.2:1] +lid_inlay_fit_clearance = 0.08; +lid_label_size = 5; +lid_label_logo_gap = 2; +lid_art_margin = 8.5; +logo_file = "assets/NME-fixed.svg"; + +/* [USB-C opening] */ +usb_opening_width = 12.5; +usb_opening_height = 7; +usb_opening_radius = 1.5; +usb_vertical_offset = 0; + +/* [Custom wall cutouts] */ +// Entry format: [face, shape, u, z, width, height, radius] +// face: front, rear, left, right. u is Y on front/rear and X on left/right. +// shape: circle, rect, roundrect, slot. Circle uses width as its diameter. +extra_cutouts = []; + +/* [Calibration] */ +coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; +m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15]; +tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; + +$fn = $preview ? 36 : 72; +eps = 0.02; + +// Adafruit Feather mechanical reference, millimetres. +feather_length = 50.8; +feather_width = 22.86; +board_thickness = 1.6; +mount_x = [2.54, 48.26]; +mount_y = [2.54, 20.32]; +usb_center_y = 11.43; +jst_center_xy = [10.9855, 19.304]; +jst_body_size = [8, 7.5, 4.5]; +jst_relief_clearance = 0.5; + +// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates. +oled_visible_min = [11.42, 3.78]; +oled_visible_size = [30, 15.3]; +oled_button_a_xy = [4.318, 15.875]; +oled_button_b_xy = [4.318, 11.43]; +oled_button_c_xy = [4.318, 6.985]; +oled_button_reset_xy = [7.366, 20.828]; + +effective_floor = tripod_insert_enabled + ? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2) + : floor_thickness; +effective_board_clearance = max( + board_bottom_clearance, + m25_insert_length + insert_melt_relief + 0.6 +); +lid_boss_r = m25_boss_diameter / 2; +lid_boss_wall_overlap = 0.6; +front_lid_boss_center = wall_thickness + lid_boss_r + - lid_boss_wall_overlap; + +// Combined battery/tripod builds need room around the centered insert entry. +combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0; +combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0; + +board_origin_x = wall_thickness + usb_board_wall_gap; +board_origin_y = wall_thickness + board_side_clearance + + combined_tripod_board_gutter; +board_bottom_z = effective_floor + effective_board_clearance; +board_top_z = board_bottom_z + board_thickness; + +computed_top_pcb_z = board_top_z + wing_count * wing_pitch; +top_pcb_z = stack_height_override > 0 + ? board_top_z + stack_height_override + : computed_top_pcb_z; +profile_component_height = + top_profile == "gps" ? gps_antenna_height : + top_profile == "oled128x64" ? oled_component_height : + generic_component_height; +profile_top_gap = + top_profile == "gps" ? gps_top_gap : + top_profile == "oled128x64" ? oled_face_gap : + generic_top_gap; + +partition_y = board_origin_y + feather_width + 1; +battery_center_y = partition_y + battery_partition_thickness + + battery_radial_clearance + battery_diameter / 2; +battery_start_x = front_lid_boss_center + lid_boss_r + + battery_end_clearance; +battery_end_x = battery_start_x + battery_length; + +case_length_plain = board_origin_x + feather_length + + board_rear_clearance + wall_thickness; +case_length_battery = battery_end_x + battery_end_clearance + + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap; +case_length = battery_enabled ? max(case_length_plain, case_length_battery) + : case_length_plain; + +case_width_plain = board_origin_y + feather_width + + board_side_clearance + wall_thickness; +case_width_battery = battery_center_y + battery_diameter / 2 + + battery_radial_clearance + wall_thickness; +case_width = battery_enabled ? max(case_width_plain, case_width_battery) + : case_width_plain; + +rocker_center_x = case_length - wall_thickness - fit_clearance + - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; +rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance + + rocker_body_size[1] / 2; + +lid_artwork_enabled = lid_label != "" || lid_logo_enabled; +lid_art_rotation = + lid_art_orientation == "usb_left" ? 0 : + lid_art_orientation == "usb_front" ? 90 : + lid_art_orientation == "usb_right" ? 180 : 270; +lid_art_safe_max_x = battery_enabled + ? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap + : case_length - lid_art_margin; +lid_art_physical_size = [ + lid_art_safe_max_x - lid_art_margin, + case_width - 2 * lid_art_margin +]; +lid_art_center = [ + lid_art_margin + lid_art_physical_size[0] / 2, + case_width / 2 +]; +lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180 + ? lid_art_physical_size + : [lid_art_physical_size[1], lid_art_physical_size[0]]; +lid_art_available_size = lid_art_canvas_size; +lid_label_height = lid_label == "" ? 0 : lid_label_size; +lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0; +logo_native_size = [30, 25.75]; +logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap; +logo_scale = lid_logo_enabled ? min( + lid_art_available_size[0] / logo_native_size[0], + logo_max_height / logo_native_size[1] +) : 0; +logo_size = [logo_native_size[0] * logo_scale, + logo_native_size[1] * logo_scale]; +lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height; +lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2; +lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height + + lid_art_gap + logo_size[1] / 2; + +tripod_center_x = case_length / 2 - combined_tripod_x_shift; +tripod_center_y = case_width / 2; +tripod_entry_radius = tripod_insert_outer_diameter / 2; +tripod_standoff_clearance = min([ + for (mx = mount_x, my = mount_y) + sqrt( + pow(board_origin_x + mx - tripod_center_x, 2) + + pow(board_origin_y + my - tripod_center_y, 2) + ) - tripod_entry_radius - m25_boss_diameter / 2 +]); +tripod_partition_clearance = partition_y + - (tripod_center_y + tripod_entry_radius); + +battery_center_z = effective_floor + battery_radial_clearance + + battery_diameter / 2; +electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap; +battery_ceiling_z = battery_enabled + ? battery_center_z + battery_diameter / 2 + battery_radial_clearance + : 0; +rocker_ceiling_z = battery_enabled + ? effective_floor + rocker_body_size[2] + fit_clearance + : 0; +body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z); + +lid_boss_xy = [ + [ + wall_thickness + lid_boss_r - lid_boss_wall_overlap, + wall_thickness + lid_boss_r - lid_boss_wall_overlap + ], + [ + case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap, + wall_thickness + lid_boss_r - lid_boss_wall_overlap + ], + [ + wall_thickness + lid_boss_r - lid_boss_wall_overlap, + case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap + ], + [ + case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap, + case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap + ] +]; + +assert(wing_count >= 1, "wing_count must be at least one"); +assert(top_profile == "generic" || top_profile == "gps" + || top_profile == "oled128x64", "Unsupported top_profile"); +assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); +assert(battery_partition_thickness >= 1.2, + "battery_partition_thickness is too small for 0.6 mm FDM"); +assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); +assert(lid_art_orientation == "usb_left" + || lid_art_orientation == "usb_front" + || lid_art_orientation == "usb_right" + || lid_art_orientation == "usb_rear", + "Unsupported lid_art_orientation"); +assert(!lid_artwork_enabled || top_profile != "oled128x64", + "Lid artwork is not supported with the OLED profile"); +assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness, + "Lid inlay must be shallower than the lid"); +assert(!lid_artwork_enabled + || (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0), + "Lid artwork margin leaves no printable area"); +assert(!lid_logo_enabled || logo_scale > 0, + "Lid logo has no printable area"); +assert(m25_boss_diameter > m25_insert_hole_diameter + 2, + "M2.5 boss does not have enough radial material"); +assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, + "M2.5 insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled + || tripod_boss_diameter > tripod_insert_hole_diameter + 3, + "Tripod insert boss does not have enough radial material"); +assert(!tripod_insert_enabled + || tripod_insert_outer_diameter >= tripod_insert_hole_diameter, + "Tripod insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2, + "Tripod insert entry is too close to a Feather standoff"); +assert(!battery_enabled || !tripod_insert_enabled + || tripod_partition_clearance >= 1, + "Tripod insert entry is too close to the battery partition"); +assert(!battery_enabled + || rocker_center_x - rocker_body_size[0] / 2 + >= board_origin_x + feather_length + fit_clearance, + "Rocker body overlaps the Feather PCB"); +assert(!battery_enabled + || rocker_center_y + rocker_body_size[1] / 2 + <= partition_y - fit_clearance, + "Rocker body overlaps the battery partition"); +assert(!battery_enabled + || body_height - rocker_body_size[2] >= effective_floor, + "Rocker body intersects the case floor"); + +function clamp(v, lo, hi) = min(max(v, lo), hi); + +module rounded_rect_2d(size, radius) { + r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); + hull() { + for (x = [r, size[0] - r], y = [r, size[1] - r]) + translate([x, y]) circle(r = r); + } +} + +module centered_rounded_rect_2d(size, radius) { + translate([-size[0] / 2, -size[1] / 2]) + rounded_rect_2d(size, radius); +} + +module centered_rounded_prism(size, height, radius) { + linear_extrude(height = height) + centered_rounded_rect_2d(size, radius); +} + +module rounded_box(size, radius) { + linear_extrude(height = size[2]) + rounded_rect_2d([size[0], size[1]], radius); +} + +module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) { + bore_d = diameter; + if (downward) { + translate([0, 0, entry_z - depth]) + cylinder(h = depth + eps, d = bore_d); + translate([0, 0, entry_z - insert_entry_chamfer]) + cylinder( + h = insert_entry_chamfer + eps, + d1 = bore_d, + d2 = entry_diameter + ); + } else { + translate([0, 0, entry_z - eps]) + cylinder(h = depth + eps, d = bore_d); + translate([0, 0, entry_z - eps]) + cylinder( + h = insert_entry_chamfer + eps, + d1 = entry_diameter, + d2 = bore_d + ); + } +} + +module face_rounded_cutout(face, u, z, width, height, radius) { + r = clamp(radius, 0.01, min(width, height) / 2); + depth = wall_thickness + 2 * eps; + if (face == "front" || face == "rear") { + x0 = face == "front" ? -eps : case_length - wall_thickness - eps; + hull() + for (yy = [u - width / 2 + r, u + width / 2 - r], + zz = [z - height / 2 + r, z + height / 2 - r]) + translate([x0, yy, zz]) + rotate([0, 90, 0]) cylinder(h = depth, r = r); + } else if (face == "left" || face == "right") { + y0 = face == "left" ? -eps : case_width - wall_thickness - eps; + hull() + for (xx = [u - width / 2 + r, u + width / 2 - r], + zz = [z - height / 2 + r, z + height / 2 - r]) + translate([xx, y0, zz]) + rotate([-90, 0, 0]) cylinder(h = depth, r = r); + } +} + +module face_circle_cutout(face, u, z, diameter) { + depth = wall_thickness + 2 * eps; + if (face == "front") + translate([-eps, u, z]) + rotate([0, 90, 0]) cylinder(h = depth, d = diameter); + else if (face == "rear") + translate([case_length - wall_thickness - eps, u, z]) + rotate([0, 90, 0]) cylinder(h = depth, d = diameter); + else if (face == "left") + translate([u, -eps, z]) + rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); + else if (face == "right") + translate([u, case_width - wall_thickness - eps, z]) + rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); +} + +module custom_wall_cutout(entry) { + face = entry[0]; + shape = entry[1]; + u = entry[2]; + z = entry[3]; + width = entry[4]; + height = len(entry) > 5 ? entry[5] : width; + radius = len(entry) > 6 ? entry[6] : min(width, height) / 2; + assert(face == "front" || face == "rear" + || face == "left" || face == "right", "Invalid cutout face"); + assert(shape == "circle" || shape == "rect" + || shape == "roundrect" || shape == "slot", "Invalid cutout shape"); + assert(z - height / 2 >= effective_floor, + "Cutout intersects the floor"); + assert(z + height / 2 <= body_height - lid_lip_depth, + "Cutout intersects the lid locating lip"); + assert( + (face == "front" || face == "rear") + ? u - width / 2 >= 0 && u + width / 2 <= case_width + : u - width / 2 >= 0 && u + width / 2 <= case_length, + "Cutout extends beyond its wall" + ); + + if (shape == "circle") + face_circle_cutout(face, u, z, width); + else + face_rounded_cutout( + face, u, z, width, height, + shape == "rect" ? 0.02 : + shape == "slot" ? min(width, height) / 2 : radius + ); +} + +module board_standoffs() { + for (mx = mount_x, my = mount_y) + translate([ + board_origin_x + mx, + board_origin_y + my, + effective_floor - eps + ]) + cylinder( + h = effective_board_clearance + eps, + d = m25_boss_diameter + ); +} + +module lid_bosses() { + for (p = lid_boss_xy) + translate([p[0], p[1], effective_floor - eps]) + cylinder( + h = body_height - effective_floor + eps, + d = m25_boss_diameter + ); +} + +module battery_cradle() { + partition_height = min( + body_height - lid_lip_depth - 0.5, + battery_center_z + battery_diameter / 2 + 1.5 + ); + rib_width = 2.4; + rib_positions = [ + battery_start_x + 4, + (battery_start_x + battery_end_x) / 2, + battery_end_x - 4 + ]; + + // One open-topped front passage provides JST plug access and enough room + // to route the battery lead beneath the Feather PCB beside the lid boss. + difference() { + translate([wall_thickness, partition_y, effective_floor - eps]) + cube([ + battery_end_x - wall_thickness + battery_end_clearance, + battery_partition_thickness, + partition_height - effective_floor + eps + ]); + translate([ + wall_thickness - eps, + partition_y - eps, + effective_floor + 1.5 + ]) + cube([ + board_origin_x + jst_center_xy[0] + + (jst_body_size[0] + 2 * jst_relief_clearance) / 2 + - wall_thickness + 2 * eps, + battery_partition_thickness + 2 * eps, + partition_height - effective_floor - 1.5 + eps + ]); + } + + // Three low curved ribs cradle the protected cylindrical pack. + for (x = rib_positions) + difference() { + translate([ + x - rib_width / 2, + battery_center_y - battery_diameter / 2 + - battery_radial_clearance, + effective_floor - eps + ]) + cube([ + rib_width, + battery_diameter + 2 * battery_radial_clearance, + battery_diameter / 2 + battery_radial_clearance + eps + ]); + translate([x - rib_width, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder( + h = rib_width * 2, + d = battery_diameter + 2 * battery_radial_clearance + ); + } + + // End stops remain below the cell center so the lid can remove vertically. + for (x = [ + battery_start_x - battery_end_clearance - wall_thickness, + battery_end_x + battery_end_clearance + ]) + translate([ + x - eps, + battery_center_y - battery_diameter / 2 - battery_radial_clearance, + effective_floor - eps + ]) + cube([ + wall_thickness + 2 * eps, + battery_diameter + 2 * battery_radial_clearance, + battery_diameter / 2 + 1 + eps + ]); + +} + +module body_positive() { + union() { + difference() { + rounded_box([case_length, case_width, body_height], corner_radius); + translate([wall_thickness, wall_thickness, effective_floor]) + rounded_box([ + case_length - 2 * wall_thickness, + case_width - 2 * wall_thickness, + body_height - effective_floor + eps + ], max(0.6, corner_radius - wall_thickness)); + } + board_standoffs(); + lid_bosses(); + if (battery_enabled) + battery_cradle(); + if (tripod_insert_enabled) + translate([tripod_center_x, tripod_center_y, 0]) + cylinder(h = effective_floor, d = tripod_boss_diameter); + } +} + +module body_cutouts() { + // Board mounting insert pockets, loaded from above before the Feather. + for (mx = mount_x, my = mount_y) + translate([board_origin_x + mx, board_origin_y + my, 0]) + insert_pocket( + m25_insert_hole_diameter, + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + board_bottom_z + ); + + // Lid insert pockets, loaded from the top of the body. + for (p = lid_boss_xy) + translate([p[0], p[1], 0]) + insert_pocket( + m25_insert_hole_diameter, + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + body_height + ); + + // USB-C cable opening. Connector center is from the official ESP32 V2 PCB. + usb_center_z = board_top_z + 1.6 + usb_vertical_offset; + face_rounded_cutout( + "front", + board_origin_y + usb_center_y, + usb_center_z, + usb_opening_width + hole_compensation, + usb_opening_height + hole_compensation, + usb_opening_radius + ); + + if (tripod_insert_enabled) { + translate([tripod_center_x, tripod_center_y, 0]) { + translate([0, 0, -eps]) + cylinder( + h = effective_floor + 2 * eps, + d = tripod_thread_clearance_diameter + hole_compensation + ); + insert_pocket( + tripod_insert_hole_diameter, + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + effective_floor + ); + } + } + + for (entry = extra_cutouts) + custom_wall_cutout(entry); +} + +module body() { + difference() { + body_positive(); + body_cutouts(); + } +} + +module lid_plate_positive() { + union() { + rounded_box([case_length, case_width, lid_thickness], corner_radius); + // Inset rim: exported pointing upward so the outer lid face prints flat. + translate([ + wall_thickness + fit_clearance, + wall_thickness + fit_clearance, + lid_thickness + ]) + linear_extrude(height = lid_lip_depth) + difference() { + rounded_rect_2d([ + case_length - 2 * (wall_thickness + fit_clearance), + case_width - 2 * (wall_thickness + fit_clearance) + ], max(0.6, corner_radius - wall_thickness)); + translate([lid_lip_thickness, lid_lip_thickness]) + rounded_rect_2d([ + case_length - 2 * ( + wall_thickness + fit_clearance + + lid_lip_thickness + ), + case_width - 2 * ( + wall_thickness + fit_clearance + + lid_lip_thickness + ) + ], max(0.5, corner_radius - wall_thickness + - lid_lip_thickness)); + } + } +} + +module beveled_oled_window() { + inner_size = oled_visible_size; + outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3]; + center = [ + board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2, + board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2 + ]; + hull() { + translate([center[0], center[1], -eps]) + linear_extrude(height = eps) + centered_rounded_rect_2d(outer_size, 1.5); + translate([center[0], center[1], lid_thickness - eps]) + linear_extrude(height = 2 * eps) + centered_rounded_rect_2d(inner_size, 0.8); + } +} + +module oled_button_lid_cutout(xy) { + center = [board_origin_x + xy[0], board_origin_y + xy[1]]; + shaft_size = [ + button_cap_size[0] + fit_clearance * 2, + button_cap_size[1] + fit_clearance * 2 + ]; + recess_size = [ + button_flange_size[0] + fit_clearance * 2, + button_flange_size[1] + fit_clearance * 2 + ]; + recess_depth = button_flange_thickness + button_travel; + translate([center[0], center[1], -eps]) + centered_rounded_prism( + shaft_size, + lid_thickness + 2 * eps, + shaft_size[1] / 2 + ); + translate([center[0], center[1], lid_thickness - recess_depth]) + centered_rounded_prism( + recess_size, + recess_depth + lid_lip_depth + eps, + recess_size[1] / 2 + ); +} + +module lid_cutouts() { + // Through holes plus shallow counterbores for flush M2.5 socket heads. + for (p = lid_boss_xy) { + // Scallop only the locating lip so it can pass over the full-height + // insert boss while leaving the lid plate supported by the boss top. + translate([p[0], p[1], lid_thickness - eps]) + cylinder( + h = lid_lip_depth + 2 * eps, + d = m25_boss_diameter + 2 * fit_clearance + ); + translate([p[0], p[1], -eps]) + cylinder( + h = lid_thickness + lid_lip_depth + 2 * eps, + d = m25_screw_clearance + hole_compensation + ); + translate([p[0], p[1], -eps]) + cylinder( + h = lid_screw_head_height + eps, + d = lid_screw_head_diameter + hole_compensation + ); + } + + if (battery_enabled) + translate([rocker_center_x, rocker_center_y, -eps]) + centered_rounded_prism( + rocker_cutout_size, + lid_thickness + lid_lip_depth + 2 * eps, + rocker_corner_radius + ); + + if (top_profile == "oled128x64") { + beveled_oled_window(); + if (button_a) oled_button_lid_cutout(oled_button_a_xy); + if (button_b) oled_button_lid_cutout(oled_button_b_xy); + if (button_c) oled_button_lid_cutout(oled_button_c_xy); + if (button_reset) oled_button_lid_cutout(oled_button_reset_xy); + } +} + +module lid_artwork_nominal_2d() { + translate(lid_art_center) + rotate(lid_art_rotation) + // The lid is modeled exterior-face-down; mirror the artwork so it + // reads normally after the lid is turned into service position. + mirror([0, 1, 0]) { + if (lid_logo_enabled) + translate([0, lid_logo_center_y]) + resize(logo_size) + import(logo_file, center = true); + if (lid_label != "") + translate([0, lid_label_center_y]) + text( + lid_label, + size = lid_label_size, + halign = "center", + valign = "center", + font = "DejaVu Sans:style=Bold" + ); + } +} + +module lid_artwork_2d(inset = 0) { + offset(delta = inset) + lid_artwork_nominal_2d(); +} + +module lid_base() { + difference() { + lid_plate_positive(); + lid_cutouts(); + if (lid_artwork_enabled) + translate([0, 0, -eps]) + linear_extrude(height = lid_inlay_depth + eps) + lid_artwork_2d(lid_inlay_fit_clearance); + } +} + +module lid_accent() { + if (lid_artwork_enabled) + linear_extrude(height = lid_inlay_depth) + lid_artwork_2d(); +} + +module lid() { + color([0.92, 0.92, 0.95]) lid_base(); + color([0.95, 0.42, 0.08]) lid_accent(); +} + +module one_oled_button() { + recess_depth = button_flange_thickness + button_travel; + stem_height = lid_thickness - recess_depth + button_projection; + // Print outer cap face down. The flange and contact nub build upward. + centered_rounded_prism( + button_cap_size, + stem_height, + button_cap_size[1] / 2 + ); + translate([0, 0, stem_height]) + centered_rounded_prism( + button_flange_size, + button_flange_thickness, + button_flange_size[1] / 2 + ); + translate([0, 0, stem_height + button_flange_thickness]) + cylinder(h = button_contact_extension, d = 3); +} + +module buttons() { + enabled = [ + button_a, + button_b, + button_c, + button_reset + ]; + count_enabled = len([for (v = enabled) if (v) 1]); + if (count_enabled == 0) + echo("No OLED buttons are enabled."); + for (i = [0 : len(enabled) - 1]) + if (enabled[i]) + translate([i * (button_flange_size[0] + 3), 0, 0]) + one_oled_button(); +} + +module reference_board() { + color([0.05, 0.25, 0.55, 0.75]) + translate([board_origin_x, board_origin_y, board_bottom_z]) + rounded_box([feather_length, feather_width, board_thickness], 2.54); + color([0.92, 0.92, 0.86, 0.9]) + translate([ + board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2, + board_origin_y + jst_center_xy[1] - 4.5, + board_top_z + ]) + cube(jst_body_size); + color([0.12, 0.45, 0.75, 0.45]) + for (i = [1 : wing_count]) + translate([ + board_origin_x, + board_origin_y, + board_top_z + i * wing_pitch - board_thickness + ]) + rounded_box( + [feather_length, feather_width, board_thickness], 2.54 + ); +} + +module reference_top_feature() { + if (top_profile == "gps") + color([0.92, 0.78, 0.35, 0.8]) + translate([ + board_origin_x + 31, + board_origin_y + (feather_width - 15) / 2, + top_pcb_z + ]) + cube([15, 15, gps_antenna_height]); + else if (top_profile == "oled128x64") { + color([0.12, 0.12, 0.12, 0.9]) + translate([ + board_origin_x + oled_visible_min[0], + board_origin_y + oled_visible_min[1], + top_pcb_z + ]) + cube([ + oled_visible_size[0], + oled_visible_size[1], + oled_component_height + ]); + color([0.6, 0.6, 0.6, 0.9]) + for (xy = [ + oled_button_a_xy, + oled_button_b_xy, + oled_button_c_xy, + oled_button_reset_xy + ]) + translate([ + board_origin_x + xy[0], + board_origin_y + xy[1], + top_pcb_z + ]) + cylinder(h = 1.6, d = 2.1); + } else + color([0.3, 0.65, 0.35, 0.6]) + translate([ + board_origin_x + 10, + board_origin_y + 4, + top_pcb_z + ]) + cube([ + feather_length - 20, + feather_width - 8, + generic_component_height + ]); +} + +module reference_battery_and_switch() { + if (battery_enabled) { + color([0.35, 0.18, 0.55, 0.75]) + translate([battery_start_x, battery_center_y, battery_center_z]) + rotate([0, 90, 0]) + cylinder(h = battery_length, d = battery_diameter); + color([0.08, 0.08, 0.08, 0.85]) + translate([ + rocker_center_x - rocker_body_size[0] / 2, + rocker_center_y - rocker_body_size[1] / 2, + body_height + exploded_view - rocker_body_size[2] + ]) + cube(rocker_body_size); + color([0.12, 0.12, 0.12, 0.95]) + translate([ + rocker_center_x - rocker_cutout_size[0] / 2, + rocker_center_y - rocker_cutout_size[1] / 2, + body_height + exploded_view + lid_thickness + ]) + cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); + } +} + +module assembly() { + color([0.82, 0.84, 0.88, 1]) body(); + if ($preview && show_references) { + reference_board(); + reference_top_feature(); + reference_battery_and_switch(); + } + // Printable lid is mirrored into its installed orientation and exploded up. + color([0.92, 0.92, 0.95, 0.88]) + translate([0, case_width, body_height + lid_thickness + exploded_view]) + rotate([180, 0, 0]) + lid(); +} + +module insert_coupon() { + spacing = 15; + coupon_length = spacing * len(tripod_coupon_steps) + 8; + coupon_width = 32; + coupon_height = max( + m25_insert_length + insert_melt_relief + 1.2, + tripod_insert_length + insert_melt_relief + 1.2 + ); + difference() { + rounded_box([coupon_length, coupon_width, coupon_height], 3); + for (i = [0 : len(tripod_coupon_steps) - 1]) { + x = 7 + i * spacing; + translate([x, 8, 0]) + insert_pocket( + m25_insert_hole_diameter + m25_coupon_steps[i], + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + coupon_height + ); + translate([x, 23, 0]) + insert_pocket( + tripod_insert_hole_diameter + tripod_coupon_steps[i], + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + coupon_height + ); + } + } +} + +module switch_coupon() { + spacing = 18; + coupon_length = spacing * len(coupon_steps) + 6; + coupon_width = rocker_cutout_size[1] + 6; + coupon_height = lid_thickness; + difference() { + rounded_box([coupon_length, coupon_width, coupon_height], 3); + for (i = [0 : len(coupon_steps) - 1]) + translate([6 + i * spacing, coupon_width / 2, -eps]) + centered_rounded_prism( + rocker_cutout_size + + [coupon_steps[i], coupon_steps[i]], + coupon_height + 2 * eps, + rocker_corner_radius + ); + } +} + +module fit_coupon() { + segment_length = 34; + base_width = 16; + base_height = 6; + cavity_size = [segment_length - 8, base_width - 8]; + male_size = [ + cavity_size[0] - 2 * fit_clearance, + cavity_size[1] - 2 * fit_clearance + ]; + difference() { + rounded_box([segment_length, base_width, base_height], 2); + translate([4, 4, 2]) + cube([segment_length - 8, base_width - 8, base_height]); + } + translate([segment_length + 5, 0, 0]) + union() { + cube([segment_length, base_width, lid_thickness]); + translate([ + 4 + fit_clearance, + 4 + fit_clearance, + lid_thickness - eps + ]) + linear_extrude(height = lid_lip_depth + eps) + difference() { + rounded_rect_2d(male_size, 1); + translate([lid_lip_thickness, lid_lip_thickness]) + rounded_rect_2d([ + male_size[0] - 2 * lid_lip_thickness, + male_size[1] - 2 * lid_lip_thickness + ], 0.5); + } + } +} + +if (part == "assembly") + assembly(); +else if (part == "body") + body(); +else if (part == "lid") + lid(); +else if (part == "lid_base") + lid_base(); +else if (part == "lid_accent") + lid_accent(); +else if (part == "buttons") + buttons(); +else if (part == "insert_coupon") + insert_coupon(); +else if (part == "switch_coupon") + switch_coupon(); +else if (part == "fit_coupon") + fit_coupon(); +else + assert(false, str("Unknown part: ", part)); diff --git a/cases/attitude-w-battery/source/render_multicolor_lid.py b/cases/attitude-w-battery/source/render_multicolor_lid.py new file mode 100644 index 0000000..05c8a58 --- /dev/null +++ b/cases/attitude-w-battery/source/render_multicolor_lid.py @@ -0,0 +1,101 @@ +"""Render aligned lid-base and accent STLs with distinct materials.""" + +import os +import sys +import math + +import bpy +from mathutils import Matrix, Vector + + +def import_mesh(path, name, color): + bpy.ops.wm.stl_import(filepath=os.path.abspath(path)) + model = bpy.context.selected_objects[0] + model.name = name + material = bpy.data.materials.new(f"{name} material") + material.diffuse_color = color + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + return model + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def main(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (4, 5): + raise SystemExit( + "expected: lid-base.stl lid-accent.stl output-directory basename [orientation]" + ) + base_path, accent_path, output_dir, basename = args[:4] + orientation = args[4] if len(args) == 5 else "usb_left" + angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270} + if orientation not in angles: + raise SystemExit(f"unknown lid orientation: {orientation}") + os.makedirs(output_dir, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + models = [ + import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)), + import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)), + ] + # Lid exports are print-oriented with the decorated exterior face down. + # Turn both aligned parts over and present the configured USB edge in its + # named position so previews inspect the finished face as the user will. + for model in models: + model.matrix_world = ( + Matrix.Rotation(math.radians(angles[orientation]), 4, "Z") + @ Matrix.Rotation(math.pi, 4, "X") + ) + bpy.context.view_layer.update() + + corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.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 + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + 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) + + 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.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" + + for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items(): + camera.location = center + Vector(direction).normalized() * span * 2.2 + look_at(camera, center) + scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png") + bpy.ops.render.render(write_still=True) + + +if __name__ == "__main__": + main() diff --git a/cases/attitude-w-battery/source/render_stl_previews.py b/cases/attitude-w-battery/source/render_stl_previews.py new file mode 100644 index 0000000..c8675ac --- /dev/null +++ b/cases/attitude-w-battery/source/render_stl_previews.py @@ -0,0 +1,127 @@ +"""Render six inspection views of an STL using Blender. + +Usage: + blender --background --python tools/render_stl_previews.py -- \ + input.stl output-directory basename [comma-separated-views] +""" + +import os +import sys + +import bpy +from mathutils import Vector + + +def arguments(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (3, 4): + raise SystemExit( + "expected: input.stl output-directory basename [comma-separated-views]" + ) + requested_views = args[3].split(",") if len(args) == 4 else None + return args[0], args[1], args[2], requested_views + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def render_view(camera, center, span, name, direction, output_dir, basename): + distance = span * 2.2 + camera.location = Vector(center) + Vector(direction).normalized() * distance + look_at(camera, center) + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + bpy.context.scene.render.filepath = os.path.join( + output_dir, f"{basename}_{name}.png" + ) + bpy.ops.render.render(write_still=True) + + +def main(): + input_path, output_dir, basename, requested_views = arguments() + os.makedirs(output_dir, exist_ok=True) + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path)) + model = bpy.context.selected_objects[0] + model.name = "Feather case" + + material = bpy.data.materials.new("Case material") + material.diffuse_color = (0.16, 0.42, 0.72, 1.0) + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + + corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box] + mins = Vector([min(v[i] for v in corners) for i in range(3)]) + maxs = Vector([max(v[i] for v in corners) for i in range(3)]) + center = (mins + maxs) / 2 + span = max(maxs - mins) + + world = bpy.data.worlds.new("Inspection world") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.035, + 0.04, + 0.05, + 1, + ) + world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5 + bpy.context.scene.world = world + + camera_data = bpy.data.cameras.new("Camera") + camera = bpy.data.objects.new("Camera", camera_data) + bpy.context.collection.objects.link(camera) + bpy.context.scene.camera = camera + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + key_data.shape = "DISK" + key_data.size = span + key = bpy.data.objects.new("Key", key_data) + key.location = center + Vector((span, -span, span * 1.5)) + bpy.context.collection.objects.link(key) + + fill_data = bpy.data.lights.new("Fill", type="AREA") + fill_data.energy = 500 + fill_data.size = span + fill = bpy.data.objects.new("Fill", fill_data) + fill.location = center + Vector((-span, span, span)) + bpy.context.collection.objects.link(fill) + + scene = bpy.context.scene + scene.render.engine = "BLENDER_WORKBENCH" + scene.render.resolution_x = 800 + scene.render.resolution_y = 600 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.film_transparent = False + scene.display.shading.light = "STUDIO" + scene.display.shading.color_type = "MATERIAL" + scene.display.shading.show_shadows = True + scene.display.shading.show_cavity = True + scene.display.shading.cavity_type = "BOTH" + scene.display.shading.curvature_ridge_factor = 1.5 + scene.display.shading.curvature_valley_factor = 1.2 + + views = { + "iso": (1, -1, 0.8), + "front": (-1, 0, 0), + "back": (1, 0, 0), + "left": (0, -1, 0), + "right": (0, 1, 0), + "top": (0, 0, 1), + } + selected = requested_views or list(views) + unknown = sorted(set(selected) - set(views)) + if unknown: + raise SystemExit(f"unknown views: {', '.join(unknown)}") + for name in selected: + direction = views[name] + render_view(camera, center, span, name, direction, output_dir, basename) + + +if __name__ == "__main__": + main() diff --git a/cases/attitude-w-battery/tools/package_multimaterial_3mf.py b/cases/attitude-w-battery/tools/package_multimaterial_3mf.py new file mode 100644 index 0000000..7d8978a --- /dev/null +++ b/cases/attitude-w-battery/tools/package_multimaterial_3mf.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Combine aligned lid meshes into one slicer-friendly multipart 3MF.""" + +from __future__ import annotations + +import argparse +import copy +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + + +CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +ET.register_namespace("", CORE) + +CONTENT_TYPES_XML = b''' + + + + +''' + +RELATIONSHIPS_XML = b''' + + + +''' + + +def load_mesh(path: Path) -> ET.Element: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("3D/3dmodel.model")) + mesh = root.find(f".//{{{CORE}}}mesh") + if mesh is None: + raise ValueError(f"{path} does not contain a 3MF mesh") + return copy.deepcopy(mesh) + + +def model_xml(base: Path, accent: Path, title: str) -> bytes: + root = ET.Element( + f"{{{CORE}}}model", + { + "unit": "millimeter", + "{http://www.w3.org/XML/1998/namespace}lang": "en-US", + }, + ) + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = ( + "feather-case-wizard" + ) + resources = ET.SubElement(root, f"{{{CORE}}}resources") + materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"}) + ET.SubElement( + materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"} + ) + ET.SubElement( + materials, + f"{{{CORE}}}base", + {"name": "Accent inlays", "displaycolor": "#F97316"}, + ) + + for object_id, name, source in ( + (2, "Lid base — Filament 1", base), + (3, "Accent inlays — Filament 2", accent), + ): + obj = ET.SubElement( + resources, + f"{{{CORE}}}object", + { + "id": str(object_id), + "name": name, + "type": "model", + "pid": "1", + "pindex": str(object_id - 2), + }, + ) + obj.append(load_mesh(source)) + + assembly = ET.SubElement( + resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"} + ) + components = ET.SubElement(assembly, f"{{{CORE}}}components") + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"}) + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"}) + build = ET.SubElement(root, f"{{{CORE}}}build") + ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def bambu_model_settings_xml(title: str) -> bytes: + root = ET.Element("config") + obj = ET.SubElement(root, "object", {"id": "4"}) + ET.SubElement(obj, "metadata", {"key": "name", "value": title}) + for part_id, name, extruder in ( + (2, "Lid base — Filament 1", 1), + (3, "Accent inlays — Filament 2", 2), + ): + part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"}) + ET.SubElement(part, "metadata", {"key": "name", "value": name}) + ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)}) + ET.SubElement(root, "assemble") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("base", type=Path) + parser.add_argument("accent", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--title", required=True) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive: + write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML) + write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML) + write_entry( + archive, + "3D/3dmodel.model", + model_xml(args.base, args.accent, args.title), + ) + write_entry( + archive, + "Metadata/model_settings.config", + bambu_model_settings_xml(args.title), + ) + + +if __name__ == "__main__": + main() diff --git a/cases/gps/README.md b/cases/gps/README.md index 0ec88be..33d81f0 100644 --- a/cases/gps/README.md +++ b/cases/gps/README.md @@ -6,14 +6,14 @@ Generated by the Feather Case Wizard. - FeatherWings: 1 - Battery compartment: yes - 1/4-20 insert: yes -- Approximate exterior: 93.9 × 57.3 × 41.8 mm +- Approximate exterior: 88.6 × 59.1 × 40.4 mm - Formats: 3mf -- Parts: `body`, `lid`, `insert_coupon`, `switch_coupon`, `fit_coupon` +- Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon` - 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. +OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`, +`BLENDER`, or `PYTHON` to override their executable paths. To open the saved Customizer values manually: @@ -23,3 +23,9 @@ 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. + +For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one +multipart object. Assign “Lid base — Filament 1” and “Accent inlays — +Filament 2” to the desired filaments. `lid.3mf` is the complete +single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also +provided separately. diff --git a/cases/gps/build.rb b/cases/gps/build.rb index 3deb8ab..01a7e8b 100755 --- a/cases/gps/build.rb +++ b/cases/gps/build.rb @@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [], permitted_symbols: [], aliases: false) openscad = executable("OPENSCAD", "openscad") blender = executable("BLENDER", "blender") +python = executable("PYTHON", "python3") 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") ) +artwork = !parameters.fetch("lid_label", "").empty? || + parameters.fetch("lid_logo_enabled", false) +abort "Python 3 not found; install it or set PYTHON" if artwork && !python parts = %w[body lid] + config.fetch("outputs").fetch("coupons") +parts.concat(%w[lid_base lid_accent]) if artwork buttons = %w[button_a button_b button_c button_reset] if parameters["top_profile"] == "oled128x64" && buttons.any? { |name| parameters[name] } @@ -80,6 +85,16 @@ begin puts "Exporting #{part}.#{format}…" export.call(directory.join("#{part}.#{format}"), part) end + if artwork && format == "3mf" + puts "Packaging lid_multicolor.3mf…" + run!( + python, ROOT.join("tools/package_multimaterial_3mf.py").to_s, + directory.join("lid_base.3mf").to_s, + directory.join("lid_accent.3mf").to_s, + directory.join("lid_multicolor.3mf").to_s, + "--title", "#{config.fetch("name")} Lid" + ) + end end assembly = scratch.join("assembly.stl") @@ -97,6 +112,16 @@ begin run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", mesh.to_s, previews.to_s, part, "iso", env: render_env) end + if artwork + puts "Rendering multicolor lid preview…" + run!( + blender, "--background", "-noaudio", "--python", + ROOT.join("source/render_multicolor_lid.py").to_s, "--", + scratch.join("lid_base.stl").to_s, + scratch.join("lid_accent.stl").to_s, + previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env + ) + end backup = ROOT.join(".build-old-#{Process.pid}") FileUtils.mkdir_p(backup) diff --git a/cases/gps/case.yml b/cases/gps/case.yml index a0d623b..94f04e1 100644 --- a/cases/gps/case.yml +++ b/cases/gps/case.yml @@ -3,7 +3,7 @@ schema_version: 1 name: GPS slug: gps generator: - sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 + sha256: 168dd3c04ca4015b07fe6c0295e7d4bb43969671f5f3552ffab31d8deffc3024 created_with: feather-case-wizard parameters: top_profile: gps @@ -36,20 +36,25 @@ parameters: 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 + battery_partition_thickness: 1.8 + rocker_cutout_size: + - 12.9 + - 19.4 + rocker_corner_radius: 0.8 + rocker_body_size: + - 15.0 + - 21.0 + - 21.0 + rocker_artwork_gap: 2.0 tripod_insert_enabled: true - tripod_insert_length: 6.4 - tripod_insert_hole_diameter: 8.8 + tripod_insert_length: 5.0 + tripod_insert_hole_diameter: 7.2 + tripod_insert_outer_diameter: 8.0 tripod_thread_clearance_diameter: 6.8 tripod_boss_diameter: 14.0 m25_insert_length: 4.0 - m25_insert_hole_diameter: 3.7 + m25_insert_hole_diameter: 3.1 + m25_insert_outer_diameter: 3.5 m25_screw_clearance: 2.8 m25_boss_diameter: 7.0 insert_entry_chamfer: 0.6 @@ -61,13 +66,27 @@ parameters: board_bottom_clearance: 5.4 board_side_clearance: 7.0 board_rear_clearance: 4.0 - usb_board_wall_gap: 1.2 + usb_board_wall_gap: 2.0 lid_lip_depth: 2.4 lid_lip_thickness: 1.2 fit_clearance: 0.35 + cutout_min_web: 1.2 hole_compensation: 0.2 lid_screw_head_diameter: 5.2 lid_screw_head_height: 2.5 + usb_opening_enabled: true + usb_guide_enabled: true + usb_connector_overhang: 2.0 + usb_guide_wall_thickness: 1.2 + usb_guide_end_gap: 1.0 + lid_label: GPS + lid_logo_enabled: true + lid_art_orientation: usb_left + lid_inlay_depth: 0.6 + lid_inlay_fit_clearance: 0.08 + lid_label_size: 5.0 + lid_label_logo_gap: 2.0 + lid_art_margin: 8.5 usb_opening_width: 12.5 usb_opening_height: 7.0 usb_opening_radius: 1.5 diff --git a/cases/gps/parameters.json b/cases/gps/parameters.json index 56144f6..6d275d7 100644 --- a/cases/gps/parameters.json +++ b/cases/gps/parameters.json @@ -27,20 +27,20 @@ "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", + "battery_partition_thickness": "1.8", + "rocker_cutout_size": "[12.9,19.4]", + "rocker_corner_radius": "0.8", + "rocker_body_size": "[15.0,21.0,21.0]", + "rocker_artwork_gap": "2.0", "tripod_insert_enabled": "true", - "tripod_insert_length": "6.4", - "tripod_insert_hole_diameter": "8.8", + "tripod_insert_length": "5.0", + "tripod_insert_hole_diameter": "7.2", + "tripod_insert_outer_diameter": "8.0", "tripod_thread_clearance_diameter": "6.8", "tripod_boss_diameter": "14.0", "m25_insert_length": "4.0", - "m25_insert_hole_diameter": "3.7", + "m25_insert_hole_diameter": "3.1", + "m25_insert_outer_diameter": "3.5", "m25_screw_clearance": "2.8", "m25_boss_diameter": "7.0", "insert_entry_chamfer": "0.6", @@ -52,13 +52,27 @@ "board_bottom_clearance": "5.4", "board_side_clearance": "7.0", "board_rear_clearance": "4.0", - "usb_board_wall_gap": "1.2", + "usb_board_wall_gap": "2.0", "lid_lip_depth": "2.4", "lid_lip_thickness": "1.2", "fit_clearance": "0.35", + "cutout_min_web": "1.2", "hole_compensation": "0.2", "lid_screw_head_diameter": "5.2", "lid_screw_head_height": "2.5", + "usb_opening_enabled": "true", + "usb_guide_enabled": "true", + "usb_connector_overhang": "2.0", + "usb_guide_wall_thickness": "1.2", + "usb_guide_end_gap": "1.0", + "lid_label": "\"GPS\"", + "lid_logo_enabled": "true", + "lid_art_orientation": "\"usb_left\"", + "lid_inlay_depth": "0.6", + "lid_inlay_fit_clearance": "0.08", + "lid_label_size": "5.0", + "lid_label_logo_gap": "2.0", + "lid_art_margin": "8.5", "usb_opening_width": "12.5", "usb_opening_height": "7.0", "usb_opening_radius": "1.5", diff --git a/cases/gps/source/assets/NME-fixed.svg b/cases/gps/source/assets/NME-fixed.svg new file mode 100644 index 0000000..450d6e3 --- /dev/null +++ b/cases/gps/source/assets/NME-fixed.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cases/gps/source/feather_case.scad b/cases/gps/source/feather_case.scad index 5a26c7b..7948af5 100644 --- a/cases/gps/source/feather_case.scad +++ b/cases/gps/source/feather_case.scad @@ -12,7 +12,7 @@ */ /* [Output] */ -part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon] +part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon] exploded_view = 8; // [0:1:20] show_references = true; @@ -47,24 +47,24 @@ 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; +battery_partition_thickness = 1.8; +rocker_cutout_size = [12.9, 19.4]; +rocker_corner_radius = 0.8; +rocker_body_size = [15, 21, 21]; +rocker_artwork_gap = 2; /* [Tripod mount] */ tripod_insert_enabled = false; -tripod_insert_length = 6.4; -tripod_insert_hole_diameter = 8.8; +tripod_insert_length = 5; +tripod_insert_hole_diameter = 7.2; +tripod_insert_outer_diameter = 8; tripod_thread_clearance_diameter = 6.8; tripod_boss_diameter = 14; /* [M2.5 inserts] */ m25_insert_length = 4; -m25_insert_hole_diameter = 3.7; +m25_insert_hole_diameter = 3.1; +m25_insert_outer_diameter = 3.5; m25_screw_clearance = 2.8; m25_boss_diameter = 7; insert_entry_chamfer = 0.6; @@ -78,15 +78,32 @@ corner_radius = 4; board_bottom_clearance = 5.4; board_side_clearance = 7; board_rear_clearance = 4; -usb_board_wall_gap = 1.2; +usb_board_wall_gap = 2; lid_lip_depth = 2.4; lid_lip_thickness = 1.2; fit_clearance = 0.35; +cutout_min_web = 1.2; hole_compensation = 0.2; lid_screw_head_diameter = 5.2; lid_screw_head_height = 2.5; +/* [Two-color lid artwork] */ +lid_label = ""; +lid_logo_enabled = false; +lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear] +lid_inlay_depth = 0.6; // [0.4:0.2:1] +lid_inlay_fit_clearance = 0.08; +lid_label_size = 5; +lid_label_logo_gap = 2; +lid_art_margin = 8.5; +logo_file = "assets/NME-fixed.svg"; + /* [USB-C opening] */ +usb_opening_enabled = true; +usb_guide_enabled = true; +usb_connector_overhang = 2; +usb_guide_wall_thickness = 1.2; +usb_guide_end_gap = 1; usb_opening_width = 12.5; usb_opening_height = 7; usb_opening_radius = 1.5; @@ -100,6 +117,8 @@ extra_cutouts = []; /* [Calibration] */ coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; +m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15]; +tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; $fn = $preview ? 36 : 72; eps = 0.02; @@ -111,6 +130,9 @@ board_thickness = 1.6; mount_x = [2.54, 48.26]; mount_y = [2.54, 20.32]; usb_center_y = 11.43; +jst_center_xy = [10.9855, 19.304]; +jst_body_size = [8, 7.5, 4.5]; +jst_relief_clearance = 0.5; // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates. oled_visible_min = [11.42, 3.78]; @@ -127,11 +149,34 @@ effective_board_clearance = max( board_bottom_clearance, m25_insert_length + insert_melt_relief + 0.6 ); +lid_boss_r = m25_boss_diameter / 2; +lid_boss_wall_overlap = 0.6; +front_lid_boss_center = wall_thickness + lid_boss_r + - lid_boss_wall_overlap; + +// Combined battery/tripod builds need room around the centered insert entry. +combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0; +combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0; board_origin_x = wall_thickness + usb_board_wall_gap; -board_origin_y = wall_thickness + board_side_clearance; +board_origin_y = wall_thickness + board_side_clearance + + combined_tripod_board_gutter; board_bottom_z = effective_floor + effective_board_clearance; board_top_z = board_bottom_z + board_thickness; +usb_center_u = board_origin_y + usb_center_y; +usb_center_z = board_top_z + 1.6 + usb_vertical_offset; +usb_inner_width = usb_opening_width + hole_compensation; +usb_inner_height = usb_opening_height + hole_compensation; +usb_guide_length = max( + 0, + usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap +); +usb_guide_active = usb_opening_enabled && usb_guide_enabled + && usb_guide_length > 0; +usb_reserved_width = usb_inner_width + + (usb_guide_active ? 2 * usb_guide_wall_thickness : 0); +usb_reserved_height = usb_inner_height + + (usb_guide_active ? 2 * usb_guide_wall_thickness : 0); computed_top_pcb_z = board_top_z + wing_count * wing_pitch; top_pcb_z = stack_height_override > 0 @@ -147,19 +192,16 @@ profile_top_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_center_y = partition_y + battery_partition_thickness + + battery_radial_clearance + battery_diameter / 2; +battery_start_x = front_lid_boss_center + lid_boss_r + + battery_end_clearance; battery_end_x = battery_start_x + battery_length; -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 = battery_end_x + battery_end_clearance + + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap; case_length = battery_enabled ? max(case_length_plain, case_length_battery) : case_length_plain; @@ -170,23 +212,70 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; +rocker_center_x = case_length - wall_thickness - fit_clearance + - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; +rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance + + rocker_body_size[1] / 2; + +lid_artwork_enabled = lid_label != "" || lid_logo_enabled; +lid_art_rotation = + lid_art_orientation == "usb_left" ? 0 : + lid_art_orientation == "usb_front" ? 90 : + lid_art_orientation == "usb_right" ? 180 : 270; +lid_art_safe_max_x = battery_enabled + ? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap + : case_length - lid_art_margin; +lid_art_physical_size = [ + lid_art_safe_max_x - lid_art_margin, + case_width - 2 * lid_art_margin +]; +lid_art_center = [ + lid_art_margin + lid_art_physical_size[0] / 2, + case_width / 2 +]; +lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180 + ? lid_art_physical_size + : [lid_art_physical_size[1], lid_art_physical_size[0]]; +lid_art_available_size = lid_art_canvas_size; +lid_label_height = lid_label == "" ? 0 : lid_label_size; +lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0; +logo_native_size = [30, 25.75]; +logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap; +logo_scale = lid_logo_enabled ? min( + lid_art_available_size[0] / logo_native_size[0], + logo_max_height / logo_native_size[1] +) : 0; +logo_size = [logo_native_size[0] * logo_scale, + logo_native_size[1] * logo_scale]; +lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height; +lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2; +lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height + + lid_art_gap + logo_size[1] / 2; + +tripod_center_x = case_length / 2 - combined_tripod_x_shift; +tripod_center_y = case_width / 2; +tripod_entry_radius = tripod_insert_outer_diameter / 2; +tripod_standoff_clearance = min([ + for (mx = mount_x, my = mount_y) + sqrt( + pow(board_origin_x + mx - tripod_center_x, 2) + + pow(board_origin_y + my - tripod_center_y, 2) + ) - tripod_entry_radius - m25_boss_diameter / 2 +]); +tripod_partition_clearance = partition_y + - (tripod_center_y + tripod_entry_radius); + battery_center_z = effective_floor + battery_radial_clearance + battery_diameter / 2; -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 - ) + ? battery_center_z + battery_diameter / 2 + battery_radial_clearance : 0; -body_height = max(electronics_ceiling_z, battery_ceiling_z); +rocker_ceiling_z = battery_enabled + ? effective_floor + rocker_body_size[2] + fit_clearance + : 0; +body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z); -lid_boss_r = m25_boss_diameter / 2; -lid_boss_wall_overlap = 0.6; lid_boss_xy = [ [ wall_thickness + lid_boss_r - lid_boss_wall_overlap, @@ -210,14 +299,86 @@ assert(wing_count >= 1, "wing_count must be at least one"); assert(top_profile == "generic" || top_profile == "gps" || top_profile == "oled128x64", "Unsupported top_profile"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); +assert(battery_partition_thickness >= 1.2, + "battery_partition_thickness is too small for 0.6 mm FDM"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); +assert(lid_art_orientation == "usb_left" + || lid_art_orientation == "usb_front" + || lid_art_orientation == "usb_right" + || lid_art_orientation == "usb_rear", + "Unsupported lid_art_orientation"); +assert(!lid_artwork_enabled || top_profile != "oled128x64", + "Lid artwork is not supported with the OLED profile"); +assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness, + "Lid inlay must be shallower than the lid"); +assert(!lid_artwork_enabled + || (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0), + "Lid artwork margin leaves no printable area"); +assert(!lid_logo_enabled || logo_scale > 0, + "Lid logo has no printable area"); assert(m25_boss_diameter > m25_insert_hole_diameter + 2, "M2.5 boss does not have enough radial material"); +assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, + "M2.5 insert entry must not be smaller than its pilot"); assert(!tripod_insert_enabled || tripod_boss_diameter > tripod_insert_hole_diameter + 3, "Tripod insert boss does not have enough radial material"); +assert(usb_opening_enabled || !usb_guide_enabled, + "USB guide requires the USB opening"); +assert(!tripod_insert_enabled + || tripod_insert_outer_diameter >= tripod_insert_hole_diameter, + "Tripod insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2, + "Tripod insert entry is too close to a Feather standoff"); +assert(!battery_enabled || !tripod_insert_enabled + || tripod_partition_clearance >= 1, + "Tripod insert entry is too close to the battery partition"); +assert(!battery_enabled + || rocker_center_x - rocker_body_size[0] / 2 + >= board_origin_x + feather_length + fit_clearance, + "Rocker body overlaps the Feather PCB"); +assert(!battery_enabled + || rocker_center_y + rocker_body_size[1] / 2 + <= partition_y - fit_clearance, + "Rocker body overlaps the battery partition"); +assert(!battery_enabled + || body_height - rocker_body_size[2] >= effective_floor, + "Rocker body intersects the case floor"); function clamp(v, lo, hi) = min(max(v, lo), hi); +function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4]; +function cutouts_separated(first, second, web) = + abs(first[2] - second[2]) + >= (first[4] + second[4]) / 2 + web + || abs(first[3] - second[3]) + >= (cutout_height(first) + cutout_height(second)) / 2 + web; +function cutout_clear_of_usb(entry, web) = + abs(entry[2] - usb_center_u) + >= (entry[4] + usb_reserved_width) / 2 + web + || abs(entry[3] - usb_center_z) + >= (cutout_height(entry) + usb_reserved_height) / 2 + web; + +module validate_cutout_spacing() { + if (len(extra_cutouts) > 1) + for (first = [0 : len(extra_cutouts) - 2], + second = [first + 1 : len(extra_cutouts) - 1]) + if (extra_cutouts[first][0] == extra_cutouts[second][0]) + assert( + cutouts_separated( + extra_cutouts[first], + extra_cutouts[second], + cutout_min_web + ), + "Custom cutouts are too close on the same wall" + ); + if (usb_opening_enabled) + for (entry = extra_cutouts) + if (entry[0] == "front") + assert( + cutout_clear_of_usb(entry, cutout_min_web), + "Front cutout is too close to the USB opening or guide" + ); +} module rounded_rect_2d(size, radius) { r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); @@ -242,8 +403,8 @@ module rounded_box(size, radius) { rounded_rect_2d([size[0], size[1]], radius); } -module insert_pocket(diameter, depth, entry_z, downward = true) { - bore_d = diameter + hole_compensation; +module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) { + bore_d = diameter; if (downward) { translate([0, 0, entry_z - depth]) cylinder(h = depth + eps, d = bore_d); @@ -251,7 +412,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) { cylinder( h = insert_entry_chamfer + eps, d1 = bore_d, - d2 = bore_d + 2 * insert_entry_chamfer + d2 = entry_diameter ); } else { translate([0, 0, entry_z - eps]) @@ -259,7 +420,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) { translate([0, 0, entry_z - eps]) cylinder( h = insert_entry_chamfer + eps, - d1 = bore_d + 2 * insert_entry_chamfer, + d1 = entry_diameter, d2 = bore_d ); } @@ -301,6 +462,40 @@ module face_circle_cutout(face, u, z, diameter) { rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); } +module x_rounded_prism(x, length, u, z, width, height, radius) { + r = clamp(radius, 0.01, min(width, height) / 2 - 0.01); + hull() + for (yy = [u - width / 2 + r, u + width / 2 - r], + zz = [z - height / 2 + r, z + height / 2 - r]) + translate([x, yy, zz]) + rotate([0, 90, 0]) cylinder(h = length, r = r); +} + +module usb_guide_shell() { + outer_width = usb_inner_width + 2 * usb_guide_wall_thickness; + outer_height = usb_inner_height + 2 * usb_guide_wall_thickness; + difference() { + x_rounded_prism( + wall_thickness - eps, + usb_guide_length + 2 * eps, + usb_center_u, + usb_center_z, + outer_width, + outer_height, + usb_opening_radius + usb_guide_wall_thickness + ); + x_rounded_prism( + wall_thickness - 2 * eps, + usb_guide_length + 4 * eps, + usb_center_u, + usb_center_z, + usb_inner_width, + usb_inner_height, + usb_opening_radius + ); + } +} + module custom_wall_cutout(entry) { face = entry[0]; shape = entry[1]; @@ -368,20 +563,27 @@ module battery_cradle() { battery_end_x - 4 ]; - // Board/battery partition, with a lead-routing notch near the JST end. + // One open-topped front passage provides JST plug access and enough room + // to route the battery lead beneath the Feather PCB beside the lid boss. difference() { translate([wall_thickness, partition_y, effective_floor - eps]) cube([ battery_end_x - wall_thickness + battery_end_clearance, - wall_thickness, + battery_partition_thickness, partition_height - effective_floor + eps ]); translate([ - board_origin_x + 6.5, + wall_thickness - eps, partition_y - eps, effective_floor + 1.5 ]) - cube([9, wall_thickness + 2 * eps, 6]); + cube([ + board_origin_x + jst_center_xy[0] + + (jst_body_size[0] + 2 * jst_relief_clearance) / 2 + - wall_thickness + 2 * eps, + battery_partition_thickness + 2 * eps, + partition_height - effective_floor - 1.5 + eps + ]); } // Three low curved ribs cradle the protected cylindrical pack. @@ -412,29 +614,16 @@ module battery_cradle() { battery_end_x + battery_end_clearance ]) translate([ - x, + x - eps, battery_center_y - battery_diameter / 2 - battery_radial_clearance, effective_floor - eps ]) cube([ - wall_thickness, + wall_thickness + 2 * eps, 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() { @@ -453,8 +642,10 @@ module body_positive() { if (battery_enabled) battery_cradle(); if (tripod_insert_enabled) - translate([case_length / 2, case_width / 2, 0]) + translate([tripod_center_x, tripod_center_y, 0]) cylinder(h = effective_floor, d = tripod_boss_diameter); + if (usb_guide_active) + usb_guide_shell(); } } @@ -464,6 +655,7 @@ module body_cutouts() { translate([board_origin_x + mx, board_origin_y + my, 0]) insert_pocket( m25_insert_hole_diameter, + m25_insert_outer_diameter, m25_insert_length + insert_melt_relief, board_bottom_z ); @@ -473,44 +665,24 @@ module body_cutouts() { translate([p[0], p[1], 0]) insert_pocket( m25_insert_hole_diameter, + m25_insert_outer_diameter, m25_insert_length + insert_melt_relief, body_height ); // USB-C cable opening. Connector center is from the official ESP32 V2 PCB. - usb_center_z = board_top_z + 1.6 + usb_vertical_offset; - face_rounded_cutout( - "front", - board_origin_y + usb_center_y, - usb_center_z, - usb_opening_width + hole_compensation, - usb_opening_height + hole_compensation, - usb_opening_radius - ); - - if (battery_enabled) { - face_circle_cutout( - "right", - switch_center_x, - switch_center_z, - switch_hole_diameter + switch_hole_compensation + if (usb_opening_enabled) + face_rounded_cutout( + "front", + usb_center_u, + usb_center_z, + usb_inner_width, + usb_inner_height, + usb_opening_radius ); - // 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([tripod_center_x, tripod_center_y, 0]) { translate([0, 0, -eps]) cylinder( h = effective_floor + 2 * eps, @@ -518,6 +690,7 @@ module body_cutouts() { ); insert_pocket( tripod_insert_hole_diameter, + tripod_insert_outer_diameter, tripod_insert_length + insert_melt_relief, effective_floor ); @@ -611,6 +784,13 @@ module oled_button_lid_cutout(xy) { module lid_cutouts() { // Through holes plus shallow counterbores for flush M2.5 socket heads. for (p = lid_boss_xy) { + // Scallop only the locating lip so it can pass over the full-height + // insert boss while leaving the lid plate supported by the boss top. + translate([p[0], p[1], lid_thickness - eps]) + cylinder( + h = lid_lip_depth + 2 * eps, + d = m25_boss_diameter + 2 * fit_clearance + ); translate([p[0], p[1], -eps]) cylinder( h = lid_thickness + lid_lip_depth + 2 * eps, @@ -623,6 +803,14 @@ module lid_cutouts() { ); } + if (battery_enabled) + translate([rocker_center_x, rocker_center_y, -eps]) + centered_rounded_prism( + rocker_cutout_size, + lid_thickness + lid_lip_depth + 2 * eps, + rocker_corner_radius + ); + if (top_profile == "oled128x64") { beveled_oled_window(); if (button_a) oled_button_lid_cutout(oled_button_a_xy); @@ -632,13 +820,55 @@ module lid_cutouts() { } } -module lid() { +module lid_artwork_nominal_2d() { + translate(lid_art_center) + rotate(lid_art_rotation) + // The lid is modeled exterior-face-down; mirror the artwork so it + // reads normally after the lid is turned into service position. + mirror([0, 1, 0]) { + if (lid_logo_enabled) + translate([0, lid_logo_center_y]) + resize(logo_size) + import(logo_file, center = true); + if (lid_label != "") + translate([0, lid_label_center_y]) + text( + lid_label, + size = lid_label_size, + halign = "center", + valign = "center", + font = "DejaVu Sans:style=Bold" + ); + } +} + +module lid_artwork_2d(inset = 0) { + offset(delta = inset) + lid_artwork_nominal_2d(); +} + +module lid_base() { difference() { lid_plate_positive(); lid_cutouts(); + if (lid_artwork_enabled) + translate([0, 0, -eps]) + linear_extrude(height = lid_inlay_depth + eps) + lid_artwork_2d(lid_inlay_fit_clearance); } } +module lid_accent() { + if (lid_artwork_enabled) + linear_extrude(height = lid_inlay_depth) + lid_artwork_2d(); +} + +module lid() { + color([0.92, 0.92, 0.95]) lid_base(); + color([0.95, 0.42, 0.08]) lid_accent(); +} + module one_oled_button() { recess_depth = button_flange_thickness + button_travel; stem_height = lid_thickness - recess_depth + button_projection; @@ -678,6 +908,13 @@ module reference_board() { color([0.05, 0.25, 0.55, 0.75]) translate([board_origin_x, board_origin_y, board_bottom_z]) rounded_box([feather_length, feather_width, board_thickness], 2.54); + color([0.92, 0.92, 0.86, 0.9]) + translate([ + board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2, + board_origin_y + jst_center_xy[1] - 4.5, + board_top_z + ]) + cube(jst_body_size); color([0.12, 0.45, 0.75, 0.45]) for (i = [1 : wing_count]) translate([ @@ -744,19 +981,20 @@ module reference_battery_and_switch() { 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]) + color([0.08, 0.08, 0.08, 0.85]) translate([ - switch_center_x, - case_width - wall_thickness, - switch_center_z + rocker_center_x - rocker_body_size[0] / 2, + rocker_center_y - rocker_body_size[1] / 2, + body_height + exploded_view - rocker_body_size[2] ]) - 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); - } + cube(rocker_body_size); + color([0.12, 0.12, 0.12, 0.95]) + translate([ + rocker_center_x - rocker_cutout_size[0] / 2, + rocker_center_y - rocker_cutout_size[1] / 2, + body_height + exploded_view + lid_thickness + ]) + cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); } } @@ -774,13 +1012,9 @@ module assembly() { 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_length = spacing * len(tripod_coupon_steps) + 8; coupon_width = 32; coupon_height = max( m25_insert_length + insert_melt_relief + 1.2, @@ -788,18 +1022,22 @@ module insert_coupon() { ); difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) { + for (i = [0 : len(tripod_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 - ); + translate([x, 8, 0]) + insert_pocket( + m25_insert_hole_diameter + m25_coupon_steps[i], + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + coupon_height + ); + translate([x, 23, 0]) + insert_pocket( + tripod_insert_hole_diameter + tripod_coupon_steps[i], + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + coupon_height + ); } } } @@ -807,15 +1045,17 @@ module insert_coupon() { module switch_coupon() { spacing = 18; coupon_length = spacing * len(coupon_steps) + 6; - coupon_width = 24; - coupon_height = wall_thickness; + coupon_width = rocker_cutout_size[1] + 6; + coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); for (i = [0 : len(coupon_steps) - 1]) translate([6 + i * spacing, coupon_width / 2, -eps]) - cylinder( - h = coupon_height + 2 * eps, - d = switch_hole_diameter + coupon_steps[i] + centered_rounded_prism( + rocker_cutout_size + + [coupon_steps[i], coupon_steps[i]], + coupon_height + 2 * eps, + rocker_corner_radius ); } } @@ -854,12 +1094,18 @@ module fit_coupon() { } } +validate_cutout_spacing(); + if (part == "assembly") assembly(); else if (part == "body") body(); else if (part == "lid") lid(); +else if (part == "lid_base") + lid_base(); +else if (part == "lid_accent") + lid_accent(); else if (part == "buttons") buttons(); else if (part == "insert_coupon") diff --git a/cases/gps/source/render_multicolor_lid.py b/cases/gps/source/render_multicolor_lid.py new file mode 100644 index 0000000..05c8a58 --- /dev/null +++ b/cases/gps/source/render_multicolor_lid.py @@ -0,0 +1,101 @@ +"""Render aligned lid-base and accent STLs with distinct materials.""" + +import os +import sys +import math + +import bpy +from mathutils import Matrix, Vector + + +def import_mesh(path, name, color): + bpy.ops.wm.stl_import(filepath=os.path.abspath(path)) + model = bpy.context.selected_objects[0] + model.name = name + material = bpy.data.materials.new(f"{name} material") + material.diffuse_color = color + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + return model + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def main(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (4, 5): + raise SystemExit( + "expected: lid-base.stl lid-accent.stl output-directory basename [orientation]" + ) + base_path, accent_path, output_dir, basename = args[:4] + orientation = args[4] if len(args) == 5 else "usb_left" + angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270} + if orientation not in angles: + raise SystemExit(f"unknown lid orientation: {orientation}") + os.makedirs(output_dir, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + models = [ + import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)), + import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)), + ] + # Lid exports are print-oriented with the decorated exterior face down. + # Turn both aligned parts over and present the configured USB edge in its + # named position so previews inspect the finished face as the user will. + for model in models: + model.matrix_world = ( + Matrix.Rotation(math.radians(angles[orientation]), 4, "Z") + @ Matrix.Rotation(math.pi, 4, "X") + ) + bpy.context.view_layer.update() + + corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.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 + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + 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) + + 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.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" + + for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items(): + camera.location = center + Vector(direction).normalized() * span * 2.2 + look_at(camera, center) + scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png") + bpy.ops.render.render(write_still=True) + + +if __name__ == "__main__": + main() diff --git a/cases/gps/tools/package_multimaterial_3mf.py b/cases/gps/tools/package_multimaterial_3mf.py new file mode 100644 index 0000000..7d8978a --- /dev/null +++ b/cases/gps/tools/package_multimaterial_3mf.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Combine aligned lid meshes into one slicer-friendly multipart 3MF.""" + +from __future__ import annotations + +import argparse +import copy +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + + +CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +ET.register_namespace("", CORE) + +CONTENT_TYPES_XML = b''' + + + + +''' + +RELATIONSHIPS_XML = b''' + + + +''' + + +def load_mesh(path: Path) -> ET.Element: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("3D/3dmodel.model")) + mesh = root.find(f".//{{{CORE}}}mesh") + if mesh is None: + raise ValueError(f"{path} does not contain a 3MF mesh") + return copy.deepcopy(mesh) + + +def model_xml(base: Path, accent: Path, title: str) -> bytes: + root = ET.Element( + f"{{{CORE}}}model", + { + "unit": "millimeter", + "{http://www.w3.org/XML/1998/namespace}lang": "en-US", + }, + ) + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = ( + "feather-case-wizard" + ) + resources = ET.SubElement(root, f"{{{CORE}}}resources") + materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"}) + ET.SubElement( + materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"} + ) + ET.SubElement( + materials, + f"{{{CORE}}}base", + {"name": "Accent inlays", "displaycolor": "#F97316"}, + ) + + for object_id, name, source in ( + (2, "Lid base — Filament 1", base), + (3, "Accent inlays — Filament 2", accent), + ): + obj = ET.SubElement( + resources, + f"{{{CORE}}}object", + { + "id": str(object_id), + "name": name, + "type": "model", + "pid": "1", + "pindex": str(object_id - 2), + }, + ) + obj.append(load_mesh(source)) + + assembly = ET.SubElement( + resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"} + ) + components = ET.SubElement(assembly, f"{{{CORE}}}components") + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"}) + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"}) + build = ET.SubElement(root, f"{{{CORE}}}build") + ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def bambu_model_settings_xml(title: str) -> bytes: + root = ET.Element("config") + obj = ET.SubElement(root, "object", {"id": "4"}) + ET.SubElement(obj, "metadata", {"key": "name", "value": title}) + for part_id, name, extruder in ( + (2, "Lid base — Filament 1", 1), + (3, "Accent inlays — Filament 2", 2), + ): + part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"}) + ET.SubElement(part, "metadata", {"key": "name", "value": name}) + ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)}) + ET.SubElement(root, "assemble") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("base", type=Path) + parser.add_argument("accent", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--title", required=True) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive: + write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML) + write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML) + write_entry( + archive, + "3D/3dmodel.model", + model_xml(args.base, args.accent, args.title), + ) + write_entry( + archive, + "Metadata/model_settings.config", + bambu_model_settings_xml(args.title), + ) + + +if __name__ == "__main__": + main() diff --git a/feather_case.scad b/feather_case.scad index 30ac230..7948af5 100644 --- a/feather_case.scad +++ b/feather_case.scad @@ -12,7 +12,7 @@ */ /* [Output] */ -part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon] +part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon] exploded_view = 8; // [0:1:20] show_references = true; @@ -47,24 +47,24 @@ 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; +battery_partition_thickness = 1.8; +rocker_cutout_size = [12.9, 19.4]; +rocker_corner_radius = 0.8; +rocker_body_size = [15, 21, 21]; +rocker_artwork_gap = 2; /* [Tripod mount] */ tripod_insert_enabled = false; -tripod_insert_length = 6.4; -tripod_insert_hole_diameter = 8.8; +tripod_insert_length = 5; +tripod_insert_hole_diameter = 7.2; +tripod_insert_outer_diameter = 8; tripod_thread_clearance_diameter = 6.8; tripod_boss_diameter = 14; /* [M2.5 inserts] */ m25_insert_length = 4; -m25_insert_hole_diameter = 3.7; +m25_insert_hole_diameter = 3.1; +m25_insert_outer_diameter = 3.5; m25_screw_clearance = 2.8; m25_boss_diameter = 7; insert_entry_chamfer = 0.6; @@ -87,6 +87,17 @@ hole_compensation = 0.2; lid_screw_head_diameter = 5.2; lid_screw_head_height = 2.5; +/* [Two-color lid artwork] */ +lid_label = ""; +lid_logo_enabled = false; +lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear] +lid_inlay_depth = 0.6; // [0.4:0.2:1] +lid_inlay_fit_clearance = 0.08; +lid_label_size = 5; +lid_label_logo_gap = 2; +lid_art_margin = 8.5; +logo_file = "assets/NME-fixed.svg"; + /* [USB-C opening] */ usb_opening_enabled = true; usb_guide_enabled = true; @@ -106,6 +117,8 @@ extra_cutouts = []; /* [Calibration] */ coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; +m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15]; +tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; $fn = $preview ? 36 : 72; eps = 0.02; @@ -117,6 +130,9 @@ board_thickness = 1.6; mount_x = [2.54, 48.26]; mount_y = [2.54, 20.32]; usb_center_y = 11.43; +jst_center_xy = [10.9855, 19.304]; +jst_body_size = [8, 7.5, 4.5]; +jst_relief_clearance = 0.5; // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates. oled_visible_min = [11.42, 3.78]; @@ -133,9 +149,18 @@ effective_board_clearance = max( board_bottom_clearance, m25_insert_length + insert_melt_relief + 0.6 ); +lid_boss_r = m25_boss_diameter / 2; +lid_boss_wall_overlap = 0.6; +front_lid_boss_center = wall_thickness + lid_boss_r + - lid_boss_wall_overlap; + +// Combined battery/tripod builds need room around the centered insert entry. +combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0; +combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0; board_origin_x = wall_thickness + usb_board_wall_gap; -board_origin_y = wall_thickness + board_side_clearance; +board_origin_y = wall_thickness + board_side_clearance + + combined_tripod_board_gutter; board_bottom_z = effective_floor + effective_board_clearance; board_top_z = board_bottom_z + board_thickness; usb_center_u = board_origin_y + usb_center_y; @@ -167,19 +192,16 @@ profile_top_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_center_y = partition_y + battery_partition_thickness + + battery_radial_clearance + battery_diameter / 2; +battery_start_x = front_lid_boss_center + lid_boss_r + + battery_end_clearance; battery_end_x = battery_start_x + battery_length; -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 = battery_end_x + battery_end_clearance + + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap; case_length = battery_enabled ? max(case_length_plain, case_length_battery) : case_length_plain; @@ -190,23 +212,70 @@ case_width_battery = battery_center_y + battery_diameter / 2 case_width = battery_enabled ? max(case_width_plain, case_width_battery) : case_width_plain; +rocker_center_x = case_length - wall_thickness - fit_clearance + - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; +rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance + + rocker_body_size[1] / 2; + +lid_artwork_enabled = lid_label != "" || lid_logo_enabled; +lid_art_rotation = + lid_art_orientation == "usb_left" ? 0 : + lid_art_orientation == "usb_front" ? 90 : + lid_art_orientation == "usb_right" ? 180 : 270; +lid_art_safe_max_x = battery_enabled + ? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap + : case_length - lid_art_margin; +lid_art_physical_size = [ + lid_art_safe_max_x - lid_art_margin, + case_width - 2 * lid_art_margin +]; +lid_art_center = [ + lid_art_margin + lid_art_physical_size[0] / 2, + case_width / 2 +]; +lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180 + ? lid_art_physical_size + : [lid_art_physical_size[1], lid_art_physical_size[0]]; +lid_art_available_size = lid_art_canvas_size; +lid_label_height = lid_label == "" ? 0 : lid_label_size; +lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0; +logo_native_size = [30, 25.75]; +logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap; +logo_scale = lid_logo_enabled ? min( + lid_art_available_size[0] / logo_native_size[0], + logo_max_height / logo_native_size[1] +) : 0; +logo_size = [logo_native_size[0] * logo_scale, + logo_native_size[1] * logo_scale]; +lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height; +lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2; +lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height + + lid_art_gap + logo_size[1] / 2; + +tripod_center_x = case_length / 2 - combined_tripod_x_shift; +tripod_center_y = case_width / 2; +tripod_entry_radius = tripod_insert_outer_diameter / 2; +tripod_standoff_clearance = min([ + for (mx = mount_x, my = mount_y) + sqrt( + pow(board_origin_x + mx - tripod_center_x, 2) + + pow(board_origin_y + my - tripod_center_y, 2) + ) - tripod_entry_radius - m25_boss_diameter / 2 +]); +tripod_partition_clearance = partition_y + - (tripod_center_y + tripod_entry_radius); + battery_center_z = effective_floor + battery_radial_clearance + battery_diameter / 2; -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 - ) + ? battery_center_z + battery_diameter / 2 + battery_radial_clearance : 0; -body_height = max(electronics_ceiling_z, battery_ceiling_z); +rocker_ceiling_z = battery_enabled + ? effective_floor + rocker_body_size[2] + fit_clearance + : 0; +body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z); -lid_boss_r = m25_boss_diameter / 2; -lid_boss_wall_overlap = 0.6; lid_boss_xy = [ [ wall_thickness + lid_boss_r - lid_boss_wall_overlap, @@ -230,14 +299,51 @@ assert(wing_count >= 1, "wing_count must be at least one"); assert(top_profile == "generic" || top_profile == "gps" || top_profile == "oled128x64", "Unsupported top_profile"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); +assert(battery_partition_thickness >= 1.2, + "battery_partition_thickness is too small for 0.6 mm FDM"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); +assert(lid_art_orientation == "usb_left" + || lid_art_orientation == "usb_front" + || lid_art_orientation == "usb_right" + || lid_art_orientation == "usb_rear", + "Unsupported lid_art_orientation"); +assert(!lid_artwork_enabled || top_profile != "oled128x64", + "Lid artwork is not supported with the OLED profile"); +assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness, + "Lid inlay must be shallower than the lid"); +assert(!lid_artwork_enabled + || (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0), + "Lid artwork margin leaves no printable area"); +assert(!lid_logo_enabled || logo_scale > 0, + "Lid logo has no printable area"); assert(m25_boss_diameter > m25_insert_hole_diameter + 2, "M2.5 boss does not have enough radial material"); +assert(m25_insert_outer_diameter >= m25_insert_hole_diameter, + "M2.5 insert entry must not be smaller than its pilot"); assert(!tripod_insert_enabled || tripod_boss_diameter > tripod_insert_hole_diameter + 3, "Tripod insert boss does not have enough radial material"); assert(usb_opening_enabled || !usb_guide_enabled, "USB guide requires the USB opening"); +assert(!tripod_insert_enabled + || tripod_insert_outer_diameter >= tripod_insert_hole_diameter, + "Tripod insert entry must not be smaller than its pilot"); +assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2, + "Tripod insert entry is too close to a Feather standoff"); +assert(!battery_enabled || !tripod_insert_enabled + || tripod_partition_clearance >= 1, + "Tripod insert entry is too close to the battery partition"); +assert(!battery_enabled + || rocker_center_x - rocker_body_size[0] / 2 + >= board_origin_x + feather_length + fit_clearance, + "Rocker body overlaps the Feather PCB"); +assert(!battery_enabled + || rocker_center_y + rocker_body_size[1] / 2 + <= partition_y - fit_clearance, + "Rocker body overlaps the battery partition"); +assert(!battery_enabled + || body_height - rocker_body_size[2] >= effective_floor, + "Rocker body intersects the case floor"); function clamp(v, lo, hi) = min(max(v, lo), hi); function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4]; @@ -297,8 +403,8 @@ module rounded_box(size, radius) { rounded_rect_2d([size[0], size[1]], radius); } -module insert_pocket(diameter, depth, entry_z, downward = true) { - bore_d = diameter + hole_compensation; +module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) { + bore_d = diameter; if (downward) { translate([0, 0, entry_z - depth]) cylinder(h = depth + eps, d = bore_d); @@ -306,7 +412,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) { cylinder( h = insert_entry_chamfer + eps, d1 = bore_d, - d2 = bore_d + 2 * insert_entry_chamfer + d2 = entry_diameter ); } else { translate([0, 0, entry_z - eps]) @@ -314,7 +420,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) { translate([0, 0, entry_z - eps]) cylinder( h = insert_entry_chamfer + eps, - d1 = bore_d + 2 * insert_entry_chamfer, + d1 = entry_diameter, d2 = bore_d ); } @@ -457,20 +563,27 @@ module battery_cradle() { battery_end_x - 4 ]; - // Board/battery partition, with a lead-routing notch near the JST end. + // One open-topped front passage provides JST plug access and enough room + // to route the battery lead beneath the Feather PCB beside the lid boss. difference() { translate([wall_thickness, partition_y, effective_floor - eps]) cube([ battery_end_x - wall_thickness + battery_end_clearance, - wall_thickness, + battery_partition_thickness, partition_height - effective_floor + eps ]); translate([ - board_origin_x + 6.5, + wall_thickness - eps, partition_y - eps, effective_floor + 1.5 ]) - cube([9, wall_thickness + 2 * eps, 6]); + cube([ + board_origin_x + jst_center_xy[0] + + (jst_body_size[0] + 2 * jst_relief_clearance) / 2 + - wall_thickness + 2 * eps, + battery_partition_thickness + 2 * eps, + partition_height - effective_floor - 1.5 + eps + ]); } // Three low curved ribs cradle the protected cylindrical pack. @@ -501,29 +614,16 @@ module battery_cradle() { battery_end_x + battery_end_clearance ]) translate([ - x, + x - eps, battery_center_y - battery_diameter / 2 - battery_radial_clearance, effective_floor - eps ]) cube([ - wall_thickness, + wall_thickness + 2 * eps, 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() { @@ -542,7 +642,7 @@ module body_positive() { if (battery_enabled) battery_cradle(); if (tripod_insert_enabled) - translate([case_length / 2, case_width / 2, 0]) + translate([tripod_center_x, tripod_center_y, 0]) cylinder(h = effective_floor, d = tripod_boss_diameter); if (usb_guide_active) usb_guide_shell(); @@ -555,6 +655,7 @@ module body_cutouts() { translate([board_origin_x + mx, board_origin_y + my, 0]) insert_pocket( m25_insert_hole_diameter, + m25_insert_outer_diameter, m25_insert_length + insert_melt_relief, board_bottom_z ); @@ -564,6 +665,7 @@ module body_cutouts() { translate([p[0], p[1], 0]) insert_pocket( m25_insert_hole_diameter, + m25_insert_outer_diameter, m25_insert_length + insert_melt_relief, body_height ); @@ -579,29 +681,8 @@ module body_cutouts() { 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([tripod_center_x, tripod_center_y, 0]) { translate([0, 0, -eps]) cylinder( h = effective_floor + 2 * eps, @@ -609,6 +690,7 @@ module body_cutouts() { ); insert_pocket( tripod_insert_hole_diameter, + tripod_insert_outer_diameter, tripod_insert_length + insert_melt_relief, effective_floor ); @@ -702,6 +784,13 @@ module oled_button_lid_cutout(xy) { module lid_cutouts() { // Through holes plus shallow counterbores for flush M2.5 socket heads. for (p = lid_boss_xy) { + // Scallop only the locating lip so it can pass over the full-height + // insert boss while leaving the lid plate supported by the boss top. + translate([p[0], p[1], lid_thickness - eps]) + cylinder( + h = lid_lip_depth + 2 * eps, + d = m25_boss_diameter + 2 * fit_clearance + ); translate([p[0], p[1], -eps]) cylinder( h = lid_thickness + lid_lip_depth + 2 * eps, @@ -714,6 +803,14 @@ module lid_cutouts() { ); } + if (battery_enabled) + translate([rocker_center_x, rocker_center_y, -eps]) + centered_rounded_prism( + rocker_cutout_size, + lid_thickness + lid_lip_depth + 2 * eps, + rocker_corner_radius + ); + if (top_profile == "oled128x64") { beveled_oled_window(); if (button_a) oled_button_lid_cutout(oled_button_a_xy); @@ -723,13 +820,55 @@ module lid_cutouts() { } } -module lid() { +module lid_artwork_nominal_2d() { + translate(lid_art_center) + rotate(lid_art_rotation) + // The lid is modeled exterior-face-down; mirror the artwork so it + // reads normally after the lid is turned into service position. + mirror([0, 1, 0]) { + if (lid_logo_enabled) + translate([0, lid_logo_center_y]) + resize(logo_size) + import(logo_file, center = true); + if (lid_label != "") + translate([0, lid_label_center_y]) + text( + lid_label, + size = lid_label_size, + halign = "center", + valign = "center", + font = "DejaVu Sans:style=Bold" + ); + } +} + +module lid_artwork_2d(inset = 0) { + offset(delta = inset) + lid_artwork_nominal_2d(); +} + +module lid_base() { difference() { lid_plate_positive(); lid_cutouts(); + if (lid_artwork_enabled) + translate([0, 0, -eps]) + linear_extrude(height = lid_inlay_depth + eps) + lid_artwork_2d(lid_inlay_fit_clearance); } } +module lid_accent() { + if (lid_artwork_enabled) + linear_extrude(height = lid_inlay_depth) + lid_artwork_2d(); +} + +module lid() { + color([0.92, 0.92, 0.95]) lid_base(); + color([0.95, 0.42, 0.08]) lid_accent(); +} + module one_oled_button() { recess_depth = button_flange_thickness + button_travel; stem_height = lid_thickness - recess_depth + button_projection; @@ -769,6 +908,13 @@ module reference_board() { color([0.05, 0.25, 0.55, 0.75]) translate([board_origin_x, board_origin_y, board_bottom_z]) rounded_box([feather_length, feather_width, board_thickness], 2.54); + color([0.92, 0.92, 0.86, 0.9]) + translate([ + board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2, + board_origin_y + jst_center_xy[1] - 4.5, + board_top_z + ]) + cube(jst_body_size); color([0.12, 0.45, 0.75, 0.45]) for (i = [1 : wing_count]) translate([ @@ -835,19 +981,20 @@ module reference_battery_and_switch() { 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]) + color([0.08, 0.08, 0.08, 0.85]) translate([ - switch_center_x, - case_width - wall_thickness, - switch_center_z + rocker_center_x - rocker_body_size[0] / 2, + rocker_center_y - rocker_body_size[1] / 2, + body_height + exploded_view - rocker_body_size[2] ]) - 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); - } + cube(rocker_body_size); + color([0.12, 0.12, 0.12, 0.95]) + translate([ + rocker_center_x - rocker_cutout_size[0] / 2, + rocker_center_y - rocker_cutout_size[1] / 2, + body_height + exploded_view + lid_thickness + ]) + cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); } } @@ -865,13 +1012,9 @@ module assembly() { 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_length = spacing * len(tripod_coupon_steps) + 8; coupon_width = 32; coupon_height = max( m25_insert_length + insert_melt_relief + 1.2, @@ -879,18 +1022,22 @@ module insert_coupon() { ); difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); - for (i = [0 : len(coupon_steps) - 1]) { + for (i = [0 : len(tripod_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 - ); + translate([x, 8, 0]) + insert_pocket( + m25_insert_hole_diameter + m25_coupon_steps[i], + m25_insert_outer_diameter, + m25_insert_length + insert_melt_relief, + coupon_height + ); + translate([x, 23, 0]) + insert_pocket( + tripod_insert_hole_diameter + tripod_coupon_steps[i], + tripod_insert_outer_diameter, + tripod_insert_length + insert_melt_relief, + coupon_height + ); } } } @@ -898,15 +1045,17 @@ module insert_coupon() { module switch_coupon() { spacing = 18; coupon_length = spacing * len(coupon_steps) + 6; - coupon_width = 24; - coupon_height = wall_thickness; + coupon_width = rocker_cutout_size[1] + 6; + coupon_height = lid_thickness; difference() { rounded_box([coupon_length, coupon_width, coupon_height], 3); for (i = [0 : len(coupon_steps) - 1]) translate([6 + i * spacing, coupon_width / 2, -eps]) - cylinder( - h = coupon_height + 2 * eps, - d = switch_hole_diameter + coupon_steps[i] + centered_rounded_prism( + rocker_cutout_size + + [coupon_steps[i], coupon_steps[i]], + coupon_height + 2 * eps, + rocker_corner_radius ); } } @@ -953,6 +1102,10 @@ else if (part == "body") body(); else if (part == "lid") lid(); +else if (part == "lid_base") + lid_base(); +else if (part == "lid_accent") + lid_accent(); else if (part == "buttons") buttons(); else if (part == "insert_coupon") diff --git a/lib/feather_case_wizard.rb b/lib/feather_case_wizard.rb index 99b74f9..9c99bf0 100644 --- a/lib/feather_case_wizard.rb +++ b/lib/feather_case_wizard.rb @@ -23,4 +23,7 @@ module FeatherCaseWizard CASES_ROOT = ROOT.join("cases").freeze MODEL_SOURCE = ROOT.join("feather_case.scad").freeze RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze + MULTICOLOR_RENDERER_SOURCE = ROOT.join("tools/render_multicolor_lid.py").freeze + MULTICOLOR_PACKAGER_SOURCE = ROOT.join("tools/package_multimaterial_3mf.py").freeze + LOGO_SOURCE = ROOT.join("assets/NME-fixed.svg").freeze end diff --git a/lib/feather_case_wizard/config.rb b/lib/feather_case_wizard/config.rb index c7b28ef..37ada96 100644 --- a/lib/feather_case_wizard/config.rb +++ b/lib/feather_case_wizard/config.rb @@ -10,6 +10,11 @@ module FeatherCaseWizard FACES = %w[front rear left right].freeze SHAPES = %w[circle rect roundrect slot].freeze COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze + LID_ART_ORIENTATIONS = %w[usb_left usb_front usb_right usb_rear].freeze + LEGACY_SWITCH_FIELDS = %w[switch_hole_diameter switch_hole_compensation + switch_cap_diameter switch_flange_diameter + switch_body_length switch_body_diameter + switch_wire_allowance].freeze DEFAULT_PARAMETERS = { "top_profile" => "generic", @@ -38,20 +43,20 @@ module FeatherCaseWizard "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, + "battery_partition_thickness" => 1.8, + "rocker_cutout_size" => [12.9, 19.4], + "rocker_corner_radius" => 0.8, + "rocker_body_size" => [15.0, 21.0, 21.0], + "rocker_artwork_gap" => 2.0, "tripod_insert_enabled" => false, - "tripod_insert_length" => 6.4, - "tripod_insert_hole_diameter" => 8.8, + "tripod_insert_length" => 5.0, + "tripod_insert_hole_diameter" => 7.2, + "tripod_insert_outer_diameter" => 8.0, "tripod_thread_clearance_diameter" => 6.8, "tripod_boss_diameter" => 14.0, "m25_insert_length" => 4.0, - "m25_insert_hole_diameter" => 3.7, + "m25_insert_hole_diameter" => 3.1, + "m25_insert_outer_diameter" => 3.5, "m25_screw_clearance" => 2.8, "m25_boss_diameter" => 7.0, "insert_entry_chamfer" => 0.6, @@ -76,6 +81,14 @@ module FeatherCaseWizard "usb_connector_overhang" => 2.0, "usb_guide_wall_thickness" => 1.2, "usb_guide_end_gap" => 1.0, + "lid_label" => "", + "lid_logo_enabled" => false, + "lid_art_orientation" => "usb_left", + "lid_inlay_depth" => 0.6, + "lid_inlay_fit_clearance" => 0.08, + "lid_label_size" => 5.0, + "lid_label_logo_gap" => 2.0, + "lid_art_margin" => 8.5, "usb_opening_width" => 12.5, "usb_opening_height" => 7.0, "usb_opening_radius" => 1.5, @@ -87,19 +100,23 @@ module FeatherCaseWizard gps_antenna_height gps_top_gap oled_component_height oled_display_height oled_face_gap], "Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius - board_bottom_clearance board_side_clearance lid_lip_depth - lid_lip_thickness fit_clearance cutout_min_web hole_compensation - lid_screw_head_diameter lid_screw_head_height], + board_bottom_clearance board_side_clearance board_rear_clearance + usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance + cutout_min_web hole_compensation lid_screw_head_diameter + lid_screw_head_height], + "Lid artwork" => %w[lid_inlay_depth lid_inlay_fit_clearance lid_label_size + lid_label_logo_gap lid_art_margin], "M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter + m25_insert_outer_diameter m25_screw_clearance m25_boss_diameter insert_entry_chamfer insert_melt_relief], "Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter + tripod_insert_outer_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], + battery_end_clearance battery_partition_thickness + rocker_cutout_size rocker_corner_radius + rocker_body_size rocker_artwork_gap], "USB-C opening" => %w[usb_opening_width usb_opening_height usb_opening_radius usb_vertical_offset usb_connector_overhang usb_guide_wall_thickness usb_guide_end_gap], @@ -158,6 +175,7 @@ module FeatherCaseWizard def printable_parts parts = %w[body lid] + parts.concat(%w[lid_base lid_accent]) if lid_artwork? if parameters["top_profile"] == "oled128x64" && %w[button_a button_b button_c button_reset].any? { |key| parameters[key] } parts << "buttons" @@ -165,6 +183,10 @@ module FeatherCaseWizard parts + outputs["coupons"] end + def lid_artwork? + !parameters["lid_label"].empty? || parameters["lid_logo_enabled"] + end + def model_parameters parameters.merge("extra_cutouts" => scad_cutouts) end @@ -179,7 +201,10 @@ module FeatherCaseWizard 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"] + combined_tripod_board_gutter = p["battery_enabled"] && p["tripod_insert_enabled"] ? + 2.4 : 0.0 + board_origin_y = p["wall_thickness"] + p["board_side_clearance"] + + combined_tripod_board_gutter board_top_z = effective_floor + effective_board_clearance + 1.6 top_pcb_z = board_top_z + ( p["stack_height_override"].positive? ? @@ -199,40 +224,40 @@ module FeatherCaseWizard p["wall_thickness"] if p["battery_enabled"] partition_y = board_origin_y + 22.86 + 1 - battery_y = partition_y + p["wall_thickness"] + + battery_y = partition_y + p["battery_partition_thickness"] + p["battery_radial_clearance"] + p["battery_diameter"] / 2 - battery_start_x = board_origin_x + p["battery_end_clearance"] + lid_boss_wall_overlap = 0.6 + lid_boss_front_x = p["wall_thickness"] + p["m25_boss_diameter"] / 2 - + lid_boss_wall_overlap + battery_start_x = lid_boss_front_x + p["m25_boss_diameter"] / 2 + + 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 + battery_length_required = battery_end_x + p["battery_end_clearance"] + + p["wall_thickness"] + p["m25_boss_diameter"] - + lid_boss_wall_overlap + length = [plain_length, battery_length_required].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 + battery_ceiling = battery_z + p["battery_diameter"] / 2 + + p["battery_radial_clearance"] + rocker_ceiling = effective_floor + p["rocker_body_size"][2] + + p["fit_clearance"] else length = plain_length width = plain_width battery_ceiling = 0 + rocker_ceiling = 0 end { "length" => length, "width" => width, - "height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max, + "height" => [top_pcb_z + component_height + top_gap, + battery_ceiling, rocker_ceiling].max, "floor" => effective_floor, - "lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max - - p["lid_lip_depth"] + "lip_bottom" => [top_pcb_z + component_height + top_gap, + battery_ceiling, rocker_ceiling].max - p["lid_lip_depth"] } end @@ -265,6 +290,15 @@ module FeatherCaseWizard raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens" end raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"]) + unless LID_ART_ORIENTATIONS.include?(parameters["lid_art_orientation"]) + raise ConfigError, "Unsupported lid art orientation" + end + unless parameters["lid_label"].is_a?(String) + raise ConfigError, "lid_label must be text" + end + if lid_artwork? && parameters["top_profile"] == "oled128x64" + raise ConfigError, "Lid artwork is not supported with the OLED profile" + end 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? @@ -289,8 +323,20 @@ module FeatherCaseWizard parameters["stack_height_override"].negative? raise ConfigError, "wall_thickness must be at least 1.2 mm" if parameters["wall_thickness"] < 1.2 + raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if + parameters["battery_partition_thickness"] < 1.2 raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if parameters["lid_lip_thickness"] < 1.2 + raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if + parameters["lid_inlay_depth"] >= parameters["lid_thickness"] + if parameters["m25_insert_outer_diameter"] < parameters["m25_insert_hole_diameter"] + raise ConfigError, "m25_insert_outer_diameter must not be smaller than the pilot" + end + if parameters["tripod_insert_outer_diameter"] < + parameters["tripod_insert_hole_diameter"] + raise ConfigError, + "tripod_insert_outer_diameter must not be smaller than the pilot" + end validate_cutouts! self @@ -305,6 +351,7 @@ module FeatherCaseWizard legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled") data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters) data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide + LEGACY_SWITCH_FIELDS.each { |key| data["parameters"].delete(key) } data["cutouts"] ||= [] data["outputs"] ||= {} data["outputs"]["formats"] ||= ["3mf"] diff --git a/lib/feather_case_wizard/package_builder.rb b/lib/feather_case_wizard/package_builder.rb index 04c563b..c1fa290 100644 --- a/lib/feather_case_wizard/package_builder.rb +++ b/lib/feather_case_wizard/package_builder.rb @@ -2,16 +2,23 @@ module FeatherCaseWizard class PackageBuilder - attr_reader :config, :root, :toolchain, :model_source, :renderer_source + attr_reader :config, :root, :toolchain, :model_source, :renderer_source, + :multicolor_renderer_source, :multicolor_packager_source, :logo_source def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new, model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, + multicolor_renderer_source: MULTICOLOR_RENDERER_SOURCE, + multicolor_packager_source: MULTICOLOR_PACKAGER_SOURCE, + logo_source: LOGO_SOURCE, reporter: nil) @config = config @root = Pathname(root) @toolchain = toolchain @model_source = Pathname(model_source) @renderer_source = Pathname(renderer_source) + @multicolor_renderer_source = Pathname(multicolor_renderer_source) + @multicolor_packager_source = Pathname(multicolor_packager_source) + @logo_source = Pathname(logo_source) @reporter = reporter || ->(_message) {} end @@ -37,13 +44,22 @@ module FeatherCaseWizard 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? + raise ToolError, "Missing multicolor renderer: #{multicolor_renderer_source}" unless + multicolor_renderer_source.file? + raise ToolError, "Missing multicolor packager: #{multicolor_packager_source}" unless + multicolor_packager_source.file? + raise ToolError, "Missing lid logo: #{logo_source}" unless logo_source.file? end def prepare_package(stage, previous) source_dir = stage.join("source") - FileUtils.mkdir_p(source_dir) + tools_dir = stage.join("tools") + FileUtils.mkdir_p([source_dir, source_dir.join("assets"), tools_dir]) FileUtils.cp(model_source, source_dir.join("feather_case.scad")) FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py")) + FileUtils.cp(multicolor_renderer_source, source_dir.join("render_multicolor_lid.py")) + FileUtils.cp(multicolor_packager_source, tools_dir.join("package_multimaterial_3mf.py")) + FileUtils.cp(logo_source, source_dir.join("assets/NME-fixed.svg")) FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb")) copy_history(previous, stage) @@ -91,9 +107,26 @@ module FeatherCaseWizard report("Exporting #{part}.#{format}") export_model(stage, directory.join("#{part}.#{format}"), part) end + package_multicolor_lid(stage, directory) if format == "3mf" && config.lid_artwork? end end + def package_multicolor_lid(stage, directory) + report("Packaging lid_multicolor.3mf") + command = [ + toolchain.python, + stage.join("tools/package_multimaterial_3mf.py").to_s, + directory.join("lid_base.3mf").to_s, + directory.join("lid_accent.3mf").to_s, + directory.join("lid_multicolor.3mf").to_s, + "--title", "#{config.name} Lid" + ] + toolchain.run!(*command) + output = directory.join("lid_multicolor.3mf") + raise ToolError, "Multicolor packager did not create #{output}" unless + output.file? && output.size.positive? + end + def generate_previews(stage) previews = stage.join("previews") scratch = stage.join(".preview-meshes") @@ -111,9 +144,22 @@ module FeatherCaseWizard export_model(stage, mesh, part) unless mesh.file? render(stage, mesh, previews, part, ["iso"]) end + render_multicolor_lid(stage, scratch, previews) if config.lid_artwork? FileUtils.rm_rf(scratch) end + def render_multicolor_lid(stage, scratch, previews) + report("Rendering multicolor lid preview") + command = [ + toolchain.blender, "--background", "-noaudio", "--python", + stage.join("source/render_multicolor_lid.py").to_s, "--", + scratch.join("lid_base.stl").to_s, + scratch.join("lid_accent.stl").to_s, + previews.to_s, "lid_multicolor", config.parameters["lid_art_orientation"] + ] + toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}) + end + def export_model(stage, output, part, overrides = {}) model = stage.join("source/feather_case.scad") parameters = config.model_parameters.merge(overrides).merge("part" => part) @@ -139,6 +185,18 @@ module FeatherCaseWizard def write_summary(stage) dims = config.dimensions parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ") + artwork = if config.lid_artwork? + <<~ARTWORK.chomp + + For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one + multipart object. Assign “Lid base — Filament 1” and “Accent inlays — + Filament 2” to the desired filaments. `lid.3mf` is the complete + single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also + provided separately. + ARTWORK + else + "" + end text = <<~MARKDOWN # #{config.name} @@ -154,8 +212,8 @@ module FeatherCaseWizard - 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. + OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`, + `BLENDER`, or `PYTHON` to override their executable paths. To open the saved Customizer values manually: @@ -165,6 +223,7 @@ module FeatherCaseWizard The 3MF files contain portable geometry only. Choose printer, filament, plate, and slicing settings in Bambu Studio. + #{artwork} MARKDOWN File.write(stage.join("README.md"), text) end diff --git a/lib/feather_case_wizard/toolchain.rb b/lib/feather_case_wizard/toolchain.rb index 59de338..0f09fdd 100644 --- a/lib/feather_case_wizard/toolchain.rb +++ b/lib/feather_case_wizard/toolchain.rb @@ -4,18 +4,20 @@ module FeatherCaseWizard class ToolError < StandardError; end class Toolchain - attr_reader :openscad, :blender + attr_reader :openscad, :blender, :python def initialize(env: ENV) @env = env @openscad = discover("OPENSCAD", "openscad") @blender = discover("BLENDER", "blender") + @python = discover("PYTHON", "python3") 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 + missing << "Python 3 (`sudo apt-get install python3` or set PYTHON)" unless python return self if missing.empty? raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}" diff --git a/lib/feather_case_wizard/wizard.rb b/lib/feather_case_wizard/wizard.rb index ee54b21..a593320 100644 --- a/lib/feather_case_wizard/wizard.rb +++ b/lib/feather_case_wizard/wizard.rb @@ -44,6 +44,7 @@ module FeatherCaseWizard choose_stack(config) choose_end_clearance(config) choose_features(config) + choose_lid_artwork(config) manage_cutouts(config) choose_coupons(config) advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question| @@ -143,6 +144,26 @@ module FeatherCaseWizard ) end + def choose_lid_artwork(config) + p = config.parameters + label_enabled = agreement("Add a two-color device title to the lid?", + !p["lid_label"].empty?) + p["lid_label"] = label_enabled ? text( + "Device title", p["lid_label"].empty? ? config.name : p["lid_label"] + ) : "" + p["lid_logo_enabled"] = agreement("Add the two-color NME logo to the lid?", + p["lid_logo_enabled"]) + return unless !p["lid_label"].empty? || p["lid_logo_enabled"] + + current = p["lid_art_orientation"] + p["lid_art_orientation"] = ui.choose("USB position when the lid text is upright") do |menu| + Config::LID_ART_ORIENTATIONS.each do |orientation| + menu.choice(orientation.delete_prefix("usb_").capitalize) { orientation } + end + menu.default = (Config::LID_ART_ORIENTATIONS.index(current).to_i + 1).to_s + end + end + def manage_cutouts(config) loop do ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" : diff --git a/templates/standalone_build.rb b/templates/standalone_build.rb index 3deb8ab..01a7e8b 100644 --- a/templates/standalone_build.rb +++ b/templates/standalone_build.rb @@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [], permitted_symbols: [], aliases: false) openscad = executable("OPENSCAD", "openscad") blender = executable("BLENDER", "blender") +python = executable("PYTHON", "python3") 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") ) +artwork = !parameters.fetch("lid_label", "").empty? || + parameters.fetch("lid_logo_enabled", false) +abort "Python 3 not found; install it or set PYTHON" if artwork && !python parts = %w[body lid] + config.fetch("outputs").fetch("coupons") +parts.concat(%w[lid_base lid_accent]) if artwork buttons = %w[button_a button_b button_c button_reset] if parameters["top_profile"] == "oled128x64" && buttons.any? { |name| parameters[name] } @@ -80,6 +85,16 @@ begin puts "Exporting #{part}.#{format}…" export.call(directory.join("#{part}.#{format}"), part) end + if artwork && format == "3mf" + puts "Packaging lid_multicolor.3mf…" + run!( + python, ROOT.join("tools/package_multimaterial_3mf.py").to_s, + directory.join("lid_base.3mf").to_s, + directory.join("lid_accent.3mf").to_s, + directory.join("lid_multicolor.3mf").to_s, + "--title", "#{config.fetch("name")} Lid" + ) + end end assembly = scratch.join("assembly.stl") @@ -97,6 +112,16 @@ begin run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", mesh.to_s, previews.to_s, part, "iso", env: render_env) end + if artwork + puts "Rendering multicolor lid preview…" + run!( + blender, "--background", "-noaudio", "--python", + ROOT.join("source/render_multicolor_lid.py").to_s, "--", + scratch.join("lid_base.stl").to_s, + scratch.join("lid_accent.stl").to_s, + previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env + ) + end backup = ROOT.join(".build-old-#{Process.pid}") FileUtils.mkdir_p(backup) diff --git a/test/config_test.rb b/test/config_test.rb index a699fcb..3b1c129 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -80,6 +80,35 @@ class ConfigTest < Minitest::Test assert_in_delta 95.6, subject.dimensions["length"], 0.01 end + def test_insert_defaults_match_selected_hardware + parameters = config.parameters + + assert_in_delta 4.0, parameters["m25_insert_length"], 0.001 + assert_in_delta 3.1, parameters["m25_insert_hole_diameter"], 0.001 + assert_in_delta 3.5, parameters["m25_insert_outer_diameter"], 0.001 + assert_in_delta 5.0, parameters["tripod_insert_length"], 0.001 + assert_in_delta 7.2, parameters["tripod_insert_hole_diameter"], 0.001 + assert_in_delta 8.0, parameters["tripod_insert_outer_diameter"], 0.001 + end + + def test_tripod_floor_tracks_insert_length + subject = config + subject.parameters["tripod_insert_enabled"] = true + + assert_in_delta 7.0, subject.dimensions["floor"], 0.001 + end + + def test_insert_coupon_uses_finished_pilot_ranges + source = FeatherCaseWizard::MODEL_SOURCE.read + + assert_includes source, + "m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];" + assert_includes source, + "tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];" + assert_includes source, "bore_d = diameter;" + refute_includes source, "bore_d = diameter + hole_compensation;" + end + def test_oled_buttons_and_selected_coupons_are_printable subject = config subject.parameters["top_profile"] = "oled128x64" @@ -90,6 +119,71 @@ class ConfigTest < Minitest::Test assert_equal %w[body lid buttons fit_coupon], subject.printable_parts end + def test_combined_battery_and_tripod_case_adds_partition_clearance + subject = config + subject.parameters["battery_enabled"] = true + subject.parameters["tripod_insert_enabled"] = true + + assert_in_delta 88.6, subject.dimensions["length"], 0.01 + assert_in_delta 59.06, subject.dimensions["width"], 0.01 + assert_in_delta 31.25, subject.dimensions["height"], 0.01 + end + + def test_legacy_round_switch_fields_migrate_to_rocker_defaults + data = config.data + data["parameters"]["switch_hole_diameter"] = 12.0 + data["parameters"]["switch_body_length"] = 24.0 + + subject = FeatherCaseWizard::Config.new(data) + + refute subject.parameters.key?("switch_hole_diameter") + refute subject.parameters.key?("switch_body_length") + assert_equal [12.9, 19.4], subject.parameters["rocker_cutout_size"] + assert_equal [15.0, 21.0, 21.0], subject.parameters["rocker_body_size"] + end + + def test_battery_partition_requires_three_06_mm_lines + subject = config + subject.parameters["battery_partition_thickness"] = 1.19 + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/battery_partition_thickness/, error.message) + end + + def test_insert_entry_cannot_be_smaller_than_pilot + subject = config + subject.parameters["m25_insert_outer_diameter"] = 3.0 + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/m25_insert_outer_diameter/, error.message) + end + + def test_lid_artwork_adds_base_and_accent_parts + subject = config + subject.parameters["lid_label"] = "GPS" + subject.parameters["lid_logo_enabled"] = true + + assert subject.lid_artwork? + assert_equal %w[body lid lid_base lid_accent], subject.printable_parts + end + + def test_lid_artwork_rejects_oled_profile + subject = config + subject.parameters["top_profile"] = "oled128x64" + subject.parameters["lid_label"] = "OLED" + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/not supported with the OLED profile/, error.message) + end + + def test_lid_artwork_orientation_is_named + subject = config + subject.parameters["lid_art_orientation"] = "diagonal" + + error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! } + assert_match(/orientation/, error.message) + end + def test_wall_local_cutouts_map_to_model_coordinates subject = config length = subject.dimensions["length"] diff --git a/test/package_builder_test.rb b/test/package_builder_test.rb index b8d627b..7febef5 100644 --- a/test/package_builder_test.rb +++ b/test/package_builder_test.rb @@ -68,6 +68,30 @@ class PackageBuilderTest < Minitest::Test end end + def test_builds_aligned_multicolor_lid_package_when_artwork_is_enabled + 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: "IMU", slug: "imu") + config.parameters["lid_label"] = "IMU" + config.parameters["lid_logo_enabled"] = true + + destination = build(config, root.join("cases"), tools, model, renderer) + + %w[lid lid_base lid_accent].each do |part| + assert destination.join("exports/3mf/#{part}.3mf").file? + end + assert destination.join("exports/3mf/lid_multicolor.3mf").file? + assert destination.join("source/assets/NME-fixed.svg").file? + assert destination.join("tools/package_multimaterial_3mf.py").file? + assert destination.join("previews/lid_multicolor_top.png").file? + end + end + private def build(config, root, tools, model, renderer) @@ -93,14 +117,26 @@ class PackageBuilderTest < Minitest::Test #!/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] + multicolor = ARGV[ARGV.index("--python") + 1].include?("render_multicolor") + if multicolor + _base, _accent, output, basename = args + views = %w[iso top] + else + _mesh, output, basename, selected = args + views = selected ? selected.split(",") : %w[iso front back left right top] + end FileUtils.mkdir_p(output) views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") } RUBY - FileUtils.chmod(0o755, [openscad, blender]) + python = bin.join("python3") + File.write(python, <<~RUBY) + #!/usr/bin/env ruby + File.binwrite(ARGV[3], "multipart") + RUBY + FileUtils.chmod(0o755, [openscad, blender, python]) FeatherCaseWizard::Toolchain.new( - env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, "BLENDER" => blender.to_s} + env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, + "BLENDER" => blender.to_s, "PYTHON" => python.to_s} ) end end diff --git a/test/toolchain_test.rb b/test/toolchain_test.rb index 8849e7f..e9759f2 100644 --- a/test/toolchain_test.rb +++ b/test/toolchain_test.rb @@ -9,6 +9,7 @@ class ToolchainTest < Minitest::Test assert_match(/OpenSCAD/, error.message) assert_match(/Blender/, error.message) + assert_match(/Python 3/, error.message) assert_match(/apt-get/, error.message) end @@ -18,13 +19,14 @@ class ToolchainTest < Minitest::Test File.write(executable, "#!/bin/sh\nexit 0\n") FileUtils.chmod(0o755, executable) toolchain = FeatherCaseWizard::Toolchain.new( - env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable} + env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable, + "PYTHON" => executable} ) assert_equal executable, toolchain.openscad assert_equal executable, toolchain.blender + assert_equal executable, toolchain.python assert_same toolchain, toolchain.check! end end end - diff --git a/test/wizard_test.rb b/test/wizard_test.rb index 0972461..85a35cb 100644 --- a/test/wizard_test.rb +++ b/test/wizard_test.rb @@ -6,8 +6,8 @@ require_relative "test_helper" class WizardTest < Minitest::Test def test_minimal_new_case_flow_and_confirmation input = StringIO.new( - ["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "1", - "n", "n", "n", "n", "y"].join("\n") + "\n" + ["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "n", "n", + "1", "n", "n", "n", "n", "y"].join("\n") + "\n" ) output = StringIO.new wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output)) @@ -24,8 +24,8 @@ class WizardTest < Minitest::Test config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps") config.parameters["top_profile"] = "gps" input = StringIO.new( - ["", "", "", "", "n", "n", "", "", "n", "n", "1", "n", "n", "n", - "n"].join("\n") + "\n" + ["", "", "", "", "n", "n", "", "", "n", "n", "n", "n", "1", "n", + "n", "n", "n"].join("\n") + "\n" ) wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, StringIO.new)) diff --git a/tools/package_multimaterial_3mf.py b/tools/package_multimaterial_3mf.py new file mode 100644 index 0000000..7d8978a --- /dev/null +++ b/tools/package_multimaterial_3mf.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Combine aligned lid meshes into one slicer-friendly multipart 3MF.""" + +from __future__ import annotations + +import argparse +import copy +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + + +CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" +ET.register_namespace("", CORE) + +CONTENT_TYPES_XML = b''' + + + + +''' + +RELATIONSHIPS_XML = b''' + + + +''' + + +def load_mesh(path: Path) -> ET.Element: + with zipfile.ZipFile(path) as archive: + root = ET.fromstring(archive.read("3D/3dmodel.model")) + mesh = root.find(f".//{{{CORE}}}mesh") + if mesh is None: + raise ValueError(f"{path} does not contain a 3MF mesh") + return copy.deepcopy(mesh) + + +def model_xml(base: Path, accent: Path, title: str) -> bytes: + root = ET.Element( + f"{{{CORE}}}model", + { + "unit": "millimeter", + "{http://www.w3.org/XML/1998/namespace}lang": "en-US", + }, + ) + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title + ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = ( + "feather-case-wizard" + ) + resources = ET.SubElement(root, f"{{{CORE}}}resources") + materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"}) + ET.SubElement( + materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"} + ) + ET.SubElement( + materials, + f"{{{CORE}}}base", + {"name": "Accent inlays", "displaycolor": "#F97316"}, + ) + + for object_id, name, source in ( + (2, "Lid base — Filament 1", base), + (3, "Accent inlays — Filament 2", accent), + ): + obj = ET.SubElement( + resources, + f"{{{CORE}}}object", + { + "id": str(object_id), + "name": name, + "type": "model", + "pid": "1", + "pindex": str(object_id - 2), + }, + ) + obj.append(load_mesh(source)) + + assembly = ET.SubElement( + resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"} + ) + components = ET.SubElement(assembly, f"{{{CORE}}}components") + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"}) + ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"}) + build = ET.SubElement(root, f"{{{CORE}}}build") + ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def bambu_model_settings_xml(title: str) -> bytes: + root = ET.Element("config") + obj = ET.SubElement(root, "object", {"id": "4"}) + ET.SubElement(obj, "metadata", {"key": "name", "value": title}) + for part_id, name, extruder in ( + (2, "Lid base — Filament 1", 1), + (3, "Accent inlays — Filament 2", 2), + ): + part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"}) + ET.SubElement(part, "metadata", {"key": "name", "value": name}) + ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)}) + ET.SubElement(root, "assemble") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("base", type=Path) + parser.add_argument("accent", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--title", required=True) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive: + write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML) + write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML) + write_entry( + archive, + "3D/3dmodel.model", + model_xml(args.base, args.accent, args.title), + ) + write_entry( + archive, + "Metadata/model_settings.config", + bambu_model_settings_xml(args.title), + ) + + +if __name__ == "__main__": + main() diff --git a/tools/render_multicolor_lid.py b/tools/render_multicolor_lid.py new file mode 100644 index 0000000..05c8a58 --- /dev/null +++ b/tools/render_multicolor_lid.py @@ -0,0 +1,101 @@ +"""Render aligned lid-base and accent STLs with distinct materials.""" + +import os +import sys +import math + +import bpy +from mathutils import Matrix, Vector + + +def import_mesh(path, name, color): + bpy.ops.wm.stl_import(filepath=os.path.abspath(path)) + model = bpy.context.selected_objects[0] + model.name = name + material = bpy.data.materials.new(f"{name} material") + material.diffuse_color = color + material.metallic = 0.0 + material.roughness = 0.45 + model.data.materials.append(material) + return model + + +def look_at(camera, target): + direction = Vector(target) - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def main(): + args = sys.argv[sys.argv.index("--") + 1 :] + if len(args) not in (4, 5): + raise SystemExit( + "expected: lid-base.stl lid-accent.stl output-directory basename [orientation]" + ) + base_path, accent_path, output_dir, basename = args[:4] + orientation = args[4] if len(args) == 5 else "usb_left" + angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270} + if orientation not in angles: + raise SystemExit(f"unknown lid orientation: {orientation}") + os.makedirs(output_dir, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + models = [ + import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)), + import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)), + ] + # Lid exports are print-oriented with the decorated exterior face down. + # Turn both aligned parts over and present the configured USB edge in its + # named position so previews inspect the finished face as the user will. + for model in models: + model.matrix_world = ( + Matrix.Rotation(math.radians(angles[orientation]), 4, "Z") + @ Matrix.Rotation(math.pi, 4, "X") + ) + bpy.context.view_layer.update() + + corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.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 + camera.data.type = "ORTHO" + camera.data.ortho_scale = span * 1.35 + + key_data = bpy.data.lights.new("Key", type="AREA") + key_data.energy = 900 + 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) + + 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.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" + + for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items(): + camera.location = center + Vector(direction).normalized() * span * 2.2 + look_at(camera, center) + scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png") + bpy.ops.render.render(write_still=True) + + +if __name__ == "__main__": + main()