adjust the stand a bit
This commit is contained in:
@@ -41,7 +41,9 @@ class GeneratorTest < Minitest::Test
|
||||
assert File.file?(File.join(result[:output_dir], "orbital_triskelion_stand.3mf"))
|
||||
manifest = File.read(File.join(result[:output_dir], "manifest.md"))
|
||||
assert_includes manifest, "Stand style: Orbital triskelion"
|
||||
assert_includes manifest, "Terrain envelope v2 (rotation independent)"
|
||||
assert_includes manifest, "terrain_envelope_v3 (rotation independent)"
|
||||
assert_includes manifest, "one connected solid; six branches support three concave pads"
|
||||
assert_includes manifest, "local supports may be needed"
|
||||
assert_includes manifest, "Guaranteed Moon clearance above hub/base: 10.00 mm"
|
||||
assert_includes manifest, "Stand dimensions:"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class StandSurfaceTest < Minitest::Test
|
||||
def test_fuller_blends_remove_port_creases_without_thinning_or_moving_feet
|
||||
[[6.0,6.0,6.0], [6.0,4.56,4.56]].each do |widths|
|
||||
mesh, surface, ports, ends = junction_fixture(widths)
|
||||
before = Marshal.load(Marshal.dump(mesh))
|
||||
before_angles = port_angles(before, ports)
|
||||
before_volume = surface.signed_volume
|
||||
surface.fair_junctions!
|
||||
surface.validate!
|
||||
after_angles = port_angles(mesh, ports)
|
||||
|
||||
assert_operator after_angles.max, :<, before_angles.max * 0.40
|
||||
# A 48-sided profile has 7.5-degree facets; compare actual flat-shaded
|
||||
# triangle normals, allowing that discretization but not the old crease.
|
||||
assert_operator after_angles.max, :<, 10.0, "avoid sharp creases across the arm openings"
|
||||
collars = ports.map { |port| { ring: port[:collar_rings].last } }
|
||||
assert_operator port_angles(mesh, collars).max, :<, 1.0, "the blend must taper smoothly into the unchanged rail"
|
||||
assert_operator surface.signed_volume, :>, before_volume
|
||||
assert mesh.vertices.each_with_index.all? { |p,i| p.first(2) == before.vertices[i].first(2) }, "preserve the footprint and routes"
|
||||
assert before.vertices.each_with_index.all? { |p,i| p[2] > 1e-7 || mesh.vertices[i] == p }, "hold every table-contact vertex fixed"
|
||||
assert ends.flatten.all? { |id| mesh.vertices[id] == before.vertices[id] }, "outer collar boundaries must stay fixed"
|
||||
sides = surface.instance_variable_get(:@sides)
|
||||
assert mesh.vertices.each_with_index.all? { |p,i| (p[2]-before.vertices[i][2])*sides.fetch(i,0) >= -1e-9 }, "only add thickness"
|
||||
assert_equal before.triangles, mesh.triangles, "retain the connected topology"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def junction_fixture(widths)
|
||||
mesh = MoonModel::Mesh.new(name: "junction_fixture")
|
||||
surface = MoonModel::StandSurface.new(mesh, 48, 48)
|
||||
section = ->(_x,_y,q,z) { [z + (q < 0 ? 3.75 : 3.0)*q, 0.0].max }
|
||||
junction = { center: [0.0,0.0,3.0], section: section, ports: [] }
|
||||
ends = []
|
||||
widths.each_with_index do |width,i|
|
||||
angle = i*2*Math::PI/3
|
||||
port = surface.port(junction, angle, 6.0, width)
|
||||
junction[:ports] << port
|
||||
finish = { center: [18*Math.cos(angle),18*Math.sin(angle),3.0], section: section }
|
||||
terminal = surface.port(finish, angle+Math::PI, 0, width)
|
||||
surface.rail(port, terminal, width, 128, section)
|
||||
ends << terminal[:ring]
|
||||
center = mesh.vertex(finish[:center])
|
||||
48.times { |j| mesh.triangle(center, terminal[:ring][j], terminal[:ring][(j+1)%48]) }
|
||||
end
|
||||
surface.junction(junction)
|
||||
surface.orient!
|
||||
[mesh, surface, junction[:ports], ends]
|
||||
end
|
||||
|
||||
def port_angles(mesh, ports)
|
||||
geometry = MoonModel::StandSurface.new(mesh, 48, 24)
|
||||
edges = geometry.edge_faces
|
||||
ports.flat_map do |port|
|
||||
# Exclude the silhouette and intentional flat-bottom edges.
|
||||
(2...22).map do |index|
|
||||
ids = [port[:ring][index],port[:ring][index+1]].minmax
|
||||
normals = edges.fetch(ids).map do |face,_|
|
||||
n = geometry.normal(mesh.triangles[face])
|
||||
length = Math.sqrt(n.sum { |v| v*v })
|
||||
n.map { |v| v/length }
|
||||
end
|
||||
cosine = normals[0].zip(normals[1]).sum { |a,b| a*b }
|
||||
Math.acos([[cosine,-1.0].max,1.0].min)*180/Math::PI
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+108
-3
@@ -17,7 +17,9 @@ class StandTest < Minitest::Test
|
||||
assert_equal "orbital_triskelion_stand", result.mesh.name
|
||||
assert_equal "orbital_triskelion", stats["style"]
|
||||
assert_in_delta 0.0, result.mesh.bounds[2][0], 1e-9
|
||||
assert_operator stats["maximum_underside_slope"], :<=, 1.0
|
||||
assert_connected_oriented_positive result.mesh
|
||||
assert_operator stats["measured_noncontact_clearance_mm"], :>=, stats["noncontact_arm_clearance_mm"]
|
||||
assert_operator stats["overhang_area_above_45_deg_mm2"], :>, 0
|
||||
assert_operator stats["contact_radius_mm"], :<, diameter / 2.0
|
||||
assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"]
|
||||
assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9
|
||||
@@ -40,11 +42,11 @@ class StandTest < Minitest::Test
|
||||
"maximum_radius_mm" => sizing.maximum_radius_mm)
|
||||
stats = result.statistics
|
||||
|
||||
assert_equal "terrain_envelope_v2", stats["safety_model"]
|
||||
assert_equal "terrain_envelope_v3", stats["safety_model"]
|
||||
assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9
|
||||
assert_operator stats["guaranteed_moon_bottom_height_mm"], :>=, stats["hub_height_mm"] + 10.0
|
||||
assert_operator stats["contact_height_mm"], :>, 30.0
|
||||
assert_operator stats["maximum_underside_slope"], :<=, 1.0
|
||||
assert_operator stats["measured_noncontact_clearance_mm"], :>=, stats["noncontact_arm_clearance_mm"]
|
||||
end
|
||||
|
||||
def test_custom_clearance_changes_guaranteed_gap
|
||||
@@ -71,8 +73,111 @@ class StandTest < Minitest::Test
|
||||
assert_equal 10.0, config["stand"]["base_clearance_mm"]
|
||||
end
|
||||
|
||||
def test_current_eight_inch_moon_has_three_concave_paired_pads
|
||||
result = MoonModel::Stand.build(@config, "envelope_mm" => 203.659589,
|
||||
"minimum_radius_mm" => 100.595822,
|
||||
"maximum_radius_mm" => 102.766127)
|
||||
stats = result.statistics
|
||||
assert_equal 6, stats["branch_count"]
|
||||
assert_equal 3, stats["pad_count"]
|
||||
assert_nil result.mesh.material
|
||||
assert_connected_oriented_positive result.mesh
|
||||
radius = stats["contact_radius_mm"]
|
||||
sphere = stats["minimum_terrain_radius_mm"]
|
||||
center_z = stats["moon_center_height_mm"]
|
||||
3.times do |index|
|
||||
angle = Math::PI / 3 + index * 2 * Math::PI / 3
|
||||
dish = result.mesh.vertices.filter_map do |x,y,z|
|
||||
radial = x*Math.cos(angle) + y*Math.sin(angle) - radius
|
||||
tangent = -x*Math.sin(angle) + y*Math.cos(angle)
|
||||
next unless radial.abs < 1e-6 && tangent.abs < 3.0
|
||||
expected = center_z - Math.sqrt(sphere*sphere - x*x - y*y)
|
||||
[tangent,z] if (z - expected).abs < 1e-6
|
||||
end
|
||||
center = dish.min_by { |t,_| t.abs }
|
||||
assert_in_delta stats["contact_height_mm"], center[1], 1e-6
|
||||
[-1,1].each do |side|
|
||||
contact = dish.select { |t,_| t*side > 0.5 }.max_by { |t,_| t.abs }
|
||||
refute_nil contact, "dish needs curved surface samples on both sides"
|
||||
assert_operator contact[1], :>, center[1], "pad must form a bowl, not a planar fan"
|
||||
end
|
||||
end
|
||||
assert_operator stats["measured_noncontact_clearance_mm"], :>=, 1.0
|
||||
end
|
||||
|
||||
def test_curve_sampling_and_round_profile_resolve_below_thirty_microns
|
||||
mesh = MoonModel::Mesh.new(name: "curve_test")
|
||||
surface = MoonModel::StandSurface.new(mesh, MoonModel::Stand::CROSS_SECTION_POINTS, 48)
|
||||
section = ->(_x,_y,q,z) { z + 3*q }
|
||||
a = { center: [0.0,0.0,4.0], section: section }
|
||||
b = { center: [100.0,80.0,40.0], section: section }
|
||||
from = surface.port(a, 0, 0, 10)
|
||||
to = surface.port(b, Math::PI/2, 0, 10)
|
||||
rings = surface.rail(from, to, 10, MoonModel::Stand::PATH_STATIONS, section)
|
||||
centers = rings.map { |ring| 3.times.map { |axis| ring.sum { |id| mesh.vertices[id][axis] } / ring.length } }
|
||||
handle = Math.hypot(100,80)*0.42
|
||||
errors = centers.each_cons(2).with_index.map do |(first,last),i|
|
||||
t = (i+0.5)/(centers.length-1)
|
||||
xy = surface.bezier(a[:center], [handle,0], [100,80+handle], b[:center], t)
|
||||
exact = xy + [4+36*surface.smooth(t)]
|
||||
Math.sqrt(3.times.sum { |axis| (exact[axis] - (first[axis]+last[axis])/2)**2 })
|
||||
end
|
||||
assert_operator errors.max, :<, 0.03
|
||||
assert_operator 5*(1-Math.cos(Math::PI / MoonModel::Stand::CROSS_SECTION_POINTS)), :<, 0.03
|
||||
assert_operator centers[1][2]-centers[0][2], :<, 0.001
|
||||
assert_operator centers[-1][2]-centers[-2][2], :<, 0.001
|
||||
end
|
||||
|
||||
def test_rejects_geometry_that_cannot_fit
|
||||
tiny_bed = MoonModel::Config.new("printer" => { "build_volume_mm" => [20,20,100] })
|
||||
assert_raises(ArgumentError) { MoonModel::Stand.build(tiny_bed, 203.2) }
|
||||
assert_raises(ArgumentError) do
|
||||
MoonModel::Stand.build(@config, "envelope_mm" => 100, "minimum_radius_mm" => 50, "maximum_radius_mm" => 49)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_connected_oriented_positive(mesh)
|
||||
neighbors = Array.new(mesh.vertices.length) { [] }
|
||||
directed_edges = Hash.new(0)
|
||||
area_on_table = 0.0
|
||||
volume = 0.0
|
||||
minimum_area = Float::INFINITY
|
||||
mesh.triangles.each do |tri|
|
||||
a, b, c = tri.map { |id| mesh.vertices[id] }
|
||||
u = b.zip(a).map { |x,y| x-y }
|
||||
v = c.zip(a).map { |x,y| x-y }
|
||||
normal = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]]
|
||||
area = Math.sqrt(normal.sum { |n| n*n }) / 2
|
||||
minimum_area = [area, minimum_area].min
|
||||
area_on_table += area if [a,b,c].all? { |p| p[2].abs < 1e-9 }
|
||||
volume += a.zip(normal).sum { |x,y| x*y } / 6
|
||||
3.times do |i|
|
||||
x, y = tri[i], tri[(i+1)%3]
|
||||
neighbors[x] << y
|
||||
neighbors[y] << x
|
||||
directed_edges[[x,y]] += 1
|
||||
end
|
||||
end
|
||||
seen = { 0 => true }
|
||||
queue = [0]
|
||||
cursor = 0
|
||||
while cursor < queue.length
|
||||
neighbors[queue[cursor]].each do |id|
|
||||
next if seen[id]
|
||||
seen[id] = true
|
||||
queue << id
|
||||
end
|
||||
cursor += 1
|
||||
end
|
||||
assert_equal mesh.vertices.length, seen.length, "pads, branches, and hub must share one surface"
|
||||
assert directed_edges.all? { |(a,b), count| count == 1 && directed_edges[[b,a]] == 1 }
|
||||
assert_operator volume, :>, 0
|
||||
assert_operator minimum_area, :>, 1e-10
|
||||
assert_operator area_on_table, :>, 0
|
||||
end
|
||||
|
||||
def assert_manifold(mesh)
|
||||
edge_uses = Hash.new(0)
|
||||
mesh.triangles.each do |triangle|
|
||||
|
||||
Reference in New Issue
Block a user