case updates

This commit is contained in:
Nick Estes
2026-08-06 18:42:21 -07:00
parent 69d9b956e6
commit 06cb7f0c4d
35 changed files with 962 additions and 4197 deletions
-25
View File
@@ -1,25 +0,0 @@
# Attitude IMU - Battery
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 35.8 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-imu-battery source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
-82
View File
@@ -1,82 +0,0 @@
---
schema_version: 1
name: Attitude IMU - Battery
slug: attitude-imu-battery
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
created_with: feather-case-wizard
parameters:
top_profile: generic
wing_count: 1
wing_pitch: 12.25
stack_height_override: 0.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 6.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: true
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
insert_melt_relief: 0.8
wall_thickness: 2.4
floor_thickness: 2.4
lid_thickness: 3.2
corner_radius: 4.0
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts: []
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts: []
@@ -1,69 +0,0 @@
{
"parameterSets": {
"attitude-imu-battery": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "12.25",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "6.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "true",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
"insert_melt_relief": "0.8",
"wall_thickness": "2.4",
"floor_thickness": "2.4",
"lid_thickness": "3.2",
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"fileFormatVersion": "1"
}
@@ -1,872 +0,0 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 12.25; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 6;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
function clamp(v, lo, hi) = min(max(v, lo), hi);
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_display_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
@@ -1,127 +0,0 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
-25
View File
@@ -1,25 +0,0 @@
# Attitude IMU
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: no
- 1/4-20 insert: yes
- Approximate exterior: 60.8 × 41.7 × 35.8 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-imu source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
-82
View File
@@ -1,82 +0,0 @@
---
schema_version: 1
name: Attitude IMU
slug: attitude-imu
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
created_with: feather-case-wizard
parameters:
top_profile: generic
wing_count: 1
wing_pitch: 12.25
stack_height_override: 0.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 6.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: false
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
insert_melt_relief: 0.8
wall_thickness: 2.4
floor_thickness: 2.4
lid_thickness: 3.2
corner_radius: 4.0
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts: []
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts: []
-69
View File
@@ -1,69 +0,0 @@
{
"parameterSets": {
"attitude-imu": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "12.25",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "6.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "false",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
"insert_melt_relief": "0.8",
"wall_thickness": "2.4",
"floor_thickness": "2.4",
"lid_thickness": "3.2",
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"fileFormatVersion": "1"
}
-872
View File
@@ -1,872 +0,0 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 12.25; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 6;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
function clamp(v, lo, hi) = min(max(v, lo), hi);
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_display_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
@@ -1,127 +0,0 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -6,7 +6,7 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1 - FeatherWings: 1
- Battery compartment: no - Battery compartment: no
- 1/4-20 insert: yes - 1/4-20 insert: yes
- Approximate exterior: 60.8 × 41.7 × 33.2 mm - Approximate exterior: 61.6 × 41.7 × 34.4 mm
- Formats: 3mf - Formats: 3mf
- Parts: `body`, `lid`, `lid_base`, `lid_accent` - Parts: `body`, `lid`, `lid_base`, `lid_accent`
- Custom cutouts: 0 - Custom cutouts: 0
+19 -7
View File
@@ -3,18 +3,19 @@ schema_version: 1
name: Attitude no battery name: Attitude no battery
slug: attitude-no-battery slug: attitude-no-battery
generator: generator:
sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: generic top_profile: generic
wing_count: 1 wing_count: 1
wing_pitch: 11.0 wing_pitch: 12.25
stack_height_override: 0.0 stack_height_override: 0.0
generic_component_height: 3.0 generic_component_height: 3.0
generic_top_gap: 2.0 generic_top_gap: 2.0
gps_antenna_height: 4.0 gps_antenna_height: 6.0
gps_top_gap: 5.0 gps_top_gap: 5.0
oled_component_height: 2.4 oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8 oled_face_gap: 0.8
button_a: true button_a: true
button_b: true button_b: true
@@ -36,6 +37,7 @@ parameters:
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
battery_partition_thickness: 1.8 battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size: rocker_cutout_size:
- 12.9 - 12.9
- 19.4 - 19.4
@@ -45,6 +47,10 @@ parameters:
- 21.0 - 21.0
- 21.0 - 21.0
rocker_artwork_gap: 2.0 rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true tripod_insert_enabled: true
tripod_insert_length: 5.0 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2 tripod_insert_hole_diameter: 7.2
@@ -65,13 +71,19 @@ parameters:
board_bottom_clearance: 5.4 board_bottom_clearance: 5.4
board_side_clearance: 7.0 board_side_clearance: 7.0
board_rear_clearance: 4.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_depth: 2.4
lid_lip_thickness: 1.2 lid_lip_thickness: 1.2
fit_clearance: 0.35 fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2 hole_compensation: 0.2
lid_screw_head_diameter: 5.2 lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5 lid_screw_head_height: 2.5
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: IMU lid_label: IMU
lid_logo_enabled: true lid_logo_enabled: true
lid_art_orientation: usb_front lid_art_orientation: usb_front
@@ -80,10 +92,10 @@ parameters:
lid_label_size: 5.0 lid_label_size: 5.0
lid_label_logo_gap: 2.0 lid_label_logo_gap: 2.0
lid_art_margin: 8.5 lid_art_margin: 8.5
usb_opening_width: 12.5 usb_opening_width: 13.5
usb_opening_height: 7.0 usb_opening_height: 8.0
usb_opening_radius: 1.5 usb_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
+18 -6
View File
@@ -3,13 +3,14 @@
"attitude-no-battery": { "attitude-no-battery": {
"top_profile": "\"generic\"", "top_profile": "\"generic\"",
"wing_count": "1", "wing_count": "1",
"wing_pitch": "11.0", "wing_pitch": "12.25",
"stack_height_override": "0.0", "stack_height_override": "0.0",
"generic_component_height": "3.0", "generic_component_height": "3.0",
"generic_top_gap": "2.0", "generic_top_gap": "2.0",
"gps_antenna_height": "4.0", "gps_antenna_height": "6.0",
"gps_top_gap": "5.0", "gps_top_gap": "5.0",
"oled_component_height": "2.4", "oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8", "oled_face_gap": "0.8",
"button_a": "true", "button_a": "true",
"button_b": "true", "button_b": "true",
@@ -27,10 +28,15 @@
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"battery_partition_thickness": "1.8", "battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]", "rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8", "rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]", "rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0", "rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "5.0", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2", "tripod_insert_hole_diameter": "7.2",
@@ -51,13 +57,19 @@
"board_bottom_clearance": "5.4", "board_bottom_clearance": "5.4",
"board_side_clearance": "7.0", "board_side_clearance": "7.0",
"board_rear_clearance": "4.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_depth": "2.4",
"lid_lip_thickness": "1.2", "lid_lip_thickness": "1.2",
"fit_clearance": "0.35", "fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2", "hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2", "lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5", "lid_screw_head_height": "2.5",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"lid_label": "\"IMU\"", "lid_label": "\"IMU\"",
"lid_logo_enabled": "true", "lid_logo_enabled": "true",
"lid_art_orientation": "\"usb_front\"", "lid_art_orientation": "\"usb_front\"",
@@ -66,10 +78,10 @@
"lid_label_size": "5.0", "lid_label_size": "5.0",
"lid_label_logo_gap": "2.0", "lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5", "lid_art_margin": "8.5",
"usb_opening_width": "12.5", "usb_opening_width": "13.5",
"usb_opening_height": "7.0", "usb_opening_height": "8.0",
"usb_opening_radius": "1.5", "usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "fileFormatVersion": "1"
@@ -19,13 +19,14 @@ show_references = true;
/* [Stack] */ /* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64] top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8] 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] stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30] generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10] generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 4; gps_antenna_height = 6;
gps_top_gap = 5; gps_top_gap = 5;
oled_component_height = 2.4; oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8; oled_face_gap = 0.8;
/* [OLED controls] */ /* [OLED controls] */
@@ -47,10 +48,15 @@ battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
battery_partition_thickness = 1.8; battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4]; rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8; rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21]; rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2; rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
@@ -77,10 +83,11 @@ corner_radius = 4;
board_bottom_clearance = 5.4; board_bottom_clearance = 5.4;
board_side_clearance = 7; board_side_clearance = 7;
board_rear_clearance = 4; board_rear_clearance = 4;
usb_board_wall_gap = 1.2; usb_board_wall_gap = 2;
lid_lip_depth = 2.4; lid_lip_depth = 2.4;
lid_lip_thickness = 1.2; lid_lip_thickness = 1.2;
fit_clearance = 0.35; fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2; hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
@@ -97,10 +104,15 @@ lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg"; logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */ /* [USB-C opening] */
usb_opening_width = 12.5; usb_opening_enabled = true;
usb_opening_height = 7; usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -156,6 +168,20 @@ board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter; + combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance; board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; 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; computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0 top_pcb_z = stack_height_override > 0
@@ -170,7 +196,7 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r battery_start_x = front_lid_boss_center + lid_boss_r
@@ -191,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery) case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : case_width_plain;
rocker_center_x = case_length - wall_thickness - fit_clearance rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2; + rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled; lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation = lid_art_rotation =
@@ -202,7 +232,7 @@ lid_art_rotation =
lid_art_orientation == "usb_front" ? 90 : lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270; lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled lid_art_safe_max_x = battery_enabled
? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap ? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin; : case_length - lid_art_margin;
lid_art_physical_size = [ lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin, lid_art_safe_max_x - lid_art_margin,
@@ -273,6 +303,34 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
@@ -280,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps"
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2, assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM"); "battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left" assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front" || lid_art_orientation == "usb_front"
@@ -302,6 +368,15 @@ assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter, || tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot"); "Tripod insert entry must not be smaller than its pilot");
@@ -311,18 +386,73 @@ assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1, || tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition"); "Tripod insert entry is too close to the battery partition");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_x - rocker_body_size[0] / 2 || rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance, >= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB"); "Rocker body overlaps the Feather PCB");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_y + rocker_body_size[1] / 2 || rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance, <= partition_y - fit_clearance,
"Rocker body overlaps the battery partition"); "Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor, || body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor"); "Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi); 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) { module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -406,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); 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) { module custom_wall_cutout(entry) {
face = entry[0]; face = entry[0];
shape = entry[1]; shape = entry[1];
@@ -554,6 +718,8 @@ module body_positive() {
if (tripod_insert_enabled) if (tripod_insert_enabled)
translate([tripod_center_x, tripod_center_y, 0]) translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter); cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
} }
} }
@@ -579,15 +745,15 @@ module body_cutouts() {
); );
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB. // USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset; if (usb_opening_enabled)
face_rounded_cutout( face_rounded_cutout(
"front", "front",
board_origin_y + usb_center_y, usb_center_u,
usb_center_z, usb_center_z,
usb_opening_width + hole_compensation, usb_inner_width,
usb_opening_height + hole_compensation, usb_inner_height,
usb_opening_radius usb_opening_radius
); );
if (tripod_insert_enabled) { if (tripod_insert_enabled) {
translate([tripod_center_x, tripod_center_y, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
@@ -711,13 +877,18 @@ module lid_cutouts() {
); );
} }
if (battery_enabled) if (battery_enabled) {
translate([rocker_center_x, rocker_center_y, -eps]) translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size, rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps, lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
@@ -728,6 +899,41 @@ module lid_cutouts() {
} }
} }
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() { module lid_artwork_nominal_2d() {
translate(lid_art_center) translate(lid_art_center)
rotate(lid_art_rotation) rotate(lid_art_rotation)
@@ -854,7 +1060,7 @@ module reference_top_feature() {
cube([ cube([
oled_visible_size[0], oled_visible_size[0],
oled_visible_size[1], oled_visible_size[1],
oled_component_height oled_display_height
]); ]);
color([0.6, 0.6, 0.6, 0.9]) color([0.6, 0.6, 0.6, 0.9])
for (xy = [ for (xy = [
@@ -891,15 +1097,15 @@ module reference_battery_and_switch() {
cylinder(h = battery_length, d = battery_diameter); cylinder(h = battery_length, d = battery_diameter);
color([0.08, 0.08, 0.08, 0.85]) color([0.08, 0.08, 0.08, 0.85])
translate([ translate([
rocker_center_x - rocker_body_size[0] / 2, rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_center_y - rocker_body_size[1] / 2, rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2] body_height + exploded_view - rocker_body_size[2]
]) ])
cube(rocker_body_size); cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95]) color([0.12, 0.12, 0.12, 0.95])
translate([ translate([
rocker_center_x - rocker_cutout_size[0] / 2, rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_center_y - rocker_cutout_size[1] / 2, rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness body_height + exploded_view + lid_thickness
]) ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
@@ -957,7 +1163,7 @@ module switch_coupon() {
coupon_height = lid_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size rocker_cutout_size
@@ -965,6 +1171,11 @@ module switch_coupon() {
coupon_height + 2 * eps, coupon_height + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
} }
} }
@@ -1002,6 +1213,8 @@ module fit_coupon() {
} }
} }
validate_cutout_spacing();
if (part == "assembly") if (part == "assembly")
assembly(); assembly();
else if (part == "body") else if (part == "body")
+1 -1
View File
@@ -6,7 +6,7 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1 - FeatherWings: 1
- Battery compartment: yes - Battery compartment: yes
- 1/4-20 insert: yes - 1/4-20 insert: yes
- Approximate exterior: 88.6 × 59.1 × 33.2 mm - Approximate exterior: 88.6 × 61.1 × 34.4 mm
- Formats: 3mf - Formats: 3mf
- Parts: `body`, `lid` - Parts: `body`, `lid`
- Custom cutouts: 0 - Custom cutouts: 0
+19 -7
View File
@@ -3,18 +3,19 @@ schema_version: 1
name: Attitude w/battery name: Attitude w/battery
slug: attitude-w-battery slug: attitude-w-battery
generator: generator:
sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: generic top_profile: generic
wing_count: 1 wing_count: 1
wing_pitch: 11.0 wing_pitch: 12.25
stack_height_override: 0.0 stack_height_override: 0.0
generic_component_height: 3.0 generic_component_height: 3.0
generic_top_gap: 2.0 generic_top_gap: 2.0
gps_antenna_height: 4.0 gps_antenna_height: 6.0
gps_top_gap: 5.0 gps_top_gap: 5.0
oled_component_height: 2.4 oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8 oled_face_gap: 0.8
button_a: true button_a: true
button_b: true button_b: true
@@ -36,6 +37,7 @@ parameters:
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
battery_partition_thickness: 1.8 battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size: rocker_cutout_size:
- 12.9 - 12.9
- 19.4 - 19.4
@@ -45,6 +47,10 @@ parameters:
- 21.0 - 21.0
- 21.0 - 21.0
rocker_artwork_gap: 2.0 rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true tripod_insert_enabled: true
tripod_insert_length: 5.0 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2 tripod_insert_hole_diameter: 7.2
@@ -65,13 +71,19 @@ parameters:
board_bottom_clearance: 5.4 board_bottom_clearance: 5.4
board_side_clearance: 7.0 board_side_clearance: 7.0
board_rear_clearance: 4.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_depth: 2.4
lid_lip_thickness: 1.2 lid_lip_thickness: 1.2
fit_clearance: 0.35 fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2 hole_compensation: 0.2
lid_screw_head_diameter: 5.2 lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5 lid_screw_head_height: 2.5
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: '' lid_label: ''
lid_logo_enabled: false lid_logo_enabled: false
lid_art_orientation: usb_left lid_art_orientation: usb_left
@@ -80,10 +92,10 @@ parameters:
lid_label_size: 5.0 lid_label_size: 5.0
lid_label_logo_gap: 2.0 lid_label_logo_gap: 2.0
lid_art_margin: 8.5 lid_art_margin: 8.5
usb_opening_width: 12.5 usb_opening_width: 13.5
usb_opening_height: 7.0 usb_opening_height: 8.0
usb_opening_radius: 1.5 usb_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
+18 -6
View File
@@ -3,13 +3,14 @@
"attitude-w-battery": { "attitude-w-battery": {
"top_profile": "\"generic\"", "top_profile": "\"generic\"",
"wing_count": "1", "wing_count": "1",
"wing_pitch": "11.0", "wing_pitch": "12.25",
"stack_height_override": "0.0", "stack_height_override": "0.0",
"generic_component_height": "3.0", "generic_component_height": "3.0",
"generic_top_gap": "2.0", "generic_top_gap": "2.0",
"gps_antenna_height": "4.0", "gps_antenna_height": "6.0",
"gps_top_gap": "5.0", "gps_top_gap": "5.0",
"oled_component_height": "2.4", "oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8", "oled_face_gap": "0.8",
"button_a": "true", "button_a": "true",
"button_b": "true", "button_b": "true",
@@ -27,10 +28,15 @@
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"battery_partition_thickness": "1.8", "battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]", "rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8", "rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]", "rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0", "rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "5.0", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2", "tripod_insert_hole_diameter": "7.2",
@@ -51,13 +57,19 @@
"board_bottom_clearance": "5.4", "board_bottom_clearance": "5.4",
"board_side_clearance": "7.0", "board_side_clearance": "7.0",
"board_rear_clearance": "4.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_depth": "2.4",
"lid_lip_thickness": "1.2", "lid_lip_thickness": "1.2",
"fit_clearance": "0.35", "fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2", "hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2", "lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5", "lid_screw_head_height": "2.5",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"lid_label": "\"\"", "lid_label": "\"\"",
"lid_logo_enabled": "false", "lid_logo_enabled": "false",
"lid_art_orientation": "\"usb_left\"", "lid_art_orientation": "\"usb_left\"",
@@ -66,10 +78,10 @@
"lid_label_size": "5.0", "lid_label_size": "5.0",
"lid_label_logo_gap": "2.0", "lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5", "lid_art_margin": "8.5",
"usb_opening_width": "12.5", "usb_opening_width": "13.5",
"usb_opening_height": "7.0", "usb_opening_height": "8.0",
"usb_opening_radius": "1.5", "usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "fileFormatVersion": "1"
+243 -30
View File
@@ -19,13 +19,14 @@ show_references = true;
/* [Stack] */ /* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64] top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8] 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] stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30] generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10] generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 4; gps_antenna_height = 6;
gps_top_gap = 5; gps_top_gap = 5;
oled_component_height = 2.4; oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8; oled_face_gap = 0.8;
/* [OLED controls] */ /* [OLED controls] */
@@ -47,10 +48,15 @@ battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
battery_partition_thickness = 1.8; battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4]; rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8; rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21]; rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2; rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
@@ -77,10 +83,11 @@ corner_radius = 4;
board_bottom_clearance = 5.4; board_bottom_clearance = 5.4;
board_side_clearance = 7; board_side_clearance = 7;
board_rear_clearance = 4; board_rear_clearance = 4;
usb_board_wall_gap = 1.2; usb_board_wall_gap = 2;
lid_lip_depth = 2.4; lid_lip_depth = 2.4;
lid_lip_thickness = 1.2; lid_lip_thickness = 1.2;
fit_clearance = 0.35; fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2; hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
@@ -97,10 +104,15 @@ lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg"; logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */ /* [USB-C opening] */
usb_opening_width = 12.5; usb_opening_enabled = true;
usb_opening_height = 7; usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -156,6 +168,20 @@ board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter; + combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance; board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; 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; computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0 top_pcb_z = stack_height_override > 0
@@ -170,7 +196,7 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r battery_start_x = front_lid_boss_center + lid_boss_r
@@ -191,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery) case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : case_width_plain;
rocker_center_x = case_length - wall_thickness - fit_clearance rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2; + rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled; lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation = lid_art_rotation =
@@ -202,7 +232,7 @@ lid_art_rotation =
lid_art_orientation == "usb_front" ? 90 : lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270; lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled lid_art_safe_max_x = battery_enabled
? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap ? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin; : case_length - lid_art_margin;
lid_art_physical_size = [ lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin, lid_art_safe_max_x - lid_art_margin,
@@ -273,6 +303,34 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
@@ -280,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps"
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2, assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM"); "battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left" assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front" || lid_art_orientation == "usb_front"
@@ -302,6 +368,15 @@ assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter, || tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot"); "Tripod insert entry must not be smaller than its pilot");
@@ -311,18 +386,73 @@ assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1, || tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition"); "Tripod insert entry is too close to the battery partition");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_x - rocker_body_size[0] / 2 || rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance, >= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB"); "Rocker body overlaps the Feather PCB");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_y + rocker_body_size[1] / 2 || rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance, <= partition_y - fit_clearance,
"Rocker body overlaps the battery partition"); "Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor, || body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor"); "Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi); 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) { module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -406,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); 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) { module custom_wall_cutout(entry) {
face = entry[0]; face = entry[0];
shape = entry[1]; shape = entry[1];
@@ -554,6 +718,8 @@ module body_positive() {
if (tripod_insert_enabled) if (tripod_insert_enabled)
translate([tripod_center_x, tripod_center_y, 0]) translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter); cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
} }
} }
@@ -579,15 +745,15 @@ module body_cutouts() {
); );
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB. // USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset; if (usb_opening_enabled)
face_rounded_cutout( face_rounded_cutout(
"front", "front",
board_origin_y + usb_center_y, usb_center_u,
usb_center_z, usb_center_z,
usb_opening_width + hole_compensation, usb_inner_width,
usb_opening_height + hole_compensation, usb_inner_height,
usb_opening_radius usb_opening_radius
); );
if (tripod_insert_enabled) { if (tripod_insert_enabled) {
translate([tripod_center_x, tripod_center_y, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
@@ -711,13 +877,18 @@ module lid_cutouts() {
); );
} }
if (battery_enabled) if (battery_enabled) {
translate([rocker_center_x, rocker_center_y, -eps]) translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size, rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps, lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
@@ -728,6 +899,41 @@ module lid_cutouts() {
} }
} }
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() { module lid_artwork_nominal_2d() {
translate(lid_art_center) translate(lid_art_center)
rotate(lid_art_rotation) rotate(lid_art_rotation)
@@ -854,7 +1060,7 @@ module reference_top_feature() {
cube([ cube([
oled_visible_size[0], oled_visible_size[0],
oled_visible_size[1], oled_visible_size[1],
oled_component_height oled_display_height
]); ]);
color([0.6, 0.6, 0.6, 0.9]) color([0.6, 0.6, 0.6, 0.9])
for (xy = [ for (xy = [
@@ -891,15 +1097,15 @@ module reference_battery_and_switch() {
cylinder(h = battery_length, d = battery_diameter); cylinder(h = battery_length, d = battery_diameter);
color([0.08, 0.08, 0.08, 0.85]) color([0.08, 0.08, 0.08, 0.85])
translate([ translate([
rocker_center_x - rocker_body_size[0] / 2, rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_center_y - rocker_body_size[1] / 2, rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2] body_height + exploded_view - rocker_body_size[2]
]) ])
cube(rocker_body_size); cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95]) color([0.12, 0.12, 0.12, 0.95])
translate([ translate([
rocker_center_x - rocker_cutout_size[0] / 2, rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_center_y - rocker_cutout_size[1] / 2, rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness body_height + exploded_view + lid_thickness
]) ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
@@ -957,7 +1163,7 @@ module switch_coupon() {
coupon_height = lid_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size rocker_cutout_size
@@ -965,6 +1171,11 @@ module switch_coupon() {
coupon_height + 2 * eps, coupon_height + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
} }
} }
@@ -1002,6 +1213,8 @@ module fit_coupon() {
} }
} }
validate_cutout_spacing();
if (part == "assembly") if (part == "assembly")
assembly(); assembly();
else if (part == "body") else if (part == "body")
+1 -1
View File
@@ -6,7 +6,7 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1 - FeatherWings: 1
- Battery compartment: yes - Battery compartment: yes
- 1/4-20 insert: yes - 1/4-20 insert: yes
- Approximate exterior: 88.6 × 59.1 × 40.4 mm - Approximate exterior: 88.6 × 61.1 × 40.4 mm
- Formats: 3mf - Formats: 3mf
- Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon` - Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon`
- Custom cutouts: 0 - Custom cutouts: 0
+9 -4
View File
@@ -3,7 +3,7 @@ schema_version: 1
name: GPS name: GPS
slug: gps slug: gps
generator: generator:
sha256: 168dd3c04ca4015b07fe6c0295e7d4bb43969671f5f3552ffab31d8deffc3024 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: gps top_profile: gps
@@ -37,6 +37,7 @@ parameters:
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
battery_partition_thickness: 1.8 battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size: rocker_cutout_size:
- 12.9 - 12.9
- 19.4 - 19.4
@@ -46,6 +47,10 @@ parameters:
- 21.0 - 21.0
- 21.0 - 21.0
rocker_artwork_gap: 2.0 rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true tripod_insert_enabled: true
tripod_insert_length: 5.0 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2 tripod_insert_hole_diameter: 7.2
@@ -87,10 +92,10 @@ parameters:
lid_label_size: 5.0 lid_label_size: 5.0
lid_label_logo_gap: 2.0 lid_label_logo_gap: 2.0
lid_art_margin: 8.5 lid_art_margin: 8.5
usb_opening_width: 12.5 usb_opening_width: 13.5
usb_opening_height: 7.0 usb_opening_height: 8.0
usb_opening_radius: 1.5 usb_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
+8 -3
View File
@@ -28,10 +28,15 @@
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"battery_partition_thickness": "1.8", "battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]", "rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8", "rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]", "rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0", "rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "5.0", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2", "tripod_insert_hole_diameter": "7.2",
@@ -73,10 +78,10 @@
"lid_label_size": "5.0", "lid_label_size": "5.0",
"lid_label_logo_gap": "2.0", "lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5", "lid_art_margin": "8.5",
"usb_opening_width": "12.5", "usb_opening_width": "13.5",
"usb_opening_height": "7.0", "usb_opening_height": "8.0",
"usb_opening_radius": "1.5", "usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "fileFormatVersion": "1"
+136 -17
View File
@@ -48,10 +48,15 @@ battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
battery_partition_thickness = 1.8; battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4]; rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8; rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21]; rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2; rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
@@ -104,10 +109,10 @@ usb_guide_enabled = true;
usb_connector_overhang = 2; usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2; usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1; usb_guide_end_gap = 1;
usb_opening_width = 12.5; usb_opening_width = 13.5;
usb_opening_height = 7; usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -191,7 +196,7 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r battery_start_x = front_lid_boss_center + lid_boss_r
@@ -212,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery) case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : case_width_plain;
rocker_center_x = case_length - wall_thickness - fit_clearance rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2; + rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled; lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation = lid_art_rotation =
@@ -223,7 +232,7 @@ lid_art_rotation =
lid_art_orientation == "usb_front" ? 90 : lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270; lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled lid_art_safe_max_x = battery_enabled
? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap ? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin; : case_length - lid_art_margin;
lid_art_physical_size = [ lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin, lid_art_safe_max_x - lid_art_margin,
@@ -294,6 +303,34 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
@@ -301,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps"
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2, assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM"); "battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left" assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front" || lid_art_orientation == "usb_front"
@@ -325,6 +370,13 @@ assert(!tripod_insert_enabled
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled, assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening"); "USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter, || tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot"); "Tripod insert entry must not be smaller than its pilot");
@@ -334,13 +386,35 @@ assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1, || tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition"); "Tripod insert entry is too close to the battery partition");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_x - rocker_body_size[0] / 2 || rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance, >= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB"); "Rocker body overlaps the Feather PCB");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_y + rocker_body_size[1] / 2 || rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance, <= partition_y - fit_clearance,
"Rocker body overlaps the battery partition"); "Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor, || body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor"); "Rocker body intersects the case floor");
@@ -803,13 +877,18 @@ module lid_cutouts() {
); );
} }
if (battery_enabled) if (battery_enabled) {
translate([rocker_center_x, rocker_center_y, -eps]) translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size, rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps, lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
@@ -820,6 +899,41 @@ module lid_cutouts() {
} }
} }
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() { module lid_artwork_nominal_2d() {
translate(lid_art_center) translate(lid_art_center)
rotate(lid_art_rotation) rotate(lid_art_rotation)
@@ -983,15 +1097,15 @@ module reference_battery_and_switch() {
cylinder(h = battery_length, d = battery_diameter); cylinder(h = battery_length, d = battery_diameter);
color([0.08, 0.08, 0.08, 0.85]) color([0.08, 0.08, 0.08, 0.85])
translate([ translate([
rocker_center_x - rocker_body_size[0] / 2, rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_center_y - rocker_body_size[1] / 2, rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2] body_height + exploded_view - rocker_body_size[2]
]) ])
cube(rocker_body_size); cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95]) color([0.12, 0.12, 0.12, 0.95])
translate([ translate([
rocker_center_x - rocker_cutout_size[0] / 2, rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_center_y - rocker_cutout_size[1] / 2, rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness body_height + exploded_view + lid_thickness
]) ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
@@ -1049,7 +1163,7 @@ module switch_coupon() {
coupon_height = lid_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size rocker_cutout_size
@@ -1057,6 +1171,11 @@ module switch_coupon() {
coupon_height + 2 * eps, coupon_height + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
} }
} }
-25
View File
@@ -1,25 +0,0 @@
# Pan/Tilt Head
Generated by the Feather Case Wizard.
- Top profile: `oled128x64`
- FeatherWings: 3
- Battery compartment: no
- 1/4-20 insert: yes
- Approximate exterior: 95.6 × 41.7 × 59.8 mm
- Formats: 3mf
- Parts: `body`, `lid`, `buttons`
- Custom cutouts: 4
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P pan-tilt-head source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
-148
View File
@@ -1,148 +0,0 @@
---
schema_version: 1
name: Pan/Tilt Head
slug: pan-tilt-head
generator:
sha256: 965260d4d402fd8894f30aa863224e2c9b42dc61a1496f01e2f00b509f9624db
created_with: feather-case-wizard
parameters:
top_profile: oled128x64
wing_count: 3
wing_pitch: 12.25
stack_height_override: 38.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 6.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: false
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
insert_melt_relief: 0.8
wall_thickness: 2.4
floor_thickness: 2.4
lid_thickness: 3.2
corner_radius: 4.0
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 20.0
usb_board_wall_gap: 20.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts:
- name: Antenna
face: left
shape: circle
horizontal: 30.4
z: 31.299999999999997
width: 6.5
height: 6.5
radius: 3.25
- name: Servo
face: rear
shape: roundrect
horizontal: 20.83
z: 35.0
width: 10.0
height: 5.0
radius: 1.25
- name: Release
face: rear
shape: circle
horizontal: 20.83
z: 20.0
width: 6.0
height: 6.0
radius: 3.0
- name: Power
face: front
shape: circle
horizontal: 20.83
z: 31.65
width: 8.0
height: 8.0
radius: 4.0
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts:
- - left
- circle
- 30.4
- 31.299999999999997
- 6.5
- 6.5
- 3.25
- - rear
- roundrect
- 20.83
- 35.0
- 10.0
- 5.0
- 1.25
- - rear
- circle
- 20.83
- 20.0
- 6.0
- 6.0
- 3.0
- - front
- circle
- 20.83
- 31.65
- 8.0
- 8.0
- 4.0
-75
View File
@@ -1,75 +0,0 @@
{
"parameterSets": {
"pan-tilt-head": {
"top_profile": "\"oled128x64\"",
"wing_count": "3",
"wing_pitch": "12.25",
"stack_height_override": "38.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "6.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "false",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
"insert_melt_relief": "0.8",
"wall_thickness": "2.4",
"floor_thickness": "2.4",
"lid_thickness": "3.2",
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "20.0",
"usb_board_wall_gap": "20.0",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"fileFormatVersion": "1"
}
@@ -1,965 +0,0 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 12.25; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 6;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
if (usb_opening_enabled)
face_rounded_cutout(
"front",
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_display_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
validate_cutout_spacing();
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
@@ -1,127 +0,0 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
+136 -17
View File
@@ -48,10 +48,15 @@ battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
battery_partition_thickness = 1.8; battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4]; rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8; rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21]; rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2; rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
@@ -104,10 +109,10 @@ usb_guide_enabled = true;
usb_connector_overhang = 2; usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2; usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1; usb_guide_end_gap = 1;
usb_opening_width = 12.5; usb_opening_width = 13.5;
usb_opening_height = 7; usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -191,7 +196,7 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r battery_start_x = front_lid_boss_center + lid_boss_r
@@ -212,10 +217,14 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery) case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : case_width_plain;
rocker_center_x = case_length - wall_thickness - fit_clearance rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2; - lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2; + rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled; lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation = lid_art_rotation =
@@ -223,7 +232,7 @@ lid_art_rotation =
lid_art_orientation == "usb_front" ? 90 : lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270; lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled lid_art_safe_max_x = battery_enabled
? rocker_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap ? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin; : case_length - lid_art_margin;
lid_art_physical_size = [ lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin, lid_art_safe_max_x - lid_art_margin,
@@ -294,6 +303,34 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
@@ -301,6 +338,14 @@ assert(top_profile == "generic" || top_profile == "gps"
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2, assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM"); "battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left" assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front" || lid_art_orientation == "usb_front"
@@ -325,6 +370,13 @@ assert(!tripod_insert_enabled
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled, assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening"); "USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter, || tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot"); "Tripod insert entry must not be smaller than its pilot");
@@ -334,13 +386,35 @@ assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1, || tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition"); "Tripod insert entry is too close to the battery partition");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_x - rocker_body_size[0] / 2 || rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance, >= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB"); "Rocker body overlaps the Feather PCB");
assert(!battery_enabled assert(!battery_enabled
|| rocker_center_y + rocker_body_size[1] / 2 || rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance, <= partition_y - fit_clearance,
"Rocker body overlaps the battery partition"); "Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor, || body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor"); "Rocker body intersects the case floor");
@@ -803,13 +877,18 @@ module lid_cutouts() {
); );
} }
if (battery_enabled) if (battery_enabled) {
translate([rocker_center_x, rocker_center_y, -eps]) translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size, rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps, lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
@@ -820,6 +899,41 @@ module lid_cutouts() {
} }
} }
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() { module lid_artwork_nominal_2d() {
translate(lid_art_center) translate(lid_art_center)
rotate(lid_art_rotation) rotate(lid_art_rotation)
@@ -983,15 +1097,15 @@ module reference_battery_and_switch() {
cylinder(h = battery_length, d = battery_diameter); cylinder(h = battery_length, d = battery_diameter);
color([0.08, 0.08, 0.08, 0.85]) color([0.08, 0.08, 0.08, 0.85])
translate([ translate([
rocker_center_x - rocker_body_size[0] / 2, rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_center_y - rocker_body_size[1] / 2, rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2] body_height + exploded_view - rocker_body_size[2]
]) ])
cube(rocker_body_size); cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95]) color([0.12, 0.12, 0.12, 0.95])
translate([ translate([
rocker_center_x - rocker_cutout_size[0] / 2, rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_center_y - rocker_cutout_size[1] / 2, rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness body_height + exploded_view + lid_thickness
]) ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]); cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
@@ -1049,7 +1163,7 @@ module switch_coupon() {
coupon_height = lid_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
centered_rounded_prism( centered_rounded_prism(
rocker_cutout_size rocker_cutout_size
@@ -1057,6 +1171,11 @@ module switch_coupon() {
coupon_height + 2 * eps, coupon_height + 2 * eps,
rocker_corner_radius rocker_corner_radius
); );
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
} }
} }
+33 -5
View File
@@ -44,10 +44,15 @@ module FeatherCaseWizard
"battery_radial_clearance" => 0.6, "battery_radial_clearance" => 0.6,
"battery_end_clearance" => 1.0, "battery_end_clearance" => 1.0,
"battery_partition_thickness" => 1.8, "battery_partition_thickness" => 1.8,
"battery_board_clearance" => 3.0,
"rocker_cutout_size" => [12.9, 19.4], "rocker_cutout_size" => [12.9, 19.4],
"rocker_corner_radius" => 0.8, "rocker_corner_radius" => 0.8,
"rocker_body_size" => [15.0, 21.0, 21.0], "rocker_body_size" => [15.0, 21.0, 21.0],
"rocker_artwork_gap" => 2.0, "rocker_artwork_gap" => 2.0,
"rocker_partition_offset" => 2.0,
"rocker_tab_panel_thickness" => 2.0,
"rocker_tab_land" => 2.0,
"rocker_tab_taper_length" => 0.6,
"tripod_insert_enabled" => false, "tripod_insert_enabled" => false,
"tripod_insert_length" => 5.0, "tripod_insert_length" => 5.0,
"tripod_insert_hole_diameter" => 7.2, "tripod_insert_hole_diameter" => 7.2,
@@ -89,10 +94,10 @@ module FeatherCaseWizard
"lid_label_size" => 5.0, "lid_label_size" => 5.0,
"lid_label_logo_gap" => 2.0, "lid_label_logo_gap" => 2.0,
"lid_art_margin" => 8.5, "lid_art_margin" => 8.5,
"usb_opening_width" => 12.5, "usb_opening_width" => 13.5,
"usb_opening_height" => 7.0, "usb_opening_height" => 8.0,
"usb_opening_radius" => 1.5, "usb_opening_radius" => 1.5,
"usb_vertical_offset" => 0.0 "usb_vertical_offset" => 0.5
}.freeze }.freeze
ADVANCED_FIELDS = { ADVANCED_FIELDS = {
@@ -115,8 +120,12 @@ module FeatherCaseWizard
tripod_thread_clearance_diameter tripod_boss_diameter], tripod_thread_clearance_diameter tripod_boss_diameter],
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance "Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
battery_end_clearance battery_partition_thickness battery_end_clearance battery_partition_thickness
battery_board_clearance
rocker_cutout_size rocker_corner_radius rocker_cutout_size rocker_corner_radius
rocker_body_size rocker_artwork_gap], rocker_body_size rocker_artwork_gap
rocker_partition_offset
rocker_tab_panel_thickness rocker_tab_land
rocker_tab_taper_length],
"USB-C opening" => %w[usb_opening_width usb_opening_height "USB-C opening" => %w[usb_opening_width usb_opening_height
usb_opening_radius usb_vertical_offset usb_connector_overhang usb_opening_radius usb_vertical_offset usb_connector_overhang
usb_guide_wall_thickness usb_guide_end_gap], usb_guide_wall_thickness usb_guide_end_gap],
@@ -223,7 +232,7 @@ module FeatherCaseWizard
plain_width = board_origin_y + 22.86 + p["board_side_clearance"] + plain_width = board_origin_y + 22.86 + p["board_side_clearance"] +
p["wall_thickness"] p["wall_thickness"]
if p["battery_enabled"] if p["battery_enabled"]
partition_y = board_origin_y + 22.86 + 1 partition_y = board_origin_y + 22.86 + p["battery_board_clearance"]
battery_y = partition_y + p["battery_partition_thickness"] + battery_y = partition_y + p["battery_partition_thickness"] +
p["battery_radial_clearance"] + p["battery_diameter"] / 2 p["battery_radial_clearance"] + p["battery_diameter"] / 2
lid_boss_wall_overlap = 0.6 lid_boss_wall_overlap = 0.6
@@ -325,6 +334,11 @@ module FeatherCaseWizard
parameters["wall_thickness"] < 1.2 parameters["wall_thickness"] < 1.2
raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if
parameters["battery_partition_thickness"] < 1.2 parameters["battery_partition_thickness"] < 1.2
raise ConfigError, "rocker_tab_panel_thickness must be at least 1.2 mm" if
parameters["rocker_tab_panel_thickness"] < 1.2
if parameters["rocker_tab_panel_thickness"] >= parameters["lid_thickness"]
raise ConfigError, "rocker_tab_panel_thickness must be less than lid_thickness"
end
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
parameters["lid_lip_thickness"] < 1.2 parameters["lid_lip_thickness"] < 1.2
raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if
@@ -338,6 +352,7 @@ module FeatherCaseWizard
"tripod_insert_outer_diameter must not be smaller than the pilot" "tripod_insert_outer_diameter must not be smaller than the pilot"
end end
validate_usb_opening!
validate_cutouts! validate_cutouts!
self self
end end
@@ -402,6 +417,19 @@ module FeatherCaseWizard
validate_cutout_spacing!(bounds, dims) validate_cutout_spacing!(bounds, dims)
end end
def validate_usb_opening!
return unless parameters["usb_opening_enabled"]
dims = dimensions
bounds = usb_wall_bounds(dims)
if bounds["bottom"] < dims["floor"]
raise ConfigError, "USB opening or guide intersects the case floor"
end
if bounds["top"] > dims["lip_bottom"]
raise ConfigError, "USB opening or guide intersects the lid lip"
end
end
def validate_cutout_spacing!(bounds, dims) def validate_cutout_spacing!(bounds, dims)
web = parameters["cutout_min_web"] web = parameters["cutout_min_web"]
bounds.combination(2) do |first, second| bounds.combination(2) do |first, second|
+1 -1
View File
@@ -225,7 +225,7 @@ module FeatherCaseWizard
plate, and slicing settings in Bambu Studio. plate, and slicing settings in Bambu Studio.
#{artwork} #{artwork}
MARKDOWN MARKDOWN
File.write(stage.join("README.md"), text) File.write(stage.join("README.md"), text.rstrip + "\n")
end end
def install_atomically(stage, destination) def install_atomically(stage, destination)
+75 -2
View File
@@ -72,6 +72,26 @@ class ConfigTest < Minitest::Test
assert_match(/guide requires/, error.message) assert_match(/guide requires/, error.message)
end end
def test_usb_opening_expands_upward_without_moving_bottom_edge
parameters = config.parameters
compensation = parameters["hole_compensation"]
board_top = config.dimensions["floor"] +
parameters["board_bottom_clearance"] + 1.6
old_center = board_top + 1.6
old_width = 12.5 + compensation
old_height = 7.0 + compensation
new_center = old_center + parameters["usb_vertical_offset"]
new_width = parameters["usb_opening_width"] + compensation
new_height = parameters["usb_opening_height"] + compensation
assert_in_delta 1.0, new_width - old_width, 0.001
assert_in_delta old_center - old_height / 2,
new_center - new_height / 2, 0.001
assert_in_delta 1.0,
(new_center + new_height / 2) -
(old_center + old_height / 2), 0.001
end
def test_end_clearances_expand_case_length def test_end_clearances_expand_case_length
subject = config subject = config
subject.parameters["usb_board_wall_gap"] = 20.0 subject.parameters["usb_board_wall_gap"] = 20.0
@@ -109,6 +129,19 @@ class ConfigTest < Minitest::Test
refute_includes source, "bore_d = diameter + hole_compensation;" refute_includes source, "bore_d = diameter + hole_compensation;"
end end
def test_lid_rocker_mirrors_to_installed_feather_side
source = FeatherCaseWizard::MODEL_SOURCE.read
assert_includes source,
"rocker_lid_center_y = case_width - rocker_installed_center_y;"
assert_includes source,
"translate([rocker_lid_center_x, rocker_lid_center_y, -eps])"
assert_includes source,
"rocker_installed_center_y - rocker_body_size[1] / 2"
assert_includes source,
"+ rocker_body_size[1] / 2 + rocker_partition_offset;"
end
def test_oled_buttons_and_selected_coupons_are_printable def test_oled_buttons_and_selected_coupons_are_printable
subject = config subject = config
subject.parameters["top_profile"] = "oled128x64" subject.parameters["top_profile"] = "oled128x64"
@@ -125,10 +158,50 @@ class ConfigTest < Minitest::Test
subject.parameters["tripod_insert_enabled"] = true subject.parameters["tripod_insert_enabled"] = true
assert_in_delta 88.6, subject.dimensions["length"], 0.01 assert_in_delta 88.6, subject.dimensions["length"], 0.01
assert_in_delta 59.06, subject.dimensions["width"], 0.01 assert_in_delta 61.06, subject.dimensions["width"], 0.01
assert_in_delta 31.25, subject.dimensions["height"], 0.01 assert_in_delta 31.25, subject.dimensions["height"], 0.01
end end
def test_battery_board_clearance_moves_the_whole_bay_outward
subject = config
subject.parameters["battery_enabled"] = true
original_width = subject.dimensions["width"]
subject.parameters["battery_board_clearance"] += 2.0
assert_in_delta 2.0, subject.dimensions["width"] - original_width, 0.001
end
def test_switch_tab_relief_uses_two_mm_lands_in_lid_and_coupon
source = FeatherCaseWizard::MODEL_SOURCE.read
assert_in_delta 2.0, config.parameters["rocker_tab_panel_thickness"], 0.001
assert_in_delta 2.0, config.parameters["rocker_tab_land"], 0.001
assert_includes source, "module rocker_tab_reliefs(center, opening_size)"
assert_equal 2, source.scan(/rocker_tab_reliefs\(/).length - 1
assert_includes source,
"edge_y = center[1] + side * opening_size[1] / 2;"
assert_includes source,
"opening_overlap = rocker_corner_radius;"
assert_includes source,
"cube([opening_size[0], rocker_tab_land + opening_overlap,"
refute_includes source,
"edge_x = center[0] + side * opening_size[0] / 2;"
end
def test_switch_moves_two_mm_toward_partition_on_installed_lid
assert_in_delta 2.0, config.parameters["rocker_partition_offset"], 0.001
end
def test_switch_tab_panel_must_be_thinner_than_lid
subject = config
subject.parameters["rocker_tab_panel_thickness"] =
subject.parameters["lid_thickness"]
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/rocker_tab_panel_thickness/, error.message)
end
def test_legacy_round_switch_fields_migrate_to_rocker_defaults def test_legacy_round_switch_fields_migrate_to_rocker_defaults
data = config.data data = config.data
data["parameters"]["switch_hole_diameter"] = 12.0 data["parameters"]["switch_hole_diameter"] = 12.0
@@ -232,7 +305,7 @@ class ConfigTest < Minitest::Test
def test_front_cutout_can_clear_usb_reservation def test_front_cutout_can_clear_usb_reservation
subject = config subject = config
subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)] subject.data["cutouts"] = [cutout("front", 8.0).merge("z" => 11.0)]
assert_same subject, subject.validate! assert_same subject, subject.validate!
end end
+1
View File
@@ -29,6 +29,7 @@ class PackageBuilderTest < Minitest::Test
%w[iso front back left right top].each do |view| %w[iso front back left right top].each do |view|
assert destination.join("previews/assembly_#{view}.png").file? assert destination.join("previews/assembly_#{view}.png").file?
end end
refute_match(/\n\n\z/, destination.join("README.md").read)
config.parameters["button_reset"] = true config.parameters["button_reset"] = true
build(config, root.join("cases"), tools, model, renderer) build(config, root.join("cases"), tools, model, renderer)