# frozen_string_literal: true module MoonModel class Config MOON_DATUM_DIAMETER_MM = 3_474_800_000.0 X2D = { "name" => "Bambu Lab X2D", "tooling" => "both_nozzles", "build_volume_mm" => [235.5, 256.0, 256.0], "main_only_volume_mm" => [256.0, 256.0, 260.0] }.freeze DEFAULTS = { "printer" => X2D, "nozzle_mm" => 0.4, "edge_clearance_mm" => 2.0, "purpose" => "display", "assembly" => "auto", "size" => { "input" => "diameter", "value" => 120.0, "unit" => "mm", "meaning" => "final_envelope" }, "vertical_exaggeration" => 1.0, "palette" => [{ "name" => "Moon gray", "hex" => "#A9A9A9" }], "grid" => { "enabled" => false, "interval_degrees" => 30.0 }, "shell" => {}, "magnets" => { "diameter_mm" => 6.0, "thickness_mm" => 3.0, "count" => 6 }, "stand" => { "enabled" => false, "style" => "orbital_triskelion", "base_clearance_mm" => 10.0, "material" => nil }, "resolution" => { "longitude_segments" => nil }, "output" => { "name" => "moon" } }.freeze attr_reader :data def initialize(values = {}) @data = deep_merge(Marshal.load(Marshal.dump(DEFAULTS)), stringify(values)) derive! validate! end def [](key) = data.fetch(key.to_s) def to_h = Marshal.load(Marshal.dump(data)) def save(path) File.write(path, YAML.dump(to_h)) end def diameter_mm size = self["size"] return MOON_DATUM_DIAMETER_MM / Float(size["value"]) if size["input"] == "scale" value = Float(size["value"]) size["unit"] == "in" ? value * 25.4 : value end def build_volume self["printer"]["build_volume_mm"].map(&:to_f) end def segmented? self["purpose"] == "box" || self["assembly"] == "eight_piece" || (self["assembly"] == "auto" && diameter_mm > maximum_diameter("one_piece")) end def maximum_diameter(mode) usable = build_volume.map { |v| v - 2.0 * self["edge_clearance_mm"] } mode == "one_piece" ? usable.min : 2.0 * usable.min end private def derive! nozzle = Float(data["nozzle_mm"]) shell = data["shell"] shell["wall_mm"] ||= 4.0 * nozzle shell["inlay_depth_mm"] ||= 2.0 * nozzle shell["minimum_feature_mm"] ||= 2.0 * nozzle shell["joint_clearance_mm"] ||= [0.2, 0.5 * nozzle].max data["grid"]["line_width_mm"] ||= 2.0 * nozzle data["grid"]["depth_mm"] ||= nozzle data["size"]["meaning"] = "datum" if data["size"]["input"] == "scale" end def validate! raise ArgumentError, "nozzle must be positive" unless self["nozzle_mm"].to_f.positive? raise ArgumentError, "build volume requires three positive dimensions" unless build_volume.length == 3 && build_volume.all?(&:positive?) raise ArgumentError, "purpose must be display or box" unless %w[display box].include?(self["purpose"]) raise ArgumentError, "assembly must be auto, one_piece, or eight_piece" unless %w[auto one_piece eight_piece].include?(self["assembly"]) raise ArgumentError, "unsupported stand style" unless self["stand"]["style"] == "orbital_triskelion" raise ArgumentError, "stand base clearance must be nonnegative" if Float(self["stand"]["base_clearance_mm"]).negative? raise ArgumentError, "palette must contain at least one color" if self["palette"].empty? self["palette"].each do |color| raise ArgumentError, "invalid color #{color.inspect}" unless color["name"].to_s.match?(/\S/) && color["hex"].to_s.match?(/\A#[0-9a-fA-F]{6}\z/) end raise ArgumentError, "diameter/scale must be positive" unless diameter_mm.positive? raise ArgumentError, "vertical exaggeration must be nonnegative" if self["vertical_exaggeration"].to_f.negative? end def deep_merge(left, right) left.merge(right) { |_key, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b } end def stringify(value) case value when Hash then value.to_h { |key, val| [key.to_s, stringify(val)] } when Array then value.map { |val| stringify(val) } else value end end end end