Files
feather-case/lib/feather_case_wizard/wizard.rb
T

290 lines
11 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
module FeatherCaseWizard
class Wizard
attr_reader :ui
def initialize(ui: HighLine.new)
@ui = 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("\nEditing #{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("\n#{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_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)
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)
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 board-to-top-PCB stack height? (y/n) ") do |question|
question.default = measured ? "yes" : "no"
end
p["stack_height_override"] = number(
"Measured 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["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("\nOLED 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 manage_cutouts(config)
loop do
ui.say("\nCustom 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|
Config::FACES.each { |item| menu.choice(item) { item } }
menu.default = Config::FACES.index(existing["face"]).to_i + 1 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 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("\n#{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