Initial parametric Feather case generator

This commit is contained in:
Nick Estes
2026-07-29 11:51:51 -07:00
commit 683697604a
26 changed files with 3949 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
---
BUNDLE_PATH: "vendor/bundle"
+35
View File
@@ -0,0 +1,35 @@
# Generated CAD and preview output
/build/
*.stl
*.3mf
*.amf
*.off
*.png
/cases/*/exports/
/cases/*/previews/
/cases/*/history/
/cases/*/.rebuild-*/
/cases/.*
# Local Ruby dependencies and test output
/vendor/bundle/
/coverage/
/tmp/
*.log
# Python/Blender caches and crash dumps
__pycache__/
*.pyc
core
core.*
# Local environment and editor files
.env
.env.*
.DS_Store
*.swp
*.swo
*~
/.idea/
/.vscode/
/.ruby-lsp/
+7
View File
@@ -0,0 +1,7 @@
source "https://rubygems.org"
gem "highline", "~> 3.1"
group :development do
gem "minitest", "~> 5.22"
end
+19
View File
@@ -0,0 +1,19 @@
GEM
remote: https://rubygems.org/
specs:
highline (3.1.2)
reline
io-console (0.8.2)
minitest (5.27.0)
reline (0.6.3)
io-console (~> 0.5)
PLATFORMS
x86_64-linux-gnu
DEPENDENCIES
highline (~> 3.1)
minitest (~> 5.22)
BUNDLED WITH
2.4.20
+168
View File
@@ -0,0 +1,168 @@
# Parametric Feather Case
An OpenSCAD enclosure generator for the Adafruit ESP32 Feather V2, stacked
FeatherWings, an optional protected 18650 pack, and an optional 1/4-20 camera
mount. It produces a body, removable lid, OLED button plungers, and fit
calibration coupons.
## Interactive Ruby wizard
The HighLine wizard asks the project-specific questions, saves the answers in
YAML, and builds a self-contained case directory with OpenSCAD source, STL
and/or 3MF geometry, and rendered previews.
Install the Ruby dependency into this repository:
```bash
bundle install
```
Then run:
```bash
bundle exec bin/feather-case
```
The menu can create a case, reopen one for dimensional tuning after a test
print, or rebuild an existing case. Generated definitions live under
`cases/<name>/`; exports, previews, revision history, and `vendor/bundle` are
ignored by Git.
OpenSCAD and Blender must be available on `PATH`. On Ubuntu:
```bash
sudo apt-get install openscad blender
```
Set `OPENSCAD=/path/to/openscad` or `BLENDER=/path/to/blender` when either
program is installed elsewhere. The wizard checks both tools before replacing
an existing package.
Each generated directory contains a dependency-free `build.rb`. After copying
that directory to another Ubuntu machine, run `ruby build.rb` to reproduce its
exports and previews. The saved generator source is used by default so a
fine-tuning pass cannot silently pick up later geometry changes.
OpenSCAD's 3MF output contains portable model geometry. It does not contain a
Bambu printer, filament, plate, or process profile; select those settings after
opening the files in Bambu Studio.
The design targets support-free FDM printing with a 0.6 mm nozzle. PLA and PETG
are suitable for general use; ASA is the preferred starting material for
outdoor exposure. The enclosure is not weather-sealed.
## Reference geometry
All dimensions are millimetres. Board coordinates come from Adafruit's source
files:
- [ESP32 Feather V2 PCB](https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB)
- [ESP32 Feather V2 CAD](https://github.com/adafruit/Adafruit_CAD_Parts/tree/main/5400%20ESP32%20Feather%20V2)
- [128x64 OLED FeatherWing PCB](https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB)
- [128x64 OLED FeatherWing CAD](https://github.com/adafruit/Adafruit_CAD_Parts/tree/main/4650%20OLED%20FeatherWing)
- [Adafruit 69 x 18 mm protected 18650 pack](https://www.adafruit.com/product/1781)
The board origin is its USB edge at `(0, 0)`. The case uses X from the USB wall
toward the rear, Y from the plain side toward the battery/JST side, and Z up
from the exterior bottom.
## Generate parts
Open [feather_case.scad](feather_case.scad) in OpenSCAD and use the Customizer,
or override values from the command line:
```bash
# Generic one-Wing case
openscad -o body.stl -D 'part="body"' feather_case.scad
openscad -o lid.stl -D 'part="lid"' feather_case.scad
# Three-Wing GPS case with battery and camera mount
openscad -o gps-body.stl \
-D 'part="body"' \
-D 'top_profile="gps"' \
-D 'wing_count=3' \
-D 'battery_enabled=true' \
-D 'tripod_insert_enabled=true' \
feather_case.scad
openscad -o gps-lid.stl \
-D 'part="lid"' \
-D 'top_profile="gps"' \
-D 'wing_count=3' \
-D 'battery_enabled=true' \
-D 'tripod_insert_enabled=true' \
feather_case.scad
# 128x64 OLED lid and all four captive buttons
openscad -o oled-lid.stl \
-D 'part="lid"' \
-D 'top_profile="oled128x64"' \
-D 'button_reset=true' \
feather_case.scad
openscad -o oled-buttons.stl \
-D 'part="buttons"' \
-D 'top_profile="oled128x64"' \
-D 'button_reset=true' \
feather_case.scad
```
The standard stack height is `wing_count * wing_pitch`. Set
`stack_height_override` to the measured distance from the Feather PCB top to
the top FeatherWing PCB when using unusual headers. It takes precedence over
the computed height.
## Custom wire egress
`extra_cutouts` accepts any number of entries:
```openscad
// [face, shape, u, z, width, height, radius]
extra_cutouts = [
["left", "circle", 35, 10, 8, 8, 4],
["rear", "roundrect", 20, 12, 14, 7, 2],
["right", "slot", 28, 15, 18, 5, 2.5]
];
```
Faces are `front`, `rear`, `left`, and `right`. For front/rear faces, `u` is
the global Y coordinate; for left/right faces it is global X. The Z value is
the opening centre. Shapes are `circle`, `rect`, `roundrect`, and `slot`.
Cutouts that enter the floor or lid-lip keepout raise an assertion instead of
silently weakening those features.
## Calibration
Heat-set insert holes vary significantly by insert brand, filament, nozzle,
temperature, and printer calibration. Treat the supplied values as starting
points.
1. Export and print `part="insert_coupon"` in the intended material.
2. Test both M2.5 and 1/4-20 inserts in the five stepped holes.
3. Set `m25_insert_hole_diameter` and `tripod_insert_hole_diameter` to the best
coupon values.
4. Print `part="switch_coupon"` and choose the best hole for the actual DMWD
switch batch.
5. Print `part="fit_coupon"` and tune `fit_clearance` before committing to the
complete lid and body.
For ASA, tune dimensional compensation with coupons rather than applying an
assumed global shrink percentage. Start with four or more walls, good chamber
temperature control, and the same extrusion settings intended for the case.
## Assembly
1. Heat-set the optional 1/4-20 insert from inside the case. Its threaded
opening faces the exterior bottom.
2. Heat-set four M2.5 inserts into the board standoffs.
3. Fasten the ESP32 Feather V2, then install the stacking headers and Wings.
4. For battery builds, place the protected 69 x 18 mm pack in the cradle, route
its lead through the partition notch, and mount the latching switch in the
battery-side wall.
5. Heat-set the four lid inserts downward into the corner bosses.
6. For OLED builds, insert each enabled printed plunger through the lid from
the inside. The elongated flange stays in the internal recess.
7. Engage the lid rim and fasten four standard M2.5 socket-head screws into the
2.5 mm-deep counterbores.
Confirm the USB plug, switch, battery, insert, and lid fits without force before
leaving a lithium-ion battery installed. The battery cradle is intended for
Adafruit's protected pack, not an unprotected loose cell.
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative "../lib/feather_case_wizard"
exit FeatherCaseWizard::CLI.new.run
+25
View File
@@ -0,0 +1,25 @@
# GPS
Generated by the Feather Case Wizard.
- Top profile: `gps`
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 38.6 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P gps source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
+81
View File
@@ -0,0 +1,81 @@
---
schema_version: 1
name: GPS
slug: gps
generator:
sha256: a265164909faa0c364792224601af36381744a129b7cd7cbd6b6293815953f63
created_with: feather-case-wizard
parameters:
top_profile: gps
wing_count: 1
wing_pitch: 11.0
stack_height_override: 0.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 4.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: true
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
insert_melt_relief: 0.8
wall_thickness: 2.4
floor_thickness: 2.4
lid_thickness: 3.2
corner_radius: 4.0
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts: []
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts: []
+68
View File
@@ -0,0 +1,68 @@
{
"parameterSets": {
"gps": {
"top_profile": "\"gps\"",
"wing_count": "1",
"wing_pitch": "11.0",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "4.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "true",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
"insert_melt_relief": "0.8",
"wall_thickness": "2.4",
"floor_thickness": "2.4",
"lid_thickness": "3.2",
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"fileFormatVersion": "1"
}
+871
View File
@@ -0,0 +1,871 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 11; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 4;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
function clamp(v, lo, hi) = min(max(v, lo), hi);
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_component_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
+127
View File
@@ -0,0 +1,127 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
+871
View File
@@ -0,0 +1,871 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 11; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 4;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
function clamp(v, lo, hi) = min(max(v, lo), hi);
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_component_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require "digest"
require "fileutils"
require "json"
require "open3"
require "pathname"
require "time"
require "tmpdir"
require "yaml"
require "highline"
require_relative "feather_case_wizard/config"
require_relative "feather_case_wizard/toolchain"
require_relative "feather_case_wizard/package_builder"
require_relative "feather_case_wizard/wizard"
require_relative "feather_case_wizard/cli"
module FeatherCaseWizard
ROOT = Pathname(__dir__).join("..").expand_path.freeze
CASES_ROOT = ROOT.join("cases").freeze
MODEL_SOURCE = ROOT.join("feather_case.scad").freeze
RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze
end
+116
View File
@@ -0,0 +1,116 @@
# frozen_string_literal: true
module FeatherCaseWizard
class CLI
def initialize(ui: HighLine.new, cases_root: CASES_ROOT,
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
toolchain: Toolchain.new)
@ui = ui
@cases_root = Pathname(cases_root)
@model_source = Pathname(model_source)
@renderer_source = Pathname(renderer_source)
@toolchain = toolchain
@wizard = Wizard.new(ui: ui)
end
def run
@ui.say(@ui.color("Feather Case Wizard", :bold))
loop do
action = @ui.choose("\nWhat would you like to do?") do |menu|
menu.choice("Create a new case") { :create }
menu.choice("Edit an existing case") { :edit }
menu.choice("Rebuild an existing case") { :rebuild }
menu.choice("Exit") { :exit }
end
return 0 if action == :exit
case action
when :create then create_case
when :edit then edit_case
when :rebuild then rebuild_case
end
rescue ConfigError, ToolError, Errno::EACCES => e
@ui.say(@ui.color("\n#{e.message}", :red))
rescue Interrupt
@ui.say("\nCancelled.")
return 130
end
end
private
def create_case
config = @wizard.create
destination = config.package_dir(@cases_root)
if destination.exist? &&
!@ui.agree("#{destination} already exists. Replace it? (y/n) ") { |q| q.default = "no" }
return
end
build(config, @model_source, @renderer_source) if @wizard.confirm_build(config)
end
def edit_case
path = choose_case
return unless path
config = Config.load(path)
package = path.dirname
source = package.join("source/feather_case.scad")
renderer = package.join("source/render_stl_previews.py")
source, renderer = choose_generator(config, source, renderer)
config = @wizard.edit(config)
build(config, source, renderer) if @wizard.confirm_build(config)
end
def rebuild_case
path = choose_case
return unless path
config = Config.load(path)
return unless @wizard.confirm_build(config)
package = path.dirname
build(config, package.join("source/feather_case.scad"),
package.join("source/render_stl_previews.py"))
end
def choose_case
paths = Dir.glob(@cases_root.join("*/case.yml")).sort.map { |item| Pathname(item) }
if paths.empty?
@ui.say("No saved cases found under #{@cases_root}.")
return nil
end
@ui.choose("Choose a saved case") do |menu|
paths.each do |path|
label = Config.load(path).name
menu.choice("#{label} (#{path.dirname.basename})") { path }
rescue ConfigError
menu.choice("Invalid configuration (#{path.dirname.basename})") { path }
end
end
end
def choose_generator(config, snapshot, renderer)
return [@model_source, @renderer_source] unless snapshot.file?
snapshot_sha = Digest::SHA256.file(snapshot).hexdigest
current_sha = Digest::SHA256.file(@model_source).hexdigest
return [snapshot, renderer] if snapshot_sha == current_sha
@ui.say("This case uses a different generator snapshot.")
upgrade = @ui.agree("Upgrade it to the current generator? (y/n) ") do |question|
question.default = "no"
end
upgrade ? [@model_source, @renderer_source] : [snapshot, renderer]
end
def build(config, source, renderer)
destination = PackageBuilder.new(
config, root: @cases_root, toolchain: @toolchain,
model_source: source, renderer_source: renderer,
reporter: ->(message) { @ui.say(" #{message}…") }
).build!
@ui.say(@ui.color("\nGenerated #{destination}", :green))
end
end
end
+347
View File
@@ -0,0 +1,347 @@
# frozen_string_literal: true
module FeatherCaseWizard
class ConfigError < StandardError; end
class Config
SCHEMA_VERSION = 1
PROFILES = %w[generic gps oled128x64].freeze
FORMATS = %w[stl 3mf].freeze
FACES = %w[front rear left right].freeze
SHAPES = %w[circle rect roundrect slot].freeze
COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze
DEFAULT_PARAMETERS = {
"top_profile" => "generic",
"wing_count" => 1,
"wing_pitch" => 11.0,
"stack_height_override" => 0.0,
"generic_component_height" => 3.0,
"generic_top_gap" => 2.0,
"gps_antenna_height" => 4.0,
"gps_top_gap" => 5.0,
"oled_component_height" => 2.4,
"oled_face_gap" => 0.8,
"button_a" => true,
"button_b" => true,
"button_c" => true,
"button_reset" => false,
"button_projection" => 1.5,
"button_cap_size" => [5.0, 3.2],
"button_flange_size" => [6.6, 3.6],
"button_flange_thickness" => 0.9,
"button_travel" => 0.5,
"button_contact_extension" => 0.7,
"battery_enabled" => false,
"battery_length" => 69.0,
"battery_diameter" => 18.0,
"battery_radial_clearance" => 0.6,
"battery_end_clearance" => 1.0,
"switch_hole_diameter" => 12.0,
"switch_hole_compensation" => 0.3,
"switch_cap_diameter" => 8.0,
"switch_flange_diameter" => 16.2,
"switch_body_length" => 24.0,
"switch_body_diameter" => 12.0,
"switch_wire_allowance" => 3.0,
"tripod_insert_enabled" => false,
"tripod_insert_length" => 6.4,
"tripod_insert_hole_diameter" => 8.8,
"tripod_thread_clearance_diameter" => 6.8,
"tripod_boss_diameter" => 14.0,
"m25_insert_length" => 4.0,
"m25_insert_hole_diameter" => 3.7,
"m25_screw_clearance" => 2.8,
"m25_boss_diameter" => 7.0,
"insert_entry_chamfer" => 0.6,
"insert_melt_relief" => 0.8,
"wall_thickness" => 2.4,
"floor_thickness" => 2.4,
"lid_thickness" => 3.2,
"corner_radius" => 4.0,
"board_bottom_clearance" => 5.4,
"board_side_clearance" => 7.0,
"board_rear_clearance" => 4.0,
"usb_board_wall_gap" => 1.2,
"lid_lip_depth" => 2.4,
"lid_lip_thickness" => 1.2,
"fit_clearance" => 0.35,
"hole_compensation" => 0.2,
"lid_screw_head_diameter" => 5.2,
"lid_screw_head_height" => 2.5,
"usb_opening_width" => 12.5,
"usb_opening_height" => 7.0,
"usb_opening_radius" => 1.5,
"usb_vertical_offset" => 0.0
}.freeze
ADVANCED_FIELDS = {
"Stack" => %w[wing_pitch generic_component_height generic_top_gap
gps_antenna_height gps_top_gap oled_component_height oled_face_gap],
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
board_bottom_clearance board_side_clearance board_rear_clearance
usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
hole_compensation lid_screw_head_diameter lid_screw_head_height],
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
m25_screw_clearance m25_boss_diameter
insert_entry_chamfer insert_melt_relief],
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
tripod_thread_clearance_diameter tripod_boss_diameter],
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
battery_end_clearance switch_hole_diameter
switch_hole_compensation switch_cap_diameter
switch_flange_diameter switch_body_length
switch_body_diameter switch_wire_allowance],
"USB-C opening" => %w[usb_opening_width usb_opening_height
usb_opening_radius usb_vertical_offset],
"OLED buttons" => %w[button_projection button_flange_thickness
button_travel button_contact_extension]
}.freeze
attr_reader :data
def self.create(name:, slug:)
new(
"schema_version" => SCHEMA_VERSION,
"name" => name,
"slug" => slug,
"generator" => {},
"parameters" => deep_copy(DEFAULT_PARAMETERS),
"cutouts" => [],
"outputs" => {
"formats" => ["3mf"],
"coupons" => [],
"assembly_exploded_view" => 8
}
)
end
def self.load(path)
raw = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [],
aliases: false)
raise ConfigError, "Configuration must be a YAML mapping" unless raw.is_a?(Hash)
new(raw)
rescue Psych::Exception => e
raise ConfigError, "Cannot read configuration: #{e.message}"
end
def self.deep_copy(value)
Marshal.load(Marshal.dump(value))
end
def initialize(data)
@data = stringify_keys(data)
normalize!
validate!
end
def name = data.fetch("name")
def slug = data.fetch("slug")
def parameters = data.fetch("parameters")
def cutouts = data.fetch("cutouts")
def outputs = data.fetch("outputs")
def generator = data.fetch("generator")
def package_dir(root = CASES_ROOT)
root.join(slug)
end
def printable_parts
parts = %w[body lid]
if parameters["top_profile"] == "oled128x64" &&
%w[button_a button_b button_c button_reset].any? { |key| parameters[key] }
parts << "buttons"
end
parts + outputs["coupons"]
end
def model_parameters
parameters.merge("extra_cutouts" => scad_cutouts)
end
def dimensions
p = parameters
effective_floor = p["tripod_insert_enabled"] ?
[p["floor_thickness"], p["tripod_insert_length"] + p["insert_melt_relief"] + 1.2].max :
p["floor_thickness"]
effective_board_clearance = [
p["board_bottom_clearance"],
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
].max
board_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"]
board_origin_y = p["wall_thickness"] + p["board_side_clearance"]
board_top_z = effective_floor + effective_board_clearance + 1.6
top_pcb_z = board_top_z + (
p["stack_height_override"].positive? ?
p["stack_height_override"] : p["wing_count"] * p["wing_pitch"]
)
component_height, top_gap = case p["top_profile"]
when "gps"
[p["gps_antenna_height"], p["gps_top_gap"]]
when "oled128x64"
[p["oled_component_height"], p["oled_face_gap"]]
else
[p["generic_component_height"], p["generic_top_gap"]]
end
plain_length = board_origin_x + 50.8 + p["board_rear_clearance"] +
p["wall_thickness"]
plain_width = board_origin_y + 22.86 + p["board_side_clearance"] +
p["wall_thickness"]
if p["battery_enabled"]
partition_y = board_origin_y + 22.86 + 1
battery_y = partition_y + p["wall_thickness"] +
p["battery_radial_clearance"] + p["battery_diameter"] / 2
battery_start_x = board_origin_x + p["battery_end_clearance"]
battery_end_x = battery_start_x + p["battery_length"]
switch_x = battery_end_x + [
p["switch_body_diameter"] / 2 + 0.8,
p["switch_flange_diameter"] / 2 + 0.5
].max
length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 +
1.2 + p["wall_thickness"]].max
width = [plain_width, battery_y + p["battery_diameter"] / 2 +
p["battery_radial_clearance"] + p["wall_thickness"]].max
battery_z = effective_floor + p["battery_radial_clearance"] +
p["battery_diameter"] / 2
switch_z = [
effective_floor + p["switch_flange_diameter"] / 2 + 1,
battery_z
].max
battery_ceiling = [
battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"],
switch_z + p["switch_flange_diameter"] / 2 + 1
].max
else
length = plain_length
width = plain_width
battery_ceiling = 0
end
{
"length" => length,
"width" => width,
"height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max,
"floor" => effective_floor,
"lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max -
p["lid_lip_depth"]
}
end
def scad_cutouts
dims = dimensions
cutouts.map do |cutout|
face = cutout.fetch("face")
horizontal = cutout.fetch("horizontal")
u = case face
when "front" then dims["width"] - horizontal
when "rear", "left" then horizontal
when "right" then dims["length"] - horizontal
end
width = cutout.fetch("width")
height = cutout.fetch("height", width)
radius = cutout.fetch("radius", [width, height].min / 2)
[face, cutout.fetch("shape"), u, cutout.fetch("z"), width, height, radius]
end
end
def to_yaml
data["resolved"] = {"extra_cutouts" => scad_cutouts}
data.to_yaml(line_width: -1)
end
def validate!
raise ConfigError, "Unsupported schema_version" unless data["schema_version"] == SCHEMA_VERSION
raise ConfigError, "Project name cannot be empty" if name.to_s.strip.empty?
unless slug.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens"
end
raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"])
raise ConfigError, "wing_count must be between 1 and 8" unless (1..8).cover?(parameters["wing_count"])
raise ConfigError, "At least one export format is required" if outputs["formats"].empty?
unless (outputs["formats"] - FORMATS).empty?
raise ConfigError, "Unsupported export format"
end
unless (outputs["coupons"] - COUPONS).empty?
raise ConfigError, "Unsupported calibration coupon"
end
positive = DEFAULT_PARAMETERS.keys - %w[usb_vertical_offset stack_height_override]
positive.each do |key|
value = parameters[key]
next if value == true || value == false
next if value.is_a?(Array) && value.all? { |item| item.to_f.positive? }
next unless value.is_a?(Numeric)
raise ConfigError, "#{key} must be positive" unless value.to_f.positive?
end
raise ConfigError, "stack_height_override cannot be negative" if
parameters["stack_height_override"].negative?
raise ConfigError, "wall_thickness must be at least 1.2 mm" if
parameters["wall_thickness"] < 1.2
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
parameters["lid_lip_thickness"] < 1.2
validate_cutouts!
self
end
private
def normalize!
data["schema_version"] = Integer(data.fetch("schema_version", SCHEMA_VERSION))
data["generator"] ||= {}
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(data["parameters"] || {})
data["cutouts"] ||= []
data["outputs"] ||= {}
data["outputs"]["formats"] ||= ["3mf"]
data["outputs"]["coupons"] ||= []
data["outputs"]["assembly_exploded_view"] ||= 8
rescue ArgumentError, TypeError => e
raise ConfigError, "Invalid configuration value: #{e.message}"
end
def validate_cutouts!
dims = dimensions
names = {}
cutouts.each do |cutout|
name = cutout["name"].to_s.strip
raise ConfigError, "Every cutout needs a name" if name.empty?
raise ConfigError, "Duplicate cutout name: #{name}" if names[name]
names[name] = true
face = cutout["face"]
shape = cutout["shape"]
raise ConfigError, "Invalid face for #{name}" unless FACES.include?(face)
raise ConfigError, "Invalid shape for #{name}" unless SHAPES.include?(shape)
width = Float(cutout["width"])
height = Float(cutout.fetch("height", width))
horizontal = Float(cutout["horizontal"])
z = Float(cutout["z"])
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
unless horizontal - width / 2 >= 0 && horizontal + width / 2 <= wall_width
raise ConfigError, "#{name} extends beyond the #{face} wall"
end
if z - height / 2 < dims["floor"]
raise ConfigError, "#{name} intersects the case floor"
end
if z + height / 2 > dims["lip_bottom"]
raise ConfigError, "#{name} intersects the lid locating lip"
end
radius = Float(cutout.fetch("radius", [width, height].min / 2))
raise ConfigError, "#{name} dimensions must be positive" unless
width.positive? && height.positive? && radius.positive?
rescue ArgumentError, TypeError, KeyError => e
raise ConfigError, "Invalid cutout #{name.inspect}: #{e.message}"
end
end
def stringify_keys(value)
case value
when Hash
value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
when Array
value.map { |item| stringify_keys(item) }
else
value
end
end
end
end
+200
View File
@@ -0,0 +1,200 @@
# frozen_string_literal: true
module FeatherCaseWizard
class PackageBuilder
attr_reader :config, :root, :toolchain, :model_source, :renderer_source
def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new,
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
reporter: nil)
@config = config
@root = Pathname(root)
@toolchain = toolchain
@model_source = Pathname(model_source)
@renderer_source = Pathname(renderer_source)
@reporter = reporter || ->(_message) {}
end
def build!
toolchain.check!
verify_sources!
config.validate!
config.generator["sha256"] = Digest::SHA256.file(model_source).hexdigest
config.generator["created_with"] ||= "feather-case-wizard"
FileUtils.mkdir_p(root)
destination = config.package_dir(root)
stage = Pathname(Dir.mktmpdir(".#{config.slug}-stage-", root.to_s))
prepare_package(stage, destination)
install_atomically(stage, destination)
destination
ensure
FileUtils.rm_rf(stage) if stage&.exist?
end
private
def verify_sources!
raise ToolError, "Missing model source: #{model_source}" unless model_source.file?
raise ToolError, "Missing preview renderer: #{renderer_source}" unless renderer_source.file?
end
def prepare_package(stage, previous)
source_dir = stage.join("source")
FileUtils.mkdir_p(source_dir)
FileUtils.cp(model_source, source_dir.join("feather_case.scad"))
FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py"))
FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb"))
copy_history(previous, stage)
write_configuration(stage)
generate_exports(stage)
generate_previews(stage)
write_summary(stage)
end
def copy_history(previous, stage)
history = stage.join("history")
if previous.directory?
old_history = previous.join("history")
FileUtils.cp_r(old_history, history) if old_history.directory?
FileUtils.mkdir_p(history)
stamp = Time.now.utc.strftime("%Y%m%dT%H%M%S.%6NZ")
FileUtils.cp(previous.join("case.yml"), history.join("case-#{stamp}.yml")) if
previous.join("case.yml").file?
old_source = previous.join("source/feather_case.scad")
if old_source.file? &&
Digest::SHA256.file(old_source).hexdigest != config.generator["sha256"]
FileUtils.cp(old_source, history.join("feather_case-#{stamp}.scad"))
end
end
end
def write_configuration(stage)
File.write(stage.join("case.yml"), config.to_yaml)
values = config.parameters.to_h do |key, value|
[key, openscad_literal(value)]
end
customizer = {
"parameterSets" => {config.slug => values},
"fileFormatVersion" => "1"
}
File.write(stage.join("parameters.json"), JSON.pretty_generate(customizer) + "\n")
FileUtils.chmod(0o755, stage.join("build.rb"))
end
def generate_exports(stage)
config.outputs["formats"].each do |format|
directory = stage.join("exports", format)
FileUtils.mkdir_p(directory)
config.printable_parts.each do |part|
report("Exporting #{part}.#{format}")
export_model(stage, directory.join("#{part}.#{format}"), part)
end
end
end
def generate_previews(stage)
previews = stage.join("previews")
scratch = stage.join(".preview-meshes")
FileUtils.mkdir_p([previews, scratch])
assembly_stl = scratch.join("assembly.stl")
report("Preparing assembly preview")
export_model(stage, assembly_stl, "assembly",
"exploded_view" => config.outputs["assembly_exploded_view"],
"show_references" => false)
render(stage, assembly_stl, previews, "assembly")
config.printable_parts.each do |part|
report("Rendering #{part} preview")
mesh = scratch.join("#{part}.stl")
export_model(stage, mesh, part) unless mesh.file?
render(stage, mesh, previews, part, ["iso"])
end
FileUtils.rm_rf(scratch)
end
def export_model(stage, output, part, overrides = {})
model = stage.join("source/feather_case.scad")
parameters = config.model_parameters.merge(overrides).merge("part" => part)
command = [toolchain.openscad, "-o", output.to_s]
parameters.each do |name, value|
command.concat(["-D", "#{name}=#{openscad_literal(value)}"])
end
command << model.to_s
toolchain.run!(*command)
raise ToolError, "OpenSCAD did not create #{output}" unless output.file? && output.size.positive?
end
def render(stage, mesh, output_dir, basename, views = nil)
renderer = stage.join("source/render_stl_previews.py")
command = [
toolchain.blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, output_dir.to_s, basename
]
command << views.join(",") if views
toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil})
end
def write_summary(stage)
dims = config.dimensions
parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ")
text = <<~MARKDOWN
# #{config.name}
Generated by the Feather Case Wizard.
- Top profile: `#{config.parameters["top_profile"]}`
- FeatherWings: #{config.parameters["wing_count"]}
- Battery compartment: #{config.parameters["battery_enabled"] ? "yes" : "no"}
- 1/4-20 insert: #{config.parameters["tripod_insert_enabled"] ? "yes" : "no"}
- Approximate exterior: #{format("%.1f", dims["length"])} × #{format("%.1f", dims["width"])} × #{format("%.1f", dims["height"] + config.parameters["lid_thickness"])} mm
- Formats: #{config.outputs["formats"].join(", ")}
- Parts: #{parts}
- Custom cutouts: #{config.cutouts.length}
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P #{config.slug} source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
MARKDOWN
File.write(stage.join("README.md"), text)
end
def install_atomically(stage, destination)
old = root.join(".#{config.slug}-old-#{Process.pid}")
if destination.exist?
FileUtils.mv(destination, old)
end
FileUtils.mv(stage, destination)
FileUtils.rm_rf(old) if old.exist?
rescue StandardError
FileUtils.mv(old, destination) if old&.exist? && !destination.exist?
raise
end
def openscad_literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| openscad_literal(item) }.join(",")}]"
else
raise ConfigError, "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def report(message)
@reporter.call(message)
end
end
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
module FeatherCaseWizard
class ToolError < StandardError; end
class Toolchain
attr_reader :openscad, :blender
def initialize(env: ENV)
@env = env
@openscad = discover("OPENSCAD", "openscad")
@blender = discover("BLENDER", "blender")
end
def check!
missing = []
missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad
missing << "Blender (`sudo apt-get install blender` or set BLENDER)" unless blender
return self if missing.empty?
raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}"
end
def run!(*command, chdir: nil, env: {})
options = chdir ? {chdir: chdir} : {}
stdout, stderr, status = Open3.capture3(env, *command, **options)
return [stdout, stderr] if status.success?
detail = [stdout, stderr].reject(&:empty?).join("\n").strip
result = status.exitstatus || "signal #{status.termsig}"
raise ToolError, "Command failed (#{result}): #{command.first}\n#{detail}"
rescue Errno::ENOENT => e
raise ToolError, "Cannot run #{command.first}: #{e.message}"
end
private
def discover(env_name, command)
override = @env[env_name]
return File.expand_path(override) if override && File.executable?(override)
@env.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
end
end
+289
View File
@@ -0,0 +1,289 @@
# frozen_string_literal: true
module FeatherCaseWizard
class Wizard
attr_reader :ui
def initialize(ui: HighLine.new)
@ui = ui
end
def create
name = required_text("Case name")
slug = ask_slug(slugify(name))
configure(Config.create(name: name, slug: slug), new_case: true)
end
def edit(config)
ui.say("\nEditing #{config.name} (`#{config.slug}`)")
config.data["name"] = text("Case name", config.name)
configure(config, new_case: false)
end
def confirm_build(config)
dims = config.dimensions
ui.say("\n#{ui.color("Build summary", :bold)}")
ui.say(" Case: #{config.name} (#{config.slug})")
ui.say(" Profile: #{config.parameters["top_profile"]}")
ui.say(" Wings: #{config.parameters["wing_count"]}")
ui.say(" Battery: #{yes_no(config.parameters["battery_enabled"])}")
ui.say(" Tripod insert: #{yes_no(config.parameters["tripod_insert_enabled"])}")
ui.say(" Cutouts: #{config.cutouts.length}")
ui.say(" Size: #{format("%.1f × %.1f × %.1f mm", dims["length"], dims["width"],
dims["height"] + config.parameters["lid_thickness"])}")
ui.say(" Parts: #{config.printable_parts.join(", ")}")
ui.say(" Formats: #{config.outputs["formats"].join(", ")}")
ui.agree("Generate this case? (y/n) ") { |question| question.default = "yes" }
end
private
def configure(config, new_case:)
choose_formats(config)
choose_profile(config)
choose_stack(config)
choose_features(config)
manage_cutouts(config)
choose_coupons(config)
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
question.default = "no"
end
config.validate!
config
rescue ConfigError => e
ui.say(ui.color("Configuration error: #{e.message}", :red))
retry unless new_case && !ui.agree("Return to the questions? (y/n) ") { |q| q.default = "yes" }
raise
end
def choose_formats(config)
current = config.outputs["formats"].sort
label = current == %w[3mf stl] ? "both" : current.first
selected = ui.choose("Export format (current: #{label})") do |menu|
menu.choice("3MF") { ["3mf"] }
menu.choice("STL") { ["stl"] }
menu.choice("Both 3MF and STL") { %w[3mf stl] }
menu.default = label == "both" ? 3 : (label == "stl" ? 2 : 1)
end
config.outputs["formats"] = selected
end
def choose_profile(config)
current = config.parameters["top_profile"]
config.parameters["top_profile"] = ui.choose("Top FeatherWing profile") do |menu|
menu.choice("Generic / other") { "generic" }
menu.choice("GPS") { "gps" }
menu.choice("128×64 OLED") { "oled128x64" }
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current)
end
end
def choose_stack(config)
p = config.parameters
p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8)
measured = p["stack_height_override"].positive?
if ui.agree("Use a measured board-to-top-PCB stack height? (y/n) ") do |question|
question.default = measured ? "yes" : "no"
end
p["stack_height_override"] = number(
"Measured height in mm", measured ? p["stack_height_override"] :
p["wing_count"] * p["wing_pitch"], min: 0.1
)
else
p["stack_height_override"] = 0.0
end
end
def choose_features(config)
p = config.parameters
p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?",
p["battery_enabled"])
p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?",
p["tripod_insert_enabled"])
return unless p["top_profile"] == "oled128x64"
ui.say("\nOLED captive buttons")
p["button_a"] = agreement("Enable button A?", p["button_a"])
p["button_b"] = agreement("Enable button B?", p["button_b"])
p["button_c"] = agreement("Enable button C?", p["button_c"])
p["button_reset"] = agreement("Enable reset button?", p["button_reset"])
end
def manage_cutouts(config)
loop do
ui.say("\nCustom cable cutouts: #{config.cutouts.empty? ? "none" :
config.cutouts.map { |cutout| cutout["name"] }.join(", ")}")
action = ui.choose("Cable cutouts") do |menu|
menu.choice("Done") { :done }
menu.choice("Add cutout") { :add }
menu.choice("Edit cutout") { :edit } unless config.cutouts.empty?
menu.choice("Remove cutout") { :remove } unless config.cutouts.empty?
end
break if action == :done
case action
when :add
config.cutouts << ask_cutout(config)
when :edit
index = choose_cutout(config, "Edit which cutout?")
old = config.cutouts.delete_at(index)
config.cutouts.insert(index, ask_cutout(config, old))
when :remove
config.cutouts.delete_at(choose_cutout(config, "Remove which cutout?"))
end
rescue ConfigError => e
ui.say(ui.color("Cutout not accepted: #{e.message}", :red))
end
end
def ask_cutout(config, existing = nil)
existing ||= {}
dims = config.dimensions
name = text("Cutout name", existing["name"] || "cable")
face = ui.choose("Wall") do |menu|
Config::FACES.each { |item| menu.choice(item) { item } }
menu.default = Config::FACES.index(existing["face"]).to_i + 1 if existing["face"]
end
shape = ui.choose("Shape") do |menu|
Config::SHAPES.each { |item| menu.choice(item) { item } }
menu.default = Config::SHAPES.index(existing["shape"]).to_i + 1 if existing["shape"]
end
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \
"usable vertical centers lie between the floor and lid lip.")
width = number(shape == "circle" ? "Diameter (mm)" : "Width (mm)",
existing["width"] || 8.0, min: 0.1)
height = shape == "circle" ? width :
number("Height (mm)", existing["height"] || 6.0, min: 0.1)
horizontal = number("Center from the wall's left edge (mm)",
existing["horizontal"] || wall_width / 2, min: width / 2,
max: wall_width - width / 2)
min_z = dims["floor"] + height / 2
max_z = dims["lip_bottom"] - height / 2
z = number("Center height from exterior bottom (mm)",
existing["z"] || (min_z + max_z) / 2, min: min_z, max: max_z)
radius = if shape == "roundrect"
number("Corner radius (mm)", existing["radius"] ||
[width, height].min / 4, min: 0.01, max: [width, height].min / 2)
else
[width, height].min / 2
end
candidate = {
"name" => name, "face" => face, "shape" => shape,
"horizontal" => horizontal, "z" => z, "width" => width,
"height" => height, "radius" => radius
}
temporary = Config.new(Config.deep_copy(config.data).tap do |data|
data["cutouts"] = config.cutouts + [candidate]
end)
temporary.validate!
candidate
end
def choose_cutout(config, prompt)
ui.choose(prompt) do |menu|
config.cutouts.each_with_index do |cutout, index|
menu.choice(cutout["name"]) { index }
end
end
end
def choose_coupons(config)
selected = []
selected << "insert_coupon" if agreement(
"Export heat-set insert calibration coupon?",
config.outputs["coupons"].include?("insert_coupon")
)
selected << "switch_coupon" if agreement(
"Export switch-hole calibration coupon?",
config.outputs["coupons"].include?("switch_coupon")
)
selected << "fit_coupon" if agreement(
"Export lid-fit calibration coupon?",
config.outputs["coupons"].include?("fit_coupon")
)
config.outputs["coupons"] = selected
end
def advanced(config)
Config::ADVANCED_FIELDS.each do |section, fields|
next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" }
ui.say("\n#{section}")
fields.each do |field|
config.parameters[field] = number(field.tr("_", " "), config.parameters[field],
min: field == "usb_vertical_offset" ? nil : 0.01)
end
next unless section == "OLED buttons"
config.parameters["button_cap_size"] = pair(
"button cap width,length", config.parameters["button_cap_size"]
)
config.parameters["button_flange_size"] = pair(
"button flange width,length", config.parameters["button_flange_size"]
)
end
end
def agreement(prompt, default)
ui.agree("#{prompt} (y/n) ") { |question| question.default = default ? "yes" : "no" }
end
def required_text(prompt)
loop do
value = ui.ask("#{prompt}: ").to_s.strip
return value unless value.empty?
ui.say(ui.color("A value is required.", :red))
end
end
def text(prompt, default)
ui.ask("#{prompt}: ") { |question| question.default = default }.to_s.strip
end
def ask_slug(default)
loop do
value = text("Directory slug", default)
return value if value.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
ui.say(ui.color("Use lowercase letters, numbers, and single hyphens.", :red))
end
end
def integer(prompt, default, range)
loop do
value = ui.ask("#{prompt}: ", Integer) { |question| question.default = default }
return value if range.cover?(value)
ui.say(ui.color("Enter a value from #{range.begin} through #{range.end}.", :red))
end
end
def number(prompt, default, min: nil, max: nil)
loop do
value = ui.ask("#{prompt}: ", Float) { |question| question.default = default }.to_f
return value if (min.nil? || value >= min) && (max.nil? || value <= max)
bounds = [min && "at least #{min}", max && "at most #{max}"].compact.join(" and ")
ui.say(ui.color("Enter a value #{bounds}.", :red))
end
end
def pair(prompt, default)
loop do
raw = text(prompt, default.join(","))
values = raw.split(",").map { |item| Float(item.strip) }
return values if values.length == 2 && values.all?(&:positive?)
ui.say(ui.color("Enter two positive comma-separated dimensions.", :red))
rescue ArgumentError
ui.say(ui.color("Enter two numeric comma-separated dimensions.", :red))
end
end
def slugify(name)
name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-?\z/, "").yield_self do |slug|
slug.empty? ? "feather-case" : slug
end
end
def yes_no(value) = value ? "yes" : "no"
end
end
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
+90
View File
@@ -0,0 +1,90 @@
# frozen_string_literal: true
require_relative "test_helper"
class ConfigTest < Minitest::Test
def config
FeatherCaseWizard::Config.create(name: "Bench GPS", slug: "bench-gps")
end
def test_default_case_has_expected_parts_and_dimensions
assert_equal %w[body lid], config.printable_parts
assert_in_delta 60.8, config.dimensions["length"], 0.01
assert_in_delta 41.66, config.dimensions["width"], 0.01
assert_equal ["3mf"], config.outputs["formats"]
end
def test_oled_buttons_and_selected_coupons_are_printable
subject = config
subject.parameters["top_profile"] = "oled128x64"
subject.parameters["button_a"] = false
subject.parameters["button_b"] = true
subject.outputs["coupons"] = %w[fit_coupon]
assert_equal %w[body lid buttons fit_coupon], subject.printable_parts
end
def test_wall_local_cutouts_map_to_model_coordinates
subject = config
length = subject.dimensions["length"]
width = subject.dimensions["width"]
subject.data["cutouts"] = [
cutout("front", 12.0),
cutout("rear", 13.0),
cutout("left", 14.0),
cutout("right", 15.0)
]
result = subject.scad_cutouts
assert_in_delta width - 12.0, result[0][2], 0.001
assert_in_delta 13.0, result[1][2], 0.001
assert_in_delta 14.0, result[2][2], 0.001
assert_in_delta length - 15.0, result[3][2], 0.001
end
def test_cutout_cannot_intersect_floor
subject = config
subject.data["cutouts"] = [cutout("rear", 12.0).merge("z" => 2.0)]
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/floor/, error.message)
end
def test_yaml_round_trip_preserves_resolved_cutouts
subject = config
subject.data["cutouts"] = [cutout("left", 20.0)]
Dir.mktmpdir do |directory|
path = File.join(directory, "case.yml")
File.write(path, subject.to_yaml)
loaded = FeatherCaseWizard::Config.load(path)
assert_equal subject.data["cutouts"], loaded.data["cutouts"]
assert_equal subject.scad_cutouts, loaded.data.dig("resolved", "extra_cutouts")
end
end
def test_unsafe_yaml_is_rejected
Dir.mktmpdir do |directory|
path = File.join(directory, "case.yml")
File.write(path, "--- !ruby/object:Object {}\n")
assert_raises(FeatherCaseWizard::ConfigError) do
FeatherCaseWizard::Config.load(path)
end
end
end
private
def cutout(face, horizontal)
{
"name" => "#{face}-cable",
"face" => face,
"shape" => "roundrect",
"horizontal" => horizontal,
"z" => 12.0,
"width" => 6.0,
"height" => 4.0,
"radius" => 1.0
}
end
end
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
require_relative "test_helper"
class PackageBuilderTest < Minitest::Test
def test_builds_self_contained_package_and_preserves_history
Dir.mktmpdir do |directory|
root = Pathname(directory)
tools = create_fake_tools(root)
model = root.join("model.scad")
renderer = root.join("renderer.py")
File.write(model, "cube([1,1,1]);\n")
File.write(renderer, "# test renderer\n")
config = FeatherCaseWizard::Config.create(name: "OLED Demo", slug: "oled-demo")
config.parameters["top_profile"] = "oled128x64"
config.outputs["formats"] = %w[3mf stl]
config.outputs["coupons"] = %w[fit_coupon]
destination = build(config, root.join("cases"), tools, model, renderer)
assert destination.join("case.yml").file?
assert destination.join("parameters.json").file?
assert destination.join("build.rb").executable?
%w[body lid buttons fit_coupon].each do |part|
assert destination.join("exports/stl/#{part}.stl").file?
assert destination.join("exports/3mf/#{part}.3mf").file?
assert destination.join("previews/#{part}_iso.png").file?
end
%w[iso front back left right top].each do |view|
assert destination.join("previews/assembly_#{view}.png").file?
end
config.parameters["button_reset"] = true
build(config, root.join("cases"), tools, model, renderer)
assert_equal 1, Dir.glob(destination.join("history/case-*.yml")).length
end
end
def test_failed_build_leaves_existing_package_untouched
Dir.mktmpdir do |directory|
root = Pathname(directory)
destination = root.join("cases/demo")
FileUtils.mkdir_p(destination)
File.write(destination.join("sentinel"), "keep")
model = root.join("model.scad")
renderer = root.join("renderer.py")
File.write(model, "cube(1);\n")
File.write(renderer, "# renderer\n")
failing = Class.new do
attr_reader :openscad, :blender
def initialize
@openscad = @blender = "/fake"
end
def check! = self
def run!(*)
raise FeatherCaseWizard::ToolError, "planned failure"
end
end.new
config = FeatherCaseWizard::Config.create(name: "Demo", slug: "demo")
assert_raises(FeatherCaseWizard::ToolError) do
FeatherCaseWizard::PackageBuilder.new(
config, root: root.join("cases"), toolchain: failing,
model_source: model, renderer_source: renderer
).build!
end
assert_equal "keep", destination.join("sentinel").read
end
end
private
def build(config, root, tools, model, renderer)
FeatherCaseWizard::PackageBuilder.new(
config, root: root, toolchain: tools,
model_source: model, renderer_source: renderer
).build!
end
def create_fake_tools(root)
bin = root.join("bin")
FileUtils.mkdir_p(bin)
openscad = bin.join("openscad")
blender = bin.join("blender")
File.write(openscad, <<~RUBY)
#!/usr/bin/env ruby
output = ARGV[ARGV.index("-o") + 1]
require "fileutils"
FileUtils.mkdir_p(File.dirname(output))
File.binwrite(output, "mesh")
RUBY
File.write(blender, <<~RUBY)
#!/usr/bin/env ruby
require "fileutils"
args = ARGV[(ARGV.index("--") + 1)..]
_mesh, output, basename, selected = args
views = selected ? selected.split(",") : %w[iso front back left right top]
FileUtils.mkdir_p(output)
views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") }
RUBY
FileUtils.chmod(0o755, [openscad, blender])
FeatherCaseWizard::Toolchain.new(
env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, "BLENDER" => blender.to_s}
)
end
end
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
require "minitest/autorun"
require "tmpdir"
require_relative "../lib/feather_case_wizard"
+30
View File
@@ -0,0 +1,30 @@
# frozen_string_literal: true
require_relative "test_helper"
class ToolchainTest < Minitest::Test
def test_reports_both_missing_dependencies
toolchain = FeatherCaseWizard::Toolchain.new(env: {"PATH" => ""})
error = assert_raises(FeatherCaseWizard::ToolError) { toolchain.check! }
assert_match(/OpenSCAD/, error.message)
assert_match(/Blender/, error.message)
assert_match(/apt-get/, error.message)
end
def test_environment_overrides_are_used
Dir.mktmpdir do |directory|
executable = File.join(directory, "tool")
File.write(executable, "#!/bin/sh\nexit 0\n")
FileUtils.chmod(0o755, executable)
toolchain = FeatherCaseWizard::Toolchain.new(
env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable}
)
assert_equal executable, toolchain.openscad
assert_equal executable, toolchain.blender
assert_same toolchain, toolchain.check!
end
end
end
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
require "stringio"
require_relative "test_helper"
class WizardTest < Minitest::Test
def test_minimal_new_case_flow_and_confirmation
input = StringIO.new(
"Demo Case\n" \
"\n" \
"1\n" \
"1\n" \
"\n" \
"n\n" \
"n\n" \
"n\n" \
"1\n" \
"n\n" \
"n\n" \
"n\n" \
"n\n" \
"y\n"
)
output = StringIO.new
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output))
config = wizard.create
assert wizard.confirm_build(config)
assert_equal "demo-case", config.slug
assert_equal ["3mf"], config.outputs["formats"]
assert_equal %w[body lid], config.printable_parts
end
end
+127
View File
@@ -0,0 +1,127 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()