326 lines
12 KiB
Ruby
326 lines
12 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
module FeatherCaseWizard
|
||
class Wizard
|
||
attr_reader :ui
|
||
|
||
def initialize(ui: HighLine.new)
|
||
@ui = PromptingUI.wrap(ui)
|
||
end
|
||
|
||
def create
|
||
name = required_text("Case name")
|
||
slug = ask_slug(slugify(name))
|
||
configure(Config.create(name: name, slug: slug), new_case: true)
|
||
end
|
||
|
||
def edit(config)
|
||
ui.say("Editing #{config.name} (`#{config.slug}`)")
|
||
config.data["name"] = text("Case name", config.name)
|
||
configure(config, new_case: false)
|
||
end
|
||
|
||
def confirm_build(config)
|
||
dims = config.dimensions
|
||
ui.say(ui.color("Build summary", :bold))
|
||
ui.say(" Case: #{config.name} (#{config.slug})")
|
||
ui.say(" Profile: #{config.parameters["top_profile"]}")
|
||
ui.say(" Wings: #{config.parameters["wing_count"]}")
|
||
ui.say(" Battery: #{yes_no(config.parameters["battery_enabled"])}")
|
||
ui.say(" Tripod insert: #{yes_no(config.parameters["tripod_insert_enabled"])}")
|
||
ui.say(" Cutouts: #{config.cutouts.length}")
|
||
ui.say(" Size: #{format("%.1f × %.1f × %.1f mm", dims["length"], dims["width"],
|
||
dims["height"] + config.parameters["lid_thickness"])}")
|
||
ui.say(" Parts: #{config.printable_parts.join(", ")}")
|
||
ui.say(" Formats: #{config.outputs["formats"].join(", ")}")
|
||
ui.agree("Generate this case? (y/n) ") { |question| question.default = "yes" }
|
||
end
|
||
|
||
private
|
||
|
||
def configure(config, new_case:)
|
||
choose_formats(config)
|
||
choose_profile(config)
|
||
choose_stack(config)
|
||
choose_end_clearance(config)
|
||
choose_features(config)
|
||
manage_cutouts(config)
|
||
choose_coupons(config)
|
||
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
|
||
question.default = "no"
|
||
end
|
||
config.validate!
|
||
config
|
||
rescue ConfigError => e
|
||
ui.say(ui.color("Configuration error: #{e.message}", :red))
|
||
retry unless new_case && !ui.agree("Return to the questions? (y/n) ") { |q| q.default = "yes" }
|
||
raise
|
||
end
|
||
|
||
def choose_formats(config)
|
||
current = config.outputs["formats"].sort
|
||
label = current == %w[3mf stl] ? "both" : current.first
|
||
selected = ui.choose("Export format (current: #{label})") do |menu|
|
||
menu.choice("3MF") { ["3mf"] }
|
||
menu.choice("STL") { ["stl"] }
|
||
menu.choice("Both 3MF and STL") { %w[3mf stl] }
|
||
menu.default = (label == "both" ? 3 : (label == "stl" ? 2 : 1)).to_s
|
||
end
|
||
config.outputs["formats"] = selected
|
||
end
|
||
|
||
def choose_profile(config)
|
||
current = config.parameters["top_profile"]
|
||
config.parameters["top_profile"] = ui.choose("Top FeatherWing profile") do |menu|
|
||
menu.choice("Generic / other") { "generic" }
|
||
menu.choice("GPS") { "gps" }
|
||
menu.choice("128×64 OLED") { "oled128x64" }
|
||
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current).to_s
|
||
end
|
||
end
|
||
|
||
def choose_stack(config)
|
||
p = config.parameters
|
||
p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8)
|
||
measured = p["stack_height_override"].positive?
|
||
if ui.agree("Use a measured Feather-PCB-top to top-Wing-PCB stack height? (y/n) ") do |question|
|
||
question.default = measured ? "yes" : "no"
|
||
end
|
||
p["stack_height_override"] = number(
|
||
"PCB-top to PCB-top height in mm", measured ? p["stack_height_override"] :
|
||
p["wing_count"] * p["wing_pitch"], min: 0.1
|
||
)
|
||
else
|
||
p["stack_height_override"] = 0.0
|
||
end
|
||
end
|
||
|
||
def choose_features(config)
|
||
p = config.parameters
|
||
p["usb_opening_enabled"] = agreement(
|
||
"Include front USB-C programming opening?", p["usb_opening_enabled"]
|
||
)
|
||
p["usb_guide_enabled"] = if p["usb_opening_enabled"]
|
||
agreement("Include inward USB plug guide?",
|
||
p["usb_guide_enabled"])
|
||
else
|
||
false
|
||
end
|
||
p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?",
|
||
p["battery_enabled"])
|
||
p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?",
|
||
p["tripod_insert_enabled"])
|
||
return unless p["top_profile"] == "oled128x64"
|
||
|
||
ui.say("OLED captive buttons")
|
||
p["button_a"] = agreement("Enable button A?", p["button_a"])
|
||
p["button_b"] = agreement("Enable button B?", p["button_b"])
|
||
p["button_c"] = agreement("Enable button C?", p["button_c"])
|
||
p["button_reset"] = agreement("Enable reset button?", p["button_reset"])
|
||
end
|
||
|
||
def choose_end_clearance(config)
|
||
p = config.parameters
|
||
defaults = Config::DEFAULT_PARAMETERS
|
||
custom = p["usb_board_wall_gap"] != defaults["usb_board_wall_gap"] ||
|
||
p["board_rear_clearance"] != defaults["board_rear_clearance"]
|
||
enabled = ui.agree("Use extra clearance beyond the Feather PCB ends? (y/n) ") do |question|
|
||
question.default = custom ? "yes" : "no"
|
||
end
|
||
unless enabled
|
||
p["usb_board_wall_gap"] = defaults["usb_board_wall_gap"]
|
||
p["board_rear_clearance"] = defaults["board_rear_clearance"]
|
||
return
|
||
end
|
||
|
||
p["usb_board_wall_gap"] = number(
|
||
"USB/front PCB-edge to inner-wall clearance (mm)",
|
||
p["usb_board_wall_gap"], min: 0.1
|
||
)
|
||
p["board_rear_clearance"] = number(
|
||
"Rear PCB-edge to inner-wall clearance (mm)",
|
||
p["board_rear_clearance"], min: 0.1
|
||
)
|
||
end
|
||
|
||
def manage_cutouts(config)
|
||
loop do
|
||
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" :
|
||
config.cutouts.map { |cutout| cutout["name"] }.join(", ")}")
|
||
action = ui.choose("Cable cutouts") do |menu|
|
||
menu.choice("Done") { :done }
|
||
menu.choice("Add cutout") { :add }
|
||
menu.choice("Edit cutout") { :edit } unless config.cutouts.empty?
|
||
menu.choice("Remove cutout") { :remove } unless config.cutouts.empty?
|
||
end
|
||
break if action == :done
|
||
|
||
case action
|
||
when :add
|
||
config.cutouts << ask_cutout(config)
|
||
when :edit
|
||
index = choose_cutout(config, "Edit which cutout?")
|
||
old = config.cutouts.delete_at(index)
|
||
config.cutouts.insert(index, ask_cutout(config, old))
|
||
when :remove
|
||
config.cutouts.delete_at(choose_cutout(config, "Remove which cutout?"))
|
||
end
|
||
rescue ConfigError => e
|
||
ui.say(ui.color("Cutout not accepted: #{e.message}", :red))
|
||
end
|
||
end
|
||
|
||
def ask_cutout(config, existing = nil)
|
||
existing ||= {}
|
||
dims = config.dimensions
|
||
name = text("Cutout name", existing["name"] || "cable")
|
||
face = ui.choose("Wall") do |menu|
|
||
menu.choice("Front — USB-C short wall") { "front" }
|
||
menu.choice("Rear — opposite short wall") { "rear" }
|
||
menu.choice("Left — plain/non-JST long wall") { "left" }
|
||
menu.choice("Right — JST/battery long wall") { "right" }
|
||
menu.default = (Config::FACES.index(existing["face"]).to_i + 1).to_s if existing["face"]
|
||
end
|
||
shape = ui.choose("Shape") do |menu|
|
||
Config::SHAPES.each { |item| menu.choice(item) { item } }
|
||
menu.default = (Config::SHAPES.index(existing["shape"]).to_i + 1).to_s if existing["shape"]
|
||
end
|
||
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
|
||
ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \
|
||
"usable vertical centers lie between the floor and lid lip.")
|
||
width = number(shape == "circle" ? "Diameter (mm)" : "Width (mm)",
|
||
existing["width"] || 8.0, min: 0.1)
|
||
height = shape == "circle" ? width :
|
||
number("Height (mm)", existing["height"] || 6.0, min: 0.1)
|
||
horizontal = number("Center from the wall's left edge (mm)",
|
||
existing["horizontal"] || wall_width / 2, min: width / 2,
|
||
max: wall_width - width / 2)
|
||
min_z = dims["floor"] + height / 2
|
||
max_z = dims["lip_bottom"] - height / 2
|
||
z = number("Center height from exterior bottom (mm)",
|
||
existing["z"] || (min_z + max_z) / 2, min: min_z, max: max_z)
|
||
radius = if shape == "roundrect"
|
||
number("Corner radius (mm)", existing["radius"] ||
|
||
[width, height].min / 4, min: 0.01, max: [width, height].min / 2)
|
||
else
|
||
[width, height].min / 2
|
||
end
|
||
candidate = {
|
||
"name" => name, "face" => face, "shape" => shape,
|
||
"horizontal" => horizontal, "z" => z, "width" => width,
|
||
"height" => height, "radius" => radius
|
||
}
|
||
temporary = Config.new(Config.deep_copy(config.data).tap do |data|
|
||
data["cutouts"] = config.cutouts + [candidate]
|
||
end)
|
||
temporary.validate!
|
||
candidate
|
||
end
|
||
|
||
def choose_cutout(config, prompt)
|
||
ui.choose(prompt) do |menu|
|
||
config.cutouts.each_with_index do |cutout, index|
|
||
menu.choice(cutout["name"]) { index }
|
||
end
|
||
end
|
||
end
|
||
|
||
def choose_coupons(config)
|
||
selected = []
|
||
selected << "insert_coupon" if agreement(
|
||
"Export heat-set insert calibration coupon?",
|
||
config.outputs["coupons"].include?("insert_coupon")
|
||
)
|
||
selected << "switch_coupon" if agreement(
|
||
"Export switch-hole calibration coupon?",
|
||
config.outputs["coupons"].include?("switch_coupon")
|
||
)
|
||
selected << "fit_coupon" if agreement(
|
||
"Export lid-fit calibration coupon?",
|
||
config.outputs["coupons"].include?("fit_coupon")
|
||
)
|
||
config.outputs["coupons"] = selected
|
||
end
|
||
|
||
def advanced(config)
|
||
Config::ADVANCED_FIELDS.each do |section, fields|
|
||
next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" }
|
||
|
||
ui.say(section)
|
||
fields.each do |field|
|
||
config.parameters[field] = number(field.tr("_", " "), config.parameters[field],
|
||
min: field == "usb_vertical_offset" ? nil : 0.01)
|
||
end
|
||
next unless section == "OLED buttons"
|
||
|
||
config.parameters["button_cap_size"] = pair(
|
||
"button cap width,length", config.parameters["button_cap_size"]
|
||
)
|
||
config.parameters["button_flange_size"] = pair(
|
||
"button flange width,length", config.parameters["button_flange_size"]
|
||
)
|
||
end
|
||
end
|
||
|
||
def agreement(prompt, default)
|
||
ui.agree("#{prompt} (y/n) ") { |question| question.default = default ? "yes" : "no" }
|
||
end
|
||
|
||
def required_text(prompt)
|
||
loop do
|
||
value = ui.ask("#{prompt}: ").to_s.strip
|
||
return value unless value.empty?
|
||
ui.say(ui.color("A value is required.", :red))
|
||
end
|
||
end
|
||
|
||
def text(prompt, default)
|
||
ui.ask("#{prompt}: ") { |question| question.default = default }.to_s.strip
|
||
end
|
||
|
||
def ask_slug(default)
|
||
loop do
|
||
value = text("Directory slug", default)
|
||
return value if value.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
||
ui.say(ui.color("Use lowercase letters, numbers, and single hyphens.", :red))
|
||
end
|
||
end
|
||
|
||
def integer(prompt, default, range)
|
||
loop do
|
||
value = ui.ask("#{prompt}: ", Integer) { |question| question.default = default }
|
||
return value if range.cover?(value)
|
||
ui.say(ui.color("Enter a value from #{range.begin} through #{range.end}.", :red))
|
||
end
|
||
end
|
||
|
||
def number(prompt, default, min: nil, max: nil)
|
||
loop do
|
||
value = ui.ask("#{prompt}: ", Float) { |question| question.default = default }.to_f
|
||
return value if (min.nil? || value >= min) && (max.nil? || value <= max)
|
||
bounds = [min && "at least #{min}", max && "at most #{max}"].compact.join(" and ")
|
||
ui.say(ui.color("Enter a value #{bounds}.", :red))
|
||
end
|
||
end
|
||
|
||
def pair(prompt, default)
|
||
loop do
|
||
raw = text(prompt, default.join(","))
|
||
values = raw.split(",").map { |item| Float(item.strip) }
|
||
return values if values.length == 2 && values.all?(&:positive?)
|
||
ui.say(ui.color("Enter two positive comma-separated dimensions.", :red))
|
||
rescue ArgumentError
|
||
ui.say(ui.color("Enter two numeric comma-separated dimensions.", :red))
|
||
end
|
||
end
|
||
|
||
def slugify(name)
|
||
name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-?\z/, "").yield_self do |slug|
|
||
slug.empty? ? "feather-case" : slug
|
||
end
|
||
end
|
||
|
||
def yes_no(value) = value ? "yes" : "no"
|
||
end
|
||
end
|