adjust the stand a bit

This commit is contained in:
Nick Estes
2026-09-08 11:50:32 -07:00
parent 59ab496f17
commit fd03a150ad
12 changed files with 854 additions and 219 deletions
+108 -206
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true
require_relative "stand_surface"
module MoonModel
module Stand
Result = Struct.new(:mesh, :statistics, keyword_init: true)
@@ -7,234 +9,134 @@ module MoonModel
STYLE = "orbital_triskelion"
ARM_COUNT = 3
PATH_STATIONS = 25
CROSS_SECTION_POINTS = 8
PATH_STATIONS = 128
CROSS_SECTION_POINTS = 48
PAD_SEGMENTS = 96
SURFACE_RINGS = 48
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")
diameter, minimum_radius, maximum_radius = profile.values_at("envelope_mm", "minimum_radius_mm", "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
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
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
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)
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)
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
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
}
)
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.0,
"maximum_radius_mm" => diameter / 2.0 }
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 do |key|
[key, Float(values.fetch(key))]
end
%w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h { |key| [key, Float(values.fetch(key))] }
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 }
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
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
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
if path.first[:bottom] > 1e-6
raise ArgumentError, "requested stand clearance cannot be reached with support-free arms inside this footprint"
end
path
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 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
def polar(radius, angle) = [radius * Math.cos(angle), radius * Math.sin(angle)]
def radians(degrees) = degrees * Math::PI / 180
end
end