Compare commits

...
3 Commits
Author SHA1 Message Date
Nick Estes f3c46275c9 small mod 2026-08-11 12:08:11 -07:00
Nick Estes 06cb7f0c4d case updates 2026-08-06 18:42:21 -07:00
Nick Estes 69d9b956e6 tweak the fit of the parts 2026-08-04 12:27:31 -07:00
49 changed files with 3894 additions and 2069 deletions
+38 -14
View File
@@ -28,15 +28,15 @@ 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:
OpenSCAD, Blender, and Python 3 must be available on `PATH`. On Ubuntu:
```bash
sudo apt-get install openscad blender
sudo apt-get install openscad blender python3
```
Set `OPENSCAD=/path/to/openscad` or `BLENDER=/path/to/blender` when either
program is installed elsewhere. The wizard checks both tools before replacing
an existing package.
Set `OPENSCAD=/path/to/openscad`, `BLENDER=/path/to/blender`, or
`PYTHON=/path/to/python3` when a program is installed elsewhere. The wizard
checks all three tools before replacing an existing package.
The wizard can add clearance beyond either short end of the Feather PCB for
right-angle headers, plug housings, and wire bends. Front clearance moves the
@@ -132,6 +132,25 @@ remains based on the taller switches plus `oled_face_gap`, placing the display
face about 1.7 mm below the lid's inner plane. The default 0.7 mm captive-button
contact extension spans most of the 0.8 mm switch-to-lid gap.
When the battery compartment and tripod insert are both enabled, the generator
adds board-side clearance to keep the insert centered across the case width and
moves it 5 mm toward the USB wall to clear the nearest Feather standoff. The
battery partition defaults to three 0.6 mm extrusion lines and can be adjusted
with `battery_partition_thickness`.
## Two-color lid artwork
Set `lid_label` to a device name and/or enable `lid_logo_enabled` to add flush
second-color artwork to non-OLED lids. Choose `lid_art_orientation` from
`usb_left`, `usb_front`, `usb_right`, or `usb_rear`; it describes where the USB
edge sits when the exterior artwork is upright. OLED profiles reject lid
artwork until a window/button-safe layout is defined.
Generated artwork packages include separate `lid_base` and `lid_accent` meshes,
a complete single-color `lid`, and `lid_multicolor.3mf`. The multicolor file is
one aligned multipart object with its base assigned to filament 1 and its logo
and title assigned to filament 2. Print the lid exterior-face-down.
## Custom wire egress
`extra_cutouts` accepts any number of entries:
@@ -164,9 +183,11 @@ points.
1. Export and print `part="insert_coupon"` in the intended material.
2. Test both M2.5 and 1/4-20 inserts in the five stepped holes.
3. Set `m25_insert_hole_diameter` and `tripod_insert_hole_diameter` to the best
coupon values.
4. Print `part="switch_coupon"` and choose the best hole for the actual DMWD
switch batch.
coupon values. These parameters are the finished pilot diameters in the
model; `hole_compensation` is not added to heat-set pockets. The coupon uses
the same tapered pocket geometry as the cases.
4. Print `part="switch_coupon"` and choose the best rounded-rectangle opening
for the actual KCD1-101 rocker-switch batch.
5. Print `part="fit_coupon"` and tune `fit_clearance` before committing to the
complete lid and body.
@@ -180,13 +201,16 @@ temperature control, and the same extrusion settings intended for the case.
opening faces the exterior bottom.
2. Heat-set four M2.5 inserts into the board standoffs.
3. Fasten the ESP32 Feather V2, then install the stacking headers and Wings.
4. For battery builds, place the protected 69 x 18 mm pack in the cradle, route
its lead through the partition notch, and mount the latching switch in the
battery-side wall.
5. Heat-set the four lid inserts downward into the corner bosses.
6. For OLED builds, insert each enabled printed plunger through the lid from
4. For battery builds, place the protected 69 x 18 mm pack in the cradle and
route its lead through the combined front partition/JST opening and beneath
the Feather PCB.
5. Snap the KCD1-101 rocker switch into the rear lid opening, connect its bent
quick-connect terminals or low-profile wiring, and lower the wired lid onto
the case.
6. Heat-set the four lid inserts downward into the corner bosses.
7. For OLED builds, insert each enabled printed plunger through the lid from
the inside. The elongated flange stays in the internal recess.
7. Engage the lid rim and fasten four standard M2.5 socket-head screws into the
8. Engage the lid rim and fasten four standard M2.5 socket-head screws into the
2.5 mm-deep counterbores.
Confirm the USB plug, switch, battery, insert, and lid fits without force before
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg584"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="NME-fixed.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview586"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.914906"
inkscape:cx="1166.24"
inkscape:cy="477.6447"
inkscape:window-width="3440"
inkscape:window-height="1390"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs581" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g772">
<path
id="path2"
d="m 97.576583,26.288608 c -0.51788,0.06491 -2.361848,0.291394 -4.044598,0.485422 -15.62735,1.747308 -32.128529,8.73619 -44.81195,18.960393 -9.803341,7.926917 -17.342201,17.309748 -23.360236,29.151792 -2.879725,5.629978 -3.170767,6.632927 -1.941336,6.535912 0.388054,-0.03245 2.556227,-0.06491 4.788604,-0.03245 l 4.0767,0.03245 4.820708,5.662083 c 2.653242,3.106208 4.918075,5.662083 5.01509,5.662083 0.09737,0 0.291394,-1.035402 0.420862,-2.297287 0.09701,-1.261888 0.517525,-3.688291 0.87348,-5.403146 3.106208,-14.559843 9.415285,-26.692928 19.186524,-36.852576 6.276975,-6.568017 12.521494,-11.098035 21.030846,-15.304204 5.597525,-2.749903 9.933164,-4.367742 15.562791,-5.823656 l 3.559172,-0.938388 -2.10291,0.03245 c -1.164868,0.03246 -2.523768,0.09701 -3.073747,0.129117"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path3"
d="m 169.08146,36.027745 c -26.62837,2.232377 -57.46291,14.33336 -85.90315,33.68181 -5.85611,3.979685 -15.530335,11.130137 -15.530335,11.453635 0,0.16157 1.3589,0.258937 3.008842,0.258937 h 2.976737 l 3.7211,-2.78271 c 4.917723,-3.688644 13.977408,-9.738784 19.574581,-13.071475 5.759445,-3.46216 18.668995,-9.997721 24.751595,-12.55395 21.90467,-9.188802 42.51501,-12.747625 55.16562,-9.576858 5.04754,1.261888 9.35073,4.432654 11.09769,8.120946 0.80891,1.747308 0.87381,2.167818 0.87381,6.406443 0,4.270727 -0.0649,4.691592 -1.06748,7.603419 -2.65325,7.700433 -8.70374,17.439568 -16.8575,27.178351 -1.26188,1.520825 -2.29693,2.814815 -2.29693,2.91183 0,0.09737 4.7886,0.129468 10.67718,0.06491 l 10.67715,-0.09701 V 90.772856 85.91934 l 2.07081,-2.814815 C 196.58332,76.860006 200.59546,69.677098 202.56925,64.11203 208.1015,48.484325 201.79243,38.616074 184.77337,36.286682 181.6996,35.86582 172.7373,35.704247 169.08146,36.027745"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path4"
d="m 19.11528,111.8358 v 26.20751 h 11.000669 v -35.91419 l 1.552926,1.71486 c 0.841375,0.93839 4.59458,5.27402 8.347782,9.64212 3.753202,4.33564 9.350727,10.80664 12.456583,14.36582 3.106208,3.55882 6.341533,7.31202 7.215362,8.34742 l 1.552929,1.84397 h 5.629979 5.62963 V 111.8358 85.628298 H 66.8066 61.079958 l 0.161573,17.957092 c 0.06491,9.86825 0.06491,17.95709 -0.03246,17.95709 -0.09666,-0.0324 -0.711552,-0.67945 -1.326443,-1.45591 -0.61454,-0.77646 -4.27073,-5.01509 -8.088842,-9.41564 C 40.857677,98.052425 37.978307,94.720085 33.998622,90.060598 l -3.7211,-4.367742 -5.565071,-0.03246 -5.597171,-0.0321 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path5"
d="m 77.030804,111.8358 v 26.20751 h 11.001023 v -35.04035 l 6.7945,9.02687 c 3.720748,4.98299 8.023933,10.67753 9.544753,12.68342 l 2.81481,3.65584 9.5123,-12.68307 9.5123,-12.68306 0.0649,13.65391 0.097,13.65355 3.85022,-3.26743 c 2.13571,-1.81186 4.56212,-3.91512 5.43595,-4.69159 l 1.55293,-1.39135 V 103.294 85.628298 h -5.56507 l -5.59753,0.0321 -9.38283,12.456935 c -5.14456,6.826957 -9.4802,12.392027 -9.57721,12.359567 -0.19438,-0.097 -4.4002,-5.56507 -14.33336,-18.798467 L 88.1934,85.628298 H 82.628329 77.030804 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path6"
d="m 170.4079,106.88563 -12.58605,0.097 -6.66538,6.56802 c -21.32189,20.93383 -39.11741,34.16723 -59.727393,44.39108 -28.796192,14.30126 -53.936193,18.6369 -68.334112,11.77749 -2.976739,-1.42381 -6.115402,-4.10916 -7.441848,-6.34154 -2.78271,-4.7565 -2.750253,-12.03642 0.06491,-18.41006 1.002946,-2.29729 1.002946,-2.3943 -0.161925,-2.3943 h -0.93839 l -0.09701,-2.84727 -0.09701,-2.81517 -1.553282,2.58869 c -3.8177601,6.40609 -6.9236165,14.00986 -7.8295498,19.18652 -1.3267981,7.50641 0.1615731,13.07183 4.6912397,17.53659 2.6856951,2.65289 5.0150871,4.04459 8.9947751,5.37068 8.088841,2.6857 18.863026,3.0413 30.608055,0.93874 l 4.238273,-0.74436 3.882673,2.39431 c 14.689312,8.96232 30.252104,13.39497 46.947304,13.39497 14.49529,0 26.66083,-2.81482 39.53828,-9.12389 14.59194,-7.18326 27.33992,-18.70146 35.91418,-32.45238 1.97345,-3.17112 5.92103,-10.80664 6.50312,-12.52185 l 0.32385,-1.0675 -0.54999,0.80892 c -8.60672,12.42447 -18.15112,20.90137 -30.5756,27.08133 -18.15112,9.05934 -39.14987,10.90366 -57.980434,5.04719 -3.268133,-1.00294 -10.839098,-4.01178 -10.806642,-4.27072 0,-0.097 3.461809,-1.90888 7.667977,-3.97969 4.206169,-2.0708 9.706679,-4.95053 12.197999,-6.43855 9.93281,-5.824 22.4222,-14.5923 31.70801,-22.26027 l 3.55918,-2.94464 0.097,1.29434 0.097,1.29399 h 23.94302 23.91021 v -5.01509 -5.01473 h -18.44215 -18.44287 v -1.22943 c 0,-1.16488 0.25929,-1.48873 4.49756,-5.50052 l 4.49721,-4.27072 h 10.54806 l 10.54769,-0.0325 v -5.17701 c 0,-2.81482 -0.0324,-5.11175 -0.0646,-5.07965 -0.0649,0.0646 -5.75944,0.12947 -12.68341,0.16193"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path7"
d="m 34.484042,128.23997 v 14.17144 l -6.018035,0.16192 -6.01839,0.16157 1.326798,2.91218 c 3.559175,7.79745 8.703382,15.82173 13.912496,21.67785 l 1.423812,1.58538 4.691592,-0.38806 c 5.079645,-0.42086 9.609312,-1.19732 15.497879,-2.6857 3.429706,-0.84102 11.518548,-3.39725 11.906604,-3.75285 0.06491,-0.0649 -0.582438,-0.74471 -1.455915,-1.48872 -9.544756,-7.95937 -17.083264,-18.15112 -21.80731,-29.44318 -1.455914,-3.39725 -1.649941,-3.68829 -7.312379,-10.25631 -3.202869,-3.7211 -5.888564,-6.7624 -5.985579,-6.7624 -0.09701,-0.0324 -0.161573,6.34154 -0.161573,14.10688"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<g
id="g475"
transform="matrix(0.14274222,0,0,0.14274222,123.03997,38.158339)">
<path
d="m 134.23594,367.14576 v 34.66667 h 69.06667 c 37.86666,0 71.06666,-0.4 73.86666,-0.8 4.4,-0.8 7.2,-3.73333 31.33333,-32.8 14.53334,-17.46667 26.53334,-32.4 26.8,-32.93333 0.26667,-0.66667 -0.93333,-1.46667 -2.66666,-2 -1.73334,-0.4 -47.06667,-0.8 -100.66667,-0.8 h -97.73333 z"
id="path251"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
<path
d="m 134.90261,432.74576 c -0.4,0.93334 -0.53333,27.2 -0.4,58.4 l 0.4,56.53333 38.4,-37.06666 38.26666,-37.06667 -0.26666,-20.93333 -0.4,-20.8 -37.73334,-0.4 c -29.46666,-0.26667 -37.86666,0 -38.26666,1.33333 z"
id="path253"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

