adjust the stand a bit
This commit is contained in:
@@ -147,7 +147,7 @@ module MoonModel
|
||||
dimensions = stand["dimensions_mm"].map { |value| format("%.2f", value) }.join(" × ")
|
||||
<<~TEXT
|
||||
- Stand style: Orbital triskelion
|
||||
- Stand safety model: Terrain envelope v2 (rotation independent)
|
||||
- Stand safety model: #{stand["safety_model"]} (rotation independent)
|
||||
- Stand dimensions: #{dimensions} mm
|
||||
- Conservative terrain radii: #{format("%.3f", stand["minimum_terrain_radius_mm"])}–#{format("%.3f", stand["maximum_terrain_radius_mm"])} mm
|
||||
- Stand contact radius/height: #{format("%.2f", stand["contact_radius_mm"])} / #{format("%.2f", stand["contact_height_mm"])} mm
|
||||
@@ -155,6 +155,9 @@ module MoonModel
|
||||
- Guaranteed Moon bottom height: #{format("%.2f", stand["guaranteed_moon_bottom_height_mm"])} mm
|
||||
- Non-contact arm clearance: at least #{format("%.2f", stand["noncontact_arm_clearance_mm"])} mm
|
||||
- Stand footprint capped to build area: #{stand["bed_capped"] ? "yes" : "no"}
|
||||
- Stand construction: one connected solid; six branches support three concave pads
|
||||
- Elevated underside area over 45° from vertical: #{format("%.1f", stand["overhang_area_above_45_deg_mm2"])} mm²
|
||||
- Stand printing: #{stand["print_guidance"]}
|
||||
TEXT
|
||||
else
|
||||
""
|
||||
@@ -252,6 +255,7 @@ module MoonModel
|
||||
"# Printing\n\nThe one-piece hollow Moon requires slicer-generated build-plate adhesion and support appropriate to the selected material. Internal supports may be difficult to remove and can break loose inside the finished Moon.\n"
|
||||
end
|
||||
end
|
||||
text += "\n## Stand\n\n#{result.statistics["stand"]["print_guidance"]} Check the sliced layers beneath the paired branches and flared pad cradles before printing.\n" if result.statistics["stand"]
|
||||
path = File.join(write_dir, "ASSEMBLY.md")
|
||||
File.write(path, text)
|
||||
path
|
||||
|
||||
+108
-206
@@ -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
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MoonModel
|
||||
# Open swept rails and shared-boundary junction patches, without overlapping
|
||||
# capped solids or a dependency on slicer repair or external mesh booleans.
|
||||
class StandSurface
|
||||
def initialize(mesh, section_points, radial_rings)
|
||||
@mesh, @n, @radial_rings = mesh, section_points, radial_rings
|
||||
@junctions = []
|
||||
@sides, @ring_sections = {}, {}
|
||||
end
|
||||
|
||||
def port(junction, angle, distance, width, section: junction[:section])
|
||||
cx, cy, cz = junction[:center]
|
||||
direction = [Math.cos(angle), Math.sin(angle)]
|
||||
center = [cx + distance * direction[0], cy + distance * direction[1], cz]
|
||||
result = { center: center, direction: direction, angle: angle % (2 * Math::PI), width: width,
|
||||
section: section }
|
||||
result[:ring] = ring(center, direction, width, section)
|
||||
result
|
||||
end
|
||||
|
||||
def ring(center, direction, width, section)
|
||||
@n.times.map do |i|
|
||||
theta = i * 2 * Math::PI / @n
|
||||
side = width * 0.5 * Math.cos(theta)
|
||||
x = center[0] - direction[1] * side
|
||||
y = center[1] + direction[0] * side
|
||||
sine = Math.sin(theta)
|
||||
id = @mesh.vertex([x, y, section.call(x, y, sine, center[2])])
|
||||
@sides[id] = sine.abs < 1e-8 ? 0 : (sine > 0 ? 1 : -1)
|
||||
@ring_sections[id] = [section, center[2]]
|
||||
id
|
||||
end
|
||||
end
|
||||
|
||||
def rail(from, to, width, stations, section)
|
||||
a, d = from[:center], to[:center]
|
||||
distance = Math.hypot(d[0] - a[0], d[1] - a[1])
|
||||
handle = distance * 0.42
|
||||
b = [a[0] + from[:direction][0] * handle, a[1] + from[:direction][1] * handle]
|
||||
c = [d[0] + to[:direction][0] * handle, d[1] + to[:direction][1] * handle]
|
||||
rings = [from[:ring]]
|
||||
(1...stations - 1).each do |i|
|
||||
t = i.to_f / (stations - 1)
|
||||
xy = bezier(a, b, c, d, t)
|
||||
tangent = 2.times.map { |axis| 3 * ((1-t)**2 * (b[axis]-a[axis]) + 2*(1-t)*t*(c[axis]-b[axis]) + t*t*(d[axis]-c[axis])) }
|
||||
norm = Math.hypot(*tangent)
|
||||
direction = tangent.map { |v| v / norm }
|
||||
center = xy + [a[2] + (d[2] - a[2]) * smooth(t)]
|
||||
flare = smooth([[((t - 0.65) / 0.35), 0.0].max, 1.0].min)
|
||||
w = width + (from[:width] - width) * (1 - smooth([t / 0.3, 1.0].min)) + (to[:width] - width) * flare
|
||||
interpolated = lambda do |x, y, q, z|
|
||||
ordinary = section.call(x, y, q, z)
|
||||
target = to[:section].call(x, y, q, d[2]) + z - d[2]
|
||||
ordinary + (target - ordinary) * flare
|
||||
end
|
||||
rings << ring(center, direction, w, interpolated)
|
||||
end
|
||||
# The arrival port points outward, opposite the rail tangent.
|
||||
rings << @n.times.map { |i| to[:ring][(@n / 2 - i) % @n] }
|
||||
rings.each_cons(2) { |first, second| join(first, second) }
|
||||
[[from, rings], [to, rings.reverse]].each do |port, ordered|
|
||||
port[:collar] = {}
|
||||
port[:collar_rings] = []
|
||||
ordered.each do |ids|
|
||||
center = 3.times.map { |axis| ids.sum { |id| @mesh.vertices[id][axis] } / ids.length }
|
||||
distance = Math.hypot(center[0]-port[:center][0], center[1]-port[:center][1])
|
||||
port[:collar_rings] << ids
|
||||
break if distance >= port[:width]
|
||||
weight = 1 - smooth(distance / port[:width])
|
||||
ids.each { |id| port[:collar][id] = weight }
|
||||
end
|
||||
end
|
||||
rings
|
||||
end
|
||||
|
||||
def junction(junction, pad: false, width: nil, segments: 96)
|
||||
first_vertex = @mesh.vertices.length
|
||||
junction[:rim_segments] = []
|
||||
ports = junction[:ports].sort_by { |p| p[:angle] }
|
||||
upper, lower, rounding = [], [], []
|
||||
ports.each_with_index do |port, index|
|
||||
(@n / 2).downto(0) do |j|
|
||||
upper << port[:ring][j]
|
||||
lower << port[:ring][(@n - j) % @n]
|
||||
rounding << 0.0
|
||||
end
|
||||
following = ports[(index + 1) % ports.length]
|
||||
a = @mesh.vertices[port[:ring][0]]
|
||||
d = @mesh.vertices[following[:ring][@n / 2]]
|
||||
span = Math.hypot(d[0] - a[0], d[1] - a[1])
|
||||
# Inward scallops at forks; outward flares along the saddle rim.
|
||||
handle = span * (pad ? 0.36 : 0.25)
|
||||
b = 2.times.map { |axis| a[axis] - port[:direction][axis] * handle }
|
||||
c = 2.times.map { |axis| d[axis] - following[:direction][axis] * handle }
|
||||
if pad
|
||||
b[0] -= port[:direction][1] * width * 0.20
|
||||
b[1] += port[:direction][0] * width * 0.20
|
||||
c[0] += following[:direction][1] * width * 0.20
|
||||
c[1] -= following[:direction][0] * width * 0.20
|
||||
end
|
||||
steps = pad ? segments / 2 : 32
|
||||
rim = [a.first(2)]
|
||||
(1...steps).each do |j|
|
||||
xy = bezier(a, b, c, d, j.to_f / steps)
|
||||
rim << xy
|
||||
id = @mesh.vertex(xy + [junction[:section].call(*xy, 0.0, junction[:center][2])])
|
||||
upper << id
|
||||
lower << id
|
||||
t = j.to_f / steps
|
||||
rounding << smooth([t / 0.15, 1.0].min) * smooth([(1-t) / 0.15, 1.0].min)
|
||||
end
|
||||
rim << d.first(2)
|
||||
junction[:rim_segments].concat(rim.each_cons(2).to_a)
|
||||
end
|
||||
junction[:outline] = upper.map { |id| @mesh.vertices[id].first(2) }
|
||||
junction[:bounds] = junction[:outline].transpose.map(&:minmax)
|
||||
patch(junction, upper, 1, pad, rounding)
|
||||
patch(junction, lower, -1, pad, rounding)
|
||||
unless pad
|
||||
junction[:surface_ids] = (first_vertex...@mesh.vertices.length).to_a + ports.flat_map { |port| port[:ring] }
|
||||
@junctions << junction
|
||||
end
|
||||
end
|
||||
|
||||
# Round the entire junction from its continuous exposed silhouette, rather
|
||||
# than independently rounding radial sectors. A one-arm-width quintic
|
||||
# collar carries this field across each port and back into the original
|
||||
# rail. Only outward height changes are allowed: XY, contact vertices and
|
||||
# minimum local thickness are preserved, with no tangential mesh folding.
|
||||
def fair_junctions!
|
||||
original = @mesh.vertices.map { |p| p[2] }
|
||||
@junctions.each do |junction|
|
||||
weights = junction[:surface_ids].to_h { |id| [id, 1.0] }
|
||||
segments = junction[:rim_segments].dup
|
||||
junction[:ports].each do |port|
|
||||
port.fetch(:collar, {}).each { |id, weight| weights[id] = [weights.fetch(id, 0.0), weight].max }
|
||||
port.fetch(:collar_rings, []).each_cons(2) do |a,b|
|
||||
[0, @n/2].each { |i| segments << [@mesh.vertices[a[i]].first(2), @mesh.vertices[b[i]].first(2)] }
|
||||
end
|
||||
end
|
||||
radius = junction[:ports].map { |p| p[:width] }.min * 0.325
|
||||
edges = segments.map do |a,b|
|
||||
dx, dy = b[0]-a[0], b[1]-a[1]
|
||||
[a[0], a[1], dx, dy, dx*dx+dy*dy]
|
||||
end
|
||||
weights.each do |id, weight|
|
||||
side = @sides.fetch(id, 0)
|
||||
next if side == 0 || original[id] < 1e-7 || weight < 1e-5
|
||||
x,y = @mesh.vertices[id]
|
||||
distance_squared = edges.map do |ax,ay,dx,dy,length_squared|
|
||||
t = [[((x-ax)*dx+(y-ay)*dy)/length_squared, 0.0].max, 1.0].min
|
||||
(x-ax-t*dx)**2 + (y-ay-t*dy)**2
|
||||
end.min
|
||||
ratio = [Math.sqrt(distance_squared)/radius, 1.0].min
|
||||
q = Math.sqrt(ratio*(2-ratio))
|
||||
section, center_z = @ring_sections.fetch(id) { [junction[:section], junction[:center][2]] }
|
||||
target = section.call(x, y, side*q, center_z)
|
||||
outward = [(target-original[id])*side, 0.0].max * weight
|
||||
candidate = original[id] + side*outward
|
||||
@mesh.vertices[id][2] = side > 0 ? [@mesh.vertices[id][2], candidate].max : [@mesh.vertices[id][2], candidate].min
|
||||
end
|
||||
end
|
||||
@fairing_displacement = @mesh.vertices.each_with_index.map { |p,i| (p[2]-original[i]).abs }.max
|
||||
end
|
||||
|
||||
attr_reader :fairing_displacement
|
||||
|
||||
def patch(junction, boundary, sign, pad, rounding)
|
||||
cx, cy, cz = junction[:center]
|
||||
section = junction[:section]
|
||||
center = @mesh.vertex([cx, cy, section.call(cx, cy, sign.to_f, cz)])
|
||||
@sides[center] = sign
|
||||
previous = nil
|
||||
(1...@radial_rings).each do |step|
|
||||
rho = Math.sin(step.to_f / @radial_rings * Math::PI / 2)
|
||||
current = boundary.each_with_index.map do |id, index|
|
||||
bx, by, bz = @mesh.vertices[id]
|
||||
x, y = cx + (bx - cx) * rho, cy + (by - cy) * rho
|
||||
equator = section.call(bx, by, 0.0, cz)
|
||||
amplitude = section.call(bx, by, sign.to_f, cz) - equator
|
||||
q = amplitude.abs < 1e-9 ? 0.0 : [[(bz - equator) / amplitude, 0.0].max, 1.0].min
|
||||
# Zero longitudinal slope at a rail port; round over vertically at
|
||||
# the exposed perimeter. The central saddle remains spherical.
|
||||
u = pad && sign == 1 ? [[(rho - 0.55) / 0.45, 0.0].max, 1.0].min : rho
|
||||
weight = (1 - smooth(u)) * (1 - rounding[index]) + (1 - u*u) * rounding[index]
|
||||
value = Math.sqrt([q*q + (1-q*q)*weight, 0.0].max)
|
||||
id = @mesh.vertex([x, y, section.call(x, y, sign * value, cz)])
|
||||
@sides[id] = sign
|
||||
id
|
||||
end
|
||||
if previous
|
||||
join(previous, current)
|
||||
else
|
||||
current.length.times { |j| @mesh.triangle(center, current[j], current[(j + 1) % current.length]) }
|
||||
end
|
||||
previous = current
|
||||
end
|
||||
join(previous, boundary)
|
||||
end
|
||||
|
||||
def join(first, second)
|
||||
first.length.times do |j|
|
||||
k = (j + 1) % first.length
|
||||
@mesh.quad(first[j], first[k], second[k], second[j])
|
||||
end
|
||||
end
|
||||
|
||||
def inside_junction?(junction, x, y)
|
||||
return false unless [x,y].zip(junction[:bounds]).all? { |v, (a,b)| v >= a-1e-7 && v <= b+1e-7 }
|
||||
points = junction[:outline]
|
||||
inside = false
|
||||
points.each_with_index do |a, i|
|
||||
b = points[(i + 1) % points.length]
|
||||
cross = (x-a[0])*(b[1]-a[1]) - (y-a[1])*(b[0]-a[0])
|
||||
return true if cross.abs < 1e-7 && x >= [a[0],b[0]].min-1e-7 && x <= [a[0],b[0]].max+1e-7 && y >= [a[1],b[1]].min-1e-7 && y <= [a[1],b[1]].max+1e-7
|
||||
next unless (a[1] > y) != (b[1] > y)
|
||||
inside = !inside if x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1]) + a[0]
|
||||
end
|
||||
inside
|
||||
end
|
||||
|
||||
def orient!
|
||||
edges = edge_faces
|
||||
raise ArgumentError, "stand has an open or nonmanifold edge" unless edges.values.all? { |uses| uses.length == 2 }
|
||||
adjacency = Array.new(@mesh.triangles.length) { [] }
|
||||
edges.each_value do |uses|
|
||||
(a, da), (b, db) = uses
|
||||
adjacency[a] << [b, da == db]
|
||||
adjacency[b] << [a, da == db]
|
||||
end
|
||||
flips = { 0 => false }
|
||||
queue = [0]
|
||||
cursor = 0
|
||||
while cursor < queue.length
|
||||
face = queue[cursor]
|
||||
cursor += 1
|
||||
adjacency[face].each do |other, opposite|
|
||||
value = flips[face] ^ opposite
|
||||
if flips.key?(other)
|
||||
raise ArgumentError, "stand surface is not orientable" unless flips[other] == value
|
||||
else
|
||||
flips[other] = value
|
||||
queue << other
|
||||
end
|
||||
end
|
||||
end
|
||||
raise ArgumentError, "stand contains disconnected surfaces" unless flips.length == @mesh.triangles.length
|
||||
@mesh.triangles.each_with_index { |tri, i| tri.reverse! if flips[i] }
|
||||
@mesh.triangles.each(&:reverse!) if signed_volume.negative?
|
||||
end
|
||||
|
||||
def validate!
|
||||
raise ArgumentError, "stand volume must be positive" unless signed_volume > 0
|
||||
@mesh.triangles.each do |tri|
|
||||
raise ArgumentError, "stand contains a degenerate triangle" if normal(tri).sum { |v| v*v } < 1e-18
|
||||
end
|
||||
end
|
||||
|
||||
def edge_faces
|
||||
edges = Hash.new { |h, k| h[k] = [] }
|
||||
@mesh.triangles.each_with_index do |tri, face|
|
||||
3.times do |j|
|
||||
a, b = tri[j], tri[(j+1)%3]
|
||||
edges[[a,b].minmax] << [face, a < b]
|
||||
end
|
||||
end
|
||||
edges
|
||||
end
|
||||
|
||||
def normal(tri)
|
||||
a, b, c = tri.map { |id| @mesh.vertices[id] }
|
||||
u = 3.times.map { |i| b[i]-a[i] }
|
||||
v = 3.times.map { |i| c[i]-a[i] }
|
||||
[u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]]
|
||||
end
|
||||
|
||||
def signed_volume
|
||||
@mesh.triangles.sum do |tri|
|
||||
a = @mesh.vertices[tri[0]]
|
||||
a.zip(normal(tri)).sum { |x, y| x*y } / 6.0
|
||||
end
|
||||
end
|
||||
|
||||
def overhang_statistics
|
||||
area = 0.0
|
||||
maximum = 0.0
|
||||
@mesh.triangles.each do |tri|
|
||||
next if tri.all? { |id| @mesh.vertices[id][2] < 0.05 }
|
||||
n = normal(tri)
|
||||
next unless n[2] < 0
|
||||
length = Math.sqrt(n.sum { |v| v*v })
|
||||
angle = Math.asin([[-n[2]/length, 0.0].max, 1.0].min) * 180 / Math::PI
|
||||
maximum = [maximum, angle].max
|
||||
area += length / 2 if angle > 45
|
||||
end
|
||||
{ "maximum_overhang_from_vertical_deg" => maximum, "overhang_area_above_45_deg_mm2" => area }
|
||||
end
|
||||
|
||||
def bezier(a, b, c, d, t)
|
||||
2.times.map { |i| (1-t)**3*a[i] + 3*(1-t)**2*t*b[i] + 3*(1-t)*t*t*c[i] + t**3*d[i] }
|
||||
end
|
||||
|
||||
def smooth(t) = t*t*t*(10 + t*(-15 + 6*t))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user