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
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
require "test_helper"
class ColorTest < Minitest::Test
def test_orders_by_luminance_and_is_deterministic
palette = [{ "name" => "white", "hex" => "#FFFFFF" }, { "name" => "black", "hex" => "#000000" }]
ordered = MoonModel::Color.ordered_palette(palette)
assert_equal %w[black white], ordered.map { |c| c["name"] }
assert_equal MoonModel::Color.choose(0.4, ordered, 10, 11), MoonModel::Color.choose(0.4, ordered, 10, 11)
end
end
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
require "test_helper"
class ConfigTest < Minitest::Test
def test_x2d_maxima_and_derived_defaults
config = MoonModel::Config.new
assert_in_delta 231.5, config.maximum_diameter("one_piece"), 0.001
assert_in_delta 463.0, config.maximum_diameter("eight_piece"), 0.001
assert_in_delta 1.6, config["shell"]["wall_mm"], 0.001
assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001
assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001
end
def test_scale_ratio_uses_lunar_datum
config = MoonModel::Config.new("size" => { "input" => "scale", "value" => 10_000_000 })
assert_in_delta 347.48, config.diameter_mm, 0.001
assert_equal "datum", config["size"]["meaning"]
assert config.segmented?
end
def test_box_forces_segments
assert MoonModel::Config.new("purpose" => "box", "size" => { "value" => 80 }).segmented?
end
def test_rejects_bad_color
assert_raises(ArgumentError) { MoonModel::Config.new("palette" => [{ "name" => "bad", "hex" => "gray" }]) }
end
def test_rejects_negative_stand_clearance
assert_raises(ArgumentError) do
MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 })
end
end
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
require "test_helper"
class DataSetTest < Minitest::Test
def test_reads_and_wraps_data
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
assert_equal 16, data.width
assert_in_delta data.reflectance(0, 0), data.reflectance(0, 360), 1e-9
assert_in_delta 300, data.relief_range_m, 1e-9
end
end
end
+127
View File
@@ -0,0 +1,127 @@
# frozen_string_literal: true
require "test_helper"
class GeneratorTest < Minitest::Test
def test_one_piece_multicolor_package
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
config = MoonModel::Config.new(
"size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 },
"palette" => [{ "name" => "dark", "hex" => "#222222" }, { "name" => "light", "hex" => "#DDDDDD" }],
"grid" => { "enabled" => true, "color_name" => "light" }, "output" => { "name" => "test" }
)
result = MoonModel::Generator.new(config, data_set: data, output_dir: File.join(dir, "out")).generate
path = File.join(result[:output_dir], "moon.3mf")
assert File.file?(path)
Zip::File.open(path) do |zip|
model = zip.read("3D/3dmodel.model")
assert_includes model, "<basematerials"
assert_includes model, "displaycolor=\"#222222\""
assert_includes model, "<components>"
end
assert File.file?(File.join(result[:output_dir], "manifest.md"))
assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01
end
end
def test_segmented_box_outputs_parts_keys_and_stand
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
config = MoonModel::Config.new("purpose" => "box", "size" => { "value" => 50 },
"stand" => { "enabled" => true },
"resolution" => { "longitude_segments" => 24 })
result = MoonModel::Generator.new(config, data_set: data, output_dir: File.join(dir, "out")).generate
assert_equal 8, Dir[File.join(result[:output_dir], "north_*.3mf")].length + Dir[File.join(result[:output_dir], "south_*.3mf")].length
assert File.file?(File.join(result[:output_dir], "assembly_keys.3mf"))
assert File.file?(File.join(result[:output_dir], "orbital_triskelion_stand.3mf"))
manifest = File.read(File.join(result[:output_dir], "manifest.md"))
assert_includes manifest, "Stand style: Orbital triskelion"
assert_includes manifest, "Terrain envelope v2 (rotation independent)"
assert_includes manifest, "Guaranteed Moon clearance above hub/base: 10.00 mm"
assert_includes manifest, "Stand dimensions:"
end
end
def test_invalid_preflight_does_not_create_output_directory
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
config = MoonModel::Config.new("size" => { "value" => 40, "meaning" => "datum" },
"vertical_exaggeration" => 100_000,
"resolution" => { "longitude_segments" => 24 })
output = File.join(dir, "out")
assert_raises(ArgumentError) { MoonModel::Generator.new(config, data_set: data, output_dir: output).generate }
refute Dir.exist?(output)
end
end
def test_revision_atomically_replaces_obsolete_files
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
output = File.join(dir, "named_moon")
common = { "size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 },
"output" => { "name" => "Named Moon" } }
MoonModel::Generator.new(MoonModel::Config.new(common), data_set: data, output_dir: output).generate
assert File.file?(File.join(output, "moon.3mf"))
revised = MoonModel::Config.new(common.merge("assembly" => "eight_piece"))
MoonModel::Generator.new(revised, data_set: data, output_dir: output).generate
refute File.exist?(File.join(output, "moon.3mf"))
assert File.file?(File.join(output, "north_1.3mf"))
assert MoonModel::Generator.generated_directory?(output)
end
end
def test_refuses_to_replace_unrelated_directory
Dir.mktmpdir do |dir|
output = File.join(dir, "not_a_model")
Dir.mkdir(output)
File.write(File.join(output, "keep.txt"), "important")
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
config = MoonModel::Config.new("size" => { "value" => 40 },
"resolution" => { "longitude_segments" => 24 })
assert_raises(ArgumentError) { MoonModel::Generator.new(config, data_set: data, output_dir: output).generate }
assert_equal "important", File.read(File.join(output, "keep.txt"))
end
end
def test_failed_atomic_install_restores_previous_model
Dir.mktmpdir do |dir|
data = MoonModel::DataSet.new(make_data(File.join(dir, "data.gz")))
output = File.join(dir, "recoverable")
common = { "size" => { "value" => 40 }, "resolution" => { "longitude_segments" => 24 },
"output" => { "name" => "Recoverable" } }
MoonModel::Generator.new(MoonModel::Config.new(common), data_set: data, output_dir: output).generate
original = File.binread(File.join(output, "moon.3mf"))
failing_class = Class.new(MoonModel::Generator) do
private
def rename_path(from, to)
@rename_count = @rename_count.to_i + 1
raise IOError, "simulated install failure" if @rename_count == 2
super
end
end
error = assert_raises(IOError) do
failing_class.new(MoonModel::Config.new(common.merge("assembly" => "eight_piece")),
data_set: data, output_dir: output).generate
end
assert_equal "simulated install failure", error.message
assert_equal original, File.binread(File.join(output, "moon.3mf"))
assert_empty Dir[File.join(dir, ".recoverable.*")]
end
end
def test_safe_model_name_and_default_build_directory
config = MoonModel::Config.new("output" => { "name" => "My 8-inch Moon!" })
generator = MoonModel::Generator.new(config)
assert_equal "My_8-inch_Moon", MoonModel::Generator.safe_name("My 8-inch Moon!")
assert_equal File.expand_path("../build/My_8-inch_Moon", __dir__), generator.output_dir
assert_raises(ArgumentError) { MoonModel::Generator.safe_name("...") }
end
end
+66
View File
@@ -0,0 +1,66 @@
# frozen_string_literal: true
require "test_helper"
class SizingTest < Minitest::Test
def setup
@data = MoonModel::DataSet.new
end
def datum_config(exaggeration, assembly: "auto")
MoonModel::Config.new(
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" },
"vertical_exaggeration" => exaggeration,
"assembly" => assembly,
"resolution" => { "longitude_segments" => 48 }
)
end
def test_eight_inch_relief_uses_consistent_units
one = MoonModel::Sizing.calculate(datum_config(1), @data)
ten = MoonModel::Sizing.calculate(datum_config(10), @data)
assert_in_delta 1.153, one.relief_range_mm, 0.001
assert_in_delta 11.532, ten.relief_range_mm, 0.001
assert_operator ten.final_envelope_mm, :>, one.final_envelope_mm
assert_operator ten.maximum_radius_mm, :>, ten.minimum_radius_mm
assert_operator ten.maximum_radius_mm, :>, ten.datum_diameter_mm / 2.0
end
def test_single_color_grid_is_included_in_conservative_minimum_radius
plain = datum_config(5)
grid = datum_config(5)
grid = MoonModel::Config.new(grid.to_h.merge("grid" => { "enabled" => true, "depth_mm" => 0.7 }))
plain_result = MoonModel::Sizing.calculate(plain, @data)
grid_result = MoonModel::Sizing.calculate(grid, @data)
assert_in_delta 0.7, plain_result.minimum_radius_mm - grid_result.minimum_radius_mm, 0.001
end
def test_unsafe_exaggeration_is_rejected_with_limit
error = assert_raises(ArgumentError) { MoonModel::Sizing.calculate(datum_config(1000), @data) }
assert_match(/maximum is 18[89]x/, error.message)
end
def test_exaggerated_datum_can_automatically_select_sections
result = MoonModel::Sizing.calculate(datum_config(100), @data)
assert result.segmented?
assert_equal "automatic", result.mode_source
end
def test_final_envelope_remains_requested_size
config = MoonModel::Config.new(
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "final_envelope" },
"vertical_exaggeration" => 10,
"resolution" => { "longitude_segments" => 48 }
)
result = MoonModel::Sizing.calculate(config, @data)
assert_in_delta 203.2, result.final_envelope_mm, 0.001
refute result.segmented?
end
end
+87
View File
@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "test_helper"
class StandTest < Minitest::Test
def setup
@config = MoonModel::Config.new("stand" => { "enabled" => true })
end
def test_orbital_stand_is_printable_and_scales_for_common_moons
[30.0, 120.0, 203.2].each do |diameter|
result = MoonModel::Stand.build(@config, diameter)
stats = result.statistics
assert result.mesh.valid?
assert_manifold result.mesh
assert_equal "orbital_triskelion_stand", result.mesh.name
assert_equal "orbital_triskelion", stats["style"]
assert_in_delta 0.0, result.mesh.bounds[2][0], 1e-9
assert_operator stats["maximum_underside_slope"], :<=, 1.0
assert_operator stats["contact_radius_mm"], :<, diameter / 2.0
assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"]
assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9
assert_in_delta stats["hub_height_mm"] + 10.0, stats["guaranteed_moon_bottom_height_mm"], 1e-9
refute stats["bed_capped"]
end
end
def test_eight_inch_exaggerated_moon_clears_hub_in_any_rotation
config = MoonModel::Config.new(
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" },
"vertical_exaggeration" => 5.0,
"stand" => { "enabled" => true },
"resolution" => { "longitude_segments" => 96 }
)
sizing = MoonModel::Sizing.calculate(config, MoonModel::DataSet.new)
result = MoonModel::Stand.build(config, "envelope_mm" => sizing.final_envelope_mm,
"minimum_radius_mm" => sizing.minimum_radius_mm,
"maximum_radius_mm" => sizing.maximum_radius_mm)
stats = result.statistics
assert_equal "terrain_envelope_v2", stats["safety_model"]
assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9
assert_operator stats["guaranteed_moon_bottom_height_mm"], :>=, stats["hub_height_mm"] + 10.0
assert_operator stats["contact_height_mm"], :>, 30.0
assert_operator stats["maximum_underside_slope"], :<=, 1.0
end
def test_custom_clearance_changes_guaranteed_gap
config = MoonModel::Config.new("stand" => { "enabled" => true, "base_clearance_mm" => 4.0 })
result = MoonModel::Stand.build(config, 120.0)
assert_in_delta 4.0, result.statistics["guaranteed_hub_clearance_mm"], 1e-9
end
def test_large_moon_stand_is_capped_to_usable_bed
result = MoonModel::Stand.build(@config, 463.0)
usable = @config.build_volume.first(2).map { |value| value - 2.0 * @config["edge_clearance_mm"] }
assert result.statistics["bed_capped"]
assert_operator result.statistics["dimensions_mm"][0], :<=, usable[0]
assert_operator result.statistics["dimensions_mm"][1], :<=, usable[1]
assert_operator result.statistics["dimensions_mm"][2], :<=, @config.build_volume[2]
end
def test_legacy_stand_configuration_gets_new_style
config = MoonModel::Config.new("stand" => { "enabled" => true, "material" => nil })
assert_equal "orbital_triskelion", config["stand"]["style"]
assert_equal 10.0, config["stand"]["base_clearance_mm"]
end
private
def assert_manifold(mesh)
edge_uses = Hash.new(0)
mesh.triangles.each do |triangle|
triangle.each_index do |index|
edge = [triangle[index], triangle[(index + 1) % triangle.length]].sort
edge_uses[edge] += 1
end
end
assert edge_uses.values.all? { |count| count == 2 }, "every mesh edge must belong to exactly two faces"
end
end
+28
View File
@@ -0,0 +1,28 @@
# frozen_string_literal: true
require "minitest/autorun"
require "tmpdir"
require "zip"
require "moon_model"
module TestData
def make_data(path, width: 16, height: 8)
elevations = Array.new(width * height) { |i| ((i % width) - width / 2) * 20 }
reflectance = Array.new(width * height) { |i| (255.0 * (i % width) / (width - 1)).round }
header = { "format_version" => 1, "title" => "test data", "width" => width, "height" => height,
"elevation_offset_range_m" => elevations.minmax }
encoded = JSON.generate(header)
Zlib::GzipWriter.open(path) do |gzip|
gzip.write(MoonModel::DataSet::MAGIC)
gzip.write([encoded.bytesize].pack("L<"))
gzip.write(encoded)
gzip.write(elevations.pack("s<*"))
gzip.write(reflectance.pack("C*"))
end
path
end
end
class Minitest::Test
include TestData
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
require "test_helper"
class ThreeMFTest < Minitest::Test
def test_bambu_part_names_and_slots_follow_palette_order
palette = [
{ "name" => "Bright & Structural", "hex" => "#EEEEEE" },
{ "name" => "Dark", "hex" => "#222222" },
{ "name" => "Mid gray", "hex" => "#888888" },
{ "name" => "Blue gray", "hex" => "#667788" }
]
meshes = [mesh("moon_structure", palette[0], 0)] +
palette.each_with_index.map { |color, index| mesh("moon_surface_#{index + 1}", color, index + 2) }
Dir.mktmpdir do |dir|
path = File.join(dir, "moon.3mf")
MoonModel::ThreeMF.write(path, meshes, metadata: { "Title" => "Four Color Moon" }, palette: palette)
Zip::File.open(path) do |zip|
model = zip.read("3D/3dmodel.model").force_encoding(Encoding::UTF_8)
settings = zip.read("Metadata/model_settings.config").force_encoding(Encoding::UTF_8)
assert_operator model.index('name="Bright &amp; Structural"'), :<, model.index('name="Dark"')
assert_includes model, 'name="Four Color Moon" type="model"><components>'
assert_includes settings, 'value="Structure — Bright &amp; Structural (#EEEEEE) — Filament 1"'
assert_includes settings, 'value="Surface — Bright &amp; Structural (#EEEEEE) — Filament 1"'
assert_equal 2, settings.scan('key="extruder" value="1"').length
assert_equal 1, settings.scan('key="extruder" value="2"').length
assert_equal 1, settings.scan('key="extruder" value="3"').length
assert_equal 1, settings.scan('key="extruder" value="4"').length
end
end
end
def test_unassigned_mesh_has_name_but_no_extruder
mesh = mesh("three_leg_stand", nil, 0)
settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, [mesh], { "Title" => "Stand" }, [])
assert_includes settings, "Three Leg Stand — material unassigned"
refute_includes settings, 'key="extruder"'
end
def test_unused_colors_do_not_renumber_later_palette_slots
palette = [
{ "name" => "First", "hex" => "#111111" },
{ "name" => "Unused", "hex" => "#222222" },
{ "name" => "Third", "hex" => "#333333" }
]
meshes = [mesh("moon_structure", palette[0], 0), mesh("moon_third", palette[2], 2)]
settings = MoonModel::ThreeMF.send(:build_bambu_model_settings, meshes, { "Title" => "Moon" }, palette)
assert_includes settings, "Surface — Third (#333333) — Filament 3"
assert_includes settings, 'key="extruder" value="3"'
end
private
def mesh(name, material, offset)
MoonModel::Mesh.new(name: name, material: material).tap do |value|
value.add_box([offset, 0, 0], [1, 1, 1])
end
end
end
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
require "test_helper"
require "stringio"
class WizardTest < Minitest::Test
def test_blank_input_accepts_false_default
answer, output = ask_yes_no("\n", false)
refute answer
assert_includes output, "|no|"
refute_includes output, 'Please enter "yes" or "no".'
end
def test_blank_input_accepts_true_default
answer, output = ask_yes_no("\n", true)
assert answer
assert_includes output, "|yes|"
refute_includes output, 'Please enter "yes" or "no".'
end
def test_explicit_answers_override_defaults
{ "yes\n" => true, "y\n" => true, "no\n" => false, "n\n" => false }.each do |input, expected|
answer, = ask_yes_no(input, !expected)
assert_equal expected, answer, "expected #{input.inspect} to override the default"
end
end
def test_selects_a_new_named_model
Dir.mktmpdir do |dir|
output = StringIO.new
wizard = MoonModel::Wizard.new(io: HighLine.new(StringIO.new("My Moon\n"), output), output_root: dir)
values = wizard.send(:select_model)
assert_equal "My Moon", values.dig("output", "name")
refute Dir.exist?(File.join(dir, "My_Moon"))
end
end
def test_existing_model_can_seed_revision_defaults
Dir.mktmpdir do |dir|
model = File.join(dir, "Saved_Moon")
Dir.mkdir(model)
config = MoonModel::Config.new("vertical_exaggeration" => 5, "output" => { "name" => "Saved Moon" })
config.save(File.join(model, "config.yml"))
File.write(File.join(model, "manifest.md"), "Generated by MoonModel test")
output = StringIO.new
wizard = MoonModel::Wizard.new(io: HighLine.new(StringIO.new("Saved Moon\n\n"), output), output_root: dir)
values = wizard.send(:select_model)
assert_equal 5, values["vertical_exaggeration"]
assert_equal "Saved Moon", values.dig("output", "name")
assert_includes output.string, "Revise the existing Saved Moon model?"
end
end
def test_warns_when_user_chooses_less_than_recommended_stand_clearance
output = StringIO.new
highline = HighLine.new(StringIO.new("\n\n5\n"), output)
wizard = MoonModel::Wizard.new(io: highline)
values = { "purpose" => "display", "assembly" => "auto" }
wizard.send(:purpose, values)
assert_equal 5.0, values.dig("stand", "base_clearance_mm")
assert_includes output.string, "waive the recommended terrain safety margin"
end
private
def ask_yes_no(input, default)
output = StringIO.new
highline = HighLine.new(StringIO.new(input), output)
wizard = MoonModel::Wizard.new(io: highline)
[wizard.send(:yes_no, "Continue?", default), output.string]
end
end