Initial MoonModel implementation

This commit is contained in:
Nick Estes
2026-08-11 14:41:33 -07:00
commit 518fe64b13
36 changed files with 2587 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
# 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"]}
- 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 shell 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
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material.\n"
end
path = File.join(write_dir, "ASSEMBLY.md")
File.write(path, text)
path
end
end
end