193 lines
8.9 KiB
Ruby
193 lines
8.9 KiB
Ruby
# 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
|