adjust the stand a bit
This commit is contained in:
@@ -19,7 +19,15 @@ The wizard first asks for a model name, then asks about the printer/tooling enve
|
||||
|
||||
## Output
|
||||
|
||||
One-piece runs create `moon.3mf`. Their interior defaults to a watertight solid volume so the slicer can apply balanced sparse infill without generating inaccessible cavity supports; `shell.interior: hollow` retains the thin-wall alternative. Segmented runs remain hollow and create eight print files, an assembled preview, and an assembly-key file. The optional one-piece, support-free orbital stand is emitted as `orbital_triskelion_stand.3mf` without a material assignment; its footprint scales with the Moon and is capped to the usable printer bed. Its saddles use conservative terrain-radius bounds so the Moon can be rotated freely while retaining a configurable 10 mm default clearance above the central hub. Every run includes its reusable YAML configuration, a slicer/spool manifest, and assembly instructions. Revisions are generated in a temporary directory and atomically replace the prior generated directory only after successful validation; unrelated directories are never overwritten. `--output DIR` continues to override the named default location.
|
||||
One-piece runs create `moon.3mf`. Their interior defaults to a watertight solid volume so the slicer can apply balanced sparse infill without generating inaccessible cavity supports; `shell.interior: hollow` retains the thin-wall alternative. Segmented runs remain hollow and create eight print files, an assembled preview, and an assembly-key file. The optional one-piece orbital stand is emitted as `orbital_triskelion_stand.3mf` without a material assignment; its footprint scales with the Moon and is capped to the usable printer bed. Three rounded stems fork toward neighboring pads, connecting each finely curved concave saddle to two branches through flared cradles. The stand is a single watertight mesh with flat table contact. Print it upright; small local supports may be needed under the elevated branches and pads. Inspect the slicer's layer and support previews. Its saddles use conservative terrain-radius bounds and a configurable 10 mm default clearance above the central hub; irregular terrain may still limit the actual contact patch. Every run includes its reusable YAML configuration, a slicer/spool manifest, and assembly instructions. Revisions are generated in a temporary directory and atomically replace the prior generated directory only after successful validation; unrelated directories are never overwritten. `--output DIR` continues to override the named default location.
|
||||
|
||||
To generate an updated stand separately from an existing Moon, use a new output directory:
|
||||
|
||||
```sh
|
||||
bundle exec ruby script/generate_stand.rb build/single_piece_4color/config.yml build/single_piece_4color_stand_organic
|
||||
```
|
||||
|
||||
This reuses the saved Moon sizing and terrain settings without regenerating or replacing its print files. The stand output includes its configuration, geometric clearance/overhang statistics, and printing notes.
|
||||
|
||||
Colors are closed surface inlay volumes over a continuous structural body. Palette entry order defines filament slots 1 through N. The 3MF records portable material names/display colors and Bambu Studio part names/slot assignments; confirm the numbered slots against physical AMS or external-spool filaments. The structural body and first-color surface remain separate bodies assigned to the same filament 1, so an N-color Moon may show N+1 bodies. The file has no fixed palette limit, though the printer and filament system will impose practical limits.
|
||||
|
||||
@@ -38,3 +46,5 @@ Developer visual QA renders front, back, left, right, top, bottom, and isometric
|
||||
```sh
|
||||
blender --background --python script/render_previews.py -- build/example/moon.3mf build/example/previews
|
||||
```
|
||||
|
||||
The renderer also accepts `--moon EXISTING_MOON.3mf --moon-center-z HEIGHT` for an assembled preview, or `--focus X Y Z --span MM` for junction details. `script/inspect_stand.py` independently checks an exported stand's connectivity, winding, table contact, and triangle intersections in Blender. `script/plot_sliced_stand.py` plots actual Bambu G-code model/support paths using NumPy and Matplotlib; it does not alter G-code or send it to a printer.
|
||||
|
||||
@@ -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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"error_string": "Success.",
|
||||
"export_time": 0,
|
||||
"layer_height": 0.0,
|
||||
"plate_index": 0,
|
||||
"prepare_time": 1,
|
||||
"return_code": 0,
|
||||
"sparse_infill_density": 0.0,
|
||||
"wall_loops": 0
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Regenerate only the stand, preserving the source model and its print files.
|
||||
# Usage: bundle exec ruby script/generate_stand.rb CONFIG NEW_OUTPUT_DIRECTORY
|
||||
require "bundler/setup"
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
require "moon_model"
|
||||
|
||||
abort "Usage: #{$PROGRAM_NAME} CONFIG NEW_OUTPUT_DIRECTORY" unless ARGV.length == 2
|
||||
source, output = ARGV.map { |path| File.expand_path(path) }
|
||||
abort "Output already exists: #{output}; choose a new directory" if File.exist?(output)
|
||||
config = MoonModel::Config.new(YAML.safe_load_file(source, aliases: false))
|
||||
sizing = MoonModel::Sizing.calculate(config, MoonModel::DataSet.new, validate_safety: false)
|
||||
profile = { "envelope_mm" => sizing.final_envelope_mm, "minimum_radius_mm" => sizing.minimum_radius_mm,
|
||||
"maximum_radius_mm" => sizing.maximum_radius_mm }
|
||||
result = MoonModel::Stand.build(config, profile)
|
||||
FileUtils.mkdir_p(File.dirname(output))
|
||||
temporary = Dir.mktmpdir(".stand-", File.dirname(output))
|
||||
begin
|
||||
MoonModel::ThreeMF.write(File.join(temporary, "orbital_triskelion_stand.3mf"), [result.mesh],
|
||||
metadata: { "Part" => "material-unassigned stand", "SourceConfig" => source }, palette: config["palette"])
|
||||
config.save(File.join(temporary, "config.yml"))
|
||||
File.write(File.join(temporary, "statistics.json"), JSON.pretty_generate(result.statistics) + "\n")
|
||||
File.write(File.join(temporary, "README.md"), <<~MD)
|
||||
# Revised orbital stand
|
||||
|
||||
Generated from `#{source}`. The source Moon and original stand were not modified.
|
||||
|
||||
Three curved stems fork toward neighboring pads; each concave pad joins two branches.
|
||||
The mesh is one connected, watertight, material-unassigned solid.
|
||||
Dimensions: #{result.statistics["dimensions_mm"].map { |v| format("%.2f", v) }.join(" × ")} mm.
|
||||
|
||||
#{result.statistics["print_guidance"]}
|
||||
Inspect the sliced layer preview before printing. Start with build-plate-only supports;
|
||||
adjust local support painting beneath the cradles if automatic supports wrap around them.
|
||||
The generator reports geometric overhangs, not a guarantee of a slicer's support layout.
|
||||
|
||||
The dish follows the conservative lunar sphere; terrain peaks may still limit actual contact.
|
||||
See `statistics.json` for sizing, clearance, connectivity, and overhang measurements.
|
||||
MD
|
||||
File.rename(temporary, output)
|
||||
ensure
|
||||
FileUtils.remove_entry(temporary) if File.directory?(temporary)
|
||||
end
|
||||
puts output
|
||||
puts JSON.pretty_generate(result.statistics)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Check an exported stand with Blender's independent triangle BVH.
|
||||
|
||||
blender --background --python-exit-code 1 --python script/inspect_stand.py -- stand.3mf report.json
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from mathutils import Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
source, output = sys.argv[sys.argv.index("--") + 1:]
|
||||
with zipfile.ZipFile(source) as package:
|
||||
root = ET.fromstring(package.read("3D/3dmodel.model"))
|
||||
ns = {"m": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"}
|
||||
meshes = root.findall("m:resources/m:object/m:mesh", ns)
|
||||
assert len(meshes) == 1, "Stand must contain exactly one mesh"
|
||||
mesh = meshes[0]
|
||||
vertices = [Vector(tuple(float(v.attrib[a]) for a in ("x", "y", "z")))
|
||||
for v in mesh.findall("m:vertices/m:vertex", ns)]
|
||||
faces = [tuple(int(t.attrib[a]) for a in ("v1", "v2", "v3"))
|
||||
for t in mesh.findall("m:triangles/m:triangle", ns)]
|
||||
edges = {}
|
||||
volume = 0.0
|
||||
flat_area = 0.0
|
||||
minimum_area = float("inf")
|
||||
for i, face in enumerate(faces):
|
||||
a, b, c = (vertices[j] for j in face)
|
||||
normal = (b-a).cross(c-a)
|
||||
area = normal.length / 2
|
||||
minimum_area = min(minimum_area, area)
|
||||
volume += a.dot(normal) / 6
|
||||
if max(abs(p.z) for p in (a, b, c)) < 1e-6:
|
||||
flat_area += area
|
||||
for j in range(3):
|
||||
u, v = face[j], face[(j+1) % 3]
|
||||
edges.setdefault(tuple(sorted((u, v))), []).append((i, u < v))
|
||||
assert all(len(uses) == 2 and uses[0][1] != uses[1][1] for uses in edges.values()), "Open edges or inconsistent winding"
|
||||
adjacency = [[] for _ in faces]
|
||||
for uses in edges.values():
|
||||
a, b = uses[0][0], uses[1][0]
|
||||
adjacency[a].append(b)
|
||||
adjacency[b].append(a)
|
||||
seen = {0}
|
||||
queue = [0]
|
||||
for face in queue:
|
||||
for other in adjacency[face]:
|
||||
if other not in seen:
|
||||
seen.add(other)
|
||||
queue.append(other)
|
||||
assert len(seen) == len(faces), "Disconnected shells"
|
||||
assert volume > 0 and minimum_area > 1e-10 and flat_area > 0
|
||||
tree = BVHTree.FromPolygons(vertices, faces, all_triangles=True, epsilon=0.0)
|
||||
intersections = []
|
||||
for a, b in tree.overlap(tree):
|
||||
if a >= b or set(faces[a]).intersection(faces[b]):
|
||||
continue
|
||||
intersections.append((a, b))
|
||||
report = {"vertices": len(vertices), "triangles": len(faces), "connected_components": 1,
|
||||
"watertight_consistent_winding": True, "signed_volume_mm3": volume,
|
||||
"table_contact_area_mm2": flat_area, "minimum_triangle_area_mm2": minimum_area,
|
||||
"nonadjacent_triangle_intersections": len(intersections), "intersection_examples": intersections[:10],
|
||||
"slicer_validation": "This script checks the mesh only; consult separate slicer results when available."}
|
||||
with open(output, "w") as stream:
|
||||
json.dump(report, stream, indent=2)
|
||||
print(json.dumps(report, indent=2))
|
||||
assert not intersections, "Nonadjacent triangles intersect"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Plot actual Bambu G-code extrusion paths, separating model and supports.
|
||||
|
||||
python3 script/plot_sliced_stand.py plate_1.gcode output_directory
|
||||
This is inspection only: no G-code is modified or sent to a printer.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.collections import LineCollection
|
||||
from mpl_toolkits.mplot3d.art3d import Line3DCollection
|
||||
import numpy as np
|
||||
|
||||
source, output = sys.argv[1:]
|
||||
os.makedirs(output, exist_ok=True)
|
||||
position = {a: 0.0 for a in "XYZE"}
|
||||
relative_e, relative_xyz = False, False
|
||||
feature = "Custom"
|
||||
segments = {"model": [], "support": []}
|
||||
with open(source) as stream:
|
||||
for line in stream:
|
||||
if line.startswith("; FEATURE:"):
|
||||
feature = line.partition(":")[2].strip()
|
||||
code = line.partition(";")[0].strip()
|
||||
if not code:
|
||||
continue
|
||||
command = code.split()[0]
|
||||
values = {a: float(v) for a, v in re.findall(r"([XYZEIJ])(-?(?:\d+(?:\.\d*)?|\.\d+))", code)}
|
||||
if command == "M83":
|
||||
relative_e = True
|
||||
elif command == "M82":
|
||||
relative_e = False
|
||||
elif command == "G91":
|
||||
relative_xyz = True
|
||||
elif command == "G90":
|
||||
relative_xyz = False
|
||||
elif command == "G92":
|
||||
position.update({a: v for a, v in values.items() if a in position})
|
||||
elif command in ("G0", "G1", "G2", "G3"):
|
||||
old = position.copy()
|
||||
for a in "XYZE":
|
||||
if a in values:
|
||||
relative = relative_e if a == "E" else relative_xyz
|
||||
position[a] = values[a] + (old[a] if relative else 0.0)
|
||||
if position["E"] <= old["E"] or feature in ("Custom", "Flush", "Prime tower", "Skirt"):
|
||||
continue
|
||||
start, end = [old[a] for a in "XYZ"], [position[a] for a in "XYZ"]
|
||||
points = [start, end]
|
||||
if command in ("G2", "G3") and ("I" in values or "J" in values):
|
||||
cx, cy = start[0] + values.get("I", 0), start[1] + values.get("J", 0)
|
||||
radius = math.hypot(start[0]-cx, start[1]-cy)
|
||||
a = math.atan2(start[1]-cy, start[0]-cx)
|
||||
b = math.atan2(end[1]-cy, end[0]-cx)
|
||||
sweep = (b-a) % (2*math.pi) if command == "G3" else -((a-b) % (2*math.pi))
|
||||
count = max(2, math.ceil(abs(sweep)*radius / 0.3))
|
||||
points = [[cx+radius*math.cos(a+sweep*t/count), cy+radius*math.sin(a+sweep*t/count),
|
||||
start[2]+(end[2]-start[2])*t/count] for t in range(count+1)]
|
||||
group = "support" if feature.startswith("Support") else "model"
|
||||
segments[group].extend(zip(points, points[1:]))
|
||||
|
||||
paths = {name: np.asarray(items).reshape(-1, 2, 3) for name, items in segments.items()}
|
||||
all_points = np.concatenate(list(paths.values())).reshape(-1, 3)
|
||||
low, high = all_points.min(axis=0), all_points.max(axis=0)
|
||||
colors = {"model": "#586879", "support": "#de790e"}
|
||||
for name, elevation, azimuth in [("iso", 25, -65), ("front", 0, -90), ("top", 90, -90)]:
|
||||
fig = plt.figure(figsize=(10, 8))
|
||||
ax = fig.add_subplot(projection="3d")
|
||||
for group, paths_for_group in paths.items():
|
||||
ax.add_collection3d(Line3DCollection(paths_for_group, colors=colors[group], linewidths=0.3, alpha=0.85))
|
||||
ax.set(xlim=(low[0], high[0]), ylim=(low[1], high[1]), zlim=(0, high[2]))
|
||||
ax.set_box_aspect(high-low)
|
||||
ax.view_init(elev=elevation, azim=azimuth)
|
||||
ax.set_title("Sliced stand — gray: model; orange: supports")
|
||||
ax.set_axis_off()
|
||||
fig.savefig(os.path.join(output, name+".png"), dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
fig, axes = plt.subplots(2, 3, figsize=(14, 9))
|
||||
for ax, z in zip(axes.flat, [0.2, 5.0, 15.0, 24.0, 30.0, 35.0]):
|
||||
for group, items in paths.items():
|
||||
subset = items[np.abs(items[:, 1, 2]-z) < 0.025]
|
||||
ax.add_collection(LineCollection(subset[:, :, :2], colors=colors[group], linewidths=0.5))
|
||||
ax.set(xlim=(low[0], high[0]), ylim=(low[1], high[1]), title=f"Z = {z:.1f} mm", aspect="equal")
|
||||
fig.suptitle("Actual sliced layers — gray: model; orange: supports")
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(output, "layers.png"), dpi=150)
|
||||
print(f"Plotted {sum(len(v) for v in segments.values())} extrusion segments in {output}")
|
||||
@@ -4,6 +4,7 @@ Usage: blender --background --python script/render_previews.py -- model.3mf outp
|
||||
"""
|
||||
|
||||
import math
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -13,12 +14,15 @@ import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
|
||||
def args():
|
||||
marker = sys.argv.index("--")
|
||||
return os.path.abspath(sys.argv[marker + 1]), os.path.abspath(sys.argv[marker + 2])
|
||||
|
||||
|
||||
source, output = args()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source")
|
||||
parser.add_argument("output")
|
||||
parser.add_argument("--moon", help="Existing Moon 3MF to show on the stand, without modifying it")
|
||||
parser.add_argument("--moon-center-z", type=float, default=0.0)
|
||||
parser.add_argument("--focus", nargs=3, type=float, help="Center a detail view at X Y Z")
|
||||
parser.add_argument("--span", type=float, help="Detail-view span in millimeters")
|
||||
options = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
|
||||
source, output = os.path.abspath(options.source), os.path.abspath(options.output)
|
||||
os.makedirs(output, exist_ok=True)
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
@@ -37,6 +41,7 @@ def import_3mf(path):
|
||||
material = bpy.data.materials.new(base.attrib.get("name", "material"))
|
||||
material.diffuse_color = rgba
|
||||
materials.append(material)
|
||||
imported = []
|
||||
for element in root.findall("m:resources/m:object", namespace):
|
||||
mesh_node = element.find("m:mesh", namespace)
|
||||
if mesh_node is None:
|
||||
@@ -50,11 +55,17 @@ def import_3mf(path):
|
||||
data.update()
|
||||
obj = bpy.data.objects.new(element.attrib.get("name", "object"), data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
imported.append(obj)
|
||||
if materials and "pindex" in element.attrib:
|
||||
obj.data.materials.append(materials[int(element.attrib["pindex"])])
|
||||
return imported
|
||||
|
||||
|
||||
import_3mf(source)
|
||||
if options.moon:
|
||||
for obj in import_3mf(options.moon):
|
||||
obj.location.z += options.moon_center_z
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
if not objects:
|
||||
@@ -67,6 +78,10 @@ minimum = Vector((min(p.x for p in corners), min(p.y for p in corners), min(p.z
|
||||
maximum = Vector((max(p.x for p in corners), max(p.y for p in corners), max(p.z for p in corners)))
|
||||
center = (minimum + maximum) / 2
|
||||
extent = max(maximum - minimum)
|
||||
if options.focus:
|
||||
center = Vector(options.focus)
|
||||
if options.span:
|
||||
extent = options.span
|
||||
|
||||
world = bpy.context.scene.world
|
||||
world.color = (0.035, 0.035, 0.035)
|
||||
@@ -86,7 +101,9 @@ camera.data.ortho_scale = extent * 1.25
|
||||
|
||||
for energy, direction in [(1800, Vector((1, -1, 2))), (900, Vector((-2, 1, 0.5)) )]:
|
||||
light_data = bpy.data.lights.new(name="Area", type="AREA")
|
||||
light_data.energy = energy
|
||||
# Imported coordinates are millimeters; scale light power with distance
|
||||
# squared so large models do not turn into unreadable silhouettes.
|
||||
light_data.energy = energy * extent * extent * 0.1
|
||||
light_data.shape = "DISK"
|
||||
light_data.size = extent
|
||||
light = bpy.data.objects.new(name="Area", object_data=light_data)
|
||||
|
||||
@@ -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