Files
3d_globe/lib/moon_model/sizing.rb
T

181 lines
8.2 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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