adjust the stand a bit
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user