# 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) 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, 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 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| 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, 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? 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 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) inner -= config["shell"]["wall_mm"] unless solid 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