-25
View File
@@ -1,25 +0,0 @@
# Attitude IMU - Battery
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 35.8 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-imu-battery source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-25
View File
@@ -1,25 +0,0 @@
# Attitude IMU
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: no
- 1/4-20 insert: yes
- Approximate exterior: 60.8 × 41.7 × 35.8 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-imu source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-69
View File
@@ -1,69 +0,0 @@
{
"parameterSets": {
"attitude-imu": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "12.25",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "6.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_display_height": "1.5",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "false",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
"insert_melt_relief": "0.8",
"wall_thickness": "2.4",
"floor_thickness": "2.4",
"lid_thickness": "3.2",
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"fileFormatVersion": "1"
}
-872
View File
@@ -1,872 +0,0 @@
/*
* Parametric enclosure for the Adafruit ESP32 Feather V2 and FeatherWings.
*
* Coordinate system:
* X: USB-C wall (0) toward the rear of the case
* Y: non-battery side (0) toward the battery/JST side
* Z: outside bottom (0) upward
*
* Board coordinates are derived from Adafruit's Eagle PCB files:
* https://github.com/adafruit/Adafruit-ESP32-Feather-V2-PCB
* https://github.com/adafruit/Adafruit-OLED-FeatherWing-PCB
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
/* [Stack] */
top_profile = "generic"; // [generic,gps,oled128x64]
wing_count = 1; // [1:1:8]
wing_pitch = 12.25; // [7:0.1:16]
stack_height_override = 0; // [0:0.1:100]
generic_component_height = 3; // [0:0.1:30]
generic_top_gap = 2; // [0.5:0.1:10]
gps_antenna_height = 6;
gps_top_gap = 5;
oled_component_height = 2.4;
oled_display_height = 1.5;
oled_face_gap = 0.8;
/* [OLED controls] */
button_a = true;
button_b = true;
button_c = true;
button_reset = false;
button_projection = 1.5; // [0.5:0.1:3]
button_cap_size = [5, 3.2];
button_flange_size = [6.6, 3.6];
button_flange_thickness = 0.9;
button_travel = 0.5;
button_contact_extension = 0.7;
/* [Battery and switch] */
battery_enabled = false;
battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
insert_melt_relief = 0.8;
/* [Shell and fit] */
wall_thickness = 2.4;
floor_thickness = 2.4;
lid_thickness = 3.2;
corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
// face: front, rear, left, right. u is Y on front/rear and X on left/right.
// shape: circle, rect, roundrect, slot. Circle uses width as its diameter.
extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
// Adafruit Feather mechanical reference, millimetres.
feather_length = 50.8;
feather_width = 22.86;
board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
oled_visible_size = [30, 15.3];
oled_button_a_xy = [4.318, 15.875];
oled_button_b_xy = [4.318, 11.43];
oled_button_c_xy = [4.318, 6.985];
oled_button_reset_xy = [7.366, 20.828];
effective_floor = tripod_insert_enabled
? max(floor_thickness, tripod_insert_length + insert_melt_relief + 1.2)
: floor_thickness;
effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
? board_top_z + stack_height_override
: computed_top_pcb_z;
profile_component_height =
top_profile == "gps" ? gps_antenna_height :
top_profile == "oled128x64" ? oled_component_height :
generic_component_height;
profile_top_gap =
top_profile == "gps" ? gps_top_gap :
top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap;
partition_y = board_origin_y + feather_width + 1;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance
+ battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
case_width_plain = board_origin_y + feather_width
+ board_side_clearance + wall_thickness;
case_width_battery = battery_center_y + battery_diameter / 2
+ battery_radial_clearance + wall_thickness;
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
wall_thickness + lid_boss_r - lid_boss_wall_overlap
],
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
],
[
case_length - wall_thickness - lid_boss_r + lid_boss_wall_overlap,
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
function clamp(v, lo, hi) = min(max(v, lo), hi);
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
hull() {
for (x = [r, size[0] - r], y = [r, size[1] - r])
translate([x, y]) circle(r = r);
}
}
module centered_rounded_rect_2d(size, radius) {
translate([-size[0] / 2, -size[1] / 2])
rounded_rect_2d(size, radius);
}
module centered_rounded_prism(size, height, radius) {
linear_extrude(height = height)
centered_rounded_rect_2d(size, radius);
}
module rounded_box(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - insert_entry_chamfer])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
);
} else {
translate([0, 0, entry_z - eps])
cylinder(h = depth + eps, d = bore_d);
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d2 = bore_d
);
}
}
module face_rounded_cutout(face, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2);
depth = wall_thickness + 2 * eps;
if (face == "front" || face == "rear") {
x0 = face == "front" ? -eps : case_length - wall_thickness - eps;
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x0, yy, zz])
rotate([0, 90, 0]) cylinder(h = depth, r = r);
} else if (face == "left" || face == "right") {
y0 = face == "left" ? -eps : case_width - wall_thickness - eps;
hull()
for (xx = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([xx, y0, zz])
rotate([-90, 0, 0]) cylinder(h = depth, r = r);
}
}
module face_circle_cutout(face, u, z, diameter) {
depth = wall_thickness + 2 * eps;
if (face == "front")
translate([-eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "rear")
translate([case_length - wall_thickness - eps, u, z])
rotate([0, 90, 0]) cylinder(h = depth, d = diameter);
else if (face == "left")
translate([u, -eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
else if (face == "right")
translate([u, case_width - wall_thickness - eps, z])
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
u = entry[2];
z = entry[3];
width = entry[4];
height = len(entry) > 5 ? entry[5] : width;
radius = len(entry) > 6 ? entry[6] : min(width, height) / 2;
assert(face == "front" || face == "rear"
|| face == "left" || face == "right", "Invalid cutout face");
assert(shape == "circle" || shape == "rect"
|| shape == "roundrect" || shape == "slot", "Invalid cutout shape");
assert(z - height / 2 >= effective_floor,
"Cutout intersects the floor");
assert(z + height / 2 <= body_height - lid_lip_depth,
"Cutout intersects the lid locating lip");
assert(
(face == "front" || face == "rear")
? u - width / 2 >= 0 && u + width / 2 <= case_width
: u - width / 2 >= 0 && u + width / 2 <= case_length,
"Cutout extends beyond its wall"
);
if (shape == "circle")
face_circle_cutout(face, u, z, width);
else
face_rounded_cutout(
face, u, z, width, height,
shape == "rect" ? 0.02 :
shape == "slot" ? min(width, height) / 2 : radius
);
}
module board_standoffs() {
for (mx = mount_x, my = mount_y)
translate([
board_origin_x + mx,
board_origin_y + my,
effective_floor - eps
])
cylinder(
h = effective_board_clearance + eps,
d = m25_boss_diameter
);
}
module lid_bosses() {
for (p = lid_boss_xy)
translate([p[0], p[1], effective_floor - eps])
cylinder(
h = body_height - effective_floor + eps,
d = m25_boss_diameter
);
}
module battery_cradle() {
partition_height = min(
body_height - lid_lip_depth - 0.5,
battery_center_z + battery_diameter / 2 + 1.5
);
rib_width = 2.4;
rib_positions = [
battery_start_x + 4,
(battery_start_x + battery_end_x) / 2,
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
}
// Three low curved ribs cradle the protected cylindrical pack.
for (x = rib_positions)
difference() {
translate([
x - rib_width / 2,
battery_center_y - battery_diameter / 2
- battery_radial_clearance,
effective_floor - eps
])
cube([
rib_width,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + battery_radial_clearance + eps
]);
translate([x - rib_width, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(
h = rib_width * 2,
d = battery_diameter + 2 * battery_radial_clearance
);
}
// End stops remain below the cell center so the lid can remove vertically.
for (x = [
battery_start_x - battery_end_clearance - wall_thickness,
battery_end_x + battery_end_clearance
])
translate([
x,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
union() {
difference() {
rounded_box([case_length, case_width, body_height], corner_radius);
translate([wall_thickness, wall_thickness, effective_floor])
rounded_box([
case_length - 2 * wall_thickness,
case_width - 2 * wall_thickness,
body_height - effective_floor + eps
], max(0.6, corner_radius - wall_thickness));
}
board_standoffs();
lid_bosses();
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
}
}
module body_cutouts() {
// Board mounting insert pockets, loaded from above before the Feather.
for (mx = mount_x, my = mount_y)
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
// Lid insert pockets, loaded from the top of the body.
for (p = lid_boss_xy)
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
d = tripod_thread_clearance_diameter + hole_compensation
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
}
}
for (entry = extra_cutouts)
custom_wall_cutout(entry);
}
module body() {
difference() {
body_positive();
body_cutouts();
}
}
module lid_plate_positive() {
union() {
rounded_box([case_length, case_width, lid_thickness], corner_radius);
// Inset rim: exported pointing upward so the outer lid face prints flat.
translate([
wall_thickness + fit_clearance,
wall_thickness + fit_clearance,
lid_thickness
])
linear_extrude(height = lid_lip_depth)
difference() {
rounded_rect_2d([
case_length - 2 * (wall_thickness + fit_clearance),
case_width - 2 * (wall_thickness + fit_clearance)
], max(0.6, corner_radius - wall_thickness));
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
case_length - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
),
case_width - 2 * (
wall_thickness + fit_clearance
+ lid_lip_thickness
)
], max(0.5, corner_radius - wall_thickness
- lid_lip_thickness));
}
}
}
module beveled_oled_window() {
inner_size = oled_visible_size;
outer_size = [oled_visible_size[0] + 3, oled_visible_size[1] + 3];
center = [
board_origin_x + oled_visible_min[0] + oled_visible_size[0] / 2,
board_origin_y + oled_visible_min[1] + oled_visible_size[1] / 2
];
hull() {
translate([center[0], center[1], -eps])
linear_extrude(height = eps)
centered_rounded_rect_2d(outer_size, 1.5);
translate([center[0], center[1], lid_thickness - eps])
linear_extrude(height = 2 * eps)
centered_rounded_rect_2d(inner_size, 0.8);
}
}
module oled_button_lid_cutout(xy) {
center = [board_origin_x + xy[0], board_origin_y + xy[1]];
shaft_size = [
button_cap_size[0] + fit_clearance * 2,
button_cap_size[1] + fit_clearance * 2
];
recess_size = [
button_flange_size[0] + fit_clearance * 2,
button_flange_size[1] + fit_clearance * 2
];
recess_depth = button_flange_thickness + button_travel;
translate([center[0], center[1], -eps])
centered_rounded_prism(
shaft_size,
lid_thickness + 2 * eps,
shaft_size[1] / 2
);
translate([center[0], center[1], lid_thickness - recess_depth])
centered_rounded_prism(
recess_size,
recess_depth + lid_lip_depth + eps,
recess_size[1] / 2
);
}
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
d = m25_screw_clearance + hole_compensation
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_screw_head_height + eps,
d = lid_screw_head_diameter + hole_compensation
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
if (button_b) oled_button_lid_cutout(oled_button_b_xy);
if (button_c) oled_button_lid_cutout(oled_button_c_xy);
if (button_reset) oled_button_lid_cutout(oled_button_reset_xy);
}
}
module lid() {
difference() {
lid_plate_positive();
lid_cutouts();
}
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
// Print outer cap face down. The flange and contact nub build upward.
centered_rounded_prism(
button_cap_size,
stem_height,
button_cap_size[1] / 2
);
translate([0, 0, stem_height])
centered_rounded_prism(
button_flange_size,
button_flange_thickness,
button_flange_size[1] / 2
);
translate([0, 0, stem_height + button_flange_thickness])
cylinder(h = button_contact_extension, d = 3);
}
module buttons() {
enabled = [
button_a,
button_b,
button_c,
button_reset
];
count_enabled = len([for (v = enabled) if (v) 1]);
if (count_enabled == 0)
echo("No OLED buttons are enabled.");
for (i = [0 : len(enabled) - 1])
if (enabled[i])
translate([i * (button_flange_size[0] + 3), 0, 0])
one_oled_button();
}
module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
board_origin_x,
board_origin_y,
board_top_z + i * wing_pitch - board_thickness
])
rounded_box(
[feather_length, feather_width, board_thickness], 2.54
);
}
module reference_top_feature() {
if (top_profile == "gps")
color([0.92, 0.78, 0.35, 0.8])
translate([
board_origin_x + 31,
board_origin_y + (feather_width - 15) / 2,
top_pcb_z
])
cube([15, 15, gps_antenna_height]);
else if (top_profile == "oled128x64") {
color([0.12, 0.12, 0.12, 0.9])
translate([
board_origin_x + oled_visible_min[0],
board_origin_y + oled_visible_min[1],
top_pcb_z
])
cube([
oled_visible_size[0],
oled_visible_size[1],
oled_display_height
]);
color([0.6, 0.6, 0.6, 0.9])
for (xy = [
oled_button_a_xy,
oled_button_b_xy,
oled_button_c_xy,
oled_button_reset_xy
])
translate([
board_origin_x + xy[0],
board_origin_y + xy[1],
top_pcb_z
])
cylinder(h = 1.6, d = 2.1);
} else
color([0.3, 0.65, 0.35, 0.6])
translate([
board_origin_x + 10,
board_origin_y + 4,
top_pcb_z
])
cube([
feather_length - 20,
feather_width - 8,
generic_component_height
]);
}
module reference_battery_and_switch() {
if (battery_enabled) {
color([0.35, 0.18, 0.55, 0.75])
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
}
}
module assembly() {
color([0.82, 0.84, 0.88, 1]) body();
if ($preview && show_references) {
reference_board();
reference_top_feature();
reference_battery_and_switch();
}
// Printable lid is mirrored into its installed orientation and exploded up.
color([0.92, 0.92, 0.95, 0.88])
translate([0, case_width, body_height + lid_thickness + exploded_view])
rotate([180, 0, 0])
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
tripod_insert_length + insert_melt_relief + 1.2
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
}
}
}
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
);
}
}
module fit_coupon() {
segment_length = 34;
base_width = 16;
base_height = 6;
cavity_size = [segment_length - 8, base_width - 8];
male_size = [
cavity_size[0] - 2 * fit_clearance,
cavity_size[1] - 2 * fit_clearance
];
difference() {
rounded_box([segment_length, base_width, base_height], 2);
translate([4, 4, 2])
cube([segment_length - 8, base_width - 8, base_height]);
}
translate([segment_length + 5, 0, 0])
union() {
cube([segment_length, base_width, lid_thickness]);
translate([
4 + fit_clearance,
4 + fit_clearance,
lid_thickness - eps
])
linear_extrude(height = lid_lip_depth + eps)
difference() {
rounded_rect_2d(male_size, 1);
translate([lid_lip_thickness, lid_lip_thickness])
rounded_rect_2d([
male_size[0] - 2 * lid_lip_thickness,
male_size[1] - 2 * lid_lip_thickness
], 0.5);
}
}
}
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
insert_coupon();
else if (part == "switch_coupon")
switch_coupon();
else if (part == "fit_coupon")
fit_coupon();
else
assert(false, str("Unknown part: ", part));
+31
View File
@@ -0,0 +1,31 @@
# Attitude no battery
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: no
- 1/4-20 insert: yes
- Approximate exterior: 61.6 × 41.7 × 34.4 mm
- Formats: 3mf
- Parts: `body`, `lid`, `lid_base`, `lid_accent`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-no-battery source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
Filament 2” to the desired filaments. `lid.3mf` is the complete
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
provided separately.
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
@@ -1,9 +1,9 @@
---
schema_version: 1
name: Attitude IMU
slug: attitude-imu
name: Attitude no battery
slug: attitude-no-battery
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard
parameters:
top_profile: generic
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
usb_board_wall_gap: 2.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: IMU
lid_logo_enabled: true
lid_art_orientation: usb_front
lid_inlay_depth: 0.6
lid_inlay_fit_clearance: 0.08
lid_label_size: 5.0
lid_label_logo_gap: 2.0
lid_art_margin: 8.5
usb_opening_width: 13.5
usb_opening_height: 8.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
usb_vertical_offset: 0.5
cutouts: []
outputs:
formats:
@@ -1,10 +1,10 @@
{
"parameterSets": {
"pan-tilt-head": {
"top_profile": "\"oled128x64\"",
"wing_count": "3",
"attitude-no-battery": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "12.25",
"stack_height_override": "38.0",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "6.0",
@@ -27,20 +27,25 @@
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
@@ -51,8 +56,8 @@
"corner_radius": "4.0",
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "20.0",
"usb_board_wall_gap": "20.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
@@ -65,10 +70,18 @@
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"lid_label": "\"IMU\"",
"lid_logo_enabled": "true",
"lid_art_orientation": "\"usb_front\"",
"lid_inlay_depth": "0.6",
"lid_inlay_fit_clearance": "0.08",
"lid_label_size": "5.0",
"lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5",
"usb_opening_width": "13.5",
"usb_opening_height": "8.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
"usb_vertical_offset": "0.5"
}
},
"fileFormatVersion": "1"
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg584"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="NME-fixed.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview586"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.914906"
inkscape:cx="1166.24"
inkscape:cy="477.6447"
inkscape:window-width="3440"
inkscape:window-height="1390"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs581" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g772">
<path
id="path2"
d="m 97.576583,26.288608 c -0.51788,0.06491 -2.361848,0.291394 -4.044598,0.485422 -15.62735,1.747308 -32.128529,8.73619 -44.81195,18.960393 -9.803341,7.926917 -17.342201,17.309748 -23.360236,29.151792 -2.879725,5.629978 -3.170767,6.632927 -1.941336,6.535912 0.388054,-0.03245 2.556227,-0.06491 4.788604,-0.03245 l 4.0767,0.03245 4.820708,5.662083 c 2.653242,3.106208 4.918075,5.662083 5.01509,5.662083 0.09737,0 0.291394,-1.035402 0.420862,-2.297287 0.09701,-1.261888 0.517525,-3.688291 0.87348,-5.403146 3.106208,-14.559843 9.415285,-26.692928 19.186524,-36.852576 6.276975,-6.568017 12.521494,-11.098035 21.030846,-15.304204 5.597525,-2.749903 9.933164,-4.367742 15.562791,-5.823656 l 3.559172,-0.938388 -2.10291,0.03245 c -1.164868,0.03246 -2.523768,0.09701 -3.073747,0.129117"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path3"
d="m 169.08146,36.027745 c -26.62837,2.232377 -57.46291,14.33336 -85.90315,33.68181 -5.85611,3.979685 -15.530335,11.130137 -15.530335,11.453635 0,0.16157 1.3589,0.258937 3.008842,0.258937 h 2.976737 l 3.7211,-2.78271 c 4.917723,-3.688644 13.977408,-9.738784 19.574581,-13.071475 5.759445,-3.46216 18.668995,-9.997721 24.751595,-12.55395 21.90467,-9.188802 42.51501,-12.747625 55.16562,-9.576858 5.04754,1.261888 9.35073,4.432654 11.09769,8.120946 0.80891,1.747308 0.87381,2.167818 0.87381,6.406443 0,4.270727 -0.0649,4.691592 -1.06748,7.603419 -2.65325,7.700433 -8.70374,17.439568 -16.8575,27.178351 -1.26188,1.520825 -2.29693,2.814815 -2.29693,2.91183 0,0.09737 4.7886,0.129468 10.67718,0.06491 l 10.67715,-0.09701 V 90.772856 85.91934 l 2.07081,-2.814815 C 196.58332,76.860006 200.59546,69.677098 202.56925,64.11203 208.1015,48.484325 201.79243,38.616074 184.77337,36.286682 181.6996,35.86582 172.7373,35.704247 169.08146,36.027745"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path4"
d="m 19.11528,111.8358 v 26.20751 h 11.000669 v -35.91419 l 1.552926,1.71486 c 0.841375,0.93839 4.59458,5.27402 8.347782,9.64212 3.753202,4.33564 9.350727,10.80664 12.456583,14.36582 3.106208,3.55882 6.341533,7.31202 7.215362,8.34742 l 1.552929,1.84397 h 5.629979 5.62963 V 111.8358 85.628298 H 66.8066 61.079958 l 0.161573,17.957092 c 0.06491,9.86825 0.06491,17.95709 -0.03246,17.95709 -0.09666,-0.0324 -0.711552,-0.67945 -1.326443,-1.45591 -0.61454,-0.77646 -4.27073,-5.01509 -8.088842,-9.41564 C 40.857677,98.052425 37.978307,94.720085 33.998622,90.060598 l -3.7211,-4.367742 -5.565071,-0.03246 -5.597171,-0.0321 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path5"
d="m 77.030804,111.8358 v 26.20751 h 11.001023 v -35.04035 l 6.7945,9.02687 c 3.720748,4.98299 8.023933,10.67753 9.544753,12.68342 l 2.81481,3.65584 9.5123,-12.68307 9.5123,-12.68306 0.0649,13.65391 0.097,13.65355 3.85022,-3.26743 c 2.13571,-1.81186 4.56212,-3.91512 5.43595,-4.69159 l 1.55293,-1.39135 V 103.294 85.628298 h -5.56507 l -5.59753,0.0321 -9.38283,12.456935 c -5.14456,6.826957 -9.4802,12.392027 -9.57721,12.359567 -0.19438,-0.097 -4.4002,-5.56507 -14.33336,-18.798467 L 88.1934,85.628298 H 82.628329 77.030804 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path6"
d="m 170.4079,106.88563 -12.58605,0.097 -6.66538,6.56802 c -21.32189,20.93383 -39.11741,34.16723 -59.727393,44.39108 -28.796192,14.30126 -53.936193,18.6369 -68.334112,11.77749 -2.976739,-1.42381 -6.115402,-4.10916 -7.441848,-6.34154 -2.78271,-4.7565 -2.750253,-12.03642 0.06491,-18.41006 1.002946,-2.29729 1.002946,-2.3943 -0.161925,-2.3943 h -0.93839 l -0.09701,-2.84727 -0.09701,-2.81517 -1.553282,2.58869 c -3.8177601,6.40609 -6.9236165,14.00986 -7.8295498,19.18652 -1.3267981,7.50641 0.1615731,13.07183 4.6912397,17.53659 2.6856951,2.65289 5.0150871,4.04459 8.9947751,5.37068 8.088841,2.6857 18.863026,3.0413 30.608055,0.93874 l 4.238273,-0.74436 3.882673,2.39431 c 14.689312,8.96232 30.252104,13.39497 46.947304,13.39497 14.49529,0 26.66083,-2.81482 39.53828,-9.12389 14.59194,-7.18326 27.33992,-18.70146 35.91418,-32.45238 1.97345,-3.17112 5.92103,-10.80664 6.50312,-12.52185 l 0.32385,-1.0675 -0.54999,0.80892 c -8.60672,12.42447 -18.15112,20.90137 -30.5756,27.08133 -18.15112,9.05934 -39.14987,10.90366 -57.980434,5.04719 -3.268133,-1.00294 -10.839098,-4.01178 -10.806642,-4.27072 0,-0.097 3.461809,-1.90888 7.667977,-3.97969 4.206169,-2.0708 9.706679,-4.95053 12.197999,-6.43855 9.93281,-5.824 22.4222,-14.5923 31.70801,-22.26027 l 3.55918,-2.94464 0.097,1.29434 0.097,1.29399 h 23.94302 23.91021 v -5.01509 -5.01473 h -18.44215 -18.44287 v -1.22943 c 0,-1.16488 0.25929,-1.48873 4.49756,-5.50052 l 4.49721,-4.27072 h 10.54806 l 10.54769,-0.0325 v -5.17701 c 0,-2.81482 -0.0324,-5.11175 -0.0646,-5.07965 -0.0649,0.0646 -5.75944,0.12947 -12.68341,0.16193"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path7"
d="m 34.484042,128.23997 v 14.17144 l -6.018035,0.16192 -6.01839,0.16157 1.326798,2.91218 c 3.559175,7.79745 8.703382,15.82173 13.912496,21.67785 l 1.423812,1.58538 4.691592,-0.38806 c 5.079645,-0.42086 9.609312,-1.19732 15.497879,-2.6857 3.429706,-0.84102 11.518548,-3.39725 11.906604,-3.75285 0.06491,-0.0649 -0.582438,-0.74471 -1.455915,-1.48872 -9.544756,-7.95937 -17.083264,-18.15112 -21.80731,-29.44318 -1.455914,-3.39725 -1.649941,-3.68829 -7.312379,-10.25631 -3.202869,-3.7211 -5.888564,-6.7624 -5.985579,-6.7624 -0.09701,-0.0324 -0.161573,6.34154 -0.161573,14.10688"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<g
id="g475"
transform="matrix(0.14274222,0,0,0.14274222,123.03997,38.158339)">
<path
d="m 134.23594,367.14576 v 34.66667 h 69.06667 c 37.86666,0 71.06666,-0.4 73.86666,-0.8 4.4,-0.8 7.2,-3.73333 31.33333,-32.8 14.53334,-17.46667 26.53334,-32.4 26.8,-32.93333 0.26667,-0.66667 -0.93333,-1.46667 -2.66666,-2 -1.73334,-0.4 -47.06667,-0.8 -100.66667,-0.8 h -97.73333 z"
id="path251"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
<path
d="m 134.90261,432.74576 c -0.4,0.93334 -0.53333,27.2 -0.4,58.4 l 0.4,56.53333 38.4,-37.06666 38.26666,-37.06667 -0.26666,-20.93333 -0.4,-20.8 -37.73334,-0.4 c -29.46666,-0.26667 -37.86666,0 -38.26666,1.33333 z"
id="path253"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -12,7 +12,7 @@
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
@@ -87,16 +92,27 @@ hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
@@ -106,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
@@ -117,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
@@ -133,9 +154,18 @@ effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
@@ -166,20 +196,17 @@ profile_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;
partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
@@ -190,23 +217,74 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation =
lid_art_orientation == "usb_left" ? 0 :
lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled
? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
@@ -225,19 +303,121 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2,
"Tripod insert entry is too close to a Feather standoff");
assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
@@ -297,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
@@ -306,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
@@ -314,7 +494,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d1 = entry_diameter,
d2 = bore_d
);
}
@@ -457,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
@@ -501,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance
])
translate([
x,
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
@@ -542,7 +716,7 @@ module body_positive() {
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
@@ -555,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
@@ -564,6 +739,7 @@ module body_cutouts() {
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
@@ -579,29 +755,8 @@ module body_cutouts() {
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
@@ -609,6 +764,7 @@ module body_cutouts() {
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
@@ -702,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
@@ -714,6 +877,19 @@ module lid_cutouts() {
);
}
if (battery_enabled) {
translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
@@ -723,13 +899,90 @@ module lid_cutouts() {
}
}
module lid() {
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
@@ -769,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
@@ -835,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
color([0.08, 0.08, 0.08, 0.85])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
@@ -865,13 +1126,9 @@ module assembly() {
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
@@ -879,17 +1136,21 @@ module insert_coupon() {
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
@@ -898,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
}
}
@@ -953,6 +1221,10 @@ else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
@@ -0,0 +1,101 @@
"""Render aligned lid-base and accent STLs with distinct materials."""
import os
import sys
import math
import bpy
from mathutils import Matrix, Vector
def import_mesh(path, name, color):
bpy.ops.wm.stl_import(filepath=os.path.abspath(path))
model = bpy.context.selected_objects[0]
model.name = name
material = bpy.data.materials.new(f"{name} material")
material.diffuse_color = color
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
return model
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (4, 5):
raise SystemExit(
"expected: lid-base.stl lid-accent.stl output-directory basename [orientation]"
)
base_path, accent_path, output_dir, basename = args[:4]
orientation = args[4] if len(args) == 5 else "usb_left"
angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270}
if orientation not in angles:
raise SystemExit(f"unknown lid orientation: {orientation}")
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
models = [
import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)),
import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)),
]
# Lid exports are print-oriented with the decorated exterior face down.
# Turn both aligned parts over and present the configured USB edge in its
# named position so previews inspect the finished face as the user will.
for model in models:
model.matrix_world = (
Matrix.Rotation(math.radians(angles[orientation]), 4, "Z")
@ Matrix.Rotation(math.pi, 4, "X")
)
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items():
camera.location = center + Vector(direction).normalized() * span * 2.2
look_at(camera, center)
scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png")
bpy.ops.render.render(write_still=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
# Attitude w/battery
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 88.6 × 61.1 × 34.4 mm
- Formats: 3mf
- Parts: `body`, `lid`, `lid_base`, `lid_accent`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-w-battery source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
Filament 2” to the desired filaments. `lid.3mf` is the complete
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
provided separately.
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
@@ -1,9 +1,9 @@
---
schema_version: 1
name: Attitude IMU - Battery
slug: attitude-imu-battery
name: Attitude w/battery
slug: attitude-w-battery
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard
parameters:
top_profile: generic
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
usb_board_wall_gap: 2.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: IMU
lid_logo_enabled: true
lid_art_orientation: usb_front
lid_inlay_depth: 0.6
lid_inlay_fit_clearance: 0.08
lid_label_size: 5.0
lid_label_logo_gap: 2.0
lid_art_margin: 8.5
usb_opening_width: 13.5
usb_opening_height: 8.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
usb_vertical_offset: 0.5
cutouts: []
outputs:
formats:
@@ -1,6 +1,6 @@
{
"parameterSets": {
"attitude-imu-battery": {
"attitude-w-battery": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "12.25",
@@ -27,20 +27,25 @@
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
@@ -52,17 +57,31 @@
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"lid_label": "\"IMU\"",
"lid_logo_enabled": "true",
"lid_art_orientation": "\"usb_front\"",
"lid_inlay_depth": "0.6",
"lid_inlay_fit_clearance": "0.08",
"lid_label_size": "5.0",
"lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5",
"usb_opening_width": "13.5",
"usb_opening_height": "8.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
"usb_vertical_offset": "0.5"
}
},
"fileFormatVersion": "1"
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg584"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="NME-fixed.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview586"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.914906"
inkscape:cx="1166.24"
inkscape:cy="477.6447"
inkscape:window-width="3440"
inkscape:window-height="1390"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs581" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g772">
<path
id="path2"
d="m 97.576583,26.288608 c -0.51788,0.06491 -2.361848,0.291394 -4.044598,0.485422 -15.62735,1.747308 -32.128529,8.73619 -44.81195,18.960393 -9.803341,7.926917 -17.342201,17.309748 -23.360236,29.151792 -2.879725,5.629978 -3.170767,6.632927 -1.941336,6.535912 0.388054,-0.03245 2.556227,-0.06491 4.788604,-0.03245 l 4.0767,0.03245 4.820708,5.662083 c 2.653242,3.106208 4.918075,5.662083 5.01509,5.662083 0.09737,0 0.291394,-1.035402 0.420862,-2.297287 0.09701,-1.261888 0.517525,-3.688291 0.87348,-5.403146 3.106208,-14.559843 9.415285,-26.692928 19.186524,-36.852576 6.276975,-6.568017 12.521494,-11.098035 21.030846,-15.304204 5.597525,-2.749903 9.933164,-4.367742 15.562791,-5.823656 l 3.559172,-0.938388 -2.10291,0.03245 c -1.164868,0.03246 -2.523768,0.09701 -3.073747,0.129117"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path3"
d="m 169.08146,36.027745 c -26.62837,2.232377 -57.46291,14.33336 -85.90315,33.68181 -5.85611,3.979685 -15.530335,11.130137 -15.530335,11.453635 0,0.16157 1.3589,0.258937 3.008842,0.258937 h 2.976737 l 3.7211,-2.78271 c 4.917723,-3.688644 13.977408,-9.738784 19.574581,-13.071475 5.759445,-3.46216 18.668995,-9.997721 24.751595,-12.55395 21.90467,-9.188802 42.51501,-12.747625 55.16562,-9.576858 5.04754,1.261888 9.35073,4.432654 11.09769,8.120946 0.80891,1.747308 0.87381,2.167818 0.87381,6.406443 0,4.270727 -0.0649,4.691592 -1.06748,7.603419 -2.65325,7.700433 -8.70374,17.439568 -16.8575,27.178351 -1.26188,1.520825 -2.29693,2.814815 -2.29693,2.91183 0,0.09737 4.7886,0.129468 10.67718,0.06491 l 10.67715,-0.09701 V 90.772856 85.91934 l 2.07081,-2.814815 C 196.58332,76.860006 200.59546,69.677098 202.56925,64.11203 208.1015,48.484325 201.79243,38.616074 184.77337,36.286682 181.6996,35.86582 172.7373,35.704247 169.08146,36.027745"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path4"
d="m 19.11528,111.8358 v 26.20751 h 11.000669 v -35.91419 l 1.552926,1.71486 c 0.841375,0.93839 4.59458,5.27402 8.347782,9.64212 3.753202,4.33564 9.350727,10.80664 12.456583,14.36582 3.106208,3.55882 6.341533,7.31202 7.215362,8.34742 l 1.552929,1.84397 h 5.629979 5.62963 V 111.8358 85.628298 H 66.8066 61.079958 l 0.161573,17.957092 c 0.06491,9.86825 0.06491,17.95709 -0.03246,17.95709 -0.09666,-0.0324 -0.711552,-0.67945 -1.326443,-1.45591 -0.61454,-0.77646 -4.27073,-5.01509 -8.088842,-9.41564 C 40.857677,98.052425 37.978307,94.720085 33.998622,90.060598 l -3.7211,-4.367742 -5.565071,-0.03246 -5.597171,-0.0321 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path5"
d="m 77.030804,111.8358 v 26.20751 h 11.001023 v -35.04035 l 6.7945,9.02687 c 3.720748,4.98299 8.023933,10.67753 9.544753,12.68342 l 2.81481,3.65584 9.5123,-12.68307 9.5123,-12.68306 0.0649,13.65391 0.097,13.65355 3.85022,-3.26743 c 2.13571,-1.81186 4.56212,-3.91512 5.43595,-4.69159 l 1.55293,-1.39135 V 103.294 85.628298 h -5.56507 l -5.59753,0.0321 -9.38283,12.456935 c -5.14456,6.826957 -9.4802,12.392027 -9.57721,12.359567 -0.19438,-0.097 -4.4002,-5.56507 -14.33336,-18.798467 L 88.1934,85.628298 H 82.628329 77.030804 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path6"
d="m 170.4079,106.88563 -12.58605,0.097 -6.66538,6.56802 c -21.32189,20.93383 -39.11741,34.16723 -59.727393,44.39108 -28.796192,14.30126 -53.936193,18.6369 -68.334112,11.77749 -2.976739,-1.42381 -6.115402,-4.10916 -7.441848,-6.34154 -2.78271,-4.7565 -2.750253,-12.03642 0.06491,-18.41006 1.002946,-2.29729 1.002946,-2.3943 -0.161925,-2.3943 h -0.93839 l -0.09701,-2.84727 -0.09701,-2.81517 -1.553282,2.58869 c -3.8177601,6.40609 -6.9236165,14.00986 -7.8295498,19.18652 -1.3267981,7.50641 0.1615731,13.07183 4.6912397,17.53659 2.6856951,2.65289 5.0150871,4.04459 8.9947751,5.37068 8.088841,2.6857 18.863026,3.0413 30.608055,0.93874 l 4.238273,-0.74436 3.882673,2.39431 c 14.689312,8.96232 30.252104,13.39497 46.947304,13.39497 14.49529,0 26.66083,-2.81482 39.53828,-9.12389 14.59194,-7.18326 27.33992,-18.70146 35.91418,-32.45238 1.97345,-3.17112 5.92103,-10.80664 6.50312,-12.52185 l 0.32385,-1.0675 -0.54999,0.80892 c -8.60672,12.42447 -18.15112,20.90137 -30.5756,27.08133 -18.15112,9.05934 -39.14987,10.90366 -57.980434,5.04719 -3.268133,-1.00294 -10.839098,-4.01178 -10.806642,-4.27072 0,-0.097 3.461809,-1.90888 7.667977,-3.97969 4.206169,-2.0708 9.706679,-4.95053 12.197999,-6.43855 9.93281,-5.824 22.4222,-14.5923 31.70801,-22.26027 l 3.55918,-2.94464 0.097,1.29434 0.097,1.29399 h 23.94302 23.91021 v -5.01509 -5.01473 h -18.44215 -18.44287 v -1.22943 c 0,-1.16488 0.25929,-1.48873 4.49756,-5.50052 l 4.49721,-4.27072 h 10.54806 l 10.54769,-0.0325 v -5.17701 c 0,-2.81482 -0.0324,-5.11175 -0.0646,-5.07965 -0.0649,0.0646 -5.75944,0.12947 -12.68341,0.16193"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path7"
d="m 34.484042,128.23997 v 14.17144 l -6.018035,0.16192 -6.01839,0.16157 1.326798,2.91218 c 3.559175,7.79745 8.703382,15.82173 13.912496,21.67785 l 1.423812,1.58538 4.691592,-0.38806 c 5.079645,-0.42086 9.609312,-1.19732 15.497879,-2.6857 3.429706,-0.84102 11.518548,-3.39725 11.906604,-3.75285 0.06491,-0.0649 -0.582438,-0.74471 -1.455915,-1.48872 -9.544756,-7.95937 -17.083264,-18.15112 -21.80731,-29.44318 -1.455914,-3.39725 -1.649941,-3.68829 -7.312379,-10.25631 -3.202869,-3.7211 -5.888564,-6.7624 -5.985579,-6.7624 -0.09701,-0.0324 -0.161573,6.34154 -0.161573,14.10688"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<g
id="g475"
transform="matrix(0.14274222,0,0,0.14274222,123.03997,38.158339)">
<path
d="m 134.23594,367.14576 v 34.66667 h 69.06667 c 37.86666,0 71.06666,-0.4 73.86666,-0.8 4.4,-0.8 7.2,-3.73333 31.33333,-32.8 14.53334,-17.46667 26.53334,-32.4 26.8,-32.93333 0.26667,-0.66667 -0.93333,-1.46667 -2.66666,-2 -1.73334,-0.4 -47.06667,-0.8 -100.66667,-0.8 h -97.73333 z"
id="path251"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
<path
d="m 134.90261,432.74576 c -0.4,0.93334 -0.53333,27.2 -0.4,58.4 l 0.4,56.53333 38.4,-37.06666 38.26666,-37.06667 -0.26666,-20.93333 -0.4,-20.8 -37.73334,-0.4 c -29.46666,-0.26667 -37.86666,0 -38.26666,1.33333 z"
id="path253"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -12,7 +12,7 @@
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
@@ -78,19 +83,36 @@ corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
usb_board_wall_gap = 2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
@@ -100,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
@@ -111,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
@@ -127,11 +154,34 @@ effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
@@ -146,20 +196,17 @@ profile_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;
partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
@@ -170,23 +217,74 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation =
lid_art_orientation == "usb_left" ? 0 :
lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled
? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
@@ -205,19 +303,156 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2,
"Tripod insert entry is too close to a Feather standoff");
assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -242,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
@@ -251,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
@@ -259,7 +494,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d1 = entry_diameter,
d2 = bore_d
);
}
@@ -301,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
@@ -368,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
@@ -412,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance
])
translate([
x,
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
@@ -453,8 +716,10 @@ module body_positive() {
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
}
}
@@ -464,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
@@ -473,44 +739,24 @@ module body_cutouts() {
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
if (usb_opening_enabled)
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_u,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
@@ -518,6 +764,7 @@ module body_cutouts() {
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
@@ -611,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
@@ -623,6 +877,19 @@ module lid_cutouts() {
);
}
if (battery_enabled) {
translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
@@ -632,13 +899,90 @@ module lid_cutouts() {
}
}
module lid() {
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
@@ -678,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
@@ -744,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
color([0.08, 0.08, 0.08, 0.85])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
@@ -774,13 +1126,9 @@ module assembly() {
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
@@ -788,17 +1136,21 @@ module insert_coupon() {
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
@@ -807,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
}
}
@@ -854,12 +1213,18 @@ module fit_coupon() {
}
}
validate_cutout_spacing();
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
@@ -0,0 +1,101 @@
"""Render aligned lid-base and accent STLs with distinct materials."""
import os
import sys
import math
import bpy
from mathutils import Matrix, Vector
def import_mesh(path, name, color):
bpy.ops.wm.stl_import(filepath=os.path.abspath(path))
model = bpy.context.selected_objects[0]
model.name = name
material = bpy.data.materials.new(f"{name} material")
material.diffuse_color = color
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
return model
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (4, 5):
raise SystemExit(
"expected: lid-base.stl lid-accent.stl output-directory basename [orientation]"
)
base_path, accent_path, output_dir, basename = args[:4]
orientation = args[4] if len(args) == 5 else "usb_left"
angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270}
if orientation not in angles:
raise SystemExit(f"unknown lid orientation: {orientation}")
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
models = [
import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)),
import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)),
]
# Lid exports are print-oriented with the decorated exterior face down.
# Turn both aligned parts over and present the configured USB edge in its
# named position so previews inspect the finished face as the user will.
for model in models:
model.matrix_world = (
Matrix.Rotation(math.radians(angles[orientation]), 4, "Z")
@ Matrix.Rotation(math.pi, 4, "X")
)
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items():
camera.location = center + Vector(direction).normalized() * span * 2.2
look_at(camera, center)
scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png")
bpy.ops.render.render(write_still=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()
+10 -4
View File
@@ -6,14 +6,14 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 41.8 mm
- Approximate exterior: 88.6 × 61.1 × 40.4 mm
- Formats: 3mf
- Parts: `body`, `lid`, `insert_coupon`, `switch_coupon`, `fit_coupon`
- Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
@@ -23,3 +23,9 @@ openscad -p parameters.json -P gps source/feather_case.scad
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
Filament 2” to the desired filaments. `lid.3mf` is the complete
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
provided separately.
+25
View File
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
+39 -15
View File
@@ -3,7 +3,7 @@ schema_version: 1
name: GPS
slug: gps
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard
parameters:
top_profile: gps
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
battery_partition_thickness: 1.8
battery_board_clearance: 3.0
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
rocker_partition_offset: 2.0
rocker_tab_panel_thickness: 2.0
rocker_tab_land: 2.0
rocker_tab_taper_length: 0.6
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
usb_board_wall_gap: 2.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: GPS
lid_logo_enabled: true
lid_art_orientation: usb_left
lid_inlay_depth: 0.6
lid_inlay_fit_clearance: 0.08
lid_label_size: 5.0
lid_label_logo_gap: 2.0
lid_art_margin: 8.5
usb_opening_width: 13.5
usb_opening_height: 8.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
usb_vertical_offset: 0.5
cutouts: []
outputs:
formats:
+33 -14
View File
@@ -27,20 +27,25 @@
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"battery_partition_thickness": "1.8",
"battery_board_clearance": "3.0",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
@@ -52,17 +57,31 @@
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"lid_label": "\"GPS\"",
"lid_logo_enabled": "true",
"lid_art_orientation": "\"usb_left\"",
"lid_inlay_depth": "0.6",
"lid_inlay_fit_clearance": "0.08",
"lid_label_size": "5.0",
"lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5",
"usb_opening_width": "13.5",
"usb_opening_height": "8.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
"usb_vertical_offset": "0.5"
}
},
"fileFormatVersion": "1"
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg584"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="NME-fixed.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview586"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.914906"
inkscape:cx="1166.24"
inkscape:cy="477.6447"
inkscape:window-width="3440"
inkscape:window-height="1390"
inkscape:window-x="0"
inkscape:window-y="1080"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs581" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g772">
<path
id="path2"
d="m 97.576583,26.288608 c -0.51788,0.06491 -2.361848,0.291394 -4.044598,0.485422 -15.62735,1.747308 -32.128529,8.73619 -44.81195,18.960393 -9.803341,7.926917 -17.342201,17.309748 -23.360236,29.151792 -2.879725,5.629978 -3.170767,6.632927 -1.941336,6.535912 0.388054,-0.03245 2.556227,-0.06491 4.788604,-0.03245 l 4.0767,0.03245 4.820708,5.662083 c 2.653242,3.106208 4.918075,5.662083 5.01509,5.662083 0.09737,0 0.291394,-1.035402 0.420862,-2.297287 0.09701,-1.261888 0.517525,-3.688291 0.87348,-5.403146 3.106208,-14.559843 9.415285,-26.692928 19.186524,-36.852576 6.276975,-6.568017 12.521494,-11.098035 21.030846,-15.304204 5.597525,-2.749903 9.933164,-4.367742 15.562791,-5.823656 l 3.559172,-0.938388 -2.10291,0.03245 c -1.164868,0.03246 -2.523768,0.09701 -3.073747,0.129117"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path3"
d="m 169.08146,36.027745 c -26.62837,2.232377 -57.46291,14.33336 -85.90315,33.68181 -5.85611,3.979685 -15.530335,11.130137 -15.530335,11.453635 0,0.16157 1.3589,0.258937 3.008842,0.258937 h 2.976737 l 3.7211,-2.78271 c 4.917723,-3.688644 13.977408,-9.738784 19.574581,-13.071475 5.759445,-3.46216 18.668995,-9.997721 24.751595,-12.55395 21.90467,-9.188802 42.51501,-12.747625 55.16562,-9.576858 5.04754,1.261888 9.35073,4.432654 11.09769,8.120946 0.80891,1.747308 0.87381,2.167818 0.87381,6.406443 0,4.270727 -0.0649,4.691592 -1.06748,7.603419 -2.65325,7.700433 -8.70374,17.439568 -16.8575,27.178351 -1.26188,1.520825 -2.29693,2.814815 -2.29693,2.91183 0,0.09737 4.7886,0.129468 10.67718,0.06491 l 10.67715,-0.09701 V 90.772856 85.91934 l 2.07081,-2.814815 C 196.58332,76.860006 200.59546,69.677098 202.56925,64.11203 208.1015,48.484325 201.79243,38.616074 184.77337,36.286682 181.6996,35.86582 172.7373,35.704247 169.08146,36.027745"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path4"
d="m 19.11528,111.8358 v 26.20751 h 11.000669 v -35.91419 l 1.552926,1.71486 c 0.841375,0.93839 4.59458,5.27402 8.347782,9.64212 3.753202,4.33564 9.350727,10.80664 12.456583,14.36582 3.106208,3.55882 6.341533,7.31202 7.215362,8.34742 l 1.552929,1.84397 h 5.629979 5.62963 V 111.8358 85.628298 H 66.8066 61.079958 l 0.161573,17.957092 c 0.06491,9.86825 0.06491,17.95709 -0.03246,17.95709 -0.09666,-0.0324 -0.711552,-0.67945 -1.326443,-1.45591 -0.61454,-0.77646 -4.27073,-5.01509 -8.088842,-9.41564 C 40.857677,98.052425 37.978307,94.720085 33.998622,90.060598 l -3.7211,-4.367742 -5.565071,-0.03246 -5.597171,-0.0321 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path5"
d="m 77.030804,111.8358 v 26.20751 h 11.001023 v -35.04035 l 6.7945,9.02687 c 3.720748,4.98299 8.023933,10.67753 9.544753,12.68342 l 2.81481,3.65584 9.5123,-12.68307 9.5123,-12.68306 0.0649,13.65391 0.097,13.65355 3.85022,-3.26743 c 2.13571,-1.81186 4.56212,-3.91512 5.43595,-4.69159 l 1.55293,-1.39135 V 103.294 85.628298 h -5.56507 l -5.59753,0.0321 -9.38283,12.456935 c -5.14456,6.826957 -9.4802,12.392027 -9.57721,12.359567 -0.19438,-0.097 -4.4002,-5.56507 -14.33336,-18.798467 L 88.1934,85.628298 H 82.628329 77.030804 V 111.8358"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path6"
d="m 170.4079,106.88563 -12.58605,0.097 -6.66538,6.56802 c -21.32189,20.93383 -39.11741,34.16723 -59.727393,44.39108 -28.796192,14.30126 -53.936193,18.6369 -68.334112,11.77749 -2.976739,-1.42381 -6.115402,-4.10916 -7.441848,-6.34154 -2.78271,-4.7565 -2.750253,-12.03642 0.06491,-18.41006 1.002946,-2.29729 1.002946,-2.3943 -0.161925,-2.3943 h -0.93839 l -0.09701,-2.84727 -0.09701,-2.81517 -1.553282,2.58869 c -3.8177601,6.40609 -6.9236165,14.00986 -7.8295498,19.18652 -1.3267981,7.50641 0.1615731,13.07183 4.6912397,17.53659 2.6856951,2.65289 5.0150871,4.04459 8.9947751,5.37068 8.088841,2.6857 18.863026,3.0413 30.608055,0.93874 l 4.238273,-0.74436 3.882673,2.39431 c 14.689312,8.96232 30.252104,13.39497 46.947304,13.39497 14.49529,0 26.66083,-2.81482 39.53828,-9.12389 14.59194,-7.18326 27.33992,-18.70146 35.91418,-32.45238 1.97345,-3.17112 5.92103,-10.80664 6.50312,-12.52185 l 0.32385,-1.0675 -0.54999,0.80892 c -8.60672,12.42447 -18.15112,20.90137 -30.5756,27.08133 -18.15112,9.05934 -39.14987,10.90366 -57.980434,5.04719 -3.268133,-1.00294 -10.839098,-4.01178 -10.806642,-4.27072 0,-0.097 3.461809,-1.90888 7.667977,-3.97969 4.206169,-2.0708 9.706679,-4.95053 12.197999,-6.43855 9.93281,-5.824 22.4222,-14.5923 31.70801,-22.26027 l 3.55918,-2.94464 0.097,1.29434 0.097,1.29399 h 23.94302 23.91021 v -5.01509 -5.01473 h -18.44215 -18.44287 v -1.22943 c 0,-1.16488 0.25929,-1.48873 4.49756,-5.50052 l 4.49721,-4.27072 h 10.54806 l 10.54769,-0.0325 v -5.17701 c 0,-2.81482 -0.0324,-5.11175 -0.0646,-5.07965 -0.0649,0.0646 -5.75944,0.12947 -12.68341,0.16193"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<path
id="path7"
d="m 34.484042,128.23997 v 14.17144 l -6.018035,0.16192 -6.01839,0.16157 1.326798,2.91218 c 3.559175,7.79745 8.703382,15.82173 13.912496,21.67785 l 1.423812,1.58538 4.691592,-0.38806 c 5.079645,-0.42086 9.609312,-1.19732 15.497879,-2.6857 3.429706,-0.84102 11.518548,-3.39725 11.906604,-3.75285 0.06491,-0.0649 -0.582438,-0.74471 -1.455915,-1.48872 -9.544756,-7.95937 -17.083264,-18.15112 -21.80731,-29.44318 -1.455914,-3.39725 -1.649941,-3.68829 -7.312379,-10.25631 -3.202869,-3.7211 -5.888564,-6.7624 -5.985579,-6.7624 -0.09701,-0.0324 -0.161573,6.34154 -0.161573,14.10688"
style="fill:#100f0d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0352777" />
<g
id="g475"
transform="matrix(0.14274222,0,0,0.14274222,123.03997,38.158339)">
<path
d="m 134.23594,367.14576 v 34.66667 h 69.06667 c 37.86666,0 71.06666,-0.4 73.86666,-0.8 4.4,-0.8 7.2,-3.73333 31.33333,-32.8 14.53334,-17.46667 26.53334,-32.4 26.8,-32.93333 0.26667,-0.66667 -0.93333,-1.46667 -2.66666,-2 -1.73334,-0.4 -47.06667,-0.8 -100.66667,-0.8 h -97.73333 z"
id="path251"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
<path
d="m 134.90261,432.74576 c -0.4,0.93334 -0.53333,27.2 -0.4,58.4 l 0.4,56.53333 38.4,-37.06666 38.26666,-37.06667 -0.26666,-20.93333 -0.4,-20.8 -37.73334,-0.4 c -29.46666,-0.26667 -37.86666,0 -38.26666,1.33333 z"
id="path253"
style="fill:#000000;stroke:none;stroke-width:0.133333" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

