# 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 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})") 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