loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/printcheck.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Can it be printed as it stands, in this orientation, without supports?
|
|
3
|
+
|
|
4
|
+
verify._overhang counts down-facing surface by its normal. That is the right
|
|
5
|
+
first number, but it calls every flat ceiling a defect, and FDM prints many
|
|
6
|
+
of them fine: a ceiling whose every point is held at both ends by walls
|
|
7
|
+
already printed is a bridge, and a slicer strings filament across it. It
|
|
8
|
+
also says nothing about the first layer, which is where most failed prints
|
|
9
|
+
fail. This module slices the part along the build direction and reads each
|
|
10
|
+
layer the way a slicer would:
|
|
11
|
+
|
|
12
|
+
support - cells of a layer with nothing under them within 45 degrees
|
|
13
|
+
(Langelaar's stencil, the same one am.AMFilter designs to),
|
|
14
|
+
grouped into regions per layer. A region is a bridge when a
|
|
15
|
+
straight span through every one of its cells, in one common
|
|
16
|
+
direction, lands on supported material at both ends and is no
|
|
17
|
+
longer than `bridge_span`; otherwise it needs support. Short
|
|
18
|
+
ledges (`ledge` mm, a few extrusion widths) print as overhang.
|
|
19
|
+
islands - regions with no printed material beside them at all: a new
|
|
20
|
+
start in mid-air, which no slicer can print unsupported.
|
|
21
|
+
bed - the first layer: contact area, how many separate pieces touch
|
|
22
|
+
the bed and the smallest, whether the centre of mass sits over
|
|
23
|
+
the contact's hull (and by how much), and how tall the part
|
|
24
|
+
stands over its narrowest base width. A part that tips, or a
|
|
25
|
+
2 mm^2 foot, fails before anything else matters.
|
|
26
|
+
support volume - what a slicer would add, column by column from every
|
|
27
|
+
unbridged cell down to the part or the bed: a cost to compare
|
|
28
|
+
orientations by, not a slicer's exact number.
|
|
29
|
+
|
|
30
|
+
The part is voxelized at `pitch` (the layer here is a cell, not the printer's
|
|
31
|
+
layer height: 45-degree geometry is scale-free, bridges and ledges are
|
|
32
|
+
measured in mm). Results carry the pitch they are good to.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from scipy import ndimage
|
|
38
|
+
|
|
39
|
+
__all__ = ["check_print", "rotation_to_z", "BRIDGE_SPAN"]
|
|
40
|
+
|
|
41
|
+
# Unsupported spans a slicer bridges reliably, mm: PLA/PETG with part
|
|
42
|
+
# cooling manage 10-20 mm cleanly and sag past ~25-50. Conservative default.
|
|
43
|
+
# Powder-bed metal barely bridges: a flat span past ~2 mm warps or drosses.
|
|
44
|
+
# Resin holds short spans between supports; past ~5 mm the slicer adds more.
|
|
45
|
+
BRIDGE_SPAN = {"fdm": 15.0, "lpbf": 2.0, "sla": 5.0, "dlp": 5.0}
|
|
46
|
+
# A ledge this deep prints as an overhang off the wall below it.
|
|
47
|
+
_LEDGE = 1.0
|
|
48
|
+
# Bed pieces smaller than this lift: a foot under ~3x3 mm rarely holds.
|
|
49
|
+
_MIN_FOOT = 9.0
|
|
50
|
+
_CROSS = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], bool)
|
|
51
|
+
_EIGHT = np.ones((3, 3), bool)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rotation_to_z(direction) -> np.ndarray:
|
|
55
|
+
"""Rotation matrix taking the build direction to +z (proper, det +1)."""
|
|
56
|
+
from .am import build_vector
|
|
57
|
+
|
|
58
|
+
u = build_vector(direction)
|
|
59
|
+
z = np.array([0.0, 0.0, 1.0])
|
|
60
|
+
c = float(u @ z)
|
|
61
|
+
if c > 1 - 1e-12:
|
|
62
|
+
return np.eye(3)
|
|
63
|
+
if c < -1 + 1e-12:
|
|
64
|
+
return np.diag([1.0, -1.0, -1.0])
|
|
65
|
+
v = np.cross(u, z)
|
|
66
|
+
s = np.linalg.norm(v)
|
|
67
|
+
k = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) / s
|
|
68
|
+
ang = np.arctan2(s, c)
|
|
69
|
+
return np.eye(3) + np.sin(ang) * k + (1 - np.cos(ang)) * (k @ k)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _spans(region, anchor, pitch, max_steps):
|
|
73
|
+
"""For each direction (0..165 deg), the chord through every cell of a
|
|
74
|
+
layer region and whether both ends land on anchor cells. Returns the
|
|
75
|
+
best direction's (fraction of cells spanned, longest chord in mm)."""
|
|
76
|
+
cells = np.argwhere(region)
|
|
77
|
+
shape = np.array(region.shape)
|
|
78
|
+
best = (0.0, np.inf)
|
|
79
|
+
for th in np.radians(np.arange(0, 180, 15)):
|
|
80
|
+
d = np.array([np.cos(th), np.sin(th)])
|
|
81
|
+
ok = np.ones(len(cells), bool)
|
|
82
|
+
length = np.zeros(len(cells))
|
|
83
|
+
for sgn in (1.0, -1.0):
|
|
84
|
+
done = np.zeros(len(cells), bool)
|
|
85
|
+
hit = np.zeros(len(cells), bool)
|
|
86
|
+
for step in range(1, max_steps + 1):
|
|
87
|
+
q = np.rint(cells + sgn * step * d).astype(int)
|
|
88
|
+
inside = np.all((q >= 0) & (q < shape), axis=1)
|
|
89
|
+
qc = np.clip(q, 0, shape - 1)
|
|
90
|
+
in_reg = inside & region[qc[:, 0], qc[:, 1]]
|
|
91
|
+
on_anchor = inside & anchor[qc[:, 0], qc[:, 1]]
|
|
92
|
+
newly = ~done & ~in_reg
|
|
93
|
+
hit |= newly & on_anchor
|
|
94
|
+
length += np.where(~done & in_reg, 1.0, 0.0)
|
|
95
|
+
done |= newly
|
|
96
|
+
if done.all():
|
|
97
|
+
break
|
|
98
|
+
ok &= done & hit
|
|
99
|
+
frac = float(ok.mean())
|
|
100
|
+
span = float((length[ok].max() + 1.0) * pitch) if ok.any() else np.inf
|
|
101
|
+
if frac > best[0] + 1e-9 or (abs(frac - best[0]) < 1e-9 and span < best[1]):
|
|
102
|
+
best = (frac, span)
|
|
103
|
+
return best
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _hull_margin(pts2, com2):
|
|
107
|
+
"""Signed distance from the centre of mass to the contact's convex hull
|
|
108
|
+
edge (positive inside)."""
|
|
109
|
+
from scipy.spatial import ConvexHull, QhullError
|
|
110
|
+
|
|
111
|
+
if len(pts2) < 3:
|
|
112
|
+
return -float(np.min(np.linalg.norm(pts2 - com2, axis=1))) if len(pts2) else -np.inf
|
|
113
|
+
try:
|
|
114
|
+
hull = ConvexHull(pts2)
|
|
115
|
+
except QhullError:
|
|
116
|
+
return 0.0
|
|
117
|
+
# hull.equations: n.x + d <= 0 inside
|
|
118
|
+
return float(-(hull.equations[:, :2] @ com2 + hull.equations[:, 2]).max())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_print(mesh, direction, process: str = "fdm", pitch: float | None = None,
|
|
122
|
+
bridge_span: float | None = None, ledge: float = _LEDGE, material=None,
|
|
123
|
+
max_cells: int = 1_500_000) -> dict:
|
|
124
|
+
"""Slice `mesh` (trimesh, mm) along the build `direction` ((axis, sign)
|
|
125
|
+
or a vector, the way layers stack) and report support, bridges, islands
|
|
126
|
+
and bed contact. Nothing here passes or fails a part: the numbers are
|
|
127
|
+
costs and risks an orientation choice weighs (orient.py)."""
|
|
128
|
+
import trimesh
|
|
129
|
+
|
|
130
|
+
from .verify import voxelize
|
|
131
|
+
|
|
132
|
+
rot = rotation_to_z(direction)
|
|
133
|
+
m = mesh.copy()
|
|
134
|
+
m.apply_transform(np.block([[rot, np.zeros((3, 1))], [np.zeros((1, 3)), np.ones((1, 1))]]))
|
|
135
|
+
m.apply_translation([0.0, 0.0, -m.bounds[0, 2]])
|
|
136
|
+
ext = m.extents
|
|
137
|
+
vol = float(abs(m.volume))
|
|
138
|
+
if pitch is None:
|
|
139
|
+
pitch = max(0.4, float((np.prod(ext) / max_cells) ** (1.0 / 3.0)))
|
|
140
|
+
span_mm = float(bridge_span if bridge_span is not None else BRIDGE_SPAN.get(process, 15.0))
|
|
141
|
+
# whole cells from the bed up, so no cell centre sits on the bottom face
|
|
142
|
+
lo = m.bounds[0]
|
|
143
|
+
n_cells = np.maximum(np.ceil((m.extents - 1e-6) / pitch), 1)
|
|
144
|
+
solid, origin = voxelize(m, pitch, box=(lo, lo + n_cells * pitch))
|
|
145
|
+
# first layer at the bed: drop empty layers the grid padding left below
|
|
146
|
+
zs = np.nonzero(solid.any(axis=(0, 1)))[0]
|
|
147
|
+
solid = solid[:, :, zs[0]:zs[-1] + 1]
|
|
148
|
+
nx, ny, nz = solid.shape
|
|
149
|
+
a_cell = pitch * pitch
|
|
150
|
+
out = {"pitch": pitch, "height": float(ext[2]), "volume": vol,
|
|
151
|
+
"buildDirection": [float(c) for c in rot.T @ np.array([0.0, 0.0, 1.0])]}
|
|
152
|
+
|
|
153
|
+
max_steps = int(np.ceil(span_mm / pitch)) + 2
|
|
154
|
+
ledge_cells = ledge / pitch
|
|
155
|
+
need = np.zeros_like(solid) # unbridged, unsupported cells
|
|
156
|
+
bridges, n_islands, island_area = [], 0, 0.0
|
|
157
|
+
bridged_area = 0.0
|
|
158
|
+
for k in range(1, nz):
|
|
159
|
+
below = ndimage.binary_dilation(solid[:, :, k - 1], _CROSS)
|
|
160
|
+
free = solid[:, :, k] & ~below
|
|
161
|
+
if not free.any():
|
|
162
|
+
continue
|
|
163
|
+
anchor = solid[:, :, k] & ~free
|
|
164
|
+
lbl, n = ndimage.label(free, _EIGHT)
|
|
165
|
+
for r in range(1, n + 1):
|
|
166
|
+
reg = lbl == r
|
|
167
|
+
ring = ndimage.binary_dilation(reg, _EIGHT) & ~reg
|
|
168
|
+
if not (ring & anchor).any():
|
|
169
|
+
n_islands += 1
|
|
170
|
+
island_area += reg.sum() * a_cell
|
|
171
|
+
need[:, :, k] |= reg
|
|
172
|
+
continue
|
|
173
|
+
# depth of the region from its nearest anchor: a narrow ledge
|
|
174
|
+
# off a wall prints as overhang, no bridge needed
|
|
175
|
+
depth = ndimage.distance_transform_edt(~anchor)[reg]
|
|
176
|
+
if depth.max() <= ledge_cells + 1.0:
|
|
177
|
+
continue
|
|
178
|
+
frac, span = _spans(reg, anchor, pitch, max_steps)
|
|
179
|
+
if frac >= 0.97 and span <= span_mm:
|
|
180
|
+
bridges.append({"z": float((k + 0.5) * pitch), "span": span, "area": float(reg.sum() * a_cell)})
|
|
181
|
+
bridged_area += reg.sum() * a_cell
|
|
182
|
+
else:
|
|
183
|
+
need[:, :, k] |= reg
|
|
184
|
+
# support columns: from each unbridged cell straight down to the part or the bed
|
|
185
|
+
sup_cells = 0
|
|
186
|
+
run = np.zeros((nx, ny), bool)
|
|
187
|
+
for k in range(nz - 1, 0, -1):
|
|
188
|
+
run = (run | need[:, :, k]) & ~solid[:, :, k - 1]
|
|
189
|
+
sup_cells += int(run.sum())
|
|
190
|
+
out["supportArea"] = float(need.sum() * a_cell)
|
|
191
|
+
out["supportVolume"] = float(sup_cells * a_cell * pitch)
|
|
192
|
+
out["islands"] = n_islands
|
|
193
|
+
out["islandArea"] = float(island_area)
|
|
194
|
+
out["bridges"] = len(bridges)
|
|
195
|
+
out["bridgedArea"] = float(bridged_area)
|
|
196
|
+
out["maxBridgeSpan"] = max((b["span"] for b in bridges), default=0.0)
|
|
197
|
+
out["bridgeList"] = sorted(bridges, key=lambda b: -b["span"])[:8]
|
|
198
|
+
out["supportFree"] = bool(out["supportArea"] == 0.0)
|
|
199
|
+
# support inside a sealed cavity can never come out: the cells that
|
|
200
|
+
# need support over a void that does not reach the outside (the grid's
|
|
201
|
+
# faces, or the open top above the part)
|
|
202
|
+
void = ~solid
|
|
203
|
+
lbl_v, _ = ndimage.label(void)
|
|
204
|
+
border = np.unique(np.concatenate([lbl_v[0].ravel(), lbl_v[-1].ravel(), lbl_v[:, 0].ravel(),
|
|
205
|
+
lbl_v[:, -1].ravel(), lbl_v[:, :, 0].ravel(), lbl_v[:, :, -1].ravel()]))
|
|
206
|
+
sealed = void & ~np.isin(lbl_v, border)
|
|
207
|
+
under = np.zeros_like(need)
|
|
208
|
+
under[:, :, 1:] = sealed[:, :, :-1]
|
|
209
|
+
trapped = need & under
|
|
210
|
+
out["trappedSupportArea"] = float(trapped.sum() * a_cell)
|
|
211
|
+
out["sealedVoidVolume"] = float(sealed.sum() * a_cell * pitch)
|
|
212
|
+
|
|
213
|
+
first = solid[:, :, 0]
|
|
214
|
+
lbl, n = ndimage.label(first, _EIGHT)
|
|
215
|
+
feet = np.bincount(lbl.ravel())[1:] * a_cell if n else np.zeros(0)
|
|
216
|
+
xy = (np.argwhere(first) + 0.5) * pitch + origin[:2]
|
|
217
|
+
try:
|
|
218
|
+
com = m.center_mass if m.is_volume else m.centroid
|
|
219
|
+
except Exception: # noqa: BLE001 - a bad mass property is not worth failing a check
|
|
220
|
+
com = m.centroid
|
|
221
|
+
margin = _hull_margin(xy, np.asarray(com[:2])) if len(xy) else -np.inf
|
|
222
|
+
if len(xy) >= 3:
|
|
223
|
+
# narrowest width of the contact (minimum over directions of its extent)
|
|
224
|
+
th = np.radians(np.arange(0, 180, 5))
|
|
225
|
+
widths = [np.ptp(xy @ np.array([np.cos(t), np.sin(t)])) + pitch for t in th]
|
|
226
|
+
base_w = float(min(widths))
|
|
227
|
+
else:
|
|
228
|
+
base_w = pitch
|
|
229
|
+
out["bed"] = {
|
|
230
|
+
"contactArea": float(first.sum() * a_cell),
|
|
231
|
+
"contactFraction": float(first.sum() * a_cell / max(vol ** (2 / 3), 1e-9)),
|
|
232
|
+
"feet": int(n),
|
|
233
|
+
"smallestFoot": float(feet.min()) if n else 0.0,
|
|
234
|
+
"comMargin": margin,
|
|
235
|
+
"baseWidth": base_w,
|
|
236
|
+
"aspect": float(ext[2] / base_w) if base_w > 0 else np.inf,
|
|
237
|
+
"footprint": [float(ext[0]), float(ext[1])],
|
|
238
|
+
}
|
|
239
|
+
warnings = []
|
|
240
|
+
bed = out["bed"]
|
|
241
|
+
if bed["comMargin"] < 0:
|
|
242
|
+
warnings.append(f"the centre of mass is {-bed['comMargin']:.1f} mm outside the bed contact: it tips over "
|
|
243
|
+
"unless supports or a brim hold it")
|
|
244
|
+
if n and bed["smallestFoot"] < _MIN_FOOT:
|
|
245
|
+
warnings.append(f"a {bed['smallestFoot']:.1f} mm^2 foot on the bed will likely lift: add a brim or "
|
|
246
|
+
"pick a face with more contact")
|
|
247
|
+
if bed["aspect"] > 4.0:
|
|
248
|
+
warnings.append(f"stands {bed['aspect']:.1f}x taller than its narrowest base: it will wobble late in the "
|
|
249
|
+
"print; a brim or a lower orientation helps")
|
|
250
|
+
if out["supportArea"] > 0 and not n_islands and process not in ("sls", "mjf", "binder", "lpbf"):
|
|
251
|
+
# (powder holds a polymer part up; LPBF counts supports by its own rules)
|
|
252
|
+
warnings.append(f"needs build supports under {out['supportArea']:.0f} mm^2 of downward faces "
|
|
253
|
+
f"({out['supportVolume']:.0f} mm^3 of support; spans over {span_mm:g} mm count as "
|
|
254
|
+
"unbridgeable, set process.bridgeSpan for your printer): reorient, chamfer the "
|
|
255
|
+
"overhangs, or accept the supports")
|
|
256
|
+
if n_islands:
|
|
257
|
+
warnings.append(f"{n_islands} region(s) start in mid-air ({island_area:.0f} mm^2): they need support")
|
|
258
|
+
mat = getattr(material, "name", material)
|
|
259
|
+
if mat in ("abs", "asa", "pa-cf", "pa12") and max(ext[0], ext[1]) > 120 and process == "fdm":
|
|
260
|
+
warnings.append(f"{max(ext[0], ext[1]):.0f} mm across the bed in {mat}: warp risk; use an enclosure "
|
|
261
|
+
"and a brim")
|
|
262
|
+
out["warnings"] = warnings
|
|
263
|
+
return out
|
loadpath/printsetup.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pick the print setup for a finished FDM part: build orientation, material,
|
|
3
|
+
bed temperature and brim, each candidate checked for strength (verify_part
|
|
4
|
+
with that material and layer direction) and for warp (buildsim, on the bed
|
|
5
|
+
at the end of the print, along the build). The search is on the delivered
|
|
6
|
+
shape: it does not re-optimize the part for each setup, so a material or
|
|
7
|
+
orientation that wins here may do better still once the part is
|
|
8
|
+
re-optimized for it (search_print_setup reports what to set; run the study
|
|
9
|
+
again with it).
|
|
10
|
+
|
|
11
|
+
Why: on a bed-printed part the warp is set by the material, the height on
|
|
12
|
+
the bed and how the bed holds it far more than by the topology (on the
|
|
13
|
+
120 mm PA12 beam of bench/buildsim_fdm_warp_limit.py every design lifted
|
|
14
|
+
2.5-3.7 mm), so these are the levers a warp limit needs.
|
|
15
|
+
|
|
16
|
+
Stage 1 runs every (material, orientation) at the material's usual bed
|
|
17
|
+
temperature without a brim; stage 2 sweeps bed temperature and brim on the
|
|
18
|
+
best few that meet the safety factor. Ranking: meets the goal and the warp
|
|
19
|
+
limit first, then lightest, then least warp, then no brim.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import dataclasses
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
AXES = ("+z", "-z", "+x", "-x", "+y", "-y")
|
|
29
|
+
BED_MAX = 110.0 # most hobby beds top out near here
|
|
30
|
+
BED_STEP = 10.0
|
|
31
|
+
TALL = 4.0 # height over narrowest base past which a print needs a brim to stand
|
|
32
|
+
TALL_MAX = 10.0 # and past which not even a brim keeps it steady
|
|
33
|
+
ROOM = 0.8 # a lift above this share of the limit counts as tight
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bed_candidates(material, setup, max_bed):
|
|
37
|
+
from .buildsim import BuildSetup, _fdm_params
|
|
38
|
+
p = _fdm_params(dataclasses.replace(setup, material=material))
|
|
39
|
+
base = p["bed"]
|
|
40
|
+
# above Tg an amorphous first layer sags (elephant's foot), so stop
|
|
41
|
+
# short of it; semi-crystalline (Tc far above) stop at the bed's limit
|
|
42
|
+
top = min(max_bed, p["tg"] - 5.0)
|
|
43
|
+
out = sorted({float(b) for b in (base - 2 * BED_STEP, base - BED_STEP, base, base + BED_STEP) if 40.0 <= b <= top})
|
|
44
|
+
return out or [min(base, top)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _warp(mesh, setup, build, cells):
|
|
48
|
+
from .buildsim import simulate_part
|
|
49
|
+
r = simulate_part(mesh, dataclasses.replace(setup, release="none"), build=build, cells=cells)
|
|
50
|
+
m = r.mesh
|
|
51
|
+
on = np.zeros(m.n_nodes, bool)
|
|
52
|
+
on[m.enode[r.part].ravel()] = True
|
|
53
|
+
lift = float(np.abs(r.u_built[on, 2]).max())
|
|
54
|
+
return {"lift": lift, "peel": (r.report.get("peel") or {}).get("meanPeelStress"),
|
|
55
|
+
"bedFailed": bool(r.report.get("peel", {}).get("bedFailed", False))}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _strength(study, mesh, material, build, max_cells):
|
|
59
|
+
from .am import parse_build_direction
|
|
60
|
+
from .materials import material_from_spec
|
|
61
|
+
from .verify import verify_part
|
|
62
|
+
from .study import _margin
|
|
63
|
+
mat = material_from_spec(material)
|
|
64
|
+
b = parse_build_direction(build)
|
|
65
|
+
proc = dataclasses.replace(study.process, build=b) if study.process is not None else None
|
|
66
|
+
st = dataclasses.replace(study, material=mat, materials=[mat], process=proc,
|
|
67
|
+
print_direction=b if study.print_direction is not None else None)
|
|
68
|
+
rep = verify_part(st, mesh, max_cells=max_cells)
|
|
69
|
+
# the goal's margin without the warp limits (those are checked here)
|
|
70
|
+
goal = _margin(st, {k: v for k, v in rep.items() if k != "warp"})
|
|
71
|
+
return {"mass": rep.get("mass"), "safetyFactor": rep.get("safetyFactor"), "margin": goal,
|
|
72
|
+
"supportVolume": (rep.get("print") or {}).get("supportVolume"),
|
|
73
|
+
"aspect": ((rep.get("print") or {}).get("bed") or {}).get("aspect"),
|
|
74
|
+
"comMargin": ((rep.get("print") or {}).get("bed") or {}).get("comMargin")}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def search_print_setup(study, mesh, materials=None, builds=None, beds=None, brims=(0.0, 5.0), max_bed=BED_MAX,
|
|
78
|
+
limit=None, cells=4000, verify_cells=30_000, top=2, progress=None, reoptimize=True) -> dict:
|
|
79
|
+
"""Rank FDM print setups for the delivered part `mesh` (trimesh, study
|
|
80
|
+
frame). materials: names (default the study's); builds: build
|
|
81
|
+
directions (default the six grid axes); beds: bed temperatures (default
|
|
82
|
+
a few around each material's usual one, below its Tg); brims: brim
|
|
83
|
+
widths, mm; limit: maxDistortion, mm (default buildSim.maxDistortion).
|
|
84
|
+
Returns {"best": row, "rows": [...], "seconds", "notes"}; each row has
|
|
85
|
+
material, build, bed, brim, lift (mm), mass, safetyFactor, margin, and
|
|
86
|
+
passes (goal and warp limit)."""
|
|
87
|
+
t0 = time.time()
|
|
88
|
+
bs = study.build_sim or {}
|
|
89
|
+
from .buildsim import BuildSetup
|
|
90
|
+
setup = bs.get("setup") or BuildSetup(process="fdm", material=study.material.name)
|
|
91
|
+
del reoptimize # used by the study runner (study._print_setup_rescue)
|
|
92
|
+
if setup.process.lower() != "fdm":
|
|
93
|
+
raise ValueError("search_print_setup is for FDM parts (buildSim with process fdm)")
|
|
94
|
+
if limit is None:
|
|
95
|
+
limit = bs.get("maxDistortion")
|
|
96
|
+
mats = list(materials or [m.name for m in (study.materials or [study.material])])
|
|
97
|
+
builds = list(builds or AXES)
|
|
98
|
+
|
|
99
|
+
def note(**kw):
|
|
100
|
+
if progress is not None:
|
|
101
|
+
progress(kw)
|
|
102
|
+
|
|
103
|
+
strength = {}
|
|
104
|
+
for mat in mats:
|
|
105
|
+
for b in builds:
|
|
106
|
+
note(stage="strength", material=mat, build=b)
|
|
107
|
+
try:
|
|
108
|
+
strength[(mat, b)] = _strength(study, mesh, mat, b, verify_cells)
|
|
109
|
+
except (RuntimeError, ValueError) as exc:
|
|
110
|
+
strength[(mat, b)] = {"error": str(exc), "margin": 0.0}
|
|
111
|
+
|
|
112
|
+
rows = []
|
|
113
|
+
|
|
114
|
+
def run(mat, b, bed, brim):
|
|
115
|
+
st = dataclasses.replace(setup, material=mat, bed=bed, brim=brim)
|
|
116
|
+
note(stage="warp", material=mat, build=b, bed=bed, brim=brim)
|
|
117
|
+
w = _warp(mesh, st, b, cells)
|
|
118
|
+
s = strength[(mat, b)]
|
|
119
|
+
ok_goal = s.get("margin") is None or s["margin"] >= 1.0
|
|
120
|
+
ok_warp = limit is None or w["lift"] <= limit
|
|
121
|
+
# a part standing over 4x taller than its narrowest base wobbles
|
|
122
|
+
# late in the print (printcheck), and one whose centre of mass is
|
|
123
|
+
# off its contact tips: both need a brim to stand
|
|
124
|
+
asp = s.get("aspect") or 0.0
|
|
125
|
+
tall = asp > TALL or (s.get("comMargin") or 0.0) < 0
|
|
126
|
+
ok_stand = asp <= TALL_MAX and (not tall or brim > 0)
|
|
127
|
+
row = {"material": mat, "build": b, "bed": bed, "brim": brim, **w,
|
|
128
|
+
**{k: s.get(k) for k in ("mass", "safetyFactor", "margin", "supportVolume", "aspect")},
|
|
129
|
+
"meetsGoal": bool(ok_goal), "meetsWarp": bool(ok_warp), "stands": bool(ok_stand),
|
|
130
|
+
"passes": bool(ok_goal and ok_warp and ok_stand)}
|
|
131
|
+
rows.append(row)
|
|
132
|
+
return row
|
|
133
|
+
|
|
134
|
+
from .buildsim import _fdm_params
|
|
135
|
+
first = []
|
|
136
|
+
for mat in mats:
|
|
137
|
+
base = _fdm_params(dataclasses.replace(setup, material=mat))["bed"]
|
|
138
|
+
for b in builds:
|
|
139
|
+
first.append(run(mat, b, base, 0.0))
|
|
140
|
+
# stage 2: bed and brim on the best few that hold the load
|
|
141
|
+
# (per material, so a lighter material is not crowded out by a flatter one)
|
|
142
|
+
can = [r for r in first if (r.get("aspect") or 0.0) <= TALL_MAX]
|
|
143
|
+
strong = []
|
|
144
|
+
for mat in mats:
|
|
145
|
+
mine = [r for r in can if r["material"] == mat]
|
|
146
|
+
mine = [r for r in mine if r["meetsGoal"]] or mine
|
|
147
|
+
strong += sorted(mine, key=lambda r: (r["lift"], r["mass"] or 0))[:top]
|
|
148
|
+
for r in strong:
|
|
149
|
+
for bed in (beds or _bed_candidates(r["material"], setup, max_bed)):
|
|
150
|
+
for brim in brims:
|
|
151
|
+
if bed == r["bed"] and brim == r["brim"]:
|
|
152
|
+
continue
|
|
153
|
+
run(r["material"], r["build"], float(bed), float(brim))
|
|
154
|
+
|
|
155
|
+
def rank(r):
|
|
156
|
+
# among equals: room under the warp limit first (the FDM warp model
|
|
157
|
+
# is good to tens of percent), then less support, no brim, less lift
|
|
158
|
+
tight = limit is not None and r["lift"] > ROOM * limit
|
|
159
|
+
return (not r["passes"], not r["meetsGoal"], round(r["mass"] or 0.0, 2) if r["passes"] else 0.0,
|
|
160
|
+
tight, round(r["supportVolume"] or 0.0, -1), r["brim"], r["lift"], abs(r["bed"]))
|
|
161
|
+
|
|
162
|
+
rows.sort(key=rank)
|
|
163
|
+
best = rows[0] if rows else None
|
|
164
|
+
cur = next((r for r in rows if r["material"] == study.material.name and r["brim"] == 0.0
|
|
165
|
+
and r["build"] == _build_name(study)), None)
|
|
166
|
+
return {"best": best, "current": cur, "rerun": rerun_candidate(rows, limit), "rows": rows, "limit": limit, "seconds": round(time.time() - t0, 1),
|
|
167
|
+
"notes": ["the delivered shape is re-checked, not re-optimized, for each setup: re-run the study with "
|
|
168
|
+
"the best setup to let the shape follow it",
|
|
169
|
+
"brim is an estimate (more bed hold under the footprint's edge), not validated; bed "
|
|
170
|
+
"temperature and material are in the validated FDM model"]}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_name(study):
|
|
174
|
+
b = study.process.build if study.process is not None else None
|
|
175
|
+
if isinstance(b, tuple) and len(b) == 2 and isinstance(b[0], (int, np.integer)):
|
|
176
|
+
return ("+" if b[1] > 0 else "-") + "xyz"[b[0]]
|
|
177
|
+
return "+z"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def rerun_candidate(rows, limit, room=ROOM):
|
|
181
|
+
"""The setup to re-optimize the part for when none passes as it is:
|
|
182
|
+
among the setups whose lift leaves room under the limit (room x limit,
|
|
183
|
+
else any under it), the most safety factor per gram (a re-optimized
|
|
184
|
+
part trades the two), then no brim, then the least lift."""
|
|
185
|
+
if limit is None:
|
|
186
|
+
return None
|
|
187
|
+
ok = [r for r in rows if r["lift"] <= room * limit] or [r for r in rows if r["lift"] <= limit]
|
|
188
|
+
ok = [r for r in ok if r.get("margin") is not None and r.get("mass") and r.get("stands", True)]
|
|
189
|
+
if not ok:
|
|
190
|
+
return None
|
|
191
|
+
return min(ok, key=lambda r: (-round(r["margin"] / r["mass"], 4), r["brim"], r["lift"]))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def study_for(study, row):
|
|
195
|
+
"""The study set up to print as `row` says: material, build direction
|
|
196
|
+
and the build sim's bed temperature and brim (printSetup off, so a
|
|
197
|
+
re-run does not search again)."""
|
|
198
|
+
from .am import parse_build_direction
|
|
199
|
+
from .materials import material_from_spec
|
|
200
|
+
mat = material_from_spec(row["material"])
|
|
201
|
+
b = parse_build_direction(row["build"])
|
|
202
|
+
proc = dataclasses.replace(study.process, build=b)
|
|
203
|
+
bs = dict(study.build_sim)
|
|
204
|
+
bs["setup"] = dataclasses.replace(bs["setup"], material=mat.name, bed=row["bed"], brim=row["brim"])
|
|
205
|
+
bs["printSetup"] = None
|
|
206
|
+
pd = b if study.print_direction is not None else None
|
|
207
|
+
return dataclasses.replace(study, material=mat, materials=[mat], process=proc, build_sim=bs, print_direction=pd)
|