Initial MoonModel implementation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MoonModel
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user