250 lines
11 KiB
Ruby
250 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MoonModel
|
|
module Geometry
|
|
Result = Struct.new(:parts, :assembled, :keys, :stand, :statistics, :mode, :mode_source, keyword_init: true) do
|
|
def segmented? = mode == "eight_piece"
|
|
end
|
|
module_function
|
|
|
|
def build(config, data_set)
|
|
sizing = Sizing.calculate(config, data_set)
|
|
diameter = sizing.datum_diameter_mm
|
|
lon_segments = sizing.longitude_segments
|
|
lat_segments = sizing.latitude_segments
|
|
palette = Color.ordered_palette(config["palette"])
|
|
ranges = sizing.segmented? ? section_ranges : [[-90.0, 90.0, 0.0, 360.0, "moon"]]
|
|
parts = build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette)
|
|
radius = diameter / 2.0
|
|
all_meshes = parts.flat_map { |part| part[:meshes] }
|
|
keys = sizing.segmented? ? build_keys(config, radius) : []
|
|
final_envelope = combined_bounds(all_meshes).map { |min, max| max - min }.max
|
|
stand_profile = {
|
|
"envelope_mm" => final_envelope,
|
|
"minimum_radius_mm" => sizing.minimum_radius_mm,
|
|
"maximum_radius_mm" => sizing.maximum_radius_mm
|
|
}
|
|
stand_result = config["stand"]["enabled"] ? Stand.build(config, stand_profile) : nil
|
|
stand = stand_result ? [stand_result.mesh] : []
|
|
Result.new(
|
|
parts: parts,
|
|
assembled: all_meshes,
|
|
keys: keys,
|
|
stand: stand,
|
|
mode: sizing.mode, mode_source: sizing.mode_source,
|
|
statistics: { "diameter_mm" => diameter, "final_envelope_mm" => final_envelope, "dimensions_mm" => sizing.dimensions_mm,
|
|
"safe_exaggeration" => sizing.safe_exaggeration, "mode" => sizing.mode, "mode_source" => sizing.mode_source,
|
|
"longitude_segments" => lon_segments,
|
|
"latitude_segments" => lat_segments, "part_count" => parts.length,
|
|
"minimum_radius_mm" => sizing.minimum_radius_mm,
|
|
"maximum_radius_mm" => sizing.maximum_radius_mm,
|
|
"relief_range_mm" => relief_range_mm(config, data_set, diameter),
|
|
"stand" => stand_result&.statistics }
|
|
)
|
|
end
|
|
|
|
def relief_range_mm(config, data_set, datum_diameter_mm = config.diameter_mm)
|
|
data_set.relief_range_m * datum_diameter_mm / (2.0 * DataSet::DATUM_RADIUS_M) * config["vertical_exaggeration"]
|
|
end
|
|
|
|
def scaled_datum_diameter(config, data_set)
|
|
requested = config.diameter_mm
|
|
return requested if config["size"]["meaning"] == "datum"
|
|
requested
|
|
end
|
|
|
|
def build_parts(config, data_set, diameter, ranges, lon_segments, lat_segments, palette)
|
|
radius = diameter / 2.0
|
|
ranges.map do |lat0, lat1, lon0, lon1, name|
|
|
build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, lon_segments, lat_segments, palette)
|
|
end
|
|
end
|
|
|
|
def section_ranges
|
|
%w[north south].flat_map do |hemisphere|
|
|
lat = hemisphere == "north" ? [0.0, 90.0] : [-90.0, 0.0]
|
|
4.times.map { |q| [lat[0], lat[1], q * 90.0, (q + 1) * 90.0, "#{hemisphere}_#{q + 1}"] }
|
|
end
|
|
end
|
|
|
|
def build_patch(config, data_set, radius, lat0, lat1, lon0, lon1, name, global_lon_segments, global_lat_segments, palette)
|
|
nlon = [(global_lon_segments * (lon1 - lon0) / 360.0).round, 2].max
|
|
nlat = [(global_lat_segments * (lat1 - lat0) / 180.0).round, 2].max
|
|
base = Mesh.new(name: "#{name}_structure", material: config["palette"].first)
|
|
color_meshes = palette.to_h { |color| [color["name"], Mesh.new(name: "#{name}_#{slug(color["name"])}", material: color)] }
|
|
multicolor = palette.length > 1
|
|
unified_base = multicolor || !config["grid"]["enabled"]
|
|
substrate_vertices = {}
|
|
inner_vertices = {}
|
|
boundary_edges = {}
|
|
cell_index = 0
|
|
|
|
nlat.times do |iy|
|
|
a0 = lat0 + (lat1 - lat0) * iy / nlat.to_f
|
|
a1 = lat0 + (lat1 - lat0) * (iy + 1) / nlat.to_f
|
|
nlon.times do |ix|
|
|
o0 = lon0 + (lon1 - lon0) * ix / nlon.to_f
|
|
o1 = lon0 + (lon1 - lon0) * (ix + 1) / nlon.to_f
|
|
triangles_for_cell(a0, a1, o0, o1).each do |coords|
|
|
center_lat = coords.sum { |p| p[0] } / 3.0
|
|
center_lon = coords.sum { |p| p[1] } / 3.0
|
|
grid_cell = config["grid"]["enabled"] && on_grid?(config, center_lat, center_lon, radius,
|
|
(a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0)
|
|
outer = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, 0.0) }
|
|
deboss = !multicolor && grid_cell
|
|
surface_offset = if multicolor
|
|
-config["shell"]["inlay_depth_mm"]
|
|
elsif deboss
|
|
-config["grid"]["depth_mm"]
|
|
else
|
|
0.0
|
|
end
|
|
substrate = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
|
|
inner = coords.map { |lat, lon| point(config, data_set, radius, lat, lon, -config["shell"]["wall_mm"]) }
|
|
if unified_base
|
|
outer_ids = coords.each_with_index.map do |coord, index|
|
|
substrate_vertices[coordinate_key(coord)] ||= base.vertex(substrate[index])
|
|
end
|
|
inner_ids = coords.each_with_index.map do |coord, index|
|
|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
|
|
end
|
|
base.triangle(*outer_ids)
|
|
base.triangle(inner_ids[0], inner_ids[2], inner_ids[1])
|
|
3.times do |edge|
|
|
next_edge = (edge + 1) % 3
|
|
key = [coordinate_key(coords[edge]), coordinate_key(coords[next_edge])].sort
|
|
record = boundary_edges[key]
|
|
if record
|
|
record[:count] += 1
|
|
else
|
|
boundary_edges[key] = { count: 1, outer: [outer_ids[edge], outer_ids[next_edge]],
|
|
inner: [inner_ids[edge], inner_ids[next_edge]] }
|
|
end
|
|
end
|
|
else
|
|
base.add_tri_prism(substrate, inner)
|
|
end
|
|
if multicolor
|
|
color = color_for(config, data_set, palette, center_lat, center_lon, iy, ix, grid_cell)
|
|
color_meshes.fetch(color["name"]).add_tri_prism(outer, substrate)
|
|
end
|
|
cell_index += 1
|
|
end
|
|
end
|
|
end
|
|
if unified_base
|
|
boundary_edges.each_value do |edge|
|
|
next unless edge[:count] == 1
|
|
base.quad(edge[:outer][0], edge[:outer][1], edge[:inner][1], edge[:inner][0])
|
|
end
|
|
end
|
|
meshes = [base] + (multicolor ? color_meshes.values.reject { |mesh| mesh.triangles.empty? } : [])
|
|
add_equatorial_hardware(config, base, radius, lat0, lat1, lon0, lon1)
|
|
{ name: name, meshes: meshes, bounds: combined_bounds(meshes), cells: cell_index }
|
|
end
|
|
|
|
def triangles_for_cell(lat0, lat1, lon0, lon1)
|
|
if lat0 <= -90.0
|
|
[[[lat0, lon0], [lat1, lon1], [lat1, lon0]]]
|
|
elsif lat1 >= 90.0
|
|
[[[lat0, lon0], [lat0, lon1], [lat1, lon0]]]
|
|
else
|
|
[[[lat0, lon0], [lat0, lon1], [lat1, lon1]], [[lat0, lon0], [lat1, lon1], [lat1, lon0]]]
|
|
end
|
|
end
|
|
|
|
def point(config, data_set, base_radius, latitude, longitude, offset_mm)
|
|
elevation = data_set.elevation_m(latitude, longitude) - DataSet::DATUM_RADIUS_M
|
|
scale = base_radius / DataSet::DATUM_RADIUS_M
|
|
r = base_radius + elevation * scale * config["vertical_exaggeration"] + offset_mm
|
|
lat = latitude * Math::PI / 180.0
|
|
lon = longitude * Math::PI / 180.0
|
|
[r * Math.cos(lat) * Math.cos(lon), r * Math.cos(lat) * Math.sin(lon), r * Math.sin(lat)]
|
|
end
|
|
|
|
def color_for(config, data_set, palette, latitude, longitude, row, column, grid_cell)
|
|
if grid_cell
|
|
grid = config["grid"]
|
|
return palette.find { |item| item["name"] == grid["color_name"] } || config["palette"].last
|
|
end
|
|
Color.choose(data_set.reflectance(latitude, longitude), palette, row, column)
|
|
end
|
|
|
|
def on_grid?(config, latitude, longitude, radius, latitude_cell_degrees = 0.0, longitude_cell_degrees = 0.0)
|
|
interval = config["grid"]["interval_degrees"].to_f
|
|
half_width_deg = config["grid"]["line_width_mm"].to_f / [radius, 0.1].max * 90.0 / Math::PI
|
|
lat_distance = (latitude / interval).round * interval - latitude
|
|
lon_distance = (longitude / interval).round * interval - longitude
|
|
cosine = Math.cos(latitude * Math::PI / 180.0).abs
|
|
lat_distance.abs <= half_width_deg + latitude_cell_degrees ||
|
|
(lon_distance.abs * cosine) <= half_width_deg + longitude_cell_degrees * cosine
|
|
end
|
|
|
|
def build_keys(config, radius)
|
|
material = config["palette"].first
|
|
clearance = config["shell"]["joint_clearance_mm"]
|
|
key = Mesh.new(name: "assembly_keys", material: material)
|
|
key_size = [[radius * 0.08, 8.0].max, 30.0].min
|
|
8.times do |i|
|
|
x = (i % 4) * (key_size + 3.0)
|
|
y = (i / 4) * (key_size * 0.45 + 3.0)
|
|
key.add_tapered_box([x, y, key_size * 0.15], key_size, key_size * 0.38 - clearance,
|
|
key_size * 0.32 - clearance, key_size * 0.3)
|
|
end
|
|
if config["purpose"] == "display"
|
|
diameter = [4.0, radius * 0.04].min
|
|
4.times do |i|
|
|
key.add_cylinder([i * (diameter + 3.0), key_size + 6.0, diameter], diameter / 2.0 - clearance,
|
|
diameter * 2.0, segments: 24)
|
|
end
|
|
end
|
|
[key]
|
|
end
|
|
|
|
def add_equatorial_hardware(config, mesh, radius, lat0, lat1, lon0, lon1)
|
|
return unless lat0.zero? || lat1.zero?
|
|
|
|
north = lat0.zero?
|
|
depth_sign = north ? 1.0 : -1.0
|
|
wall = config["shell"]["wall_mm"]
|
|
clearance = config["shell"]["joint_clearance_mm"]
|
|
angles = if config["purpose"] == "box"
|
|
count = config["magnets"]["count"].to_i
|
|
count.times.map { |i| i * 360.0 / count }
|
|
else
|
|
[(lon0 + lon1) / 2.0]
|
|
end
|
|
angles.select { |angle| angle >= lon0 && (angle < lon1 || (lon1 == 360.0 && angle <= lon1)) }.each do |angle|
|
|
inner = if config["purpose"] == "box"
|
|
config["magnets"]["diameter_mm"].to_f / 2.0 + clearance
|
|
else
|
|
[2.0, radius * 0.02].min + clearance
|
|
end
|
|
outer = inner + [wall, 1.2].max
|
|
depth = if config["purpose"] == "box"
|
|
config["magnets"]["thickness_mm"].to_f + wall
|
|
else
|
|
inner * 2.5
|
|
end
|
|
radial = radius - config["shell"]["wall_mm"] - outer * 0.75
|
|
radians = angle * Math::PI / 180.0
|
|
center = [radial * Math.cos(radians), radial * Math.sin(radians)]
|
|
mesh.add_annular_cylinder(center, inner, outer, 0.0, depth_sign * depth)
|
|
end
|
|
end
|
|
|
|
def combined_bounds(meshes)
|
|
bounds = meshes.map(&:bounds)
|
|
3.times.map { |axis| [bounds.map { |b| b[axis][0] }.min, bounds.map { |b| b[axis][1] }.max] }
|
|
end
|
|
|
|
def slug(value) = value.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
|
|
|
|
def coordinate_key(coord)
|
|
lat, lon = coord
|
|
normalized_lon = lat.abs >= 89.999_999 ? 0.0 : lon % 360.0
|
|
[lat.round(10), normalized_lon.round(10)]
|
|
end
|
|
end
|
|
end
|