# 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