tweak the fit of the parts

This commit is contained in:
Nick Estes
2026-08-04 12:27:31 -07:00
parent 45a77885f9
commit 69d9b956e6
41 changed files with 5378 additions and 324 deletions
+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: 60.8 × 41.7 × 33.2 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.
+148
View File
@@ -0,0 +1,148 @@
#!/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")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
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
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
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
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%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
+94
View File
@@ -0,0 +1,94 @@
---
schema_version: 1
name: Attitude no battery
slug: attitude-no-battery
generator:
sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036
created_with: feather-case-wizard
parameters:
top_profile: generic
wing_count: 1
wing_pitch: 11.0
stack_height_override: 0.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 4.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: false
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
battery_partition_thickness: 1.8
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
tripod_insert_enabled: true
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
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
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: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts: []
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts: []
+76
View File
@@ -0,0 +1,76 @@
{
"parameterSets": {
"attitude-no-battery": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "11.0",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "4.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "false",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"battery_partition_thickness": "1.8",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"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",
"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": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"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

File diff suppressed because it is too large Load Diff
@@ -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,127 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
@@ -0,0 +1,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
@@ -0,0 +1,25 @@
# Attitude w/battery
Generated by the Feather Case Wizard.
- Top profile: `generic`
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 88.6 × 59.1 × 33.2 mm
- Formats: 3mf
- Parts: `body`, `lid`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
```bash
openscad -p parameters.json -P attitude-w-battery source/feather_case.scad
```
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
+148
View File
@@ -0,0 +1,148 @@
#!/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")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
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
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
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
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
%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
+94
View File
@@ -0,0 +1,94 @@
---
schema_version: 1
name: Attitude w/battery
slug: attitude-w-battery
generator:
sha256: daeb9015a2bd04a16a75b77b32ee84bca2a9e2a4f4fe16605f3d5dc7e6d53036
created_with: feather-case-wizard
parameters:
top_profile: generic
wing_count: 1
wing_pitch: 11.0
stack_height_override: 0.0
generic_component_height: 3.0
generic_top_gap: 2.0
gps_antenna_height: 4.0
gps_top_gap: 5.0
oled_component_height: 2.4
oled_face_gap: 0.8
button_a: true
button_b: true
button_c: true
button_reset: false
button_projection: 1.5
button_cap_size:
- 5.0
- 3.2
button_flange_size:
- 6.6
- 3.6
button_flange_thickness: 0.9
button_travel: 0.5
button_contact_extension: 0.7
battery_enabled: true
battery_length: 69.0
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
battery_partition_thickness: 1.8
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
tripod_insert_enabled: true
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
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
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: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
usb_vertical_offset: 0.0
cutouts: []
outputs:
formats:
- 3mf
coupons: []
assembly_exploded_view: 8
resolved:
extra_cutouts: []
+76
View File
@@ -0,0 +1,76 @@
{
"parameterSets": {
"attitude-w-battery": {
"top_profile": "\"generic\"",
"wing_count": "1",
"wing_pitch": "11.0",
"stack_height_override": "0.0",
"generic_component_height": "3.0",
"generic_top_gap": "2.0",
"gps_antenna_height": "4.0",
"gps_top_gap": "5.0",
"oled_component_height": "2.4",
"oled_face_gap": "0.8",
"button_a": "true",
"button_b": "true",
"button_c": "true",
"button_reset": "false",
"button_projection": "1.5",
"button_cap_size": "[5.0,3.2]",
"button_flange_size": "[6.6,3.6]",
"button_flange_thickness": "0.9",
"button_travel": "0.5",
"button_contact_extension": "0.7",
"battery_enabled": "true",
"battery_length": "69.0",
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"battery_partition_thickness": "1.8",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"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",
"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": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
"usb_vertical_offset": "0.0"
}
},
"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

File diff suppressed because it is too large Load Diff
@@ -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,127 @@
"""Render six inspection views of an STL using Blender.
Usage:
blender --background --python tools/render_stl_previews.py -- \
input.stl output-directory basename [comma-separated-views]
"""
import os
import sys
import bpy
from mathutils import Vector
def arguments():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (3, 4):
raise SystemExit(
"expected: input.stl output-directory basename [comma-separated-views]"
)
requested_views = args[3].split(",") if len(args) == 4 else None
return args[0], args[1], args[2], requested_views
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def render_view(camera, center, span, name, direction, output_dir, basename):
distance = span * 2.2
camera.location = Vector(center) + Vector(direction).normalized() * distance
look_at(camera, center)
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
bpy.context.scene.render.filepath = os.path.join(
output_dir, f"{basename}_{name}.png"
)
bpy.ops.render.render(write_still=True)
def main():
input_path, output_dir, basename, requested_views = arguments()
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.stl_import(filepath=os.path.abspath(input_path))
model = bpy.context.selected_objects[0]
model.name = "Feather case"
material = bpy.data.materials.new("Case material")
material.diffuse_color = (0.16, 0.42, 0.72, 1.0)
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
corners = [model.matrix_world @ Vector(corner) for corner in model.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
0.035,
0.04,
0.05,
1,
)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.shape = "DISK"
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
fill_data = bpy.data.lights.new("Fill", type="AREA")
fill_data.energy = 500
fill_data.size = span
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((-span, span, span))
bpy.context.collection.objects.link(fill)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
scene.display.shading.curvature_ridge_factor = 1.5
scene.display.shading.curvature_valley_factor = 1.2
views = {
"iso": (1, -1, 0.8),
"front": (-1, 0, 0),
"back": (1, 0, 0),
"left": (0, -1, 0),
"right": (0, 1, 0),
"top": (0, 0, 1),
}
selected = requested_views or list(views)
unknown = sorted(set(selected) - set(views))
if unknown:
raise SystemExit(f"unknown views: {', '.join(unknown)}")
for name in selected:
direction = views[name]
render_view(camera, center, span, name, direction, output_dir, basename)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()
+10 -4
View File
@@ -6,14 +6,14 @@ Generated by the Feather Case Wizard.
- FeatherWings: 1
- Battery compartment: yes
- 1/4-20 insert: yes
- Approximate exterior: 93.9 × 57.3 × 41.8 mm
- Approximate exterior: 88.6 × 59.1 × 40.4 mm
- Formats: 3mf
- Parts: `body`, `lid`, `insert_coupon`, `switch_coupon`, `fit_coupon`
- Parts: `body`, `lid`, `lid_base`, `lid_accent`, `insert_coupon`, `switch_coupon`, `fit_coupon`
- Custom cutouts: 0
Run `ruby build.rb` in this directory to regenerate exports and previews.
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
override their executable paths.
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
`BLENDER`, or `PYTHON` to override their executable paths.
To open the saved Customizer values manually:
@@ -23,3 +23,9 @@ openscad -p parameters.json -P gps source/feather_case.scad
The 3MF files contain portable geometry only. Choose printer, filament,
plate, and slicing settings in Bambu Studio.
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
Filament 2” to the desired filaments. `lid.3mf` is the complete
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
provided separately.
+25
View File
@@ -44,13 +44,18 @@ config = YAML.safe_load_file(ROOT.join("case.yml"), permitted_classes: [],
permitted_symbols: [], aliases: false)
openscad = executable("OPENSCAD", "openscad")
blender = executable("BLENDER", "blender")
python = executable("PYTHON", "python3")
abort "OpenSCAD not found; install it or set OPENSCAD" unless openscad
abort "Blender not found; install it or set BLENDER" unless blender
parameters = config.fetch("parameters").merge(
"extra_cutouts" => config.fetch("resolved").fetch("extra_cutouts")
)
artwork = !parameters.fetch("lid_label", "").empty? ||
parameters.fetch("lid_logo_enabled", false)
abort "Python 3 not found; install it or set PYTHON" if artwork && !python
parts = %w[body lid] + config.fetch("outputs").fetch("coupons")
parts.concat(%w[lid_base lid_accent]) if artwork
buttons = %w[button_a button_b button_c button_reset]
if parameters["top_profile"] == "oled128x64" &&
buttons.any? { |name| parameters[name] }
@@ -80,6 +85,16 @@ begin
puts "Exporting #{part}.#{format}…"
export.call(directory.join("#{part}.#{format}"), part)
end
if artwork && format == "3mf"
puts "Packaging lid_multicolor.3mf…"
run!(
python, ROOT.join("tools/package_multimaterial_3mf.py").to_s,
directory.join("lid_base.3mf").to_s,
directory.join("lid_accent.3mf").to_s,
directory.join("lid_multicolor.3mf").to_s,
"--title", "#{config.fetch("name")} Lid"
)
end
end
assembly = scratch.join("assembly.stl")
@@ -97,6 +112,16 @@ begin
run!(blender, "--background", "-noaudio", "--python", renderer.to_s, "--",
mesh.to_s, previews.to_s, part, "iso", env: render_env)
end
if artwork
puts "Rendering multicolor lid preview…"
run!(
blender, "--background", "-noaudio", "--python",
ROOT.join("source/render_multicolor_lid.py").to_s, "--",
scratch.join("lid_base.stl").to_s,
scratch.join("lid_accent.stl").to_s,
previews.to_s, "lid_multicolor", parameters["lid_art_orientation"], env: render_env
)
end
backup = ROOT.join(".build-old-#{Process.pid}")
FileUtils.mkdir_p(backup)
+31 -12
View File
@@ -3,7 +3,7 @@ schema_version: 1
name: GPS
slug: gps
generator:
sha256: be611e3255e7d802719ebb0628e3d11481f8de05fc96c24d452f026d7b8e3353
sha256: 168dd3c04ca4015b07fe6c0295e7d4bb43969671f5f3552ffab31d8deffc3024
created_with: feather-case-wizard
parameters:
top_profile: gps
@@ -36,20 +36,25 @@ parameters:
battery_diameter: 18.0
battery_radial_clearance: 0.6
battery_end_clearance: 1.0
switch_hole_diameter: 12.0
switch_hole_compensation: 0.3
switch_cap_diameter: 8.0
switch_flange_diameter: 16.2
switch_body_length: 24.0
switch_body_diameter: 12.0
switch_wire_allowance: 3.0
battery_partition_thickness: 1.8
rocker_cutout_size:
- 12.9
- 19.4
rocker_corner_radius: 0.8
rocker_body_size:
- 15.0
- 21.0
- 21.0
rocker_artwork_gap: 2.0
tripod_insert_enabled: true
tripod_insert_length: 6.4
tripod_insert_hole_diameter: 8.8
tripod_insert_length: 5.0
tripod_insert_hole_diameter: 7.2
tripod_insert_outer_diameter: 8.0
tripod_thread_clearance_diameter: 6.8
tripod_boss_diameter: 14.0
m25_insert_length: 4.0
m25_insert_hole_diameter: 3.7
m25_insert_hole_diameter: 3.1
m25_insert_outer_diameter: 3.5
m25_screw_clearance: 2.8
m25_boss_diameter: 7.0
insert_entry_chamfer: 0.6
@@ -61,13 +66,27 @@ parameters:
board_bottom_clearance: 5.4
board_side_clearance: 7.0
board_rear_clearance: 4.0
usb_board_wall_gap: 1.2
usb_board_wall_gap: 2.0
lid_lip_depth: 2.4
lid_lip_thickness: 1.2
fit_clearance: 0.35
cutout_min_web: 1.2
hole_compensation: 0.2
lid_screw_head_diameter: 5.2
lid_screw_head_height: 2.5
usb_opening_enabled: true
usb_guide_enabled: true
usb_connector_overhang: 2.0
usb_guide_wall_thickness: 1.2
usb_guide_end_gap: 1.0
lid_label: GPS
lid_logo_enabled: true
lid_art_orientation: usb_left
lid_inlay_depth: 0.6
lid_inlay_fit_clearance: 0.08
lid_label_size: 5.0
lid_label_logo_gap: 2.0
lid_art_margin: 8.5
usb_opening_width: 12.5
usb_opening_height: 7.0
usb_opening_radius: 1.5
+25 -11
View File
@@ -27,20 +27,20 @@
"battery_diameter": "18.0",
"battery_radial_clearance": "0.6",
"battery_end_clearance": "1.0",
"switch_hole_diameter": "12.0",
"switch_hole_compensation": "0.3",
"switch_cap_diameter": "8.0",
"switch_flange_diameter": "16.2",
"switch_body_length": "24.0",
"switch_body_diameter": "12.0",
"switch_wire_allowance": "3.0",
"battery_partition_thickness": "1.8",
"rocker_cutout_size": "[12.9,19.4]",
"rocker_corner_radius": "0.8",
"rocker_body_size": "[15.0,21.0,21.0]",
"rocker_artwork_gap": "2.0",
"tripod_insert_enabled": "true",
"tripod_insert_length": "6.4",
"tripod_insert_hole_diameter": "8.8",
"tripod_insert_length": "5.0",
"tripod_insert_hole_diameter": "7.2",
"tripod_insert_outer_diameter": "8.0",
"tripod_thread_clearance_diameter": "6.8",
"tripod_boss_diameter": "14.0",
"m25_insert_length": "4.0",
"m25_insert_hole_diameter": "3.7",
"m25_insert_hole_diameter": "3.1",
"m25_insert_outer_diameter": "3.5",
"m25_screw_clearance": "2.8",
"m25_boss_diameter": "7.0",
"insert_entry_chamfer": "0.6",
@@ -52,13 +52,27 @@
"board_bottom_clearance": "5.4",
"board_side_clearance": "7.0",
"board_rear_clearance": "4.0",
"usb_board_wall_gap": "1.2",
"usb_board_wall_gap": "2.0",
"lid_lip_depth": "2.4",
"lid_lip_thickness": "1.2",
"fit_clearance": "0.35",
"cutout_min_web": "1.2",
"hole_compensation": "0.2",
"lid_screw_head_diameter": "5.2",
"lid_screw_head_height": "2.5",
"usb_opening_enabled": "true",
"usb_guide_enabled": "true",
"usb_connector_overhang": "2.0",
"usb_guide_wall_thickness": "1.2",
"usb_guide_end_gap": "1.0",
"lid_label": "\"GPS\"",
"lid_logo_enabled": "true",
"lid_art_orientation": "\"usb_left\"",
"lid_inlay_depth": "0.6",
"lid_inlay_fit_clearance": "0.08",
"lid_label_size": "5.0",
"lid_label_logo_gap": "2.0",
"lid_art_margin": "8.5",
"usb_opening_width": "12.5",
"usb_opening_height": "7.0",
"usb_opening_radius": "1.5",
+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

+366 -120
View File
@@ -12,7 +12,7 @@
*/
/* [Output] */
part = "assembly"; // [assembly,body,lid,buttons,insert_coupon,switch_coupon,fit_coupon]
part = "assembly"; // [assembly,body,lid,lid_base,lid_accent,buttons,insert_coupon,switch_coupon,fit_coupon]
exploded_view = 8; // [0:1:20]
show_references = true;
@@ -47,24 +47,24 @@ battery_length = 69;
battery_diameter = 18;
battery_radial_clearance = 0.6;
battery_end_clearance = 1;
switch_hole_diameter = 12;
switch_hole_compensation = 0.3;
switch_cap_diameter = 8;
switch_flange_diameter = 16.2;
switch_body_length = 24;
switch_body_diameter = 12;
switch_wire_allowance = 3;
battery_partition_thickness = 1.8;
rocker_cutout_size = [12.9, 19.4];
rocker_corner_radius = 0.8;
rocker_body_size = [15, 21, 21];
rocker_artwork_gap = 2;
/* [Tripod mount] */
tripod_insert_enabled = false;
tripod_insert_length = 6.4;
tripod_insert_hole_diameter = 8.8;
tripod_insert_length = 5;
tripod_insert_hole_diameter = 7.2;
tripod_insert_outer_diameter = 8;
tripod_thread_clearance_diameter = 6.8;
tripod_boss_diameter = 14;
/* [M2.5 inserts] */
m25_insert_length = 4;
m25_insert_hole_diameter = 3.7;
m25_insert_hole_diameter = 3.1;
m25_insert_outer_diameter = 3.5;
m25_screw_clearance = 2.8;
m25_boss_diameter = 7;
insert_entry_chamfer = 0.6;
@@ -78,15 +78,32 @@ corner_radius = 4;
board_bottom_clearance = 5.4;
board_side_clearance = 7;
board_rear_clearance = 4;
usb_board_wall_gap = 1.2;
usb_board_wall_gap = 2;
lid_lip_depth = 2.4;
lid_lip_thickness = 1.2;
fit_clearance = 0.35;
cutout_min_web = 1.2;
hole_compensation = 0.2;
lid_screw_head_diameter = 5.2;
lid_screw_head_height = 2.5;
/* [Two-color lid artwork] */
lid_label = "";
lid_logo_enabled = false;
lid_art_orientation = "usb_left"; // [usb_left,usb_front,usb_right,usb_rear]
lid_inlay_depth = 0.6; // [0.4:0.2:1]
lid_inlay_fit_clearance = 0.08;
lid_label_size = 5;
lid_label_logo_gap = 2;
lid_art_margin = 8.5;
logo_file = "assets/NME-fixed.svg";
/* [USB-C opening] */
usb_opening_enabled = true;
usb_guide_enabled = true;
usb_connector_overhang = 2;
usb_guide_wall_thickness = 1.2;
usb_guide_end_gap = 1;
usb_opening_width = 12.5;
usb_opening_height = 7;
usb_opening_radius = 1.5;
@@ -100,6 +117,8 @@ extra_cutouts = [];
/* [Calibration] */
coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
m25_coupon_steps = [-0.15, -0.075, 0, 0.075, 0.15];
tripod_coupon_steps = [-0.3, -0.15, 0, 0.15, 0.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
@@ -111,6 +130,9 @@ board_thickness = 1.6;
mount_x = [2.54, 48.26];
mount_y = [2.54, 20.32];
usb_center_y = 11.43;
jst_center_xy = [10.9855, 19.304];
jst_body_size = [8, 7.5, 4.5];
jst_relief_clearance = 0.5;
// Official 128x64 OLED FeatherWing geometry in Feather-board coordinates.
oled_visible_min = [11.42, 3.78];
@@ -127,11 +149,34 @@ effective_board_clearance = max(
board_bottom_clearance,
m25_insert_length + insert_melt_relief + 0.6
);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
front_lid_boss_center = wall_thickness + lid_boss_r
- lid_boss_wall_overlap;
// Combined battery/tripod builds need room around the centered insert entry.
combined_tripod_board_gutter = battery_enabled && tripod_insert_enabled ? 2.4 : 0;
combined_tripod_x_shift = battery_enabled && tripod_insert_enabled ? 5 : 0;
board_origin_x = wall_thickness + usb_board_wall_gap;
board_origin_y = wall_thickness + board_side_clearance;
board_origin_y = wall_thickness + board_side_clearance
+ combined_tripod_board_gutter;
board_bottom_z = effective_floor + effective_board_clearance;
board_top_z = board_bottom_z + board_thickness;
usb_center_u = board_origin_y + usb_center_y;
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
usb_inner_width = usb_opening_width + hole_compensation;
usb_inner_height = usb_opening_height + hole_compensation;
usb_guide_length = max(
0,
usb_board_wall_gap - usb_connector_overhang - usb_guide_end_gap
);
usb_guide_active = usb_opening_enabled && usb_guide_enabled
&& usb_guide_length > 0;
usb_reserved_width = usb_inner_width
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
usb_reserved_height = usb_inner_height
+ (usb_guide_active ? 2 * usb_guide_wall_thickness : 0);
computed_top_pcb_z = board_top_z + wing_count * wing_pitch;
top_pcb_z = stack_height_override > 0
@@ -147,19 +192,16 @@ profile_top_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_center_y = partition_y + battery_partition_thickness
+ battery_radial_clearance + battery_diameter / 2;
battery_start_x = front_lid_boss_center + lid_boss_r
+ battery_end_clearance;
battery_end_x = battery_start_x + battery_length;
switch_center_x = battery_end_x + max(
switch_body_diameter / 2 + 0.8,
switch_flange_diameter / 2 + 0.5
);
case_length_plain = board_origin_x + feather_length
+ board_rear_clearance + wall_thickness;
case_length_battery = switch_center_x + switch_flange_diameter / 2
+ 1.2 + wall_thickness;
case_length_battery = battery_end_x + battery_end_clearance
+ wall_thickness + m25_boss_diameter - lid_boss_wall_overlap;
case_length = battery_enabled ? max(case_length_plain, case_length_battery)
: case_length_plain;
@@ -170,23 +212,70 @@ case_width_battery = battery_center_y + battery_diameter / 2
case_width = battery_enabled ? max(case_width_plain, case_width_battery)
: case_width_plain;
rocker_center_x = case_length - wall_thickness - fit_clearance
- lid_lip_thickness - fit_clearance - rocker_body_size[0] / 2;
rocker_center_y = front_lid_boss_center + lid_boss_r + fit_clearance
+ rocker_body_size[1] / 2;
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_center_x - rocker_cutout_size[0] / 2 - rocker_artwork_gap
: case_length - lid_art_margin;
lid_art_physical_size = [
lid_art_safe_max_x - lid_art_margin,
case_width - 2 * lid_art_margin
];
lid_art_center = [
lid_art_margin + lid_art_physical_size[0] / 2,
case_width / 2
];
lid_art_canvas_size = lid_art_rotation == 0 || lid_art_rotation == 180
? lid_art_physical_size
: [lid_art_physical_size[1], lid_art_physical_size[0]];
lid_art_available_size = lid_art_canvas_size;
lid_label_height = lid_label == "" ? 0 : lid_label_size;
lid_art_gap = lid_label != "" && lid_logo_enabled ? lid_label_logo_gap : 0;
logo_native_size = [30, 25.75];
logo_max_height = lid_art_available_size[1] - lid_label_height - lid_art_gap;
logo_scale = lid_logo_enabled ? min(
lid_art_available_size[0] / logo_native_size[0],
logo_max_height / logo_native_size[1]
) : 0;
logo_size = [logo_native_size[0] * logo_scale,
logo_native_size[1] * logo_scale];
lid_art_total_height = logo_size[1] + lid_art_gap + lid_label_height;
lid_label_center_y = -lid_art_total_height / 2 + lid_label_height / 2;
lid_logo_center_y = -lid_art_total_height / 2 + lid_label_height
+ lid_art_gap + logo_size[1] / 2;
tripod_center_x = case_length / 2 - combined_tripod_x_shift;
tripod_center_y = case_width / 2;
tripod_entry_radius = tripod_insert_outer_diameter / 2;
tripod_standoff_clearance = min([
for (mx = mount_x, my = mount_y)
sqrt(
pow(board_origin_x + mx - tripod_center_x, 2)
+ pow(board_origin_y + my - tripod_center_y, 2)
) - tripod_entry_radius - m25_boss_diameter / 2
]);
tripod_partition_clearance = partition_y
- (tripod_center_y + tripod_entry_radius);
battery_center_z = effective_floor + battery_radial_clearance
+ battery_diameter / 2;
switch_center_z = max(
effective_floor + switch_flange_diameter / 2 + 1,
battery_center_z
);
electronics_ceiling_z = top_pcb_z + profile_component_height + profile_top_gap;
battery_ceiling_z = battery_enabled
? max(
battery_center_z + battery_diameter / 2 + battery_radial_clearance,
switch_center_z + switch_flange_diameter / 2 + 1
)
? battery_center_z + battery_diameter / 2 + battery_radial_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z);
rocker_ceiling_z = battery_enabled
? effective_floor + rocker_body_size[2] + fit_clearance
: 0;
body_height = max(electronics_ceiling_z, battery_ceiling_z, rocker_ceiling_z);
lid_boss_r = m25_boss_diameter / 2;
lid_boss_wall_overlap = 0.6;
lid_boss_xy = [
[
wall_thickness + lid_boss_r - lid_boss_wall_overlap,
@@ -210,14 +299,86 @@ assert(wing_count >= 1, "wing_count must be at least one");
assert(top_profile == "generic" || top_profile == "gps"
|| top_profile == "oled128x64", "Unsupported top_profile");
assert(wall_thickness >= 1.2, "wall_thickness is too small for 0.6 mm FDM");
assert(battery_partition_thickness >= 1.2,
"battery_partition_thickness is too small for 0.6 mm FDM");
assert(lid_lip_thickness >= 1.2, "lid lip needs at least two 0.6 mm lines");
assert(lid_art_orientation == "usb_left"
|| lid_art_orientation == "usb_front"
|| lid_art_orientation == "usb_right"
|| lid_art_orientation == "usb_rear",
"Unsupported lid_art_orientation");
assert(!lid_artwork_enabled || top_profile != "oled128x64",
"Lid artwork is not supported with the OLED profile");
assert(!lid_artwork_enabled || lid_inlay_depth < lid_thickness,
"Lid inlay must be shallower than the lid");
assert(!lid_artwork_enabled
|| (lid_art_available_size[0] > 0 && lid_art_available_size[1] > 0),
"Lid artwork margin leaves no printable area");
assert(!lid_logo_enabled || logo_scale > 0,
"Lid logo has no printable area");
assert(m25_boss_diameter > m25_insert_hole_diameter + 2,
"M2.5 boss does not have enough radial material");
assert(m25_insert_outer_diameter >= m25_insert_hole_diameter,
"M2.5 insert entry must not be smaller than its pilot");
assert(!tripod_insert_enabled
|| tripod_boss_diameter > tripod_insert_hole_diameter + 3,
"Tripod insert boss does not have enough radial material");
assert(usb_opening_enabled || !usb_guide_enabled,
"USB guide requires the USB opening");
assert(!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_center_x - rocker_body_size[0] / 2
>= board_origin_x + feather_length + fit_clearance,
"Rocker body overlaps the Feather PCB");
assert(!battery_enabled
|| rocker_center_y + rocker_body_size[1] / 2
<= partition_y - fit_clearance,
"Rocker body overlaps the battery partition");
assert(!battery_enabled
|| body_height - rocker_body_size[2] >= effective_floor,
"Rocker body intersects the case floor");
function clamp(v, lo, hi) = min(max(v, lo), hi);
function cutout_height(entry) = len(entry) > 5 ? entry[5] : entry[4];
function cutouts_separated(first, second, web) =
abs(first[2] - second[2])
>= (first[4] + second[4]) / 2 + web
|| abs(first[3] - second[3])
>= (cutout_height(first) + cutout_height(second)) / 2 + web;
function cutout_clear_of_usb(entry, web) =
abs(entry[2] - usb_center_u)
>= (entry[4] + usb_reserved_width) / 2 + web
|| abs(entry[3] - usb_center_z)
>= (cutout_height(entry) + usb_reserved_height) / 2 + web;
module validate_cutout_spacing() {
if (len(extra_cutouts) > 1)
for (first = [0 : len(extra_cutouts) - 2],
second = [first + 1 : len(extra_cutouts) - 1])
if (extra_cutouts[first][0] == extra_cutouts[second][0])
assert(
cutouts_separated(
extra_cutouts[first],
extra_cutouts[second],
cutout_min_web
),
"Custom cutouts are too close on the same wall"
);
if (usb_opening_enabled)
for (entry = extra_cutouts)
if (entry[0] == "front")
assert(
cutout_clear_of_usb(entry, cutout_min_web),
"Front cutout is too close to the USB opening or guide"
);
}
module rounded_rect_2d(size, radius) {
r = clamp(radius, 0.01, min(size[0], size[1]) / 2 - 0.01);
@@ -242,8 +403,8 @@ module rounded_box(size, radius) {
rounded_rect_2d([size[0], size[1]], radius);
}
module insert_pocket(diameter, depth, entry_z, downward = true) {
bore_d = diameter + hole_compensation;
module insert_pocket(diameter, entry_diameter, depth, entry_z, downward = true) {
bore_d = diameter;
if (downward) {
translate([0, 0, entry_z - depth])
cylinder(h = depth + eps, d = bore_d);
@@ -251,7 +412,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d,
d2 = bore_d + 2 * insert_entry_chamfer
d2 = entry_diameter
);
} else {
translate([0, 0, entry_z - eps])
@@ -259,7 +420,7 @@ module insert_pocket(diameter, depth, entry_z, downward = true) {
translate([0, 0, entry_z - eps])
cylinder(
h = insert_entry_chamfer + eps,
d1 = bore_d + 2 * insert_entry_chamfer,
d1 = entry_diameter,
d2 = bore_d
);
}
@@ -301,6 +462,40 @@ module face_circle_cutout(face, u, z, diameter) {
rotate([-90, 0, 0]) cylinder(h = depth, d = diameter);
}
module x_rounded_prism(x, length, u, z, width, height, radius) {
r = clamp(radius, 0.01, min(width, height) / 2 - 0.01);
hull()
for (yy = [u - width / 2 + r, u + width / 2 - r],
zz = [z - height / 2 + r, z + height / 2 - r])
translate([x, yy, zz])
rotate([0, 90, 0]) cylinder(h = length, r = r);
}
module usb_guide_shell() {
outer_width = usb_inner_width + 2 * usb_guide_wall_thickness;
outer_height = usb_inner_height + 2 * usb_guide_wall_thickness;
difference() {
x_rounded_prism(
wall_thickness - eps,
usb_guide_length + 2 * eps,
usb_center_u,
usb_center_z,
outer_width,
outer_height,
usb_opening_radius + usb_guide_wall_thickness
);
x_rounded_prism(
wall_thickness - 2 * eps,
usb_guide_length + 4 * eps,
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
}
}
module custom_wall_cutout(entry) {
face = entry[0];
shape = entry[1];
@@ -368,20 +563,27 @@ module battery_cradle() {
battery_end_x - 4
];
// Board/battery partition, with a lead-routing notch near the JST end.
// One open-topped front passage provides JST plug access and enough room
// to route the battery lead beneath the Feather PCB beside the lid boss.
difference() {
translate([wall_thickness, partition_y, effective_floor - eps])
cube([
battery_end_x - wall_thickness + battery_end_clearance,
wall_thickness,
battery_partition_thickness,
partition_height - effective_floor + eps
]);
translate([
board_origin_x + 6.5,
wall_thickness - eps,
partition_y - eps,
effective_floor + 1.5
])
cube([9, wall_thickness + 2 * eps, 6]);
cube([
board_origin_x + jst_center_xy[0]
+ (jst_body_size[0] + 2 * jst_relief_clearance) / 2
- wall_thickness + 2 * eps,
battery_partition_thickness + 2 * eps,
partition_height - effective_floor - 1.5 + eps
]);
}
// Three low curved ribs cradle the protected cylindrical pack.
@@ -412,29 +614,16 @@ module battery_cradle() {
battery_end_x + battery_end_clearance
])
translate([
x,
x - eps,
battery_center_y - battery_diameter / 2 - battery_radial_clearance,
effective_floor - eps
])
cube([
wall_thickness,
wall_thickness + 2 * eps,
battery_diameter + 2 * battery_radial_clearance,
battery_diameter / 2 + 1 + eps
]);
// Rear cross-strip separates the side-mounted switch body from the cell.
translate([
battery_end_x + battery_end_clearance,
partition_y,
effective_floor - eps
])
cube([
case_length - wall_thickness - battery_end_x - battery_end_clearance,
case_width - wall_thickness - partition_y,
min(body_height - effective_floor, switch_center_z
+ switch_body_diameter / 2 + switch_wire_allowance
- effective_floor) + eps
]);
}
module body_positive() {
@@ -453,8 +642,10 @@ module body_positive() {
if (battery_enabled)
battery_cradle();
if (tripod_insert_enabled)
translate([case_length / 2, case_width / 2, 0])
translate([tripod_center_x, tripod_center_y, 0])
cylinder(h = effective_floor, d = tripod_boss_diameter);
if (usb_guide_active)
usb_guide_shell();
}
}
@@ -464,6 +655,7 @@ module body_cutouts() {
translate([board_origin_x + mx, board_origin_y + my, 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
board_bottom_z
);
@@ -473,44 +665,24 @@ module body_cutouts() {
translate([p[0], p[1], 0])
insert_pocket(
m25_insert_hole_diameter,
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
body_height
);
// USB-C cable opening. Connector center is from the official ESP32 V2 PCB.
usb_center_z = board_top_z + 1.6 + usb_vertical_offset;
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
if (usb_opening_enabled)
face_rounded_cutout(
"front",
usb_center_u,
usb_center_z,
usb_inner_width,
usb_inner_height,
usb_opening_radius
);
// Clear the switch body and its prewired terminals through the rear bay.
translate([
switch_center_x,
case_width - wall_thickness + eps,
switch_center_z
])
rotate([90, 0, 0])
cylinder(
h = switch_body_length + switch_wire_allowance
+ wall_thickness + eps,
d = switch_body_diameter + 2 * fit_clearance
);
}
if (tripod_insert_enabled) {
translate([case_length / 2, case_width / 2, 0]) {
translate([tripod_center_x, tripod_center_y, 0]) {
translate([0, 0, -eps])
cylinder(
h = effective_floor + 2 * eps,
@@ -518,6 +690,7 @@ module body_cutouts() {
);
insert_pocket(
tripod_insert_hole_diameter,
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
effective_floor
);
@@ -611,6 +784,13 @@ module oled_button_lid_cutout(xy) {
module lid_cutouts() {
// Through holes plus shallow counterbores for flush M2.5 socket heads.
for (p = lid_boss_xy) {
// Scallop only the locating lip so it can pass over the full-height
// insert boss while leaving the lid plate supported by the boss top.
translate([p[0], p[1], lid_thickness - eps])
cylinder(
h = lid_lip_depth + 2 * eps,
d = m25_boss_diameter + 2 * fit_clearance
);
translate([p[0], p[1], -eps])
cylinder(
h = lid_thickness + lid_lip_depth + 2 * eps,
@@ -623,6 +803,14 @@ module lid_cutouts() {
);
}
if (battery_enabled)
translate([rocker_center_x, rocker_center_y, -eps])
centered_rounded_prism(
rocker_cutout_size,
lid_thickness + lid_lip_depth + 2 * eps,
rocker_corner_radius
);
if (top_profile == "oled128x64") {
beveled_oled_window();
if (button_a) oled_button_lid_cutout(oled_button_a_xy);
@@ -632,13 +820,55 @@ module lid_cutouts() {
}
}
module lid() {
module lid_artwork_nominal_2d() {
translate(lid_art_center)
rotate(lid_art_rotation)
// The lid is modeled exterior-face-down; mirror the artwork so it
// reads normally after the lid is turned into service position.
mirror([0, 1, 0]) {
if (lid_logo_enabled)
translate([0, lid_logo_center_y])
resize(logo_size)
import(logo_file, center = true);
if (lid_label != "")
translate([0, lid_label_center_y])
text(
lid_label,
size = lid_label_size,
halign = "center",
valign = "center",
font = "DejaVu Sans:style=Bold"
);
}
}
module lid_artwork_2d(inset = 0) {
offset(delta = inset)
lid_artwork_nominal_2d();
}
module lid_base() {
difference() {
lid_plate_positive();
lid_cutouts();
if (lid_artwork_enabled)
translate([0, 0, -eps])
linear_extrude(height = lid_inlay_depth + eps)
lid_artwork_2d(lid_inlay_fit_clearance);
}
}
module lid_accent() {
if (lid_artwork_enabled)
linear_extrude(height = lid_inlay_depth)
lid_artwork_2d();
}
module lid() {
color([0.92, 0.92, 0.95]) lid_base();
color([0.95, 0.42, 0.08]) lid_accent();
}
module one_oled_button() {
recess_depth = button_flange_thickness + button_travel;
stem_height = lid_thickness - recess_depth + button_projection;
@@ -678,6 +908,13 @@ module reference_board() {
color([0.05, 0.25, 0.55, 0.75])
translate([board_origin_x, board_origin_y, board_bottom_z])
rounded_box([feather_length, feather_width, board_thickness], 2.54);
color([0.92, 0.92, 0.86, 0.9])
translate([
board_origin_x + jst_center_xy[0] - jst_body_size[0] / 2,
board_origin_y + jst_center_xy[1] - 4.5,
board_top_z
])
cube(jst_body_size);
color([0.12, 0.45, 0.75, 0.45])
for (i = [1 : wing_count])
translate([
@@ -744,19 +981,20 @@ module reference_battery_and_switch() {
translate([battery_start_x, battery_center_y, battery_center_z])
rotate([0, 90, 0])
cylinder(h = battery_length, d = battery_diameter);
color([0.8, 0.15, 0.1, 0.65])
color([0.08, 0.08, 0.08, 0.85])
translate([
switch_center_x,
case_width - wall_thickness,
switch_center_z
rocker_center_x - rocker_body_size[0] / 2,
rocker_center_y - rocker_body_size[1] / 2,
body_height + exploded_view - rocker_body_size[2]
])
rotate([90, 0, 0]) {
cylinder(h = switch_body_length, d = switch_body_diameter);
translate([0, 0, -1.5])
cylinder(h = 1.5, d = switch_flange_diameter);
translate([0, 0, -3])
cylinder(h = 3, d = switch_cap_diameter);
}
cube(rocker_body_size);
color([0.12, 0.12, 0.12, 0.95])
translate([
rocker_center_x - rocker_cutout_size[0] / 2,
rocker_center_y - rocker_cutout_size[1] / 2,
body_height + exploded_view + lid_thickness
])
cube([rocker_cutout_size[0], rocker_cutout_size[1], 2.5]);
}
}
@@ -774,13 +1012,9 @@ module assembly() {
lid();
}
module coupon_hole(x, y, diameter, depth) {
translate([x, y, -eps]) cylinder(h = depth + 2 * eps, d = diameter);
}
module insert_coupon() {
spacing = 15;
coupon_length = spacing * len(coupon_steps) + 8;
coupon_length = spacing * len(tripod_coupon_steps) + 8;
coupon_width = 32;
coupon_height = max(
m25_insert_length + insert_melt_relief + 1.2,
@@ -788,18 +1022,22 @@ module insert_coupon() {
);
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1]) {
for (i = [0 : len(tripod_coupon_steps) - 1]) {
x = 7 + i * spacing;
coupon_hole(
x, 8,
m25_insert_hole_diameter + coupon_steps[i],
m25_insert_length + insert_melt_relief
);
coupon_hole(
x, 23,
tripod_insert_hole_diameter + coupon_steps[i],
tripod_insert_length + insert_melt_relief
);
translate([x, 8, 0])
insert_pocket(
m25_insert_hole_diameter + m25_coupon_steps[i],
m25_insert_outer_diameter,
m25_insert_length + insert_melt_relief,
coupon_height
);
translate([x, 23, 0])
insert_pocket(
tripod_insert_hole_diameter + tripod_coupon_steps[i],
tripod_insert_outer_diameter,
tripod_insert_length + insert_melt_relief,
coupon_height
);
}
}
}
@@ -807,15 +1045,17 @@ module insert_coupon() {
module switch_coupon() {
spacing = 18;
coupon_length = spacing * len(coupon_steps) + 6;
coupon_width = 24;
coupon_height = wall_thickness;
coupon_width = rocker_cutout_size[1] + 6;
coupon_height = lid_thickness;
difference() {
rounded_box([coupon_length, coupon_width, coupon_height], 3);
for (i = [0 : len(coupon_steps) - 1])
translate([6 + i * spacing, coupon_width / 2, -eps])
cylinder(
h = coupon_height + 2 * eps,
d = switch_hole_diameter + coupon_steps[i]
centered_rounded_prism(
rocker_cutout_size
+ [coupon_steps[i], coupon_steps[i]],
coupon_height + 2 * eps,
rocker_corner_radius
);
}
}
@@ -854,12 +1094,18 @@ module fit_coupon() {
}
}
validate_cutout_spacing();
if (part == "assembly")
assembly();
else if (part == "body")
body();
else if (part == "lid")
lid();
else if (part == "lid_base")
lid_base();
else if (part == "lid_accent")
lid_accent();
else if (part == "buttons")
buttons();
else if (part == "insert_coupon")
+101
View File
@@ -0,0 +1,101 @@
"""Render aligned lid-base and accent STLs with distinct materials."""
import os
import sys
import math
import bpy
from mathutils import Matrix, Vector
def import_mesh(path, name, color):
bpy.ops.wm.stl_import(filepath=os.path.abspath(path))
model = bpy.context.selected_objects[0]
model.name = name
material = bpy.data.materials.new(f"{name} material")
material.diffuse_color = color
material.metallic = 0.0
material.roughness = 0.45
model.data.materials.append(material)
return model
def look_at(camera, target):
direction = Vector(target) - camera.location
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) not in (4, 5):
raise SystemExit(
"expected: lid-base.stl lid-accent.stl output-directory basename [orientation]"
)
base_path, accent_path, output_dir, basename = args[:4]
orientation = args[4] if len(args) == 5 else "usb_left"
angles = {"usb_left": 0, "usb_front": 90, "usb_right": 180, "usb_rear": 270}
if orientation not in angles:
raise SystemExit(f"unknown lid orientation: {orientation}")
os.makedirs(output_dir, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
models = [
import_mesh(base_path, "Lid base", (0.16, 0.42, 0.72, 1.0)),
import_mesh(accent_path, "Accent inlays", (0.95, 0.42, 0.08, 1.0)),
]
# Lid exports are print-oriented with the decorated exterior face down.
# Turn both aligned parts over and present the configured USB edge in its
# named position so previews inspect the finished face as the user will.
for model in models:
model.matrix_world = (
Matrix.Rotation(math.radians(angles[orientation]), 4, "Z")
@ Matrix.Rotation(math.pi, 4, "X")
)
bpy.context.view_layer.update()
corners = [obj.matrix_world @ Vector(corner) for obj in models for corner in obj.bound_box]
mins = Vector([min(v[i] for v in corners) for i in range(3)])
maxs = Vector([max(v[i] for v in corners) for i in range(3)])
center = (mins + maxs) / 2
span = max(maxs - mins)
world = bpy.data.worlds.new("Inspection world")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1)
world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.5
bpy.context.scene.world = world
camera_data = bpy.data.cameras.new("Camera")
camera = bpy.data.objects.new("Camera", camera_data)
bpy.context.collection.objects.link(camera)
bpy.context.scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = span * 1.35
key_data = bpy.data.lights.new("Key", type="AREA")
key_data.energy = 900
key_data.size = span
key = bpy.data.objects.new("Key", key_data)
key.location = center + Vector((span, -span, span * 1.5))
bpy.context.collection.objects.link(key)
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 800
scene.render.resolution_y = 600
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "MATERIAL"
scene.display.shading.show_shadows = True
scene.display.shading.show_cavity = True
scene.display.shading.cavity_type = "BOTH"
for view, direction in {"iso": (1, -1, 0.8), "top": (0, 0, 1)}.items():
camera.location = center + Vector(direction).normalized() * span * 2.2
look_at(camera, center)
scene.render.filepath = os.path.join(output_dir, f"{basename}_{view}.png")
bpy.ops.render.render(write_still=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Combine aligned lid meshes into one slicer-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(base: Path, accent: Path, title: str) -> bytes:
root = ET.Element(
f"{{{CORE}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = (
"feather-case-wizard"
)
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(
materials, f"{{{CORE}}}base", {"name": "Lid base", "displaycolor": "#D1D5DB"}
)
ET.SubElement(
materials,
f"{{{CORE}}}base",
{"name": "Accent inlays", "displaycolor": "#F97316"},
)
for object_id, name, source in (
(2, "Lid base — Filament 1", base),
(3, "Accent inlays — Filament 2", accent),
):
obj = ET.SubElement(
resources,
f"{{{CORE}}}object",
{
"id": str(object_id),
"name": name,
"type": "model",
"pid": "1",
"pindex": str(object_id - 2),
},
)
obj.append(load_mesh(source))
assembly = ET.SubElement(
resources, f"{{{CORE}}}object", {"id": "4", "name": title, "type": "model"}
)
components = ET.SubElement(assembly, f"{{{CORE}}}components")
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "2"})
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": "3"})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": "4"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def bambu_model_settings_xml(title: str) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": "4"})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, extruder in (
(2, "Lid base — Filament 1", 1),
(3, "Accent inlays — Filament 2", 2),
):
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base", type=Path)
parser.add_argument("accent", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(
archive,
"3D/3dmodel.model",
model_xml(args.base, args.accent, args.title),
)
write_entry(
archive,
"Metadata/model_settings.config",
bambu_model_settings_xml(args.title),
)
if __name__ == "__main__":
main()