+484 -119
View File
@@ -12,7 +12,7 @@
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
@@ -78,19 +83,36 @@ corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
usb_board_wall_gap = 2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
@@ -100,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
@@ -111,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
@@ -127,11 +154,34 @@ effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
@@ -146,20 +196,17 @@ profile_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;
partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
@@ -170,23 +217,74 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation =
lid_art_orientation == "usb_left" ? 0 :
lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled
? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
@@ -205,19 +303,156 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2,
"Tripod insert entry is too close to a Feather standoff");
assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -242,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
@@ -251,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
@@ -259,7 +494,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d1 = entry_diameter,
d2 = bore_d
);
}
@@ -301,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
@@ -368,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
@@ -412,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance
])
translate([
x,
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
@@ -453,8 +716,10 @@ module body_positive() {
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
}
}
@@ -464,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
@@ -473,44 +739,24 @@ module body_cutouts() {
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
if (usb_opening_enabled)
face_rounded_cutout(
"front",
board_origin_y + usb_center_y,
usb_center_u,
usb_center_z,
usb_opening_width + hole_compensation,
usb_opening_height + hole_compensation,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
@@ -518,6 +764,7 @@ module body_cutouts() {
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
@@ -611,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
@@ -623,6 +877,19 @@ module lid_cutouts() {
);
}
if (battery_enabled) {
translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
@@ -632,13 +899,90 @@ module lid_cutouts() {
}
}
module lid() {
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
@@ -678,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
@@ -744,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
color([0.08, 0.08, 0.08, 0.85])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
@@ -774,13 +1126,9 @@ module assembly() {
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
@@ -788,17 +1136,21 @@ module insert_coupon() {
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
@@ -807,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
}
}
@@ -854,12 +1213,18 @@ module fit_coupon() {
}
}
validate_cutout_spacing();
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
+101
View File
@@ -0,0 +1,101 @@
"""Render aligned lid-base and accent STLs with distinct materials."""
import os
import sys
import math
import bpy
from mathutils import Matrix, Vector
def import_mesh(path, name, color):
bpy.ops.wm.stl_import(filepath=os.path.abspath(path))
model = bpy.context.selected_objects[0]
model.name = name
material = bpy.data.materials.new(f"{name} material")
material.diffuse_color = color
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
return model
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (4, 5):
raise SystemExit(
"expected: lid-base.stl lid-accent.stl output-directory basename [orientation]"
)
base_path, accent_path, output_dir, basename = args[:4]
orientation = args[4] if len(args) == 5 else "usb_left"
angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270}
if orientation not in angles:
raise SystemExit(f"unknown lid orientation: {orientation}")
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
models = [
import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)),
import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)),
]
# Lid exports are print-oriented with the decorated exterior face down.
# Turn both aligned parts over and present the configured USB edge in its
# named position so previews inspect the finished face as the user will.
for model in models:
model.matrix_world = (
Matrix.Rotation(math.radians(angles[orientation]), 4, "Z")
@ Matrix.Rotation(math.pi, 4, "X")
)
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items():
camera.location = center + Vector(direction).normalized() * span * 2.2
look_at(camera, center)
scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png")
bpy.ops.render.render(write_still=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()
-25
View File
@@ -1,25 +0,0 @@
# Pan/Tilt Head
Generated by the Feather Case Wizard.
- Top profile: `oled128x64`
- FeatherWings: 3
- Battery compartment: no
- 1/4-20 insert: yes
- Approximate exterior: 95.6 × 41.7 × 59.8 mm
- Formats: 3mf
- Parts: `body`, `lid`, `buttons`
- Custom cutouts: 4
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P pan-tilt-head source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
-123
View File
@@ -1,123 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
require "pathname"
require "tmpdir"
require "yaml"
ROOT = Pathname(__dir__).expand_path
def executable(env_name, command)
override = ENV[env_name]
return File.expand_path(override) if override && File.executable?(override)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
candidate = File.join(directory, command)
return candidate if File.file?(candidate) && File.executable?(candidate)
end
nil
end
def literal(value)
case value
when String then JSON.generate(value)
when TrueClass then "true"
when FalseClass then "false"
when Numeric then value.to_s
when Array then "[#{value.map { |item| literal(item) }.join(",")}]"
else raise "Cannot convert #{value.inspect} to OpenSCAD"
end
end
def run!(*command, env: {})
stdout, stderr, status = Open3.capture3(env, *command)
return if status.success?
warn [stdout, stderr].reject(&:empty?).join("\n")
raise "Command failed (#{status.exitstatus}): #{command.first}"
end
config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
parts << "buttons"
end
stage = Pathname(Dir.mktmpdir(".rebuild-", ROOT.to_s))
begin
exports = stage.join("exports")
previews = stage.join("previews")
scratch = stage.join("meshes")
FileUtils.mkdir_p([exports, previews, scratch])
model = ROOT.join("source/feather_case.scad")
renderer = ROOT.join("source/render_stl_previews.py")
export = lambda do |output, part, overrides = {}|
values = parameters.merge(overrides).merge("part" => part)
command = [openscad, "-o", output.to_s]
values.each { |name, value| command.concat(["-D", "#{name}=#{literal(value)}"]) }
run!(*command, model.to_s)
end
config.fetch("outputs").fetch("formats").each do |format|
directory = exports.join(format)
FileUtils.mkdir_p(directory)
parts.each do |part|
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
end
assembly = scratch.join("assembly.stl")
puts "Preparing assembly preview…"
export.call(assembly, "assembly",
"exploded_view" => config.fetch("outputs").fetch("assembly_exploded_view"),
"show_references" => false)
render_env = {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil}
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
assembly.to_s, previews.to_s, "assembly", env: render_env)
parts.each do |part|
puts "Rendering #{part} preview…"
mesh = scratch.join("#{part}.stl")
export.call(mesh, part) unless mesh.file?
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%w[exports previews].each do |name|
current = ROOT.join(name)
FileUtils.mv(current, backup.join(name)) if current.exist?
end
begin
FileUtils.mv(exports, ROOT.join("exports"))
FileUtils.mv(previews, ROOT.join("previews"))
FileUtils.rm_rf(backup)
rescue StandardError
FileUtils.rm_rf(ROOT.join("exports"))
FileUtils.rm_rf(ROOT.join("previews"))
%w[exports previews].each do |name|
saved = backup.join(name)
FileUtils.mv(saved, ROOT.join(name)) if saved.exist?
end
raise
end
puts "Rebuilt #{ROOT}"
ensure
FileUtils.rm_rf(stage)
end
-148
View File
@@ -1,148 +0,0 @@
---
schema_version: 1
name: Pan/Tilt Head
slug: pan-tilt-head
generator:
sha256: 965260d4d402fd8894f30aa863224e2c9b42dc61a1496f01e2f00b509f9624db
created_with: feather-case-wizard
parameters:
top_profile: oled128x64
wing_count: 3
wing_pitch: 12.25
stack_height_override: 38.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 6.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_display_height: 1.5
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: false
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
insert_melt_relief: 0.8
wall_thickness: 2.4
floor_thickness: 2.4
lid_thickness: 3.2
corner_radius: 4.0
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 20.0
usb_board_wall_gap: 20.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts:
- name: Antenna
face: left
shape: circle
horizontal: 30.4
z: 31.299999999999997
width: 6.5
height: 6.5
radius: 3.25
- name: Servo
face: rear
shape: roundrect
horizontal: 20.83
z: 35.0
width: 10.0
height: 5.0
radius: 1.25
- name: Release
face: rear
shape: circle
horizontal: 20.83
z: 20.0
width: 6.0
height: 6.0
radius: 3.0
- name: Power
face: front
shape: circle
horizontal: 20.83
z: 31.65
width: 8.0
height: 8.0
radius: 4.0
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts:
- - left
- circle
- 30.4
- 31.299999999999997
- 6.5
- 6.5
- 3.25
- - rear
- roundrect
- 20.83
- 35.0
- 10.0
- 5.0
- 1.25
- - rear
- circle
- 20.83
- 20.0
- 6.0
- 6.0
- 3.0
- - front
- circle
- 20.83
- 31.65
- 8.0
- 8.0
- 4.0
@@ -1,127 +0,0 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
+386 -114
View File
@@ -12,7 +12,7 @@
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
battery_partition_thickness = 1.8;
battery_board_clearance = 3;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
@@ -87,16 +92,27 @@ hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5;
usb_vertical_offset = 0;
usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius]
@@ -106,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
@@ -117,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
@@ -133,9 +154,18 @@ effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
@@ -166,20 +196,17 @@ profile_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;
partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
@@ -190,23 +217,74 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_installed_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_installed_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2 + rocker_partition_offset;
// The printable lid is flipped across Y when installed, so its local cutout
// coordinate must be mirrored from the assembled switch location.
rocker_lid_center_x = rocker_installed_center_x;
rocker_lid_center_y = case_width - rocker_installed_center_y;
lid_artwork_enabled = lid_label != "" || lid_logo_enabled;
lid_art_rotation =
lid_art_orientation == "usb_left" ? 0 :
lid_art_orientation == "usb_front" ? 90 :
lid_art_orientation == "usb_right" ? 180 : 270;
lid_art_safe_max_x = battery_enabled
? rocker_lid_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
@@ -225,19 +303,121 @@ lid_boss_xy = [
case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
]
];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| tripod_insert_outer_diameter >= tripod_insert_hole_diameter,
"Tripod insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled || tripod_standoff_clearance >= 1.2,
"Tripod insert entry is too close to a Feather standoff");
assert(!battery_enabled || !tripod_insert_enabled
|| tripod_partition_clearance >= 1,
"Tripod insert entry is too close to the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_installed_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| rocker_installed_center_x - rocker_body_size[0] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the front lid lip");
assert(!battery_enabled
|| rocker_installed_center_x + rocker_body_size[0] / 2
<= case_length - wall_thickness - lid_lip_thickness - fit_clearance,
"Rocker body overlaps the rear lid lip");
assert(!battery_enabled
|| rocker_installed_center_y - rocker_body_size[1] / 2
>= wall_thickness + lid_lip_thickness + fit_clearance,
"Rocker body overlaps the Feather-side lid lip");
assert(!battery_enabled || rocker_boss_clearance >= fit_clearance - eps,
"Rocker body overlaps a lid boss");
assert(!battery_enabled
|| rocker_relief_boss_clearance >= fit_clearance - eps,
"Rocker tab relief overlaps a lid boss scallop");
assert(!battery_enabled
|| abs(
case_width - rocker_lid_center_y - rocker_installed_center_y
) < eps,
"Rocker lid cutout does not mirror to the installed switch location");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
@@ -297,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
@@ -306,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
@@ -314,7 +494,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d1 = entry_diameter,
d2 = bore_d
);
}
@@ -457,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
@@ -501,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance
])
translate([
x,
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
@@ -542,7 +716,7 @@ module body_positive() {
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
@@ -555,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
@@ -564,6 +739,7 @@ module body_cutouts() {
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
@@ -579,29 +755,8 @@ module body_cutouts() {
usb_opening_radius
);
if (battery_enabled) {
face_circle_cutout(
"right",
switch_center_x,
switch_center_z,
switch_hole_diameter + switch_hole_compensation
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
@@ -609,6 +764,7 @@ module body_cutouts() {
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
@@ -702,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
@@ -714,6 +877,19 @@ module lid_cutouts() {
);
}
if (battery_enabled) {
translate([rocker_lid_center_x, rocker_lid_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[rocker_lid_center_x, rocker_lid_center_y],
rocker_cutout_size
);
}
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
@@ -723,13 +899,90 @@ module lid_cutouts() {
}
}
module lid() {
// Thin the horizontal top/bottom edges of the upright switch opening where the
// spring tabs latch. Each full-width land overlaps the rounded opening by its
// corner radius, then extends outward before tapering to the surrounding lid.
module rocker_tab_reliefs(center, opening_size) {
relief_depth = lid_thickness - rocker_tab_panel_thickness;
opening_overlap = rocker_corner_radius;
strip = 0.02;
for (side = [-1, 1]) {
edge_y = center[1] + side * opening_size[1] / 2;
translate([
center[0] - opening_size[0] / 2,
edge_y - (side < 0 ? rocker_tab_land : opening_overlap),
rocker_tab_panel_thickness
])
cube([opening_size[0], rocker_tab_land + opening_overlap,
relief_depth + eps]);
hull() {
translate([
center[0] - opening_size[0] / 2,
edge_y + side * rocker_tab_land - (side < 0 ? strip : 0),
rocker_tab_panel_thickness
])
cube([opening_size[0], strip, relief_depth + eps]);
translate([
center[0] - opening_size[0] / 2,
edge_y + side * (rocker_tab_land + rocker_tab_taper_length)
- (side < 0 ? strip : 0),
lid_thickness - strip
])
cube([opening_size[0], strip, strip + eps]);
}
}
}
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
@@ -769,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
@@ -835,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
color([0.08, 0.08, 0.08, 0.85])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
rocker_installed_center_x - rocker_body_size[0] / 2,
rocker_installed_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_installed_center_x - rocker_cutout_size[0] / 2,
rocker_installed_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
@@ -865,13 +1126,9 @@ module assembly() {
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
@@ -879,17 +1136,21 @@ module insert_coupon() {
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
@@ -898,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
rocker_tab_reliefs(
[6 + i * spacing, coupon_width / 2],
rocker_cutout_size + [coupon_steps[i], coupon_steps[i]]
);
}
}
}
@@ -953,6 +1221,10 @@ else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
+3
View File
@@ -23,4 +23,7 @@ module FeatherCaseWizard
CASES_ROOT = ROOT.join("cases").freeze
MODEL_SOURCE = ROOT.join("feather_case.scad").freeze
RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").freeze
MULTICOLOR_RENDERER_SOURCE = ROOT.join("tools/render_multicolor_lid.py").freeze
MULTICOLOR_PACKAGER_SOURCE = ROOT.join("tools/package_multimaterial_3mf.py").freeze
LOGO_SOURCE = ROOT.join("assets/NME-fixed.svg").freeze
end
+116 -41
View File
@@ -10,6 +10,11 @@ module FeatherCaseWizard
FACES = %w[front rear left right].freeze
SHAPES = %w[circle rect roundrect slot].freeze
COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze
LID_ART_ORIENTATIONS = %w[usb_left usb_front usb_right usb_rear].freeze
LEGACY_SWITCH_FIELDS = %w[switch_hole_diameter switch_hole_compensation
switch_cap_diameter switch_flange_diameter
switch_body_length switch_body_diameter
switch_wire_allowance].freeze
DEFAULT_PARAMETERS = {
"top_profile" => "generic",
@@ -38,20 +43,25 @@ module FeatherCaseWizard
"battery_diameter" => 18.0,
"battery_radial_clearance" => 0.6,
"battery_end_clearance" => 1.0,
"switch_hole_diameter" => 12.0,
"switch_hole_compensation" => 0.3,
"switch_cap_diameter" => 8.0,
"switch_flange_diameter" => 16.2,
"switch_body_length" => 24.0,
"switch_body_diameter" => 12.0,
"switch_wire_allowance" => 3.0,
"battery_partition_thickness" => 1.8,
"battery_board_clearance" => 3.0,
"rocker_cutout_size" => [12.9, 19.4],
"rocker_corner_radius" => 0.8,
"rocker_body_size" => [15.0, 21.0, 21.0],
"rocker_artwork_gap" => 2.0,
"rocker_partition_offset" => 2.0,
"rocker_tab_panel_thickness" => 2.0,
"rocker_tab_land" => 2.0,
"rocker_tab_taper_length" => 0.6,
"tripod_insert_enabled" => false,
"tripod_insert_length" => 6.4,
"tripod_insert_hole_diameter" => 8.8,
"tripod_insert_length" => 5.0,
"tripod_insert_hole_diameter" => 7.2,
"tripod_insert_outer_diameter" => 8.0,
"tripod_thread_clearance_diameter" => 6.8,
"tripod_boss_diameter" => 14.0,
"m25_insert_length" => 4.0,
"m25_insert_hole_diameter" => 3.7,
"m25_insert_hole_diameter" => 3.1,
"m25_insert_outer_diameter" => 3.5,
"m25_screw_clearance" => 2.8,
"m25_boss_diameter" => 7.0,
"insert_entry_chamfer" => 0.6,
@@ -76,10 +86,18 @@ module FeatherCaseWizard
"usb_connector_overhang" => 2.0,
"usb_guide_wall_thickness" => 1.2,
"usb_guide_end_gap" => 1.0,
"usb_opening_width" => 12.5,
"usb_opening_height" => 7.0,
"lid_label" => "",
"lid_logo_enabled" => false,
"lid_art_orientation" => "usb_left",
"lid_inlay_depth" => 0.6,
"lid_inlay_fit_clearance" => 0.08,
"lid_label_size" => 5.0,
"lid_label_logo_gap" => 2.0,
"lid_art_margin" => 8.5,
"usb_opening_width" => 13.5,
"usb_opening_height" => 8.0,
"usb_opening_radius" => 1.5,
"usb_vertical_offset" => 0.0
"usb_vertical_offset" => 0.5
}.freeze
ADVANCED_FIELDS = {
@@ -87,19 +105,27 @@ module FeatherCaseWizard
gps_antenna_height gps_top_gap oled_component_height
oled_display_height oled_face_gap],
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
board_bottom_clearance board_side_clearance lid_lip_depth
lid_lip_thickness fit_clearance cutout_min_web hole_compensation
lid_screw_head_diameter lid_screw_head_height],
board_bottom_clearance board_side_clearance board_rear_clearance
usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
cutout_min_web hole_compensation lid_screw_head_diameter
lid_screw_head_height],
"Lid artwork" => %w[lid_inlay_depth lid_inlay_fit_clearance lid_label_size
lid_label_logo_gap lid_art_margin],
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
m25_insert_outer_diameter
m25_screw_clearance m25_boss_diameter
insert_entry_chamfer insert_melt_relief],
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
tripod_insert_outer_diameter
tripod_thread_clearance_diameter tripod_boss_diameter],
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
battery_end_clearance switch_hole_diameter
switch_hole_compensation switch_cap_diameter
switch_flange_diameter switch_body_length
switch_body_diameter switch_wire_allowance],
battery_end_clearance battery_partition_thickness
battery_board_clearance
rocker_cutout_size rocker_corner_radius
rocker_body_size rocker_artwork_gap
rocker_partition_offset
rocker_tab_panel_thickness rocker_tab_land
rocker_tab_taper_length],
"USB-C opening" => %w[usb_opening_width usb_opening_height
usb_opening_radius usb_vertical_offset usb_connector_overhang
usb_guide_wall_thickness usb_guide_end_gap],
@@ -158,6 +184,7 @@ module FeatherCaseWizard
def printable_parts
parts = %w[body lid]
parts.concat(%w[lid_base lid_accent]) if lid_artwork?
if parameters["top_profile"] == "oled128x64" &&
%w[button_a button_b button_c button_reset].any? { |key| parameters[key] }
parts << "buttons"
@@ -165,6 +192,10 @@ module FeatherCaseWizard
parts + outputs["coupons"]
end
def lid_artwork?
!parameters["lid_label"].empty? || parameters["lid_logo_enabled"]
end
def model_parameters
parameters.merge("extra_cutouts" => scad_cutouts)
end
@@ -179,7 +210,10 @@ module FeatherCaseWizard
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
].max
board_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"]
board_origin_y = p["wall_thickness"] + p["board_side_clearance"]
combined_tripod_board_gutter = p["battery_enabled"] && p["tripod_insert_enabled"] ?
2.4 : 0.0
board_origin_y = p["wall_thickness"] + p["board_side_clearance"] +
combined_tripod_board_gutter
board_top_z = effective_floor + effective_board_clearance + 1.6
top_pcb_z = board_top_z + (
p["stack_height_override"].positive? ?
@@ -198,41 +232,41 @@ module FeatherCaseWizard
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"] +
partition_y = board_origin_y + 22.86 + p["battery_board_clearance"]
battery_y = partition_y + p["battery_partition_thickness"] +
p["battery_radial_clearance"] + p["battery_diameter"] / 2
battery_start_x = board_origin_x + p["battery_end_clearance"]
lid_boss_wall_overlap = 0.6
lid_boss_front_x = p["wall_thickness"] + p["m25_boss_diameter"] / 2 -
lid_boss_wall_overlap
battery_start_x = lid_boss_front_x + p["m25_boss_diameter"] / 2 +
p["battery_end_clearance"]
battery_end_x = battery_start_x + p["battery_length"]
switch_x = battery_end_x + [
p["switch_body_diameter"] / 2 + 0.8,
p["switch_flange_diameter"] / 2 + 0.5
].max
length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 +
1.2 + p["wall_thickness"]].max
battery_length_required = battery_end_x + p["battery_end_clearance"] +
p["wall_thickness"] + p["m25_boss_diameter"] -
lid_boss_wall_overlap
length = [plain_length, battery_length_required].max
width = [plain_width, battery_y + p["battery_diameter"] / 2 +
p["battery_radial_clearance"] + p["wall_thickness"]].max
battery_z = effective_floor + p["battery_radial_clearance"] +
p["battery_diameter"] / 2
switch_z = [
effective_floor + p["switch_flange_diameter"] / 2 + 1,
battery_z
].max
battery_ceiling = [
battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"],
switch_z + p["switch_flange_diameter"] / 2 + 1
].max
battery_ceiling = battery_z + p["battery_diameter"] / 2 +
p["battery_radial_clearance"]
rocker_ceiling = effective_floor + p["rocker_body_size"][2] +
p["fit_clearance"]
else
length = plain_length
width = plain_width
battery_ceiling = 0
rocker_ceiling = 0
end
{
"length" => length,
"width" => width,
"height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max,
"height" => [top_pcb_z + component_height + top_gap,
battery_ceiling, rocker_ceiling].max,
"floor" => effective_floor,
"lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max -
p["lid_lip_depth"]
"lip_bottom" => [top_pcb_z + component_height + top_gap,
battery_ceiling, rocker_ceiling].max - p["lid_lip_depth"]
}
end
@@ -265,6 +299,15 @@ module FeatherCaseWizard
raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens"
end
raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"])
unless LID_ART_ORIENTATIONS.include?(parameters["lid_art_orientation"])
raise ConfigError, "Unsupported lid art orientation"
end
unless parameters["lid_label"].is_a?(String)
raise ConfigError, "lid_label must be text"
end
if lid_artwork? && parameters["top_profile"] == "oled128x64"
raise ConfigError, "Lid artwork is not supported with the OLED profile"
end
raise ConfigError, "wing_count must be between 1 and 8" unless (1..8).cover?(parameters["wing_count"])
raise ConfigError, "At least one export format is required" if outputs["formats"].empty?
unless (outputs["formats"] - FORMATS).empty?
@@ -289,9 +332,27 @@ module FeatherCaseWizard
parameters["stack_height_override"].negative?
raise ConfigError, "wall_thickness must be at least 1.2 mm" if
parameters["wall_thickness"] < 1.2
raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if
parameters["battery_partition_thickness"] < 1.2
raise ConfigError, "rocker_tab_panel_thickness must be at least 1.2 mm" if
parameters["rocker_tab_panel_thickness"] < 1.2
if parameters["rocker_tab_panel_thickness"] >= parameters["lid_thickness"]
raise ConfigError, "rocker_tab_panel_thickness must be less than lid_thickness"
end
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
parameters["lid_lip_thickness"] < 1.2
raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if
parameters["lid_inlay_depth"] >= parameters["lid_thickness"]
if parameters["m25_insert_outer_diameter"] < parameters["m25_insert_hole_diameter"]
raise ConfigError, "m25_insert_outer_diameter must not be smaller than the pilot"
end
if parameters["tripod_insert_outer_diameter"] <
parameters["tripod_insert_hole_diameter"]
raise ConfigError,
"tripod_insert_outer_diameter must not be smaller than the pilot"
end
validate_usb_opening!
validate_cutouts!
self
end
@@ -305,6 +366,7 @@ module FeatherCaseWizard
legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled")
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters)
data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide
LEGACY_SWITCH_FIELDS.each { |key| data["parameters"].delete(key) }
data["cutouts"] ||= []
data["outputs"] ||= {}
data["outputs"]["formats"] ||= ["3mf"]
@@ -355,6 +417,19 @@ module FeatherCaseWizard
validate_cutout_spacing!(bounds, dims)
end
def validate_usb_opening!
return unless parameters["usb_opening_enabled"]
dims = dimensions
bounds = usb_wall_bounds(dims)
if bounds["bottom"] < dims["floor"]
raise ConfigError, "USB opening or guide intersects the case floor"
end
if bounds["top"] > dims["lip_bottom"]
raise ConfigError, "USB opening or guide intersects the lid lip"
end
end
def validate_cutout_spacing!(bounds, dims)
web = parameters["cutout_min_web"]
bounds.combination(2) do |first, second|
+64 -5
View File
@@ -2,16 +2,23 @@
module FeatherCaseWizard
class PackageBuilder
attr_reader :config, :root, :toolchain, :model_source, :renderer_source
attr_reader :config, :root, :toolchain, :model_source, :renderer_source,
:multicolor_renderer_source, :multicolor_packager_source, :logo_source
def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new,
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
multicolor_renderer_source: MULTICOLOR_RENDERER_SOURCE,
multicolor_packager_source: MULTICOLOR_PACKAGER_SOURCE,
logo_source: LOGO_SOURCE,
reporter: nil)
@config = config
@root = Pathname(root)
@toolchain = toolchain
@model_source = Pathname(model_source)
@renderer_source = Pathname(renderer_source)
@multicolor_renderer_source = Pathname(multicolor_renderer_source)
@multicolor_packager_source = Pathname(multicolor_packager_source)
@logo_source = Pathname(logo_source)
@reporter = reporter || ->(_message) {}
end
@@ -37,13 +44,22 @@ module FeatherCaseWizard
def verify_sources!
raise ToolError, "Missing model source: #{model_source}" unless model_source.file?
raise ToolError, "Missing preview renderer: #{renderer_source}" unless renderer_source.file?
raise ToolError, "Missing multicolor renderer: #{multicolor_renderer_source}" unless
multicolor_renderer_source.file?
raise ToolError, "Missing multicolor packager: #{multicolor_packager_source}" unless
multicolor_packager_source.file?
raise ToolError, "Missing lid logo: #{logo_source}" unless logo_source.file?
end
def prepare_package(stage, previous)
source_dir = stage.join("source")
FileUtils.mkdir_p(source_dir)
tools_dir = stage.join("tools")
FileUtils.mkdir_p([source_dir, source_dir.join("assets"), tools_dir])
FileUtils.cp(model_source, source_dir.join("feather_case.scad"))
FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py"))
FileUtils.cp(multicolor_renderer_source, source_dir.join("render_multicolor_lid.py"))
FileUtils.cp(multicolor_packager_source, tools_dir.join("package_multimaterial_3mf.py"))
FileUtils.cp(logo_source, source_dir.join("assets/NME-fixed.svg"))
FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb"))
copy_history(previous, stage)
@@ -91,9 +107,26 @@ module FeatherCaseWizard
report("Exporting #{part}.#{format}")
export_model(stage, directory.join("#{part}.#{format}"), part)
end
package_multicolor_lid(stage, directory) if format == "3mf" && config.lid_artwork?
end
end
def package_multicolor_lid(stage, directory)
report("Packaging lid_multicolor.3mf")
command = [
toolchain.python,
stage.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.name} Lid"
]
toolchain.run!(*command)
output = directory.join("lid_multicolor.3mf")
raise ToolError, "Multicolor packager did not create #{output}" unless
output.file? && output.size.positive?
end
def generate_previews(stage)
previews = stage.join("previews")
scratch = stage.join(".preview-meshes")
@@ -111,9 +144,22 @@ module FeatherCaseWizard
export_model(stage, mesh, part) unless mesh.file?
render(stage, mesh, previews, part, ["iso"])
end
render_multicolor_lid(stage, scratch, previews) if config.lid_artwork?
FileUtils.rm_rf(scratch)
end
def render_multicolor_lid(stage, scratch, previews)
report("Rendering multicolor lid preview")
command = [
toolchain.blender, "--background", "-noaudio", "--python",
stage.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", config.parameters["lid_art_orientation"]
]
toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil})
end
def export_model(stage, output, part, overrides = {})
model = stage.join("source/feather_case.scad")
parameters = config.model_parameters.merge(overrides).merge("part" => part)
@@ -139,6 +185,18 @@ module FeatherCaseWizard
def write_summary(stage)
dims = config.dimensions
parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ")
artwork = if config.lid_artwork?
<<~ARTWORK.chomp
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
Filament 2” to the desired filaments. `lid.3mf` is the complete
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
provided separately.
ARTWORK
else
""
end
text = <<~MARKDOWN
# #{config.name}
@@ -154,8 +212,8 @@ module FeatherCaseWizard
- Custom cutouts: #{config.cutouts.length}
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
@@ -165,8 +223,9 @@ module FeatherCaseWizard
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
#{artwork}
MARKDOWN
File.write(stage.join("README.md"), text)
File.write(stage.join("README.md"), text.rstrip + "\n")
end
def install_atomically(stage, destination)
+3 -1
View File
@@ -4,18 +4,20 @@ module FeatherCaseWizard
class ToolError < StandardError; end
class Toolchain
attr_reader :openscad, :blender
attr_reader :openscad, :blender, :python
def initialize(env: ENV)
@env = env
@openscad = discover("OPENSCAD", "openscad")
@blender = discover("BLENDER", "blender")
@python = discover("PYTHON", "python3")
end
def check!
missing = []
missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad
missing << "Blender (`sudo apt-get install blender` or set BLENDER)" unless blender
missing << "Python 3 (`sudo apt-get install python3` or set PYTHON)" unless python
return self if missing.empty?
raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}"
+21
View File
@@ -44,6 +44,7 @@ module FeatherCaseWizard
choose_stack(config)
choose_end_clearance(config)
choose_features(config)
choose_lid_artwork(config)
manage_cutouts(config)
choose_coupons(config)
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
@@ -143,6 +144,26 @@ module FeatherCaseWizard
)
end
def choose_lid_artwork(config)
p = config.parameters
label_enabled = agreement("Add a two-color device title to the lid?",
!p["lid_label"].empty?)
p["lid_label"] = label_enabled ? text(
"Device title", p["lid_label"].empty? ? config.name : p["lid_label"]
) : ""
p["lid_logo_enabled"] = agreement("Add the two-color NME logo to the lid?",
p["lid_logo_enabled"])
return unless !p["lid_label"].empty? || p["lid_logo_enabled"]
current = p["lid_art_orientation"]
p["lid_art_orientation"] = ui.choose("USB position when the lid text is upright") do |menu|
Config::LID_ART_ORIENTATIONS.each do |orientation|
menu.choice(orientation.delete_prefix("usb_").capitalize) { orientation }
end
menu.default = (Config::LID_ART_ORIENTATIONS.index(current).to_i + 1).to_s
end
end
def manage_cutouts(config)
loop do
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" :
+25
View File
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
+168 -1
View File
@@ -72,6 +72,26 @@ class ConfigTest < Minitest::Test
assert_match(/guide requires/, error.message)
end
def test_usb_opening_expands_upward_without_moving_bottom_edge
parameters = config.parameters
compensation = parameters["hole_compensation"]
board_top = config.dimensions["floor"] +
parameters["board_bottom_clearance"] + 1.6
old_center = board_top + 1.6
old_width = 12.5 + compensation
old_height = 7.0 + compensation
new_center = old_center + parameters["usb_vertical_offset"]
new_width = parameters["usb_opening_width"] + compensation
new_height = parameters["usb_opening_height"] + compensation
assert_in_delta 1.0, new_width - old_width, 0.001
assert_in_delta old_center - old_height / 2,
new_center - new_height / 2, 0.001
assert_in_delta 1.0,
(new_center + new_height / 2) -
(old_center + old_height / 2), 0.001
end
def test_end_clearances_expand_case_length
subject = config
subject.parameters["usb_board_wall_gap"] = 20.0
@@ -80,6 +100,48 @@ class ConfigTest < Minitest::Test
assert_in_delta 95.6, subject.dimensions["length"], 0.01
end
def test_insert_defaults_match_selected_hardware
parameters = config.parameters
assert_in_delta 4.0, parameters["m25_insert_length"], 0.001
assert_in_delta 3.1, parameters["m25_insert_hole_diameter"], 0.001
assert_in_delta 3.5, parameters["m25_insert_outer_diameter"], 0.001
assert_in_delta 5.0, parameters["tripod_insert_length"], 0.001
assert_in_delta 7.2, parameters["tripod_insert_hole_diameter"], 0.001
assert_in_delta 8.0, parameters["tripod_insert_outer_diameter"], 0.001
end
def test_tripod_floor_tracks_insert_length
subject = config
subject.parameters["tripod_insert_enabled"] = true
assert_in_delta 7.0, subject.dimensions["floor"], 0.001
end
def test_insert_coupon_uses_finished_pilot_ranges
source = FeatherCaseWizard::MODEL_SOURCE.read
assert_includes source,
"m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];"
assert_includes source,
"tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];"
assert_includes source, "bore_d = diameter;"
refute_includes source, "bore_d = diameter + hole_compensation;"
end
def test_lid_rocker_mirrors_to_installed_feather_side
source = FeatherCaseWizard::MODEL_SOURCE.read
assert_includes source,
"rocker_lid_center_y = case_width - rocker_installed_center_y;"
assert_includes source,
"translate([rocker_lid_center_x, rocker_lid_center_y, -eps])"
assert_includes source,
"rocker_installed_center_y - rocker_body_size[1] / 2"
assert_includes source,
"+ rocker_body_size[1] / 2 + rocker_partition_offset;"
end
def test_oled_buttons_and_selected_coupons_are_printable
subject = config
subject.parameters["top_profile"] = "oled128x64"
@@ -90,6 +152,111 @@ class ConfigTest < Minitest::Test
assert_equal %w[body lid buttons fit_coupon], subject.printable_parts
end
def test_combined_battery_and_tripod_case_adds_partition_clearance
subject = config
subject.parameters["battery_enabled"] = true
subject.parameters["tripod_insert_enabled"] = true
assert_in_delta 88.6, subject.dimensions["length"], 0.01
assert_in_delta 61.06, subject.dimensions["width"], 0.01
assert_in_delta 31.25, subject.dimensions["height"], 0.01
end
def test_battery_board_clearance_moves_the_whole_bay_outward
subject = config
subject.parameters["battery_enabled"] = true
original_width = subject.dimensions["width"]
subject.parameters["battery_board_clearance"] += 2.0
assert_in_delta 2.0, subject.dimensions["width"] - original_width, 0.001
end
def test_switch_tab_relief_uses_two_mm_lands_in_lid_and_coupon
source = FeatherCaseWizard::MODEL_SOURCE.read
assert_in_delta 2.0, config.parameters["rocker_tab_panel_thickness"], 0.001
assert_in_delta 2.0, config.parameters["rocker_tab_land"], 0.001
assert_includes source, "module rocker_tab_reliefs(center, opening_size)"
assert_equal 2, source.scan(/rocker_tab_reliefs\(/).length - 1
assert_includes source,
"edge_y = center[1] + side * opening_size[1] / 2;"
assert_includes source,
"opening_overlap = rocker_corner_radius;"
assert_includes source,
"cube([opening_size[0], rocker_tab_land + opening_overlap,"
refute_includes source,
"edge_x = center[0] + side * opening_size[0] / 2;"
end
def test_switch_moves_two_mm_toward_partition_on_installed_lid
assert_in_delta 2.0, config.parameters["rocker_partition_offset"], 0.001
end
def test_switch_tab_panel_must_be_thinner_than_lid
subject = config
subject.parameters["rocker_tab_panel_thickness"] =
subject.parameters["lid_thickness"]
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/rocker_tab_panel_thickness/, error.message)
end
def test_legacy_round_switch_fields_migrate_to_rocker_defaults
data = config.data
data["parameters"]["switch_hole_diameter"] = 12.0
data["parameters"]["switch_body_length"] = 24.0
subject = FeatherCaseWizard::Config.new(data)
refute subject.parameters.key?("switch_hole_diameter")
refute subject.parameters.key?("switch_body_length")
assert_equal [12.9, 19.4], subject.parameters["rocker_cutout_size"]
assert_equal [15.0, 21.0, 21.0], subject.parameters["rocker_body_size"]
end
def test_battery_partition_requires_three_06_mm_lines
subject = config
subject.parameters["battery_partition_thickness"] = 1.19
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/battery_partition_thickness/, error.message)
end
def test_insert_entry_cannot_be_smaller_than_pilot
subject = config
subject.parameters["m25_insert_outer_diameter"] = 3.0
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/m25_insert_outer_diameter/, error.message)
end
def test_lid_artwork_adds_base_and_accent_parts
subject = config
subject.parameters["lid_label"] = "GPS"
subject.parameters["lid_logo_enabled"] = true
assert subject.lid_artwork?
assert_equal %w[body lid lid_base lid_accent], subject.printable_parts
end
def test_lid_artwork_rejects_oled_profile
subject = config
subject.parameters["top_profile"] = "oled128x64"
subject.parameters["lid_label"] = "OLED"
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/not supported with the OLED profile/, error.message)
end
def test_lid_artwork_orientation_is_named
subject = config
subject.parameters["lid_art_orientation"] = "diagonal"
error = assert_raises(FeatherCaseWizard::ConfigError) { subject.validate! }
assert_match(/orientation/, error.message)
end
def test_wall_local_cutouts_map_to_model_coordinates
subject = config
length = subject.dimensions["length"]
@@ -138,7 +305,7 @@ class ConfigTest < Minitest::Test
def test_front_cutout_can_clear_usb_reservation
subject = config
subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)]
subject.data["cutouts"] = [cutout("front", 8.0).merge("z" => 11.0)]
assert_same subject, subject.validate!
end
+39 -2
View File
@@ -29,6 +29,7 @@ class PackageBuilderTest < Minitest::Test
%w[iso front back left right top].each do |view|
assert destination.join("previews/assembly_#{view}.png").file?
end
refute_match(/\n\n\z/, destination.join("README.md").read)
config.parameters["button_reset"] = true
build(config, root.join("cases"), tools, model, renderer)
@@ -68,6 +69,30 @@ class PackageBuilderTest < Minitest::Test
end
end
def test_builds_aligned_multicolor_lid_package_when_artwork_is_enabled
Dir.mktmpdir do |directory|
root = Pathname(directory)
tools = create_fake_tools(root)
model = root.join("model.scad")
renderer = root.join("renderer.py")
File.write(model, "cube([1,1,1]);\n")
File.write(renderer, "# test renderer\n")
config = FeatherCaseWizard::Config.create(name: "IMU", slug: "imu")
config.parameters["lid_label"] = "IMU"
config.parameters["lid_logo_enabled"] = true
destination = build(config, root.join("cases"), tools, model, renderer)
%w[lid lid_base lid_accent].each do |part|
assert destination.join("exports/3mf/#{part}.3mf").file?
end
assert destination.join("exports/3mf/lid_multicolor.3mf").file?
assert destination.join("source/assets/NME-fixed.svg").file?
assert destination.join("tools/package_multimaterial_3mf.py").file?
assert destination.join("previews/lid_multicolor_top.png").file?
end
end
private
def build(config, root, tools, model, renderer)
@@ -93,14 +118,26 @@ class PackageBuilderTest < Minitest::Test
#!/usr/bin/env ruby
require "fileutils"
args = ARGV[(ARGV.index("--") + 1)..]
multicolor = ARGV[ARGV.index("--python") + 1].include?("render_multicolor")
if multicolor
_base, _accent, output, basename = args
views = %w[iso top]
else
_mesh, output, basename, selected = args
views = selected ? selected.split(",") : %w[iso front back left right top]
end
FileUtils.mkdir_p(output)
views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") }
RUBY
FileUtils.chmod(0o755, [openscad, blender])
python = bin.join("python3")
File.write(python, <<~RUBY)
#!/usr/bin/env ruby
File.binwrite(ARGV[3], "multipart")
RUBY
FileUtils.chmod(0o755, [openscad, blender, python])
FeatherCaseWizard::Toolchain.new(
env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s, "BLENDER" => blender.to_s}
env: {"PATH" => bin.to_s, "OPENSCAD" => openscad.to_s,
"BLENDER" => blender.to_s, "PYTHON" => python.to_s}
)
end
end
+4 -2
View File
@@ -9,6 +9,7 @@ class ToolchainTest < Minitest::Test
assert_match(/OpenSCAD/, error.message)
assert_match(/Blender/, error.message)
assert_match(/Python 3/, error.message)
assert_match(/apt-get/, error.message)
end
@@ -18,13 +19,14 @@ class ToolchainTest < Minitest::Test
File.write(executable, "#!/bin/sh\nexit 0\n")
FileUtils.chmod(0o755, executable)
toolchain = FeatherCaseWizard::Toolchain.new(
env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable}
env: {"PATH" => "", "OPENSCAD" => executable, "BLENDER" => executable,
"PYTHON" => executable}
)
assert_equal executable, toolchain.openscad
assert_equal executable, toolchain.blender
assert_equal executable, toolchain.python
assert_same toolchain, toolchain.check!
end
end
end
+4 -4
View File
@@ -6,8 +6,8 @@ require_relative "test_helper"
class WizardTest < Minitest::Test
def test_minimal_new_case_flow_and_confirmation
input = StringIO.new(
["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "1",
"n", "n", "n", "n", "y"].join("\n") + "\n"
["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "n", "n",
"1", "n", "n", "n", "n", "y"].join("\n") + "\n"
)
output = StringIO.new
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output))
@@ -24,8 +24,8 @@ class WizardTest < Minitest::Test
config = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps")
config.parameters["top_profile"] = "gps"
input = StringIO.new(
["", "", "", "", "n", "n", "", "", "n", "n", "1", "n", "n", "n",
"n"].join("\n") + "\n"
["", "", "", "", "n", "n", "", "", "n", "n", "n", "n", "1", "n",
"n", "n", "n"].join("\n") + "\n"
)
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, StringIO.new))
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""Render aligned lid-base and accent STLs with distinct materials."""
import os
import sys
import math
import bpy
from mathutils import Matrix, Vector
def import_mesh(path, name, color):
bpy.ops.wm.stl_import(filepath=os.path.abspath(path))
model = bpy.context.selected_objects[0]
model.name = name
material = bpy.data.materials.new(f"{name} material")
material.diffuse_color = color
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
return model
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (4, 5):
raise SystemExit(
"expected: lid-base.stl lid-accent.stl output-directory basename [orientation]"
)
base_path, accent_path, output_dir, basename = args[:4]
orientation = args[4] if len(args) == 5 else "usb_left"
angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270}
if orientation not in angles:
raise SystemExit(f"unknown lid orientation: {orientation}")
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
models = [
import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)),
import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)),
]
# Lid exports are print-oriented with the decorated exterior face down.
# Turn both aligned parts over and present the configured USB edge in its
# named position so previews inspect the finished face as the user will.
for model in models:
model.matrix_world = (
Matrix.Rotation(math.radians(angles[orientation]), 4, "Z")
@ Matrix.Rotation(math.pi, 4, "X")
)
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items():
camera.location = center + Vector(direction).normalized() * span * 2.2
look_at(camera, center)
scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png")
bpy.ops.render.render(write_still=True)
if __name__ == "__main__":
main()