# frozen_string_literal: true require "test_helper" class StandTest < Minitest::Test def setup @config = MoonModel::Config.new("stand" => { "enabled" => true }) end def test_orbital_stand_is_printable_and_scales_for_common_moons [30.0, 120.0, 203.2].each do |diameter| result = MoonModel::Stand.build(@config, diameter) stats = result.statistics assert result.mesh.valid? assert_manifold result.mesh assert_equal "orbital_triskelion_stand", result.mesh.name assert_equal "orbital_triskelion", stats["style"] assert_in_delta 0.0, result.mesh.bounds[2][0], 1e-9 assert_connected_oriented_positive result.mesh assert_operator stats["measured_noncontact_clearance_mm"], :>=, stats["noncontact_arm_clearance_mm"] assert_operator stats["overhang_area_above_45_deg_mm2"], :>, 0 assert_operator stats["contact_radius_mm"], :<, diameter / 2.0 assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"] assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9 assert_in_delta stats["hub_height_mm"] + 10.0, stats["guaranteed_moon_bottom_height_mm"], 1e-9 refute stats["bed_capped"] end end def test_eight_inch_exaggerated_moon_clears_hub_in_any_rotation config = MoonModel::Config.new( "size" => { "input" => "diameter", "value" => 8.0, "unit" => "in", "meaning" => "datum" }, "vertical_exaggeration" => 5.0, "stand" => { "enabled" => true }, "resolution" => { "longitude_segments" => 96 } ) sizing = MoonModel::Sizing.calculate(config, MoonModel::DataSet.new) result = MoonModel::Stand.build(config, "envelope_mm" => sizing.final_envelope_mm, "minimum_radius_mm" => sizing.minimum_radius_mm, "maximum_radius_mm" => sizing.maximum_radius_mm) stats = result.statistics assert_equal "terrain_envelope_v3", stats["safety_model"] assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9 assert_operator stats["guaranteed_moon_bottom_height_mm"], :>=, stats["hub_height_mm"] + 10.0 assert_operator stats["contact_height_mm"], :>, 30.0 assert_operator stats["measured_noncontact_clearance_mm"], :>=, stats["noncontact_arm_clearance_mm"] end def test_custom_clearance_changes_guaranteed_gap config = MoonModel::Config.new("stand" => { "enabled" => true, "base_clearance_mm" => 4.0 }) result = MoonModel::Stand.build(config, 120.0) assert_in_delta 4.0, result.statistics["guaranteed_hub_clearance_mm"], 1e-9 end def test_large_moon_stand_is_capped_to_usable_bed result = MoonModel::Stand.build(@config, 463.0) usable = @config.build_volume.first(2).map { |value| value - 2.0 * @config["edge_clearance_mm"] } assert result.statistics["bed_capped"] assert_operator result.statistics["dimensions_mm"][0], :<=, usable[0] assert_operator result.statistics["dimensions_mm"][1], :<=, usable[1] assert_operator result.statistics["dimensions_mm"][2], :<=, @config.build_volume[2] end def test_legacy_stand_configuration_gets_new_style config = MoonModel::Config.new("stand" => { "enabled" => true, "material" => nil }) assert_equal "orbital_triskelion", config["stand"]["style"] assert_equal 10.0, config["stand"]["base_clearance_mm"] end def test_current_eight_inch_moon_has_three_concave_paired_pads result = MoonModel::Stand.build(@config, "envelope_mm" => 203.659589, "minimum_radius_mm" => 100.595822, "maximum_radius_mm" => 102.766127) stats = result.statistics assert_equal 6, stats["branch_count"] assert_equal 3, stats["pad_count"] assert_nil result.mesh.material assert_connected_oriented_positive result.mesh radius = stats["contact_radius_mm"] sphere = stats["minimum_terrain_radius_mm"] center_z = stats["moon_center_height_mm"] 3.times do |index| angle = Math::PI / 3 + index * 2 * Math::PI / 3 dish = result.mesh.vertices.filter_map do |x,y,z| radial = x*Math.cos(angle) + y*Math.sin(angle) - radius tangent = -x*Math.sin(angle) + y*Math.cos(angle) next unless radial.abs < 1e-6 && tangent.abs < 3.0 expected = center_z - Math.sqrt(sphere*sphere - x*x - y*y) [tangent,z] if (z - expected).abs < 1e-6 end center = dish.min_by { |t,_| t.abs } assert_in_delta stats["contact_height_mm"], center[1], 1e-6 [-1,1].each do |side| contact = dish.select { |t,_| t*side > 0.5 }.max_by { |t,_| t.abs } refute_nil contact, "dish needs curved surface samples on both sides" assert_operator contact[1], :>, center[1], "pad must form a bowl, not a planar fan" end end assert_operator stats["measured_noncontact_clearance_mm"], :>=, 1.0 end def test_curve_sampling_and_round_profile_resolve_below_thirty_microns mesh = MoonModel::Mesh.new(name: "curve_test") surface = MoonModel::StandSurface.new(mesh, MoonModel::Stand::CROSS_SECTION_POINTS, 48) section = ->(_x,_y,q,z) { z + 3*q } a = { center: [0.0,0.0,4.0], section: section } b = { center: [100.0,80.0,40.0], section: section } from = surface.port(a, 0, 0, 10) to = surface.port(b, Math::PI/2, 0, 10) rings = surface.rail(from, to, 10, MoonModel::Stand::PATH_STATIONS, section) centers = rings.map { |ring| 3.times.map { |axis| ring.sum { |id| mesh.vertices[id][axis] } / ring.length } } handle = Math.hypot(100,80)*0.42 errors = centers.each_cons(2).with_index.map do |(first,last),i| t = (i+0.5)/(centers.length-1) xy = surface.bezier(a[:center], [handle,0], [100,80+handle], b[:center], t) exact = xy + [4+36*surface.smooth(t)] Math.sqrt(3.times.sum { |axis| (exact[axis] - (first[axis]+last[axis])/2)**2 }) end assert_operator errors.max, :<, 0.03 assert_operator 5*(1-Math.cos(Math::PI / MoonModel::Stand::CROSS_SECTION_POINTS)), :<, 0.03 assert_operator centers[1][2]-centers[0][2], :<, 0.001 assert_operator centers[-1][2]-centers[-2][2], :<, 0.001 end def test_rejects_geometry_that_cannot_fit tiny_bed = MoonModel::Config.new("printer" => { "build_volume_mm" => [20,20,100] }) assert_raises(ArgumentError) { MoonModel::Stand.build(tiny_bed, 203.2) } assert_raises(ArgumentError) do MoonModel::Stand.build(@config, "envelope_mm" => 100, "minimum_radius_mm" => 50, "maximum_radius_mm" => 49) end end private def assert_connected_oriented_positive(mesh) neighbors = Array.new(mesh.vertices.length) { [] } directed_edges = Hash.new(0) area_on_table = 0.0 volume = 0.0 minimum_area = Float::INFINITY mesh.triangles.each do |tri| a, b, c = tri.map { |id| mesh.vertices[id] } u = b.zip(a).map { |x,y| x-y } v = c.zip(a).map { |x,y| x-y } normal = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]] area = Math.sqrt(normal.sum { |n| n*n }) / 2 minimum_area = [area, minimum_area].min area_on_table += area if [a,b,c].all? { |p| p[2].abs < 1e-9 } volume += a.zip(normal).sum { |x,y| x*y } / 6 3.times do |i| x, y = tri[i], tri[(i+1)%3] neighbors[x] << y neighbors[y] << x directed_edges[[x,y]] += 1 end end seen = { 0 => true } queue = [0] cursor = 0 while cursor < queue.length neighbors[queue[cursor]].each do |id| next if seen[id] seen[id] = true queue << id end cursor += 1 end assert_equal mesh.vertices.length, seen.length, "pads, branches, and hub must share one surface" assert directed_edges.all? { |(a,b), count| count == 1 && directed_edges[[b,a]] == 1 } assert_operator volume, :>, 0 assert_operator minimum_area, :>, 1e-10 assert_operator area_on_table, :>, 0 end def assert_manifold(mesh) edge_uses = Hash.new(0) mesh.triangles.each do |triangle| triangle.each_index do |index| edge = [triangle[index], triangle[(index + 1) % triangle.length]].sort edge_uses[edge] += 1 end end assert edge_uses.values.all? { |count| count == 2 }, "every mesh edge must belong to exactly two faces" end end