# frozen_string_literal: true require_relative "stand_surface" module MoonModel module Stand Result = Struct.new(:mesh, :statistics, keyword_init: true) module_function STYLE = "orbital_triskelion" ARM_COUNT = 3 PATH_STATIONS = 128 CROSS_SECTION_POINTS = 48 PAD_SEGMENTS = 96 SURFACE_RINGS = 48 def build(config, moon_profile) profile = normalize_profile(moon_profile) diameter, minimum_radius, maximum_radius = profile.values_at("envelope_mm", "minimum_radius_mm", "maximum_radius_mm") nozzle = Float(config["nozzle_mm"]) usable = config.build_volume.map { |v| v - 2 * config["edge_clearance_mm"] } width = [[0.035 * diameter, 8 * nozzle].max, 10.0].min height = [0.75 * width, 3 * nozzle].max desired = [0.68 * diameter, 12 * width].max footprint = [desired, usable.first(2).min].min raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8 * width raise ArgumentError, "terrain profile has invalid radial bounds" unless minimum_radius.positive? && maximum_radius >= minimum_radius contact_radius = [0.30 * diameter, 0.41 * footprint, 0.78 * minimum_radius].min hub_radius = [width, contact_radius * 0.25].min fork_port_distance = [width * 0.95, contact_radius * 0.12].min raise ArgumentError, "Moon is too small for the selected nozzle and stand geometry" unless contact_radius > hub_radius + width clearance = Float(config["stand"]["base_clearance_mm"]) gap = [1.0, 2 * nozzle].max center_z = maximum_radius + height + clearance sphere = ->(x, y) { center_z - Math.sqrt([minimum_radius**2 - x*x - y*y, 0.0].max) } contact_z = sphere.call(contact_radius, 0) pad_length = [2.8 * width, 0.12 * diameter, 24.0].min pad_width = [1.9 * width, 0.30 * minimum_radius].min edge_radius = [contact_radius + pad_width, minimum_radius * 0.98].min terrain_gap = Math.sqrt(maximum_radius**2 - edge_radius**2) - Math.sqrt(minimum_radius**2 - edge_radius**2) rim = terrain_gap + gap + height * 0.5 + 0.5 pad_depth = rim + height * 0.65 mesh = Mesh.new(name: "orbital_triskelion_stand", material: nil) surface = StandSurface.new(mesh, CROSS_SECTION_POINTS, SURFACE_RINGS) arm_section = lambda do |_x, _y, q, z| lift = surface.smooth([[(z - height * 0.5) / height, 0.0].max, 1.0].min) # Clip a broad chord at the table, blending to an oval once airborne. amplitude = height * 0.5 * (q < 0 ? 1 + 0.25 * (1-lift) : 1) [z + amplitude * q, 0.0].max end pad_section = ->(x, y, q, _z) { sphere.call(x, y) - rim + (q >= 0 ? rim : pad_depth - rim) * q } approach_section = ->(x, y, q, _z) { sphere.call(x, y) - rim + height * 0.5 * q } hub = { center: [0.0, 0.0, height * 0.5], ports: [], section: arm_section } forks = [] pads = [] ARM_COUNT.times do |i| angle = i * 2 * Math::PI / ARM_COUNT # Halfway along the curved hub-to-pad route, rather than half its # radial distance: broadens the three flat feet for tipping stability. forks << { center: polar(contact_radius * 0.72, angle + radians(12)) + [height * 0.48], ports: [], section: arm_section } pad_angle = angle + Math::PI / 3 xy = polar(contact_radius, pad_angle) pads << { center: xy + [sphere.call(*xy) - rim], ports: [], section: pad_section, angle: pad_angle } hub[:ports] << surface.port(hub, angle, hub_radius, width) end ARM_COUNT.times do |i| angle = i * 2 * Math::PI / ARM_COUNT fork = forks[i] trunk = surface.port(fork, angle + Math::PI, fork_port_distance, width) fork[:ports] << trunk surface.rail(hub[:ports][i], trunk, width, PATH_STATIONS, arm_section) [-1, 1].each do |side| pad = pads[side == 1 ? i : (i - 1) % ARM_COUNT] departure = surface.port(fork, angle + side * radians(62), fork_port_distance, width * 0.76) arrival = surface.port(pad, pad[:angle] - side * Math::PI / 2, pad_length * 0.50, pad_width * 0.70, section: approach_section) fork[:ports] << departure pad[:ports] << arrival surface.rail(departure, arrival, width * 0.76, PATH_STATIONS, arm_section) end end surface.junction(hub) forks.each { |fork| surface.junction(fork) } pads.each { |pad| surface.junction(pad, pad: true, width: pad_width, segments: PAD_SEGMENTS) } surface.orient! surface.fair_junctions! surface.validate! dimensions = mesh.bounds.map { |a, b| b - a } raise ArgumentError, "stand exceeds usable printer build volume" if dimensions.zip(usable).any? { |a, b| a > b + 1e-6 } measured_gap = validate_clearance!(mesh, pads, surface, maximum_radius, center_z, gap) Result.new(mesh: mesh, statistics: { "style" => STYLE, "dimensions_mm" => dimensions, "footprint_mm" => dimensions.first(2).max, "height_mm" => dimensions[2], "safety_model" => "terrain_envelope_v3", "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" => clearance, "guaranteed_hub_clearance_mm" => clearance, "guaranteed_moon_bottom_height_mm" => height + clearance, "moon_bottom_clearance_mm" => height + clearance, "noncontact_arm_clearance_mm" => gap, "measured_noncontact_clearance_mm" => measured_gap, "hub_height_mm" => height, "moon_center_height_mm" => center_z, "arm_width_mm" => width, "bed_capped" => footprint < desired - 1e-6, "connected_components" => 1, "branch_count" => 6, "pad_count" => 3, "path_stations" => PATH_STATIONS, "junction_blend" => "outward_fairing_v1", "junction_max_displacement_mm" => surface.fairing_displacement, "cross_section_points" => CROSS_SECTION_POINTS, "pad_perimeter_segments" => PAD_SEGMENTS, "pad_surface_rings" => SURFACE_RINGS, "pad_depth_mm" => pad_depth, "print_guidance" => "Print upright; local supports may be needed beneath elevated branches and pad cradles." }.merge(surface.overhang_statistics)) end def normalize_profile(profile) if profile.is_a?(Numeric) diameter = Float(profile) return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2, "maximum_radius_mm" => diameter / 2 } end values = profile.transform_keys(&:to_s) %w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h { |key| [key, Float(values.fetch(key))] } end def validate_clearance!(mesh, pads, surface, radius, center_z, gap) minimum = Float::INFINITY check = lambda do |x, y, z| next if pads.any? { |pad| surface.inside_junction?(pad, x, y) } next if x*x + y*y >= radius**2 minimum = [minimum, center_z - Math.sqrt(radius**2 - x*x - y*y) - z].min end mesh.vertices.each { |point| check.call(*point) } mesh.triangles.each do |triangle| a, b, c = triangle.map { |id| mesh.vertices[id] } check.call((a[0]+b[0]+c[0])/3, (a[1]+b[1]+c[1])/3, (a[2]+b[2]+c[2])/3) end raise ArgumentError, format("non-contact stand surface clears terrain by %.3f mm; %.3f mm required", minimum, gap) if minimum < gap - 1e-6 minimum end def polar(radius, angle) = [radius * Math.cos(angle), radius * Math.sin(angle)] def radians(degrees) = degrees * Math::PI / 180 end end