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
+180
View File
@@ -0,0 +1,180 @@
# frozen_string_literal: true
module MoonModel
module Sizing
Result = Struct.new(:datum_diameter_mm, :final_envelope_mm, :dimensions_mm,
:minimum_radius_mm, :maximum_radius_mm,
:relief_range_mm, :safe_exaggeration, :mode,
:mode_source, :longitude_segments, :latitude_segments,
keyword_init: true) do
def segmented? = mode == "eight_piece"
def to_h
{
"diameter_mm" => datum_diameter_mm,
"final_envelope_mm" => final_envelope_mm,
"dimensions_mm" => dimensions_mm,
"minimum_radius_mm" => minimum_radius_mm,
"maximum_radius_mm" => maximum_radius_mm,
"relief_range_mm" => relief_range_mm,
"safe_exaggeration" => safe_exaggeration,
"mode" => mode,
"mode_source" => mode_source,
"longitude_segments" => longitude_segments,
"latitude_segments" => latitude_segments
}
end
end
module_function
def calculate(config, data_set, validate_safety: true)
exaggeration = Float(config["vertical_exaggeration"])
raise ArgumentError, "vertical exaggeration must be nonnegative" if exaggeration.negative?
lon_segments = segment_count(config)
lat_segments = lon_segments / 2
unit = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments)
factor = unit.map { |min, max| max - min }.max
datum = config["size"]["meaning"] == "final_envelope" ? config.diameter_mm / factor : config.diameter_mm
dimensions = unit.map { |min, max| (max - min) * datum }
minimum_radius, maximum_radius = conservative_radial_bounds(config, data_set, datum, exaggeration,
lon_segments, lat_segments)
safe = validate_safety ? maximum_safe_exaggeration(config, data_set) : nil
if safe && exaggeration > safe + 1e-9
raise ArgumentError, format("vertical exaggeration %.3gx is unsafe; maximum is %.3gx", exaggeration, safe)
end
mode, source = resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
Result.new(
datum_diameter_mm: datum,
final_envelope_mm: dimensions.max,
dimensions_mm: dimensions,
minimum_radius_mm: minimum_radius,
maximum_radius_mm: maximum_radius,
relief_range_mm: data_set.relief_range_m * datum / (2.0 * DataSet::DATUM_RADIUS_M) * exaggeration,
safe_exaggeration: safe,
mode: mode,
mode_source: source,
longitude_segments: lon_segments,
latitude_segments: lat_segments
)
end
def conservative_radial_bounds(config, data_set, datum, exaggeration, lon_segments, lat_segments)
base_radius = datum / 2.0
radii = (0..lat_segments).flat_map do |iy|
latitude = -90.0 + 180.0 * iy / lat_segments
(0..lon_segments).map do |ix|
longitude = 360.0 * ix / lon_segments
elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M
base_radius + elevation * base_radius / DataSet::DATUM_RADIUS_M * exaggeration
end
end
sampled_min, sampled_max = radii.minmax
cell_diagonal = Math.hypot(180.0 / lat_segments, 360.0 / lon_segments) * Math::PI / 180.0
facet_allowance = sampled_max * (1.0 - Math.cos(cell_diagonal))
deboss_allowance = if config["grid"]["enabled"] && config["palette"].length == 1
config["grid"]["depth_mm"].to_f
else
0.0
end
[sampled_min - facet_allowance - deboss_allowance, sampled_max]
end
def segment_count(config)
requested = config["resolution"]["longitude_segments"]&.to_i
requested ||= [[(Math::PI * config.diameter_mm / config["shell"]["minimum_feature_mm"]).ceil, 96].max, 1024].min
requested + (8 - requested % 8) % 8
end
def sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range = [-90.0, 90.0, 0.0, 360.0])
lat0, lat1, lon0, lon1 = range
nlat = [(lat_segments * (lat1 - lat0) / 180.0).round, 2].max
nlon = [(lon_segments * (lon1 - lon0) / 360.0).round, 2].max
bounds = Array.new(3) { [Float::INFINITY, -Float::INFINITY] }
(0..nlat).each do |iy|
lat = lat0 + (lat1 - lat0) * iy / nlat.to_f
(0..nlon).each do |ix|
lon = lon0 + (lon1 - lon0) * ix / nlon.to_f
offset = data_set.elevation_m(lat, lon) - DataSet::DATUM_RADIUS_M
radius = 0.5 * (1.0 + offset / DataSet::DATUM_RADIUS_M * exaggeration)
lat_r = lat * Math::PI / 180.0
lon_r = lon * Math::PI / 180.0
point = [radius * Math.cos(lat_r) * Math.cos(lon_r),
radius * Math.cos(lat_r) * Math.sin(lon_r), radius * Math.sin(lat_r)]
3.times do |axis|
bounds[axis][0] = [bounds[axis][0], point[axis]].min
bounds[axis][1] = [bounds[axis][1], point[axis]].max
end
end
end
bounds
end
def maximum_safe_exaggeration(config, data_set)
minimum_offset = data_set.metadata.fetch("elevation_offset_range_m", [-data_set.relief_range_m, 0]).first.to_f
return Float::INFINITY unless minimum_offset.negative?
required = config["shell"]["wall_mm"] + config["shell"]["minimum_feature_mm"]
if config["size"]["meaning"] == "datum"
available = config.diameter_mm / 2.0
return 0.0 if available <= required
return (1.0 - required / available) * DataSet::DATUM_RADIUS_M / -minimum_offset
end
# Final-envelope sizing changes the datum radius as exaggeration changes.
low = 0.0
high = DataSet::DATUM_RADIUS_M / -minimum_offset
# Twenty-four bisections are well beyond the precision meaningful to a printer
# while keeping the interactive preflight responsive at dense mesh settings.
24.times do
mid = (low + high) / 2.0
factor = sampled_bounds(data_set, mid, segment_count(config), segment_count(config) / 2).map { |a, b| b - a }.max
radius = config.diameter_mm / (2.0 * factor)
inner = radius * (1.0 + minimum_offset / DataSet::DATUM_RADIUS_M * mid) - config["shell"]["wall_mm"]
inner >= config["shell"]["minimum_feature_mm"] ? low = mid : high = mid
end
low
end
def resolve_mode(config, data_set, datum, exaggeration, lon_segments, lat_segments)
requested = config["purpose"] == "box" ? "eight_piece" : config["assembly"]
if requested == "one_piece"
ensure_fit!(full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments), config, "one-piece Moon")
return ["one_piece", "forced"]
end
if requested == "eight_piece"
ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
return ["eight_piece", config["purpose"] == "box" ? "purpose" : "forced"]
end
dims = full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
return ["one_piece", "automatic"] if fits?(dims, config)
ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
["eight_piece", "automatic"]
end
def full_dimensions(data_set, datum, exaggeration, lon_segments, lat_segments)
sampled_bounds(data_set, exaggeration, lon_segments, lat_segments).map { |a, b| (b - a) * datum }
end
def ensure_sections_fit!(config, data_set, datum, exaggeration, lon_segments, lat_segments)
Geometry.section_ranges.each do |range|
dims = sampled_bounds(data_set, exaggeration, lon_segments, lat_segments, range[0, 4]).map { |a, b| (b - a) * datum }
ensure_fit!(dims, config, "eight-piece section")
end
end
def fits?(dimensions, config)
usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] }
dimensions.zip(usable).all? { |actual, limit| actual <= limit + 1e-6 }
end
def ensure_fit!(dimensions, config, label)
return if fits?(dimensions, config)
usable = config.build_volume.map { |value| value - 2.0 * config["edge_clearance_mm"] }
raise ArgumentError, "#{label} #{dimensions.map { |v| format('%.2f', v) }.join('×')} mm exceeds usable build volume #{usable.join('×')} mm"
end
end
end