Files
3d_globe/lib/moon_model/wizard.rb
T
2026-09-07 12:11:43 -07:00

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