adjust the stand a bit

This commit is contained in:
Nick Estes
2026-09-08 11:50:32 -07:00
parent 59ab496f17
commit fd03a150ad
12 changed files with 854 additions and 219 deletions
+11 -1
View File
@@ -19,7 +19,15 @@ The wizard first asks for a model name, then asks about the printer/tooling enve
## Output ## 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. 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 ```sh
blender --background --python script/render_previews.py -- build/example/moon.3mf build/example/previews 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.
+5 -1
View File
@@ -147,7 +147,7 @@ module MoonModel
dimensions = stand["dimensions_mm"].map { |value| format("%.2f", value) }.join(" × ") dimensions = stand["dimensions_mm"].map { |value| format("%.2f", value) }.join(" × ")
<<~TEXT <<~TEXT
- Stand style: Orbital triskelion - Stand style: Orbital triskelion
- Stand safety model: Terrain envelope v2 (rotation independent) - Stand safety model: #{stand["safety_model"]} (rotation independent)
- Stand dimensions: #{dimensions} mm - Stand dimensions: #{dimensions} mm
- Conservative terrain radii: #{format("%.3f", stand["minimum_terrain_radius_mm"])}–#{format("%.3f", stand["maximum_terrain_radius_mm"])} 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 - 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 - 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 - 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 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 TEXT
else 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" "# 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
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") path = File.join(write_dir, "ASSEMBLY.md")
File.write(path, text) File.write(path, text)
path path
+108 -206
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "stand_surface"
module MoonModel module MoonModel
module Stand module Stand
Result = Struct.new(:mesh, :statistics, keyword_init: true) Result = Struct.new(:mesh, :statistics, keyword_init: true)
@@ -7,234 +9,134 @@ module MoonModel
STYLE = "orbital_triskelion" STYLE = "orbital_triskelion"
ARM_COUNT = 3 ARM_COUNT = 3
PATH_STATIONS = 25 PATH_STATIONS = 128
CROSS_SECTION_POINTS = 8 CROSS_SECTION_POINTS = 48
PAD_SEGMENTS = 96
SURFACE_RINGS = 48
def build(config, moon_profile) def build(config, moon_profile)
profile = normalize_profile(moon_profile) profile = normalize_profile(moon_profile)
diameter = profile.fetch("envelope_mm") diameter, minimum_radius, maximum_radius = profile.values_at("envelope_mm", "minimum_radius_mm", "maximum_radius_mm")
minimum_radius = profile.fetch("minimum_radius_mm")
maximum_radius = profile.fetch("maximum_radius_mm")
nozzle = Float(config["nozzle_mm"]) nozzle = Float(config["nozzle_mm"])
usable_xy = config.build_volume.first(2).map { |value| value - 2.0 * config["edge_clearance_mm"] } usable = config.build_volume.map { |v| v - 2 * config["edge_clearance_mm"] }
arm_width = [[0.035 * diameter, 8.0 * nozzle].max, 10.0].min width = [[0.035 * diameter, 8 * nozzle].max, 10.0].min
arm_height = [0.75 * arm_width, 3.0 * nozzle].max height = [0.75 * width, 3 * nozzle].max
desired_footprint = [0.68 * diameter, 12.0 * arm_width].max desired = [0.68 * diameter, 12 * width].max
footprint = [desired_footprint, usable_xy.min].min footprint = [desired, usable.first(2).min].min
raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8.0 * arm_width raise ArgumentError, "printer build area is too small for a printable stand" if footprint < 8 * 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
raise ArgumentError, "terrain profile has invalid radial bounds" unless minimum_radius.positive? && maximum_radius >= minimum_radius 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"]) contact_radius = [0.30 * diameter, 0.41 * footprint, 0.78 * minimum_radius].min
approach_clearance = [1.0, 2.0 * nozzle].max hub_radius = [width, contact_radius * 0.25].min
moon_center_z = maximum_radius + arm_height + base_clearance fork_port_distance = [width * 0.95, contact_radius * 0.12].min
contact_z = moon_center_z - Math.sqrt(minimum_radius**2 - contact_radius**2) raise ArgumentError, "Moon is too small for the selected nozzle and stand geometry" unless contact_radius > hub_radius + width
pad_thickness = [4.0 * nozzle, 0.45 * arm_width].max clearance = Float(config["stand"]["base_clearance_mm"])
pad_length = [[2.8 * arm_width, 0.12 * diameter].min, 24.0].min gap = [1.0, 2 * nozzle].max
pad_width = [2.0 * arm_width, 2.0 * (0.94 * minimum_radius - contact_radius)].min center_z = maximum_radius + height + clearance
pad_width = [pad_width, 1.5 * arm_width].max sphere = ->(x, y) { center_z - Math.sqrt([minimum_radius**2 - x*x - y*y, 0.0].max) }
rail_end_top = contact_z - 0.55 * pad_thickness contact_z = sphere.call(contact_radius, 0)
end_bottom = [rail_end_top - arm_height, 0.0].max 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 = Mesh.new(name: "orbital_triskelion_stand", material: nil)
mesh.add_cylinder([0, 0, arm_height / 2.0], hub_radius, arm_height, segments: 48) surface = StandSurface.new(mesh, CROSS_SECTION_POINTS, SURFACE_RINGS)
maximum_slope = 0.0 arm_section = lambda do |_x, _y, q, z|
ARM_COUNT.times do |arm_index| lift = surface.smooth([[(z - height * 0.5) / height, 0.0].max, 1.0].min)
rotation = arm_index * 2.0 * Math::PI / ARM_COUNT # Clip a broad chord at the table, blending to an oval once airborne.
path = arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) amplitude = height * 0.5 * (q < 0 ? 1 + 0.25 * (1-lift) : 1)
maximum_slope = [maximum_slope, path_slope(path)].max [z + amplitude * q, 0.0].max
saddle_angle = rotation + radians(55.0) end
first_vertex = mesh.vertices.length pad_section = ->(x, y, q, _z) { sphere.call(x, y) - rim + (q >= 0 ? rim : pad_depth - rim) * q }
add_swept_rail(mesh, path, arm_width, arm_height) approach_section = ->(x, y, q, _z) { sphere.call(x, y) - rim + height * 0.5 * q }
rail_vertices = mesh.vertices[first_vertex..] hub = { center: [0.0, 0.0, height * 0.5], ports: [], section: arm_section }
validate_noncontact_clearance!(rail_vertices, maximum_radius, moon_center_z, contact_radius, forks = []
saddle_angle, pad_length, pad_width, approach_clearance) pads = []
add_saddle(mesh, minimum_radius, moon_center_z, contact_radius, saddle_angle, ARM_COUNT.times do |i|
pad_length, pad_width, pad_thickness) 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 end
dimensions = mesh.bounds.map { |minimum, maximum| maximum - minimum } ARM_COUNT.times do |i|
Result.new( angle = i * 2 * Math::PI / ARM_COUNT
mesh: mesh, fork = forks[i]
statistics: { trunk = surface.port(fork, angle + Math::PI, fork_port_distance, width)
"style" => STYLE, fork[:ports] << trunk
"dimensions_mm" => dimensions, surface.rail(hub[:ports][i], trunk, width, PATH_STATIONS, arm_section)
"footprint_mm" => dimensions.first(2).max, [-1, 1].each do |side|
"height_mm" => dimensions[2], pad = pads[side == 1 ? i : (i - 1) % ARM_COUNT]
"safety_model" => "terrain_envelope_v2", departure = surface.port(fork, angle + side * radians(62), fork_port_distance, width * 0.76)
"minimum_terrain_radius_mm" => minimum_radius, arrival = surface.port(pad, pad[:angle] - side * Math::PI / 2, pad_length * 0.50, pad_width * 0.70,
"maximum_terrain_radius_mm" => maximum_radius, section: approach_section)
"contact_radius_mm" => contact_radius, fork[:ports] << departure
"contact_height_mm" => contact_z, pad[:ports] << arrival
"requested_base_clearance_mm" => base_clearance, surface.rail(departure, arrival, width * 0.76, PATH_STATIONS, arm_section)
"guaranteed_hub_clearance_mm" => base_clearance, end
"guaranteed_moon_bottom_height_mm" => arm_height + base_clearance, end
"moon_bottom_clearance_mm" => arm_height + base_clearance, surface.junction(hub)
"noncontact_arm_clearance_mm" => approach_clearance, forks.each { |fork| surface.junction(fork) }
"hub_height_mm" => arm_height, pads.each { |pad| surface.junction(pad, pad: true, width: pad_width, segments: PAD_SEGMENTS) }
"moon_center_height_mm" => moon_center_z, surface.orient!
"arm_width_mm" => arm_width, surface.fair_junctions!
"bed_capped" => capped, surface.validate!
"maximum_underside_slope" => maximum_slope
} 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 end
def normalize_profile(profile) def normalize_profile(profile)
if profile.is_a?(Numeric) if profile.is_a?(Numeric)
diameter = Float(profile) diameter = Float(profile)
return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2.0, return { "envelope_mm" => diameter, "minimum_radius_mm" => diameter / 2,
"maximum_radius_mm" => diameter / 2.0 } "maximum_radius_mm" => diameter / 2 }
end end
values = profile.transform_keys(&:to_s) values = profile.transform_keys(&:to_s)
%w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h do |key| %w[envelope_mm minimum_radius_mm maximum_radius_mm].to_h { |key| [key, Float(values.fetch(key))] }
[key, Float(values.fetch(key))]
end
end end
def arm_path(hub_radius, outer_radius, contact_radius, arm_height, end_bottom, rotation) def validate_clearance!(mesh, pads, surface, radius, center_z, gap)
path = PATH_STATIONS.times.map do |index| minimum = Float::INFINITY
t = index / (PATH_STATIONS - 1.0) check = lambda do |x, y, z|
if t <= 0.58 next if pads.any? { |pad| surface.inside_junction?(pad, x, y) }
local = smoothstep(t / 0.58) next if x*x + y*y >= radius**2
radius = lerp(hub_radius * 0.68, outer_radius, local) minimum = [minimum, center_z - Math.sqrt(radius**2 - x*x - y*y) - z].min
else
local = smoothstep((t - 0.58) / 0.42)
radius = lerp(outer_radius, contact_radius, local)
end end
angle = rotation + radians(-25.0 + 80.0 * t) mesh.vertices.each { |point| check.call(*point) }
{ x: radius * Math.cos(angle), y: radius * Math.sin(angle), height: arm_height } 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 end
distance_to_end = 0.0 raise ArgumentError, format("non-contact stand surface clears terrain by %.3f mm; %.3f mm required", minimum, gap) if minimum < gap - 1e-6
(path.length - 1).downto(0) do |index| minimum
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
end
if path.first[:bottom] > 1e-6
raise ArgumentError, "requested stand clearance cannot be reached with support-free arms inside this footprint"
end
path
end end
def validate_noncontact_clearance!(vertices, maximum_radius, moon_center_z, contact_radius, def polar(radius, angle) = [radius * Math.cos(angle), radius * Math.sin(angle)]
saddle_angle, pad_length, pad_width, required_clearance) def radians(degrees) = degrees * Math::PI / 180
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
end end
end end
+307
View File
@@ -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
View File
@@ -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
}
+46
View File
@@ -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)
+68
View File
@@ -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"
+91
View File
@@ -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}")
+24 -7
View File
@@ -4,6 +4,7 @@ Usage: blender --background --python script/render_previews.py -- model.3mf outp
""" """
import math import math
import argparse
import os import os
import sys import sys
import zipfile import zipfile
@@ -13,12 +14,15 @@ import bpy
from mathutils import Vector from mathutils import Vector
def args(): parser = argparse.ArgumentParser(description=__doc__)
marker = sys.argv.index("--") parser.add_argument("source")
return os.path.abspath(sys.argv[marker + 1]), os.path.abspath(sys.argv[marker + 2]) 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)
source, output = args() 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) os.makedirs(output, exist_ok=True)
bpy.ops.object.select_all(action="SELECT") bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False) 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 = bpy.data.materials.new(base.attrib.get("name", "material"))
material.diffuse_color = rgba material.diffuse_color = rgba
materials.append(material) materials.append(material)
imported = []
for element in root.findall("m:resources/m:object", namespace): for element in root.findall("m:resources/m:object", namespace):
mesh_node = element.find("m:mesh", namespace) mesh_node = element.find("m:mesh", namespace)
if mesh_node is None: if mesh_node is None:
@@ -50,11 +55,17 @@ def import_3mf(path):
data.update() data.update()
obj = bpy.data.objects.new(element.attrib.get("name", "object"), data) obj = bpy.data.objects.new(element.attrib.get("name", "object"), data)
bpy.context.collection.objects.link(obj) bpy.context.collection.objects.link(obj)
imported.append(obj)
if materials and "pindex" in element.attrib: if materials and "pindex" in element.attrib:
obj.data.materials.append(materials[int(element.attrib["pindex"])]) obj.data.materials.append(materials[int(element.attrib["pindex"])])
return imported
import_3mf(source) 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"] objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
if not objects: 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))) 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 center = (minimum + maximum) / 2
extent = max(maximum - minimum) extent = max(maximum - minimum)
if options.focus:
center = Vector(options.focus)
if options.span:
extent = options.span
world = bpy.context.scene.world world = bpy.context.scene.world
world.color = (0.035, 0.035, 0.035) 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)) )]: 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 = 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.shape = "DISK"
light_data.size = extent light_data.size = extent
light = bpy.data.objects.new(name="Area", object_data=light_data) light = bpy.data.objects.new(name="Area", object_data=light_data)
+3 -1
View File
@@ -41,7 +41,9 @@ class GeneratorTest < Minitest::Test
assert File.file?(File.join(result[:output_dir], "orbital_triskelion_stand.3mf")) assert File.file?(File.join(result[:output_dir], "orbital_triskelion_stand.3mf"))
manifest = File.read(File.join(result[:output_dir], "manifest.md")) manifest = File.read(File.join(result[:output_dir], "manifest.md"))
assert_includes manifest, "Stand style: Orbital triskelion" 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, "Guaranteed Moon clearance above hub/base: 10.00 mm"
assert_includes manifest, "Stand dimensions:" assert_includes manifest, "Stand dimensions:"
end end
+73
View File
@@ -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
View File
@@ -17,7 +17,9 @@ class StandTest < Minitest::Test
assert_equal "orbital_triskelion_stand", result.mesh.name assert_equal "orbital_triskelion_stand", result.mesh.name
assert_equal "orbital_triskelion", stats["style"] assert_equal "orbital_triskelion", stats["style"]
assert_in_delta 0.0, result.mesh.bounds[2][0], 1e-9 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_radius_mm"], :<, diameter / 2.0
assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"] assert_operator stats["contact_height_mm"], :>, stats["moon_bottom_clearance_mm"]
assert_in_delta 10.0, stats["guaranteed_hub_clearance_mm"], 1e-9 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) "maximum_radius_mm" => sizing.maximum_radius_mm)
stats = result.statistics 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_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["guaranteed_moon_bottom_height_mm"], :>=, stats["hub_height_mm"] + 10.0
assert_operator stats["contact_height_mm"], :>, 30.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 end
def test_custom_clearance_changes_guaranteed_gap def test_custom_clearance_changes_guaranteed_gap
@@ -71,8 +73,111 @@ class StandTest < Minitest::Test
assert_equal 10.0, config["stand"]["base_clearance_mm"] assert_equal 10.0, config["stand"]["base_clearance_mm"]
end 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 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) def assert_manifold(mesh)
edge_uses = Hash.new(0) edge_uses = Hash.new(0)
mesh.triangles.each do |triangle| mesh.triangles.each do |triangle|