Files
2026-09-07 12:11:43 -07:00

263 lines
12 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)
interior = sizing.segmented? ? "hollow" : config["shell"]["interior"]
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, interior)
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,
"interior" => interior,
"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, interior)
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, interior)
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, interior)
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
solid = interior == "solid"
unified_base = solid || 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 = if solid && !multicolor && config["grid"]["enabled"]
coords.map do |lat, lon|
vertex_is_grid = on_grid?(config, lat, lon, radius, (a1 - a0).abs / 2.0, (o1 - o0).abs / 2.0)
point(config, data_set, radius, lat, lon, vertex_is_grid ? -config["grid"]["depth_mm"] : 0.0)
end
else
coords.map { |lat, lon| point(config, data_set, radius, lat, lon, surface_offset) }
end
inner = solid ? nil : 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
base.triangle(*outer_ids)
unless solid
inner_ids = coords.each_with_index.map do |coord, index|
inner_vertices[coordinate_key(coord)] ||= base.vertex(inner[index])
end
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
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
next if solid
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