Compare commits

...
2 Commits
Author SHA1 Message Date
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
48 changed files with 3868 additions and 2049 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 `cases/<name>/`; exports, previews, revision history, and `vendor/bundle` are
ignored by Git. 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 ```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 Set `OPENSCAD=/path/to/openscad`, `BLENDER=/path/to/blender`, or
program is installed elsewhere. The wizard checks both tools before replacing `PYTHON=/path/to/python3` when a program is installed elsewhere. The wizard
an existing package. checks all three tools before replacing an existing package.
The wizard can add clearance beyond either short end of the Feather PCB for 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 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 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. 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 ## Custom wire egress
`extra_cutouts` accepts any number of entries: `extra_cutouts` accepts any number of entries:
@@ -164,9 +183,11 @@ points.
1. Export and print `part="insert_coupon"` in the intended material. 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. 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 3. Set `m25_insert_hole_diameter` and `tripod_insert_hole_diameter` to the best
coupon values. coupon values. These parameters are the finished pilot diameters in the
4. Print `part="switch_coupon"` and choose the best hole for the actual DMWD model; `hole_compensation` is not added to heat-set pockets. The coupon uses
switch batch. 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 5. Print `part="fit_coupon"` and tune `fit_clearance` before committing to the
complete lid and body. complete lid and body.
@@ -180,13 +201,16 @@ temperature control, and the same extrusion settings intended for the case.
opening faces the exterior bottom. opening faces the exterior bottom.
2. Heat-set four M2.5 inserts into the board standoffs. 2. Heat-set four M2.5 inserts into the board standoffs.
3. Fasten the ESP32 Feather V2, then install the stacking headers and Wings. 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 4. For battery builds, place the protected 69 x 18 mm pack in the cradle and
its lead through the partition notch, and mount the latching switch in the route its lead through the combined front partition/JST opening and beneath
battery-side wall. the Feather PCB.
5. Heat-set the four lid inserts downward into the corner bosses. 5. Snap the KCD1-101 rocker switch into the rear lid opening, connect its bent
6. For OLED builds, insert each enabled printed plunger through the lid from 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. 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. 2.5 mm-deep counterbores.
Confirm the USB plug, switch, battery, insert, and lid fits without force before 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
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) permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad") openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender") blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge( parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") "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 = %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] buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" && if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] } buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…" puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part) export.call(directory.join("#{part}.#{format}"), part)
end 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 end
assembly = scratch.join("assembly.stl") assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env) mesh.to_s, previews.to_s, part, "iso", env: render_env)
end 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}") backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup) FileUtils.mkdir_p(backup)
@@ -1,9 +1,9 @@
--- ---
schema_version: 1 schema_version: 1
name: Attitude IMU name: Attitude no battery
slug: attitude-imu slug: attitude-no-battery
generator: generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: generic top_profile: generic
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0 battery_diameter: 18.0
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
switch_hole_diameter: 12.0 battery_partition_thickness: 1.8
switch_hole_compensation: 0.3 battery_board_clearance: 3.0
switch_cap_diameter: 8.0 rocker_cutout_size:
switch_flange_diameter: 16.2 - 12.9
switch_body_length: 24.0 - 19.4
switch_body_diameter: 12.0 rocker_corner_radius: 0.8
switch_wire_allowance: 3.0 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_enabled: true
tripod_insert_length: 6.4 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 8.8 tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8 tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0 tripod_boss_diameter: 14.0
m25_insert_length: 4.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_screw_clearance: 2.8
m25_boss_diameter: 7.0 m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6 insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4 board_bottom_clearance: 5.4
board_side_clearance: 7.0 board_side_clearance: 7.0
board_rear_clearance: 4.0 board_rear_clearance: 4.0
usb_board_wall_gap: 1.2 usb_board_wall_gap: 2.0
lid_lip_depth: 2.4 lid_lip_depth: 2.4
lid_lip_thickness: 1.2 lid_lip_thickness: 1.2
fit_clearance: 0.35 fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2 hole_compensation: 0.2
lid_screw_head_diameter: 5.2 lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5 lid_screw_head_height: 2.5
usb_opening_width: 12.5 usb_opening_enabled: true
usb_opening_height: 7.0 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_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
@@ -1,10 +1,10 @@
{ {
"parameterSets": { "parameterSets": {
"pan-tilt-head": { "attitude-no-battery": {
"top_profile": "\"oled128x64\"", "top_profile": "\"generic\"",
"wing_count": "3", "wing_count": "1",
"wing_pitch": "12.25", "wing_pitch": "12.25",
"stack_height_override": "38.0", "stack_height_override": "0.0",
"generic_component_height": "3.0", "generic_component_height": "3.0",
"generic_top_gap": "2.0", "generic_top_gap": "2.0",
"gps_antenna_height": "6.0", "gps_antenna_height": "6.0",
@@ -27,20 +27,25 @@
"battery_diameter": "18.0", "battery_diameter": "18.0",
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0", "battery_partition_thickness": "1.8",
"switch_hole_compensation": "0.3", "battery_board_clearance": "3.0",
"switch_cap_diameter": "8.0", "rocker_cutout_size": "[12.9,19.4]",
"switch_flange_diameter": "16.2", "rocker_corner_radius": "0.8",
"switch_body_length": "24.0", "rocker_body_size": "[15.0,21.0,21.0]",
"switch_body_diameter": "12.0", "rocker_artwork_gap": "2.0",
"switch_wire_allowance": "3.0", "rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "6.4", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "8.8", "tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8", "tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0", "tripod_boss_diameter": "14.0",
"m25_insert_length": "4.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_screw_clearance": "2.8",
"m25_boss_diameter": "7.0", "m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6", "insert_entry_chamfer": "0.6",
@@ -51,8 +56,8 @@
"corner_radius": "4.0", "corner_radius": "4.0",
"board_bottom_clearance": "5.4", "board_bottom_clearance": "5.4",
"board_side_clearance": "7.0", "board_side_clearance": "7.0",
"board_rear_clearance": "20.0", "board_rear_clearance": "4.0",
"usb_board_wall_gap": "20.0", "usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4", "lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2", "lid_lip_thickness": "1.2",
"fit_clearance": "0.35", "fit_clearance": "0.35",
@@ -65,10 +70,18 @@
"usb_connector_overhang": "2.0", "usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2", "usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0", "usb_guide_end_gap": "1.0",
"usb_opening_width": "12.5", "lid_label": "\"IMU\"",
"usb_opening_height": "7.0", "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_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "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] */ /* [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] exploded_view = 8; // [0:1:20]
show_references = true; show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18; battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
switch_hole_diameter = 12; battery_partition_thickness = 1.8;
switch_hole_compensation = 0.3; battery_board_clearance = 3;
switch_cap_diameter = 8; rocker_cutout_size = [12.9, 19.4];
switch_flange_diameter = 16.2; rocker_corner_radius = 0.8;
switch_body_length = 24; rocker_body_size = [15, 21, 21];
switch_body_diameter = 12; rocker_artwork_gap = 2;
switch_wire_allowance = 3; rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
tripod_insert_length = 6.4; tripod_insert_length = 5;
tripod_insert_hole_diameter = 8.8; tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8; tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14; tripod_boss_diameter = 14;
/* [M2.5 inserts] */ /* [M2.5 inserts] */
m25_insert_length = 4; 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_screw_clearance = 2.8;
m25_boss_diameter = 7; m25_boss_diameter = 7;
insert_entry_chamfer = 0.6; insert_entry_chamfer = 0.6;
@@ -87,16 +92,27 @@ hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
/* [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-C opening] */
usb_opening_enabled = true; usb_opening_enabled = true;
usb_guide_enabled = true; usb_guide_enabled = true;
usb_connector_overhang = 2; usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2; usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1; usb_guide_end_gap = 1;
usb_opening_width = 12.5; usb_opening_width = 13.5;
usb_opening_height = 7; usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -106,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */ /* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; 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; $fn = $preview ? 36 : 72;
eps = 0.02; eps = 0.02;
@@ -117,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26]; mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32]; mount_y = [2.54, 20.32];
usb_center_y = 11.43; 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. // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78]; oled_visible_min = [11.42, 3.78];
@@ -133,9 +154,18 @@ effective_board_clearance = max(
board_bottom_clearance, board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6 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_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_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y; usb_center_u = board_origin_y + usb_center_y;
@@ -166,20 +196,17 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance battery_center_y = partition_y + battery_partition_thickness
+ battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance; battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length; 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 case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness; + board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2 case_length_battery = battery_end_x + battery_end_clearance
+ 1.2 + wall_thickness; + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery) case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain; : 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 = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : 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_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2; + 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; electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled battery_ceiling_z = battery_enabled
? max( ? battery_center_z + battery_diameter / 2 + battery_radial_clearance
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0; : 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 = [ lid_boss_xy = [
[ [
wall_thickness + lid_boss_r - lid_boss_wall_overlap, 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 case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile"); || top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| 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, assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material"); "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 assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled, assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening"); "USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| 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 clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4]; 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); rounded_rect_2d([size[0], size[1]], radius);
} }
module insert_pocket(diameter, depth, entry_z, downward = true) { module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation; bore_d = diameter;
if (downward) { if (downward) {
translate([0, 0, entry_z - depth]) translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d); cylinder(h = depth + eps, d = bore_d);
@@ -306,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d, d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer d2 = entry_diameter
); );
} else { } else {
translate([0, 0, entry_z - eps]) 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]) translate([0, 0, entry_z - eps])
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer, d1 = entry_diameter,
d2 = bore_d d2 = bore_d
); );
} }
@@ -457,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4 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() { difference() {
translate([wall_thickness, partition_y, effective_floor - eps]) translate([wall_thickness, partition_y, effective_floor - eps])
cube([ cube([
battery_end_x - wall_thickness + battery_end_clearance, battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness, battery_partition_thickness,
partition_height - effective_floor + eps partition_height - effective_floor + eps
]); ]);
translate([ translate([
board_origin_x + 6.5, wall_thickness - eps,
partition_y - eps, partition_y - eps,
effective_floor + 1.5 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. // Three low curved ribs cradle the protected cylindrical pack.
@@ -501,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance battery_end_x + battery_end_clearance
]) ])
translate([ translate([
x, x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance, battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps effective_floor - eps
]) ])
cube([ cube([
wall_thickness, wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance, battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps 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() { module body_positive() {
@@ -542,7 +716,7 @@ module body_positive() {
if (battery_enabled) if (battery_enabled)
battery_cradle(); battery_cradle();
if (tripod_insert_enabled) 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); cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active) if (usb_guide_active)
usb_guide_shell(); usb_guide_shell();
@@ -555,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0]) translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
board_bottom_z board_bottom_z
); );
@@ -564,6 +739,7 @@ module body_cutouts() {
translate([p[0], p[1], 0]) translate([p[0], p[1], 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
body_height body_height
); );
@@ -579,29 +755,8 @@ module body_cutouts() {
usb_opening_radius 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) { if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps]) translate([0, 0, -eps])
cylinder( cylinder(
h = effective_floor + 2 * eps, h = effective_floor + 2 * eps,
@@ -609,6 +764,7 @@ module body_cutouts() {
); );
insert_pocket( insert_pocket(
tripod_insert_hole_diameter, tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief, tripod_insert_length + insert_melt_relief,
effective_floor effective_floor
); );
@@ -702,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() { module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads. // Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) { 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]) translate([p[0], p[1], -eps])
cylinder( cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps, 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") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy); 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() { difference() {
lid_plate_positive(); lid_plate_positive();
lid_cutouts(); 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() { module one_oled_button() {
recess_depth = button_flange_thickness + button_travel; recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection; stem_height = lid_thickness - recess_depth + button_projection;
@@ -769,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75]) color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z]) translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54); 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]) color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count]) for (i = [1 : wing_count])
translate([ translate([
@@ -835,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z]) translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0]) rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter); 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([ translate([
switch_center_x, rocker_installed_center_x - rocker_body_size[0] / 2,
case_width - wall_thickness, rocker_installed_center_y - rocker_body_size[1] / 2,
switch_center_z body_height + exploded_view - rocker_body_size[2]
]) ])
rotate([90, 0, 0]) { cube(rocker_body_size);
cylinder(h = switch_body_length, d = switch_body_diameter); color([0.12, 0.12, 0.12, 0.95])
translate([0, 0, -1.5]) translate([
cylinder(h = 1.5, d = switch_flange_diameter); rocker_installed_center_x - rocker_cutout_size[0] / 2,
translate([0, 0, -3]) rocker_installed_center_y - rocker_cutout_size[1] / 2,
cylinder(h = 3, d = switch_cap_diameter); body_height + exploded_view + lid_thickness
} ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
} }
} }
@@ -865,13 +1126,9 @@ module assembly() {
lid(); lid();
} }
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() { module insert_coupon() {
spacing = 15; spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8; coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32; coupon_width = 32;
coupon_height = max( coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2, m25_insert_length + insert_melt_relief + 1.2,
@@ -879,17 +1136,21 @@ module insert_coupon() {
); );
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) { for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing; x = 7 + i * spacing;
coupon_hole( translate([x, 8, 0])
x, 8, insert_pocket(
m25_insert_hole_diameter + coupon_steps[i], m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_length + insert_melt_relief m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
); );
coupon_hole( translate([x, 23, 0])
x, 23, insert_pocket(
tripod_insert_hole_diameter + coupon_steps[i], tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_length + insert_melt_relief tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
); );
} }
} }
@@ -898,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() { module switch_coupon() {
spacing = 18; spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6; coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24; coupon_width = rocker_cutout_size[1] + 6;
coupon_height = wall_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder( centered_rounded_prism(
h = coupon_height + 2 * eps, rocker_cutout_size
d = switch_hole_diameter + coupon_steps[i] + [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(); body();
else if (part == "lid") else if (part == "lid")
lid(); lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons") else if (part == "buttons")
buttons(); buttons();
else if (part == "insert_coupon") 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()
@@ -1,4 +1,4 @@
# Attitude IMU - Battery # Attitude w/battery
Generated by the Feather Case Wizard. Generated by the Feather Case Wizard.
@@ -6,19 +6,19 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1 - FeatherWings: 1
- Battery compartment: yes - Battery compartment: yes
- 1/4-20 insert: yes - 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 35.8 mm - Approximate exterior: 88.6 × 61.1 × 34.4 mm
- Formats: 3mf - Formats: 3mf
- Parts: `body`, `lid` - Parts: `body`, `lid`
- Custom cutouts: 0 - Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews. Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
override their executable paths. `BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually: To open the saved Customizer values manually:
```bash ```bash
openscad -p parameters.json -P attitude-imu-battery source/feather_case.scad openscad -p parameters.json -P attitude-w-battery source/feather_case.scad
``` ```
The 3MF files contain portable geometry only. Choose printer, filament, The 3MF files contain portable geometry only. Choose printer, filament,
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false) permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad") openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender") blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge( parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") "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 = %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] buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" && if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] } buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…" puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part) export.call(directory.join("#{part}.#{format}"), part)
end 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 end
assembly = scratch.join("assembly.stl") assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env) mesh.to_s, previews.to_s, part, "iso", env: render_env)
end 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}") backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup) FileUtils.mkdir_p(backup)
@@ -1,9 +1,9 @@
--- ---
schema_version: 1 schema_version: 1
name: Attitude IMU - Battery name: Attitude w/battery
slug: attitude-imu-battery slug: attitude-w-battery
generator: generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: generic top_profile: generic
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0 battery_diameter: 18.0
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
switch_hole_diameter: 12.0 battery_partition_thickness: 1.8
switch_hole_compensation: 0.3 battery_board_clearance: 3.0
switch_cap_diameter: 8.0 rocker_cutout_size:
switch_flange_diameter: 16.2 - 12.9
switch_body_length: 24.0 - 19.4
switch_body_diameter: 12.0 rocker_corner_radius: 0.8
switch_wire_allowance: 3.0 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_enabled: true
tripod_insert_length: 6.4 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 8.8 tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8 tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0 tripod_boss_diameter: 14.0
m25_insert_length: 4.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_screw_clearance: 2.8
m25_boss_diameter: 7.0 m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6 insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4 board_bottom_clearance: 5.4
board_side_clearance: 7.0 board_side_clearance: 7.0
board_rear_clearance: 4.0 board_rear_clearance: 4.0
usb_board_wall_gap: 1.2 usb_board_wall_gap: 2.0
lid_lip_depth: 2.4 lid_lip_depth: 2.4
lid_lip_thickness: 1.2 lid_lip_thickness: 1.2
fit_clearance: 0.35 fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2 hole_compensation: 0.2
lid_screw_head_diameter: 5.2 lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5 lid_screw_head_height: 2.5
usb_opening_width: 12.5 usb_opening_enabled: true
usb_opening_height: 7.0 usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.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_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
@@ -1,6 +1,6 @@
{ {
"parameterSets": { "parameterSets": {
"attitude-imu-battery": { "attitude-w-battery": {
"top_profile": "\"generic\"", "top_profile": "\"generic\"",
"wing_count": "1", "wing_count": "1",
"wing_pitch": "12.25", "wing_pitch": "12.25",
@@ -27,20 +27,25 @@
"battery_diameter": "18.0", "battery_diameter": "18.0",
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0", "battery_partition_thickness": "1.8",
"switch_hole_compensation": "0.3", "battery_board_clearance": "3.0",
"switch_cap_diameter": "8.0", "rocker_cutout_size": "[12.9,19.4]",
"switch_flange_diameter": "16.2", "rocker_corner_radius": "0.8",
"switch_body_length": "24.0", "rocker_body_size": "[15.0,21.0,21.0]",
"switch_body_diameter": "12.0", "rocker_artwork_gap": "2.0",
"switch_wire_allowance": "3.0", "rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "6.4", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "8.8", "tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8", "tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0", "tripod_boss_diameter": "14.0",
"m25_insert_length": "4.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_screw_clearance": "2.8",
"m25_boss_diameter": "7.0", "m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6", "insert_entry_chamfer": "0.6",
@@ -52,17 +57,31 @@
"board_bottom_clearance": "5.4", "board_bottom_clearance": "5.4",
"board_side_clearance": "7.0", "board_side_clearance": "7.0",
"board_rear_clearance": "4.0", "board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2", "usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4", "lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2", "lid_lip_thickness": "1.2",
"fit_clearance": "0.35", "fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2", "hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2", "lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5", "lid_screw_head_height": "2.5",
"usb_opening_width": "12.5", "usb_opening_enabled": "true",
"usb_opening_height": "7.0", "usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.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_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "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] */ /* [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] exploded_view = 8; // [0:1:20]
show_references = true; show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18; battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
switch_hole_diameter = 12; battery_partition_thickness = 1.8;
switch_hole_compensation = 0.3; battery_board_clearance = 3;
switch_cap_diameter = 8; rocker_cutout_size = [12.9, 19.4];
switch_flange_diameter = 16.2; rocker_corner_radius = 0.8;
switch_body_length = 24; rocker_body_size = [15, 21, 21];
switch_body_diameter = 12; rocker_artwork_gap = 2;
switch_wire_allowance = 3; rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
tripod_insert_length = 6.4; tripod_insert_length = 5;
tripod_insert_hole_diameter = 8.8; tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8; tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14; tripod_boss_diameter = 14;
/* [M2.5 inserts] */ /* [M2.5 inserts] */
m25_insert_length = 4; 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_screw_clearance = 2.8;
m25_boss_diameter = 7; m25_boss_diameter = 7;
insert_entry_chamfer = 0.6; insert_entry_chamfer = 0.6;
@@ -78,19 +83,36 @@ corner_radius = 4;
board_bottom_clearance = 5.4; board_bottom_clearance = 5.4;
board_side_clearance = 7; board_side_clearance = 7;
board_rear_clearance = 4; board_rear_clearance = 4;
usb_board_wall_gap = 1.2; usb_board_wall_gap = 2;
lid_lip_depth = 2.4; lid_lip_depth = 2.4;
lid_lip_thickness = 1.2; lid_lip_thickness = 1.2;
fit_clearance = 0.35; fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2; hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
/* [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-C opening] */
usb_opening_width = 12.5; usb_opening_enabled = true;
usb_opening_height = 7; usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -100,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */ /* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; 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; $fn = $preview ? 36 : 72;
eps = 0.02; eps = 0.02;
@@ -111,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26]; mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32]; mount_y = [2.54, 20.32];
usb_center_y = 11.43; 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. // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78]; oled_visible_min = [11.42, 3.78];
@@ -127,11 +154,34 @@ effective_board_clearance = max(
board_bottom_clearance, board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6 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_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_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch; computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0 top_pcb_z = stack_height_override > 0
@@ -146,20 +196,17 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance battery_center_y = partition_y + battery_partition_thickness
+ battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance; battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length; 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 case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness; + board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2 case_length_battery = battery_end_x + battery_end_clearance
+ 1.2 + wall_thickness; + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery) case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain; : 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 = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : 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_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2; + 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; electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled battery_ceiling_z = battery_enabled
? max( ? battery_center_z + battery_diameter / 2 + battery_radial_clearance
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0; : 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 = [ lid_boss_xy = [
[ [
wall_thickness + lid_boss_r - lid_boss_wall_overlap, 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 case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile"); || top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| 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, assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material"); "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 assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| 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 clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) { module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -242,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius); rounded_rect_2d([size[0], size[1]], radius);
} }
module insert_pocket(diameter, depth, entry_z, downward = true) { module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation; bore_d = diameter;
if (downward) { if (downward) {
translate([0, 0, entry_z - depth]) translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d); cylinder(h = depth + eps, d = bore_d);
@@ -251,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d, d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer d2 = entry_diameter
); );
} else { } else {
translate([0, 0, entry_z - eps]) 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]) translate([0, 0, entry_z - eps])
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer, d1 = entry_diameter,
d2 = bore_d d2 = bore_d
); );
} }
@@ -301,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
} }
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) { module custom_wall_cutout(entry) {
face = entry[0]; face = entry[0];
shape = entry[1]; shape = entry[1];
@@ -368,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4 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() { difference() {
translate([wall_thickness, partition_y, effective_floor - eps]) translate([wall_thickness, partition_y, effective_floor - eps])
cube([ cube([
battery_end_x - wall_thickness + battery_end_clearance, battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness, battery_partition_thickness,
partition_height - effective_floor + eps partition_height - effective_floor + eps
]); ]);
translate([ translate([
board_origin_x + 6.5, wall_thickness - eps,
partition_y - eps, partition_y - eps,
effective_floor + 1.5 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. // Three low curved ribs cradle the protected cylindrical pack.
@@ -412,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance battery_end_x + battery_end_clearance
]) ])
translate([ translate([
x, x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance, battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps effective_floor - eps
]) ])
cube([ cube([
wall_thickness, wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance, battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps 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() { module body_positive() {
@@ -453,8 +716,10 @@ module body_positive() {
if (battery_enabled) if (battery_enabled)
battery_cradle(); battery_cradle();
if (tripod_insert_enabled) 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); 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]) translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
board_bottom_z board_bottom_z
); );
@@ -473,44 +739,24 @@ module body_cutouts() {
translate([p[0], p[1], 0]) translate([p[0], p[1], 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
body_height body_height
); );
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB. // USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset; if (usb_opening_enabled)
face_rounded_cutout( face_rounded_cutout(
"front", "front",
board_origin_y + usb_center_y, usb_center_u,
usb_center_z, usb_center_z,
usb_opening_width + hole_compensation, usb_inner_width,
usb_opening_height + hole_compensation, usb_inner_height,
usb_opening_radius usb_opening_radius
); );
if (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) { if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps]) translate([0, 0, -eps])
cylinder( cylinder(
h = effective_floor + 2 * eps, h = effective_floor + 2 * eps,
@@ -518,6 +764,7 @@ module body_cutouts() {
); );
insert_pocket( insert_pocket(
tripod_insert_hole_diameter, tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief, tripod_insert_length + insert_melt_relief,
effective_floor effective_floor
); );
@@ -611,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() { module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads. // Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) { 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]) translate([p[0], p[1], -eps])
cylinder( cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps, 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") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy); 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() { difference() {
lid_plate_positive(); lid_plate_positive();
lid_cutouts(); 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() { module one_oled_button() {
recess_depth = button_flange_thickness + button_travel; recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection; stem_height = lid_thickness - recess_depth + button_projection;
@@ -678,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75]) color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z]) translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54); 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]) color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count]) for (i = [1 : wing_count])
translate([ translate([
@@ -744,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z]) translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0]) rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter); 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([ translate([
switch_center_x, rocker_installed_center_x - rocker_body_size[0] / 2,
case_width - wall_thickness, rocker_installed_center_y - rocker_body_size[1] / 2,
switch_center_z body_height + exploded_view - rocker_body_size[2]
]) ])
rotate([90, 0, 0]) { cube(rocker_body_size);
cylinder(h = switch_body_length, d = switch_body_diameter); color([0.12, 0.12, 0.12, 0.95])
translate([0, 0, -1.5]) translate([
cylinder(h = 1.5, d = switch_flange_diameter); rocker_installed_center_x - rocker_cutout_size[0] / 2,
translate([0, 0, -3]) rocker_installed_center_y - rocker_cutout_size[1] / 2,
cylinder(h = 3, d = switch_cap_diameter); body_height + exploded_view + lid_thickness
} ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
} }
} }
@@ -774,13 +1126,9 @@ module assembly() {
lid(); lid();
} }
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() { module insert_coupon() {
spacing = 15; spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8; coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32; coupon_width = 32;
coupon_height = max( coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2, m25_insert_length + insert_melt_relief + 1.2,
@@ -788,17 +1136,21 @@ module insert_coupon() {
); );
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) { for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing; x = 7 + i * spacing;
coupon_hole( translate([x, 8, 0])
x, 8, insert_pocket(
m25_insert_hole_diameter + coupon_steps[i], m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_length + insert_melt_relief m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
); );
coupon_hole( translate([x, 23, 0])
x, 23, insert_pocket(
tripod_insert_hole_diameter + coupon_steps[i], tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_length + insert_melt_relief tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
); );
} }
} }
@@ -807,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() { module switch_coupon() {
spacing = 18; spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6; coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24; coupon_width = rocker_cutout_size[1] + 6;
coupon_height = wall_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder( centered_rounded_prism(
h = coupon_height + 2 * eps, rocker_cutout_size
d = switch_hole_diameter + coupon_steps[i] + [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") if (part == "assembly")
assembly(); assembly();
else if (part == "body") else if (part == "body")
body(); body();
else if (part == "lid") else if (part == "lid")
lid(); lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons") else if (part == "buttons")
buttons(); buttons();
else if (part == "insert_coupon") 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 - FeatherWings: 1
- Battery compartment: yes - Battery compartment: yes
- 1/4-20 insert: 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 - 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 - Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews. Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
override their executable paths. `BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually: 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, The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio. 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) permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad") openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender") blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge( parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") "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 = %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] buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" && if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] } buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…" puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part) export.call(directory.join("#{part}.#{format}"), part)
end 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 end
assembly = scratch.join("assembly.stl") assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env) mesh.to_s, previews.to_s, part, "iso", env: render_env)
end 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}") backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup) FileUtils.mkdir_p(backup)
+39 -15
View File
@@ -3,7 +3,7 @@ schema_version: 1
name: GPS name: GPS
slug: gps slug: gps
generator: generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353 sha256: 281917098487eeacd19a136ba27752698712e90cb4c03c6525a1ea3167c9ea13
created_with: feather-case-wizard created_with: feather-case-wizard
parameters: parameters:
top_profile: gps top_profile: gps
@@ -36,20 +36,30 @@ parameters:
battery_diameter: 18.0 battery_diameter: 18.0
battery_radial_clearance: 0.6 battery_radial_clearance: 0.6
battery_end_clearance: 1.0 battery_end_clearance: 1.0
switch_hole_diameter: 12.0 battery_partition_thickness: 1.8
switch_hole_compensation: 0.3 battery_board_clearance: 3.0
switch_cap_diameter: 8.0 rocker_cutout_size:
switch_flange_diameter: 16.2 - 12.9
switch_body_length: 24.0 - 19.4
switch_body_diameter: 12.0 rocker_corner_radius: 0.8
switch_wire_allowance: 3.0 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_enabled: true
tripod_insert_length: 6.4 tripod_insert_length: 5.0
tripod_insert_hole_diameter: 8.8 tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8 tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0 tripod_boss_diameter: 14.0
m25_insert_length: 4.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_screw_clearance: 2.8
m25_boss_diameter: 7.0 m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6 insert_entry_chamfer: 0.6
@@ -61,17 +71,31 @@ parameters:
board_bottom_clearance: 5.4 board_bottom_clearance: 5.4
board_side_clearance: 7.0 board_side_clearance: 7.0
board_rear_clearance: 4.0 board_rear_clearance: 4.0
usb_board_wall_gap: 1.2 usb_board_wall_gap: 2.0
lid_lip_depth: 2.4 lid_lip_depth: 2.4
lid_lip_thickness: 1.2 lid_lip_thickness: 1.2
fit_clearance: 0.35 fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2 hole_compensation: 0.2
lid_screw_head_diameter: 5.2 lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5 lid_screw_head_height: 2.5
usb_opening_width: 12.5 usb_opening_enabled: true
usb_opening_height: 7.0 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_opening_radius: 1.5
usb_vertical_offset: 0.0 usb_vertical_offset: 0.5
cutouts: [] cutouts: []
outputs: outputs:
formats: formats:
+33 -14
View File
@@ -27,20 +27,25 @@
"battery_diameter": "18.0", "battery_diameter": "18.0",
"battery_radial_clearance": "0.6", "battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0", "battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0", "battery_partition_thickness": "1.8",
"switch_hole_compensation": "0.3", "battery_board_clearance": "3.0",
"switch_cap_diameter": "8.0", "rocker_cutout_size": "[12.9,19.4]",
"switch_flange_diameter": "16.2", "rocker_corner_radius": "0.8",
"switch_body_length": "24.0", "rocker_body_size": "[15.0,21.0,21.0]",
"switch_body_diameter": "12.0", "rocker_artwork_gap": "2.0",
"switch_wire_allowance": "3.0", "rocker_partition_offset": "2.0",
"rocker_tab_panel_thickness": "2.0",
"rocker_tab_land": "2.0",
"rocker_tab_taper_length": "0.6",
"tripod_insert_enabled": "true", "tripod_insert_enabled": "true",
"tripod_insert_length": "6.4", "tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "8.8", "tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8", "tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0", "tripod_boss_diameter": "14.0",
"m25_insert_length": "4.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_screw_clearance": "2.8",
"m25_boss_diameter": "7.0", "m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6", "insert_entry_chamfer": "0.6",
@@ -52,17 +57,31 @@
"board_bottom_clearance": "5.4", "board_bottom_clearance": "5.4",
"board_side_clearance": "7.0", "board_side_clearance": "7.0",
"board_rear_clearance": "4.0", "board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2", "usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4", "lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2", "lid_lip_thickness": "1.2",
"fit_clearance": "0.35", "fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2", "hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2", "lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5", "lid_screw_head_height": "2.5",
"usb_opening_width": "12.5", "usb_opening_enabled": "true",
"usb_opening_height": "7.0", "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_opening_radius": "1.5",
"usb_vertical_offset": "0.0" "usb_vertical_offset": "0.5"
} }
}, },
"fileFormatVersion": "1" "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] */ /* [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] exploded_view = 8; // [0:1:20]
show_references = true; show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18; battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
switch_hole_diameter = 12; battery_partition_thickness = 1.8;
switch_hole_compensation = 0.3; battery_board_clearance = 3;
switch_cap_diameter = 8; rocker_cutout_size = [12.9, 19.4];
switch_flange_diameter = 16.2; rocker_corner_radius = 0.8;
switch_body_length = 24; rocker_body_size = [15, 21, 21];
switch_body_diameter = 12; rocker_artwork_gap = 2;
switch_wire_allowance = 3; rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
tripod_insert_length = 6.4; tripod_insert_length = 5;
tripod_insert_hole_diameter = 8.8; tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8; tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14; tripod_boss_diameter = 14;
/* [M2.5 inserts] */ /* [M2.5 inserts] */
m25_insert_length = 4; 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_screw_clearance = 2.8;
m25_boss_diameter = 7; m25_boss_diameter = 7;
insert_entry_chamfer = 0.6; insert_entry_chamfer = 0.6;
@@ -78,19 +83,36 @@ corner_radius = 4;
board_bottom_clearance = 5.4; board_bottom_clearance = 5.4;
board_side_clearance = 7; board_side_clearance = 7;
board_rear_clearance = 4; board_rear_clearance = 4;
usb_board_wall_gap = 1.2; usb_board_wall_gap = 2;
lid_lip_depth = 2.4; lid_lip_depth = 2.4;
lid_lip_thickness = 1.2; lid_lip_thickness = 1.2;
fit_clearance = 0.35; fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2; hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
/* [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-C opening] */
usb_opening_width = 12.5; usb_opening_enabled = true;
usb_opening_height = 7; usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 13.5;
usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -100,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */ /* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; 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; $fn = $preview ? 36 : 72;
eps = 0.02; eps = 0.02;
@@ -111,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26]; mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32]; mount_y = [2.54, 20.32];
usb_center_y = 11.43; 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. // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78]; oled_visible_min = [11.42, 3.78];
@@ -127,11 +154,34 @@ effective_board_clearance = max(
board_bottom_clearance, board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6 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_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_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch; computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0 top_pcb_z = stack_height_override > 0
@@ -146,20 +196,17 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance battery_center_y = partition_y + battery_partition_thickness
+ battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance; battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length; 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 case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness; + board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2 case_length_battery = battery_end_x + battery_end_clearance
+ 1.2 + wall_thickness; + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery) case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain; : 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 = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : 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_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2; + 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; electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled battery_ceiling_z = battery_enabled
? max( ? battery_center_z + battery_diameter / 2 + battery_radial_clearance
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0; : 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 = [ lid_boss_xy = [
[ [
wall_thickness + lid_boss_r - lid_boss_wall_overlap, 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 case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile"); || top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| 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, assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material"); "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 assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| 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 clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) { module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01); r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -242,8 +477,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius); rounded_rect_2d([size[0], size[1]], radius);
} }
module insert_pocket(diameter, depth, entry_z, downward = true) { module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation; bore_d = diameter;
if (downward) { if (downward) {
translate([0, 0, entry_z - depth]) translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d); cylinder(h = depth + eps, d = bore_d);
@@ -251,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d, d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer d2 = entry_diameter
); );
} else { } else {
translate([0, 0, entry_z - eps]) 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]) translate([0, 0, entry_z - eps])
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer, d1 = entry_diameter,
d2 = bore_d d2 = bore_d
); );
} }
@@ -301,6 +536,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter); rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
} }
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) { module custom_wall_cutout(entry) {
face = entry[0]; face = entry[0];
shape = entry[1]; shape = entry[1];
@@ -368,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4 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() { difference() {
translate([wall_thickness, partition_y, effective_floor - eps]) translate([wall_thickness, partition_y, effective_floor - eps])
cube([ cube([
battery_end_x - wall_thickness + battery_end_clearance, battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness, battery_partition_thickness,
partition_height - effective_floor + eps partition_height - effective_floor + eps
]); ]);
translate([ translate([
board_origin_x + 6.5, wall_thickness - eps,
partition_y - eps, partition_y - eps,
effective_floor + 1.5 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. // Three low curved ribs cradle the protected cylindrical pack.
@@ -412,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance battery_end_x + battery_end_clearance
]) ])
translate([ translate([
x, x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance, battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps effective_floor - eps
]) ])
cube([ cube([
wall_thickness, wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance, battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps 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() { module body_positive() {
@@ -453,8 +716,10 @@ module body_positive() {
if (battery_enabled) if (battery_enabled)
battery_cradle(); battery_cradle();
if (tripod_insert_enabled) 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); 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]) translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
board_bottom_z board_bottom_z
); );
@@ -473,44 +739,24 @@ module body_cutouts() {
translate([p[0], p[1], 0]) translate([p[0], p[1], 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
body_height body_height
); );
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB. // USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset; if (usb_opening_enabled)
face_rounded_cutout( face_rounded_cutout(
"front", "front",
board_origin_y + usb_center_y, usb_center_u,
usb_center_z, usb_center_z,
usb_opening_width + hole_compensation, usb_inner_width,
usb_opening_height + hole_compensation, usb_inner_height,
usb_opening_radius usb_opening_radius
); );
if (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) { if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps]) translate([0, 0, -eps])
cylinder( cylinder(
h = effective_floor + 2 * eps, h = effective_floor + 2 * eps,
@@ -518,6 +764,7 @@ module body_cutouts() {
); );
insert_pocket( insert_pocket(
tripod_insert_hole_diameter, tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief, tripod_insert_length + insert_melt_relief,
effective_floor effective_floor
); );
@@ -611,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() { module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads. // Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) { 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]) translate([p[0], p[1], -eps])
cylinder( cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps, 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") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy); 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() { difference() {
lid_plate_positive(); lid_plate_positive();
lid_cutouts(); 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() { module one_oled_button() {
recess_depth = button_flange_thickness + button_travel; recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection; stem_height = lid_thickness - recess_depth + button_projection;
@@ -678,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75]) color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z]) translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54); 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]) color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count]) for (i = [1 : wing_count])
translate([ translate([
@@ -744,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z]) translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0]) rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter); 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([ translate([
switch_center_x, rocker_installed_center_x - rocker_body_size[0] / 2,
case_width - wall_thickness, rocker_installed_center_y - rocker_body_size[1] / 2,
switch_center_z body_height + exploded_view - rocker_body_size[2]
]) ])
rotate([90, 0, 0]) { cube(rocker_body_size);
cylinder(h = switch_body_length, d = switch_body_diameter); color([0.12, 0.12, 0.12, 0.95])
translate([0, 0, -1.5]) translate([
cylinder(h = 1.5, d = switch_flange_diameter); rocker_installed_center_x - rocker_cutout_size[0] / 2,
translate([0, 0, -3]) rocker_installed_center_y - rocker_cutout_size[1] / 2,
cylinder(h = 3, d = switch_cap_diameter); body_height + exploded_view + lid_thickness
} ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
} }
} }
@@ -774,13 +1126,9 @@ module assembly() {
lid(); lid();
} }
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() { module insert_coupon() {
spacing = 15; spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8; coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32; coupon_width = 32;
coupon_height = max( coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2, m25_insert_length + insert_melt_relief + 1.2,
@@ -788,17 +1136,21 @@ module insert_coupon() {
); );
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) { for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing; x = 7 + i * spacing;
coupon_hole( translate([x, 8, 0])
x, 8, insert_pocket(
m25_insert_hole_diameter + coupon_steps[i], m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_length + insert_melt_relief m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
); );
coupon_hole( translate([x, 23, 0])
x, 23, insert_pocket(
tripod_insert_hole_diameter + coupon_steps[i], tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_length + insert_melt_relief tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
); );
} }
} }
@@ -807,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() { module switch_coupon() {
spacing = 18; spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6; coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24; coupon_width = rocker_cutout_size[1] + 6;
coupon_height = wall_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder( centered_rounded_prism(
h = coupon_height + 2 * eps, rocker_cutout_size
d = switch_hole_diameter + coupon_steps[i] + [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") if (part == "assembly")
assembly(); assembly();
else if (part == "body") else if (part == "body")
body(); body();
else if (part == "lid") else if (part == "lid")
lid(); lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons") else if (part == "buttons")
buttons(); buttons();
else if (part == "insert_coupon") 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] */ /* [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] exploded_view = 8; // [0:1:20]
show_references = true; show_references = true;
@@ -47,24 +47,29 @@ battery_length = 69;
battery_diameter = 18; battery_diameter = 18;
battery_radial_clearance = 0.6; battery_radial_clearance = 0.6;
battery_end_clearance = 1; battery_end_clearance = 1;
switch_hole_diameter = 12; battery_partition_thickness = 1.8;
switch_hole_compensation = 0.3; battery_board_clearance = 3;
switch_cap_diameter = 8; rocker_cutout_size = [12.9, 19.4];
switch_flange_diameter = 16.2; rocker_corner_radius = 0.8;
switch_body_length = 24; rocker_body_size = [15, 21, 21];
switch_body_diameter = 12; rocker_artwork_gap = 2;
switch_wire_allowance = 3; rocker_partition_offset = 2;
rocker_tab_panel_thickness = 2;
rocker_tab_land = 2;
rocker_tab_taper_length = 0.6;
/* [Tripod mount] */ /* [Tripod mount] */
tripod_insert_enabled = false; tripod_insert_enabled = false;
tripod_insert_length = 6.4; tripod_insert_length = 5;
tripod_insert_hole_diameter = 8.8; tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8; tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14; tripod_boss_diameter = 14;
/* [M2.5 inserts] */ /* [M2.5 inserts] */
m25_insert_length = 4; 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_screw_clearance = 2.8;
m25_boss_diameter = 7; m25_boss_diameter = 7;
insert_entry_chamfer = 0.6; insert_entry_chamfer = 0.6;
@@ -87,16 +92,27 @@ hole_compensation = 0.2;
lid_screw_head_diameter = 5.2; lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5; lid_screw_head_height = 2.5;
/* [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-C opening] */
usb_opening_enabled = true; usb_opening_enabled = true;
usb_guide_enabled = true; usb_guide_enabled = true;
usb_connector_overhang = 2; usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2; usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1; usb_guide_end_gap = 1;
usb_opening_width = 12.5; usb_opening_width = 13.5;
usb_opening_height = 7; usb_opening_height = 8;
usb_opening_radius = 1.5; usb_opening_radius = 1.5;
usb_vertical_offset = 0; usb_vertical_offset = 0.5;
/* [Custom wall cutouts] */ /* [Custom wall cutouts] */
// Entry format: [face, shape, u, z, width, height, radius] // Entry format: [face, shape, u, z, width, height, radius]
@@ -106,6 +122,8 @@ extra_cutouts = [];
/* [Calibration] */ /* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3]; 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; $fn = $preview ? 36 : 72;
eps = 0.02; eps = 0.02;
@@ -117,6 +135,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26]; mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32]; mount_y = [2.54, 20.32];
usb_center_y = 11.43; 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. // Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78]; oled_visible_min = [11.42, 3.78];
@@ -133,9 +154,18 @@ effective_board_clearance = max(
board_bottom_clearance, board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6 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_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_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness; board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y; usb_center_u = board_origin_y + usb_center_y;
@@ -166,20 +196,17 @@ profile_top_gap =
top_profile == "oled128x64" ? oled_face_gap : top_profile == "oled128x64" ? oled_face_gap :
generic_top_gap; generic_top_gap;
partition_y = board_origin_y + feather_width + 1; partition_y = board_origin_y + feather_width + battery_board_clearance;
battery_center_y = partition_y + wall_thickness + battery_radial_clearance battery_center_y = partition_y + battery_partition_thickness
+ battery_diameter / 2; + battery_radial_clearance + battery_diameter / 2;
battery_start_x = board_origin_x + battery_end_clearance; battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length; 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 case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness; + board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2 case_length_battery = battery_end_x + battery_end_clearance
+ 1.2 + wall_thickness; + wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery) case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain; : 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 = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain; : 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_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2; + 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; electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled battery_ceiling_z = battery_enabled
? max( ? battery_center_z + battery_diameter / 2 + battery_radial_clearance
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
: 0; : 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 = [ lid_boss_xy = [
[ [
wall_thickness + lid_boss_r - lid_boss_wall_overlap, 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 case_width - wall_thickness - lid_boss_r + lid_boss_wall_overlap
] ]
]; ];
rocker_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(
abs(p[0] - rocker_installed_center_x)
- rocker_body_size[0] / 2,
0
), 2)
+ pow(max(
abs(p[1] - rocker_installed_center_y)
- rocker_body_size[1] / 2,
0
), 2)
) - lid_boss_r
]);
rocker_relief_half_size = [
rocker_cutout_size[0] / 2,
rocker_cutout_size[1] / 2 + rocker_tab_land + rocker_tab_taper_length
];
rocker_relief_boss_clearance = min([
for (p = lid_boss_xy)
sqrt(
pow(max(abs(p[0] - rocker_lid_center_x)
- rocker_relief_half_size[0], 0), 2)
+ pow(max(abs(p[1] - rocker_lid_center_y)
- rocker_relief_half_size[1], 0), 2)
) - lid_boss_r
]);
assert(wing_count >= 1, "wing_count must be at least one"); assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps" assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile"); || top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM"); assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(battery_board_clearance > 0,
"battery_board_clearance must be positive");
assert(rocker_tab_panel_thickness >= 1.2,
"rocker tab panel needs at least two 0.6 mm lines");
assert(rocker_tab_panel_thickness < lid_thickness,
"rocker tab panel must be thinner than the lid");
assert(rocker_tab_land > 0 && rocker_tab_taper_length > 0,
"rocker tab relief land and taper must be positive");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines"); assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| 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, assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material"); "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 assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3, || tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material"); "Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled, assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening"); "USB guide requires the USB opening");
assert(!usb_opening_enabled
|| usb_center_z - usb_reserved_height / 2 >= effective_floor,
"USB opening or guide intersects the case floor");
assert(!usb_opening_enabled
|| usb_center_z + usb_reserved_height / 2
<= body_height - lid_lip_depth,
"USB opening or guide intersects the lid lip");
assert(!tripod_insert_enabled
|| 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 clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4]; 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); rounded_rect_2d([size[0], size[1]], radius);
} }
module insert_pocket(diameter, depth, entry_z, downward = true) { module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation; bore_d = diameter;
if (downward) { if (downward) {
translate([0, 0, entry_z - depth]) translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d); cylinder(h = depth + eps, d = bore_d);
@@ -306,7 +486,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d, d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer d2 = entry_diameter
); );
} else { } else {
translate([0, 0, entry_z - eps]) 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]) translate([0, 0, entry_z - eps])
cylinder( cylinder(
h = insert_entry_chamfer + eps, h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer, d1 = entry_diameter,
d2 = bore_d d2 = bore_d
); );
} }
@@ -457,20 +637,27 @@ module battery_cradle() {
battery_end_x - 4 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() { difference() {
translate([wall_thickness, partition_y, effective_floor - eps]) translate([wall_thickness, partition_y, effective_floor - eps])
cube([ cube([
battery_end_x - wall_thickness + battery_end_clearance, battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness, battery_partition_thickness,
partition_height - effective_floor + eps partition_height - effective_floor + eps
]); ]);
translate([ translate([
board_origin_x + 6.5, wall_thickness - eps,
partition_y - eps, partition_y - eps,
effective_floor + 1.5 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. // Three low curved ribs cradle the protected cylindrical pack.
@@ -501,29 +688,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance battery_end_x + battery_end_clearance
]) ])
translate([ translate([
x, x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance, battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps effective_floor - eps
]) ])
cube([ cube([
wall_thickness, wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance, battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps 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() { module body_positive() {
@@ -542,7 +716,7 @@ module body_positive() {
if (battery_enabled) if (battery_enabled)
battery_cradle(); battery_cradle();
if (tripod_insert_enabled) 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); cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active) if (usb_guide_active)
usb_guide_shell(); usb_guide_shell();
@@ -555,6 +729,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0]) translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
board_bottom_z board_bottom_z
); );
@@ -564,6 +739,7 @@ module body_cutouts() {
translate([p[0], p[1], 0]) translate([p[0], p[1], 0])
insert_pocket( insert_pocket(
m25_insert_hole_diameter, m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief, m25_insert_length + insert_melt_relief,
body_height body_height
); );
@@ -579,29 +755,8 @@ module body_cutouts() {
usb_opening_radius 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) { if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) { translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps]) translate([0, 0, -eps])
cylinder( cylinder(
h = effective_floor + 2 * eps, h = effective_floor + 2 * eps,
@@ -609,6 +764,7 @@ module body_cutouts() {
); );
insert_pocket( insert_pocket(
tripod_insert_hole_diameter, tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief, tripod_insert_length + insert_melt_relief,
effective_floor effective_floor
); );
@@ -702,6 +858,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() { module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads. // Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) { 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]) translate([p[0], p[1], -eps])
cylinder( cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps, 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") { if (top_profile == "oled128x64") {
beveled_oled_window(); beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy); 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() { difference() {
lid_plate_positive(); lid_plate_positive();
lid_cutouts(); 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() { module one_oled_button() {
recess_depth = button_flange_thickness + button_travel; recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection; stem_height = lid_thickness - recess_depth + button_projection;
@@ -769,6 +1022,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75]) color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z]) translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54); 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]) color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count]) for (i = [1 : wing_count])
translate([ translate([
@@ -835,19 +1095,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z]) translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0]) rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter); 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([ translate([
switch_center_x, rocker_installed_center_x - rocker_body_size[0] / 2,
case_width - wall_thickness, rocker_installed_center_y - rocker_body_size[1] / 2,
switch_center_z body_height + exploded_view - rocker_body_size[2]
]) ])
rotate([90, 0, 0]) { cube(rocker_body_size);
cylinder(h = switch_body_length, d = switch_body_diameter); color([0.12, 0.12, 0.12, 0.95])
translate([0, 0, -1.5]) translate([
cylinder(h = 1.5, d = switch_flange_diameter); rocker_installed_center_x - rocker_cutout_size[0] / 2,
translate([0, 0, -3]) rocker_installed_center_y - rocker_cutout_size[1] / 2,
cylinder(h = 3, d = switch_cap_diameter); body_height + exploded_view + lid_thickness
} ])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
} }
} }
@@ -865,13 +1126,9 @@ module assembly() {
lid(); lid();
} }
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() { module insert_coupon() {
spacing = 15; spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8; coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32; coupon_width = 32;
coupon_height = max( coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2, m25_insert_length + insert_melt_relief + 1.2,
@@ -879,17 +1136,21 @@ module insert_coupon() {
); );
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) { for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing; x = 7 + i * spacing;
coupon_hole( translate([x, 8, 0])
x, 8, insert_pocket(
m25_insert_hole_diameter + coupon_steps[i], m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_length + insert_melt_relief m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
); );
coupon_hole( translate([x, 23, 0])
x, 23, insert_pocket(
tripod_insert_hole_diameter + coupon_steps[i], tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_length + insert_melt_relief tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
); );
} }
} }
@@ -898,16 +1159,23 @@ module insert_coupon() {
module switch_coupon() { module switch_coupon() {
spacing = 18; spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6; coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24; coupon_width = rocker_cutout_size[1] + 6;
coupon_height = wall_thickness; coupon_height = lid_thickness;
difference() { difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3); rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) for (i = [0 : len(coupon_steps) - 1]) {
translate([6 + i * spacing, coupon_width / 2, -eps]) translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder( centered_rounded_prism(
h = coupon_height + 2 * eps, rocker_cutout_size
d = switch_hole_diameter + coupon_steps[i] + [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(); body();
else if (part == "lid") else if (part == "lid")
lid(); lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons") else if (part == "buttons")
buttons(); buttons();
else if (part == "insert_coupon") else if (part == "insert_coupon")
+3
View File
@@ -23,4 +23,7 @@ module FeatherCaseWizard
CASES_ROOT = ROOT.join("cases").freeze CASES_ROOT = ROOT.join("cases").freeze
MODEL_SOURCE = ROOT.join("feather_case.scad").freeze MODEL_SOURCE = ROOT.join("feather_case.scad").freeze
RENDERER_SOURCE = ROOT.join("tools/render_stl_previews.py").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 end
+116 -41
View File
@@ -10,6 +10,11 @@ module FeatherCaseWizard
FACES = %w[front rear left right].freeze FACES = %w[front rear left right].freeze
SHAPES = %w[circle rect roundrect slot].freeze SHAPES = %w[circle rect roundrect slot].freeze
COUPONS = %w[insert_coupon switch_coupon fit_coupon].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 = { DEFAULT_PARAMETERS = {
"top_profile" => "generic", "top_profile" => "generic",
@@ -38,20 +43,25 @@ module FeatherCaseWizard
"battery_diameter" => 18.0, "battery_diameter" => 18.0,
"battery_radial_clearance" => 0.6, "battery_radial_clearance" => 0.6,
"battery_end_clearance" => 1.0, "battery_end_clearance" => 1.0,
"switch_hole_diameter" => 12.0, "battery_partition_thickness" => 1.8,
"switch_hole_compensation" => 0.3, "battery_board_clearance" => 3.0,
"switch_cap_diameter" => 8.0, "rocker_cutout_size" => [12.9, 19.4],
"switch_flange_diameter" => 16.2, "rocker_corner_radius" => 0.8,
"switch_body_length" => 24.0, "rocker_body_size" => [15.0, 21.0, 21.0],
"switch_body_diameter" => 12.0, "rocker_artwork_gap" => 2.0,
"switch_wire_allowance" => 3.0, "rocker_partition_offset" => 2.0,
"rocker_tab_panel_thickness" => 2.0,
"rocker_tab_land" => 2.0,
"rocker_tab_taper_length" => 0.6,
"tripod_insert_enabled" => false, "tripod_insert_enabled" => false,
"tripod_insert_length" => 6.4, "tripod_insert_length" => 5.0,
"tripod_insert_hole_diameter" => 8.8, "tripod_insert_hole_diameter" => 7.2,
"tripod_insert_outer_diameter" => 8.0,
"tripod_thread_clearance_diameter" => 6.8, "tripod_thread_clearance_diameter" => 6.8,
"tripod_boss_diameter" => 14.0, "tripod_boss_diameter" => 14.0,
"m25_insert_length" => 4.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_screw_clearance" => 2.8,
"m25_boss_diameter" => 7.0, "m25_boss_diameter" => 7.0,
"insert_entry_chamfer" => 0.6, "insert_entry_chamfer" => 0.6,
@@ -76,10 +86,18 @@ module FeatherCaseWizard
"usb_connector_overhang" => 2.0, "usb_connector_overhang" => 2.0,
"usb_guide_wall_thickness" => 1.2, "usb_guide_wall_thickness" => 1.2,
"usb_guide_end_gap" => 1.0, "usb_guide_end_gap" => 1.0,
"usb_opening_width" => 12.5, "lid_label" => "",
"usb_opening_height" => 7.0, "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_opening_radius" => 1.5,
"usb_vertical_offset" => 0.0 "usb_vertical_offset" => 0.5
}.freeze }.freeze
ADVANCED_FIELDS = { ADVANCED_FIELDS = {
@@ -87,19 +105,27 @@ module FeatherCaseWizard
gps_antenna_height gps_top_gap oled_component_height gps_antenna_height gps_top_gap oled_component_height
oled_display_height oled_face_gap], oled_display_height oled_face_gap],
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius "Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
board_bottom_clearance board_side_clearance lid_lip_depth board_bottom_clearance board_side_clearance board_rear_clearance
lid_lip_thickness fit_clearance cutout_min_web hole_compensation usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
lid_screw_head_diameter lid_screw_head_height], 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 "M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
m25_insert_outer_diameter
m25_screw_clearance m25_boss_diameter m25_screw_clearance m25_boss_diameter
insert_entry_chamfer insert_melt_relief], insert_entry_chamfer insert_melt_relief],
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter "Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
tripod_insert_outer_diameter
tripod_thread_clearance_diameter tripod_boss_diameter], tripod_thread_clearance_diameter tripod_boss_diameter],
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance "Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
battery_end_clearance switch_hole_diameter battery_end_clearance battery_partition_thickness
switch_hole_compensation switch_cap_diameter battery_board_clearance
switch_flange_diameter switch_body_length rocker_cutout_size rocker_corner_radius
switch_body_diameter switch_wire_allowance], rocker_body_size rocker_artwork_gap
rocker_partition_offset
rocker_tab_panel_thickness rocker_tab_land
rocker_tab_taper_length],
"USB-C opening" => %w[usb_opening_width usb_opening_height "USB-C opening" => %w[usb_opening_width usb_opening_height
usb_opening_radius usb_vertical_offset usb_connector_overhang usb_opening_radius usb_vertical_offset usb_connector_overhang
usb_guide_wall_thickness usb_guide_end_gap], usb_guide_wall_thickness usb_guide_end_gap],
@@ -158,6 +184,7 @@ module FeatherCaseWizard
def printable_parts def printable_parts
parts = %w[body lid] parts = %w[body lid]
parts.concat(%w[lid_base lid_accent]) if lid_artwork?
if parameters["top_profile"] == "oled128x64" && if parameters["top_profile"] == "oled128x64" &&
%w[button_a button_b button_c button_reset].any? { |key| parameters[key] } %w[button_a button_b button_c button_reset].any? { |key| parameters[key] }
parts << "buttons" parts << "buttons"
@@ -165,6 +192,10 @@ module FeatherCaseWizard
parts + outputs["coupons"] parts + outputs["coupons"]
end end
def lid_artwork?
!parameters["lid_label"].empty? || parameters["lid_logo_enabled"]
end
def model_parameters def model_parameters
parameters.merge("extra_cutouts" => scad_cutouts) parameters.merge("extra_cutouts" => scad_cutouts)
end end
@@ -179,7 +210,10 @@ module FeatherCaseWizard
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6 p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
].max ].max
board_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"] 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 board_top_z = effective_floor + effective_board_clearance + 1.6
top_pcb_z = board_top_z + ( top_pcb_z = board_top_z + (
p["stack_height_override"].positive? ? p["stack_height_override"].positive? ?
@@ -198,41 +232,41 @@ module FeatherCaseWizard
plain_width = board_origin_y + 22.86 + p["board_side_clearance"] + plain_width = board_origin_y + 22.86 + p["board_side_clearance"] +
p["wall_thickness"] p["wall_thickness"]
if p["battery_enabled"] if p["battery_enabled"]
partition_y = board_origin_y + 22.86 + 1 partition_y = board_origin_y + 22.86 + p["battery_board_clearance"]
battery_y = partition_y + p["wall_thickness"] + battery_y = partition_y + p["battery_partition_thickness"] +
p["battery_radial_clearance"] + p["battery_diameter"] / 2 p["battery_radial_clearance"] + p["battery_diameter"] / 2
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"] battery_end_x = battery_start_x + p["battery_length"]
switch_x = battery_end_x + [ battery_length_required = battery_end_x + p["battery_end_clearance"] +
p["switch_body_diameter"] / 2 + 0.8, p["wall_thickness"] + p["m25_boss_diameter"] -
p["switch_flange_diameter"] / 2 + 0.5 lid_boss_wall_overlap
].max length = [plain_length, battery_length_required].max
length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 +
1.2 + p["wall_thickness"]].max
width = [plain_width, battery_y + p["battery_diameter"] / 2 + width = [plain_width, battery_y + p["battery_diameter"] / 2 +
p["battery_radial_clearance"] + p["wall_thickness"]].max p["battery_radial_clearance"] + p["wall_thickness"]].max
battery_z = effective_floor + p["battery_radial_clearance"] + battery_z = effective_floor + p["battery_radial_clearance"] +
p["battery_diameter"] / 2 p["battery_diameter"] / 2
switch_z = [ battery_ceiling = battery_z + p["battery_diameter"] / 2 +
effective_floor + p["switch_flange_diameter"] / 2 + 1, p["battery_radial_clearance"]
battery_z rocker_ceiling = effective_floor + p["rocker_body_size"][2] +
].max p["fit_clearance"]
battery_ceiling = [
battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"],
switch_z + p["switch_flange_diameter"] / 2 + 1
].max
else else
length = plain_length length = plain_length
width = plain_width width = plain_width
battery_ceiling = 0 battery_ceiling = 0
rocker_ceiling = 0
end end
{ {
"length" => length, "length" => length,
"width" => width, "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, "floor" => effective_floor,
"lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max - "lip_bottom" => [top_pcb_z + component_height + top_gap,
p["lid_lip_depth"] battery_ceiling, rocker_ceiling].max - p["lid_lip_depth"]
} }
end end
@@ -265,6 +299,15 @@ module FeatherCaseWizard
raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens" raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens"
end end
raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"]) 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, "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? raise ConfigError, "At least one export format is required" if outputs["formats"].empty?
unless (outputs["formats"] - FORMATS).empty? unless (outputs["formats"] - FORMATS).empty?
@@ -289,9 +332,27 @@ module FeatherCaseWizard
parameters["stack_height_override"].negative? parameters["stack_height_override"].negative?
raise ConfigError, "wall_thickness must be at least 1.2 mm" if raise ConfigError, "wall_thickness must be at least 1.2 mm" if
parameters["wall_thickness"] < 1.2 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 raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
parameters["lid_lip_thickness"] < 1.2 parameters["lid_lip_thickness"] < 1.2
raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if
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! validate_cutouts!
self self
end end
@@ -305,6 +366,7 @@ module FeatherCaseWizard
legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled") legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled")
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters) data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters)
data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide
LEGACY_SWITCH_FIELDS.each { |key| data["parameters"].delete(key) }
data["cutouts"] ||= [] data["cutouts"] ||= []
data["outputs"] ||= {} data["outputs"] ||= {}
data["outputs"]["formats"] ||= ["3mf"] data["outputs"]["formats"] ||= ["3mf"]
@@ -355,6 +417,19 @@ module FeatherCaseWizard
validate_cutout_spacing!(bounds, dims) validate_cutout_spacing!(bounds, dims)
end end
def validate_usb_opening!
return unless parameters["usb_opening_enabled"]
dims = dimensions
bounds = usb_wall_bounds(dims)
if bounds["bottom"] < dims["floor"]
raise ConfigError, "USB opening or guide intersects the case floor"
end
if bounds["top"] > dims["lip_bottom"]
raise ConfigError, "USB opening or guide intersects the lid lip"
end
end
def validate_cutout_spacing!(bounds, dims) def validate_cutout_spacing!(bounds, dims)
web = parameters["cutout_min_web"] web = parameters["cutout_min_web"]
bounds.combination(2) do |first, second| bounds.combination(2) do |first, second|
+64 -5
View File
@@ -2,16 +2,23 @@
module FeatherCaseWizard module FeatherCaseWizard
class PackageBuilder 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, def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new,
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE, 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) reporter: nil)
@config = config @config = config
@root = Pathname(root) @root = Pathname(root)
@toolchain = toolchain @toolchain = toolchain
@model_source = Pathname(model_source) @model_source = Pathname(model_source)
@renderer_source = Pathname(renderer_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) {} @reporter = reporter || ->(_message) {}
end end
@@ -37,13 +44,22 @@ module FeatherCaseWizard
def verify_sources! def verify_sources!
raise ToolError, "Missing model source: #{model_source}" unless model_source.file? 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 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 end
def prepare_package(stage, previous) def prepare_package(stage, previous)
source_dir = stage.join("source") 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(model_source, source_dir.join("feather_case.scad"))
FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py")) 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")) FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb"))
copy_history(previous, stage) copy_history(previous, stage)
@@ -91,9 +107,26 @@ module FeatherCaseWizard
report("Exporting #{part}.#{format}") report("Exporting #{part}.#{format}")
export_model(stage, directory.join("#{part}.#{format}"), part) export_model(stage, directory.join("#{part}.#{format}"), part)
end end
package_multicolor_lid(stage, directory) if format == "3mf" && config.lid_artwork?
end end
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) def generate_previews(stage)
previews = stage.join("previews") previews = stage.join("previews")
scratch = stage.join(".preview-meshes") scratch = stage.join(".preview-meshes")
@@ -111,9 +144,22 @@ module FeatherCaseWizard
export_model(stage, mesh, part) unless mesh.file? export_model(stage, mesh, part) unless mesh.file?
render(stage, mesh, previews, part, ["iso"]) render(stage, mesh, previews, part, ["iso"])
end end
render_multicolor_lid(stage, scratch, previews) if config.lid_artwork?
FileUtils.rm_rf(scratch) FileUtils.rm_rf(scratch)
end 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 = {}) def export_model(stage, output, part, overrides = {})
model = stage.join("source/feather_case.scad") model = stage.join("source/feather_case.scad")
parameters = config.model_parameters.merge(overrides).merge("part" => part) parameters = config.model_parameters.merge(overrides).merge("part" => part)
@@ -139,6 +185,18 @@ module FeatherCaseWizard
def write_summary(stage) def write_summary(stage)
dims = config.dimensions dims = config.dimensions
parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ") 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 text = <<~MARKDOWN
# #{config.name} # #{config.name}
@@ -154,8 +212,8 @@ module FeatherCaseWizard
- Custom cutouts: #{config.cutouts.length} - Custom cutouts: #{config.cutouts.length}
Run `ruby build.rb` in this directory to regenerate exports and previews. Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
override their executable paths. `BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually: To open the saved Customizer values manually:
@@ -165,8 +223,9 @@ module FeatherCaseWizard
The 3MF files contain portable geometry only. Choose printer, filament, The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio. plate, and slicing settings in Bambu Studio.
#{artwork}
MARKDOWN MARKDOWN
File.write(stage.join("README.md"), text) File.write(stage.join("README.md"), text.rstrip + "\n")
end end
def install_atomically(stage, destination) def install_atomically(stage, destination)
+3 -1
View File
@@ -4,18 +4,20 @@ module FeatherCaseWizard
class ToolError < StandardError; end class ToolError < StandardError; end
class Toolchain class Toolchain
attr_reader :openscad, :blender attr_reader :openscad, :blender, :python
def initialize(env: ENV) def initialize(env: ENV)
@env = env @env = env
@openscad = discover("OPENSCAD", "openscad") @openscad = discover("OPENSCAD", "openscad")
@blender = discover("BLENDER", "blender") @blender = discover("BLENDER", "blender")
@python = discover("PYTHON", "python3")
end end
def check! def check!
missing = [] missing = []
missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad 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 << "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? return self if missing.empty?
raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}" raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}"
+21
View File
@@ -44,6 +44,7 @@ module FeatherCaseWizard
choose_stack(config) choose_stack(config)
choose_end_clearance(config) choose_end_clearance(config)
choose_features(config) choose_features(config)
choose_lid_artwork(config)
manage_cutouts(config) manage_cutouts(config)
choose_coupons(config) choose_coupons(config)
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question| advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
@@ -143,6 +144,26 @@ module FeatherCaseWizard
) )
end 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) def manage_cutouts(config)
loop do loop do
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" : 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) permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad") openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender") blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge( parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts") "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 = %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] buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" && if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] } buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…" puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part) export.call(directory.join("#{part}.#{format}"), part)
end 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 end
assembly = scratch.join("assembly.stl") assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--", run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env) mesh.to_s, previews.to_s, part, "iso", env: render_env)
end 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}") backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup) FileUtils.mkdir_p(backup)
+168 -1
View File
@@ -72,6 +72,26 @@ class ConfigTest < Minitest::Test
assert_match(/guide requires/, error.message) assert_match(/guide requires/, error.message)
end end
def test_usb_opening_expands_upward_without_moving_bottom_edge
parameters = config.parameters
compensation = parameters["hole_compensation"]
board_top = config.dimensions["floor"] +
parameters["board_bottom_clearance"] + 1.6
old_center = board_top + 1.6
old_width = 12.5 + compensation
old_height = 7.0 + compensation
new_center = old_center + parameters["usb_vertical_offset"]
new_width = parameters["usb_opening_width"] + compensation
new_height = parameters["usb_opening_height"] + compensation
assert_in_delta 1.0, new_width - old_width, 0.001
assert_in_delta old_center - old_height / 2,
new_center - new_height / 2, 0.001
assert_in_delta 1.0,
(new_center + new_height / 2) -
(old_center + old_height / 2), 0.001
end
def test_end_clearances_expand_case_length def test_end_clearances_expand_case_length
subject = config subject = config
subject.parameters["usb_board_wall_gap"] = 20.0 subject.parameters["usb_board_wall_gap"] = 20.0
@@ -80,6 +100,48 @@ class ConfigTest < Minitest::Test
assert_in_delta 95.6, subject.dimensions["length"], 0.01 assert_in_delta 95.6, subject.dimensions["length"], 0.01
end 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 def test_oled_buttons_and_selected_coupons_are_printable
subject = config subject = config
subject.parameters["top_profile"] = "oled128x64" subject.parameters["top_profile"] = "oled128x64"
@@ -90,6 +152,111 @@ class ConfigTest < Minitest::Test
assert_equal %w[body lid buttons fit_coupon], subject.printable_parts assert_equal %w[body lid buttons fit_coupon], subject.printable_parts
end 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 def test_wall_local_cutouts_map_to_model_coordinates
subject = config subject = config
length = subject.dimensions["length"] length = subject.dimensions["length"]
@@ -138,7 +305,7 @@ class ConfigTest < Minitest::Test
def test_front_cutout_can_clear_usb_reservation def test_front_cutout_can_clear_usb_reservation
subject = config subject = config
subject.data["cutouts"] = [cutout("front", 10.0).merge("z" => 11.0)] subject.data["cutouts"] = [cutout("front", 8.0).merge("z" => 11.0)]
assert_same subject, subject.validate! assert_same subject, subject.validate!
end end
+39 -2
View File
@@ -29,6 +29,7 @@ class PackageBuilderTest < Minitest::Test
%w[iso front back left right top].each do |view| %w[iso front back left right top].each do |view|
assert destination.join("previews/assembly_#{view}.png").file? assert destination.join("previews/assembly_#{view}.png").file?
end end
refute_match(/\n\n\z/, destination.join("README.md").read)
config.parameters["button_reset"] = true config.parameters["button_reset"] = true
build(config, root.join("cases"), tools, model, renderer) build(config, root.join("cases"), tools, model, renderer)
@@ -68,6 +69,30 @@ class PackageBuilderTest < Minitest::Test
end end
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 private
def build(config, root, tools, model, renderer) def build(config, root, tools, model, renderer)
@@ -93,14 +118,26 @@ class PackageBuilderTest < Minitest::Test
#!/usr/bin/env ruby #!/usr/bin/env ruby
require "fileutils" require "fileutils"
args = ARGV[(ARGV.index("--") + 1)..] 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 _mesh, output, basename, selected = args
views = selected ? selected.split(",") : %w[iso front back left right top] views = selected ? selected.split(",") : %w[iso front back left right top]
end
FileUtils.mkdir_p(output) FileUtils.mkdir_p(output)
views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") } views.each { |view| File.binwrite(File.join(output, "\#{basename}_\#{view}.png"), "png") }
RUBY 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( 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
end end
+4 -2
View File
@@ -9,6 +9,7 @@ class ToolchainTest < Minitest::Test
assert_match(/OpenSCAD/, error.message) assert_match(/OpenSCAD/, error.message)
assert_match(/Blender/, error.message) assert_match(/Blender/, error.message)
assert_match(/Python 3/, error.message)
assert_match(/apt-get/, error.message) assert_match(/apt-get/, error.message)
end end
@@ -18,13 +19,14 @@ class ToolchainTest < Minitest::Test
File.write(executable, "#!/bin/sh\nexit 0\n") File.write(executable, "#!/bin/sh\nexit 0\n")
FileUtils.chmod(0o755, executable) FileUtils.chmod(0o755, executable)
toolchain = FeatherCaseWizard::Toolchain.new( 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.openscad
assert_equal executable, toolchain.blender assert_equal executable, toolchain.blender
assert_equal executable, toolchain.python
assert_same toolchain, toolchain.check! assert_same toolchain, toolchain.check!
end end
end end
end end
+4 -4
View File
@@ -6,8 +6,8 @@ require_relative "test_helper"
class WizardTest < Minitest::Test class WizardTest < Minitest::Test
def test_minimal_new_case_flow_and_confirmation def test_minimal_new_case_flow_and_confirmation
input = StringIO.new( input = StringIO.new(
["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "1", ["Demo Case", "", "1", "1", "", "n", "n", "", "", "n", "n", "n", "n",
"n", "n", "n", "n", "y"].join("\n") + "\n" "1", "n", "n", "n", "n", "y"].join("\n") + "\n"
) )
output = StringIO.new output = StringIO.new
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, output)) 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 = FeatherCaseWizard::Config.create(name: "GPS", slug: "gps")
config.parameters["top_profile"] = "gps" config.parameters["top_profile"] = "gps"
input = StringIO.new( input = StringIO.new(
["", "", "", "", "n", "n", "", "", "n", "n", "1", "n", "n", "n", ["", "", "", "", "n", "n", "", "", "n", "n", "n", "n", "1", "n",
"n"].join("\n") + "\n" "n", "n", "n"].join("\n") + "\n"
) )
wizard = FeatherCaseWizard::Wizard.new(ui: HighLine.new(input, StringIO.new)) 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()