Files
3d_globe/lib/moon_model/stand.rb
T
2026-09-08 11:50:32 -07:00

143 lines
7.5 KiB
Ruby

# 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