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
+2
View File
@@ -70,6 +70,7 @@ module MoonModel
def derive!
nozzle = Float(data["nozzle_mm"])
shell = data["shell"]
shell["interior"] ||= "solid"
shell["wall_mm"] ||= 4.0 * nozzle
shell["inlay_depth_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, "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, "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, "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?
+11 -2
View File
@@ -166,6 +166,7 @@ module MoonModel
- Mode: #{result.segmented? ? "eight section" : "one piece"} (#{result.mode_source})
- 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)
- 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"]}×
@@ -186,7 +187,7 @@ module MoonModel
|---|---|---:|
#{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
@@ -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.
MD
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
path = File.join(write_dir, "ASSEMBLY.md")
File.write(path, text)
+33 -20
View File
@@ -9,12 +9,13 @@ module MoonModel
def build(config, data_set)
sizing = Sizing.calculate(config, data_set)
interior = sizing.segmented? ? "hollow" : config["shell"]["interior"]
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)
parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette, interior)
radius = diameter / 2.0
all_meshes = parts.flat_map { |part| part[:meshes] }
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,
"longitude_segments" => lon_segments,
"latitude_segments" => lat_segments, "part_count" => parts.length,
"interior" => interior,
"minimum_radius_mm" => sizing.minimum_radius_mm,
"maximum_radius_mm" => sizing.maximum_radius_mm,
"relief_range_mm" => relief_range_mm(config, data_set, diameter),
@@ -53,10 +55,10 @@ module MoonModel
requested
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
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
@@ -67,13 +69,14 @@ module MoonModel
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
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"]
solid = interior == "solid"
unified_base = solid || multicolor || !config["grid"]["enabled"]
substrate_vertices = {}
inner_vertices = {}
boundary_edges = {}
@@ -99,26 +102,35 @@ module MoonModel
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"]) }
substrate = if solid && !multicolor && config["grid"]["enabled"]
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
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]] }
unless solid
inner_ids = coords.each_with_index.map do |coord, index|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
end
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
end
else
@@ -135,6 +147,7 @@ module MoonModel
if unified_base
boundary_edges.each_value do |edge|
next unless edge[:count] == 1
next if solid
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
end
end
+17 -5
View File
@@ -40,12 +40,13 @@ module MoonModel
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
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
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,
@@ -61,6 +62,14 @@ module MoonModel
)
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)
base_radius = datum / 2.0
radii = (0..lat_segments).flat_map do |iy|
@@ -112,11 +121,13 @@ module MoonModel
bounds
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
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"
available = config.diameter_mm / 2.0
return 0.0 if available <= required
@@ -132,7 +143,8 @@ module MoonModel
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 = 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
end
low
+8
View File
@@ -152,6 +152,10 @@ module MoonModel
end
values["size"] = { "input" => "diameter", "value" => value, "unit" => unit, "meaning" => meaning }
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:")
[1, 2, 5, 10].each do |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("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})")
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
def engineering(values)