Initial MoonModel implementation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user