209 lines
9.5 KiB
Ruby
209 lines
9.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "highline"
|
|
|
|
module MoonModel
|
|
class Wizard
|
|
def initialize(io: HighLine.new, data_set: nil, output_root: File.expand_path("../../build", __dir__))
|
|
@io = io
|
|
@data_set = data_set
|
|
@output_root = output_root
|
|
end
|
|
|
|
def run
|
|
values = select_model
|
|
printer(values)
|
|
purpose(values)
|
|
palette(values)
|
|
grid(values)
|
|
sizing(values)
|
|
engineering(values)
|
|
Config.new(values)
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :io
|
|
|
|
def data_set = @data_set ||= DataSet.new
|
|
|
|
def select_model
|
|
default_name = "moon"
|
|
loop do
|
|
name = ask("Model name", default_name).strip
|
|
begin
|
|
safe = Generator.safe_name(name)
|
|
rescue ArgumentError => e
|
|
io.say(e.message)
|
|
next
|
|
end
|
|
path = File.join(@output_root, safe)
|
|
unless File.exist?(path)
|
|
return { "printer" => Config::X2D.dup, "output" => { "name" => name } }
|
|
end
|
|
unless Generator.generated_directory?(path)
|
|
io.say("#{path} already exists but is not a valid MoonModel output directory.")
|
|
default_name = "#{name}_2"
|
|
next
|
|
end
|
|
unless yes_no("Revise the existing #{name} model?", true)
|
|
default_name = "#{name}_2"
|
|
next
|
|
end
|
|
begin
|
|
values = YAML.safe_load_file(File.join(path, "config.yml"), aliases: false)
|
|
Config.new(values)
|
|
values["output"] ||= {}
|
|
values["output"]["name"] = name
|
|
return values
|
|
rescue ArgumentError, Psych::Exception => e
|
|
io.say("Cannot revise #{name}: #{e.message}")
|
|
default_name = "#{name}_2"
|
|
end
|
|
end
|
|
end
|
|
|
|
def printer(values)
|
|
current = values.fetch("printer", Config::X2D)
|
|
custom = yes_no("Use a custom printer build volume?", current["tooling"] == "custom")
|
|
if custom
|
|
dimensions = current.fetch("build_volume_mm", [256.0, 256.0, 256.0])
|
|
values["printer"] = { "name" => ask("Printer name", current.fetch("name", "Custom")), "tooling" => "custom",
|
|
"build_volume_mm" => %w[X Y Z].each_with_index.map { |axis, i| ask_float("#{axis} build dimension (mm)", dimensions[i]) } }
|
|
else
|
|
both = yes_no("Use the X2D shared two-nozzle area?", current["tooling"] != "main_only")
|
|
values["printer"] = Config::X2D.dup
|
|
unless both
|
|
values["printer"]["tooling"] = "main_only"
|
|
values["printer"]["build_volume_mm"] = Config::X2D["main_only_volume_mm"]
|
|
end
|
|
end
|
|
values["nozzle_mm"] = ask_float("Nozzle diameter (mm)", values.fetch("nozzle_mm", 0.4))
|
|
values["edge_clearance_mm"] = ask_float("Build-edge clearance (mm)", values.fetch("edge_clearance_mm", 2.0))
|
|
end
|
|
|
|
def purpose(values)
|
|
current = values.fetch("purpose", "display")
|
|
previous_assembly = values.fetch("assembly", "auto")
|
|
values["purpose"] = io.choose do |m|
|
|
m.prompt = "Intended use?"
|
|
m.default = current == "box" ? "Magnetic decorative box" : "Glued display globe"
|
|
m.choice("Glued display globe") { "display" }
|
|
m.choice("Magnetic decorative box") { "box" }
|
|
end
|
|
values["assembly"] = values["purpose"] == "box" ? "eight_piece" : previous_assembly
|
|
previous_stand = values.fetch("stand", {})
|
|
enabled = yes_no("Generate a separate orbital triskelion stand?", previous_stand["enabled"] != false)
|
|
clearance = previous_stand.fetch("base_clearance_mm", 10.0)
|
|
if enabled
|
|
clearance = ask_float("Worst-case Moon clearance above the stand hub (mm)", clearance)
|
|
io.say("Warning: clearances below 10 mm waive the recommended terrain safety margin.") if clearance < 10.0
|
|
end
|
|
values["stand"] = {
|
|
"enabled" => enabled,
|
|
"style" => "orbital_triskelion",
|
|
"base_clearance_mm" => clearance,
|
|
"material" => previous_stand["material"]
|
|
}
|
|
end
|
|
|
|
def palette(values)
|
|
previous = values.fetch("palette", Config::DEFAULTS["palette"])
|
|
count = ask_float("Number of Moon colors", previous.length).round
|
|
count = ask_float("Number of Moon colors", 1).round until count.positive?
|
|
colors = count.times.map do |index|
|
|
old = previous[index] || { "name" => "Moon color #{index + 1}", "hex" => "#FFFFFF" }
|
|
{ "name" => ask("Color #{index + 1} name", old["name"]), "hex" => ask_hex(old["hex"]) }
|
|
end
|
|
values["palette"] = colors
|
|
end
|
|
|
|
def grid(values)
|
|
old = values.fetch("grid", {})
|
|
enabled = yes_no("Add latitude/longitude lines?", old.fetch("enabled", false))
|
|
values["grid"] = old.merge("enabled" => enabled)
|
|
return unless enabled
|
|
values["grid"]["interval_degrees"] = ask_float("Grid interval (degrees)", old.fetch("interval_degrees", 30.0))
|
|
if values["palette"].length > 1
|
|
names = values["palette"].map { |c| c["name"] }
|
|
default = names.include?(old["color_name"]) ? old["color_name"] : names.last
|
|
values["grid"]["color_name"] = io.choose { |m| m.prompt = "Grid color?"; m.default = default; names.each { |name| m.choice(name) { name } } }
|
|
end
|
|
end
|
|
|
|
def sizing(values)
|
|
provisional = Config.new(values)
|
|
io.say("One-piece maximum: #{units(provisional.maximum_diameter("one_piece"))}")
|
|
io.say("Eight-section maximum: #{units(provisional.maximum_diameter("eight_piece"))}")
|
|
old = values.fetch("size", Config::DEFAULTS["size"])
|
|
scale = yes_no("Specify size as a scale ratio?", old["input"] == "scale")
|
|
if scale
|
|
default = old["input"] == "scale" ? old["value"] : 10_000_000.0
|
|
values["size"] = { "input" => "scale", "value" => ask_float("Scale denominator (for 1:N)", default), "unit" => "ratio", "meaning" => "datum" }
|
|
else
|
|
unit = yes_no("Enter diameter in inches?", old["unit"] == "in") ? "in" : "mm"
|
|
default_value = old["input"] == "diameter" && old["unit"] == unit ? old["value"] : (unit == "in" ? 4.0 : 120.0)
|
|
value = ask_float("Requested diameter (#{unit})", default_value)
|
|
meaning = io.choose do |m|
|
|
m.prompt = "What does that diameter mean?"
|
|
m.default = old["meaning"] == "datum" ? "Lunar datum sphere" : "Final terrain envelope"
|
|
m.choice("Final terrain envelope") { "final_envelope" }
|
|
m.choice("Lunar datum sphere") { "datum" }
|
|
end
|
|
values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning }
|
|
end
|
|
shell = values.fetch("shell", {}).dup
|
|
shell["interior"] = yes_no("For a one-piece Moon, use slicer-controlled infill instead of a hollow cavity?",
|
|
shell.fetch("interior", "solid") == "solid") ? "solid" : "hollow"
|
|
values["shell"] = shell
|
|
io.say("Terrain sizing at this size:")
|
|
[1, 2, 5, 10].each do |factor|
|
|
trial = Config.new(values.merge("vertical_exaggeration" => factor))
|
|
begin
|
|
result = Sizing.calculate(trial, data_set, validate_safety: false)
|
|
io.say(" #{factor}x: relief #{format('%.3f', result.relief_range_mm)} mm; " \
|
|
"envelope #{units(result.final_envelope_mm)}; #{result.mode.tr('_', ' ')}")
|
|
rescue ArgumentError
|
|
io.say(" #{factor}x: does not fit")
|
|
end
|
|
end
|
|
validated = nil
|
|
loop do
|
|
values["vertical_exaggeration"] = ask_float("Vertical exaggeration", values.fetch("vertical_exaggeration", 1.0))
|
|
begin
|
|
validated = Sizing.calculate(Config.new(values), data_set)
|
|
break
|
|
rescue ArgumentError => e
|
|
io.say(e.message)
|
|
end
|
|
end
|
|
force = yes_no("Force eight-section output?", values["assembly"] == "eight_piece")
|
|
values["assembly"] = values["purpose"] == "box" || force ? "eight_piece" : "auto"
|
|
summary = values["assembly"] == "auto" || values["assembly"] == validated.mode ? validated : Sizing.calculate(Config.new(values), data_set)
|
|
io.say("Datum diameter: #{units(summary.datum_diameter_mm)}")
|
|
io.say("Finished envelope: #{units(summary.final_envelope_mm)}; relief #{format('%.3f', summary.relief_range_mm)} mm")
|
|
io.say("Maximum safe exaggeration: #{format('%.2f', summary.safe_exaggeration)}x; mode: #{summary.mode.tr('_', ' ')} (#{summary.mode_source})")
|
|
if summary.segmented?
|
|
values["shell"]["interior"] = "hollow"
|
|
io.say("Eight-section output uses hollow sections; the solid/infill option applies only to one-piece Moons.")
|
|
end
|
|
end
|
|
|
|
def engineering(values)
|
|
config = Config.new(values)
|
|
io.say("Derived wall #{config["shell"]["wall_mm"]} mm; inlay/feature #{config["shell"]["inlay_depth_mm"]} mm; joint clearance #{config["shell"]["joint_clearance_mm"]} mm.")
|
|
return unless values["purpose"] == "box"
|
|
old = values.fetch("magnets", Config::DEFAULTS["magnets"])
|
|
values["magnets"] = { "diameter_mm" => ask_float("Magnet diameter (mm)", old["diameter_mm"]),
|
|
"thickness_mm" => ask_float("Magnet thickness (mm)", old["thickness_mm"]),
|
|
"count" => ask_float("Magnet count", old["count"]).round }
|
|
end
|
|
|
|
def ask(prompt, default) = io.ask("#{prompt} [#{default}]: ") { |q| q.default = default }
|
|
def ask_float(prompt, default) = io.ask("#{prompt} [#{default}]: ", Float) { |q| q.default = default }
|
|
def ask_hex(default) = io.ask("Hex color [#{default}]: ") { |q| q.default = default; q.validate = /\A#[0-9a-fA-F]{6}\z/ }
|
|
def yes_no(prompt, default) = io.agree("#{prompt} (y/n) ") { |q| q.default = default ? "yes" : "no" }
|
|
def units(mm) = format("%.1f mm / %.2f in", mm, mm / 25.4)
|
|
end
|
|
end
|