# frozen_string_literal: true module MoonModel class Generator attr_reader :config, :data_set, :output_dir def self.safe_name(name) value = name.to_s.strip raise ArgumentError, "model name cannot be empty" if value.empty? || %w[. ..].include?(value) safe = value.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "") raise ArgumentError, "model name must contain a letter or number" if safe.empty? safe end def self.generated_directory?(path) config_path = File.join(path, "config.yml") manifest_path = File.join(path, "manifest.md") return false unless File.file?(config_path) && File.file?(manifest_path) return false unless File.read(manifest_path, 256).include?("Generated by MoonModel") Config.new(YAML.safe_load_file(config_path, aliases: false)) true rescue ArgumentError, Psych::Exception, Errno::ENOENT false end def initialize(config, data_set: DataSet.new, output_dir: nil) @config = config @data_set = data_set name = self.class.safe_name(config["output"]["name"]) @output_dir = File.expand_path(output_dir || File.expand_path("../../build/#{name}", __dir__)) end def generate result = Geometry.build(config, data_set) validate_build_fit!(result) if File.exist?(output_dir) && !self.class.generated_directory?(output_dir) raise ArgumentError, "refusing to replace non-MoonModel directory: #{output_dir}" end parent = File.dirname(output_dir) FileUtils.mkdir_p(parent) temporary = Dir.mktmpdir(".#{File.basename(output_dir)}.tmp-", parent) @write_dir = temporary files = write_outputs(result) install_atomically(temporary) @write_dir = nil files = files.map { |path| File.join(output_dir, File.basename(path)) } { output_dir: output_dir, files: files, statistics: result.statistics } rescue StandardError @write_dir = nil FileUtils.remove_entry(temporary) if defined?(temporary) && temporary && File.exist?(temporary) raise end private def write_outputs(result) files = [] if result.segmented? result.parts.each do |part| path = File.join(write_dir, "#{part[:name]}.3mf") ThreeMF.write(path, print_oriented(part), metadata: metadata.merge("Part" => part[:name]), palette: config["palette"]) files << path end assembled = File.join(write_dir, "moon_assembled_preview.3mf") ThreeMF.write(assembled, result.assembled, metadata: metadata.merge("Purpose" => "assembled preview; not print layout"), palette: config["palette"]) files << assembled keys = File.join(write_dir, "assembly_keys.3mf") ThreeMF.write(keys, result.keys, metadata: metadata.merge("Part" => "assembly keys"), palette: config["palette"]) files << keys else moon = File.join(write_dir, "moon.3mf") ThreeMF.write(moon, result.parts.first[:meshes], metadata: metadata, palette: config["palette"]) files << moon end unless result.stand.empty? stand = File.join(write_dir, "orbital_triskelion_stand.3mf") ThreeMF.write(stand, result.stand, metadata: metadata.merge("Part" => "material-unassigned stand"), palette: config["palette"]) files << stand end config_path = File.join(write_dir, "config.yml") config.save(config_path) files << config_path manifest = write_manifest(result, files) assembly = write_assembly(result) files.concat([manifest, assembly]) files end def write_dir = @write_dir || output_dir def install_atomically(temporary) unless File.exist?(output_dir) rename_path(temporary, output_dir) return end backup = File.join(File.dirname(output_dir), ".#{File.basename(output_dir)}.backup-#{Process.pid}-#{rand(1_000_000)}") rename_path(output_dir, backup) begin rename_path(temporary, output_dir) FileUtils.remove_entry(backup) rescue StandardError rename_path(backup, output_dir) unless File.exist?(output_dir) raise end end def rename_path(from, to) = File.rename(from, to) def validate_build_fit!(result) candidates = result.segmented? ? result.parts.map { |part| print_oriented(part) } : [result.parts.first[:meshes]] candidates.concat(result.stand.map { |mesh| [mesh] }) candidates.each do |meshes| bounds = Geometry.combined_bounds(meshes) dimensions = bounds.map { |min, max| max - min } usable = config.build_volume.map { |v| v - 2.0 * config["edge_clearance_mm"] } next if dimensions.zip(usable).all? { |actual, limit| actual <= limit + 1e-6 } raise ArgumentError, "generated part #{dimensions.map { |v| format("%.2f", v) }.join("×")} mm exceeds usable build volume #{usable.join("×")} mm" end end def print_oriented(part) south = part[:name].start_with?("south") part[:meshes].map do |source| mesh = Marshal.load(Marshal.dump(source)) if south mesh.transform { |x, y, z| [x, y, -z] } mesh.triangles.map! { |a, b, c| [a, c, b] } end min_z = mesh.bounds[2][0] mesh.transform { |x, y, z| [x, y, z - min_z] } end end def metadata { "Title" => config["output"]["name"], "Generator" => "MoonModel #{VERSION}", "Data" => data_set.metadata.fetch("title", "LROC WAC GLD100/LOLA and normalized reflectance") } end def write_manifest(result, files) palette = config["palette"].map.with_index { |color, i| "| #{i + 1} | #{color["name"]} | `#{color["hex"].upcase}` | |" }.join("\n") bodies = manifest_bodies(result).map do |body| "| #{body[:role]} | #{body[:name]} | #{body[:slot] || "Unassigned"} |" end.join("\n") stand = result.statistics["stand"] stand_summary = if stand dimensions = stand["dimensions_mm"].map { |value| format("%.2f", value) }.join(" × ") <<~TEXT - Stand style: Orbital triskelion - Stand safety model: Terrain envelope v2 (rotation independent) - Stand dimensions: #{dimensions} mm - Conservative terrain radii: #{format("%.3f", stand["minimum_terrain_radius_mm"])}–#{format("%.3f", stand["maximum_terrain_radius_mm"])} mm - Stand contact radius/height: #{format("%.2f", stand["contact_radius_mm"])} / #{format("%.2f", stand["contact_height_mm"])} mm - Guaranteed Moon clearance above hub/base: #{format("%.2f", stand["guaranteed_hub_clearance_mm"])} mm - Guaranteed Moon bottom height: #{format("%.2f", stand["guaranteed_moon_bottom_height_mm"])} mm - Non-contact arm clearance: at least #{format("%.2f", stand["noncontact_arm_clearance_mm"])} mm - Stand footprint capped to build area: #{stand["bed_capped"] ? "yes" : "no"} TEXT else "" end body = <<~MD # #{config["output"]["name"]} print manifest Generated by MoonModel #{VERSION} at #{Time.now.iso8601}. - Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source}) - Purpose: #{config["purpose"]} - Interior: #{result.statistics["interior"] == "solid" ? "solid slicer volume (use configured infill)" : "hollow shell"} - Datum diameter: #{format("%.2f", result.statistics["diameter_mm"])} mm (#{format("%.3f", result.statistics["diameter_mm"] / 25.4)} in) - Final terrain envelope: #{format("%.2f", result.statistics["final_envelope_mm"])} mm (#{format("%.3f", result.statistics["final_envelope_mm"] / 25.4)} in) - Vertical exaggeration: #{config["vertical_exaggeration"]}× - Peak-to-trough relief: #{format("%.3f", result.statistics["relief_range_mm"])} mm - Maximum safe exaggeration: #{format("%.2f", result.statistics["safe_exaggeration"])}× - Mesh: #{result.statistics["longitude_segments"]} longitude segments #{stand_summary.rstrip} ## Slicer material assignments | Object/material | Name | Display color | Slicer/spool | |---:|---|---|---| #{palette} ## 3MF body assignments | Body | Material | Filament slot | |---|---|---:| #{bodies} The structural volume remains separate from the colored surface. Both the structure and the first-color surface use filament 1, so an N-color Moon can contain N+1 bodies. The 3MF contains standard material names/colors plus Bambu Studio part names and slot assignments; confirm physical AMS or external-spool mappings in the slicer. The stand intentionally has no material assignment. ## Files #{files.map { |path| "- `#{File.basename(path)}`" }.join("\n")} ## Data provenance Terrain derives from LROC WAC GLD100 with LOLA polar coverage. Surface tones derive from the LROC WAC normalized reflectance mosaic. See `data/SOURCES.md` in the project. MD path = File.join(write_dir, "manifest.md") File.write(path, body) path end def manifest_bodies(result) meshes = result.parts.flat_map { |part| part[:meshes] } + result.keys + result.stand rows = meshes.map do |mesh| if mesh.material slot = config["palette"].index do |color| color["name"] == mesh.material["name"] && color["hex"].casecmp?(mesh.material["hex"]) end role = if mesh.name.end_with?("_structure") "Structure" elsif mesh.name == "assembly_keys" "Assembly keys" else "Surface" end { role: role, name: "#{mesh.material["name"]} (#{mesh.material["hex"].upcase})", slot: slot && slot + 1 } else { role: mesh.name.tr("_", " ").split.map(&:capitalize).join(" "), name: "Material unassigned", slot: nil } end end rows.uniq { |row| [row[:role], row[:name], row[:slot]] } end def write_assembly(result) text = if result.segmented? config["purpose"] == "box" ? <<~MD : <<~MD # Magnetic-box assembly 1. Dry-fit the four keyed sections of each hemisphere. 2. Bond each hemisphere's quarter seams while its equator remains open. 3. Install #{config["magnets"]["count"]} round magnets (#{config["magnets"]["diameter_mm"]} × #{config["magnets"]["thickness_mm"]} mm), verifying alternating polarity before adhesive cures. 4. Bring the hemispheres together using the equatorial alignment seam. Do not glue the equator. MD # Glued display assembly 1. Dry-fit the four keyed sections of each hemisphere. 2. Insert the internal seam keys from the open equator and bond each hemisphere. 3. Preinstall the equatorial alignment keys in one hemisphere. 4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside. MD else if result.statistics["interior"] == "solid" <<~MD # Printing The one-piece Moon is a watertight solid slicer volume. Select the desired sparse infill pattern and percentage in the slicer; no internal cavity supports are required. Configure build-plate adhesion and external support for the lower surface as appropriate to the selected material and printer. MD else "# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material. Internal supports may be difficult to remove and can break loose inside the finished Moon.\n" end end path = File.join(write_dir, "ASSEMBLY.md") File.write(path, text) path end end end