252 lines
11 KiB
Ruby
252 lines
11 KiB
Ruby
# 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
|