This commit is contained in:
Nick Estes
2026-09-07 12:11:43 -07:00
parent 518fe64b13
commit 59ab496f17
13 changed files with 168 additions and 32 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
# MoonModel # MoonModel
MoonModel creates hollow, terrain-accurate lunar globes as generic, material-aware 3MF files. It supports a one-piece display Moon, an eight-section large Moon, arbitrary named filament colors, nozzle-aware tonal dithering, optional coordinate lines, magnetic-box configuration, and a separate orbital-triskelion stand. MoonModel creates terrain-accurate lunar globes as generic, material-aware 3MF files. It supports a solid one-piece display Moon intended for slicer-controlled sparse infill, an optional hollow one-piece Moon, a hollow eight-section large Moon, arbitrary named filament colors, nozzle-aware tonal dithering, optional coordinate lines, magnetic-box configuration, and a separate orbital-triskelion stand.
## Setup and use ## Setup and use
@@ -15,13 +15,13 @@ The executable begins with `require "bundler/setup"`, so all application imports
bundle exec ruby bin/moon_model --config config/example.yml --output build/example bundle exec ruby bin/moon_model --config config/example.yml --output build/example
``` ```
The wizard first asks for a model name, then asks about the printer/tooling envelope, intended use, palette, grid, size or scale, vertical exaggeration, and assembly hardware. Named models are written to `build/<name>/`. Selecting an existing generated name offers to revise it, using its saved configuration as the defaults for a fresh wizard run. It displays maximum one-piece/eight-section sizes in millimeters and inches and a terrain-relief cheat sheet before generation. The wizard first asks for a model name, then asks about the printer/tooling envelope, intended use, palette, grid, size or scale, one-piece interior, vertical exaggeration, and assembly hardware. Named models are written to `build/<name>/`. Selecting an existing generated name offers to revise it, using its saved configuration as the defaults for a fresh wizard run. It displays maximum one-piece/eight-section sizes in millimeters and inches and a terrain-relief cheat sheet before generation.
## Output ## Output
One-piece runs create `moon.3mf`. Segmented runs create eight print files, an assembled preview, and an assembly-key file. The optional one-piece, support-free orbital stand is emitted as `orbital_triskelion_stand.3mf` without a material assignment; its footprint scales with the Moon and is capped to the usable printer bed. Its saddles use conservative terrain-radius bounds so the Moon can be rotated freely while retaining a configurable 10 mm default clearance above the central hub. Every run includes its reusable YAML configuration, a slicer/spool manifest, and assembly instructions. Revisions are generated in a temporary directory and atomically replace the prior generated directory only after successful validation; unrelated directories are never overwritten. `--output DIR` continues to override the named default location. One-piece runs create `moon.3mf`. Their interior defaults to a watertight solid volume so the slicer can apply balanced sparse infill without generating inaccessible cavity supports; `shell.interior: hollow` retains the thin-wall alternative. Segmented runs remain hollow and create eight print files, an assembled preview, and an assembly-key file. The optional one-piece, support-free orbital stand is emitted as `orbital_triskelion_stand.3mf` without a material assignment; its footprint scales with the Moon and is capped to the usable printer bed. Its saddles use conservative terrain-radius bounds so the Moon can be rotated freely while retaining a configurable 10 mm default clearance above the central hub. Every run includes its reusable YAML configuration, a slicer/spool manifest, and assembly instructions. Revisions are generated in a temporary directory and atomically replace the prior generated directory only after successful validation; unrelated directories are never overwritten. `--output DIR` continues to override the named default location.
Colors are closed surface inlay volumes over a continuous structural shell. Palette entry order defines filament slots 1 through N. The 3MF records portable material names/display colors and Bambu Studio part names/slot assignments; confirm the numbered slots against physical AMS or external-spool filaments. The structural shell and first-color surface remain separate bodies assigned to the same filament 1, so an N-color Moon may show N+1 bodies. The file has no fixed palette limit, though the printer and filament system will impose practical limits. Colors are closed surface inlay volumes over a continuous structural body. Palette entry order defines filament slots 1 through N. The 3MF records portable material names/display colors and Bambu Studio part names/slot assignments; confirm the numbered slots against physical AMS or external-spool filaments. The structural body and first-color surface remain separate bodies assigned to the same filament 1, so an N-color Moon may show N+1 bodies. The file has no fixed palette limit, though the printer and filament system will impose practical limits.
## Modeling notes ## Modeling notes
+2
View File
@@ -19,6 +19,8 @@ palette:
grid: grid:
enabled: false enabled: false
interval_degrees: 30 interval_degrees: 30
shell:
interior: solid
stand: stand:
enabled: true enabled: true
style: orbital_triskelion style: orbital_triskelion
+1
View File
@@ -16,6 +16,7 @@ grid:
interval_degrees: 30 interval_degrees: 30
line_width_mm: 1.2 line_width_mm: 1.2
color_name: Highland silver color_name: Highland silver
shell: {interior: hollow}
magnets: {diameter_mm: 6, thickness_mm: 3, count: 8} magnets: {diameter_mm: 6, thickness_mm: 3, count: 8}
stand: {enabled: true, style: orbital_triskelion, base_clearance_mm: 10.0, material: null} stand: {enabled: true, style: orbital_triskelion, base_clearance_mm: 10.0, material: null}
resolution: {longitude_segments: 360} resolution: {longitude_segments: 360}
+2
View File
@@ -70,6 +70,7 @@ module MoonModel
def derive! def derive!
nozzle = Float(data["nozzle_mm"]) nozzle = Float(data["nozzle_mm"])
shell = data["shell"] shell = data["shell"]
shell["interior"] ||= "solid"
shell["wall_mm"] ||= 4.0 * nozzle shell["wall_mm"] ||= 4.0 * nozzle
shell["inlay_depth_mm"] ||= 2.0 * nozzle shell["inlay_depth_mm"] ||= 2.0 * nozzle
shell["minimum_feature_mm"] ||= 2.0 * nozzle shell["minimum_feature_mm"] ||= 2.0 * nozzle
@@ -84,6 +85,7 @@ module MoonModel
raise ArgumentError, "build volume requires three positive dimensions" unless build_volume.length == 3 && build_volume.all?(&: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, "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, "assembly must be auto, one_piece, or eight_piece" unless %w[auto one_piece eight_piece].include?(self["assembly"])
raise ArgumentError, "shell interior must be solid or hollow" unless %w[solid hollow].include?(self["shell"]["interior"])
raise ArgumentError, "unsupported stand style" unless self["stand"]["style"] == "orbital_triskelion" 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, "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? raise ArgumentError, "palette must contain at least one color" if self["palette"].empty?
+11 -2
View File
@@ -166,6 +166,7 @@ module MoonModel
- Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source}) - Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source})
- Purpose: #{config["purpose"]} - Purpose: #{config["purpose"]}
- Interior: #{result.statistics["interior"] == "solid" ? "solid slicer volume (use configured infill)" : "hollow shell"}
- Datum diameter: #{format("%.2f", result.statistics["diameter_mm"])} mm (#{format("%.3f", result.statistics["diameter_mm"] / 25.4)} in) - 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) - 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"]}× - Vertical exaggeration: #{config["vertical_exaggeration"]}×
@@ -186,7 +187,7 @@ module MoonModel
|---|---|---:| |---|---|---:|
#{bodies} #{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. The structural volume 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
@@ -241,7 +242,15 @@ module MoonModel
4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside. 4. Apply adhesive to the recessed equatorial land, align longitude seams, and close the second hemisphere from the outside.
MD MD
else else
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material.\n" if result.statistics["interior"] == "solid"
<<~MD
# Printing
The one-piece Moon is a watertight solid slicer volume. Select the desired sparse infill pattern and percentage in the slicer; no internal cavity supports are required. Configure build-plate adhesion and external support for the lower surface as appropriate to the selected material and printer.
MD
else
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material. Internal supports may be difficult to remove and can break loose inside the finished Moon.\n"
end
end end
path = File.join(write_dir, "ASSEMBLY.md") path = File.join(write_dir, "ASSEMBLY.md")
File.write(path, text) File.write(path, text)
+21 -8
View File
@@ -9,12 +9,13 @@ module MoonModel
def build(config, data_set) def build(config, data_set)
sizing = Sizing.calculate(config, data_set) sizing = Sizing.calculate(config, data_set)
interior = sizing.segmented? ? "hollow" : config["shell"]["interior"]
diameter = sizing.datum_diameter_mm diameter = sizing.datum_diameter_mm
lon_segments = sizing.longitude_segments lon_segments = sizing.longitude_segments
lat_segments = sizing.latitude_segments lat_segments = sizing.latitude_segments
palette = Color.ordered_palette(config["palette"]) palette = Color.ordered_palette(config["palette"])
ranges = sizing.segmented? ? section_ranges : [[-90.0, 90.0, 0.0, 360.0, "moon"]] 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) parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette, interior)
radius = diameter / 2.0 radius = diameter / 2.0
all_meshes = parts.flat_map { |part| part[:meshes] } all_meshes = parts.flat_map { |part| part[:meshes] }
keys = sizing.segmented? ? build_keys(config, radius) : [] keys = sizing.segmented? ? build_keys(config, radius) : []
@@ -36,6 +37,7 @@ module MoonModel
"safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source, "safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source,
"longitude_segments" => lon_segments, "longitude_segments" => lon_segments,
"latitude_segments" => lat_segments, "part_count" => parts.length, "latitude_segments" => lat_segments, "part_count" => parts.length,
"interior" => interior,
"minimum_radius_mm" => sizing.minimum_radius_mm, "minimum_radius_mm" => sizing.minimum_radius_mm,
"maximum_radius_mm" => sizing.maximum_radius_mm, "maximum_radius_mm" => sizing.maximum_radius_mm,
"relief_range_mm" => relief_range_mm(config, data_set, diameter), "relief_range_mm" => relief_range_mm(config, data_set, diameter),
@@ -53,10 +55,10 @@ module MoonModel
requested requested
end end
def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette) def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette, interior)
radius = diameter / 2.0 radius = diameter / 2.0
ranges.map do |lat0, lat1, lon0, lon1, name| ranges.map do |lat0, lat1, lon0, lon1, name|
build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette) build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette, interior)
end end
end end
@@ -67,13 +69,14 @@ module MoonModel
end end
end end
def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette) def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette, interior)
nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max
nlat = [(global_lat_segments * (lat1 - lat0) / 180.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) 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)] } color_meshes = palette.to_h { |color| [color["name"], Mesh.new(name: "#{name}_#{slug(color["name"])}", material: color)] }
multicolor = palette.length > 1 multicolor = palette.length > 1
unified_base = multicolor || !config["grid"]["enabled"] solid = interior == "solid"
unified_base = solid || multicolor || !config["grid"]["enabled"]
substrate_vertices = {} substrate_vertices = {}
inner_vertices = {} inner_vertices = {}
boundary_edges = {} boundary_edges = {}
@@ -99,16 +102,24 @@ module MoonModel
else else
0.0 0.0
end end
substrate = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) } substrate = if solid && !multicolor && config["grid"]["enabled"]
inner = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) } coords.map do |lat, lon|
vertex_is_grid = on_grid?(config, lat, lon, radius, (a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0)
point(config, data_set, radius, lat, lon, vertex_is_grid ? -config["grid"]["depth_mm"] : 0.0)
end
else
coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
end
inner = solid ? nil : coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) }
if unified_base if unified_base
outer_ids = coords.each_with_index.map do |coord, index| outer_ids = coords.each_with_index.map do |coord, index|
substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index]) substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index])
end end
base.triangle(*outer_ids)
unless solid
inner_ids = coords.each_with_index.map do |coord, index| inner_ids = coords.each_with_index.map do |coord, index|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index]) inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
end end
base.triangle(*outer_ids)
base.triangle(inner_ids[0], inner_ids[2], inner_ids[1]) base.triangle(inner_ids[0], inner_ids[2], inner_ids[1])
3.times do |edge| 3.times do |edge|
next_edge = (edge + 1) % 3 next_edge = (edge + 1) % 3
@@ -121,6 +132,7 @@ module MoonModel
inner: [inner_ids[edge], inner_ids[next_edge]] } inner: [inner_ids[edge], inner_ids[next_edge]] }
end end
end end
end
else else
base.add_tri_prism(substrate, inner) base.add_tri_prism(substrate, inner)
end end
@@ -135,6 +147,7 @@ module MoonModel
if unified_base if unified_base
boundary_edges.each_value do |edge| boundary_edges.each_value do |edge|
next unless edge[:count] == 1 next unless edge[:count] == 1
next if solid
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0]) base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
end end
end end
+17 -5
View File
@@ -40,12 +40,13 @@ module MoonModel
dimensions = unit.map { |min, max| (max - min) * datum } dimensions = unit.map { |min, max| (max - min) * datum }
minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration, minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration,
lon_segments, lat_segments) lon_segments, lat_segments)
safe = validate_safety ? maximum_safe_exaggeration(config, data_set) : nil interior = prospective_interior(config, data_set, datum, exaggeration, lon_segments, lat_segments)
safe = validate_safety ? maximum_safe_exaggeration(config, data_set, interior: interior) : nil
if safe && exaggeration > safe + 1e-9 if safe && exaggeration > safe + 1e-9
raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe) raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe)
end end
mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments) mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
Result.new( Result.new(
datum_diameter_mm: datum, datum_diameter_mm: datum,
final_envelope_mm: dimensions.max, final_envelope_mm: dimensions.max,
@@ -61,6 +62,14 @@ module MoonModel
) )
end end
def prospective_interior(config, data_set, datum, exaggeration, lon_segments, lat_segments)
return "hollow" if config["purpose"] == "box" || config["assembly"] == "eight_piece"
return config["shell"]["interior"] if config["assembly"] == "one_piece"
dimensions = full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
fits?(dimensions, config) ? config["shell"]["interior"] : "hollow"
end
def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments) def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments)
base_radius = datum / 2.0 base_radius = datum / 2.0
radii = (0..lat_segments).flat_map do |iy| radii = (0..lat_segments).flat_map do |iy|
@@ -112,11 +121,13 @@ module MoonModel
bounds bounds
end end
def maximum_safe_exaggeration(config, data_set) def maximum_safe_exaggeration(config, data_set, interior: config["shell"]["interior"])
minimum_offset = data_set.metadata.fetch("elevation_offset_range_m", [-data_set.relief_range_m, 0]).first.to_f 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? return Float::INFINITY unless minimum_offset.negative?
required = config["shell"]["wall_mm"] + config["shell"]["minimum_feature_mm"] solid = interior == "solid"
required = config["shell"]["minimum_feature_mm"]
required += config["shell"]["wall_mm"] unless solid
if config["size"]["meaning"] == "datum" if config["size"]["meaning"] == "datum"
available = config.diameter_mm / 2.0 available = config.diameter_mm / 2.0
return 0.0 if available <= required return 0.0 if available <= required
@@ -132,7 +143,8 @@ module MoonModel
mid = (low + high) / 2.0 mid = (low + high) / 2.0
factor = sampled_bounds(data_set, mid, segment_count(config), segment_count(config) / 2).map { |a, b| b - a }.max 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) radius = config.diameter_mm / (2.0 * factor)
inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid) - config["shell"]["wall_mm"] inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid)
inner -= config["shell"]["wall_mm"] unless solid
inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid
end end
low low
+8
View File
@@ -152,6 +152,10 @@ module MoonModel
end end
values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning } values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning }
end end
shell = values.fetch("shell", {}).dup
shell["interior"] = yes_no("For a one-piece Moon, use slicer-controlled infill instead of a hollow cavity?",
shell.fetch("interior", "solid") == "solid") ? "solid" : "hollow"
values["shell"] = shell
io.say("Terrain sizing at this size:") io.say("Terrain sizing at this size:")
[1, 2, 5, 10].each do |factor| [1, 2, 5, 10].each do |factor|
trial = Config.new(values.merge("vertical_exaggeration" => factor)) trial = Config.new(values.merge("vertical_exaggeration" => factor))
@@ -179,6 +183,10 @@ module MoonModel
io.say("Datum diameter: #{units(summary.datum_diameter_mm)}") 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("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})") io.say("Maximum safe exaggeration: #{format('%.2f', summary.safe_exaggeration)}x; mode: #{summary.mode.tr('_', ' ')} (#{summary.mode_source})")
if summary.segmented?
values["shell"]["interior"] = "hollow"
io.say("Eight-section output uses hollow sections; the solid/infill option applies only to one-piece Moons.")
end
end end
def engineering(values) def engineering(values)
+5
View File
@@ -9,6 +9,7 @@ class ConfigTest < Minitest::Test
assert_in_delta 463.0, config.maximum_diameter("eight_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 1.6, config["shell"]["wall_mm"], 0.001
assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001 assert_in_delta 0.8, config["shell"]["minimum_feature_mm"], 0.001
assert_equal "solid", config["shell"]["interior"]
assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001 assert_in_delta 10.0, config["stand"]["base_clearance_mm"], 0.001
end end
@@ -32,4 +33,8 @@ class ConfigTest < Minitest::Test
MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 }) MoonModel::Config.new("stand" => { "base_clearance_mm" => -0.1 })
end end
end end
def test_rejects_unknown_interior_mode
assert_raises(ArgumentError) { MoonModel::Config.new("shell" => { "interior" => "foam" }) }
end
end end
+4
View File
@@ -21,6 +21,10 @@ class GeneratorTest < Minitest::Test
assert_includes model, "<components>" assert_includes model, "<components>"
end end
assert File.file?(File.join(result[:output_dir], "manifest.md")) assert File.file?(File.join(result[:output_dir], "manifest.md"))
manifest = File.read(File.join(result[:output_dir], "manifest.md"))
assembly = File.read(File.join(result[:output_dir], "ASSEMBLY.md"))
assert_includes manifest, "Interior: solid slicer volume"
assert_includes assembly, "no internal cavity supports are required"
assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01 assert_in_delta 40.0, result[:statistics]["final_envelope_mm"], 0.01
end end
end end
+59
View File
@@ -0,0 +1,59 @@
# frozen_string_literal: true
require "test_helper"
class GeometryTest < Minitest::Test
def setup
@data = MoonModel::DataSet.new
end
def test_solid_one_piece_is_a_closed_outer_volume_without_inner_surface
solid = structure("solid")
hollow = structure("hollow")
assert solid.valid?
assert_manifold solid
assert_equal hollow.triangles.length / 2, solid.triangles.length
assert_equal hollow.vertices.length / 2, solid.vertices.length
end
def test_solid_single_color_grid_remains_manifold
config = config_for("solid", "grid" => { "enabled" => true, "depth_mm" => 0.4 })
mesh = MoonModel::Geometry.build(config, @data).parts.first[:meshes].first
assert mesh.valid?
assert_manifold mesh
end
def test_segmented_output_is_hollow_even_when_solid_is_requested
config = config_for("solid", "purpose" => "box", "assembly" => "eight_piece")
result = MoonModel::Geometry.build(config, @data)
assert result.segmented?
assert_equal "hollow", result.statistics["interior"]
end
private
def structure(interior)
MoonModel::Geometry.build(config_for(interior), @data).parts.first[:meshes].first
end
def config_for(interior, extra = {})
MoonModel::Config.new({
"size" => { "value" => 40 },
"shell" => { "interior" => interior },
"resolution" => { "longitude_segments" => 24 }
}.merge(extra))
end
def assert_manifold(mesh)
edge_uses = Hash.new(0)
mesh.triangles.each do |triangle|
triangle.each_index do |index|
edge_uses[[triangle[index], triangle[(index + 1) % 3]].sort] += 1
end
end
assert edge_uses.values.all? { |count| count == 2 }, "every edge must belong to exactly two faces"
end
end
+10 -1
View File
@@ -11,11 +11,20 @@ class SizingTest < Minitest::Test
MoonModel::Config.new( MoonModel::Config.new(
"size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" }, "size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" },
"vertical_exaggeration" => exaggeration, "vertical_exaggeration" => exaggeration,
"assembly" => assembly, "assembly" => assembly, "shell" => { "interior" => "hollow" },
"resolution" => { "longitude_segments" => 48 } "resolution" => { "longitude_segments" => 48 }
) )
end end
def test_solid_one_piece_allows_more_relief_before_radial_collapse
hollow = datum_config(1, assembly: "one_piece")
solid = MoonModel::Config.new(hollow.to_h.merge("shell" => hollow["shell"].merge("interior" => "solid")))
assert_operator MoonModel::Sizing.maximum_safe_exaggeration(solid, @data), :>,
MoonModel::Sizing.maximum_safe_exaggeration(hollow, @data)
end
def test_eight_inch_relief_uses_consistent_units def test_eight_inch_relief_uses_consistent_units
one = MoonModel::Sizing.calculate(datum_config(1), @data) one = MoonModel::Sizing.calculate(datum_config(1), @data)
ten = MoonModel::Sizing.calculate(datum_config(10), @data) ten = MoonModel::Sizing.calculate(datum_config(10), @data)
+12
View File
@@ -69,6 +69,18 @@ class WizardTest < Minitest::Test
assert_includes output.string, "waive the recommended terrain safety margin" assert_includes output.string, "waive the recommended terrain safety margin"
end end
def test_one_piece_interior_choice_can_select_hollow
input = StringIO.new("\n\n\n\nn\n\n\n")
output = StringIO.new
wizard = MoonModel::Wizard.new(io: HighLine.new(input, output), data_set: MoonModel::DataSet.new)
values = { "printer" => MoonModel::Config::X2D.dup, "purpose" => "display", "assembly" => "auto" }
wizard.send(:sizing, values)
assert_equal "hollow", values.dig("shell", "interior")
assert_includes output.string, "use slicer-controlled infill instead of a hollow cavity"
end
private private
def ask_yes_no(input, default) def ask_yes_no(input, default)