308 lines
12 KiB
Ruby
308 lines
12 KiB
Ruby
# 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
|