tweak the fit of the parts
This commit is contained in:
@@ -10,6 +10,11 @@ module FeatherCaseWizard
|
||||
FACES = %w[front rear left right].freeze
|
||||
SHAPES = %w[circle rect roundrect slot].freeze
|
||||
COUPONS = %w[insert_coupon switch_coupon fit_coupon].freeze
|
||||
LID_ART_ORIENTATIONS = %w[usb_left usb_front usb_right usb_rear].freeze
|
||||
LEGACY_SWITCH_FIELDS = %w[switch_hole_diameter switch_hole_compensation
|
||||
switch_cap_diameter switch_flange_diameter
|
||||
switch_body_length switch_body_diameter
|
||||
switch_wire_allowance].freeze
|
||||
|
||||
DEFAULT_PARAMETERS = {
|
||||
"top_profile" => "generic",
|
||||
@@ -38,20 +43,20 @@ module FeatherCaseWizard
|
||||
"battery_diameter" => 18.0,
|
||||
"battery_radial_clearance" => 0.6,
|
||||
"battery_end_clearance" => 1.0,
|
||||
"switch_hole_diameter" => 12.0,
|
||||
"switch_hole_compensation" => 0.3,
|
||||
"switch_cap_diameter" => 8.0,
|
||||
"switch_flange_diameter" => 16.2,
|
||||
"switch_body_length" => 24.0,
|
||||
"switch_body_diameter" => 12.0,
|
||||
"switch_wire_allowance" => 3.0,
|
||||
"battery_partition_thickness" => 1.8,
|
||||
"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" => false,
|
||||
"tripod_insert_length" => 6.4,
|
||||
"tripod_insert_hole_diameter" => 8.8,
|
||||
"tripod_insert_length" => 5.0,
|
||||
"tripod_insert_hole_diameter" => 7.2,
|
||||
"tripod_insert_outer_diameter" => 8.0,
|
||||
"tripod_thread_clearance_diameter" => 6.8,
|
||||
"tripod_boss_diameter" => 14.0,
|
||||
"m25_insert_length" => 4.0,
|
||||
"m25_insert_hole_diameter" => 3.7,
|
||||
"m25_insert_hole_diameter" => 3.1,
|
||||
"m25_insert_outer_diameter" => 3.5,
|
||||
"m25_screw_clearance" => 2.8,
|
||||
"m25_boss_diameter" => 7.0,
|
||||
"insert_entry_chamfer" => 0.6,
|
||||
@@ -76,6 +81,14 @@ module FeatherCaseWizard
|
||||
"usb_connector_overhang" => 2.0,
|
||||
"usb_guide_wall_thickness" => 1.2,
|
||||
"usb_guide_end_gap" => 1.0,
|
||||
"lid_label" => "",
|
||||
"lid_logo_enabled" => false,
|
||||
"lid_art_orientation" => "usb_left",
|
||||
"lid_inlay_depth" => 0.6,
|
||||
"lid_inlay_fit_clearance" => 0.08,
|
||||
"lid_label_size" => 5.0,
|
||||
"lid_label_logo_gap" => 2.0,
|
||||
"lid_art_margin" => 8.5,
|
||||
"usb_opening_width" => 12.5,
|
||||
"usb_opening_height" => 7.0,
|
||||
"usb_opening_radius" => 1.5,
|
||||
@@ -87,19 +100,23 @@ module FeatherCaseWizard
|
||||
gps_antenna_height gps_top_gap oled_component_height
|
||||
oled_display_height oled_face_gap],
|
||||
"Shell and fit" => %w[wall_thickness floor_thickness lid_thickness corner_radius
|
||||
board_bottom_clearance board_side_clearance lid_lip_depth
|
||||
lid_lip_thickness fit_clearance cutout_min_web hole_compensation
|
||||
lid_screw_head_diameter lid_screw_head_height],
|
||||
board_bottom_clearance board_side_clearance board_rear_clearance
|
||||
usb_board_wall_gap lid_lip_depth lid_lip_thickness fit_clearance
|
||||
cutout_min_web hole_compensation lid_screw_head_diameter
|
||||
lid_screw_head_height],
|
||||
"Lid artwork" => %w[lid_inlay_depth lid_inlay_fit_clearance lid_label_size
|
||||
lid_label_logo_gap lid_art_margin],
|
||||
"M2.5 inserts" => %w[m25_insert_length m25_insert_hole_diameter
|
||||
m25_insert_outer_diameter
|
||||
m25_screw_clearance m25_boss_diameter
|
||||
insert_entry_chamfer insert_melt_relief],
|
||||
"Tripod insert" => %w[tripod_insert_length tripod_insert_hole_diameter
|
||||
tripod_insert_outer_diameter
|
||||
tripod_thread_clearance_diameter tripod_boss_diameter],
|
||||
"Battery and switch" => %w[battery_length battery_diameter battery_radial_clearance
|
||||
battery_end_clearance switch_hole_diameter
|
||||
switch_hole_compensation switch_cap_diameter
|
||||
switch_flange_diameter switch_body_length
|
||||
switch_body_diameter switch_wire_allowance],
|
||||
battery_end_clearance battery_partition_thickness
|
||||
rocker_cutout_size rocker_corner_radius
|
||||
rocker_body_size rocker_artwork_gap],
|
||||
"USB-C opening" => %w[usb_opening_width usb_opening_height
|
||||
usb_opening_radius usb_vertical_offset usb_connector_overhang
|
||||
usb_guide_wall_thickness usb_guide_end_gap],
|
||||
@@ -158,6 +175,7 @@ module FeatherCaseWizard
|
||||
|
||||
def printable_parts
|
||||
parts = %w[body lid]
|
||||
parts.concat(%w[lid_base lid_accent]) if lid_artwork?
|
||||
if parameters["top_profile"] == "oled128x64" &&
|
||||
%w[button_a button_b button_c button_reset].any? { |key| parameters[key] }
|
||||
parts << "buttons"
|
||||
@@ -165,6 +183,10 @@ module FeatherCaseWizard
|
||||
parts + outputs["coupons"]
|
||||
end
|
||||
|
||||
def lid_artwork?
|
||||
!parameters["lid_label"].empty? || parameters["lid_logo_enabled"]
|
||||
end
|
||||
|
||||
def model_parameters
|
||||
parameters.merge("extra_cutouts" => scad_cutouts)
|
||||
end
|
||||
@@ -179,7 +201,10 @@ module FeatherCaseWizard
|
||||
p["m25_insert_length"] + p["insert_melt_relief"] + 0.6
|
||||
].max
|
||||
board_origin_x = p["wall_thickness"] + p["usb_board_wall_gap"]
|
||||
board_origin_y = p["wall_thickness"] + p["board_side_clearance"]
|
||||
combined_tripod_board_gutter = p["battery_enabled"] && p["tripod_insert_enabled"] ?
|
||||
2.4 : 0.0
|
||||
board_origin_y = p["wall_thickness"] + p["board_side_clearance"] +
|
||||
combined_tripod_board_gutter
|
||||
board_top_z = effective_floor + effective_board_clearance + 1.6
|
||||
top_pcb_z = board_top_z + (
|
||||
p["stack_height_override"].positive? ?
|
||||
@@ -199,40 +224,40 @@ module FeatherCaseWizard
|
||||
p["wall_thickness"]
|
||||
if p["battery_enabled"]
|
||||
partition_y = board_origin_y + 22.86 + 1
|
||||
battery_y = partition_y + p["wall_thickness"] +
|
||||
battery_y = partition_y + p["battery_partition_thickness"] +
|
||||
p["battery_radial_clearance"] + p["battery_diameter"] / 2
|
||||
battery_start_x = board_origin_x + p["battery_end_clearance"]
|
||||
lid_boss_wall_overlap = 0.6
|
||||
lid_boss_front_x = p["wall_thickness"] + p["m25_boss_diameter"] / 2 -
|
||||
lid_boss_wall_overlap
|
||||
battery_start_x = lid_boss_front_x + p["m25_boss_diameter"] / 2 +
|
||||
p["battery_end_clearance"]
|
||||
battery_end_x = battery_start_x + p["battery_length"]
|
||||
switch_x = battery_end_x + [
|
||||
p["switch_body_diameter"] / 2 + 0.8,
|
||||
p["switch_flange_diameter"] / 2 + 0.5
|
||||
].max
|
||||
length = [plain_length, switch_x + p["switch_flange_diameter"] / 2 +
|
||||
1.2 + p["wall_thickness"]].max
|
||||
battery_length_required = battery_end_x + p["battery_end_clearance"] +
|
||||
p["wall_thickness"] + p["m25_boss_diameter"] -
|
||||
lid_boss_wall_overlap
|
||||
length = [plain_length, battery_length_required].max
|
||||
width = [plain_width, battery_y + p["battery_diameter"] / 2 +
|
||||
p["battery_radial_clearance"] + p["wall_thickness"]].max
|
||||
battery_z = effective_floor + p["battery_radial_clearance"] +
|
||||
p["battery_diameter"] / 2
|
||||
switch_z = [
|
||||
effective_floor + p["switch_flange_diameter"] / 2 + 1,
|
||||
battery_z
|
||||
].max
|
||||
battery_ceiling = [
|
||||
battery_z + p["battery_diameter"] / 2 + p["battery_radial_clearance"],
|
||||
switch_z + p["switch_flange_diameter"] / 2 + 1
|
||||
].max
|
||||
battery_ceiling = battery_z + p["battery_diameter"] / 2 +
|
||||
p["battery_radial_clearance"]
|
||||
rocker_ceiling = effective_floor + p["rocker_body_size"][2] +
|
||||
p["fit_clearance"]
|
||||
else
|
||||
length = plain_length
|
||||
width = plain_width
|
||||
battery_ceiling = 0
|
||||
rocker_ceiling = 0
|
||||
end
|
||||
{
|
||||
"length" => length,
|
||||
"width" => width,
|
||||
"height" => [top_pcb_z + component_height + top_gap, battery_ceiling].max,
|
||||
"height" => [top_pcb_z + component_height + top_gap,
|
||||
battery_ceiling, rocker_ceiling].max,
|
||||
"floor" => effective_floor,
|
||||
"lip_bottom" => [top_pcb_z + component_height + top_gap, battery_ceiling].max -
|
||||
p["lid_lip_depth"]
|
||||
"lip_bottom" => [top_pcb_z + component_height + top_gap,
|
||||
battery_ceiling, rocker_ceiling].max - p["lid_lip_depth"]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -265,6 +290,15 @@ module FeatherCaseWizard
|
||||
raise ConfigError, "Slug must contain lowercase letters, numbers, and single hyphens"
|
||||
end
|
||||
raise ConfigError, "Unsupported top profile" unless PROFILES.include?(parameters["top_profile"])
|
||||
unless LID_ART_ORIENTATIONS.include?(parameters["lid_art_orientation"])
|
||||
raise ConfigError, "Unsupported lid art orientation"
|
||||
end
|
||||
unless parameters["lid_label"].is_a?(String)
|
||||
raise ConfigError, "lid_label must be text"
|
||||
end
|
||||
if lid_artwork? && parameters["top_profile"] == "oled128x64"
|
||||
raise ConfigError, "Lid artwork is not supported with the OLED profile"
|
||||
end
|
||||
raise ConfigError, "wing_count must be between 1 and 8" unless (1..8).cover?(parameters["wing_count"])
|
||||
raise ConfigError, "At least one export format is required" if outputs["formats"].empty?
|
||||
unless (outputs["formats"] - FORMATS).empty?
|
||||
@@ -289,8 +323,20 @@ module FeatherCaseWizard
|
||||
parameters["stack_height_override"].negative?
|
||||
raise ConfigError, "wall_thickness must be at least 1.2 mm" if
|
||||
parameters["wall_thickness"] < 1.2
|
||||
raise ConfigError, "battery_partition_thickness must be at least 1.2 mm" if
|
||||
parameters["battery_partition_thickness"] < 1.2
|
||||
raise ConfigError, "lid_lip_thickness must be at least 1.2 mm" if
|
||||
parameters["lid_lip_thickness"] < 1.2
|
||||
raise ConfigError, "lid_inlay_depth must be less than lid_thickness" if
|
||||
parameters["lid_inlay_depth"] >= parameters["lid_thickness"]
|
||||
if parameters["m25_insert_outer_diameter"] < parameters["m25_insert_hole_diameter"]
|
||||
raise ConfigError, "m25_insert_outer_diameter must not be smaller than the pilot"
|
||||
end
|
||||
if parameters["tripod_insert_outer_diameter"] <
|
||||
parameters["tripod_insert_hole_diameter"]
|
||||
raise ConfigError,
|
||||
"tripod_insert_outer_diameter must not be smaller than the pilot"
|
||||
end
|
||||
|
||||
validate_cutouts!
|
||||
self
|
||||
@@ -305,6 +351,7 @@ module FeatherCaseWizard
|
||||
legacy_usb_guide = !supplied_parameters.key?("usb_guide_enabled")
|
||||
data["parameters"] = self.class.deep_copy(DEFAULT_PARAMETERS).merge(supplied_parameters)
|
||||
data["parameters"]["usb_guide_enabled"] = false if legacy_usb_guide
|
||||
LEGACY_SWITCH_FIELDS.each { |key| data["parameters"].delete(key) }
|
||||
data["cutouts"] ||= []
|
||||
data["outputs"] ||= {}
|
||||
data["outputs"]["formats"] ||= ["3mf"]
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
module FeatherCaseWizard
|
||||
class PackageBuilder
|
||||
attr_reader :config, :root, :toolchain, :model_source, :renderer_source
|
||||
attr_reader :config, :root, :toolchain, :model_source, :renderer_source,
|
||||
:multicolor_renderer_source, :multicolor_packager_source, :logo_source
|
||||
|
||||
def initialize(config, root: CASES_ROOT, toolchain: Toolchain.new,
|
||||
model_source: MODEL_SOURCE, renderer_source: RENDERER_SOURCE,
|
||||
multicolor_renderer_source: MULTICOLOR_RENDERER_SOURCE,
|
||||
multicolor_packager_source: MULTICOLOR_PACKAGER_SOURCE,
|
||||
logo_source: LOGO_SOURCE,
|
||||
reporter: nil)
|
||||
@config = config
|
||||
@root = Pathname(root)
|
||||
@toolchain = toolchain
|
||||
@model_source = Pathname(model_source)
|
||||
@renderer_source = Pathname(renderer_source)
|
||||
@multicolor_renderer_source = Pathname(multicolor_renderer_source)
|
||||
@multicolor_packager_source = Pathname(multicolor_packager_source)
|
||||
@logo_source = Pathname(logo_source)
|
||||
@reporter = reporter || ->(_message) {}
|
||||
end
|
||||
|
||||
@@ -37,13 +44,22 @@ module FeatherCaseWizard
|
||||
def verify_sources!
|
||||
raise ToolError, "Missing model source: #{model_source}" unless model_source.file?
|
||||
raise ToolError, "Missing preview renderer: #{renderer_source}" unless renderer_source.file?
|
||||
raise ToolError, "Missing multicolor renderer: #{multicolor_renderer_source}" unless
|
||||
multicolor_renderer_source.file?
|
||||
raise ToolError, "Missing multicolor packager: #{multicolor_packager_source}" unless
|
||||
multicolor_packager_source.file?
|
||||
raise ToolError, "Missing lid logo: #{logo_source}" unless logo_source.file?
|
||||
end
|
||||
|
||||
def prepare_package(stage, previous)
|
||||
source_dir = stage.join("source")
|
||||
FileUtils.mkdir_p(source_dir)
|
||||
tools_dir = stage.join("tools")
|
||||
FileUtils.mkdir_p([source_dir, source_dir.join("assets"), tools_dir])
|
||||
FileUtils.cp(model_source, source_dir.join("feather_case.scad"))
|
||||
FileUtils.cp(renderer_source, source_dir.join("render_stl_previews.py"))
|
||||
FileUtils.cp(multicolor_renderer_source, source_dir.join("render_multicolor_lid.py"))
|
||||
FileUtils.cp(multicolor_packager_source, tools_dir.join("package_multimaterial_3mf.py"))
|
||||
FileUtils.cp(logo_source, source_dir.join("assets/NME-fixed.svg"))
|
||||
FileUtils.cp(ROOT.join("templates/standalone_build.rb"), stage.join("build.rb"))
|
||||
|
||||
copy_history(previous, stage)
|
||||
@@ -91,9 +107,26 @@ module FeatherCaseWizard
|
||||
report("Exporting #{part}.#{format}")
|
||||
export_model(stage, directory.join("#{part}.#{format}"), part)
|
||||
end
|
||||
package_multicolor_lid(stage, directory) if format == "3mf" && config.lid_artwork?
|
||||
end
|
||||
end
|
||||
|
||||
def package_multicolor_lid(stage, directory)
|
||||
report("Packaging lid_multicolor.3mf")
|
||||
command = [
|
||||
toolchain.python,
|
||||
stage.join("tools/package_multimaterial_3mf.py").to_s,
|
||||
directory.join("lid_base.3mf").to_s,
|
||||
directory.join("lid_accent.3mf").to_s,
|
||||
directory.join("lid_multicolor.3mf").to_s,
|
||||
"--title", "#{config.name} Lid"
|
||||
]
|
||||
toolchain.run!(*command)
|
||||
output = directory.join("lid_multicolor.3mf")
|
||||
raise ToolError, "Multicolor packager did not create #{output}" unless
|
||||
output.file? && output.size.positive?
|
||||
end
|
||||
|
||||
def generate_previews(stage)
|
||||
previews = stage.join("previews")
|
||||
scratch = stage.join(".preview-meshes")
|
||||
@@ -111,9 +144,22 @@ module FeatherCaseWizard
|
||||
export_model(stage, mesh, part) unless mesh.file?
|
||||
render(stage, mesh, previews, part, ["iso"])
|
||||
end
|
||||
render_multicolor_lid(stage, scratch, previews) if config.lid_artwork?
|
||||
FileUtils.rm_rf(scratch)
|
||||
end
|
||||
|
||||
def render_multicolor_lid(stage, scratch, previews)
|
||||
report("Rendering multicolor lid preview")
|
||||
command = [
|
||||
toolchain.blender, "--background", "-noaudio", "--python",
|
||||
stage.join("source/render_multicolor_lid.py").to_s, "--",
|
||||
scratch.join("lid_base.stl").to_s,
|
||||
scratch.join("lid_accent.stl").to_s,
|
||||
previews.to_s, "lid_multicolor", config.parameters["lid_art_orientation"]
|
||||
]
|
||||
toolchain.run!(*command, env: {"DISPLAY" => nil, "WAYLAND_DISPLAY" => nil})
|
||||
end
|
||||
|
||||
def export_model(stage, output, part, overrides = {})
|
||||
model = stage.join("source/feather_case.scad")
|
||||
parameters = config.model_parameters.merge(overrides).merge("part" => part)
|
||||
@@ -139,6 +185,18 @@ module FeatherCaseWizard
|
||||
def write_summary(stage)
|
||||
dims = config.dimensions
|
||||
parts = config.printable_parts.map { |part| "`#{part}`" }.join(", ")
|
||||
artwork = if config.lid_artwork?
|
||||
<<~ARTWORK.chomp
|
||||
|
||||
For a two-color lid, open `exports/3mf/lid_multicolor.3mf` as one
|
||||
multipart object. Assign “Lid base — Filament 1” and “Accent inlays —
|
||||
Filament 2” to the desired filaments. `lid.3mf` is the complete
|
||||
single-color fallback; `lid_base.3mf` and `lid_accent.3mf` are also
|
||||
provided separately.
|
||||
ARTWORK
|
||||
else
|
||||
""
|
||||
end
|
||||
text = <<~MARKDOWN
|
||||
# #{config.name}
|
||||
|
||||
@@ -154,8 +212,8 @@ module FeatherCaseWizard
|
||||
- Custom cutouts: #{config.cutouts.length}
|
||||
|
||||
Run `ruby build.rb` in this directory to regenerate exports and previews.
|
||||
OpenSCAD and Blender must be installed. Set `OPENSCAD` or `BLENDER` to
|
||||
override their executable paths.
|
||||
OpenSCAD, Blender, and Python 3 must be installed. Set `OPENSCAD`,
|
||||
`BLENDER`, or `PYTHON` to override their executable paths.
|
||||
|
||||
To open the saved Customizer values manually:
|
||||
|
||||
@@ -165,6 +223,7 @@ module FeatherCaseWizard
|
||||
|
||||
The 3MF files contain portable geometry only. Choose printer, filament,
|
||||
plate, and slicing settings in Bambu Studio.
|
||||
#{artwork}
|
||||
MARKDOWN
|
||||
File.write(stage.join("README.md"), text)
|
||||
end
|
||||
|
||||
@@ -4,18 +4,20 @@ module FeatherCaseWizard
|
||||
class ToolError < StandardError; end
|
||||
|
||||
class Toolchain
|
||||
attr_reader :openscad, :blender
|
||||
attr_reader :openscad, :blender, :python
|
||||
|
||||
def initialize(env: ENV)
|
||||
@env = env
|
||||
@openscad = discover("OPENSCAD", "openscad")
|
||||
@blender = discover("BLENDER", "blender")
|
||||
@python = discover("PYTHON", "python3")
|
||||
end
|
||||
|
||||
def check!
|
||||
missing = []
|
||||
missing << "OpenSCAD (`sudo apt-get install openscad` or set OPENSCAD)" unless openscad
|
||||
missing << "Blender (`sudo apt-get install blender` or set BLENDER)" unless blender
|
||||
missing << "Python 3 (`sudo apt-get install python3` or set PYTHON)" unless python
|
||||
return self if missing.empty?
|
||||
|
||||
raise ToolError, "Missing required tools:\n - #{missing.join("\n - ")}"
|
||||
|
||||
@@ -44,6 +44,7 @@ module FeatherCaseWizard
|
||||
choose_stack(config)
|
||||
choose_end_clearance(config)
|
||||
choose_features(config)
|
||||
choose_lid_artwork(config)
|
||||
manage_cutouts(config)
|
||||
choose_coupons(config)
|
||||
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
|
||||
@@ -143,6 +144,26 @@ module FeatherCaseWizard
|
||||
)
|
||||
end
|
||||
|
||||
def choose_lid_artwork(config)
|
||||
p = config.parameters
|
||||
label_enabled = agreement("Add a two-color device title to the lid?",
|
||||
!p["lid_label"].empty?)
|
||||
p["lid_label"] = label_enabled ? text(
|
||||
"Device title", p["lid_label"].empty? ? config.name : p["lid_label"]
|
||||
) : ""
|
||||
p["lid_logo_enabled"] = agreement("Add the two-color NME logo to the lid?",
|
||||
p["lid_logo_enabled"])
|
||||
return unless !p["lid_label"].empty? || p["lid_logo_enabled"]
|
||||
|
||||
current = p["lid_art_orientation"]
|
||||
p["lid_art_orientation"] = ui.choose("USB position when the lid text is upright") do |menu|
|
||||
Config::LID_ART_ORIENTATIONS.each do |orientation|
|
||||
menu.choice(orientation.delete_prefix("usb_").capitalize) { orientation }
|
||||
end
|
||||
menu.default = (Config::LID_ART_ORIENTATIONS.index(current).to_i + 1).to_s
|
||||
end
|
||||
end
|
||||
|
||||
def manage_cutouts(config)
|
||||
loop do
|
||||
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" :
|
||||
|
||||
Reference in New Issue
Block a user