update some deisng parameters and add new cases
This commit is contained in:
@@ -20,7 +20,7 @@ bundle install
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
bundle exec bin/feather-case
|
||||
bin/feather-case
|
||||
```
|
||||
|
||||
The menu can create a case, reopen one for dimensional tuning after a test
|
||||
@@ -38,6 +38,18 @@ Set `OPENSCAD=/path/to/openscad` or `BLENDER=/path/to/blender` when either
|
||||
program is installed elsewhere. The wizard checks both tools before replacing
|
||||
an existing package.
|
||||
|
||||
The wizard can add clearance beyond either short end of the Feather PCB for
|
||||
right-angle headers, plug housings, and wire bends. Front clearance moves the
|
||||
board and its standoffs away from the USB-C wall; rear clearance extends the
|
||||
opposite end of the case.
|
||||
|
||||
USB programming access can be omitted for a closed front wall or paired with
|
||||
an enclosed plug guide. The guide runs from the wall toward the connector and
|
||||
automatically stops short based on the configured connector overhang and end
|
||||
gap. New cases default to a 2 mm USB connector overhang, an enabled wall
|
||||
opening, and an enabled guide; with the standard 2 mm front clearance the guide
|
||||
has no inward length and behaves as the normal wall opening.
|
||||
|
||||
Each generated directory contains a dependency-free `build.rb`. After copying
|
||||
that directory to another Ubuntu machine, run `ruby build.rb` to reproduce its
|
||||
exports and previews. The saved generator source is used by default so a
|
||||
@@ -105,10 +117,20 @@ openscad -o oled-buttons.stl \
|
||||
feather_case.scad
|
||||
```
|
||||
|
||||
The standard stack height is `wing_count * wing_pitch`. Set
|
||||
`stack_height_override` to the measured distance from the Feather PCB top to
|
||||
the top FeatherWing PCB when using unusual headers. It takes precedence over
|
||||
the computed height.
|
||||
The standard stack height is `wing_count * wing_pitch`, with a 12.25 mm default
|
||||
pitch. Set `stack_height_override` to the measured distance from the top surface
|
||||
of the Feather PCB to the top surface of the uppermost FeatherWing PCB when
|
||||
using unusual headers. Do not include components on the Wing; profile-specific
|
||||
component heights and top gaps are added separately. The measured override
|
||||
takes precedence over the computed height.
|
||||
|
||||
For the 128×64 OLED profile, `oled_component_height` is the height of the
|
||||
tallest board feature (the switch tops), while `oled_display_height` controls
|
||||
the display-face reference shown in the assembly preview. The defaults are
|
||||
2.4 mm and 1.5 mm above the Wing PCB, respectively. The enclosure ceiling
|
||||
remains based on the taller switches plus `oled_face_gap`, placing the display
|
||||
face about 1.7 mm below the lid's inner plane. The default 0.7 mm captive-button
|
||||
contact extension spans most of the 0.8 mm switch-to-lid gap.
|
||||
|
||||
## Custom wire egress
|
||||
|
||||
@@ -123,11 +145,15 @@ extra_cutouts = [
|
||||
];
|
||||
```
|
||||
|
||||
Faces are `front`, `rear`, `left`, and `right`. For front/rear faces, `u` is
|
||||
the global Y coordinate; for left/right faces it is global X. The Z value is
|
||||
the opening centre. Shapes are `circle`, `rect`, `roundrect`, and `slot`.
|
||||
Cutouts that enter the floor or lid-lip keepout raise an assertion instead of
|
||||
silently weakening those features.
|
||||
Faces are `front` (the USB-C short wall), `rear` (the opposite short wall),
|
||||
`left` (the plain/non-JST long wall), and `right` (the JST/battery long wall).
|
||||
For front/rear faces, `u` is the global Y coordinate; for left/right faces it
|
||||
is global X. The wizard presents this as distance from the selected wall's
|
||||
left edge while viewing it from outside. The Z value is the opening centre.
|
||||
Shapes are `circle`, `rect`, `roundrect`, and `slot`. Cutouts that enter the
|
||||
floor or lid-lip keepout, approach another opening too closely, or overlap the
|
||||
USB opening or guide keepout raise an assertion instead of silently weakening
|
||||
those features.
|
||||
|
||||
## Calibration
|
||||
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
||||
require "bundler/setup"
|
||||
|
||||
require_relative "../lib/feather_case_wizard"
|
||||
|
||||
exit FeatherCaseWizard::CLI.new.run
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Attitude IMU - Battery
|
||||
|
||||
Generated by the Feather Case Wizard.
|
||||
|
||||
- Top profile: `generic`
|
||||
- FeatherWings: 1
|
||||
- Battery compartment: yes
|
||||
- 1/4-20 insert: yes
|
||||
- Approximate exterior: 93.9 × 57.3 × 35.8 mm
|
||||
- Formats: 3mf
|
||||
- Parts: `body`, `lid`
|
||||
- Custom cutouts: 0
|
||||
|
||||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||||
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
|
||||
override their executable paths.
|
||||
|
||||
To open the saved Customizer values manually:
|
||||
|
||||
```bash
|
||||
openscad -p parameters.json -P attitude-imu-battery source/feather_case.scad
|
||||
```
|
||||
|
||||
The 3MF files contain portable geometry only. Choose printer, filament,
|
||||
plate, and slicing settings in Bambu Studio.
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "pathname"
|
||||
require "tmpdir"
|
||||
require "yaml"
|
||||
|
||||
ROOT = Pathname(__dir__).expand_path
|
||||
|
||||
def executable(env_name, command)
|
||||
override = ENV[env_name]
|
||||
return File.expand_path(override) if override && File.executable?(override)
|
||||
|
||||
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
||||
candidate = File.join(directory, command)
|
||||
return candidate if File.file?(candidate) && File.executable?(candidate)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def literal(value)
|
||||
case value
|
||||
when String then JSON.generate(value)
|
||||
when TrueClass then "true"
|
||||
when FalseClass then "false"
|
||||
when Numeric then value.to_s
|
||||
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
|
||||
else raise "Cannot convert #{value.inspect} to OpenSCAD"
|
||||
end
|
||||
end
|
||||
|
||||
def run!(*command, env: {})
|
||||
stdout, stderr, status = Open3.capture3(env, *command)
|
||||
return if status.success?
|
||||
|
||||
warn [stdout, stderr].reject(&:empty?).join("\n")
|
||||
raise "Command failed (#{status.exitstatus}): #{command.first}"
|
||||
end
|
||||
|
||||
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
|
||||
permitted_symbols: [], aliases: false)
|
||||
openscad = executable("OPENSCAD", "openscad")
|
||||
blender = executable("BLENDER", "blender")
|
||||
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
|
||||
abort "Blender not found; install it or set BLENDER" unless blender
|
||||
|
||||
parameters = config.fetch("parameters").merge(
|
||||
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
|
||||
)
|
||||
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
|
||||
buttons = %w[button_a button_b button_c button_reset]
|
||||
if parameters["top_profile"] == "oled128x64" &&
|
||||
buttons.any? { |name| parameters[name] }
|
||||
parts << "buttons"
|
||||
end
|
||||
|
||||
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
|
||||
begin
|
||||
exports = stage.join("exports")
|
||||
previews = stage.join("previews")
|
||||
scratch = stage.join("meshes")
|
||||
FileUtils.mkdir_p([exports, previews, scratch])
|
||||
model = ROOT.join("source/feather_case.scad")
|
||||
renderer = ROOT.join("source/render_stl_previews.py")
|
||||
|
||||
export = lambda do |output, part, overrides = {}|
|
||||
values = parameters.merge(overrides).merge("part" => part)
|
||||
command = [openscad, "-o", output.to_s]
|
||||
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
|
||||
run!(*command, model.to_s)
|
||||
end
|
||||
|
||||
config.fetch("outputs").fetch("formats").each do |format|
|
||||
directory = exports.join(format)
|
||||
FileUtils.mkdir_p(directory)
|
||||
parts.each do |part|
|
||||
puts "Exporting #{part}.#{format}…"
|
||||
export.call(directory.join("#{part}.#{format}"), part)
|
||||
end
|
||||
end
|
||||
|
||||
assembly = scratch.join("assembly.stl")
|
||||
puts "Preparing assembly preview…"
|
||||
export.call(assembly, "assembly",
|
||||
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
|
||||
"show_references" => false)
|
||||
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
assembly.to_s, previews.to_s, "assembly", env: render_env)
|
||||
parts.each do |part|
|
||||
puts "Rendering #{part} preview…"
|
||||
mesh = scratch.join("#{part}.stl")
|
||||
export.call(mesh, part) unless mesh.file?
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
mesh.to_s, previews.to_s, part, "iso", env: render_env)
|
||||
end
|
||||
|
||||
backup = ROOT.join(".build-old-#{Process.pid}")
|
||||
FileUtils.mkdir_p(backup)
|
||||
%w[exports previews].each do |name|
|
||||
current = ROOT.join(name)
|
||||
FileUtils.mv(current, backup.join(name)) if current.exist?
|
||||
end
|
||||
begin
|
||||
FileUtils.mv(exports, ROOT.join("exports"))
|
||||
FileUtils.mv(previews, ROOT.join("previews"))
|
||||
FileUtils.rm_rf(backup)
|
||||
rescue StandardError
|
||||
FileUtils.rm_rf(ROOT.join("exports"))
|
||||
FileUtils.rm_rf(ROOT.join("previews"))
|
||||
%w[exports previews].each do |name|
|
||||
saved = backup.join(name)
|
||||
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
|
||||
end
|
||||
raise
|
||||
end
|
||||
puts "Rebuilt #{ROOT}"
|
||||
ensure
|
||||
FileUtils.rm_rf(stage)
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
schema_version: 1
|
||||
name: Attitude IMU - Battery
|
||||
slug: attitude-imu-battery
|
||||
generator:
|
||||
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
|
||||
created_with: feather-case-wizard
|
||||
parameters:
|
||||
top_profile: generic
|
||||
wing_count: 1
|
||||
wing_pitch: 12.25
|
||||
stack_height_override: 0.0
|
||||
generic_component_height: 3.0
|
||||
generic_top_gap: 2.0
|
||||
gps_antenna_height: 6.0
|
||||
gps_top_gap: 5.0
|
||||
oled_component_height: 2.4
|
||||
oled_display_height: 1.5
|
||||
oled_face_gap: 0.8
|
||||
button_a: true
|
||||
button_b: true
|
||||
button_c: true
|
||||
button_reset: false
|
||||
button_projection: 1.5
|
||||
button_cap_size:
|
||||
- 5.0
|
||||
- 3.2
|
||||
button_flange_size:
|
||||
- 6.6
|
||||
- 3.6
|
||||
button_flange_thickness: 0.9
|
||||
button_travel: 0.5
|
||||
button_contact_extension: 0.7
|
||||
battery_enabled: true
|
||||
battery_length: 69.0
|
||||
battery_diameter: 18.0
|
||||
battery_radial_clearance: 0.6
|
||||
battery_end_clearance: 1.0
|
||||
switch_hole_diameter: 12.0
|
||||
switch_hole_compensation: 0.3
|
||||
switch_cap_diameter: 8.0
|
||||
switch_flange_diameter: 16.2
|
||||
switch_body_length: 24.0
|
||||
switch_body_diameter: 12.0
|
||||
switch_wire_allowance: 3.0
|
||||
tripod_insert_enabled: true
|
||||
tripod_insert_length: 6.4
|
||||
tripod_insert_hole_diameter: 8.8
|
||||
tripod_thread_clearance_diameter: 6.8
|
||||
tripod_boss_diameter: 14.0
|
||||
m25_insert_length: 4.0
|
||||
m25_insert_hole_diameter: 3.7
|
||||
m25_screw_clearance: 2.8
|
||||
m25_boss_diameter: 7.0
|
||||
insert_entry_chamfer: 0.6
|
||||
insert_melt_relief: 0.8
|
||||
wall_thickness: 2.4
|
||||
floor_thickness: 2.4
|
||||
lid_thickness: 3.2
|
||||
corner_radius: 4.0
|
||||
board_bottom_clearance: 5.4
|
||||
board_side_clearance: 7.0
|
||||
board_rear_clearance: 4.0
|
||||
usb_board_wall_gap: 1.2
|
||||
lid_lip_depth: 2.4
|
||||
lid_lip_thickness: 1.2
|
||||
fit_clearance: 0.35
|
||||
hole_compensation: 0.2
|
||||
lid_screw_head_diameter: 5.2
|
||||
lid_screw_head_height: 2.5
|
||||
usb_opening_width: 12.5
|
||||
usb_opening_height: 7.0
|
||||
usb_opening_radius: 1.5
|
||||
usb_vertical_offset: 0.0
|
||||
cutouts: []
|
||||
outputs:
|
||||
formats:
|
||||
- 3mf
|
||||
coupons: []
|
||||
assembly_exploded_view: 8
|
||||
resolved:
|
||||
extra_cutouts: []
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"parameterSets": {
|
||||
"attitude-imu-battery": {
|
||||
"top_profile": "\"generic\"",
|
||||
"wing_count": "1",
|
||||
"wing_pitch": "12.25",
|
||||
"stack_height_override": "0.0",
|
||||
"generic_component_height": "3.0",
|
||||
"generic_top_gap": "2.0",
|
||||
"gps_antenna_height": "6.0",
|
||||
"gps_top_gap": "5.0",
|
||||
"oled_component_height": "2.4",
|
||||
"oled_display_height": "1.5",
|
||||
"oled_face_gap": "0.8",
|
||||
"button_a": "true",
|
||||
"button_b": "true",
|
||||
"button_c": "true",
|
||||
"button_reset": "false",
|
||||
"button_projection": "1.5",
|
||||
"button_cap_size": "[5.0,3.2]",
|
||||
"button_flange_size": "[6.6,3.6]",
|
||||
"button_flange_thickness": "0.9",
|
||||
"button_travel": "0.5",
|
||||
"button_contact_extension": "0.7",
|
||||
"battery_enabled": "true",
|
||||
"battery_length": "69.0",
|
||||
"battery_diameter": "18.0",
|
||||
"battery_radial_clearance": "0.6",
|
||||
"battery_end_clearance": "1.0",
|
||||
"switch_hole_diameter": "12.0",
|
||||
"switch_hole_compensation": "0.3",
|
||||
"switch_cap_diameter": "8.0",
|
||||
"switch_flange_diameter": "16.2",
|
||||
"switch_body_length": "24.0",
|
||||
"switch_body_diameter": "12.0",
|
||||
"switch_wire_allowance": "3.0",
|
||||
"tripod_insert_enabled": "true",
|
||||
"tripod_insert_length": "6.4",
|
||||
"tripod_insert_hole_diameter": "8.8",
|
||||
"tripod_thread_clearance_diameter": "6.8",
|
||||
"tripod_boss_diameter": "14.0",
|
||||
"m25_insert_length": "4.0",
|
||||
"m25_insert_hole_diameter": "3.7",
|
||||
"m25_screw_clearance": "2.8",
|
||||
"m25_boss_diameter": "7.0",
|
||||
"insert_entry_chamfer": "0.6",
|
||||
"insert_melt_relief": "0.8",
|
||||
"wall_thickness": "2.4",
|
||||
"floor_thickness": "2.4",
|
||||
"lid_thickness": "3.2",
|
||||
"corner_radius": "4.0",
|
||||
"board_bottom_clearance": "5.4",
|
||||
"board_side_clearance": "7.0",
|
||||
"board_rear_clearance": "4.0",
|
||||
"usb_board_wall_gap": "1.2",
|
||||
"lid_lip_depth": "2.4",
|
||||
"lid_lip_thickness": "1.2",
|
||||
"fit_clearance": "0.35",
|
||||
"hole_compensation": "0.2",
|
||||
"lid_screw_head_diameter": "5.2",
|
||||
"lid_screw_head_height": "2.5",
|
||||
"usb_opening_width": "12.5",
|
||||
"usb_opening_height": "7.0",
|
||||
"usb_opening_radius": "1.5",
|
||||
"usb_vertical_offset": "0.0"
|
||||
}
|
||||
},
|
||||
"fileFormatVersion": "1"
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
/*
|
||||
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
|
||||
*
|
||||
* Coordinate system:
|
||||
* X: USB-C wall (0) toward the rear of the case
|
||||
* Y: non-battery side (0) toward the battery/JST side
|
||||
* Z: outside bottom (0) upward
|
||||
*
|
||||
* Board coordinates are derived from Adafruit's Eagle PCB files:
|
||||
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
|
||||
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
|
||||
*/
|
||||
|
||||
/* [Output] */
|
||||
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
|
||||
exploded_view = 8; // [0:1:20]
|
||||
show_references = true;
|
||||
|
||||
/* [Stack] */
|
||||
top_profile = "generic"; // [generic,gps,oled128x64]
|
||||
wing_count = 1; // [1:1:8]
|
||||
wing_pitch = 12.25; // [7:0.1:16]
|
||||
stack_height_override = 0; // [0:0.1:100]
|
||||
generic_component_height = 3; // [0:0.1:30]
|
||||
generic_top_gap = 2; // [0.5:0.1:10]
|
||||
gps_antenna_height = 6;
|
||||
gps_top_gap = 5;
|
||||
oled_component_height = 2.4;
|
||||
oled_display_height = 1.5;
|
||||
oled_face_gap = 0.8;
|
||||
|
||||
/* [OLED controls] */
|
||||
button_a = true;
|
||||
button_b = true;
|
||||
button_c = true;
|
||||
button_reset = false;
|
||||
button_projection = 1.5; // [0.5:0.1:3]
|
||||
button_cap_size = [5, 3.2];
|
||||
button_flange_size = [6.6, 3.6];
|
||||
button_flange_thickness = 0.9;
|
||||
button_travel = 0.5;
|
||||
button_contact_extension = 0.7;
|
||||
|
||||
/* [Battery and switch] */
|
||||
battery_enabled = false;
|
||||
battery_length = 69;
|
||||
battery_diameter = 18;
|
||||
battery_radial_clearance = 0.6;
|
||||
battery_end_clearance = 1;
|
||||
switch_hole_diameter = 12;
|
||||
switch_hole_compensation = 0.3;
|
||||
switch_cap_diameter = 8;
|
||||
switch_flange_diameter = 16.2;
|
||||
switch_body_length = 24;
|
||||
switch_body_diameter = 12;
|
||||
switch_wire_allowance = 3;
|
||||
|
||||
/* [Tripod mount] */
|
||||
tripod_insert_enabled = false;
|
||||
tripod_insert_length = 6.4;
|
||||
tripod_insert_hole_diameter = 8.8;
|
||||
tripod_thread_clearance_diameter = 6.8;
|
||||
tripod_boss_diameter = 14;
|
||||
|
||||
/* [M2.5 inserts] */
|
||||
m25_insert_length = 4;
|
||||
m25_insert_hole_diameter = 3.7;
|
||||
m25_screw_clearance = 2.8;
|
||||
m25_boss_diameter = 7;
|
||||
insert_entry_chamfer = 0.6;
|
||||
insert_melt_relief = 0.8;
|
||||
|
||||
/* [Shell and fit] */
|
||||
wall_thickness = 2.4;
|
||||
floor_thickness = 2.4;
|
||||
lid_thickness = 3.2;
|
||||
corner_radius = 4;
|
||||
board_bottom_clearance = 5.4;
|
||||
board_side_clearance = 7;
|
||||
board_rear_clearance = 4;
|
||||
usb_board_wall_gap = 1.2;
|
||||
lid_lip_depth = 2.4;
|
||||
lid_lip_thickness = 1.2;
|
||||
fit_clearance = 0.35;
|
||||
hole_compensation = 0.2;
|
||||
lid_screw_head_diameter = 5.2;
|
||||
lid_screw_head_height = 2.5;
|
||||
|
||||
/* [USB-C opening] */
|
||||
usb_opening_width = 12.5;
|
||||
usb_opening_height = 7;
|
||||
usb_opening_radius = 1.5;
|
||||
usb_vertical_offset = 0;
|
||||
|
||||
/* [Custom wall cutouts] */
|
||||
// Entry format: [face, shape, u, z, width, height, radius]
|
||||
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
|
||||
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
|
||||
extra_cutouts = [];
|
||||
|
||||
/* [Calibration] */
|
||||
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
|
||||
|
||||
$fn = $preview ? 36 : 72;
|
||||
eps = 0.02;
|
||||
|
||||
// Adafruit Feather mechanical reference, millimetres.
|
||||
feather_length = 50.8;
|
||||
feather_width = 22.86;
|
||||
board_thickness = 1.6;
|
||||
mount_x = [2.54, 48.26];
|
||||
mount_y = [2.54, 20.32];
|
||||
usb_center_y = 11.43;
|
||||
|
||||
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
|
||||
oled_visible_min = [11.42, 3.78];
|
||||
oled_visible_size = [30, 15.3];
|
||||
oled_button_a_xy = [4.318, 15.875];
|
||||
oled_button_b_xy = [4.318, 11.43];
|
||||
oled_button_c_xy = [4.318, 6.985];
|
||||
oled_button_reset_xy = [7.366, 20.828];
|
||||
|
||||
effective_floor = tripod_insert_enabled
|
||||
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
|
||||
: floor_thickness;
|
||||
effective_board_clearance = max(
|
||||
board_bottom_clearance,
|
||||
m25_insert_length + insert_melt_relief + 0.6
|
||||
);
|
||||
|
||||
board_origin_x = wall_thickness + usb_board_wall_gap;
|
||||
board_origin_y = wall_thickness + board_side_clearance;
|
||||
board_bottom_z = effective_floor + effective_board_clearance;
|
||||
board_top_z = board_bottom_z + board_thickness;
|
||||
|
||||
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
|
||||
top_pcb_z = stack_height_override > 0
|
||||
? board_top_z + stack_height_override
|
||||
: computed_top_pcb_z;
|
||||
profile_component_height =
|
||||
top_profile == "gps" ? gps_antenna_height :
|
||||
top_profile == "oled128x64" ? oled_component_height :
|
||||
generic_component_height;
|
||||
profile_top_gap =
|
||||
top_profile == "gps" ? gps_top_gap :
|
||||
top_profile == "oled128x64" ? oled_face_gap :
|
||||
generic_top_gap;
|
||||
|
||||
partition_y = board_origin_y + feather_width + 1;
|
||||
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
battery_start_x = board_origin_x + battery_end_clearance;
|
||||
battery_end_x = battery_start_x + battery_length;
|
||||
switch_center_x = battery_end_x + max(
|
||||
switch_body_diameter / 2 + 0.8,
|
||||
switch_flange_diameter / 2 + 0.5
|
||||
);
|
||||
|
||||
case_length_plain = board_origin_x + feather_length
|
||||
+ board_rear_clearance + wall_thickness;
|
||||
case_length_battery = switch_center_x + switch_flange_diameter / 2
|
||||
+ 1.2 + wall_thickness;
|
||||
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
|
||||
: case_length_plain;
|
||||
|
||||
case_width_plain = board_origin_y + feather_width
|
||||
+ board_side_clearance + wall_thickness;
|
||||
case_width_battery = battery_center_y + battery_diameter / 2
|
||||
+ battery_radial_clearance + wall_thickness;
|
||||
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
|
||||
: case_width_plain;
|
||||
|
||||
battery_center_z = effective_floor + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
switch_center_z = max(
|
||||
effective_floor + switch_flange_diameter / 2 + 1,
|
||||
battery_center_z
|
||||
);
|
||||
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
|
||||
battery_ceiling_z = battery_enabled
|
||||
? max(
|
||||
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
|
||||
switch_center_z + switch_flange_diameter / 2 + 1
|
||||
)
|
||||
: 0;
|
||||
body_height = max(electronics_ceiling_z, battery_ceiling_z);
|
||||
|
||||
lid_boss_r = m25_boss_diameter / 2;
|
||||
lid_boss_wall_overlap = 0.6;
|
||||
lid_boss_xy = [
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
]
|
||||
];
|
||||
|
||||
assert(wing_count >= 1, "wing_count must be at least one");
|
||||
assert(top_profile == "generic" || top_profile == "gps"
|
||||
|| top_profile == "oled128x64", "Unsupported top_profile");
|
||||
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
|
||||
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
|
||||
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
|
||||
"M2.5 boss does not have enough radial material");
|
||||
assert(!tripod_insert_enabled
|
||||
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
|
||||
"Tripod insert boss does not have enough radial material");
|
||||
|
||||
function clamp(v, lo, hi) = min(max(v, lo), hi);
|
||||
|
||||
module rounded_rect_2d(size, radius) {
|
||||
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
|
||||
hull() {
|
||||
for (x = [r, size[0] - r], y = [r, size[1] - r])
|
||||
translate([x, y]) circle(r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module centered_rounded_rect_2d(size, radius) {
|
||||
translate([-size[0] / 2, -size[1] / 2])
|
||||
rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module centered_rounded_prism(size, height, radius) {
|
||||
linear_extrude(height = height)
|
||||
centered_rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module rounded_box(size, radius) {
|
||||
linear_extrude(height = size[2])
|
||||
rounded_rect_2d([size[0], size[1]], radius);
|
||||
}
|
||||
|
||||
module insert_pocket(diameter, depth, entry_z, downward = true) {
|
||||
bore_d = diameter + hole_compensation;
|
||||
if (downward) {
|
||||
translate([0, 0, entry_z - depth])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - insert_entry_chamfer])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d,
|
||||
d2 = bore_d + 2 * insert_entry_chamfer
|
||||
);
|
||||
} else {
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d + 2 * insert_entry_chamfer,
|
||||
d2 = bore_d
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module face_rounded_cutout(face, u, z, width, height, radius) {
|
||||
r = clamp(radius, 0.01, min(width, height) / 2);
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front" || face == "rear") {
|
||||
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
|
||||
hull()
|
||||
for (yy = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([x0, yy, zz])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, r = r);
|
||||
} else if (face == "left" || face == "right") {
|
||||
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
|
||||
hull()
|
||||
for (xx = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([xx, y0, zz])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module face_circle_cutout(face, u, z, diameter) {
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front")
|
||||
translate([-eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "rear")
|
||||
translate([case_length - wall_thickness - eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "left")
|
||||
translate([u, -eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "right")
|
||||
translate([u, case_width - wall_thickness - eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
}
|
||||
|
||||
module custom_wall_cutout(entry) {
|
||||
face = entry[0];
|
||||
shape = entry[1];
|
||||
u = entry[2];
|
||||
z = entry[3];
|
||||
width = entry[4];
|
||||
height = len(entry) > 5 ? entry[5] : width;
|
||||
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
|
||||
assert(face == "front" || face == "rear"
|
||||
|| face == "left" || face == "right", "Invalid cutout face");
|
||||
assert(shape == "circle" || shape == "rect"
|
||||
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
|
||||
assert(z - height / 2 >= effective_floor,
|
||||
"Cutout intersects the floor");
|
||||
assert(z + height / 2 <= body_height - lid_lip_depth,
|
||||
"Cutout intersects the lid locating lip");
|
||||
assert(
|
||||
(face == "front" || face == "rear")
|
||||
? u - width / 2 >= 0 && u + width / 2 <= case_width
|
||||
: u - width / 2 >= 0 && u + width / 2 <= case_length,
|
||||
"Cutout extends beyond its wall"
|
||||
);
|
||||
|
||||
if (shape == "circle")
|
||||
face_circle_cutout(face, u, z, width);
|
||||
else
|
||||
face_rounded_cutout(
|
||||
face, u, z, width, height,
|
||||
shape == "rect" ? 0.02 :
|
||||
shape == "slot" ? min(width, height) / 2 : radius
|
||||
);
|
||||
}
|
||||
|
||||
module board_standoffs() {
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([
|
||||
board_origin_x + mx,
|
||||
board_origin_y + my,
|
||||
effective_floor - eps
|
||||
])
|
||||
cylinder(
|
||||
h = effective_board_clearance + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module lid_bosses() {
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], effective_floor - eps])
|
||||
cylinder(
|
||||
h = body_height - effective_floor + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module battery_cradle() {
|
||||
partition_height = min(
|
||||
body_height - lid_lip_depth - 0.5,
|
||||
battery_center_z + battery_diameter / 2 + 1.5
|
||||
);
|
||||
rib_width = 2.4;
|
||||
rib_positions = [
|
||||
battery_start_x + 4,
|
||||
(battery_start_x + battery_end_x) / 2,
|
||||
battery_end_x - 4
|
||||
];
|
||||
|
||||
// Board/battery partition, with a lead-routing notch near the JST end.
|
||||
difference() {
|
||||
translate([wall_thickness, partition_y, effective_floor - eps])
|
||||
cube([
|
||||
battery_end_x - wall_thickness + battery_end_clearance,
|
||||
wall_thickness,
|
||||
partition_height - effective_floor + eps
|
||||
]);
|
||||
translate([
|
||||
board_origin_x + 6.5,
|
||||
partition_y - eps,
|
||||
effective_floor + 1.5
|
||||
])
|
||||
cube([9, wall_thickness + 2 * eps, 6]);
|
||||
}
|
||||
|
||||
// Three low curved ribs cradle the protected cylindrical pack.
|
||||
for (x = rib_positions)
|
||||
difference() {
|
||||
translate([
|
||||
x - rib_width / 2,
|
||||
battery_center_y - battery_diameter / 2
|
||||
- battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
rib_width,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + battery_radial_clearance + eps
|
||||
]);
|
||||
translate([x - rib_width, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(
|
||||
h = rib_width * 2,
|
||||
d = battery_diameter + 2 * battery_radial_clearance
|
||||
);
|
||||
}
|
||||
|
||||
// End stops remain below the cell center so the lid can remove vertically.
|
||||
for (x = [
|
||||
battery_start_x - battery_end_clearance - wall_thickness,
|
||||
battery_end_x + battery_end_clearance
|
||||
])
|
||||
translate([
|
||||
x,
|
||||
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
wall_thickness,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + 1 + eps
|
||||
]);
|
||||
|
||||
// Rear cross-strip separates the side-mounted switch body from the cell.
|
||||
translate([
|
||||
battery_end_x + battery_end_clearance,
|
||||
partition_y,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
case_length - wall_thickness - battery_end_x - battery_end_clearance,
|
||||
case_width - wall_thickness - partition_y,
|
||||
min(body_height - effective_floor, switch_center_z
|
||||
+ switch_body_diameter / 2 + switch_wire_allowance
|
||||
- effective_floor) + eps
|
||||
]);
|
||||
}
|
||||
|
||||
module body_positive() {
|
||||
union() {
|
||||
difference() {
|
||||
rounded_box([case_length, case_width, body_height], corner_radius);
|
||||
translate([wall_thickness, wall_thickness, effective_floor])
|
||||
rounded_box([
|
||||
case_length - 2 * wall_thickness,
|
||||
case_width - 2 * wall_thickness,
|
||||
body_height - effective_floor + eps
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
}
|
||||
board_standoffs();
|
||||
lid_bosses();
|
||||
if (battery_enabled)
|
||||
battery_cradle();
|
||||
if (tripod_insert_enabled)
|
||||
translate([case_length / 2, case_width / 2, 0])
|
||||
cylinder(h = effective_floor, d = tripod_boss_diameter);
|
||||
}
|
||||
}
|
||||
|
||||
module body_cutouts() {
|
||||
// Board mounting insert pockets, loaded from above before the Feather.
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([board_origin_x + mx, board_origin_y + my, 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
board_bottom_z
|
||||
);
|
||||
|
||||
// Lid insert pockets, loaded from the top of the body.
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
body_height
|
||||
);
|
||||
|
||||
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
|
||||
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
|
||||
face_rounded_cutout(
|
||||
"front",
|
||||
board_origin_y + usb_center_y,
|
||||
usb_center_z,
|
||||
usb_opening_width + hole_compensation,
|
||||
usb_opening_height + hole_compensation,
|
||||
usb_opening_radius
|
||||
);
|
||||
|
||||
if (battery_enabled) {
|
||||
face_circle_cutout(
|
||||
"right",
|
||||
switch_center_x,
|
||||
switch_center_z,
|
||||
switch_hole_diameter + switch_hole_compensation
|
||||
);
|
||||
// Clear the switch body and its prewired terminals through the rear bay.
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness + eps,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(
|
||||
h = switch_body_length + switch_wire_allowance
|
||||
+ wall_thickness + eps,
|
||||
d = switch_body_diameter + 2 * fit_clearance
|
||||
);
|
||||
}
|
||||
|
||||
if (tripod_insert_enabled) {
|
||||
translate([case_length / 2, case_width / 2, 0]) {
|
||||
translate([0, 0, -eps])
|
||||
cylinder(
|
||||
h = effective_floor + 2 * eps,
|
||||
d = tripod_thread_clearance_diameter + hole_compensation
|
||||
);
|
||||
insert_pocket(
|
||||
tripod_insert_hole_diameter,
|
||||
tripod_insert_length + insert_melt_relief,
|
||||
effective_floor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (entry = extra_cutouts)
|
||||
custom_wall_cutout(entry);
|
||||
}
|
||||
|
||||
module body() {
|
||||
difference() {
|
||||
body_positive();
|
||||
body_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module lid_plate_positive() {
|
||||
union() {
|
||||
rounded_box([case_length, case_width, lid_thickness], corner_radius);
|
||||
// Inset rim: exported pointing upward so the outer lid face prints flat.
|
||||
translate([
|
||||
wall_thickness + fit_clearance,
|
||||
wall_thickness + fit_clearance,
|
||||
lid_thickness
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth)
|
||||
difference() {
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (wall_thickness + fit_clearance),
|
||||
case_width - 2 * (wall_thickness + fit_clearance)
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
),
|
||||
case_width - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
)
|
||||
], max(0.5, corner_radius - wall_thickness
|
||||
- lid_lip_thickness));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module beveled_oled_window() {
|
||||
inner_size = oled_visible_size;
|
||||
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
|
||||
center = [
|
||||
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
|
||||
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
|
||||
];
|
||||
hull() {
|
||||
translate([center[0], center[1], -eps])
|
||||
linear_extrude(height = eps)
|
||||
centered_rounded_rect_2d(outer_size, 1.5);
|
||||
translate([center[0], center[1], lid_thickness - eps])
|
||||
linear_extrude(height = 2 * eps)
|
||||
centered_rounded_rect_2d(inner_size, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
module oled_button_lid_cutout(xy) {
|
||||
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
|
||||
shaft_size = [
|
||||
button_cap_size[0] + fit_clearance * 2,
|
||||
button_cap_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_size = [
|
||||
button_flange_size[0] + fit_clearance * 2,
|
||||
button_flange_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
translate([center[0], center[1], -eps])
|
||||
centered_rounded_prism(
|
||||
shaft_size,
|
||||
lid_thickness + 2 * eps,
|
||||
shaft_size[1] / 2
|
||||
);
|
||||
translate([center[0], center[1], lid_thickness - recess_depth])
|
||||
centered_rounded_prism(
|
||||
recess_size,
|
||||
recess_depth + lid_lip_depth + eps,
|
||||
recess_size[1] / 2
|
||||
);
|
||||
}
|
||||
|
||||
module lid_cutouts() {
|
||||
// Through holes plus shallow counterbores for flush M2.5 socket heads.
|
||||
for (p = lid_boss_xy) {
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_thickness + lid_lip_depth + 2 * eps,
|
||||
d = m25_screw_clearance + hole_compensation
|
||||
);
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_screw_head_height + eps,
|
||||
d = lid_screw_head_diameter + hole_compensation
|
||||
);
|
||||
}
|
||||
|
||||
if (top_profile == "oled128x64") {
|
||||
beveled_oled_window();
|
||||
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
|
||||
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
|
||||
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
|
||||
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
|
||||
}
|
||||
}
|
||||
|
||||
module lid() {
|
||||
difference() {
|
||||
lid_plate_positive();
|
||||
lid_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module one_oled_button() {
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
stem_height = lid_thickness - recess_depth + button_projection;
|
||||
// Print outer cap face down. The flange and contact nub build upward.
|
||||
centered_rounded_prism(
|
||||
button_cap_size,
|
||||
stem_height,
|
||||
button_cap_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height])
|
||||
centered_rounded_prism(
|
||||
button_flange_size,
|
||||
button_flange_thickness,
|
||||
button_flange_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height + button_flange_thickness])
|
||||
cylinder(h = button_contact_extension, d = 3);
|
||||
}
|
||||
|
||||
module buttons() {
|
||||
enabled = [
|
||||
button_a,
|
||||
button_b,
|
||||
button_c,
|
||||
button_reset
|
||||
];
|
||||
count_enabled = len([for (v = enabled) if (v) 1]);
|
||||
if (count_enabled == 0)
|
||||
echo("No OLED buttons are enabled.");
|
||||
for (i = [0 : len(enabled) - 1])
|
||||
if (enabled[i])
|
||||
translate([i * (button_flange_size[0] + 3), 0, 0])
|
||||
one_oled_button();
|
||||
}
|
||||
|
||||
module reference_board() {
|
||||
color([0.05, 0.25, 0.55, 0.75])
|
||||
translate([board_origin_x, board_origin_y, board_bottom_z])
|
||||
rounded_box([feather_length, feather_width, board_thickness], 2.54);
|
||||
color([0.12, 0.45, 0.75, 0.45])
|
||||
for (i = [1 : wing_count])
|
||||
translate([
|
||||
board_origin_x,
|
||||
board_origin_y,
|
||||
board_top_z + i * wing_pitch - board_thickness
|
||||
])
|
||||
rounded_box(
|
||||
[feather_length, feather_width, board_thickness], 2.54
|
||||
);
|
||||
}
|
||||
|
||||
module reference_top_feature() {
|
||||
if (top_profile == "gps")
|
||||
color([0.92, 0.78, 0.35, 0.8])
|
||||
translate([
|
||||
board_origin_x + 31,
|
||||
board_origin_y + (feather_width - 15) / 2,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([15, 15, gps_antenna_height]);
|
||||
else if (top_profile == "oled128x64") {
|
||||
color([0.12, 0.12, 0.12, 0.9])
|
||||
translate([
|
||||
board_origin_x + oled_visible_min[0],
|
||||
board_origin_y + oled_visible_min[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
oled_visible_size[0],
|
||||
oled_visible_size[1],
|
||||
oled_display_height
|
||||
]);
|
||||
color([0.6, 0.6, 0.6, 0.9])
|
||||
for (xy = [
|
||||
oled_button_a_xy,
|
||||
oled_button_b_xy,
|
||||
oled_button_c_xy,
|
||||
oled_button_reset_xy
|
||||
])
|
||||
translate([
|
||||
board_origin_x + xy[0],
|
||||
board_origin_y + xy[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cylinder(h = 1.6, d = 2.1);
|
||||
} else
|
||||
color([0.3, 0.65, 0.35, 0.6])
|
||||
translate([
|
||||
board_origin_x + 10,
|
||||
board_origin_y + 4,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
feather_length - 20,
|
||||
feather_width - 8,
|
||||
generic_component_height
|
||||
]);
|
||||
}
|
||||
|
||||
module reference_battery_and_switch() {
|
||||
if (battery_enabled) {
|
||||
color([0.35, 0.18, 0.55, 0.75])
|
||||
translate([battery_start_x, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(h = battery_length, d = battery_diameter);
|
||||
color([0.8, 0.15, 0.1, 0.65])
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0]) {
|
||||
cylinder(h = switch_body_length, d = switch_body_diameter);
|
||||
translate([0, 0, -1.5])
|
||||
cylinder(h = 1.5, d = switch_flange_diameter);
|
||||
translate([0, 0, -3])
|
||||
cylinder(h = 3, d = switch_cap_diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module assembly() {
|
||||
color([0.82, 0.84, 0.88, 1]) body();
|
||||
if ($preview && show_references) {
|
||||
reference_board();
|
||||
reference_top_feature();
|
||||
reference_battery_and_switch();
|
||||
}
|
||||
// Printable lid is mirrored into its installed orientation and exploded up.
|
||||
color([0.92, 0.92, 0.95, 0.88])
|
||||
translate([0, case_width, body_height + lid_thickness + exploded_view])
|
||||
rotate([180, 0, 0])
|
||||
lid();
|
||||
}
|
||||
|
||||
module coupon_hole(x, y, diameter, depth) {
|
||||
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
|
||||
}
|
||||
|
||||
module insert_coupon() {
|
||||
spacing = 15;
|
||||
coupon_length = spacing * len(coupon_steps) + 8;
|
||||
coupon_width = 32;
|
||||
coupon_height = max(
|
||||
m25_insert_length + insert_melt_relief + 1.2,
|
||||
tripod_insert_length + insert_melt_relief + 1.2
|
||||
);
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1]) {
|
||||
x = 7 + i * spacing;
|
||||
coupon_hole(
|
||||
x, 8,
|
||||
m25_insert_hole_diameter + coupon_steps[i],
|
||||
m25_insert_length + insert_melt_relief
|
||||
);
|
||||
coupon_hole(
|
||||
x, 23,
|
||||
tripod_insert_hole_diameter + coupon_steps[i],
|
||||
tripod_insert_length + insert_melt_relief
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module switch_coupon() {
|
||||
spacing = 18;
|
||||
coupon_length = spacing * len(coupon_steps) + 6;
|
||||
coupon_width = 24;
|
||||
coupon_height = wall_thickness;
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1])
|
||||
translate([6 + i * spacing, coupon_width / 2, -eps])
|
||||
cylinder(
|
||||
h = coupon_height + 2 * eps,
|
||||
d = switch_hole_diameter + coupon_steps[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module fit_coupon() {
|
||||
segment_length = 34;
|
||||
base_width = 16;
|
||||
base_height = 6;
|
||||
cavity_size = [segment_length - 8, base_width - 8];
|
||||
male_size = [
|
||||
cavity_size[0] - 2 * fit_clearance,
|
||||
cavity_size[1] - 2 * fit_clearance
|
||||
];
|
||||
difference() {
|
||||
rounded_box([segment_length, base_width, base_height], 2);
|
||||
translate([4, 4, 2])
|
||||
cube([segment_length - 8, base_width - 8, base_height]);
|
||||
}
|
||||
translate([segment_length + 5, 0, 0])
|
||||
union() {
|
||||
cube([segment_length, base_width, lid_thickness]);
|
||||
translate([
|
||||
4 + fit_clearance,
|
||||
4 + fit_clearance,
|
||||
lid_thickness - eps
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth + eps)
|
||||
difference() {
|
||||
rounded_rect_2d(male_size, 1);
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
male_size[0] - 2 * lid_lip_thickness,
|
||||
male_size[1] - 2 * lid_lip_thickness
|
||||
], 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part == "assembly")
|
||||
assembly();
|
||||
else if (part == "body")
|
||||
body();
|
||||
else if (part == "lid")
|
||||
lid();
|
||||
else if (part == "buttons")
|
||||
buttons();
|
||||
else if (part == "insert_coupon")
|
||||
insert_coupon();
|
||||
else if (part == "switch_coupon")
|
||||
switch_coupon();
|
||||
else if (part == "fit_coupon")
|
||||
fit_coupon();
|
||||
else
|
||||
assert(false, str("Unknown part: ", part));
|
||||
@@ -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()
|
||||
@@ -0,0 +1,25 @@
|
||||
# Attitude IMU
|
||||
|
||||
Generated by the Feather Case Wizard.
|
||||
|
||||
- Top profile: `generic`
|
||||
- FeatherWings: 1
|
||||
- Battery compartment: no
|
||||
- 1/4-20 insert: yes
|
||||
- Approximate exterior: 60.8 × 41.7 × 35.8 mm
|
||||
- Formats: 3mf
|
||||
- Parts: `body`, `lid`
|
||||
- Custom cutouts: 0
|
||||
|
||||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||||
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
|
||||
override their executable paths.
|
||||
|
||||
To open the saved Customizer values manually:
|
||||
|
||||
```bash
|
||||
openscad -p parameters.json -P attitude-imu source/feather_case.scad
|
||||
```
|
||||
|
||||
The 3MF files contain portable geometry only. Choose printer, filament,
|
||||
plate, and slicing settings in Bambu Studio.
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "pathname"
|
||||
require "tmpdir"
|
||||
require "yaml"
|
||||
|
||||
ROOT = Pathname(__dir__).expand_path
|
||||
|
||||
def executable(env_name, command)
|
||||
override = ENV[env_name]
|
||||
return File.expand_path(override) if override && File.executable?(override)
|
||||
|
||||
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
||||
candidate = File.join(directory, command)
|
||||
return candidate if File.file?(candidate) && File.executable?(candidate)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def literal(value)
|
||||
case value
|
||||
when String then JSON.generate(value)
|
||||
when TrueClass then "true"
|
||||
when FalseClass then "false"
|
||||
when Numeric then value.to_s
|
||||
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
|
||||
else raise "Cannot convert #{value.inspect} to OpenSCAD"
|
||||
end
|
||||
end
|
||||
|
||||
def run!(*command, env: {})
|
||||
stdout, stderr, status = Open3.capture3(env, *command)
|
||||
return if status.success?
|
||||
|
||||
warn [stdout, stderr].reject(&:empty?).join("\n")
|
||||
raise "Command failed (#{status.exitstatus}): #{command.first}"
|
||||
end
|
||||
|
||||
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
|
||||
permitted_symbols: [], aliases: false)
|
||||
openscad = executable("OPENSCAD", "openscad")
|
||||
blender = executable("BLENDER", "blender")
|
||||
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
|
||||
abort "Blender not found; install it or set BLENDER" unless blender
|
||||
|
||||
parameters = config.fetch("parameters").merge(
|
||||
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
|
||||
)
|
||||
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
|
||||
buttons = %w[button_a button_b button_c button_reset]
|
||||
if parameters["top_profile"] == "oled128x64" &&
|
||||
buttons.any? { |name| parameters[name] }
|
||||
parts << "buttons"
|
||||
end
|
||||
|
||||
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
|
||||
begin
|
||||
exports = stage.join("exports")
|
||||
previews = stage.join("previews")
|
||||
scratch = stage.join("meshes")
|
||||
FileUtils.mkdir_p([exports, previews, scratch])
|
||||
model = ROOT.join("source/feather_case.scad")
|
||||
renderer = ROOT.join("source/render_stl_previews.py")
|
||||
|
||||
export = lambda do |output, part, overrides = {}|
|
||||
values = parameters.merge(overrides).merge("part" => part)
|
||||
command = [openscad, "-o", output.to_s]
|
||||
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
|
||||
run!(*command, model.to_s)
|
||||
end
|
||||
|
||||
config.fetch("outputs").fetch("formats").each do |format|
|
||||
directory = exports.join(format)
|
||||
FileUtils.mkdir_p(directory)
|
||||
parts.each do |part|
|
||||
puts "Exporting #{part}.#{format}…"
|
||||
export.call(directory.join("#{part}.#{format}"), part)
|
||||
end
|
||||
end
|
||||
|
||||
assembly = scratch.join("assembly.stl")
|
||||
puts "Preparing assembly preview…"
|
||||
export.call(assembly, "assembly",
|
||||
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
|
||||
"show_references" => false)
|
||||
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
assembly.to_s, previews.to_s, "assembly", env: render_env)
|
||||
parts.each do |part|
|
||||
puts "Rendering #{part} preview…"
|
||||
mesh = scratch.join("#{part}.stl")
|
||||
export.call(mesh, part) unless mesh.file?
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
mesh.to_s, previews.to_s, part, "iso", env: render_env)
|
||||
end
|
||||
|
||||
backup = ROOT.join(".build-old-#{Process.pid}")
|
||||
FileUtils.mkdir_p(backup)
|
||||
%w[exports previews].each do |name|
|
||||
current = ROOT.join(name)
|
||||
FileUtils.mv(current, backup.join(name)) if current.exist?
|
||||
end
|
||||
begin
|
||||
FileUtils.mv(exports, ROOT.join("exports"))
|
||||
FileUtils.mv(previews, ROOT.join("previews"))
|
||||
FileUtils.rm_rf(backup)
|
||||
rescue StandardError
|
||||
FileUtils.rm_rf(ROOT.join("exports"))
|
||||
FileUtils.rm_rf(ROOT.join("previews"))
|
||||
%w[exports previews].each do |name|
|
||||
saved = backup.join(name)
|
||||
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
|
||||
end
|
||||
raise
|
||||
end
|
||||
puts "Rebuilt #{ROOT}"
|
||||
ensure
|
||||
FileUtils.rm_rf(stage)
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
schema_version: 1
|
||||
name: Attitude IMU
|
||||
slug: attitude-imu
|
||||
generator:
|
||||
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
|
||||
created_with: feather-case-wizard
|
||||
parameters:
|
||||
top_profile: generic
|
||||
wing_count: 1
|
||||
wing_pitch: 12.25
|
||||
stack_height_override: 0.0
|
||||
generic_component_height: 3.0
|
||||
generic_top_gap: 2.0
|
||||
gps_antenna_height: 6.0
|
||||
gps_top_gap: 5.0
|
||||
oled_component_height: 2.4
|
||||
oled_display_height: 1.5
|
||||
oled_face_gap: 0.8
|
||||
button_a: true
|
||||
button_b: true
|
||||
button_c: true
|
||||
button_reset: false
|
||||
button_projection: 1.5
|
||||
button_cap_size:
|
||||
- 5.0
|
||||
- 3.2
|
||||
button_flange_size:
|
||||
- 6.6
|
||||
- 3.6
|
||||
button_flange_thickness: 0.9
|
||||
button_travel: 0.5
|
||||
button_contact_extension: 0.7
|
||||
battery_enabled: false
|
||||
battery_length: 69.0
|
||||
battery_diameter: 18.0
|
||||
battery_radial_clearance: 0.6
|
||||
battery_end_clearance: 1.0
|
||||
switch_hole_diameter: 12.0
|
||||
switch_hole_compensation: 0.3
|
||||
switch_cap_diameter: 8.0
|
||||
switch_flange_diameter: 16.2
|
||||
switch_body_length: 24.0
|
||||
switch_body_diameter: 12.0
|
||||
switch_wire_allowance: 3.0
|
||||
tripod_insert_enabled: true
|
||||
tripod_insert_length: 6.4
|
||||
tripod_insert_hole_diameter: 8.8
|
||||
tripod_thread_clearance_diameter: 6.8
|
||||
tripod_boss_diameter: 14.0
|
||||
m25_insert_length: 4.0
|
||||
m25_insert_hole_diameter: 3.7
|
||||
m25_screw_clearance: 2.8
|
||||
m25_boss_diameter: 7.0
|
||||
insert_entry_chamfer: 0.6
|
||||
insert_melt_relief: 0.8
|
||||
wall_thickness: 2.4
|
||||
floor_thickness: 2.4
|
||||
lid_thickness: 3.2
|
||||
corner_radius: 4.0
|
||||
board_bottom_clearance: 5.4
|
||||
board_side_clearance: 7.0
|
||||
board_rear_clearance: 4.0
|
||||
usb_board_wall_gap: 1.2
|
||||
lid_lip_depth: 2.4
|
||||
lid_lip_thickness: 1.2
|
||||
fit_clearance: 0.35
|
||||
hole_compensation: 0.2
|
||||
lid_screw_head_diameter: 5.2
|
||||
lid_screw_head_height: 2.5
|
||||
usb_opening_width: 12.5
|
||||
usb_opening_height: 7.0
|
||||
usb_opening_radius: 1.5
|
||||
usb_vertical_offset: 0.0
|
||||
cutouts: []
|
||||
outputs:
|
||||
formats:
|
||||
- 3mf
|
||||
coupons: []
|
||||
assembly_exploded_view: 8
|
||||
resolved:
|
||||
extra_cutouts: []
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"parameterSets": {
|
||||
"attitude-imu": {
|
||||
"top_profile": "\"generic\"",
|
||||
"wing_count": "1",
|
||||
"wing_pitch": "12.25",
|
||||
"stack_height_override": "0.0",
|
||||
"generic_component_height": "3.0",
|
||||
"generic_top_gap": "2.0",
|
||||
"gps_antenna_height": "6.0",
|
||||
"gps_top_gap": "5.0",
|
||||
"oled_component_height": "2.4",
|
||||
"oled_display_height": "1.5",
|
||||
"oled_face_gap": "0.8",
|
||||
"button_a": "true",
|
||||
"button_b": "true",
|
||||
"button_c": "true",
|
||||
"button_reset": "false",
|
||||
"button_projection": "1.5",
|
||||
"button_cap_size": "[5.0,3.2]",
|
||||
"button_flange_size": "[6.6,3.6]",
|
||||
"button_flange_thickness": "0.9",
|
||||
"button_travel": "0.5",
|
||||
"button_contact_extension": "0.7",
|
||||
"battery_enabled": "false",
|
||||
"battery_length": "69.0",
|
||||
"battery_diameter": "18.0",
|
||||
"battery_radial_clearance": "0.6",
|
||||
"battery_end_clearance": "1.0",
|
||||
"switch_hole_diameter": "12.0",
|
||||
"switch_hole_compensation": "0.3",
|
||||
"switch_cap_diameter": "8.0",
|
||||
"switch_flange_diameter": "16.2",
|
||||
"switch_body_length": "24.0",
|
||||
"switch_body_diameter": "12.0",
|
||||
"switch_wire_allowance": "3.0",
|
||||
"tripod_insert_enabled": "true",
|
||||
"tripod_insert_length": "6.4",
|
||||
"tripod_insert_hole_diameter": "8.8",
|
||||
"tripod_thread_clearance_diameter": "6.8",
|
||||
"tripod_boss_diameter": "14.0",
|
||||
"m25_insert_length": "4.0",
|
||||
"m25_insert_hole_diameter": "3.7",
|
||||
"m25_screw_clearance": "2.8",
|
||||
"m25_boss_diameter": "7.0",
|
||||
"insert_entry_chamfer": "0.6",
|
||||
"insert_melt_relief": "0.8",
|
||||
"wall_thickness": "2.4",
|
||||
"floor_thickness": "2.4",
|
||||
"lid_thickness": "3.2",
|
||||
"corner_radius": "4.0",
|
||||
"board_bottom_clearance": "5.4",
|
||||
"board_side_clearance": "7.0",
|
||||
"board_rear_clearance": "4.0",
|
||||
"usb_board_wall_gap": "1.2",
|
||||
"lid_lip_depth": "2.4",
|
||||
"lid_lip_thickness": "1.2",
|
||||
"fit_clearance": "0.35",
|
||||
"hole_compensation": "0.2",
|
||||
"lid_screw_head_diameter": "5.2",
|
||||
"lid_screw_head_height": "2.5",
|
||||
"usb_opening_width": "12.5",
|
||||
"usb_opening_height": "7.0",
|
||||
"usb_opening_radius": "1.5",
|
||||
"usb_vertical_offset": "0.0"
|
||||
}
|
||||
},
|
||||
"fileFormatVersion": "1"
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
/*
|
||||
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
|
||||
*
|
||||
* Coordinate system:
|
||||
* X: USB-C wall (0) toward the rear of the case
|
||||
* Y: non-battery side (0) toward the battery/JST side
|
||||
* Z: outside bottom (0) upward
|
||||
*
|
||||
* Board coordinates are derived from Adafruit's Eagle PCB files:
|
||||
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
|
||||
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
|
||||
*/
|
||||
|
||||
/* [Output] */
|
||||
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
|
||||
exploded_view = 8; // [0:1:20]
|
||||
show_references = true;
|
||||
|
||||
/* [Stack] */
|
||||
top_profile = "generic"; // [generic,gps,oled128x64]
|
||||
wing_count = 1; // [1:1:8]
|
||||
wing_pitch = 12.25; // [7:0.1:16]
|
||||
stack_height_override = 0; // [0:0.1:100]
|
||||
generic_component_height = 3; // [0:0.1:30]
|
||||
generic_top_gap = 2; // [0.5:0.1:10]
|
||||
gps_antenna_height = 6;
|
||||
gps_top_gap = 5;
|
||||
oled_component_height = 2.4;
|
||||
oled_display_height = 1.5;
|
||||
oled_face_gap = 0.8;
|
||||
|
||||
/* [OLED controls] */
|
||||
button_a = true;
|
||||
button_b = true;
|
||||
button_c = true;
|
||||
button_reset = false;
|
||||
button_projection = 1.5; // [0.5:0.1:3]
|
||||
button_cap_size = [5, 3.2];
|
||||
button_flange_size = [6.6, 3.6];
|
||||
button_flange_thickness = 0.9;
|
||||
button_travel = 0.5;
|
||||
button_contact_extension = 0.7;
|
||||
|
||||
/* [Battery and switch] */
|
||||
battery_enabled = false;
|
||||
battery_length = 69;
|
||||
battery_diameter = 18;
|
||||
battery_radial_clearance = 0.6;
|
||||
battery_end_clearance = 1;
|
||||
switch_hole_diameter = 12;
|
||||
switch_hole_compensation = 0.3;
|
||||
switch_cap_diameter = 8;
|
||||
switch_flange_diameter = 16.2;
|
||||
switch_body_length = 24;
|
||||
switch_body_diameter = 12;
|
||||
switch_wire_allowance = 3;
|
||||
|
||||
/* [Tripod mount] */
|
||||
tripod_insert_enabled = false;
|
||||
tripod_insert_length = 6.4;
|
||||
tripod_insert_hole_diameter = 8.8;
|
||||
tripod_thread_clearance_diameter = 6.8;
|
||||
tripod_boss_diameter = 14;
|
||||
|
||||
/* [M2.5 inserts] */
|
||||
m25_insert_length = 4;
|
||||
m25_insert_hole_diameter = 3.7;
|
||||
m25_screw_clearance = 2.8;
|
||||
m25_boss_diameter = 7;
|
||||
insert_entry_chamfer = 0.6;
|
||||
insert_melt_relief = 0.8;
|
||||
|
||||
/* [Shell and fit] */
|
||||
wall_thickness = 2.4;
|
||||
floor_thickness = 2.4;
|
||||
lid_thickness = 3.2;
|
||||
corner_radius = 4;
|
||||
board_bottom_clearance = 5.4;
|
||||
board_side_clearance = 7;
|
||||
board_rear_clearance = 4;
|
||||
usb_board_wall_gap = 1.2;
|
||||
lid_lip_depth = 2.4;
|
||||
lid_lip_thickness = 1.2;
|
||||
fit_clearance = 0.35;
|
||||
hole_compensation = 0.2;
|
||||
lid_screw_head_diameter = 5.2;
|
||||
lid_screw_head_height = 2.5;
|
||||
|
||||
/* [USB-C opening] */
|
||||
usb_opening_width = 12.5;
|
||||
usb_opening_height = 7;
|
||||
usb_opening_radius = 1.5;
|
||||
usb_vertical_offset = 0;
|
||||
|
||||
/* [Custom wall cutouts] */
|
||||
// Entry format: [face, shape, u, z, width, height, radius]
|
||||
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
|
||||
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
|
||||
extra_cutouts = [];
|
||||
|
||||
/* [Calibration] */
|
||||
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
|
||||
|
||||
$fn = $preview ? 36 : 72;
|
||||
eps = 0.02;
|
||||
|
||||
// Adafruit Feather mechanical reference, millimetres.
|
||||
feather_length = 50.8;
|
||||
feather_width = 22.86;
|
||||
board_thickness = 1.6;
|
||||
mount_x = [2.54, 48.26];
|
||||
mount_y = [2.54, 20.32];
|
||||
usb_center_y = 11.43;
|
||||
|
||||
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
|
||||
oled_visible_min = [11.42, 3.78];
|
||||
oled_visible_size = [30, 15.3];
|
||||
oled_button_a_xy = [4.318, 15.875];
|
||||
oled_button_b_xy = [4.318, 11.43];
|
||||
oled_button_c_xy = [4.318, 6.985];
|
||||
oled_button_reset_xy = [7.366, 20.828];
|
||||
|
||||
effective_floor = tripod_insert_enabled
|
||||
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
|
||||
: floor_thickness;
|
||||
effective_board_clearance = max(
|
||||
board_bottom_clearance,
|
||||
m25_insert_length + insert_melt_relief + 0.6
|
||||
);
|
||||
|
||||
board_origin_x = wall_thickness + usb_board_wall_gap;
|
||||
board_origin_y = wall_thickness + board_side_clearance;
|
||||
board_bottom_z = effective_floor + effective_board_clearance;
|
||||
board_top_z = board_bottom_z + board_thickness;
|
||||
|
||||
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
|
||||
top_pcb_z = stack_height_override > 0
|
||||
? board_top_z + stack_height_override
|
||||
: computed_top_pcb_z;
|
||||
profile_component_height =
|
||||
top_profile == "gps" ? gps_antenna_height :
|
||||
top_profile == "oled128x64" ? oled_component_height :
|
||||
generic_component_height;
|
||||
profile_top_gap =
|
||||
top_profile == "gps" ? gps_top_gap :
|
||||
top_profile == "oled128x64" ? oled_face_gap :
|
||||
generic_top_gap;
|
||||
|
||||
partition_y = board_origin_y + feather_width + 1;
|
||||
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
battery_start_x = board_origin_x + battery_end_clearance;
|
||||
battery_end_x = battery_start_x + battery_length;
|
||||
switch_center_x = battery_end_x + max(
|
||||
switch_body_diameter / 2 + 0.8,
|
||||
switch_flange_diameter / 2 + 0.5
|
||||
);
|
||||
|
||||
case_length_plain = board_origin_x + feather_length
|
||||
+ board_rear_clearance + wall_thickness;
|
||||
case_length_battery = switch_center_x + switch_flange_diameter / 2
|
||||
+ 1.2 + wall_thickness;
|
||||
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
|
||||
: case_length_plain;
|
||||
|
||||
case_width_plain = board_origin_y + feather_width
|
||||
+ board_side_clearance + wall_thickness;
|
||||
case_width_battery = battery_center_y + battery_diameter / 2
|
||||
+ battery_radial_clearance + wall_thickness;
|
||||
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
|
||||
: case_width_plain;
|
||||
|
||||
battery_center_z = effective_floor + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
switch_center_z = max(
|
||||
effective_floor + switch_flange_diameter / 2 + 1,
|
||||
battery_center_z
|
||||
);
|
||||
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
|
||||
battery_ceiling_z = battery_enabled
|
||||
? max(
|
||||
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
|
||||
switch_center_z + switch_flange_diameter / 2 + 1
|
||||
)
|
||||
: 0;
|
||||
body_height = max(electronics_ceiling_z, battery_ceiling_z);
|
||||
|
||||
lid_boss_r = m25_boss_diameter / 2;
|
||||
lid_boss_wall_overlap = 0.6;
|
||||
lid_boss_xy = [
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
]
|
||||
];
|
||||
|
||||
assert(wing_count >= 1, "wing_count must be at least one");
|
||||
assert(top_profile == "generic" || top_profile == "gps"
|
||||
|| top_profile == "oled128x64", "Unsupported top_profile");
|
||||
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
|
||||
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
|
||||
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
|
||||
"M2.5 boss does not have enough radial material");
|
||||
assert(!tripod_insert_enabled
|
||||
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
|
||||
"Tripod insert boss does not have enough radial material");
|
||||
|
||||
function clamp(v, lo, hi) = min(max(v, lo), hi);
|
||||
|
||||
module rounded_rect_2d(size, radius) {
|
||||
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
|
||||
hull() {
|
||||
for (x = [r, size[0] - r], y = [r, size[1] - r])
|
||||
translate([x, y]) circle(r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module centered_rounded_rect_2d(size, radius) {
|
||||
translate([-size[0] / 2, -size[1] / 2])
|
||||
rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module centered_rounded_prism(size, height, radius) {
|
||||
linear_extrude(height = height)
|
||||
centered_rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module rounded_box(size, radius) {
|
||||
linear_extrude(height = size[2])
|
||||
rounded_rect_2d([size[0], size[1]], radius);
|
||||
}
|
||||
|
||||
module insert_pocket(diameter, depth, entry_z, downward = true) {
|
||||
bore_d = diameter + hole_compensation;
|
||||
if (downward) {
|
||||
translate([0, 0, entry_z - depth])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - insert_entry_chamfer])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d,
|
||||
d2 = bore_d + 2 * insert_entry_chamfer
|
||||
);
|
||||
} else {
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d + 2 * insert_entry_chamfer,
|
||||
d2 = bore_d
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module face_rounded_cutout(face, u, z, width, height, radius) {
|
||||
r = clamp(radius, 0.01, min(width, height) / 2);
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front" || face == "rear") {
|
||||
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
|
||||
hull()
|
||||
for (yy = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([x0, yy, zz])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, r = r);
|
||||
} else if (face == "left" || face == "right") {
|
||||
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
|
||||
hull()
|
||||
for (xx = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([xx, y0, zz])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module face_circle_cutout(face, u, z, diameter) {
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front")
|
||||
translate([-eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "rear")
|
||||
translate([case_length - wall_thickness - eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "left")
|
||||
translate([u, -eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "right")
|
||||
translate([u, case_width - wall_thickness - eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
}
|
||||
|
||||
module custom_wall_cutout(entry) {
|
||||
face = entry[0];
|
||||
shape = entry[1];
|
||||
u = entry[2];
|
||||
z = entry[3];
|
||||
width = entry[4];
|
||||
height = len(entry) > 5 ? entry[5] : width;
|
||||
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
|
||||
assert(face == "front" || face == "rear"
|
||||
|| face == "left" || face == "right", "Invalid cutout face");
|
||||
assert(shape == "circle" || shape == "rect"
|
||||
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
|
||||
assert(z - height / 2 >= effective_floor,
|
||||
"Cutout intersects the floor");
|
||||
assert(z + height / 2 <= body_height - lid_lip_depth,
|
||||
"Cutout intersects the lid locating lip");
|
||||
assert(
|
||||
(face == "front" || face == "rear")
|
||||
? u - width / 2 >= 0 && u + width / 2 <= case_width
|
||||
: u - width / 2 >= 0 && u + width / 2 <= case_length,
|
||||
"Cutout extends beyond its wall"
|
||||
);
|
||||
|
||||
if (shape == "circle")
|
||||
face_circle_cutout(face, u, z, width);
|
||||
else
|
||||
face_rounded_cutout(
|
||||
face, u, z, width, height,
|
||||
shape == "rect" ? 0.02 :
|
||||
shape == "slot" ? min(width, height) / 2 : radius
|
||||
);
|
||||
}
|
||||
|
||||
module board_standoffs() {
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([
|
||||
board_origin_x + mx,
|
||||
board_origin_y + my,
|
||||
effective_floor - eps
|
||||
])
|
||||
cylinder(
|
||||
h = effective_board_clearance + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module lid_bosses() {
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], effective_floor - eps])
|
||||
cylinder(
|
||||
h = body_height - effective_floor + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module battery_cradle() {
|
||||
partition_height = min(
|
||||
body_height - lid_lip_depth - 0.5,
|
||||
battery_center_z + battery_diameter / 2 + 1.5
|
||||
);
|
||||
rib_width = 2.4;
|
||||
rib_positions = [
|
||||
battery_start_x + 4,
|
||||
(battery_start_x + battery_end_x) / 2,
|
||||
battery_end_x - 4
|
||||
];
|
||||
|
||||
// Board/battery partition, with a lead-routing notch near the JST end.
|
||||
difference() {
|
||||
translate([wall_thickness, partition_y, effective_floor - eps])
|
||||
cube([
|
||||
battery_end_x - wall_thickness + battery_end_clearance,
|
||||
wall_thickness,
|
||||
partition_height - effective_floor + eps
|
||||
]);
|
||||
translate([
|
||||
board_origin_x + 6.5,
|
||||
partition_y - eps,
|
||||
effective_floor + 1.5
|
||||
])
|
||||
cube([9, wall_thickness + 2 * eps, 6]);
|
||||
}
|
||||
|
||||
// Three low curved ribs cradle the protected cylindrical pack.
|
||||
for (x = rib_positions)
|
||||
difference() {
|
||||
translate([
|
||||
x - rib_width / 2,
|
||||
battery_center_y - battery_diameter / 2
|
||||
- battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
rib_width,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + battery_radial_clearance + eps
|
||||
]);
|
||||
translate([x - rib_width, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(
|
||||
h = rib_width * 2,
|
||||
d = battery_diameter + 2 * battery_radial_clearance
|
||||
);
|
||||
}
|
||||
|
||||
// End stops remain below the cell center so the lid can remove vertically.
|
||||
for (x = [
|
||||
battery_start_x - battery_end_clearance - wall_thickness,
|
||||
battery_end_x + battery_end_clearance
|
||||
])
|
||||
translate([
|
||||
x,
|
||||
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
wall_thickness,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + 1 + eps
|
||||
]);
|
||||
|
||||
// Rear cross-strip separates the side-mounted switch body from the cell.
|
||||
translate([
|
||||
battery_end_x + battery_end_clearance,
|
||||
partition_y,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
case_length - wall_thickness - battery_end_x - battery_end_clearance,
|
||||
case_width - wall_thickness - partition_y,
|
||||
min(body_height - effective_floor, switch_center_z
|
||||
+ switch_body_diameter / 2 + switch_wire_allowance
|
||||
- effective_floor) + eps
|
||||
]);
|
||||
}
|
||||
|
||||
module body_positive() {
|
||||
union() {
|
||||
difference() {
|
||||
rounded_box([case_length, case_width, body_height], corner_radius);
|
||||
translate([wall_thickness, wall_thickness, effective_floor])
|
||||
rounded_box([
|
||||
case_length - 2 * wall_thickness,
|
||||
case_width - 2 * wall_thickness,
|
||||
body_height - effective_floor + eps
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
}
|
||||
board_standoffs();
|
||||
lid_bosses();
|
||||
if (battery_enabled)
|
||||
battery_cradle();
|
||||
if (tripod_insert_enabled)
|
||||
translate([case_length / 2, case_width / 2, 0])
|
||||
cylinder(h = effective_floor, d = tripod_boss_diameter);
|
||||
}
|
||||
}
|
||||
|
||||
module body_cutouts() {
|
||||
// Board mounting insert pockets, loaded from above before the Feather.
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([board_origin_x + mx, board_origin_y + my, 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
board_bottom_z
|
||||
);
|
||||
|
||||
// Lid insert pockets, loaded from the top of the body.
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
body_height
|
||||
);
|
||||
|
||||
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
|
||||
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
|
||||
face_rounded_cutout(
|
||||
"front",
|
||||
board_origin_y + usb_center_y,
|
||||
usb_center_z,
|
||||
usb_opening_width + hole_compensation,
|
||||
usb_opening_height + hole_compensation,
|
||||
usb_opening_radius
|
||||
);
|
||||
|
||||
if (battery_enabled) {
|
||||
face_circle_cutout(
|
||||
"right",
|
||||
switch_center_x,
|
||||
switch_center_z,
|
||||
switch_hole_diameter + switch_hole_compensation
|
||||
);
|
||||
// Clear the switch body and its prewired terminals through the rear bay.
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness + eps,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(
|
||||
h = switch_body_length + switch_wire_allowance
|
||||
+ wall_thickness + eps,
|
||||
d = switch_body_diameter + 2 * fit_clearance
|
||||
);
|
||||
}
|
||||
|
||||
if (tripod_insert_enabled) {
|
||||
translate([case_length / 2, case_width / 2, 0]) {
|
||||
translate([0, 0, -eps])
|
||||
cylinder(
|
||||
h = effective_floor + 2 * eps,
|
||||
d = tripod_thread_clearance_diameter + hole_compensation
|
||||
);
|
||||
insert_pocket(
|
||||
tripod_insert_hole_diameter,
|
||||
tripod_insert_length + insert_melt_relief,
|
||||
effective_floor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (entry = extra_cutouts)
|
||||
custom_wall_cutout(entry);
|
||||
}
|
||||
|
||||
module body() {
|
||||
difference() {
|
||||
body_positive();
|
||||
body_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module lid_plate_positive() {
|
||||
union() {
|
||||
rounded_box([case_length, case_width, lid_thickness], corner_radius);
|
||||
// Inset rim: exported pointing upward so the outer lid face prints flat.
|
||||
translate([
|
||||
wall_thickness + fit_clearance,
|
||||
wall_thickness + fit_clearance,
|
||||
lid_thickness
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth)
|
||||
difference() {
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (wall_thickness + fit_clearance),
|
||||
case_width - 2 * (wall_thickness + fit_clearance)
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
),
|
||||
case_width - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
)
|
||||
], max(0.5, corner_radius - wall_thickness
|
||||
- lid_lip_thickness));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module beveled_oled_window() {
|
||||
inner_size = oled_visible_size;
|
||||
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
|
||||
center = [
|
||||
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
|
||||
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
|
||||
];
|
||||
hull() {
|
||||
translate([center[0], center[1], -eps])
|
||||
linear_extrude(height = eps)
|
||||
centered_rounded_rect_2d(outer_size, 1.5);
|
||||
translate([center[0], center[1], lid_thickness - eps])
|
||||
linear_extrude(height = 2 * eps)
|
||||
centered_rounded_rect_2d(inner_size, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
module oled_button_lid_cutout(xy) {
|
||||
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
|
||||
shaft_size = [
|
||||
button_cap_size[0] + fit_clearance * 2,
|
||||
button_cap_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_size = [
|
||||
button_flange_size[0] + fit_clearance * 2,
|
||||
button_flange_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
translate([center[0], center[1], -eps])
|
||||
centered_rounded_prism(
|
||||
shaft_size,
|
||||
lid_thickness + 2 * eps,
|
||||
shaft_size[1] / 2
|
||||
);
|
||||
translate([center[0], center[1], lid_thickness - recess_depth])
|
||||
centered_rounded_prism(
|
||||
recess_size,
|
||||
recess_depth + lid_lip_depth + eps,
|
||||
recess_size[1] / 2
|
||||
);
|
||||
}
|
||||
|
||||
module lid_cutouts() {
|
||||
// Through holes plus shallow counterbores for flush M2.5 socket heads.
|
||||
for (p = lid_boss_xy) {
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_thickness + lid_lip_depth + 2 * eps,
|
||||
d = m25_screw_clearance + hole_compensation
|
||||
);
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_screw_head_height + eps,
|
||||
d = lid_screw_head_diameter + hole_compensation
|
||||
);
|
||||
}
|
||||
|
||||
if (top_profile == "oled128x64") {
|
||||
beveled_oled_window();
|
||||
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
|
||||
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
|
||||
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
|
||||
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
|
||||
}
|
||||
}
|
||||
|
||||
module lid() {
|
||||
difference() {
|
||||
lid_plate_positive();
|
||||
lid_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module one_oled_button() {
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
stem_height = lid_thickness - recess_depth + button_projection;
|
||||
// Print outer cap face down. The flange and contact nub build upward.
|
||||
centered_rounded_prism(
|
||||
button_cap_size,
|
||||
stem_height,
|
||||
button_cap_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height])
|
||||
centered_rounded_prism(
|
||||
button_flange_size,
|
||||
button_flange_thickness,
|
||||
button_flange_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height + button_flange_thickness])
|
||||
cylinder(h = button_contact_extension, d = 3);
|
||||
}
|
||||
|
||||
module buttons() {
|
||||
enabled = [
|
||||
button_a,
|
||||
button_b,
|
||||
button_c,
|
||||
button_reset
|
||||
];
|
||||
count_enabled = len([for (v = enabled) if (v) 1]);
|
||||
if (count_enabled == 0)
|
||||
echo("No OLED buttons are enabled.");
|
||||
for (i = [0 : len(enabled) - 1])
|
||||
if (enabled[i])
|
||||
translate([i * (button_flange_size[0] + 3), 0, 0])
|
||||
one_oled_button();
|
||||
}
|
||||
|
||||
module reference_board() {
|
||||
color([0.05, 0.25, 0.55, 0.75])
|
||||
translate([board_origin_x, board_origin_y, board_bottom_z])
|
||||
rounded_box([feather_length, feather_width, board_thickness], 2.54);
|
||||
color([0.12, 0.45, 0.75, 0.45])
|
||||
for (i = [1 : wing_count])
|
||||
translate([
|
||||
board_origin_x,
|
||||
board_origin_y,
|
||||
board_top_z + i * wing_pitch - board_thickness
|
||||
])
|
||||
rounded_box(
|
||||
[feather_length, feather_width, board_thickness], 2.54
|
||||
);
|
||||
}
|
||||
|
||||
module reference_top_feature() {
|
||||
if (top_profile == "gps")
|
||||
color([0.92, 0.78, 0.35, 0.8])
|
||||
translate([
|
||||
board_origin_x + 31,
|
||||
board_origin_y + (feather_width - 15) / 2,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([15, 15, gps_antenna_height]);
|
||||
else if (top_profile == "oled128x64") {
|
||||
color([0.12, 0.12, 0.12, 0.9])
|
||||
translate([
|
||||
board_origin_x + oled_visible_min[0],
|
||||
board_origin_y + oled_visible_min[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
oled_visible_size[0],
|
||||
oled_visible_size[1],
|
||||
oled_display_height
|
||||
]);
|
||||
color([0.6, 0.6, 0.6, 0.9])
|
||||
for (xy = [
|
||||
oled_button_a_xy,
|
||||
oled_button_b_xy,
|
||||
oled_button_c_xy,
|
||||
oled_button_reset_xy
|
||||
])
|
||||
translate([
|
||||
board_origin_x + xy[0],
|
||||
board_origin_y + xy[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cylinder(h = 1.6, d = 2.1);
|
||||
} else
|
||||
color([0.3, 0.65, 0.35, 0.6])
|
||||
translate([
|
||||
board_origin_x + 10,
|
||||
board_origin_y + 4,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
feather_length - 20,
|
||||
feather_width - 8,
|
||||
generic_component_height
|
||||
]);
|
||||
}
|
||||
|
||||
module reference_battery_and_switch() {
|
||||
if (battery_enabled) {
|
||||
color([0.35, 0.18, 0.55, 0.75])
|
||||
translate([battery_start_x, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(h = battery_length, d = battery_diameter);
|
||||
color([0.8, 0.15, 0.1, 0.65])
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0]) {
|
||||
cylinder(h = switch_body_length, d = switch_body_diameter);
|
||||
translate([0, 0, -1.5])
|
||||
cylinder(h = 1.5, d = switch_flange_diameter);
|
||||
translate([0, 0, -3])
|
||||
cylinder(h = 3, d = switch_cap_diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module assembly() {
|
||||
color([0.82, 0.84, 0.88, 1]) body();
|
||||
if ($preview && show_references) {
|
||||
reference_board();
|
||||
reference_top_feature();
|
||||
reference_battery_and_switch();
|
||||
}
|
||||
// Printable lid is mirrored into its installed orientation and exploded up.
|
||||
color([0.92, 0.92, 0.95, 0.88])
|
||||
translate([0, case_width, body_height + lid_thickness + exploded_view])
|
||||
rotate([180, 0, 0])
|
||||
lid();
|
||||
}
|
||||
|
||||
module coupon_hole(x, y, diameter, depth) {
|
||||
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
|
||||
}
|
||||
|
||||
module insert_coupon() {
|
||||
spacing = 15;
|
||||
coupon_length = spacing * len(coupon_steps) + 8;
|
||||
coupon_width = 32;
|
||||
coupon_height = max(
|
||||
m25_insert_length + insert_melt_relief + 1.2,
|
||||
tripod_insert_length + insert_melt_relief + 1.2
|
||||
);
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1]) {
|
||||
x = 7 + i * spacing;
|
||||
coupon_hole(
|
||||
x, 8,
|
||||
m25_insert_hole_diameter + coupon_steps[i],
|
||||
m25_insert_length + insert_melt_relief
|
||||
);
|
||||
coupon_hole(
|
||||
x, 23,
|
||||
tripod_insert_hole_diameter + coupon_steps[i],
|
||||
tripod_insert_length + insert_melt_relief
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module switch_coupon() {
|
||||
spacing = 18;
|
||||
coupon_length = spacing * len(coupon_steps) + 6;
|
||||
coupon_width = 24;
|
||||
coupon_height = wall_thickness;
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1])
|
||||
translate([6 + i * spacing, coupon_width / 2, -eps])
|
||||
cylinder(
|
||||
h = coupon_height + 2 * eps,
|
||||
d = switch_hole_diameter + coupon_steps[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module fit_coupon() {
|
||||
segment_length = 34;
|
||||
base_width = 16;
|
||||
base_height = 6;
|
||||
cavity_size = [segment_length - 8, base_width - 8];
|
||||
male_size = [
|
||||
cavity_size[0] - 2 * fit_clearance,
|
||||
cavity_size[1] - 2 * fit_clearance
|
||||
];
|
||||
difference() {
|
||||
rounded_box([segment_length, base_width, base_height], 2);
|
||||
translate([4, 4, 2])
|
||||
cube([segment_length - 8, base_width - 8, base_height]);
|
||||
}
|
||||
translate([segment_length + 5, 0, 0])
|
||||
union() {
|
||||
cube([segment_length, base_width, lid_thickness]);
|
||||
translate([
|
||||
4 + fit_clearance,
|
||||
4 + fit_clearance,
|
||||
lid_thickness - eps
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth + eps)
|
||||
difference() {
|
||||
rounded_rect_2d(male_size, 1);
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
male_size[0] - 2 * lid_lip_thickness,
|
||||
male_size[1] - 2 * lid_lip_thickness
|
||||
], 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part == "assembly")
|
||||
assembly();
|
||||
else if (part == "body")
|
||||
body();
|
||||
else if (part == "lid")
|
||||
lid();
|
||||
else if (part == "buttons")
|
||||
buttons();
|
||||
else if (part == "insert_coupon")
|
||||
insert_coupon();
|
||||
else if (part == "switch_coupon")
|
||||
switch_coupon();
|
||||
else if (part == "fit_coupon")
|
||||
fit_coupon();
|
||||
else
|
||||
assert(false, str("Unknown part: ", part));
|
||||
@@ -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()
|
||||
+2
-2
@@ -6,9 +6,9 @@ Generated by the Feather Case Wizard.
|
||||
- FeatherWings: 1
|
||||
- Battery compartment: yes
|
||||
- 1/4-20 insert: yes
|
||||
- Approximate exterior: 93.9 × 57.3 × 38.6 mm
|
||||
- Approximate exterior: 93.9 × 57.3 × 41.8 mm
|
||||
- Formats: 3mf
|
||||
- Parts: `body`, `lid`
|
||||
- Parts: `body`, `lid`, `insert_coupon`, `switch_coupon`, `fit_coupon`
|
||||
- Custom cutouts: 0
|
||||
|
||||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||||
|
||||
+9
-5
@@ -3,18 +3,19 @@ schema_version: 1
|
||||
name: GPS
|
||||
slug: gps
|
||||
generator:
|
||||
sha256: a265164909faa0c364792224601af36381744a129b7cd7cbd6b6293815953f63
|
||||
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
|
||||
created_with: feather-case-wizard
|
||||
parameters:
|
||||
top_profile: gps
|
||||
wing_count: 1
|
||||
wing_pitch: 11.0
|
||||
stack_height_override: 0.0
|
||||
wing_pitch: 12.25
|
||||
stack_height_override: 12.25
|
||||
generic_component_height: 3.0
|
||||
generic_top_gap: 2.0
|
||||
gps_antenna_height: 4.0
|
||||
gps_antenna_height: 6.0
|
||||
gps_top_gap: 5.0
|
||||
oled_component_height: 2.4
|
||||
oled_display_height: 1.5
|
||||
oled_face_gap: 0.8
|
||||
button_a: true
|
||||
button_b: true
|
||||
@@ -75,7 +76,10 @@ cutouts: []
|
||||
outputs:
|
||||
formats:
|
||||
- 3mf
|
||||
coupons: []
|
||||
coupons:
|
||||
- insert_coupon
|
||||
- switch_coupon
|
||||
- fit_coupon
|
||||
assembly_exploded_view: 8
|
||||
resolved:
|
||||
extra_cutouts: []
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
"gps": {
|
||||
"top_profile": "\"gps\"",
|
||||
"wing_count": "1",
|
||||
"wing_pitch": "11.0",
|
||||
"stack_height_override": "0.0",
|
||||
"wing_pitch": "12.25",
|
||||
"stack_height_override": "12.25",
|
||||
"generic_component_height": "3.0",
|
||||
"generic_top_gap": "2.0",
|
||||
"gps_antenna_height": "4.0",
|
||||
"gps_antenna_height": "6.0",
|
||||
"gps_top_gap": "5.0",
|
||||
"oled_component_height": "2.4",
|
||||
"oled_display_height": "1.5",
|
||||
"oled_face_gap": "0.8",
|
||||
"button_a": "true",
|
||||
"button_b": "true",
|
||||
|
||||
@@ -19,13 +19,14 @@ show_references = true;
|
||||
/* [Stack] */
|
||||
top_profile = "generic"; // [generic,gps,oled128x64]
|
||||
wing_count = 1; // [1:1:8]
|
||||
wing_pitch = 11; // [7:0.1:16]
|
||||
wing_pitch = 12.25; // [7:0.1:16]
|
||||
stack_height_override = 0; // [0:0.1:100]
|
||||
generic_component_height = 3; // [0:0.1:30]
|
||||
generic_top_gap = 2; // [0.5:0.1:10]
|
||||
gps_antenna_height = 4;
|
||||
gps_antenna_height = 6;
|
||||
gps_top_gap = 5;
|
||||
oled_component_height = 2.4;
|
||||
oled_display_height = 1.5;
|
||||
oled_face_gap = 0.8;
|
||||
|
||||
/* [OLED controls] */
|
||||
@@ -708,7 +709,7 @@ module reference_top_feature() {
|
||||
cube([
|
||||
oled_visible_size[0],
|
||||
oled_visible_size[1],
|
||||
oled_component_height
|
||||
oled_display_height
|
||||
]);
|
||||
color([0.6, 0.6, 0.6, 0.9])
|
||||
for (xy = [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Pan/Tilt Head
|
||||
|
||||
Generated by the Feather Case Wizard.
|
||||
|
||||
- Top profile: `oled128x64`
|
||||
- FeatherWings: 3
|
||||
- Battery compartment: no
|
||||
- 1/4-20 insert: yes
|
||||
- Approximate exterior: 95.6 × 41.7 × 59.8 mm
|
||||
- Formats: 3mf
|
||||
- Parts: `body`, `lid`, `buttons`
|
||||
- Custom cutouts: 4
|
||||
|
||||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||||
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
|
||||
override their executable paths.
|
||||
|
||||
To open the saved Customizer values manually:
|
||||
|
||||
```bash
|
||||
openscad -p parameters.json -P pan-tilt-head source/feather_case.scad
|
||||
```
|
||||
|
||||
The 3MF files contain portable geometry only. Choose printer, filament,
|
||||
plate, and slicing settings in Bambu Studio.
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "pathname"
|
||||
require "tmpdir"
|
||||
require "yaml"
|
||||
|
||||
ROOT = Pathname(__dir__).expand_path
|
||||
|
||||
def executable(env_name, command)
|
||||
override = ENV[env_name]
|
||||
return File.expand_path(override) if override && File.executable?(override)
|
||||
|
||||
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
||||
candidate = File.join(directory, command)
|
||||
return candidate if File.file?(candidate) && File.executable?(candidate)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def literal(value)
|
||||
case value
|
||||
when String then JSON.generate(value)
|
||||
when TrueClass then "true"
|
||||
when FalseClass then "false"
|
||||
when Numeric then value.to_s
|
||||
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
|
||||
else raise "Cannot convert #{value.inspect} to OpenSCAD"
|
||||
end
|
||||
end
|
||||
|
||||
def run!(*command, env: {})
|
||||
stdout, stderr, status = Open3.capture3(env, *command)
|
||||
return if status.success?
|
||||
|
||||
warn [stdout, stderr].reject(&:empty?).join("\n")
|
||||
raise "Command failed (#{status.exitstatus}): #{command.first}"
|
||||
end
|
||||
|
||||
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
|
||||
permitted_symbols: [], aliases: false)
|
||||
openscad = executable("OPENSCAD", "openscad")
|
||||
blender = executable("BLENDER", "blender")
|
||||
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
|
||||
abort "Blender not found; install it or set BLENDER" unless blender
|
||||
|
||||
parameters = config.fetch("parameters").merge(
|
||||
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
|
||||
)
|
||||
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
|
||||
buttons = %w[button_a button_b button_c button_reset]
|
||||
if parameters["top_profile"] == "oled128x64" &&
|
||||
buttons.any? { |name| parameters[name] }
|
||||
parts << "buttons"
|
||||
end
|
||||
|
||||
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
|
||||
begin
|
||||
exports = stage.join("exports")
|
||||
previews = stage.join("previews")
|
||||
scratch = stage.join("meshes")
|
||||
FileUtils.mkdir_p([exports, previews, scratch])
|
||||
model = ROOT.join("source/feather_case.scad")
|
||||
renderer = ROOT.join("source/render_stl_previews.py")
|
||||
|
||||
export = lambda do |output, part, overrides = {}|
|
||||
values = parameters.merge(overrides).merge("part" => part)
|
||||
command = [openscad, "-o", output.to_s]
|
||||
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
|
||||
run!(*command, model.to_s)
|
||||
end
|
||||
|
||||
config.fetch("outputs").fetch("formats").each do |format|
|
||||
directory = exports.join(format)
|
||||
FileUtils.mkdir_p(directory)
|
||||
parts.each do |part|
|
||||
puts "Exporting #{part}.#{format}…"
|
||||
export.call(directory.join("#{part}.#{format}"), part)
|
||||
end
|
||||
end
|
||||
|
||||
assembly = scratch.join("assembly.stl")
|
||||
puts "Preparing assembly preview…"
|
||||
export.call(assembly, "assembly",
|
||||
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
|
||||
"show_references" => false)
|
||||
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
assembly.to_s, previews.to_s, "assembly", env: render_env)
|
||||
parts.each do |part|
|
||||
puts "Rendering #{part} preview…"
|
||||
mesh = scratch.join("#{part}.stl")
|
||||
export.call(mesh, part) unless mesh.file?
|
||||
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||
mesh.to_s, previews.to_s, part, "iso", env: render_env)
|
||||
end
|
||||
|
||||
backup = ROOT.join(".build-old-#{Process.pid}")
|
||||
FileUtils.mkdir_p(backup)
|
||||
%w[exports previews].each do |name|
|
||||
current = ROOT.join(name)
|
||||
FileUtils.mv(current, backup.join(name)) if current.exist?
|
||||
end
|
||||
begin
|
||||
FileUtils.mv(exports, ROOT.join("exports"))
|
||||
FileUtils.mv(previews, ROOT.join("previews"))
|
||||
FileUtils.rm_rf(backup)
|
||||
rescue StandardError
|
||||
FileUtils.rm_rf(ROOT.join("exports"))
|
||||
FileUtils.rm_rf(ROOT.join("previews"))
|
||||
%w[exports previews].each do |name|
|
||||
saved = backup.join(name)
|
||||
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
|
||||
end
|
||||
raise
|
||||
end
|
||||
puts "Rebuilt #{ROOT}"
|
||||
ensure
|
||||
FileUtils.rm_rf(stage)
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
schema_version: 1
|
||||
name: Pan/Tilt Head
|
||||
slug: pan-tilt-head
|
||||
generator:
|
||||
sha256: 965260d4d402fd8894f30aa863224e2c9b42dc61a1496f01e2f00b509f9624db
|
||||
created_with: feather-case-wizard
|
||||
parameters:
|
||||
top_profile: oled128x64
|
||||
wing_count: 3
|
||||
wing_pitch: 12.25
|
||||
stack_height_override: 38.0
|
||||
generic_component_height: 3.0
|
||||
generic_top_gap: 2.0
|
||||
gps_antenna_height: 6.0
|
||||
gps_top_gap: 5.0
|
||||
oled_component_height: 2.4
|
||||
oled_display_height: 1.5
|
||||
oled_face_gap: 0.8
|
||||
button_a: true
|
||||
button_b: true
|
||||
button_c: true
|
||||
button_reset: false
|
||||
button_projection: 1.5
|
||||
button_cap_size:
|
||||
- 5.0
|
||||
- 3.2
|
||||
button_flange_size:
|
||||
- 6.6
|
||||
- 3.6
|
||||
button_flange_thickness: 0.9
|
||||
button_travel: 0.5
|
||||
button_contact_extension: 0.7
|
||||
battery_enabled: false
|
||||
battery_length: 69.0
|
||||
battery_diameter: 18.0
|
||||
battery_radial_clearance: 0.6
|
||||
battery_end_clearance: 1.0
|
||||
switch_hole_diameter: 12.0
|
||||
switch_hole_compensation: 0.3
|
||||
switch_cap_diameter: 8.0
|
||||
switch_flange_diameter: 16.2
|
||||
switch_body_length: 24.0
|
||||
switch_body_diameter: 12.0
|
||||
switch_wire_allowance: 3.0
|
||||
tripod_insert_enabled: true
|
||||
tripod_insert_length: 6.4
|
||||
tripod_insert_hole_diameter: 8.8
|
||||
tripod_thread_clearance_diameter: 6.8
|
||||
tripod_boss_diameter: 14.0
|
||||
m25_insert_length: 4.0
|
||||
m25_insert_hole_diameter: 3.7
|
||||
m25_screw_clearance: 2.8
|
||||
m25_boss_diameter: 7.0
|
||||
insert_entry_chamfer: 0.6
|
||||
insert_melt_relief: 0.8
|
||||
wall_thickness: 2.4
|
||||
floor_thickness: 2.4
|
||||
lid_thickness: 3.2
|
||||
corner_radius: 4.0
|
||||
board_bottom_clearance: 5.4
|
||||
board_side_clearance: 7.0
|
||||
board_rear_clearance: 20.0
|
||||
usb_board_wall_gap: 20.0
|
||||
lid_lip_depth: 2.4
|
||||
lid_lip_thickness: 1.2
|
||||
fit_clearance: 0.35
|
||||
cutout_min_web: 1.2
|
||||
hole_compensation: 0.2
|
||||
lid_screw_head_diameter: 5.2
|
||||
lid_screw_head_height: 2.5
|
||||
usb_opening_enabled: true
|
||||
usb_guide_enabled: true
|
||||
usb_connector_overhang: 2.0
|
||||
usb_guide_wall_thickness: 1.2
|
||||
usb_guide_end_gap: 1.0
|
||||
usb_opening_width: 12.5
|
||||
usb_opening_height: 7.0
|
||||
usb_opening_radius: 1.5
|
||||
usb_vertical_offset: 0.0
|
||||
cutouts:
|
||||
- name: Antenna
|
||||
face: left
|
||||
shape: circle
|
||||
horizontal: 30.4
|
||||
z: 31.299999999999997
|
||||
width: 6.5
|
||||
height: 6.5
|
||||
radius: 3.25
|
||||
- name: Servo
|
||||
face: rear
|
||||
shape: roundrect
|
||||
horizontal: 20.83
|
||||
z: 35.0
|
||||
width: 10.0
|
||||
height: 5.0
|
||||
radius: 1.25
|
||||
- name: Release
|
||||
face: rear
|
||||
shape: circle
|
||||
horizontal: 20.83
|
||||
z: 20.0
|
||||
width: 6.0
|
||||
height: 6.0
|
||||
radius: 3.0
|
||||
- name: Power
|
||||
face: front
|
||||
shape: circle
|
||||
horizontal: 20.83
|
||||
z: 31.65
|
||||
width: 8.0
|
||||
height: 8.0
|
||||
radius: 4.0
|
||||
outputs:
|
||||
formats:
|
||||
- 3mf
|
||||
coupons: []
|
||||
assembly_exploded_view: 8
|
||||
resolved:
|
||||
extra_cutouts:
|
||||
- - left
|
||||
- circle
|
||||
- 30.4
|
||||
- 31.299999999999997
|
||||
- 6.5
|
||||
- 6.5
|
||||
- 3.25
|
||||
- - rear
|
||||
- roundrect
|
||||
- 20.83
|
||||
- 35.0
|
||||
- 10.0
|
||||
- 5.0
|
||||
- 1.25
|
||||
- - rear
|
||||
- circle
|
||||
- 20.83
|
||||
- 20.0
|
||||
- 6.0
|
||||
- 6.0
|
||||
- 3.0
|
||||
- - front
|
||||
- circle
|
||||
- 20.83
|
||||
- 31.65
|
||||
- 8.0
|
||||
- 8.0
|
||||
- 4.0
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"parameterSets": {
|
||||
"pan-tilt-head": {
|
||||
"top_profile": "\"oled128x64\"",
|
||||
"wing_count": "3",
|
||||
"wing_pitch": "12.25",
|
||||
"stack_height_override": "38.0",
|
||||
"generic_component_height": "3.0",
|
||||
"generic_top_gap": "2.0",
|
||||
"gps_antenna_height": "6.0",
|
||||
"gps_top_gap": "5.0",
|
||||
"oled_component_height": "2.4",
|
||||
"oled_display_height": "1.5",
|
||||
"oled_face_gap": "0.8",
|
||||
"button_a": "true",
|
||||
"button_b": "true",
|
||||
"button_c": "true",
|
||||
"button_reset": "false",
|
||||
"button_projection": "1.5",
|
||||
"button_cap_size": "[5.0,3.2]",
|
||||
"button_flange_size": "[6.6,3.6]",
|
||||
"button_flange_thickness": "0.9",
|
||||
"button_travel": "0.5",
|
||||
"button_contact_extension": "0.7",
|
||||
"battery_enabled": "false",
|
||||
"battery_length": "69.0",
|
||||
"battery_diameter": "18.0",
|
||||
"battery_radial_clearance": "0.6",
|
||||
"battery_end_clearance": "1.0",
|
||||
"switch_hole_diameter": "12.0",
|
||||
"switch_hole_compensation": "0.3",
|
||||
"switch_cap_diameter": "8.0",
|
||||
"switch_flange_diameter": "16.2",
|
||||
"switch_body_length": "24.0",
|
||||
"switch_body_diameter": "12.0",
|
||||
"switch_wire_allowance": "3.0",
|
||||
"tripod_insert_enabled": "true",
|
||||
"tripod_insert_length": "6.4",
|
||||
"tripod_insert_hole_diameter": "8.8",
|
||||
"tripod_thread_clearance_diameter": "6.8",
|
||||
"tripod_boss_diameter": "14.0",
|
||||
"m25_insert_length": "4.0",
|
||||
"m25_insert_hole_diameter": "3.7",
|
||||
"m25_screw_clearance": "2.8",
|
||||
"m25_boss_diameter": "7.0",
|
||||
"insert_entry_chamfer": "0.6",
|
||||
"insert_melt_relief": "0.8",
|
||||
"wall_thickness": "2.4",
|
||||
"floor_thickness": "2.4",
|
||||
"lid_thickness": "3.2",
|
||||
"corner_radius": "4.0",
|
||||
"board_bottom_clearance": "5.4",
|
||||
"board_side_clearance": "7.0",
|
||||
"board_rear_clearance": "20.0",
|
||||
"usb_board_wall_gap": "20.0",
|
||||
"lid_lip_depth": "2.4",
|
||||
"lid_lip_thickness": "1.2",
|
||||
"fit_clearance": "0.35",
|
||||
"cutout_min_web": "1.2",
|
||||
"hole_compensation": "0.2",
|
||||
"lid_screw_head_diameter": "5.2",
|
||||
"lid_screw_head_height": "2.5",
|
||||
"usb_opening_enabled": "true",
|
||||
"usb_guide_enabled": "true",
|
||||
"usb_connector_overhang": "2.0",
|
||||
"usb_guide_wall_thickness": "1.2",
|
||||
"usb_guide_end_gap": "1.0",
|
||||
"usb_opening_width": "12.5",
|
||||
"usb_opening_height": "7.0",
|
||||
"usb_opening_radius": "1.5",
|
||||
"usb_vertical_offset": "0.0"
|
||||
}
|
||||
},
|
||||
"fileFormatVersion": "1"
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
/*
|
||||
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
|
||||
*
|
||||
* Coordinate system:
|
||||
* X: USB-C wall (0) toward the rear of the case
|
||||
* Y: non-battery side (0) toward the battery/JST side
|
||||
* Z: outside bottom (0) upward
|
||||
*
|
||||
* Board coordinates are derived from Adafruit's Eagle PCB files:
|
||||
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
|
||||
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
|
||||
*/
|
||||
|
||||
/* [Output] */
|
||||
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
|
||||
exploded_view = 8; // [0:1:20]
|
||||
show_references = true;
|
||||
|
||||
/* [Stack] */
|
||||
top_profile = "generic"; // [generic,gps,oled128x64]
|
||||
wing_count = 1; // [1:1:8]
|
||||
wing_pitch = 12.25; // [7:0.1:16]
|
||||
stack_height_override = 0; // [0:0.1:100]
|
||||
generic_component_height = 3; // [0:0.1:30]
|
||||
generic_top_gap = 2; // [0.5:0.1:10]
|
||||
gps_antenna_height = 6;
|
||||
gps_top_gap = 5;
|
||||
oled_component_height = 2.4;
|
||||
oled_display_height = 1.5;
|
||||
oled_face_gap = 0.8;
|
||||
|
||||
/* [OLED controls] */
|
||||
button_a = true;
|
||||
button_b = true;
|
||||
button_c = true;
|
||||
button_reset = false;
|
||||
button_projection = 1.5; // [0.5:0.1:3]
|
||||
button_cap_size = [5, 3.2];
|
||||
button_flange_size = [6.6, 3.6];
|
||||
button_flange_thickness = 0.9;
|
||||
button_travel = 0.5;
|
||||
button_contact_extension = 0.7;
|
||||
|
||||
/* [Battery and switch] */
|
||||
battery_enabled = false;
|
||||
battery_length = 69;
|
||||
battery_diameter = 18;
|
||||
battery_radial_clearance = 0.6;
|
||||
battery_end_clearance = 1;
|
||||
switch_hole_diameter = 12;
|
||||
switch_hole_compensation = 0.3;
|
||||
switch_cap_diameter = 8;
|
||||
switch_flange_diameter = 16.2;
|
||||
switch_body_length = 24;
|
||||
switch_body_diameter = 12;
|
||||
switch_wire_allowance = 3;
|
||||
|
||||
/* [Tripod mount] */
|
||||
tripod_insert_enabled = false;
|
||||
tripod_insert_length = 6.4;
|
||||
tripod_insert_hole_diameter = 8.8;
|
||||
tripod_thread_clearance_diameter = 6.8;
|
||||
tripod_boss_diameter = 14;
|
||||
|
||||
/* [M2.5 inserts] */
|
||||
m25_insert_length = 4;
|
||||
m25_insert_hole_diameter = 3.7;
|
||||
m25_screw_clearance = 2.8;
|
||||
m25_boss_diameter = 7;
|
||||
insert_entry_chamfer = 0.6;
|
||||
insert_melt_relief = 0.8;
|
||||
|
||||
/* [Shell and fit] */
|
||||
wall_thickness = 2.4;
|
||||
floor_thickness = 2.4;
|
||||
lid_thickness = 3.2;
|
||||
corner_radius = 4;
|
||||
board_bottom_clearance = 5.4;
|
||||
board_side_clearance = 7;
|
||||
board_rear_clearance = 4;
|
||||
usb_board_wall_gap = 2;
|
||||
lid_lip_depth = 2.4;
|
||||
lid_lip_thickness = 1.2;
|
||||
fit_clearance = 0.35;
|
||||
cutout_min_web = 1.2;
|
||||
hole_compensation = 0.2;
|
||||
lid_screw_head_diameter = 5.2;
|
||||
lid_screw_head_height = 2.5;
|
||||
|
||||
/* [USB-C opening] */
|
||||
usb_opening_enabled = true;
|
||||
usb_guide_enabled = true;
|
||||
usb_connector_overhang = 2;
|
||||
usb_guide_wall_thickness = 1.2;
|
||||
usb_guide_end_gap = 1;
|
||||
usb_opening_width = 12.5;
|
||||
usb_opening_height = 7;
|
||||
usb_opening_radius = 1.5;
|
||||
usb_vertical_offset = 0;
|
||||
|
||||
/* [Custom wall cutouts] */
|
||||
// Entry format: [face, shape, u, z, width, height, radius]
|
||||
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
|
||||
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
|
||||
extra_cutouts = [];
|
||||
|
||||
/* [Calibration] */
|
||||
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
|
||||
|
||||
$fn = $preview ? 36 : 72;
|
||||
eps = 0.02;
|
||||
|
||||
// Adafruit Feather mechanical reference, millimetres.
|
||||
feather_length = 50.8;
|
||||
feather_width = 22.86;
|
||||
board_thickness = 1.6;
|
||||
mount_x = [2.54, 48.26];
|
||||
mount_y = [2.54, 20.32];
|
||||
usb_center_y = 11.43;
|
||||
|
||||
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
|
||||
oled_visible_min = [11.42, 3.78];
|
||||
oled_visible_size = [30, 15.3];
|
||||
oled_button_a_xy = [4.318, 15.875];
|
||||
oled_button_b_xy = [4.318, 11.43];
|
||||
oled_button_c_xy = [4.318, 6.985];
|
||||
oled_button_reset_xy = [7.366, 20.828];
|
||||
|
||||
effective_floor = tripod_insert_enabled
|
||||
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
|
||||
: floor_thickness;
|
||||
effective_board_clearance = max(
|
||||
board_bottom_clearance,
|
||||
m25_insert_length + insert_melt_relief + 0.6
|
||||
);
|
||||
|
||||
board_origin_x = wall_thickness + usb_board_wall_gap;
|
||||
board_origin_y = wall_thickness + board_side_clearance;
|
||||
board_bottom_z = effective_floor + effective_board_clearance;
|
||||
board_top_z = board_bottom_z + board_thickness;
|
||||
usb_center_u = board_origin_y + usb_center_y;
|
||||
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
|
||||
usb_inner_width = usb_opening_width + hole_compensation;
|
||||
usb_inner_height = usb_opening_height + hole_compensation;
|
||||
usb_guide_length = max(
|
||||
0,
|
||||
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
|
||||
);
|
||||
usb_guide_active = usb_opening_enabled && usb_guide_enabled
|
||||
&& usb_guide_length > 0;
|
||||
usb_reserved_width = usb_inner_width
|
||||
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
|
||||
usb_reserved_height = usb_inner_height
|
||||
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
|
||||
|
||||
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
|
||||
top_pcb_z = stack_height_override > 0
|
||||
? board_top_z + stack_height_override
|
||||
: computed_top_pcb_z;
|
||||
profile_component_height =
|
||||
top_profile == "gps" ? gps_antenna_height :
|
||||
top_profile == "oled128x64" ? oled_component_height :
|
||||
generic_component_height;
|
||||
profile_top_gap =
|
||||
top_profile == "gps" ? gps_top_gap :
|
||||
top_profile == "oled128x64" ? oled_face_gap :
|
||||
generic_top_gap;
|
||||
|
||||
partition_y = board_origin_y + feather_width + 1;
|
||||
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
battery_start_x = board_origin_x + battery_end_clearance;
|
||||
battery_end_x = battery_start_x + battery_length;
|
||||
switch_center_x = battery_end_x + max(
|
||||
switch_body_diameter / 2 + 0.8,
|
||||
switch_flange_diameter / 2 + 0.5
|
||||
);
|
||||
|
||||
case_length_plain = board_origin_x + feather_length
|
||||
+ board_rear_clearance + wall_thickness;
|
||||
case_length_battery = switch_center_x + switch_flange_diameter / 2
|
||||
+ 1.2 + wall_thickness;
|
||||
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
|
||||
: case_length_plain;
|
||||
|
||||
case_width_plain = board_origin_y + feather_width
|
||||
+ board_side_clearance + wall_thickness;
|
||||
case_width_battery = battery_center_y + battery_diameter / 2
|
||||
+ battery_radial_clearance + wall_thickness;
|
||||
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
|
||||
: case_width_plain;
|
||||
|
||||
battery_center_z = effective_floor + battery_radial_clearance
|
||||
+ battery_diameter / 2;
|
||||
switch_center_z = max(
|
||||
effective_floor + switch_flange_diameter / 2 + 1,
|
||||
battery_center_z
|
||||
);
|
||||
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
|
||||
battery_ceiling_z = battery_enabled
|
||||
? max(
|
||||
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
|
||||
switch_center_z + switch_flange_diameter / 2 + 1
|
||||
)
|
||||
: 0;
|
||||
body_height = max(electronics_ceiling_z, battery_ceiling_z);
|
||||
|
||||
lid_boss_r = m25_boss_diameter / 2;
|
||||
lid_boss_wall_overlap = 0.6;
|
||||
lid_boss_xy = [
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
],
|
||||
[
|
||||
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
|
||||
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
|
||||
]
|
||||
];
|
||||
|
||||
assert(wing_count >= 1, "wing_count must be at least one");
|
||||
assert(top_profile == "generic" || top_profile == "gps"
|
||||
|| top_profile == "oled128x64", "Unsupported top_profile");
|
||||
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
|
||||
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
|
||||
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
|
||||
"M2.5 boss does not have enough radial material");
|
||||
assert(!tripod_insert_enabled
|
||||
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
|
||||
"Tripod insert boss does not have enough radial material");
|
||||
assert(usb_opening_enabled || !usb_guide_enabled,
|
||||
"USB guide requires the USB opening");
|
||||
|
||||
function clamp(v, lo, hi) = min(max(v, lo), hi);
|
||||
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
|
||||
function cutouts_separated(first, second, web) =
|
||||
abs(first[2] - second[2])
|
||||
>= (first[4] + second[4]) / 2 + web
|
||||
|| abs(first[3] - second[3])
|
||||
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
|
||||
function cutout_clear_of_usb(entry, web) =
|
||||
abs(entry[2] - usb_center_u)
|
||||
>= (entry[4] + usb_reserved_width) / 2 + web
|
||||
|| abs(entry[3] - usb_center_z)
|
||||
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
|
||||
|
||||
module validate_cutout_spacing() {
|
||||
if (len(extra_cutouts) > 1)
|
||||
for (first = [0 : len(extra_cutouts) - 2],
|
||||
second = [first + 1 : len(extra_cutouts) - 1])
|
||||
if (extra_cutouts[first][0] == extra_cutouts[second][0])
|
||||
assert(
|
||||
cutouts_separated(
|
||||
extra_cutouts[first],
|
||||
extra_cutouts[second],
|
||||
cutout_min_web
|
||||
),
|
||||
"Custom cutouts are too close on the same wall"
|
||||
);
|
||||
if (usb_opening_enabled)
|
||||
for (entry = extra_cutouts)
|
||||
if (entry[0] == "front")
|
||||
assert(
|
||||
cutout_clear_of_usb(entry, cutout_min_web),
|
||||
"Front cutout is too close to the USB opening or guide"
|
||||
);
|
||||
}
|
||||
|
||||
module rounded_rect_2d(size, radius) {
|
||||
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
|
||||
hull() {
|
||||
for (x = [r, size[0] - r], y = [r, size[1] - r])
|
||||
translate([x, y]) circle(r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module centered_rounded_rect_2d(size, radius) {
|
||||
translate([-size[0] / 2, -size[1] / 2])
|
||||
rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module centered_rounded_prism(size, height, radius) {
|
||||
linear_extrude(height = height)
|
||||
centered_rounded_rect_2d(size, radius);
|
||||
}
|
||||
|
||||
module rounded_box(size, radius) {
|
||||
linear_extrude(height = size[2])
|
||||
rounded_rect_2d([size[0], size[1]], radius);
|
||||
}
|
||||
|
||||
module insert_pocket(diameter, depth, entry_z, downward = true) {
|
||||
bore_d = diameter + hole_compensation;
|
||||
if (downward) {
|
||||
translate([0, 0, entry_z - depth])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - insert_entry_chamfer])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d,
|
||||
d2 = bore_d + 2 * insert_entry_chamfer
|
||||
);
|
||||
} else {
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(h = depth + eps, d = bore_d);
|
||||
translate([0, 0, entry_z - eps])
|
||||
cylinder(
|
||||
h = insert_entry_chamfer + eps,
|
||||
d1 = bore_d + 2 * insert_entry_chamfer,
|
||||
d2 = bore_d
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module face_rounded_cutout(face, u, z, width, height, radius) {
|
||||
r = clamp(radius, 0.01, min(width, height) / 2);
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front" || face == "rear") {
|
||||
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
|
||||
hull()
|
||||
for (yy = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([x0, yy, zz])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, r = r);
|
||||
} else if (face == "left" || face == "right") {
|
||||
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
|
||||
hull()
|
||||
for (xx = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([xx, y0, zz])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
|
||||
}
|
||||
}
|
||||
|
||||
module face_circle_cutout(face, u, z, diameter) {
|
||||
depth = wall_thickness + 2 * eps;
|
||||
if (face == "front")
|
||||
translate([-eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "rear")
|
||||
translate([case_length - wall_thickness - eps, u, z])
|
||||
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "left")
|
||||
translate([u, -eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
else if (face == "right")
|
||||
translate([u, case_width - wall_thickness - eps, z])
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
}
|
||||
|
||||
module x_rounded_prism(x, length, u, z, width, height, radius) {
|
||||
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
|
||||
hull()
|
||||
for (yy = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([x, yy, zz])
|
||||
rotate([0, 90, 0]) cylinder(h = length, r = r);
|
||||
}
|
||||
|
||||
module usb_guide_shell() {
|
||||
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
|
||||
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
|
||||
difference() {
|
||||
x_rounded_prism(
|
||||
wall_thickness - eps,
|
||||
usb_guide_length + 2 * eps,
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
outer_width,
|
||||
outer_height,
|
||||
usb_opening_radius + usb_guide_wall_thickness
|
||||
);
|
||||
x_rounded_prism(
|
||||
wall_thickness - 2 * eps,
|
||||
usb_guide_length + 4 * eps,
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
usb_inner_width,
|
||||
usb_inner_height,
|
||||
usb_opening_radius
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module custom_wall_cutout(entry) {
|
||||
face = entry[0];
|
||||
shape = entry[1];
|
||||
u = entry[2];
|
||||
z = entry[3];
|
||||
width = entry[4];
|
||||
height = len(entry) > 5 ? entry[5] : width;
|
||||
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
|
||||
assert(face == "front" || face == "rear"
|
||||
|| face == "left" || face == "right", "Invalid cutout face");
|
||||
assert(shape == "circle" || shape == "rect"
|
||||
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
|
||||
assert(z - height / 2 >= effective_floor,
|
||||
"Cutout intersects the floor");
|
||||
assert(z + height / 2 <= body_height - lid_lip_depth,
|
||||
"Cutout intersects the lid locating lip");
|
||||
assert(
|
||||
(face == "front" || face == "rear")
|
||||
? u - width / 2 >= 0 && u + width / 2 <= case_width
|
||||
: u - width / 2 >= 0 && u + width / 2 <= case_length,
|
||||
"Cutout extends beyond its wall"
|
||||
);
|
||||
|
||||
if (shape == "circle")
|
||||
face_circle_cutout(face, u, z, width);
|
||||
else
|
||||
face_rounded_cutout(
|
||||
face, u, z, width, height,
|
||||
shape == "rect" ? 0.02 :
|
||||
shape == "slot" ? min(width, height) / 2 : radius
|
||||
);
|
||||
}
|
||||
|
||||
module board_standoffs() {
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([
|
||||
board_origin_x + mx,
|
||||
board_origin_y + my,
|
||||
effective_floor - eps
|
||||
])
|
||||
cylinder(
|
||||
h = effective_board_clearance + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module lid_bosses() {
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], effective_floor - eps])
|
||||
cylinder(
|
||||
h = body_height - effective_floor + eps,
|
||||
d = m25_boss_diameter
|
||||
);
|
||||
}
|
||||
|
||||
module battery_cradle() {
|
||||
partition_height = min(
|
||||
body_height - lid_lip_depth - 0.5,
|
||||
battery_center_z + battery_diameter / 2 + 1.5
|
||||
);
|
||||
rib_width = 2.4;
|
||||
rib_positions = [
|
||||
battery_start_x + 4,
|
||||
(battery_start_x + battery_end_x) / 2,
|
||||
battery_end_x - 4
|
||||
];
|
||||
|
||||
// Board/battery partition, with a lead-routing notch near the JST end.
|
||||
difference() {
|
||||
translate([wall_thickness, partition_y, effective_floor - eps])
|
||||
cube([
|
||||
battery_end_x - wall_thickness + battery_end_clearance,
|
||||
wall_thickness,
|
||||
partition_height - effective_floor + eps
|
||||
]);
|
||||
translate([
|
||||
board_origin_x + 6.5,
|
||||
partition_y - eps,
|
||||
effective_floor + 1.5
|
||||
])
|
||||
cube([9, wall_thickness + 2 * eps, 6]);
|
||||
}
|
||||
|
||||
// Three low curved ribs cradle the protected cylindrical pack.
|
||||
for (x = rib_positions)
|
||||
difference() {
|
||||
translate([
|
||||
x - rib_width / 2,
|
||||
battery_center_y - battery_diameter / 2
|
||||
- battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
rib_width,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + battery_radial_clearance + eps
|
||||
]);
|
||||
translate([x - rib_width, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(
|
||||
h = rib_width * 2,
|
||||
d = battery_diameter + 2 * battery_radial_clearance
|
||||
);
|
||||
}
|
||||
|
||||
// End stops remain below the cell center so the lid can remove vertically.
|
||||
for (x = [
|
||||
battery_start_x - battery_end_clearance - wall_thickness,
|
||||
battery_end_x + battery_end_clearance
|
||||
])
|
||||
translate([
|
||||
x,
|
||||
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
wall_thickness,
|
||||
battery_diameter + 2 * battery_radial_clearance,
|
||||
battery_diameter / 2 + 1 + eps
|
||||
]);
|
||||
|
||||
// Rear cross-strip separates the side-mounted switch body from the cell.
|
||||
translate([
|
||||
battery_end_x + battery_end_clearance,
|
||||
partition_y,
|
||||
effective_floor - eps
|
||||
])
|
||||
cube([
|
||||
case_length - wall_thickness - battery_end_x - battery_end_clearance,
|
||||
case_width - wall_thickness - partition_y,
|
||||
min(body_height - effective_floor, switch_center_z
|
||||
+ switch_body_diameter / 2 + switch_wire_allowance
|
||||
- effective_floor) + eps
|
||||
]);
|
||||
}
|
||||
|
||||
module body_positive() {
|
||||
union() {
|
||||
difference() {
|
||||
rounded_box([case_length, case_width, body_height], corner_radius);
|
||||
translate([wall_thickness, wall_thickness, effective_floor])
|
||||
rounded_box([
|
||||
case_length - 2 * wall_thickness,
|
||||
case_width - 2 * wall_thickness,
|
||||
body_height - effective_floor + eps
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
}
|
||||
board_standoffs();
|
||||
lid_bosses();
|
||||
if (battery_enabled)
|
||||
battery_cradle();
|
||||
if (tripod_insert_enabled)
|
||||
translate([case_length / 2, case_width / 2, 0])
|
||||
cylinder(h = effective_floor, d = tripod_boss_diameter);
|
||||
if (usb_guide_active)
|
||||
usb_guide_shell();
|
||||
}
|
||||
}
|
||||
|
||||
module body_cutouts() {
|
||||
// Board mounting insert pockets, loaded from above before the Feather.
|
||||
for (mx = mount_x, my = mount_y)
|
||||
translate([board_origin_x + mx, board_origin_y + my, 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
board_bottom_z
|
||||
);
|
||||
|
||||
// Lid insert pockets, loaded from the top of the body.
|
||||
for (p = lid_boss_xy)
|
||||
translate([p[0], p[1], 0])
|
||||
insert_pocket(
|
||||
m25_insert_hole_diameter,
|
||||
m25_insert_length + insert_melt_relief,
|
||||
body_height
|
||||
);
|
||||
|
||||
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
|
||||
if (usb_opening_enabled)
|
||||
face_rounded_cutout(
|
||||
"front",
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
usb_inner_width,
|
||||
usb_inner_height,
|
||||
usb_opening_radius
|
||||
);
|
||||
|
||||
if (battery_enabled) {
|
||||
face_circle_cutout(
|
||||
"right",
|
||||
switch_center_x,
|
||||
switch_center_z,
|
||||
switch_hole_diameter + switch_hole_compensation
|
||||
);
|
||||
// Clear the switch body and its prewired terminals through the rear bay.
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness + eps,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0])
|
||||
cylinder(
|
||||
h = switch_body_length + switch_wire_allowance
|
||||
+ wall_thickness + eps,
|
||||
d = switch_body_diameter + 2 * fit_clearance
|
||||
);
|
||||
}
|
||||
|
||||
if (tripod_insert_enabled) {
|
||||
translate([case_length / 2, case_width / 2, 0]) {
|
||||
translate([0, 0, -eps])
|
||||
cylinder(
|
||||
h = effective_floor + 2 * eps,
|
||||
d = tripod_thread_clearance_diameter + hole_compensation
|
||||
);
|
||||
insert_pocket(
|
||||
tripod_insert_hole_diameter,
|
||||
tripod_insert_length + insert_melt_relief,
|
||||
effective_floor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (entry = extra_cutouts)
|
||||
custom_wall_cutout(entry);
|
||||
}
|
||||
|
||||
module body() {
|
||||
difference() {
|
||||
body_positive();
|
||||
body_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module lid_plate_positive() {
|
||||
union() {
|
||||
rounded_box([case_length, case_width, lid_thickness], corner_radius);
|
||||
// Inset rim: exported pointing upward so the outer lid face prints flat.
|
||||
translate([
|
||||
wall_thickness + fit_clearance,
|
||||
wall_thickness + fit_clearance,
|
||||
lid_thickness
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth)
|
||||
difference() {
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (wall_thickness + fit_clearance),
|
||||
case_width - 2 * (wall_thickness + fit_clearance)
|
||||
], max(0.6, corner_radius - wall_thickness));
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
case_length - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
),
|
||||
case_width - 2 * (
|
||||
wall_thickness + fit_clearance
|
||||
+ lid_lip_thickness
|
||||
)
|
||||
], max(0.5, corner_radius - wall_thickness
|
||||
- lid_lip_thickness));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module beveled_oled_window() {
|
||||
inner_size = oled_visible_size;
|
||||
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
|
||||
center = [
|
||||
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
|
||||
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
|
||||
];
|
||||
hull() {
|
||||
translate([center[0], center[1], -eps])
|
||||
linear_extrude(height = eps)
|
||||
centered_rounded_rect_2d(outer_size, 1.5);
|
||||
translate([center[0], center[1], lid_thickness - eps])
|
||||
linear_extrude(height = 2 * eps)
|
||||
centered_rounded_rect_2d(inner_size, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
module oled_button_lid_cutout(xy) {
|
||||
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
|
||||
shaft_size = [
|
||||
button_cap_size[0] + fit_clearance * 2,
|
||||
button_cap_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_size = [
|
||||
button_flange_size[0] + fit_clearance * 2,
|
||||
button_flange_size[1] + fit_clearance * 2
|
||||
];
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
translate([center[0], center[1], -eps])
|
||||
centered_rounded_prism(
|
||||
shaft_size,
|
||||
lid_thickness + 2 * eps,
|
||||
shaft_size[1] / 2
|
||||
);
|
||||
translate([center[0], center[1], lid_thickness - recess_depth])
|
||||
centered_rounded_prism(
|
||||
recess_size,
|
||||
recess_depth + lid_lip_depth + eps,
|
||||
recess_size[1] / 2
|
||||
);
|
||||
}
|
||||
|
||||
module lid_cutouts() {
|
||||
// Through holes plus shallow counterbores for flush M2.5 socket heads.
|
||||
for (p = lid_boss_xy) {
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_thickness + lid_lip_depth + 2 * eps,
|
||||
d = m25_screw_clearance + hole_compensation
|
||||
);
|
||||
translate([p[0], p[1], -eps])
|
||||
cylinder(
|
||||
h = lid_screw_head_height + eps,
|
||||
d = lid_screw_head_diameter + hole_compensation
|
||||
);
|
||||
}
|
||||
|
||||
if (top_profile == "oled128x64") {
|
||||
beveled_oled_window();
|
||||
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
|
||||
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
|
||||
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
|
||||
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
|
||||
}
|
||||
}
|
||||
|
||||
module lid() {
|
||||
difference() {
|
||||
lid_plate_positive();
|
||||
lid_cutouts();
|
||||
}
|
||||
}
|
||||
|
||||
module one_oled_button() {
|
||||
recess_depth = button_flange_thickness + button_travel;
|
||||
stem_height = lid_thickness - recess_depth + button_projection;
|
||||
// Print outer cap face down. The flange and contact nub build upward.
|
||||
centered_rounded_prism(
|
||||
button_cap_size,
|
||||
stem_height,
|
||||
button_cap_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height])
|
||||
centered_rounded_prism(
|
||||
button_flange_size,
|
||||
button_flange_thickness,
|
||||
button_flange_size[1] / 2
|
||||
);
|
||||
translate([0, 0, stem_height + button_flange_thickness])
|
||||
cylinder(h = button_contact_extension, d = 3);
|
||||
}
|
||||
|
||||
module buttons() {
|
||||
enabled = [
|
||||
button_a,
|
||||
button_b,
|
||||
button_c,
|
||||
button_reset
|
||||
];
|
||||
count_enabled = len([for (v = enabled) if (v) 1]);
|
||||
if (count_enabled == 0)
|
||||
echo("No OLED buttons are enabled.");
|
||||
for (i = [0 : len(enabled) - 1])
|
||||
if (enabled[i])
|
||||
translate([i * (button_flange_size[0] + 3), 0, 0])
|
||||
one_oled_button();
|
||||
}
|
||||
|
||||
module reference_board() {
|
||||
color([0.05, 0.25, 0.55, 0.75])
|
||||
translate([board_origin_x, board_origin_y, board_bottom_z])
|
||||
rounded_box([feather_length, feather_width, board_thickness], 2.54);
|
||||
color([0.12, 0.45, 0.75, 0.45])
|
||||
for (i = [1 : wing_count])
|
||||
translate([
|
||||
board_origin_x,
|
||||
board_origin_y,
|
||||
board_top_z + i * wing_pitch - board_thickness
|
||||
])
|
||||
rounded_box(
|
||||
[feather_length, feather_width, board_thickness], 2.54
|
||||
);
|
||||
}
|
||||
|
||||
module reference_top_feature() {
|
||||
if (top_profile == "gps")
|
||||
color([0.92, 0.78, 0.35, 0.8])
|
||||
translate([
|
||||
board_origin_x + 31,
|
||||
board_origin_y + (feather_width - 15) / 2,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([15, 15, gps_antenna_height]);
|
||||
else if (top_profile == "oled128x64") {
|
||||
color([0.12, 0.12, 0.12, 0.9])
|
||||
translate([
|
||||
board_origin_x + oled_visible_min[0],
|
||||
board_origin_y + oled_visible_min[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
oled_visible_size[0],
|
||||
oled_visible_size[1],
|
||||
oled_display_height
|
||||
]);
|
||||
color([0.6, 0.6, 0.6, 0.9])
|
||||
for (xy = [
|
||||
oled_button_a_xy,
|
||||
oled_button_b_xy,
|
||||
oled_button_c_xy,
|
||||
oled_button_reset_xy
|
||||
])
|
||||
translate([
|
||||
board_origin_x + xy[0],
|
||||
board_origin_y + xy[1],
|
||||
top_pcb_z
|
||||
])
|
||||
cylinder(h = 1.6, d = 2.1);
|
||||
} else
|
||||
color([0.3, 0.65, 0.35, 0.6])
|
||||
translate([
|
||||
board_origin_x + 10,
|
||||
board_origin_y + 4,
|
||||
top_pcb_z
|
||||
])
|
||||
cube([
|
||||
feather_length - 20,
|
||||
feather_width - 8,
|
||||
generic_component_height
|
||||
]);
|
||||
}
|
||||
|
||||
module reference_battery_and_switch() {
|
||||
if (battery_enabled) {
|
||||
color([0.35, 0.18, 0.55, 0.75])
|
||||
translate([battery_start_x, battery_center_y, battery_center_z])
|
||||
rotate([0, 90, 0])
|
||||
cylinder(h = battery_length, d = battery_diameter);
|
||||
color([0.8, 0.15, 0.1, 0.65])
|
||||
translate([
|
||||
switch_center_x,
|
||||
case_width - wall_thickness,
|
||||
switch_center_z
|
||||
])
|
||||
rotate([90, 0, 0]) {
|
||||
cylinder(h = switch_body_length, d = switch_body_diameter);
|
||||
translate([0, 0, -1.5])
|
||||
cylinder(h = 1.5, d = switch_flange_diameter);
|
||||
translate([0, 0, -3])
|
||||
cylinder(h = 3, d = switch_cap_diameter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module assembly() {
|
||||
color([0.82, 0.84, 0.88, 1]) body();
|
||||
if ($preview && show_references) {
|
||||
reference_board();
|
||||
reference_top_feature();
|
||||
reference_battery_and_switch();
|
||||
}
|
||||
// Printable lid is mirrored into its installed orientation and exploded up.
|
||||
color([0.92, 0.92, 0.95, 0.88])
|
||||
translate([0, case_width, body_height + lid_thickness + exploded_view])
|
||||
rotate([180, 0, 0])
|
||||
lid();
|
||||
}
|
||||
|
||||
module coupon_hole(x, y, diameter, depth) {
|
||||
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
|
||||
}
|
||||
|
||||
module insert_coupon() {
|
||||
spacing = 15;
|
||||
coupon_length = spacing * len(coupon_steps) + 8;
|
||||
coupon_width = 32;
|
||||
coupon_height = max(
|
||||
m25_insert_length + insert_melt_relief + 1.2,
|
||||
tripod_insert_length + insert_melt_relief + 1.2
|
||||
);
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1]) {
|
||||
x = 7 + i * spacing;
|
||||
coupon_hole(
|
||||
x, 8,
|
||||
m25_insert_hole_diameter + coupon_steps[i],
|
||||
m25_insert_length + insert_melt_relief
|
||||
);
|
||||
coupon_hole(
|
||||
x, 23,
|
||||
tripod_insert_hole_diameter + coupon_steps[i],
|
||||
tripod_insert_length + insert_melt_relief
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module switch_coupon() {
|
||||
spacing = 18;
|
||||
coupon_length = spacing * len(coupon_steps) + 6;
|
||||
coupon_width = 24;
|
||||
coupon_height = wall_thickness;
|
||||
difference() {
|
||||
rounded_box([coupon_length, coupon_width, coupon_height], 3);
|
||||
for (i = [0 : len(coupon_steps) - 1])
|
||||
translate([6 + i * spacing, coupon_width / 2, -eps])
|
||||
cylinder(
|
||||
h = coupon_height + 2 * eps,
|
||||
d = switch_hole_diameter + coupon_steps[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module fit_coupon() {
|
||||
segment_length = 34;
|
||||
base_width = 16;
|
||||
base_height = 6;
|
||||
cavity_size = [segment_length - 8, base_width - 8];
|
||||
male_size = [
|
||||
cavity_size[0] - 2 * fit_clearance,
|
||||
cavity_size[1] - 2 * fit_clearance
|
||||
];
|
||||
difference() {
|
||||
rounded_box([segment_length, base_width, base_height], 2);
|
||||
translate([4, 4, 2])
|
||||
cube([segment_length - 8, base_width - 8, base_height]);
|
||||
}
|
||||
translate([segment_length + 5, 0, 0])
|
||||
union() {
|
||||
cube([segment_length, base_width, lid_thickness]);
|
||||
translate([
|
||||
4 + fit_clearance,
|
||||
4 + fit_clearance,
|
||||
lid_thickness - eps
|
||||
])
|
||||
linear_extrude(height = lid_lip_depth + eps)
|
||||
difference() {
|
||||
rounded_rect_2d(male_size, 1);
|
||||
translate([lid_lip_thickness, lid_lip_thickness])
|
||||
rounded_rect_2d([
|
||||
male_size[0] - 2 * lid_lip_thickness,
|
||||
male_size[1] - 2 * lid_lip_thickness
|
||||
], 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate_cutout_spacing();
|
||||
|
||||
if (part == "assembly")
|
||||
assembly();
|
||||
else if (part == "body")
|
||||
body();
|
||||
else if (part == "lid")
|
||||
lid();
|
||||
else if (part == "buttons")
|
||||
buttons();
|
||||
else if (part == "insert_coupon")
|
||||
insert_coupon();
|
||||
else if (part == "switch_coupon")
|
||||
switch_coupon();
|
||||
else if (part == "fit_coupon")
|
||||
fit_coupon();
|
||||
else
|
||||
assert(false, str("Unknown part: ", part));
|
||||
@@ -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()
|
||||
+107
-13
@@ -19,13 +19,14 @@ show_references = true;
|
||||
/* [Stack] */
|
||||
top_profile = "generic"; // [generic,gps,oled128x64]
|
||||
wing_count = 1; // [1:1:8]
|
||||
wing_pitch = 11; // [7:0.1:16]
|
||||
wing_pitch = 12.25; // [7:0.1:16]
|
||||
stack_height_override = 0; // [0:0.1:100]
|
||||
generic_component_height = 3; // [0:0.1:30]
|
||||
generic_top_gap = 2; // [0.5:0.1:10]
|
||||
gps_antenna_height = 4;
|
||||
gps_antenna_height = 6;
|
||||
gps_top_gap = 5;
|
||||
oled_component_height = 2.4;
|
||||
oled_display_height = 1.5;
|
||||
oled_face_gap = 0.8;
|
||||
|
||||
/* [OLED controls] */
|
||||
@@ -77,15 +78,21 @@ corner_radius = 4;
|
||||
board_bottom_clearance = 5.4;
|
||||
board_side_clearance = 7;
|
||||
board_rear_clearance = 4;
|
||||
usb_board_wall_gap = 1.2;
|
||||
usb_board_wall_gap = 2;
|
||||
lid_lip_depth = 2.4;
|
||||
lid_lip_thickness = 1.2;
|
||||
fit_clearance = 0.35;
|
||||
cutout_min_web = 1.2;
|
||||
hole_compensation = 0.2;
|
||||
lid_screw_head_diameter = 5.2;
|
||||
lid_screw_head_height = 2.5;
|
||||
|
||||
/* [USB-C opening] */
|
||||
usb_opening_enabled = true;
|
||||
usb_guide_enabled = true;
|
||||
usb_connector_overhang = 2;
|
||||
usb_guide_wall_thickness = 1.2;
|
||||
usb_guide_end_gap = 1;
|
||||
usb_opening_width = 12.5;
|
||||
usb_opening_height = 7;
|
||||
usb_opening_radius = 1.5;
|
||||
@@ -131,6 +138,20 @@ board_origin_x = wall_thickness + usb_board_wall_gap;
|
||||
board_origin_y = wall_thickness + board_side_clearance;
|
||||
board_bottom_z = effective_floor + effective_board_clearance;
|
||||
board_top_z = board_bottom_z + board_thickness;
|
||||
usb_center_u = board_origin_y + usb_center_y;
|
||||
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
|
||||
usb_inner_width = usb_opening_width + hole_compensation;
|
||||
usb_inner_height = usb_opening_height + hole_compensation;
|
||||
usb_guide_length = max(
|
||||
0,
|
||||
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
|
||||
);
|
||||
usb_guide_active = usb_opening_enabled && usb_guide_enabled
|
||||
&& usb_guide_length > 0;
|
||||
usb_reserved_width = usb_inner_width
|
||||
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
|
||||
usb_reserved_height = usb_inner_height
|
||||
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
|
||||
|
||||
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
|
||||
top_pcb_z = stack_height_override > 0
|
||||
@@ -215,8 +236,43 @@ assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
|
||||
assert(!tripod_insert_enabled
|
||||
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
|
||||
"Tripod insert boss does not have enough radial material");
|
||||
assert(usb_opening_enabled || !usb_guide_enabled,
|
||||
"USB guide requires the USB opening");
|
||||
|
||||
function clamp(v, lo, hi) = min(max(v, lo), hi);
|
||||
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
|
||||
function cutouts_separated(first, second, web) =
|
||||
abs(first[2] - second[2])
|
||||
>= (first[4] + second[4]) / 2 + web
|
||||
|| abs(first[3] - second[3])
|
||||
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
|
||||
function cutout_clear_of_usb(entry, web) =
|
||||
abs(entry[2] - usb_center_u)
|
||||
>= (entry[4] + usb_reserved_width) / 2 + web
|
||||
|| abs(entry[3] - usb_center_z)
|
||||
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
|
||||
|
||||
module validate_cutout_spacing() {
|
||||
if (len(extra_cutouts) > 1)
|
||||
for (first = [0 : len(extra_cutouts) - 2],
|
||||
second = [first + 1 : len(extra_cutouts) - 1])
|
||||
if (extra_cutouts[first][0] == extra_cutouts[second][0])
|
||||
assert(
|
||||
cutouts_separated(
|
||||
extra_cutouts[first],
|
||||
extra_cutouts[second],
|
||||
cutout_min_web
|
||||
),
|
||||
"Custom cutouts are too close on the same wall"
|
||||
);
|
||||
if (usb_opening_enabled)
|
||||
for (entry = extra_cutouts)
|
||||
if (entry[0] == "front")
|
||||
assert(
|
||||
cutout_clear_of_usb(entry, cutout_min_web),
|
||||
"Front cutout is too close to the USB opening or guide"
|
||||
);
|
||||
}
|
||||
|
||||
module rounded_rect_2d(size, radius) {
|
||||
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
|
||||
@@ -300,6 +356,40 @@ module face_circle_cutout(face, u, z, diameter) {
|
||||
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
|
||||
}
|
||||
|
||||
module x_rounded_prism(x, length, u, z, width, height, radius) {
|
||||
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
|
||||
hull()
|
||||
for (yy = [u - width / 2 + r, u + width / 2 - r],
|
||||
zz = [z - height / 2 + r, z + height / 2 - r])
|
||||
translate([x, yy, zz])
|
||||
rotate([0, 90, 0]) cylinder(h = length, r = r);
|
||||
}
|
||||
|
||||
module usb_guide_shell() {
|
||||
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
|
||||
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
|
||||
difference() {
|
||||
x_rounded_prism(
|
||||
wall_thickness - eps,
|
||||
usb_guide_length + 2 * eps,
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
outer_width,
|
||||
outer_height,
|
||||
usb_opening_radius + usb_guide_wall_thickness
|
||||
);
|
||||
x_rounded_prism(
|
||||
wall_thickness - 2 * eps,
|
||||
usb_guide_length + 4 * eps,
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
usb_inner_width,
|
||||
usb_inner_height,
|
||||
usb_opening_radius
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module custom_wall_cutout(entry) {
|
||||
face = entry[0];
|
||||
shape = entry[1];
|
||||
@@ -454,6 +544,8 @@ module body_positive() {
|
||||
if (tripod_insert_enabled)
|
||||
translate([case_length / 2, case_width / 2, 0])
|
||||
cylinder(h = effective_floor, d = tripod_boss_diameter);
|
||||
if (usb_guide_active)
|
||||
usb_guide_shell();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,15 +569,15 @@ module body_cutouts() {
|
||||
);
|
||||
|
||||
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
|
||||
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
|
||||
face_rounded_cutout(
|
||||
"front",
|
||||
board_origin_y + usb_center_y,
|
||||
usb_center_z,
|
||||
usb_opening_width + hole_compensation,
|
||||
usb_opening_height + hole_compensation,
|
||||
usb_opening_radius
|
||||
);
|
||||
if (usb_opening_enabled)
|
||||
face_rounded_cutout(
|
||||
"front",
|
||||
usb_center_u,
|
||||
usb_center_z,
|
||||
usb_inner_width,
|
||||
usb_inner_height,
|
||||
usb_opening_radius
|
||||
);
|
||||
|
||||
if (battery_enabled) {
|
||||
face_circle_cutout(
|
||||
@@ -708,7 +800,7 @@ module reference_top_feature() {
|
||||
cube([
|
||||
oled_visible_size[0],
|
||||
oled_visible_size[1],
|
||||
oled_component_height
|
||||
oled_display_height
|
||||
]);
|
||||
color([0.6, 0.6, 0.6, 0.9])
|
||||
for (xy = [
|
||||
@@ -853,6 +945,8 @@ module fit_coupon() {
|
||||
}
|
||||
}
|
||||
|
||||
validate_cutout_spacing();
|
||||
|
||||
if (part == "assembly")
|
||||
assembly();
|
||||
else if (part == "body")
|
||||
|
||||
@@ -11,6 +11,7 @@ require "yaml"
|
||||
|
||||
require "highline"
|
||||
|
||||
require_relative "feather_case_wizard/prompting_ui"
|
||||
require_relative "feather_case_wizard/config"
|
||||
require_relative "feather_case_wizard/toolchain"
|
||||
require_relative "feather_case_wizard/package_builder"
|
||||
@@ -23,4 +24,3 @@ module FeatherCaseWizard
|
||||
MODEL_SOURCE = ROOT.join("feather_case.scad").freeze
|
||||
RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze
|
||||
end
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ module FeatherCaseWizard
|
||||
def initialize(ui: HighLine.new, cases_root: CASES_ROOT,
|
||||
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
|
||||
toolchain: Toolchain.new)
|
||||
@ui = ui
|
||||
@ui = PromptingUI.wrap(ui)
|
||||
@cases_root = Pathname(cases_root)
|
||||
@model_source = Pathname(model_source)
|
||||
@renderer_source = Pathname(renderer_source)
|
||||
@toolchain = toolchain
|
||||
@wizard = Wizard.new(ui: ui)
|
||||
@wizard = Wizard.new(ui: @ui)
|
||||
end
|
||||
|
||||
def run
|
||||
@ui.say(@ui.color("Feather Case Wizard", :bold))
|
||||
loop do
|
||||
action = @ui.choose("\nWhat would you like to do?") do |menu|
|
||||
action = @ui.choose("What would you like to do?") do |menu|
|
||||
menu.choice("Create a new case") { :create }
|
||||
menu.choice("Edit an existing case") { :edit }
|
||||
menu.choice("Rebuild an existing case") { :rebuild }
|
||||
|
||||
@@ -14,13 +14,14 @@ module FeatherCaseWizard
|
||||
DEFAULT_PARAMETERS = {
|
||||
"top_profile" => "generic",
|
||||
"wing_count" => 1,
|
||||
"wing_pitch" => 11.0,
|
||||
"wing_pitch" => 12.25,
|
||||
"stack_height_override" => 0.0,
|
||||
"generic_component_height" => 3.0,
|
||||
"generic_top_gap" => 2.0,
|
||||
"gps_antenna_height" => 4.0,
|
||||
"gps_antenna_height" => 6.0,
|
||||
"gps_top_gap" => 5.0,
|
||||
"oled_component_height" => 2.4,
|
||||
"oled_display_height" => 1.5,
|
||||
"oled_face_gap" => 0.8,
|
||||
"button_a" => true,
|
||||
"button_b" => true,
|
||||
@@ -62,13 +63,19 @@ module FeatherCaseWizard
|
||||
"board_bottom_clearance" => 5.4,
|
||||
"board_side_clearance" => 7.0,
|
||||
"board_rear_clearance" => 4.0,
|
||||
"usb_board_wall_gap" => 1.2,
|
||||
"usb_board_wall_gap" => 2.0,
|
||||
"lid_lip_depth" => 2.4,
|
||||
"lid_lip_thickness" => 1.2,
|
||||
"fit_clearance" => 0.35,
|
||||
"cutout_min_web" => 1.2,
|
||||
"hole_compensation" => 0.2,
|
||||
"lid_screw_head_diameter" => 5.2,
|
||||
"lid_screw_head_height" => 2.5,
|
||||
"usb_opening_enabled" => true,
|
||||
"usb_guide_enabled" => true,
|
||||
"usb_connector_overhang" => 2.0,
|
||||
"usb_guide_wall_thickness" => 1.2,
|
||||
"usb_guide_end_gap" => 1.0,
|
||||
"usb_opening_width" => 12.5,
|
||||
"usb_opening_height" => 7.0,
|
||||
"usb_opening_radius" => 1.5,
|
||||
@@ -77,11 +84,12 @@ module FeatherCaseWizard
|
||||
|
||||
ADVANCED_FIELDS = {
|
||||
"Stack" => %w[wing_pitch generic_component_height generic_top_gap
|
||||
gps_antenna_height gps_top_gap oled_component_height oled_face_gap],
|
||||
gps_antenna_height gps_top_gap oled_component_height
|
||||
oled_display_height oled_face_gap],
|
||||
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
|
||||
board_bottom_clearance board_side_clearance board_rear_clearance
|
||||
usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
|
||||
hole_compensation lid_screw_head_diameter lid_screw_head_height],
|
||||
board_bottom_clearance board_side_clearance lid_lip_depth
|
||||
lid_lip_thickness fit_clearance cutout_min_web hole_compensation
|
||||
lid_screw_head_diameter lid_screw_head_height],
|
||||
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
|
||||
m25_screw_clearance m25_boss_diameter
|
||||
insert_entry_chamfer insert_melt_relief],
|
||||
@@ -93,7 +101,8 @@ module FeatherCaseWizard
|
||||
switch_flange_diameter switch_body_length
|
||||
switch_body_diameter switch_wire_allowance],
|
||||
"USB-C opening" => %w[usb_opening_width usb_opening_height
|
||||
usb_opening_radius usb_vertical_offset],
|
||||
usb_opening_radius usb_vertical_offset usb_connector_overhang
|
||||
usb_guide_wall_thickness usb_guide_end_gap],
|
||||
"OLED buttons" => %w[button_projection button_flange_thickness
|
||||
button_travel button_contact_extension]
|
||||
}.freeze
|
||||
@@ -264,6 +273,9 @@ module FeatherCaseWizard
|
||||
unless (outputs["coupons"] - COUPONS).empty?
|
||||
raise ConfigError, "Unsupported calibration coupon"
|
||||
end
|
||||
if parameters["usb_guide_enabled"] && !parameters["usb_opening_enabled"]
|
||||
raise ConfigError, "USB guide requires the USB opening"
|
||||
end
|
||||
|
||||
positive = DEFAULT_PARAMETERS.keys - %w[usb_vertical_offset stack_height_override]
|
||||
positive.each do |key|
|
||||
@@ -289,7 +301,10 @@ module FeatherCaseWizard
|
||||
def normalize!
|
||||
data["schema_version"] = Integer(data.fetch("schema_version", SCHEMA_VERSION))
|
||||
data["generator"] ||= {}
|
||||
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(data["parameters"] || {})
|
||||
supplied_parameters = data["parameters"] || {}
|
||||
legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled")
|
||||
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters)
|
||||
data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide
|
||||
data["cutouts"] ||= []
|
||||
data["outputs"] ||= {}
|
||||
data["outputs"]["formats"] ||= ["3mf"]
|
||||
@@ -302,6 +317,7 @@ module FeatherCaseWizard
|
||||
def validate_cutouts!
|
||||
dims = dimensions
|
||||
names = {}
|
||||
bounds = []
|
||||
cutouts.each do |cutout|
|
||||
name = cutout["name"].to_s.strip
|
||||
raise ConfigError, "Every cutout needs a name" if name.empty?
|
||||
@@ -328,9 +344,68 @@ module FeatherCaseWizard
|
||||
radius = Float(cutout.fetch("radius", [width, height].min / 2))
|
||||
raise ConfigError, "#{name} dimensions must be positive" unless
|
||||
width.positive? && height.positive? && radius.positive?
|
||||
bounds << {
|
||||
"name" => name, "face" => face,
|
||||
"left" => horizontal - width / 2, "right" => horizontal + width / 2,
|
||||
"bottom" => z - height / 2, "top" => z + height / 2
|
||||
}
|
||||
rescue ArgumentError, TypeError, KeyError => e
|
||||
raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}"
|
||||
end
|
||||
validate_cutout_spacing!(bounds, dims)
|
||||
end
|
||||
|
||||
def validate_cutout_spacing!(bounds, dims)
|
||||
web = parameters["cutout_min_web"]
|
||||
bounds.combination(2) do |first, second|
|
||||
next unless first["face"] == second["face"]
|
||||
next if bounds_separated?(first, second, web)
|
||||
|
||||
raise ConfigError,
|
||||
"#{first["name"]} and #{second["name"]} are too close on the #{first["face"]} wall"
|
||||
end
|
||||
return unless parameters["usb_opening_enabled"]
|
||||
|
||||
usb = usb_wall_bounds(dims)
|
||||
bounds.select { |item| item["face"] == "front" }.each do |cutout|
|
||||
next if bounds_separated?(cutout, usb, web)
|
||||
|
||||
raise ConfigError, "#{cutout["name"]} is too close to the USB opening or guide"
|
||||
end
|
||||
end
|
||||
|
||||
def bounds_separated?(first, second, web)
|
||||
first["right"] + web <= second["left"] ||
|
||||
second["right"] + web <= first["left"] ||
|
||||
first["top"] + web <= second["bottom"] ||
|
||||
second["top"] + web <= first["bottom"]
|
||||
end
|
||||
|
||||
def usb_wall_bounds(dims)
|
||||
p = parameters
|
||||
effective_floor = p["tripod_insert_enabled"] ?
|
||||
[p["floor_thickness"], p["tripod_insert_length"] + p["insert_melt_relief"] + 1.2].max :
|
||||
p["floor_thickness"]
|
||||
effective_board_clearance = [
|
||||
p["board_bottom_clearance"],
|
||||
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
|
||||
].max
|
||||
board_top_z = effective_floor + effective_board_clearance + 1.6
|
||||
global_y = p["wall_thickness"] + p["board_side_clearance"] + 11.43
|
||||
horizontal = dims["width"] - global_y
|
||||
z = board_top_z + 1.6 + p["usb_vertical_offset"]
|
||||
guide_length = [
|
||||
p["usb_board_wall_gap"] - p["usb_connector_overhang"] - p["usb_guide_end_gap"], 0
|
||||
].max
|
||||
guide_active = p["usb_guide_enabled"] && guide_length.positive?
|
||||
shell = guide_active ? 2 * p["usb_guide_wall_thickness"] : 0
|
||||
width = p["usb_opening_width"] + p["hole_compensation"] + shell
|
||||
height = p["usb_opening_height"] + p["hole_compensation"] + shell
|
||||
{
|
||||
"name" => "USB opening", "face" => "front",
|
||||
"left" => horizontal - width / 2, "right" => horizontal + width / 2,
|
||||
"bottom" => z - height / 2, "top" => z + height / 2
|
||||
}
|
||||
end
|
||||
|
||||
def stringify_keys(value)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module FeatherCaseWizard
|
||||
class PromptingUI
|
||||
def self.wrap(ui)
|
||||
ui.is_a?(self) ? ui : new(ui)
|
||||
end
|
||||
|
||||
def initialize(ui)
|
||||
@ui = ui
|
||||
end
|
||||
|
||||
def ask(*arguments, &block)
|
||||
separate_after { @ui.ask(*arguments, &block) }
|
||||
end
|
||||
|
||||
def agree(*arguments, &block)
|
||||
separate_after { @ui.agree(*arguments, &block) }
|
||||
end
|
||||
|
||||
def choose(prompt, &block)
|
||||
separate_after do
|
||||
@ui.choose do |menu|
|
||||
menu.header = prompt.to_s.sub(/[?:]\s*\z/, "")
|
||||
block.call(menu)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def say(*arguments, &block)
|
||||
@ui.say(*arguments, &block)
|
||||
end
|
||||
|
||||
def color(*arguments, &block)
|
||||
@ui.color(*arguments, &block)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def separate_after
|
||||
result = yield
|
||||
@ui.say("\n")
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ module FeatherCaseWizard
|
||||
attr_reader :ui
|
||||
|
||||
def initialize(ui: HighLine.new)
|
||||
@ui = ui
|
||||
@ui = PromptingUI.wrap(ui)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -15,14 +15,14 @@ module FeatherCaseWizard
|
||||
end
|
||||
|
||||
def edit(config)
|
||||
ui.say("\nEditing #{config.name} (`#{config.slug}`)")
|
||||
ui.say("Editing #{config.name} (`#{config.slug}`)")
|
||||
config.data["name"] = text("Case name", config.name)
|
||||
configure(config, new_case: false)
|
||||
end
|
||||
|
||||
def confirm_build(config)
|
||||
dims = config.dimensions
|
||||
ui.say("\n#{ui.color("Build summary", :bold)}")
|
||||
ui.say(ui.color("Build summary", :bold))
|
||||
ui.say(" Case: #{config.name} (#{config.slug})")
|
||||
ui.say(" Profile: #{config.parameters["top_profile"]}")
|
||||
ui.say(" Wings: #{config.parameters["wing_count"]}")
|
||||
@@ -42,6 +42,7 @@ module FeatherCaseWizard
|
||||
choose_formats(config)
|
||||
choose_profile(config)
|
||||
choose_stack(config)
|
||||
choose_end_clearance(config)
|
||||
choose_features(config)
|
||||
manage_cutouts(config)
|
||||
choose_coupons(config)
|
||||
@@ -63,7 +64,7 @@ module FeatherCaseWizard
|
||||
menu.choice("3MF") { ["3mf"] }
|
||||
menu.choice("STL") { ["stl"] }
|
||||
menu.choice("Both 3MF and STL") { %w[3mf stl] }
|
||||
menu.default = label == "both" ? 3 : (label == "stl" ? 2 : 1)
|
||||
menu.default = (label == "both" ? 3 : (label == "stl" ? 2 : 1)).to_s
|
||||
end
|
||||
config.outputs["formats"] = selected
|
||||
end
|
||||
@@ -74,7 +75,7 @@ module FeatherCaseWizard
|
||||
menu.choice("Generic / other") { "generic" }
|
||||
menu.choice("GPS") { "gps" }
|
||||
menu.choice("128×64 OLED") { "oled128x64" }
|
||||
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current)
|
||||
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current).to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,11 +83,11 @@ module FeatherCaseWizard
|
||||
p = config.parameters
|
||||
p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8)
|
||||
measured = p["stack_height_override"].positive?
|
||||
if ui.agree("Use a measured board-to-top-PCB stack height? (y/n) ") do |question|
|
||||
if ui.agree("Use a measured Feather-PCB-top to top-Wing-PCB stack height? (y/n) ") do |question|
|
||||
question.default = measured ? "yes" : "no"
|
||||
end
|
||||
p["stack_height_override"] = number(
|
||||
"Measured height in mm", measured ? p["stack_height_override"] :
|
||||
"PCB-top to PCB-top height in mm", measured ? p["stack_height_override"] :
|
||||
p["wing_count"] * p["wing_pitch"], min: 0.1
|
||||
)
|
||||
else
|
||||
@@ -96,22 +97,55 @@ module FeatherCaseWizard
|
||||
|
||||
def choose_features(config)
|
||||
p = config.parameters
|
||||
p["usb_opening_enabled"] = agreement(
|
||||
"Include front USB-C programming opening?", p["usb_opening_enabled"]
|
||||
)
|
||||
p["usb_guide_enabled"] = if p["usb_opening_enabled"]
|
||||
agreement("Include inward USB plug guide?",
|
||||
p["usb_guide_enabled"])
|
||||
else
|
||||
false
|
||||
end
|
||||
p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?",
|
||||
p["battery_enabled"])
|
||||
p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?",
|
||||
p["tripod_insert_enabled"])
|
||||
return unless p["top_profile"] == "oled128x64"
|
||||
|
||||
ui.say("\nOLED captive buttons")
|
||||
ui.say("OLED captive buttons")
|
||||
p["button_a"] = agreement("Enable button A?", p["button_a"])
|
||||
p["button_b"] = agreement("Enable button B?", p["button_b"])
|
||||
p["button_c"] = agreement("Enable button C?", p["button_c"])
|
||||
p["button_reset"] = agreement("Enable reset button?", p["button_reset"])
|
||||
end
|
||||
|
||||
def choose_end_clearance(config)
|
||||
p = config.parameters
|
||||
defaults = Config::DEFAULT_PARAMETERS
|
||||
custom = p["usb_board_wall_gap"] != defaults["usb_board_wall_gap"] ||
|
||||
p["board_rear_clearance"] != defaults["board_rear_clearance"]
|
||||
enabled = ui.agree("Use extra clearance beyond the Feather PCB ends? (y/n) ") do |question|
|
||||
question.default = custom ? "yes" : "no"
|
||||
end
|
||||
unless enabled
|
||||
p["usb_board_wall_gap"] = defaults["usb_board_wall_gap"]
|
||||
p["board_rear_clearance"] = defaults["board_rear_clearance"]
|
||||
return
|
||||
end
|
||||
|
||||
p["usb_board_wall_gap"] = number(
|
||||
"USB/front PCB-edge to inner-wall clearance (mm)",
|
||||
p["usb_board_wall_gap"], min: 0.1
|
||||
)
|
||||
p["board_rear_clearance"] = number(
|
||||
"Rear PCB-edge to inner-wall clearance (mm)",
|
||||
p["board_rear_clearance"], min: 0.1
|
||||
)
|
||||
end
|
||||
|
||||
def manage_cutouts(config)
|
||||
loop do
|
||||
ui.say("\nCustom cable cutouts: #{config.cutouts.empty? ? "none" :
|
||||
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" :
|
||||
config.cutouts.map { |cutout| cutout["name"] }.join(", ")}")
|
||||
action = ui.choose("Cable cutouts") do |menu|
|
||||
menu.choice("Done") { :done }
|
||||
@@ -141,12 +175,15 @@ module FeatherCaseWizard
|
||||
dims = config.dimensions
|
||||
name = text("Cutout name", existing["name"] || "cable")
|
||||
face = ui.choose("Wall") do |menu|
|
||||
Config::FACES.each { |item| menu.choice(item) { item } }
|
||||
menu.default = Config::FACES.index(existing["face"]).to_i + 1 if existing["face"]
|
||||
menu.choice("Front — USB-C short wall") { "front" }
|
||||
menu.choice("Rear — opposite short wall") { "rear" }
|
||||
menu.choice("Left — plain/non-JST long wall") { "left" }
|
||||
menu.choice("Right — JST/battery long wall") { "right" }
|
||||
menu.default = (Config::FACES.index(existing["face"]).to_i + 1).to_s if existing["face"]
|
||||
end
|
||||
shape = ui.choose("Shape") do |menu|
|
||||
Config::SHAPES.each { |item| menu.choice(item) { item } }
|
||||
menu.default = Config::SHAPES.index(existing["shape"]).to_i + 1 if existing["shape"]
|
||||
menu.default = (Config::SHAPES.index(existing["shape"]).to_i + 1).to_s if existing["shape"]
|
||||
end
|
||||
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
|
||||
ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \
|
||||
@@ -209,7 +246,7 @@ module FeatherCaseWizard
|
||||
Config::ADVANCED_FIELDS.each do |section, fields|
|
||||
next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" }
|
||||
|
||||
ui.say("\n#{section}")
|
||||
ui.say(section)
|
||||
fields.each do |field|
|
||||
config.parameters[field] = number(field.tr("_", " "), config.parameters[field],
|
||||
min: field == "usb_vertical_offset" ? nil : 0.01)
|
||||
@@ -286,4 +323,3 @@ module FeatherCaseWizard
|
||||
def yes_no(value) = value ? "yes" : "no"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "stringio"
|
||||
require_relative "test_helper"
|
||||
|
||||
class CLITest < Minitest::Test
|
||||
def test_main_prompt_is_not_an_extra_choice
|
||||
output = StringIO.new
|
||||
cli = FeatherCaseWizard::CLI.new(ui: HighLine.new(StringIO.new("4\n"), output))
|
||||
|
||||
assert_equal 0, cli.run
|
||||
assert_includes output.string, "What would you like to do:\n"
|
||||
assert_includes output.string, "4. Exit\n"
|
||||
refute_includes output.string, "5. What would you like to do?"
|
||||
end
|
||||
end
|
||||
+94
-1
@@ -9,11 +9,77 @@ class ConfigTest < Minitest::Test
|
||||
|
||||
def test_default_case_has_expected_parts_and_dimensions
|
||||
assert_equal %w[body lid], config.printable_parts
|
||||
assert_in_delta 60.8, config.dimensions["length"], 0.01
|
||||
assert_in_delta 61.6, config.dimensions["length"], 0.01
|
||||
assert_in_delta 41.66, config.dimensions["width"], 0.01
|
||||
assert_in_delta 12.25, config.parameters["wing_pitch"], 0.001
|
||||
assert_in_delta 6.0, config.parameters["gps_antenna_height"], 0.001
|
||||
assert_in_delta 2.4, config.parameters["oled_component_height"], 0.001
|
||||
assert_in_delta 1.5, config.parameters["oled_display_height"], 0.001
|
||||
assert_in_delta 2.0, config.parameters["usb_board_wall_gap"], 0.001
|
||||
assert config.parameters["usb_opening_enabled"]
|
||||
assert config.parameters["usb_guide_enabled"]
|
||||
assert_equal ["3mf"], config.outputs["formats"]
|
||||
end
|
||||
|
||||
def test_gps_height_uses_stack_pitch_antenna_height_and_top_gap
|
||||
subject = config
|
||||
subject.parameters["top_profile"] = "gps"
|
||||
parameters = subject.parameters
|
||||
|
||||
expected_electronics_height =
|
||||
parameters["wing_pitch"] + parameters["gps_antenna_height"] +
|
||||
parameters["gps_top_gap"]
|
||||
|
||||
assert_in_delta 23.25, expected_electronics_height, 0.001
|
||||
assert_in_delta 35.85, subject.dimensions["height"] + parameters["lid_thickness"], 0.001
|
||||
end
|
||||
|
||||
def test_oled_height_uses_tallest_component_not_display_reference
|
||||
subject = config
|
||||
subject.parameters["top_profile"] = "oled128x64"
|
||||
original_height = subject.dimensions["height"]
|
||||
|
||||
subject.parameters["oled_display_height"] = 10.0
|
||||
|
||||
assert_in_delta original_height, subject.dimensions["height"], 0.001
|
||||
end
|
||||
|
||||
def test_older_config_receives_default_oled_display_height
|
||||
data = FeatherCaseWizard::Config.deep_copy(config.data)
|
||||
data["parameters"].delete("oled_display_height")
|
||||
|
||||
loaded = FeatherCaseWizard::Config.new(data)
|
||||
|
||||
assert_in_delta 1.5, loaded.parameters["oled_display_height"], 0.001
|
||||
end
|
||||
|
||||
def test_older_config_preserves_opening_without_enabling_guide
|
||||
data = FeatherCaseWizard::Config.deep_copy(config.data)
|
||||
data["parameters"].delete("usb_opening_enabled")
|
||||
data["parameters"].delete("usb_guide_enabled")
|
||||
|
||||
loaded = FeatherCaseWizard::Config.new(data)
|
||||
|
||||
assert loaded.parameters["usb_opening_enabled"]
|
||||
refute loaded.parameters["usb_guide_enabled"]
|
||||
end
|
||||
|
||||
def test_usb_guide_requires_opening
|
||||
subject = config
|
||||
subject.parameters["usb_opening_enabled"] = false
|
||||
|
||||
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
|
||||
assert_match(/guide requires/, error.message)
|
||||
end
|
||||
|
||||
def test_end_clearances_expand_case_length
|
||||
subject = config
|
||||
subject.parameters["usb_board_wall_gap"] = 20.0
|
||||
subject.parameters["board_rear_clearance"] = 20.0
|
||||
|
||||
assert_in_delta 95.6, subject.dimensions["length"], 0.01
|
||||
end
|
||||
|
||||
def test_oled_buttons_and_selected_coupons_are_printable
|
||||
subject = config
|
||||
subject.parameters["top_profile"] = "oled128x64"
|
||||
@@ -50,6 +116,33 @@ class ConfigTest < Minitest::Test
|
||||
assert_match(/floor/, error.message)
|
||||
end
|
||||
|
||||
def test_cutouts_on_same_wall_require_minimum_web
|
||||
subject = config
|
||||
first = cutout("rear", 18.0).merge("name" => "first")
|
||||
second = cutout("rear", 24.0).merge("name" => "second")
|
||||
subject.data["cutouts"] = [first, second]
|
||||
|
||||
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
|
||||
assert_match(/too close/, error.message)
|
||||
end
|
||||
|
||||
def test_front_cutout_cannot_overlap_usb_opening
|
||||
subject = config
|
||||
subject.data["cutouts"] = [
|
||||
cutout("front", subject.dimensions["width"] / 2).merge("z" => 11.0)
|
||||
]
|
||||
|
||||
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
|
||||
assert_match(/USB opening or guide/, error.message)
|
||||
end
|
||||
|
||||
def test_front_cutout_can_clear_usb_reservation
|
||||
subject = config
|
||||
subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)]
|
||||
|
||||
assert_same subject, subject.validate!
|
||||
end
|
||||
|
||||
def test_yaml_round_trip_preserves_resolved_cutouts
|
||||
subject = config
|
||||
subject.data["cutouts"] = [cutout("left", 20.0)]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "stringio"
|
||||
require_relative "test_helper"
|
||||
|
||||
class PromptingUITest < Minitest::Test
|
||||
def test_menu_prompt_is_a_header_and_answers_are_separated
|
||||
output = StringIO.new
|
||||
ui = FeatherCaseWizard::PromptingUI.wrap(
|
||||
HighLine.new(StringIO.new("1\nDemo\n"), output)
|
||||
)
|
||||
|
||||
selected = ui.choose("Export format (current: 3mf)") do |menu|
|
||||
menu.choice("3MF") { "3mf" }
|
||||
menu.choice("STL") { "stl" }
|
||||
end
|
||||
name = ui.ask("Case name: ")
|
||||
|
||||
assert_equal "3mf", selected
|
||||
assert_equal "Demo", name
|
||||
assert_includes output.string, "Export format (current: 3mf):\n1. 3MF\n2. STL\n? \n"
|
||||
refute_includes output.string, "3. Export format"
|
||||
assert_match(/\? \nCase name: /, output.string)
|
||||
end
|
||||
|
||||
def test_wrap_is_idempotent
|
||||
ui = FeatherCaseWizard::PromptingUI.wrap(HighLine.new)
|
||||
|
||||
assert_same ui, FeatherCaseWizard::PromptingUI.wrap(ui)
|
||||
end
|
||||
end
|
||||
+53
-14
@@ -6,20 +6,8 @@ require_relative "test_helper"
|
||||
class WizardTest < Minitest::Test
|
||||
def test_minimal_new_case_flow_and_confirmation
|
||||
input = StringIO.new(
|
||||
"Demo Case\n" \
|
||||
"\n" \
|
||||
"1\n" \
|
||||
"1\n" \
|
||||
"\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"1\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"n\n" \
|
||||
"y\n"
|
||||
["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "1",
|
||||
"n", "n", "n", "n", "y"].join("\n") + "\n"
|
||||
)
|
||||
output = StringIO.new
|
||||
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output))
|
||||
@@ -31,4 +19,55 @@ class WizardTest < Minitest::Test
|
||||
assert_equal ["3mf"], config.outputs["formats"]
|
||||
assert_equal %w[body lid], config.printable_parts
|
||||
end
|
||||
|
||||
def test_edit_accepts_current_menu_choices_as_defaults
|
||||
config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps")
|
||||
config.parameters["top_profile"] = "gps"
|
||||
input = StringIO.new(
|
||||
["", "", "", "", "n", "n", "", "", "n", "n", "1", "n", "n", "n",
|
||||
"n"].join("\n") + "\n"
|
||||
)
|
||||
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, StringIO.new))
|
||||
|
||||
edited = wizard.edit(config)
|
||||
|
||||
assert_equal ["3mf"], edited.outputs["formats"]
|
||||
assert_equal "gps", edited.parameters["top_profile"]
|
||||
end
|
||||
|
||||
def test_edit_cutout_accepts_current_wall_and_shape_as_defaults
|
||||
config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps")
|
||||
cutout = {
|
||||
"name" => "cable",
|
||||
"face" => "left",
|
||||
"shape" => "roundrect",
|
||||
"horizontal" => 30.0,
|
||||
"z" => 12.0,
|
||||
"width" => 8.0,
|
||||
"height" => 6.0,
|
||||
"radius" => 1.5
|
||||
}
|
||||
output = StringIO.new
|
||||
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(StringIO.new("\n" * 8), output))
|
||||
|
||||
edited = wizard.send(:ask_cutout, config, cutout)
|
||||
|
||||
assert_equal "left", edited["face"]
|
||||
assert_equal "roundrect", edited["shape"]
|
||||
assert_includes output.string, "Wall:\n1. Front — USB-C short wall\n"
|
||||
assert_includes output.string, "3. Left — plain/non-JST long wall\n"
|
||||
refute_includes output.string, "5. Wall"
|
||||
end
|
||||
|
||||
def test_end_clearance_step_sets_both_board_end_distances
|
||||
config = FeatherCaseWizard::Config.create(name: "Servo", slug: "servo")
|
||||
wizard = FeatherCaseWizard::Wizard.new(
|
||||
ui: HighLine.new(StringIO.new("y\n20\n20\n"), StringIO.new)
|
||||
)
|
||||
|
||||
wizard.send(:choose_end_clearance, config)
|
||||
|
||||
assert_in_delta 20.0, config.parameters["usb_board_wall_gap"], 0.001
|
||||
assert_in_delta 20.0, config.parameters["board_rear_clearance"], 0.001
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user