143 lines
7.5 KiB
Ruby
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
|