Initial parametric Feather case generator
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
BUNDLE_PATH: "vendor/bundle"
|
||||||
|
|
||||||
+35
@@ -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/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "highline", "~> 3.1"
|
||||||
|
|
||||||
|
group :development do
|
||||||
|
gem "minitest", "~> 5.22"
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "../lib/feather_case_wizard"
|
||||||
|
|
||||||
|
exit FeatherCaseWizard::CLI.new.run
|
||||||
|
|
||||||
@@ -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.
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "fileutils"
|
||||||
|
require "json"
|
||||||
|
require "open3"
|
||||||
|
require "pathname"
|
||||||
|
require "tmpdir"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
ROOT = Pathname(__dir__).expand_path
|
||||||
|
|
||||||
|
def executable(env_name, command)
|
||||||
|
override = ENV[env_name]
|
||||||
|
return File.expand_path(override) if override && File.executable?(override)
|
||||||
|
|
||||||
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
||||||
|
candidate = File.join(directory, command)
|
||||||
|
return candidate if File.file?(candidate) && File.executable?(candidate)
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def literal(value)
|
||||||
|
case value
|
||||||
|
when String then JSON.generate(value)
|
||||||
|
when TrueClass then "true"
|
||||||
|
when FalseClass then "false"
|
||||||
|
when Numeric then value.to_s
|
||||||
|
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
|
||||||
|
else raise "Cannot convert #{value.inspect} to OpenSCAD"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run!(*command, env: {})
|
||||||
|
stdout, stderr, status = Open3.capture3(env, *command)
|
||||||
|
return if status.success?
|
||||||
|
|
||||||
|
warn [stdout, stderr].reject(&:empty?).join("\n")
|
||||||
|
raise "Command failed (#{status.exitstatus}): #{command.first}"
|
||||||
|
end
|
||||||
|
|
||||||
|
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
|
||||||
|
permitted_symbols: [], aliases: false)
|
||||||
|
openscad = executable("OPENSCAD", "openscad")
|
||||||
|
blender = executable("BLENDER", "blender")
|
||||||
|
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
|
||||||
|
abort "Blender not found; install it or set BLENDER" unless blender
|
||||||
|
|
||||||
|
parameters = config.fetch("parameters").merge(
|
||||||
|
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
|
||||||
|
)
|
||||||
|
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
|
||||||
|
buttons = %w[button_a button_b button_c button_reset]
|
||||||
|
if parameters["top_profile"] == "oled128x64" &&
|
||||||
|
buttons.any? { |name| parameters[name] }
|
||||||
|
parts << "buttons"
|
||||||
|
end
|
||||||
|
|
||||||
|
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
|
||||||
|
begin
|
||||||
|
exports = stage.join("exports")
|
||||||
|
previews = stage.join("previews")
|
||||||
|
scratch = stage.join("meshes")
|
||||||
|
FileUtils.mkdir_p([exports, previews, scratch])
|
||||||
|
model = ROOT.join("source/feather_case.scad")
|
||||||
|
renderer = ROOT.join("source/render_stl_previews.py")
|
||||||
|
|
||||||
|
export = lambda do |output, part, overrides = {}|
|
||||||
|
values = parameters.merge(overrides).merge("part" => part)
|
||||||
|
command = [openscad, "-o", output.to_s]
|
||||||
|
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
|
||||||
|
run!(*command, model.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
config.fetch("outputs").fetch("formats").each do |format|
|
||||||
|
directory = exports.join(format)
|
||||||
|
FileUtils.mkdir_p(directory)
|
||||||
|
parts.each do |part|
|
||||||
|
puts "Exporting #{part}.#{format}…"
|
||||||
|
export.call(directory.join("#{part}.#{format}"), part)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assembly = scratch.join("assembly.stl")
|
||||||
|
puts "Preparing assembly preview…"
|
||||||
|
export.call(assembly, "assembly",
|
||||||
|
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
|
||||||
|
"show_references" => false)
|
||||||
|
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
|
||||||
|
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||||
|
assembly.to_s, previews.to_s, "assembly", env: render_env)
|
||||||
|
parts.each do |part|
|
||||||
|
puts "Rendering #{part} preview…"
|
||||||
|
mesh = scratch.join("#{part}.stl")
|
||||||
|
export.call(mesh, part) unless mesh.file?
|
||||||
|
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||||
|
mesh.to_s, previews.to_s, part, "iso", env: render_env)
|
||||||
|
end
|
||||||
|
|
||||||
|
backup = ROOT.join(".build-old-#{Process.pid}")
|
||||||
|
FileUtils.mkdir_p(backup)
|
||||||
|
%w[exports previews].each do |name|
|
||||||
|
current = ROOT.join(name)
|
||||||
|
FileUtils.mv(current, backup.join(name)) if current.exist?
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
FileUtils.mv(exports, ROOT.join("exports"))
|
||||||
|
FileUtils.mv(previews, ROOT.join("previews"))
|
||||||
|
FileUtils.rm_rf(backup)
|
||||||
|
rescue StandardError
|
||||||
|
FileUtils.rm_rf(ROOT.join("exports"))
|
||||||
|
FileUtils.rm_rf(ROOT.join("previews"))
|
||||||
|
%w[exports previews].each do |name|
|
||||||
|
saved = backup.join(name)
|
||||||
|
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
|
||||||
|
end
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
puts "Rebuilt #{ROOT}"
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_rf(stage)
|
||||||
|
end
|
||||||
@@ -0,0 +1,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: []
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Render six inspection views of an STL using Blender.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
blender --background --python tools/render_stl_previews.py -- \
|
||||||
|
input.stl output-directory basename [comma-separated-views]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
args = sys.argv[sys.argv.index("--") + 1 :]
|
||||||
|
if len(args) not in (3, 4):
|
||||||
|
raise SystemExit(
|
||||||
|
"expected: input.stl output-directory basename [comma-separated-views]"
|
||||||
|
)
|
||||||
|
requested_views = args[3].split(",") if len(args) == 4 else None
|
||||||
|
return args[0], args[1], args[2], requested_views
|
||||||
|
|
||||||
|
|
||||||
|
def look_at(camera, target):
|
||||||
|
direction = Vector(target) - camera.location
|
||||||
|
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||||
|
|
||||||
|
|
||||||
|
def render_view(camera, center, span, name, direction, output_dir, basename):
|
||||||
|
distance = span * 2.2
|
||||||
|
camera.location = Vector(center) + Vector(direction).normalized() * distance
|
||||||
|
look_at(camera, center)
|
||||||
|
camera.data.type = "ORTHO"
|
||||||
|
camera.data.ortho_scale = span * 1.35
|
||||||
|
bpy.context.scene.render.filepath = os.path.join(
|
||||||
|
output_dir, f"{basename}_{name}.png"
|
||||||
|
)
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
input_path, output_dir, basename, requested_views = arguments()
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
|
||||||
|
model = bpy.context.selected_objects[0]
|
||||||
|
model.name = "Feather case"
|
||||||
|
|
||||||
|
material = bpy.data.materials.new("Case material")
|
||||||
|
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
|
||||||
|
material.metallic = 0.0
|
||||||
|
material.roughness = 0.45
|
||||||
|
model.data.materials.append(material)
|
||||||
|
|
||||||
|
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
|
||||||
|
mins = Vector([min(v[i] for v in corners) for i in range(3)])
|
||||||
|
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
|
||||||
|
center = (mins + maxs) / 2
|
||||||
|
span = max(maxs - mins)
|
||||||
|
|
||||||
|
world = bpy.data.worlds.new("Inspection world")
|
||||||
|
world.use_nodes = True
|
||||||
|
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
|
||||||
|
0.035,
|
||||||
|
0.04,
|
||||||
|
0.05,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
|
||||||
|
bpy.context.scene.world = world
|
||||||
|
|
||||||
|
camera_data = bpy.data.cameras.new("Camera")
|
||||||
|
camera = bpy.data.objects.new("Camera", camera_data)
|
||||||
|
bpy.context.collection.objects.link(camera)
|
||||||
|
bpy.context.scene.camera = camera
|
||||||
|
|
||||||
|
key_data = bpy.data.lights.new("Key", type="AREA")
|
||||||
|
key_data.energy = 900
|
||||||
|
key_data.shape = "DISK"
|
||||||
|
key_data.size = span
|
||||||
|
key = bpy.data.objects.new("Key", key_data)
|
||||||
|
key.location = center + Vector((span, -span, span * 1.5))
|
||||||
|
bpy.context.collection.objects.link(key)
|
||||||
|
|
||||||
|
fill_data = bpy.data.lights.new("Fill", type="AREA")
|
||||||
|
fill_data.energy = 500
|
||||||
|
fill_data.size = span
|
||||||
|
fill = bpy.data.objects.new("Fill", fill_data)
|
||||||
|
fill.location = center + Vector((-span, span, span))
|
||||||
|
bpy.context.collection.objects.link(fill)
|
||||||
|
|
||||||
|
scene = bpy.context.scene
|
||||||
|
scene.render.engine = "BLENDER_WORKBENCH"
|
||||||
|
scene.render.resolution_x = 800
|
||||||
|
scene.render.resolution_y = 600
|
||||||
|
scene.render.resolution_percentage = 100
|
||||||
|
scene.render.image_settings.file_format = "PNG"
|
||||||
|
scene.render.film_transparent = False
|
||||||
|
scene.display.shading.light = "STUDIO"
|
||||||
|
scene.display.shading.color_type = "MATERIAL"
|
||||||
|
scene.display.shading.show_shadows = True
|
||||||
|
scene.display.shading.show_cavity = True
|
||||||
|
scene.display.shading.cavity_type = "BOTH"
|
||||||
|
scene.display.shading.curvature_ridge_factor = 1.5
|
||||||
|
scene.display.shading.curvature_valley_factor = 1.2
|
||||||
|
|
||||||
|
views = {
|
||||||
|
"iso": (1, -1, 0.8),
|
||||||
|
"front": (-1, 0, 0),
|
||||||
|
"back": (1, 0, 0),
|
||||||
|
"left": (0, -1, 0),
|
||||||
|
"right": (0, 1, 0),
|
||||||
|
"top": (0, 0, 1),
|
||||||
|
}
|
||||||
|
selected = requested_views or list(views)
|
||||||
|
unknown = sorted(set(selected) - set(views))
|
||||||
|
if unknown:
|
||||||
|
raise SystemExit(f"unknown views: {', '.join(unknown)}")
|
||||||
|
for name in selected:
|
||||||
|
direction = views[name]
|
||||||
|
render_view(camera, center, span, name, direction, output_dir, basename)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,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));
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "fileutils"
|
||||||
|
require "json"
|
||||||
|
require "open3"
|
||||||
|
require "pathname"
|
||||||
|
require "tmpdir"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
ROOT = Pathname(__dir__).expand_path
|
||||||
|
|
||||||
|
def executable(env_name, command)
|
||||||
|
override = ENV[env_name]
|
||||||
|
return File.expand_path(override) if override && File.executable?(override)
|
||||||
|
|
||||||
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
||||||
|
candidate = File.join(directory, command)
|
||||||
|
return candidate if File.file?(candidate) && File.executable?(candidate)
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def literal(value)
|
||||||
|
case value
|
||||||
|
when String then JSON.generate(value)
|
||||||
|
when TrueClass then "true"
|
||||||
|
when FalseClass then "false"
|
||||||
|
when Numeric then value.to_s
|
||||||
|
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
|
||||||
|
else raise "Cannot convert #{value.inspect} to OpenSCAD"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run!(*command, env: {})
|
||||||
|
stdout, stderr, status = Open3.capture3(env, *command)
|
||||||
|
return if status.success?
|
||||||
|
|
||||||
|
warn [stdout, stderr].reject(&:empty?).join("\n")
|
||||||
|
raise "Command failed (#{status.exitstatus}): #{command.first}"
|
||||||
|
end
|
||||||
|
|
||||||
|
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
|
||||||
|
permitted_symbols: [], aliases: false)
|
||||||
|
openscad = executable("OPENSCAD", "openscad")
|
||||||
|
blender = executable("BLENDER", "blender")
|
||||||
|
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
|
||||||
|
abort "Blender not found; install it or set BLENDER" unless blender
|
||||||
|
|
||||||
|
parameters = config.fetch("parameters").merge(
|
||||||
|
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
|
||||||
|
)
|
||||||
|
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
|
||||||
|
buttons = %w[button_a button_b button_c button_reset]
|
||||||
|
if parameters["top_profile"] == "oled128x64" &&
|
||||||
|
buttons.any? { |name| parameters[name] }
|
||||||
|
parts << "buttons"
|
||||||
|
end
|
||||||
|
|
||||||
|
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
|
||||||
|
begin
|
||||||
|
exports = stage.join("exports")
|
||||||
|
previews = stage.join("previews")
|
||||||
|
scratch = stage.join("meshes")
|
||||||
|
FileUtils.mkdir_p([exports, previews, scratch])
|
||||||
|
model = ROOT.join("source/feather_case.scad")
|
||||||
|
renderer = ROOT.join("source/render_stl_previews.py")
|
||||||
|
|
||||||
|
export = lambda do |output, part, overrides = {}|
|
||||||
|
values = parameters.merge(overrides).merge("part" => part)
|
||||||
|
command = [openscad, "-o", output.to_s]
|
||||||
|
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
|
||||||
|
run!(*command, model.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
config.fetch("outputs").fetch("formats").each do |format|
|
||||||
|
directory = exports.join(format)
|
||||||
|
FileUtils.mkdir_p(directory)
|
||||||
|
parts.each do |part|
|
||||||
|
puts "Exporting #{part}.#{format}…"
|
||||||
|
export.call(directory.join("#{part}.#{format}"), part)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assembly = scratch.join("assembly.stl")
|
||||||
|
puts "Preparing assembly preview…"
|
||||||
|
export.call(assembly, "assembly",
|
||||||
|
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
|
||||||
|
"show_references" => false)
|
||||||
|
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
|
||||||
|
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||||
|
assembly.to_s, previews.to_s, "assembly", env: render_env)
|
||||||
|
parts.each do |part|
|
||||||
|
puts "Rendering #{part} preview…"
|
||||||
|
mesh = scratch.join("#{part}.stl")
|
||||||
|
export.call(mesh, part) unless mesh.file?
|
||||||
|
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
|
||||||
|
mesh.to_s, previews.to_s, part, "iso", env: render_env)
|
||||||
|
end
|
||||||
|
|
||||||
|
backup = ROOT.join(".build-old-#{Process.pid}")
|
||||||
|
FileUtils.mkdir_p(backup)
|
||||||
|
%w[exports previews].each do |name|
|
||||||
|
current = ROOT.join(name)
|
||||||
|
FileUtils.mv(current, backup.join(name)) if current.exist?
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
FileUtils.mv(exports, ROOT.join("exports"))
|
||||||
|
FileUtils.mv(previews, ROOT.join("previews"))
|
||||||
|
FileUtils.rm_rf(backup)
|
||||||
|
rescue StandardError
|
||||||
|
FileUtils.rm_rf(ROOT.join("exports"))
|
||||||
|
FileUtils.rm_rf(ROOT.join("previews"))
|
||||||
|
%w[exports previews].each do |name|
|
||||||
|
saved = backup.join(name)
|
||||||
|
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
|
||||||
|
end
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
puts "Rebuilt #{ROOT}"
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_rf(stage)
|
||||||
|
end
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "minitest/autorun"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
require_relative "../lib/feather_case_wizard"
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user