# frozen_string_literal: true module MoonModel module Stand Result = Struct.new(:mesh, :statistics, keyword_init: true) module_function STYLE = "orbital_triskelion" ARM_COUNT = 3 PATH_STATIONS = 25 CROSS_SECTION_POINTS = 8 def build(config, moon_profile) profile = normalize_profile(moon_profile) diameter = profile.fetch("envelope_mm") minimum_radius = profile.fetch("minimum_radius_mm") maximum_radius = profile.fetch("maximum_radius_mm") nozzle = Float(config["nozzle_mm"]) usable_xy = config.build_volume.first(2).map { |value| value - 2.0 * config["edge_clearance_mm"] } arm_width = [[0.035 * diameter, 8.0 * nozzle].max, 10.0].min arm_height = [0.75 * arm_width, 3.0 * nozzle].max desired_footprint = [0.68 * diameter, 12.0 * arm_width].max footprint = [desired_footprint, usable_xy.min].min raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8.0 * arm_width capped = footprint < desired_footprint - 1e-6 footprint_radius = footprint / 2.0 outer_radius = footprint_radius - 0.65 * arm_width contact_radius = [0.30 * diameter, 0.82 * footprint_radius].min hub_radius = [2.2 * arm_width, [0.10 * diameter, 0.12 * footprint].min].max contact_radius = [contact_radius, hub_radius + 0.75 * arm_width].max contact_radius = [contact_radius, outer_radius - arm_width].min raise ArgumentError, "Moon is too small for the selected nozzle and stand geometry" unless contact_radius > hub_radius raise ArgumentError, "terrain profile has invalid radial bounds" unless minimum_radius.positive? && maximum_radius >= minimum_radius raise ArgumentError, "stand contact radius exceeds the conservative Moon radius" unless contact_radius < minimum_radius base_clearance = Float(config["stand"]["base_clearance_mm"]) approach_clearance = [1.0, 2.0 * nozzle].max moon_center_z = maximum_radius + arm_height + base_clearance contact_z = moon_center_z - Math.sqrt(minimum_radius**2 - contact_radius**2) pad_thickness = [4.0 * nozzle, 0.45 * arm_width].max pad_length = [[2.8 * arm_width, 0.12 * diameter].min, 24.0].min pad_width = [2.0 * arm_width, 2.0 * (0.94 * minimum_radius - contact_radius)].min pad_width = [pad_width, 1.5 * arm_width].max rail_end_top = contact_z - 0.55 * pad_thickness end_bottom = [rail_end_top - arm_height, 0.0].max mesh = Mesh.new(name: "orbital_triskelion_stand", material: nil) mesh.add_cylinder([0, 0, arm_height / 2.0], hub_radius, arm_height, segments: 48) maximum_slope = 0.0 ARM_COUNT.times do |arm_index| rotation = arm_index * 2.0 * Math::PI / ARM_COUNT path = arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) maximum_slope = [maximum_slope, path_slope(path)].max saddle_angle = rotation + radians(55.0) first_vertex = mesh.vertices.length add_swept_rail(mesh, path, arm_width, arm_height) rail_vertices = mesh.vertices[first_vertex..] validate_noncontact_clearance!(rail_vertices, maximum_radius, moon_center_z, contact_radius, saddle_angle, pad_length, pad_width, approach_clearance) add_saddle(mesh, minimum_radius, moon_center_z, contact_radius, saddle_angle, pad_length, pad_width, pad_thickness) end dimensions = mesh.bounds.map { |minimum, maximum| maximum - minimum } Result.new( mesh: mesh, statistics: { "style" => STYLE, "dimensions_mm" => dimensions, "footprint_mm" => dimensions.first(2).max, "height_mm" => dimensions[2], "safety_model" => "terrain_envelope_v2", "minimum_terrain_radius_mm" => minimum_radius, "maximum_terrain_radius_mm" => maximum_radius, "contact_radius_mm" => contact_radius, "contact_height_mm" => contact_z, "requested_base_clearance_mm" => base_clearance, "guaranteed_hub_clearance_mm" => base_clearance, "guaranteed_moon_bottom_height_mm" => arm_height + base_clearance, "moon_bottom_clearance_mm" => arm_height + base_clearance, "noncontact_arm_clearance_mm" => approach_clearance, "hub_height_mm" => arm_height, "moon_center_height_mm" => moon_center_z, "arm_width_mm" => arm_width, "bed_capped" => capped, "maximum_underside_slope" => maximum_slope } ) end def normalize_profile(profile) if profile.is_a?(Numeric) diameter = Float(profile) return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2.0, "maximum_radius_mm" => diameter / 2.0 } end values = profile.transform_keys(&:to_s) %w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h do |key| [key, Float(values.fetch(key))] end end def arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) path = PATH_STATIONS.times.map do |index| t = index / (PATH_STATIONS - 1.0) if t <= 0.58 local = smoothstep(t / 0.58) radius = lerp(hub_radius * 0.68, outer_radius, local) else local = smoothstep((t - 0.58) / 0.42) radius = lerp(outer_radius, contact_radius, local) end angle = rotation + radians(-25.0 + 80.0 * t) { x: radius * Math.cos(angle), y: radius * Math.sin(angle), height: arm_height } end distance_to_end = 0.0 (path.length - 1).downto(0) do |index| if index < path.length - 1 distance_to_end += Math.hypot(path[index + 1][:x] - path[index][:x], path[index + 1][:y] - path[index][:y]) end path[index][:bottom] = [end_bottom - 0.999 * distance_to_end, 0.0].max end if path.first[:bottom] > 1e-6 raise ArgumentError, "requested stand clearance cannot be reached with support-free arms inside this footprint" end path end def validate_noncontact_clearance!(vertices, maximum_radius, moon_center_z, contact_radius, saddle_angle, pad_length, pad_width, required_clearance) minimum_gap = Float::INFINITY vertices.each do |x, y, z| next if beneath_saddle?(x, y, contact_radius, saddle_angle, pad_length, pad_width) radial = Math.hypot(x, y) next if radial >= maximum_radius moon_z = moon_center_z - Math.sqrt(maximum_radius**2 - radial**2) minimum_gap = [minimum_gap, moon_z - z].min end return if minimum_gap >= required_clearance - 1e-6 raise ArgumentError, format("support-free arm would approach the worst-case terrain by %.2f mm; %.2f mm is required", minimum_gap, required_clearance) end def beneath_saddle?(x, y, contact_radius, angle, length, width) center_x = contact_radius * Math.cos(angle) center_y = contact_radius * Math.sin(angle) dx = x - center_x dy = y - center_y tangent_offset = dx * -Math.sin(angle) + dy * Math.cos(angle) radial_offset = dx * Math.cos(angle) + dy * Math.sin(angle) (tangent_offset / (0.52 * length))**2 + (radial_offset / (0.52 * width))**2 <= 1.0 end def add_swept_rail(mesh, path, width, height) rings = path.each_with_index.map do |point, index| previous = path[[index - 1, 0].max] following = path[[index + 1, path.length - 1].min] dx = following[:x] - previous[:x] dy = following[:y] - previous[:y] length = Math.hypot(dx, dy) px = -dy / length py = dx / length cross_section(width, height).map do |side, z| mesh.vertex([point[:x] + px * side, point[:y] + py * side, point[:bottom] + z]) end end rings.each_cons(2) do |first, second| CROSS_SECTION_POINTS.times do |index| following = (index + 1) % CROSS_SECTION_POINTS mesh.quad(first[index], first[following], second[following], second[index]) end end cap_ring(mesh, rings.first, reverse: true) cap_ring(mesh, rings.last, reverse: false) end def cross_section(width, height) [[-0.30 * width, 0.0], [0.30 * width, 0.0], [0.50 * width, 0.25 * height], [0.50 * width, 0.75 * height], [0.30 * width, height], [-0.30 * width, height], [-0.50 * width, 0.75 * height], [-0.50 * width, 0.25 * height]] end def cap_ring(mesh, ring, reverse:) center = mesh.vertex(3.times.map { |axis| ring.sum { |id| mesh.vertices[id][axis] } / ring.length.to_f }) ring.length.times do |index| following = (index + 1) % ring.length reverse ? mesh.triangle(center, ring[following], ring[index]) : mesh.triangle(center, ring[index], ring[following]) end end def add_saddle(mesh, sphere_radius, moon_center_z, contact_radius, angle, length, width, thickness) tangent = [-Math.sin(angle), Math.cos(angle)] radial = [Math.cos(angle), Math.sin(angle)] segments = 24 top_center = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, 0.0, 0.0, tangent, radial) top = mesh.vertex(top_center) bottom = mesh.vertex([top_center[0], top_center[1], top_center[2] - thickness]) top_ring = [] bottom_ring = [] segments.times do |index| theta = index * 2.0 * Math::PI / segments u = Math.cos(theta) * length / 2.0 v = Math.sin(theta) * width / 2.0 point = saddle_point(sphere_radius, moon_center_z, contact_radius, angle, u, v, tangent, radial) top_ring << mesh.vertex(point) bottom_ring << mesh.vertex([point[0], point[1], point[2] - thickness]) end segments.times do |index| following = (index + 1) % segments mesh.triangle(top, top_ring[index], top_ring[following]) mesh.triangle(bottom, bottom_ring[following], bottom_ring[index]) mesh.quad(top_ring[index], bottom_ring[index], bottom_ring[following], top_ring[following]) end end def saddle_point(sphere_radius, moon_center_z, contact_radius, angle, tangent_offset, radial_offset, tangent, radial) x = contact_radius * Math.cos(angle) + tangent[0] * tangent_offset + radial[0] * radial_offset y = contact_radius * Math.sin(angle) + tangent[1] * tangent_offset + radial[1] * radial_offset z = moon_center_z - Math.sqrt([sphere_radius**2 - x**2 - y**2, 0.0].max) [x, y, z] end def path_slope(path) path.each_cons(2).map do |first, second| horizontal = Math.hypot(second[:x] - first[:x], second[:y] - first[:y]) horizontal.zero? ? 0.0 : (second[:bottom] - first[:bottom]).abs / horizontal end.max || 0.0 end def smoothstep(value) = value * value * (3.0 - 2.0 * value) def lerp(from, to, fraction) = from + (to - from) * fraction def radians(degrees) = degrees * Math::PI / 180.0 end end