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
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
require "json"
require "yaml"
require "fileutils"
require "tmpdir"
require "time"
require "moon_model/version"
require "moon_model/config"
require "moon_model/data_set"
require "moon_model/mesh"
require "moon_model/color"
require "moon_model/sizing"
require "moon_model/stand"
require "moon_model/geometry"
require "moon_model/three_mf"
require "moon_model/generator"
require "moon_model/wizard"
require "moon_model/cli"
module MoonModel
ROOT = File.expand_path("..", __dir__)
end
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
require "optparse"
module MoonModel
class CLI
def self.run(argv)
options = {}
parser = OptionParser.new do |opts|
opts.banner = "Usage: moon_model [--config FILE] [--output DIR]"
opts.on("-c", "--config FILE", "Generate non-interactively from YAML") { |v| options[:config] = v }
opts.on("-o", "--output DIR", "Output directory (default: build/<model name>)") { |v| options[:output] = v }
opts.on("--version", "Print version") { puts VERSION; return 0 }
opts.on("-h", "--help", "Show help") { puts opts; return 0 }
end
parser.parse!(argv)
config = options[:config] ? Config.new(YAML.safe_load_file(options[:config], aliases: false)) : Wizard.new.run
result = Generator.new(config, output_dir: options[:output]).generate
puts "Generated #{result[:files].length} files in #{result[:output_dir]}"
puts "Datum diameter: #{format("%.2f", result[:statistics]["diameter_mm"])} mm; final envelope: #{format("%.2f", result[:statistics]["final_envelope_mm"])} mm; relief: #{format("%.3f", result[:statistics]["relief_range_mm"])} mm"
0
rescue OptionParser::ParseError, ArgumentError, Errno::ENOENT, Psych::Exception => e
warn "moon_model: #{e.message}"
2
end
end
end
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module MoonModel
module Color
module_function
def normalize_hex(value) = value.upcase
def luminance(hex)
rgb = hex.delete_prefix("#").scan(/../).map { |part| part.to_i(16) / 255.0 }
linear = rgb.map { |v| v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4 }
0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
end
def ordered_palette(palette) = palette.sort_by { |item| luminance(item["hex"]) }
def choose(reflectance, palette, lat_index, lon_index)
return palette.first if palette.length == 1
value = [[reflectance, 0.0].max, 1.0].min * (palette.length - 1)
low = value.floor
high = [low + 1, palette.length - 1].min
threshold = value - low
noise = hash01(lat_index, lon_index)
palette[noise < threshold ? high : low]
end
# Stable spatial threshold; avoids traversal-order seams at longitude 0.
def hash01(a, b)
n = ((a * 73_856_093) ^ (b * 19_349_663)) & 0xffff_ffff
n ^= n >> 13
n = (n * 1_274_126_177) & 0xffff_ffff
n.to_f / 0xffff_ffff
end
end
end
+109
View File
@@ -0,0 +1,109 @@
# 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
+132
View File
@@ -0,0 +1,132 @@
# frozen_string_literal: true
require "json"
require "open3"
require "optparse"
require "tmpdir"
require "zlib"
require "digest"
require "fileutils"
require_relative "data_set"
module MoonModel
class DataPreparer
MAGIC = "MOONDATA1\n".b
DEFAULT_DTM = "/scratch0/lunaserv/layer-new/luna/wac_dtm_numeric/WAC_GLD100_V1.0_GLOBAL_with_LOLA_30M_POLE.0032km.cub"
DEFAULT_REFLECTANCE = "/scratch0/lunaserv/layer-new/luna/wac_normalized_reflectance/*.tif"
def self.run(argv)
options = { dtm: DEFAULT_DTM, reflectance: DEFAULT_REFLECTANCE,
output: File.expand_path("../../data/moon_surface.bin.gz", __dir__) }
OptionParser.new do |opts|
opts.banner = "Usage: prepare_lunar_data [options]"
opts.on("--dtm FILE") { |v| options[:dtm] = v }
opts.on("--reflectance GLOB") { |v| options[:reflectance] = v }
opts.on("-o", "--output FILE") { |v| options[:output] = v }
end.parse!(argv)
new(**options).run
0
rescue StandardError => e
warn "prepare_lunar_data: #{e.message}"
2
end
def initialize(dtm:, reflectance:, output:)
@dtm = dtm
@reflectance = reflectance
@output = output
end
def run
sources = Dir.glob(@reflectance).sort
raise "DTM not found: #{@dtm}" unless File.file?(@dtm)
raise "no reflectance files matched #{@reflectance}" if sources.empty?
info = JSON.parse(capture("gdalinfo", "-json", @dtm))
width, height = info.fetch("size")
transform = info.fetch("geoTransform")
xmin = transform[0]; ymax = transform[3]
xmax = xmin + transform[1] * width
ymin = ymax + transform[5] * height
Dir.mktmpdir("moon-data-") do |tmp|
elevation_path = File.join(tmp, "elevation.bin")
reflectance_path = File.join(tmp, "reflectance.bin")
vrt = File.join(tmp, "reflectance.vrt")
system!("gdal_translate", "-q", "-of", "ENVI", "-ot", "Float32", @dtm, elevation_path)
wrapped_sources = sources.map.with_index do |source, index|
source_info = JSON.parse(capture("gdalinfo", "-json", source))
source_transform = source_info.fetch("geoTransform")
source_width, source_height = source_info.fetch("size")
sx0 = source_transform[0]; sy1 = source_transform[3]
sx1 = sx0 + source_transform[1] * source_width
sy0 = sy1 + source_transform[5] * source_height
next source unless sx0.negative?
# The western COGs are encoded as -180..0 while GLD100 is 0..360.
shifted = File.join(tmp, "wrapped_#{index}.vrt")
circumference = 2.0 * (sx1 - sx0)
system!("gdal_translate", "-q", "-of", "VRT", "-a_ullr",
(sx0 + circumference).to_s, sy1.to_s, (sx1 + circumference).to_s, sy0.to_s,
source, shifted)
shifted
end
system!("gdalbuildvrt", "-q", vrt, *wrapped_sources)
system!("gdalwarp", "-q", "-overwrite", "-of", "ENVI", "-ot", "Byte", "-r", "bilinear",
"-te", xmin.to_s, ymin.to_s, xmax.to_s, ymax.to_s, "-ts", width.to_s, height.to_s,
vrt, reflectance_path)
elevations = File.binread(elevation_path).unpack("e*").map do |radius|
[[(radius - DataSet::DATUM_RADIUS_M).round, -32_768].max, 32_767].min
end
reflectance = File.binread(reflectance_path, width * height)
raise "unexpected reflectance byte count" unless reflectance.bytesize == width * height
histogram = Array.new(256, 0)
reflectance.each_byte { |value| histogram[value] += 1 }
percentiles = [0.01, 0.99].map do |p|
target = (p * (reflectance.bytesize - 1)).round
cumulative = 0
histogram.index { |count| cumulative += count; cumulative > target }
end
minmax = elevations.minmax
header = {
"format_version" => 1, "title" => "Reduced LROC WAC GLD100/LOLA terrain and normalized reflectance",
"width" => width, "height" => height, "longitude" => "0 degrees east, wrapping through 360",
"latitude" => "+90 degrees north to -90 degrees south", "datum_radius_m" => DataSet::DATUM_RADIUS_M,
"elevation_encoding" => "little-endian signed int16 metres relative to datum",
"reflectance_encoding" => "uint8 normalized reflectance", "elevation_offset_range_m" => minmax,
"reflectance_percentiles" => percentiles,
"sources" => [File.basename(@dtm)] + sources.map { |path| File.basename(path) },
"source_sha256" => ([@dtm] + sources).to_h { |path| [File.basename(path), Digest::SHA256.file(path).hexdigest] }
}
write_asset(header, elevations.pack("s<*"), reflectance)
end
puts "Wrote #{@output}"
end
private
def write_asset(header, elevations, reflectance)
encoded = JSON.generate(header)
FileUtils.mkdir_p(File.dirname(@output))
Zlib::GzipWriter.open(@output) do |gzip|
gzip.mtime = 0
gzip.write(MAGIC)
gzip.write([encoded.bytesize].pack("L<"))
gzip.write(encoded)
gzip.write(elevations)
gzip.write(reflectance)
end
end
def capture(*command)
output, status = Open3.capture2e(*command)
raise "command failed: #{command.join(" ")}\n#{output}" unless status.success?
output
end
def system!(*command)
success = system(*command)
raise "command failed: #{command.join(" ")}" unless success
end
end
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
require "zlib"
module MoonModel
class DataSet
MAGIC = "MOONDATA1\n".b
DATUM_RADIUS_M = 1_737_400.0
attr_reader :width, :height, :metadata
def self.default_path
File.expand_path("../../data/moon_surface.bin.gz", __dir__)
end
def initialize(path = self.class.default_path)
raise ArgumentError, "lunar data asset not found: #{path}" unless File.file?(path)
raw = Zlib::GzipReader.open(path, &:read)
raise ArgumentError, "unrecognized lunar data asset" unless raw.start_with?(MAGIC)
offset = MAGIC.bytesize
header_length = raw.byteslice(offset, 4).unpack1("L<")
offset += 4
@metadata = JSON.parse(raw.byteslice(offset, header_length))
offset += header_length
@width = metadata.fetch("width")
@height = metadata.fetch("height")
elevation_bytes = width * height * 2
@elevation = raw.byteslice(offset, elevation_bytes).unpack("s<*")
@reflectance = raw.byteslice(offset + elevation_bytes, width * height).bytes
end
def elevation_m(latitude, longitude)
DATUM_RADIUS_M + sample(@elevation, latitude, longitude)
end
def reflectance(latitude, longitude)
value = sample(@reflectance, latitude, longitude)
low, high = metadata.fetch("reflectance_percentiles", [0.0, 255.0])
[[(value - low) / (high - low), 0.0].max, 1.0].min
end
def relief_range_m
minmax = metadata["elevation_offset_range_m"]
minmax ? minmax[1] - minmax[0] : @elevation.minmax.then { |a, b| b - a }
end
private
def sample(values, latitude, longitude)
x = (longitude % 360.0) / 360.0 * width
y = (90.0 - [[latitude, 90.0].min, -90.0].max) / 180.0 * (height - 1)
x0 = x.floor % width
x1 = (x0 + 1) % width
y0 = y.floor
y1 = [y0 + 1, height - 1].min
tx = x - x.floor
ty = y - y.floor
a = values[y0 * width + x0] * (1.0 - tx) + values[y0 * width + x1] * tx
b = values[y1 * width + x0] * (1.0 - tx) + values[y1 * width + x1] * tx
a * (1.0 - ty) + b * ty
end
end
end
+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
+249
View File
@@ -0,0 +1,249 @@
# frozen_string_literal: true
module MoonModel
module Geometry
Result = Struct.new(:parts, :assembled, :keys, :stand, :statistics, :mode, :mode_source, keyword_init: true) do
def segmented? = mode == "eight_piece"
end
module_function
def build(config, data_set)
sizing = Sizing.calculate(config, data_set)
diameter = sizing.datum_diameter_mm
lon_segments = sizing.longitude_segments
lat_segments = sizing.latitude_segments
palette = Color.ordered_palette(config["palette"])
ranges = sizing.segmented? ? section_ranges : [[-90.0, 90.0, 0.0, 360.0, "moon"]]
parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette)
radius = diameter / 2.0
all_meshes = parts.flat_map { |part| part[:meshes] }
keys = sizing.segmented? ? build_keys(config, radius) : []
final_envelope = combined_bounds(all_meshes).map { |min, max| max - min }.max
stand_profile = {
"envelope_mm" => final_envelope,
"minimum_radius_mm" => sizing.minimum_radius_mm,
"maximum_radius_mm" => sizing.maximum_radius_mm
}
stand_result = config["stand"]["enabled"] ? Stand.build(config, stand_profile) : nil
stand = stand_result ? [stand_result.mesh] : []
Result.new(
parts: parts,
assembled: all_meshes,
keys: keys,
stand: stand,
mode: sizing.mode, mode_source: sizing.mode_source,
statistics: { "diameter_mm" => diameter, "final_envelope_mm" => final_envelope, "dimensions_mm" => sizing.dimensions_mm,
"safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source,
"longitude_segments" => lon_segments,
"latitude_segments" => lat_segments, "part_count" => parts.length,
"minimum_radius_mm" => sizing.minimum_radius_mm,
"maximum_radius_mm" => sizing.maximum_radius_mm,
"relief_range_mm" => relief_range_mm(config, data_set, diameter),
"stand" => stand_result&.statistics }
)
end
def relief_range_mm(config, data_set, datum_diameter_mm = config.diameter_mm)
data_set.relief_range_m * datum_diameter_mm / (2.0 * DataSet::DATUM_RADIUS_M) * config["vertical_exaggeration"]
end
def scaled_datum_diameter(config, data_set)
requested = config.diameter_mm
return requested if config["size"]["meaning"] == "datum"
requested
end
def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette)
radius = diameter / 2.0
ranges.map do |lat0, lat1, lon0, lon1, name|
build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette)
end
end
def section_ranges
%w[north south].flat_map do |hemisphere|
lat = hemisphere == "north" ? [0.0, 90.0] : [-90.0, 0.0]
4.times.map { |q| [lat[0], lat[1], q * 90.0, (q + 1) * 90.0, "#{hemisphere}_#{q + 1}"] }
end
end
def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette)
nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max
nlat = [(global_lat_segments * (lat1 - lat0) / 180.0).round, 2].max
base = Mesh.new(name: "#{name}_structure", material: config["palette"].first)
color_meshes = palette.to_h { |color| [color["name"], Mesh.new(name: "#{name}_#{slug(color["name"])}", material: color)] }
multicolor = palette.length > 1
unified_base = multicolor || !config["grid"]["enabled"]
substrate_vertices = {}
inner_vertices = {}
boundary_edges = {}
cell_index = 0
nlat.times do |iy|
a0 = lat0 + (lat1 - lat0) * iy / nlat.to_f
a1 = lat0 + (lat1 - lat0) * (iy + 1) / nlat.to_f
nlon.times do |ix|
o0 = lon0 + (lon1 - lon0) * ix / nlon.to_f
o1 = lon0 + (lon1 - lon0) * (ix + 1) / nlon.to_f
triangles_for_cell(a0, a1, o0, o1).each do |coords|
center_lat = coords.sum { |p| p[0] } / 3.0
center_lon = coords.sum { |p| p[1] } / 3.0
grid_cell = config["grid"]["enabled"] && on_grid?(config, center_lat, center_lon, radius,
(a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0)
outer = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, 0.0) }
deboss = !multicolor && grid_cell
surface_offset = if multicolor
-config["shell"]["inlay_depth_mm"]
elsif deboss
-config["grid"]["depth_mm"]
else
0.0
end
substrate = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
inner = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) }
if unified_base
outer_ids = coords.each_with_index.map do |coord, index|
substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index])
end
inner_ids = coords.each_with_index.map do |coord, index|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
end
base.triangle(*outer_ids)
base.triangle(inner_ids[0], inner_ids[2], inner_ids[1])
3.times do |edge|
next_edge = (edge + 1) % 3
key = [coordinate_key(coords[edge]), coordinate_key(coords[next_edge])].sort
record = boundary_edges[key]
if record
record[:count] += 1
else
boundary_edges[key] = { count: 1, outer: [outer_ids[edge], outer_ids[next_edge]],
inner: [inner_ids[edge], inner_ids[next_edge]] }
end
end
else
base.add_tri_prism(substrate, inner)
end
if multicolor
color = color_for(config, data_set, palette, center_lat, center_lon, iy, ix, grid_cell)
color_meshes.fetch(color["name"]).add_tri_prism(outer, substrate)
end
cell_index += 1
end
end
end
if unified_base
boundary_edges.each_value do |edge|
next unless edge[:count] == 1
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
end
end
meshes = [base] + (multicolor ? color_meshes.values.reject { |mesh| mesh.triangles.empty? } : [])
add_equatorial_hardware(config, base, radius, lat0, lat1, lon0, lon1)
{ name: name, meshes: meshes, bounds: combined_bounds(meshes), cells: cell_index }
end
def triangles_for_cell(lat0, lat1, lon0, lon1)
if lat0 <= -90.0
[[[lat0, lon0], [lat1, lon1], [lat1, lon0]]]
elsif lat1 >= 90.0
[[[lat0, lon0], [lat0, lon1], [lat1, lon0]]]
else
[[[lat0, lon0], [lat0, lon1], [lat1, lon1]], [[lat0, lon0], [lat1, lon1], [lat1, lon0]]]
end
end
def point(config, data_set, base_radius, latitude, longitude, offset_mm)
elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M
scale = base_radius / DataSet::DATUM_RADIUS_M
r = base_radius + elevation * scale * config["vertical_exaggeration"] + offset_mm
lat = latitude * Math::PI / 180.0
lon = longitude * Math::PI / 180.0
[r * Math.cos(lat) * Math.cos(lon), r * Math.cos(lat) * Math.sin(lon), r * Math.sin(lat)]
end
def color_for(config, data_set, palette, latitude, longitude, row, column, grid_cell)
if grid_cell
grid = config["grid"]
return palette.find { |item| item["name"] == grid["color_name"] } || config["palette"].last
end
Color.choose(data_set.reflectance(latitude, longitude), palette, row, column)
end
def on_grid?(config, latitude, longitude, radius, latitude_cell_degrees = 0.0, longitude_cell_degrees = 0.0)
interval = config["grid"]["interval_degrees"].to_f
half_width_deg = config["grid"]["line_width_mm"].to_f / [radius, 0.1].max * 90.0 / Math::PI
lat_distance = (latitude / interval).round * interval - latitude
lon_distance = (longitude / interval).round * interval - longitude
cosine = Math.cos(latitude * Math::PI / 180.0).abs
lat_distance.abs <= half_width_deg + latitude_cell_degrees ||
(lon_distance.abs * cosine) <= half_width_deg + longitude_cell_degrees * cosine
end
def build_keys(config, radius)
material = config["palette"].first
clearance = config["shell"]["joint_clearance_mm"]
key = Mesh.new(name: "assembly_keys", material: material)
key_size = [[radius * 0.08, 8.0].max, 30.0].min
8.times do |i|
x = (i % 4) * (key_size + 3.0)
y = (i / 4) * (key_size * 0.45 + 3.0)
key.add_tapered_box([x, y, key_size * 0.15], key_size, key_size * 0.38 - clearance,
key_size * 0.32 - clearance, key_size * 0.3)
end
if config["purpose"] == "display"
diameter = [4.0, radius * 0.04].min
4.times do |i|
key.add_cylinder([i * (diameter + 3.0), key_size + 6.0, diameter], diameter / 2.0 - clearance,
diameter * 2.0, segments: 24)
end
end
[key]
end
def add_equatorial_hardware(config, mesh, radius, lat0, lat1, lon0, lon1)
return unless lat0.zero? || lat1.zero?
north = lat0.zero?
depth_sign = north ? 1.0 : -1.0
wall = config["shell"]["wall_mm"]
clearance = config["shell"]["joint_clearance_mm"]
angles = if config["purpose"] == "box"
count = config["magnets"]["count"].to_i
count.times.map { |i| i * 360.0 / count }
else
[(lon0 + lon1) / 2.0]
end
angles.select { |angle| angle >= lon0 && (angle < lon1 || (lon1 == 360.0 && angle <= lon1)) }.each do |angle|
inner = if config["purpose"] == "box"
config["magnets"]["diameter_mm"].to_f / 2.0 + clearance
else
[2.0, radius * 0.02].min + clearance
end
outer = inner + [wall, 1.2].max
depth = if config["purpose"] == "box"
config["magnets"]["thickness_mm"].to_f + wall
else
inner * 2.5
end
radial = radius - config["shell"]["wall_mm"] - outer * 0.75
radians = angle * Math::PI / 180.0
center = [radial * Math.cos(radians), radial * Math.sin(radians)]
mesh.add_annular_cylinder(center, inner, outer, 0.0, depth_sign * depth)
end
end
def combined_bounds(meshes)
bounds = meshes.map(&:bounds)
3.times.map { |axis| [bounds.map { |b| b[axis][0] }.min, bounds.map { |b| b[axis][1] }.max] }
end
def slug(value) = value.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
def coordinate_key(coord)
lat, lon = coord
normalized_lon = lat.abs >= 89.999_999 ? 0.0 : lon % 360.0
[lat.round(10), normalized_lon.round(10)]
end
end
end
+98
View File
@@ -0,0 +1,98 @@
# frozen_string_literal: true
module MoonModel
class Mesh
attr_reader :name, :vertices, :triangles, :material
def initialize(name:, material: nil)
@name = name
@material = material
@vertices = []
@triangles = []
end
def vertex(point)
@vertices << point.map(&:to_f)
@vertices.length - 1
end
def triangle(a, b, c)
@triangles << [a, b, c]
end
def quad(a, b, c, d, flip: false)
flip ? (triangle(a, c, b); triangle(a, d, c)) : (triangle(a, b, c); triangle(a, c, d))
end
def add_box(center, size)
cx, cy, cz = center
sx, sy, sz = size.map { |v| v / 2.0 }
ids = [[-sx,-sy,-sz],[sx,-sy,-sz],[sx,sy,-sz],[-sx,sy,-sz],[-sx,-sy,sz],[sx,-sy,sz],[sx,sy,sz],[-sx,sy,sz]].map do |x,y,z|
vertex([cx+x, cy+y, cz+z])
end
quad(ids[0], ids[3], ids[2], ids[1]); quad(ids[4], ids[5], ids[6], ids[7])
quad(ids[0], ids[1], ids[5], ids[4]); quad(ids[1], ids[2], ids[6], ids[5])
quad(ids[2], ids[3], ids[7], ids[6]); quad(ids[3], ids[0], ids[4], ids[7])
end
def add_tapered_box(center, length, width_a, width_b, height)
cx, cy, cz = center
x0 = cx - length / 2.0; x1 = cx + length / 2.0
z0 = cz - height / 2.0; z1 = cz + height / 2.0
ids = [[x0,cy-width_a/2,z0],[x0,cy+width_a/2,z0],[x1,cy+width_b/2,z0],[x1,cy-width_b/2,z0],
[x0,cy-width_a/2,z1],[x0,cy+width_a/2,z1],[x1,cy+width_b/2,z1],[x1,cy-width_b/2,z1]].map { |p| vertex(p) }
quad(ids[0],ids[3],ids[2],ids[1]); quad(ids[4],ids[5],ids[6],ids[7])
quad(ids[0],ids[1],ids[5],ids[4]); quad(ids[1],ids[2],ids[6],ids[5])
quad(ids[2],ids[3],ids[7],ids[6]); quad(ids[3],ids[0],ids[4],ids[7])
end
def add_cylinder(center, radius, height, segments: 24)
cx, cy, cz = center
bottom = segments.times.map { |i| a = i * 2*Math::PI/segments; vertex([cx+radius*Math.cos(a), cy+radius*Math.sin(a), cz-height/2]) }
top = segments.times.map { |i| a = i * 2*Math::PI/segments; vertex([cx+radius*Math.cos(a), cy+radius*Math.sin(a), cz+height/2]) }
cb = vertex([cx,cy,cz-height/2]); ct = vertex([cx,cy,cz+height/2])
segments.times do |i|
j = (i+1)%segments
triangle(cb,bottom[j],bottom[i]); triangle(ct,top[i],top[j]); quad(bottom[i],bottom[j],top[j],top[i])
end
end
def add_annular_cylinder(center, inner_radius, outer_radius, z0, z1, segments: 32)
cx, cy = center
oi=[]; oo=[]; ti=[]; to=[]
segments.times do |i|
a=i*2*Math::PI/segments
oi << vertex([cx+inner_radius*Math.cos(a),cy+inner_radius*Math.sin(a),z0])
oo << vertex([cx+outer_radius*Math.cos(a),cy+outer_radius*Math.sin(a),z0])
ti << vertex([cx+inner_radius*Math.cos(a),cy+inner_radius*Math.sin(a),z1])
to << vertex([cx+outer_radius*Math.cos(a),cy+outer_radius*Math.sin(a),z1])
end
segments.times do |i|
j=(i+1)%segments
quad(oo[i],oo[j],to[j],to[i]); quad(oi[i],ti[i],ti[j],oi[j])
quad(oi[i],oi[j],oo[j],oo[i]); quad(ti[i],to[i],to[j],ti[j])
end
end
def add_tri_prism(outer, inner)
o = outer.map { |p| vertex(p) }
i = inner.map { |p| vertex(p) }
triangle(o[0], o[1], o[2]); triangle(i[0], i[2], i[1])
3.times { |n| quad(o[n], o[(n + 1) % 3], i[(n + 1) % 3], i[n]) }
end
def transform
vertices.map! { |point| yield(point) }
self
end
def bounds
axes = vertices.transpose
axes.map { |axis| [axis.min || 0.0, axis.max || 0.0] }
end
def valid?
!vertices.empty? && triangles.all? { |tri| tri.uniq.length == 3 && tri.all? { |i| i.between?(0, vertices.length - 1) } }
end
end
end
+180
View File
@@ -0,0 +1,180 @@
# frozen_string_literal: true
module MoonModel
module Sizing
Result = Struct.new(:datum_diameter_mm, :final_envelope_mm, :dimensions_mm,
:minimum_radius_mm, :maximum_radius_mm,
:relief_range_mm, :safe_exaggeration, :mode,
:mode_source, :longitude_segments, :latitude_segments,
keyword_init: true) do
def segmented? = mode == "eight_piece"
def to_h
{
"diameter_mm" => datum_diameter_mm,
"final_envelope_mm" => final_envelope_mm,
"dimensions_mm" => dimensions_mm,
"minimum_radius_mm" => minimum_radius_mm,
"maximum_radius_mm" => maximum_radius_mm,
"relief_range_mm" => relief_range_mm,
"safe_exaggeration" => safe_exaggeration,
"mode" => mode,
"mode_source" => mode_source,
"longitude_segments" => longitude_segments,
"latitude_segments" => latitude_segments
}
end
end
module_function
def calculate(config, data_set, validate_safety: true)
exaggeration = Float(config["vertical_exaggeration"])
raise ArgumentError, "vertical exaggeration must be nonnegative" if exaggeration.negative?
lon_segments = segment_count(config)
lat_segments = lon_segments / 2
unit = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments)
factor = unit.map { |min, max| max - min }.max
datum = config["size"]["meaning"] == "final_envelope" ? config.diameter_mm / factor : config.diameter_mm
dimensions = unit.map { |min, max| (max - min) * datum }
minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration,
lon_segments, lat_segments)
safe = validate_safety ? maximum_safe_exaggeration(config, data_set) : nil
if safe && exaggeration > safe + 1e-9
raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe)
end
mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
Result.new(
datum_diameter_mm: datum,
final_envelope_mm: dimensions.max,
dimensions_mm: dimensions,
minimum_radius_mm: minimum_radius,
maximum_radius_mm: maximum_radius,
relief_range_mm: data_set.relief_range_m * datum / (2.0 * DataSet::DATUM_RADIUS_M) * exaggeration,
safe_exaggeration: safe,
mode: mode,
mode_source: source,
longitude_segments: lon_segments,
latitude_segments: lat_segments
)
end
def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments)
base_radius = datum / 2.0
radii = (0..lat_segments).flat_map do |iy|
latitude = -90.0 + 180.0 * iy / lat_segments
(0..lon_segments).map do |ix|
longitude = 360.0 * ix / lon_segments
elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M
base_radius + elevation * base_radius / DataSet::DATUM_RADIUS_M * exaggeration
end
end
sampled_min, sampled_max = radii.minmax
cell_diagonal = Math.hypot(180.0 / lat_segments, 360.0 / lon_segments) * Math::PI / 180.0
facet_allowance = sampled_max * (1.0 - Math.cos(cell_diagonal))
deboss_allowance = if config["grid"]["enabled"] && config["palette"].length == 1
config["grid"]["depth_mm"].to_f
else
0.0
end
[sampled_min - facet_allowance - deboss_allowance, sampled_max]
end
def segment_count(config)
requested = config["resolution"]["longitude_segments"]&.to_i
requested ||= [[(Math::PI * config.diameter_mm / config["shell"]["minimum_feature_mm"]).ceil, 96].max, 1024].min
requested + (8 - requested % 8) % 8
end
def sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range = [-90.0, 90.0, 0.0, 360.0])
lat0, lat1, lon0, lon1 = range
nlat = [(lat_segments * (lat1 - lat0) / 180.0).round, 2].max
nlon = [(lon_segments * (lon1 - lon0) / 360.0).round, 2].max
bounds = Array.new(3) { [Float::INFINITY, -Float::INFINITY] }
(0..nlat).each do |iy|
lat = lat0 + (lat1 - lat0) * iy / nlat.to_f
(0..nlon).each do |ix|
lon = lon0 + (lon1 - lon0) * ix / nlon.to_f
offset = data_set.elevation_m(lat, lon) - DataSet::DATUM_RADIUS_M
radius = 0.5 * (1.0 + offset / DataSet::DATUM_RADIUS_M * exaggeration)
lat_r = lat * Math::PI / 180.0
lon_r = lon * Math::PI / 180.0
point = [radius * Math.cos(lat_r) * Math.cos(lon_r),
radius * Math.cos(lat_r) * Math.sin(lon_r), radius * Math.sin(lat_r)]
3.times do |axis|
bounds[axis][0] = [bounds[axis][0], point[axis]].min
bounds[axis][1] = [bounds[axis][1], point[axis]].max
end
end
end
bounds
end
def maximum_safe_exaggeration(config, data_set)
minimum_offset = data_set.metadata.fetch("elevation_offset_range_m", [-data_set.relief_range_m, 0]).first.to_f
return Float::INFINITY unless minimum_offset.negative?
required = config["shell"]["wall_mm"] + config["shell"]["minimum_feature_mm"]
if config["size"]["meaning"] == "datum"
available = config.diameter_mm / 2.0
return 0.0 if available <= required
return (1.0 - required / available) * DataSet::DATUM_RADIUS_M / -minimum_offset
end
# Final-envelope sizing changes the datum radius as exaggeration changes.
low = 0.0
high = DataSet::DATUM_RADIUS_M / -minimum_offset
# Twenty-four bisections are well beyond the precision meaningful to a printer
# while keeping the interactive preflight responsive at dense mesh settings.
24.times do
mid = (low + high) / 2.0
factor = sampled_bounds(data_set, mid, segment_count(config), segment_count(config) / 2).map { |a, b| b - a }.max
radius = config.diameter_mm / (2.0 * factor)
inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid) - config["shell"]["wall_mm"]
inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid
end
low
end
def resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
requested = config["purpose"] == "box" ? "eight_piece" : config["assembly"]
if requested == "one_piece"
ensure_fit!(full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments), config, "one-piece Moon")
return ["one_piece", "forced"]
end
if requested == "eight_piece"
ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
return ["eight_piece", config["purpose"] == "box" ? "purpose" : "forced"]
end
dims = full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
return ["one_piece", "automatic"] if fits?(dims, config)
ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
["eight_piece", "automatic"]
end
def full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
sampled_bounds(data_set, exaggeration, lon_segments, lat_segments).map { |a, b| (b - a) * datum }
end
def ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
Geometry.section_ranges.each do |range|
dims = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range[0, 4]).map { |a, b| (b - a) * datum }
ensure_fit!(dims, config, "eight-piece section")
end
end
def fits?(dimensions, config)
usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] }
dimensions.zip(usable).all? { |actual, limit| actual <= limit + 1e-6 }
end
def ensure_fit!(dimensions, config, label)
return if fits?(dimensions, config)
usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] }
raise ArgumentError, "#{label} #{dimensions.map { |v| format('%.2f', v) }.join('×')} mm exceeds usable build volume #{usable.join('×')} mm"
end
end
end
+240
View File
@@ -0,0 +1,240 @@
# frozen_string_literal: true
module MoonModel
module Stand
Result = Struct.new(:mesh, :statistics, keyword_init: true)
module_function
STYLE = "orbital_triskelion"
ARM_COUNT = 3
PATH_STATIONS = 25
CROSS_SECTION_POINTS = 8
def build(config, moon_profile)
profile = normalize_profile(moon_profile)
diameter = profile.fetch("envelope_mm")
minimum_radius = profile.fetch("minimum_radius_mm")
maximum_radius = profile.fetch("maximum_radius_mm")
nozzle = Float(config["nozzle_mm"])
usable_xy = config.build_volume.first(2).map { |value| value - 2.0 * config["edge_clearance_mm"] }
arm_width = [[0.035 * diameter, 8.0 * nozzle].max, 10.0].min
arm_height = [0.75 * arm_width, 3.0 * nozzle].max
desired_footprint = [0.68 * diameter, 12.0 * arm_width].max
footprint = [desired_footprint, usable_xy.min].min
raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8.0 * arm_width
capped = footprint < desired_footprint - 1e-6
footprint_radius = footprint / 2.0
outer_radius = footprint_radius - 0.65 * arm_width
contact_radius = [0.30 * diameter, 0.82 * footprint_radius].min
hub_radius = [2.2 * arm_width, [0.10 * diameter, 0.12 * footprint].min].max
contact_radius = [contact_radius, hub_radius + 0.75 * arm_width].max
contact_radius = [contact_radius, outer_radius - arm_width].min
raise ArgumentError, "Moon is too small for the selected nozzle and stand geometry" unless contact_radius > hub_radius
raise ArgumentError, "terrain profile has invalid radial bounds" unless minimum_radius.positive? && maximum_radius >= minimum_radius
raise ArgumentError, "stand contact radius exceeds the conservative Moon radius" unless contact_radius < minimum_radius
base_clearance = Float(config["stand"]["base_clearance_mm"])
approach_clearance = [1.0, 2.0 * nozzle].max
moon_center_z = maximum_radius + arm_height + base_clearance
contact_z = moon_center_z - Math.sqrt(minimum_radius**2 - contact_radius**2)
pad_thickness = [4.0 * nozzle, 0.45 * arm_width].max
pad_length = [[2.8 * arm_width, 0.12 * diameter].min, 24.0].min
pad_width = [2.0 * arm_width, 2.0 * (0.94 * minimum_radius - contact_radius)].min
pad_width = [pad_width, 1.5 * arm_width].max
rail_end_top = contact_z - 0.55 * pad_thickness
end_bottom = [rail_end_top - arm_height, 0.0].max
mesh = Mesh.new(name: "orbital_triskelion_stand", material: nil)
mesh.add_cylinder([0, 0, arm_height / 2.0], hub_radius, arm_height, segments: 48)
maximum_slope = 0.0
ARM_COUNT.times do |arm_index|
rotation = arm_index * 2.0 * Math::PI / ARM_COUNT
path = arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation)
maximum_slope = [maximum_slope, path_slope(path)].max
saddle_angle = rotation + radians(55.0)
first_vertex = mesh.vertices.length
add_swept_rail(mesh, path, arm_width, arm_height)
rail_vertices = mesh.vertices[first_vertex..]
validate_noncontact_clearance!(rail_vertices, maximum_radius, moon_center_z, contact_radius,
saddle_angle, pad_length, pad_width, approach_clearance)
add_saddle(mesh, minimum_radius, moon_center_z, contact_radius, saddle_angle,
pad_length, pad_width, pad_thickness)
end
dimensions = mesh.bounds.map { |minimum, maximum| maximum - minimum }
Result.new(
mesh: mesh,
statistics: {
"style" => STYLE,
"dimensions_mm" => dimensions,
"footprint_mm" => dimensions.first(2).max,
"height_mm" => dimensions[2],
"safety_model" => "terrain_envelope_v2",
"minimum_terrain_radius_mm" => minimum_radius,
"maximum_terrain_radius_mm" => maximum_radius,
"contact_radius_mm" => contact_radius,
"contact_height_mm" => contact_z,
"requested_base_clearance_mm" => base_clearance,
"guaranteed_hub_clearance_mm" => base_clearance,
"guaranteed_moon_bottom_height_mm" => arm_height + base_clearance,
"moon_bottom_clearance_mm" => arm_height + base_clearance,
"noncontact_arm_clearance_mm" => approach_clearance,
"hub_height_mm" => arm_height,
"moon_center_height_mm" => moon_center_z,
"arm_width_mm" => arm_width,
"bed_capped" => capped,
"maximum_underside_slope" => maximum_slope
}
)
end
def normalize_profile(profile)
if profile.is_a?(Numeric)
diameter = Float(profile)
return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2.0,
"maximum_radius_mm" => diameter / 2.0 }
end
values = profile.transform_keys(&:to_s)
%w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h do |key|
[key, Float(values.fetch(key))]
end
end
def arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation)
path = PATH_STATIONS.times.map do |index|
t = index / (PATH_STATIONS - 1.0)
if t <= 0.58
local = smoothstep(t / 0.58)
radius = lerp(hub_radius * 0.68, outer_radius, local)
else
local = smoothstep((t - 0.58) / 0.42)
radius = lerp(outer_radius, contact_radius, local)
end
angle = rotation + radians(-25.0 + 80.0 * t)
{ x: radius * Math.cos(angle), y: radius * Math.sin(angle), height: arm_height }
end
distance_to_end = 0.0
(path.length - 1).downto(0) do |index|
if index < path.length - 1
distance_to_end += Math.hypot(path[index + 1][:x] - path[index][:x],
path[index + 1][:y] - path[index][:y])
end
path[index][:bottom] = [end_bottom - 0.999 * distance_to_end, 0.0].max
end
if path.first[:bottom] > 1e-6
raise ArgumentError, "requested stand clearance cannot be reached with support-free arms inside this footprint"
end
path
end
def validate_noncontact_clearance!(vertices, maximum_radius, moon_center_z, contact_radius,
saddle_angle, pad_length, pad_width, required_clearance)
minimum_gap = Float::INFINITY
vertices.each do |x, y, z|
next if beneath_saddle?(x, y, contact_radius, saddle_angle, pad_length, pad_width)
radial = Math.hypot(x, y)
next if radial >= maximum_radius
moon_z = moon_center_z - Math.sqrt(maximum_radius**2 - radial**2)
minimum_gap = [minimum_gap, moon_z - z].min
end
return if minimum_gap >= required_clearance - 1e-6
raise ArgumentError, format("support-free arm would approach the worst-case terrain by %.2f mm; %.2f mm is required",
minimum_gap, required_clearance)
end
def beneath_saddle?(x, y, contact_radius, angle, length, width)
center_x = contact_radius * Math.cos(angle)
center_y = contact_radius * Math.sin(angle)
dx = x - center_x
dy = y - center_y
tangent_offset = dx * -Math.sin(angle) + dy * Math.cos(angle)
radial_offset = dx * Math.cos(angle) + dy * Math.sin(angle)
(tangent_offset / (0.52 * length))**2 + (radial_offset / (0.52 * width))**2 <= 1.0
end
def add_swept_rail(mesh, path, width, height)
rings = path.each_with_index.map do |point, index|
previous = path[[index - 1, 0].max]
following = path[[index + 1, path.length - 1].min]
dx = following[:x] - previous[:x]
dy = following[:y] - previous[:y]
length = Math.hypot(dx, dy)
px = -dy / length
py = dx / length
cross_section(width, height).map do |side, z|
mesh.vertex([point[:x] + px * side, point[:y] + py * side, point[:bottom] + z])
end
end
rings.each_cons(2) do |first, second|
CROSS_SECTION_POINTS.times do |index|
following = (index + 1) % CROSS_SECTION_POINTS
mesh.quad(first[index], first[following], second[following], second[index])
end
end
cap_ring(mesh, rings.first, reverse: true)
cap_ring(mesh, rings.last, reverse: false)
end
def cross_section(width, height)
[[-0.30 * width, 0.0], [0.30 * width, 0.0], [0.50 * width, 0.25 * height],
[0.50 * width, 0.75 * height], [0.30 * width, height], [-0.30 * width, height],
[-0.50 * width, 0.75 * height], [-0.50 * width, 0.25 * height]]
end
def cap_ring(mesh, ring, reverse:)
center = mesh.vertex(3.times.map { |axis| ring.sum { |id| mesh.vertices[id][axis] } / ring.length.to_f })
ring.length.times do |index|
following = (index + 1) % ring.length
reverse ? mesh.triangle(center, ring[following], ring[index]) : mesh.triangle(center, ring[index], ring[following])
end
end
def add_saddle(mesh, sphere_radius, moon_center_z, contact_radius, angle, length, width, thickness)
tangent = [-Math.sin(angle), Math.cos(angle)]
radial = [Math.cos(angle), Math.sin(angle)]
segments = 24
top_center = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, 0.0, 0.0, tangent, radial)
top = mesh.vertex(top_center)
bottom = mesh.vertex([top_center[0], top_center[1], top_center[2] - thickness])
top_ring = []
bottom_ring = []
segments.times do |index|
theta = index * 2.0 * Math::PI / segments
u = Math.cos(theta) * length / 2.0
v = Math.sin(theta) * width / 2.0
point = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, u, v, tangent, radial)
top_ring << mesh.vertex(point)
bottom_ring << mesh.vertex([point[0], point[1], point[2] - thickness])
end
segments.times do |index|
following = (index + 1) % segments
mesh.triangle(top, top_ring[index], top_ring[following])
mesh.triangle(bottom, bottom_ring[following], bottom_ring[index])
mesh.quad(top_ring[index], bottom_ring[index], bottom_ring[following], top_ring[following])
end
end
def saddle_point(sphere_radius, moon_center_z, contact_radius, angle, tangent_offset, radial_offset, tangent, radial)
x = contact_radius * Math.cos(angle) + tangent[0] * tangent_offset + radial[0] * radial_offset
y = contact_radius * Math.sin(angle) + tangent[1] * tangent_offset + radial[1] * radial_offset
z = moon_center_z - Math.sqrt([sphere_radius**2 - x**2 - y**2, 0.0].max)
[x, y, z]
end
def path_slope(path)
path.each_cons(2).map do |first, second|
horizontal = Math.hypot(second[:x] - first[:x], second[:y] - first[:y])
horizontal.zero? ? 0.0 : (second[:bottom] - first[:bottom]).abs / horizontal
end.max || 0.0
end
def smoothstep(value) = value * value * (3.0 - 2.0 * value)
def lerp(from, to, fraction) = from + (to - from) * fraction
def radians(degrees) = degrees * Math::PI / 180.0
end
end
+126
View File
@@ -0,0 +1,126 @@
# frozen_string_literal: true
require "cgi"
require "zip"
module MoonModel
class ThreeMF
CONTENT_TYPES = <<~XML.freeze
<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
XML
RELATIONSHIPS = <<~XML.freeze
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
XML
def self.write(path, meshes, metadata: {}, palette: [])
raise ArgumentError, "3MF needs at least one mesh" if meshes.empty?
meshes.each { |mesh| raise ArgumentError, "invalid mesh #{mesh.name}" unless mesh.valid? }
materials = ordered_materials(meshes, palette)
model = build_model(meshes, metadata, materials)
model_settings = build_bambu_model_settings(meshes, metadata, materials)
FileUtils.mkdir_p(File.dirname(path))
Zip::File.open(path, create: true) do |zip|
zip.get_output_stream("[Content_Types].xml") { |io| io.write(CONTENT_TYPES) }
zip.get_output_stream("_rels/.rels") { |io| io.write(RELATIONSHIPS) }
zip.get_output_stream("3D/3dmodel.model") { |io| io.write(model) }
zip.get_output_stream("Metadata/model_settings.config") { |io| io.write(model_settings) }
end
path
end
def self.build_model(meshes, metadata, materials = ordered_materials(meshes, []))
material_index = materials.each_with_index.to_h { |item, index| [[item["name"], item["hex"]], index] }
labels = mesh_labels(meshes, materials)
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
xml << %(<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">\n)
metadata.each { |key, value| xml << %(<metadata name="#{escape(key)}">#{escape(value)}</metadata>\n) }
xml << "<resources>\n"
unless materials.empty?
xml << %(<basematerials id="1">\n)
materials.each { |item| xml << %(<base name="#{escape(item["name"])}" displaycolor="#{escape(item["hex"].upcase)}"/>\n) }
xml << "</basematerials>\n"
end
meshes.each_with_index do |mesh, index|
id = index + 2
attrs = mesh.material ? %( pid="1" pindex="#{material_index.fetch([mesh.material["name"], mesh.material["hex"]])}") : ""
xml << %(<object id="#{id}" name="#{escape(labels[index])}" type="model"#{attrs}><mesh><vertices>\n)
mesh.vertices.each { |x, y, z| xml << %(<vertex x="#{fmt(x)}" y="#{fmt(y)}" z="#{fmt(z)}"/>\n) }
xml << "</vertices><triangles>\n"
mesh.triangles.each { |a, b, c| xml << %(<triangle v1="#{a}" v2="#{b}" v3="#{c}"/>\n) }
xml << "</triangles></mesh></object>\n"
end
assembly_id = meshes.length + 2
if meshes.length > 1
xml << %(<object id="#{assembly_id}" name="#{escape(wrapper_name(metadata))}" type="model"><components>\n)
meshes.each_index { |index| xml << %(<component objectid="#{index + 2}"/>\n) }
xml << "</components></object>\n"
end
xml << "</resources><build>\n"
xml << %(<item objectid="#{meshes.length > 1 ? assembly_id : 2}"/>\n)
xml << "</build></model>\n"
xml
end
def self.ordered_materials(meshes, palette)
used = meshes.filter_map(&:material).uniq { |item| [item["name"], item["hex"]] }
(palette + used).uniq { |item| [item["name"], item["hex"].upcase] }
end
def self.mesh_labels(meshes, materials)
meshes.map do |mesh|
unless mesh.material
next "#{humanize(mesh.name)} — material unassigned"
end
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
role = if mesh.name.end_with?("_structure")
"Structure"
elsif mesh.name == "assembly_keys"
"Assembly keys"
else
"Surface"
end
"#{role} — #{mesh.material["name"]} (#{mesh.material["hex"].upcase}) — Filament #{slot}"
end
end
def self.build_bambu_model_settings(meshes, metadata, materials)
labels = mesh_labels(meshes, materials)
object_id = meshes.length > 1 ? meshes.length + 2 : 2
xml = +%(<?xml version="1.0" encoding="UTF-8"?>\n<config>\n)
xml << %( <object id="#{object_id}">\n)
xml << %( <metadata key="name" value="#{escape(wrapper_name(metadata))}"/>\n)
meshes.each_with_index do |mesh, index|
xml << %( <part id="#{index + 2}" subtype="normal_part">\n)
xml << %( <metadata key="name" value="#{escape(labels[index])}"/>\n)
if mesh.material
slot = materials.index { |item| item["name"] == mesh.material["name"] && item["hex"].casecmp?(mesh.material["hex"]) } + 1
xml << %( <metadata key="extruder" value="#{slot}"/>\n)
end
xml << " </part>\n"
end
xml << " </object>\n <assemble/>\n</config>\n"
xml
end
def self.wrapper_name(metadata)
title = metadata.fetch("Title", "Moon")
part = metadata["Part"]
part && !part.empty? ? "#{title} — #{part}" : title
end
def self.humanize(value) = value.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
def self.escape(value) = CGI.escapeHTML(value.to_s)
def self.fmt(value) = format("%.6f", value).sub(/\.?0+\z/, "")
private_class_method :escape, :fmt, :ordered_materials, :mesh_labels, :build_bambu_model_settings,
:wrapper_name, :humanize
end
end
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module MoonModel
VERSION = "0.1.0"
end
+200
View File
@@ -0,0 +1,200 @@
# 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
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})")
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