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/processcheck.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Can a powder-bed polymer (SLS, MJF) or resin (SLA, DLP) printer make it?
|
|
3
|
+
|
|
4
|
+
FDM and LPBF parts fail on overhangs and bridges (printcheck.py, cavities.py).
|
|
5
|
+
These processes fail elsewhere:
|
|
6
|
+
|
|
7
|
+
powder bed (SLS, MJF: PA12, PA11, TPU) - the part sits in its own powder,
|
|
8
|
+
so nothing needs support, but
|
|
9
|
+
walls below ~0.8 mm break in depowdering;
|
|
10
|
+
powder inside a cavity has to come out: a fully enclosed void
|
|
11
|
+
keeps it (and the weight), and a chamber that only reaches
|
|
12
|
+
the outside through a slot narrower than an escape hole
|
|
13
|
+
(~4-5 mm) keeps most of it;
|
|
14
|
+
warp long thin flat panels cool unevenly and curl: under ~2 mm
|
|
15
|
+
thick and over ~100 mm long, a plate bows.
|
|
16
|
+
resin vat (SLA, DLP, bottom-up) - layers are peeled off a film, so
|
|
17
|
+
walls below ~0.6 mm (unsupported) tear or droop;
|
|
18
|
+
supports every overhang past 45 degrees and every island (a point
|
|
19
|
+
that starts in mid-air) needs a support, which leaves
|
|
20
|
+
marks; the count and volume come from printcheck;
|
|
21
|
+
peel a large cross-section pulls hard on every peel: tilt it;
|
|
22
|
+
cups a void open only toward the vat, sealed on the plate side
|
|
23
|
+
(a cup), is a suction cup at every peel until it closes: it
|
|
24
|
+
tears layers or the part off the plate. Vent it near the
|
|
25
|
+
plate side, or turn it over;
|
|
26
|
+
resin trapped in a closed cavity, or behind a drain narrower than
|
|
27
|
+
~3.5 mm, stays liquid inside and cracks the part later.
|
|
28
|
+
|
|
29
|
+
Everything is read off one voxel grid of the part (a trimesh, or an implicit
|
|
30
|
+
field with bounds) at a pitch fine enough for the process's minimum wall,
|
|
31
|
+
within a cell budget. A probe that could not run says so (None), it never
|
|
32
|
+
guesses. The numbers are heuristics with published-range thresholds (RULES),
|
|
33
|
+
meant to flag what a print bureau would reject or a print would lose, not to
|
|
34
|
+
replace the bureau's own check.
|
|
35
|
+
|
|
36
|
+
rep = check_process(mesh, "mjf", build="+z", material="pa12")
|
|
37
|
+
rep["ok"], rep["warnings"]
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
from scipy import ndimage
|
|
43
|
+
|
|
44
|
+
__all__ = ["RULES", "POWDER", "RESIN", "check_process", "rules_for"]
|
|
45
|
+
|
|
46
|
+
POWDER = ("sls", "mjf")
|
|
47
|
+
RESIN = ("sla", "dlp")
|
|
48
|
+
|
|
49
|
+
# Typical published design-guide values (EOS/Formlabs Fuse/HP MJF and
|
|
50
|
+
# Formlabs/Asiga resin guides), on the conservative side. mm, mm^2.
|
|
51
|
+
RULES = {
|
|
52
|
+
"sls": {"minWall": 0.8, "escapeHole": 4.0, "escapeHoles": 2, "minClearance": 0.5,
|
|
53
|
+
"warpThickness": 2.0, "warpSpan": 100.0,
|
|
54
|
+
"note": "SLS nylon: no supports; leave >= 2 escape holes of >= 4 mm in hollow parts; long flat "
|
|
55
|
+
"panels under 2 mm warp"},
|
|
56
|
+
"mjf": {"minWall": 0.8, "escapeHole": 5.0, "escapeHoles": 2, "minClearance": 0.4,
|
|
57
|
+
"warpThickness": 2.0, "warpSpan": 100.0,
|
|
58
|
+
"note": "HP Multi Jet Fusion: as SLS; HP asks for >= 2 escape holes of >= 5 mm"},
|
|
59
|
+
"sla": {"minWall": 0.6, "minSupportedWall": 0.4, "escapeHole": 3.5, "escapeHoles": 2, "minClearance": 0.5,
|
|
60
|
+
"peelArea": 2500.0, "cupArea": 10.0,
|
|
61
|
+
"note": "resin vat (bottom-up SLA): supports below 45 degrees and at islands; drains >= 3.5 mm "
|
|
62
|
+
"plus a vent near the plate in hollow parts; tilt large cross-sections"},
|
|
63
|
+
"dlp": {"minWall": 0.6, "minSupportedWall": 0.4, "escapeHole": 3.5, "escapeHoles": 2, "minClearance": 0.3,
|
|
64
|
+
"peelArea": 2000.0, "cupArea": 10.0,
|
|
65
|
+
"note": "resin vat (DLP/LCD, bottom-up): as SLA; the whole layer cures at once, so peel force "
|
|
66
|
+
"grows with cross-section faster"},
|
|
67
|
+
}
|
|
68
|
+
# Material-specific changes to a process's rules: TPU powder tears thin walls
|
|
69
|
+
# out in depowdering and does not warp like nylon.
|
|
70
|
+
_MATERIAL_RULES = {
|
|
71
|
+
"tpu88a": {"minWall": 1.0, "warpSpan": None},
|
|
72
|
+
}
|
|
73
|
+
# Cell budget for the grid (one byte per cell, plus float distance fields).
|
|
74
|
+
_BUDGET = 12_000_000
|
|
75
|
+
# Cells per minimum wall the pitch aims for; below 2 the wall verdict is None.
|
|
76
|
+
_CELLS_PER_WALL = 2.5
|
|
77
|
+
_EIGHT = np.ones((3, 3), bool)
|
|
78
|
+
_CONN = np.ones((3, 3, 3), bool)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rules_for(process: str, material=None, overrides: dict | None = None) -> dict:
|
|
82
|
+
"""The rule card for a process (RULES), adjusted for the material and
|
|
83
|
+
any overrides (same keys)."""
|
|
84
|
+
kind = str(getattr(process, "kind", process)).lower()
|
|
85
|
+
if kind not in RULES:
|
|
86
|
+
raise ValueError(f"check_process covers {sorted(RULES)}; got {kind!r} (FDM and LPBF: printcheck, cavities)")
|
|
87
|
+
out = dict(RULES[kind])
|
|
88
|
+
mat = str(getattr(material, "name", material) or "").lower()
|
|
89
|
+
out.update(_MATERIAL_RULES.get(mat, {}))
|
|
90
|
+
if getattr(process, "escape_hole", None):
|
|
91
|
+
out["escapeHole"] = float(process.escape_hole)
|
|
92
|
+
out.update({k: v for k, v in (overrides or {}).items() if v is not None})
|
|
93
|
+
min_feature = getattr(process, "min_feature", None)
|
|
94
|
+
if min_feature is not None:
|
|
95
|
+
out["minWall"] = max(out["minWall"], float(min_feature))
|
|
96
|
+
out["process"] = kind
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _grid(part, rot, bounds, pitch, pad=2):
|
|
101
|
+
"""Solid cells of the part with the build direction turned to +z (k = 0
|
|
102
|
+
at the plate). Returns (solid, origin in the build frame)."""
|
|
103
|
+
from .voxels import solid_grid
|
|
104
|
+
|
|
105
|
+
if callable(part) and not hasattr(part, "faces"):
|
|
106
|
+
lo, hi = (np.asarray(b, float) for b in bounds)
|
|
107
|
+
corners = np.array([[x, y, z] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])
|
|
108
|
+
rc = corners @ rot.T
|
|
109
|
+
# world = rot^T @ build, so as row vectors P_world = P_build @ rot
|
|
110
|
+
field = part if np.allclose(rot, np.eye(3)) else (lambda p: part(np.asarray(p) @ rot))
|
|
111
|
+
solid, origin, _ = solid_grid(field, pitch, pad=pad, bounds=(rc.min(axis=0), rc.max(axis=0)))
|
|
112
|
+
return solid, origin
|
|
113
|
+
m = part.copy()
|
|
114
|
+
m.apply_transform(np.block([[rot, np.zeros((3, 1))], [np.zeros((1, 3)), np.ones((1, 1))]]))
|
|
115
|
+
solid, origin, _ = solid_grid(m, pitch, pad=pad)
|
|
116
|
+
return solid, origin
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _choose_pitch(extents, min_wall, pitch=None, budget=_BUDGET):
|
|
120
|
+
if pitch:
|
|
121
|
+
return float(pitch)
|
|
122
|
+
want = min_wall / _CELLS_PER_WALL
|
|
123
|
+
floor = float((np.prod(np.asarray(extents, float) + 4 * want) / budget) ** (1.0 / 3.0))
|
|
124
|
+
return max(want, floor)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _walls(solid, pitch, origin, min_wall):
|
|
128
|
+
from .voxels import wall_thickness
|
|
129
|
+
|
|
130
|
+
if pitch > min_wall / 2.0 + 1e-9:
|
|
131
|
+
return {"minWallMm": None, "ok": None,
|
|
132
|
+
"inconclusive": f"pitch {pitch:.3g} mm (cell budget) is coarser than half the {min_wall:g} mm wall"}
|
|
133
|
+
w = wall_thickness(solid, pitch, cap_mm=4.0 * min_wall, limit_mm=min_wall, spots=5, origin=origin)
|
|
134
|
+
p1 = w.get("p1Mm")
|
|
135
|
+
out = {"minWallMm": w.get("minMm"), "p1WallMm": p1, "thinAreaMm2": w.get("thinAreaMm2", 0.0),
|
|
136
|
+
"thinSpots": w.get("thinSpots", []), "toleranceMm": pitch,
|
|
137
|
+
# judged on the 1st percentile with the half-cell estimator error,
|
|
138
|
+
# as check_dfm does: one sliver does not condemn a part
|
|
139
|
+
"ok": p1 is None or p1 + 0.5 * pitch >= min_wall,
|
|
140
|
+
"basis": "ok is judged on p1WallMm (1st percentile, +/- half a cell); minWallMm is the single "
|
|
141
|
+
"thinnest spot, which can sit under the minimum on a passing part (a sliver at an edge)"}
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _escapes(solid, pitch, origin, hole, rot):
|
|
146
|
+
"""Enclosed voids, and chambers that only reach the outside through
|
|
147
|
+
openings narrower than `hole` mm (a ball that size cannot get out)."""
|
|
148
|
+
void = ~solid
|
|
149
|
+
lbl, n = ndimage.label(void, _CONN)
|
|
150
|
+
border = set()
|
|
151
|
+
for face in (lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1], lbl[:, :, 0], lbl[:, :, -1]):
|
|
152
|
+
border.update(np.unique(face).tolist())
|
|
153
|
+
border.discard(0)
|
|
154
|
+
sizes = np.bincount(lbl.ravel(), minlength=n + 1)
|
|
155
|
+
cell3 = pitch ** 3
|
|
156
|
+
world = lambda ijk: [round(float(c), 2) for c in rot.T @ (origin + (np.asarray(ijk, float) + 0.5) * pitch)] # noqa: E731
|
|
157
|
+
enclosed = []
|
|
158
|
+
tiny = max(8, int(np.ceil(1.0 / cell3))) # under ~1 mm^3 is aliasing, not a pocket
|
|
159
|
+
keep = [k for k in range(1, n + 1) if k not in border and sizes[k] >= tiny]
|
|
160
|
+
if keep:
|
|
161
|
+
for k, c in zip(keep, ndimage.center_of_mass(void, lbl, keep)):
|
|
162
|
+
enclosed.append({"volumeMm3": round(float(sizes[k] * cell3), 2), "center": world(c)})
|
|
163
|
+
enclosed.sort(key=lambda v: -v["volumeMm3"])
|
|
164
|
+
|
|
165
|
+
# balls of the escape hole's size: their centres (wide), the ones that
|
|
166
|
+
# can roll out to the boundary, and the void they sweep
|
|
167
|
+
r = 0.5 * hole / pitch
|
|
168
|
+
dist = ndimage.distance_transform_edt(void)
|
|
169
|
+
wide = dist >= r
|
|
170
|
+
wl, wn = ndimage.label(wide, _CONN)
|
|
171
|
+
wborder = set()
|
|
172
|
+
for face in (wl[0], wl[-1], wl[:, 0], wl[:, -1], wl[:, :, 0], wl[:, :, -1]):
|
|
173
|
+
wborder.update(np.unique(face).tolist())
|
|
174
|
+
wborder.discard(0)
|
|
175
|
+
chambers = []
|
|
176
|
+
if wn:
|
|
177
|
+
inner = [k for k in range(1, wn + 1) if k not in wborder]
|
|
178
|
+
if inner:
|
|
179
|
+
# only chambers in void that does reach the outside (the rest are
|
|
180
|
+
# enclosed, above): a wide component lies in one void component
|
|
181
|
+
host = ndimage.maximum(lbl, wl, inner)
|
|
182
|
+
inner = [k for k, h in zip(inner, np.atleast_1d(host)) if int(h) in border]
|
|
183
|
+
if inner:
|
|
184
|
+
# what the chambers' balls sweep: their centres grown back by r
|
|
185
|
+
sel = np.isin(wl, inner)
|
|
186
|
+
swept = (ndimage.distance_transform_edt(~sel) <= r) & void
|
|
187
|
+
gl, gn = ndimage.label(swept, _CONN)
|
|
188
|
+
vols = np.bincount(gl.ravel(), minlength=gn + 1)
|
|
189
|
+
ball = 4.0 / 3.0 * np.pi * (0.5 * hole) ** 3
|
|
190
|
+
big = [k for k in range(1, gn + 1) if vols[k] * cell3 >= 2.0 * ball]
|
|
191
|
+
if big:
|
|
192
|
+
for k, c in zip(big, ndimage.center_of_mass(swept, gl, big)):
|
|
193
|
+
chambers.append({"volumeMm3": round(float(vols[k] * cell3), 1), "center": world(c)})
|
|
194
|
+
chambers.sort(key=lambda v: -v["volumeMm3"])
|
|
195
|
+
return {
|
|
196
|
+
"escapeHoleMm": hole,
|
|
197
|
+
"enclosedVoids": enclosed[:10], "enclosedCount": len(enclosed),
|
|
198
|
+
"enclosedVolumeMm3": round(float(sum(v["volumeMm3"] for v in enclosed)), 2),
|
|
199
|
+
"narrowExitChambers": chambers[:10], "narrowExitCount": len(chambers),
|
|
200
|
+
"narrowExitVolumeMm3": round(float(sum(v["volumeMm3"] for v in chambers)), 1),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _panels(solid, pitch, origin, rot, t_warp, span_warn):
|
|
205
|
+
"""Thin flat panels: the medial ridge of members thinner than t_warp,
|
|
206
|
+
grouped, kept when flat and filled in (a sheet, not a strut or the
|
|
207
|
+
medial lines running into a thick part's corners)."""
|
|
208
|
+
edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
|
|
209
|
+
ridge = solid & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
|
|
210
|
+
thick = (2.0 * edt - 0.5) * pitch
|
|
211
|
+
thin = ridge & (thick < t_warp)
|
|
212
|
+
if not thin.any():
|
|
213
|
+
return {"warpThicknessMm": t_warp, "panels": [], "maxSpan": 0.0}
|
|
214
|
+
lbl, n = ndimage.label(ndimage.binary_dilation(thin, _CONN) & solid, _CONN)
|
|
215
|
+
panels = []
|
|
216
|
+
for k, sl in enumerate(ndimage.find_objects(lbl), start=1):
|
|
217
|
+
if sl is None:
|
|
218
|
+
continue
|
|
219
|
+
sub = (lbl[sl] == k) & thin[sl]
|
|
220
|
+
pts = np.argwhere(sub).astype(float)
|
|
221
|
+
if len(pts) < 20:
|
|
222
|
+
continue
|
|
223
|
+
pts = (pts + [s.start for s in sl] + 0.5) * pitch
|
|
224
|
+
c = pts.mean(axis=0)
|
|
225
|
+
_, _, vt = np.linalg.svd(pts - c, full_matrices=False)
|
|
226
|
+
proj = (pts - c) @ vt.T
|
|
227
|
+
ext = np.ptp(proj, axis=0) + pitch
|
|
228
|
+
span, width, depth = float(ext[0]), float(ext[1]), float(ext[2])
|
|
229
|
+
# a ridge sheet is a cell or two thick: area ~ ridge cells x pitch^2
|
|
230
|
+
area = float(len(pts) * pitch * pitch)
|
|
231
|
+
if width < 4 * t_warp or depth > max(2.0 * t_warp, 0.15 * width) or area < 0.3 * span * width:
|
|
232
|
+
continue
|
|
233
|
+
panels.append({"span": round(span, 1), "width": round(width, 1), "area": round(min(area, span * width), 0),
|
|
234
|
+
"thickness": round(float(np.median(thick[sl][sub])), 2),
|
|
235
|
+
"center": [round(float(v), 2) for v in rot.T @ (origin + c)],
|
|
236
|
+
"normal": [round(float(v), 3) for v in rot.T @ vt[2]]})
|
|
237
|
+
panels.sort(key=lambda p: -p["span"])
|
|
238
|
+
out = {"warpThicknessMm": t_warp, "panels": panels[:5], "maxSpan": panels[0]["span"] if panels else 0.0}
|
|
239
|
+
if span_warn is not None:
|
|
240
|
+
out["ok"] = out["maxSpan"] < span_warn
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _layers(solid, pitch):
|
|
245
|
+
"""Per layer from the plate: cross-section area and suction cups (void
|
|
246
|
+
in the layer enclosed by it and sealed below by part, plate or an
|
|
247
|
+
earlier cup)."""
|
|
248
|
+
a = pitch * pitch
|
|
249
|
+
nz = solid.shape[2]
|
|
250
|
+
area = solid.sum(axis=(0, 1)) * a
|
|
251
|
+
sealed_prev = None
|
|
252
|
+
cup_area = np.zeros(nz)
|
|
253
|
+
worst = None
|
|
254
|
+
for k in range(nz):
|
|
255
|
+
layer = solid[:, :, k]
|
|
256
|
+
if not layer.any():
|
|
257
|
+
sealed_prev = np.zeros_like(layer)
|
|
258
|
+
continue
|
|
259
|
+
lbl, n = ndimage.label(~layer, _EIGHT)
|
|
260
|
+
if n == 0:
|
|
261
|
+
sealed_prev = np.zeros_like(layer)
|
|
262
|
+
continue
|
|
263
|
+
edge = set(np.unique(np.concatenate([lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1]])).tolist())
|
|
264
|
+
if k == 0 or sealed_prev is None:
|
|
265
|
+
leak = np.zeros_like(layer) # the plate seals the first layer
|
|
266
|
+
else:
|
|
267
|
+
leak = ~solid[:, :, k - 1] & ~sealed_prev
|
|
268
|
+
leaky = set(np.unique(lbl[leak & (lbl > 0)]).tolist()) | edge
|
|
269
|
+
sealed = (lbl > 0) & ~np.isin(lbl, list(leaky))
|
|
270
|
+
cup_area[k] = sealed.sum() * a
|
|
271
|
+
if sealed.any() and (worst is None or cup_area[k] > worst[1]):
|
|
272
|
+
worst = (k, cup_area[k], np.argwhere(sealed).mean(axis=0))
|
|
273
|
+
sealed_prev = sealed
|
|
274
|
+
return area, cup_area, worst
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def check_process(part, process="sls", build=None, material=None, pitch: float | None = None,
|
|
278
|
+
bounds=None, rules: dict | None = None, slicer: bool = True) -> dict:
|
|
279
|
+
"""Process checks for powder-bed polymer (sls, mjf) and resin (sla, dlp)
|
|
280
|
+
parts. `part` is a trimesh (mm) or a field f(P) < 0 inside with
|
|
281
|
+
`bounds`. `process` is a name or a spec.Process; `build` the direction
|
|
282
|
+
layers stack ((axis, sign), a vector or '+z'; default the process's,
|
|
283
|
+
else +z), which only resin and the layer report depend on. `rules`
|
|
284
|
+
overrides any RULES entry. slicer=True (meshes, resin) adds printcheck's
|
|
285
|
+
support and island read."""
|
|
286
|
+
from .am import parse_build_direction
|
|
287
|
+
from .printcheck import rotation_to_z
|
|
288
|
+
|
|
289
|
+
kind = str(getattr(process, "kind", process)).lower()
|
|
290
|
+
card = rules_for(process, material, rules)
|
|
291
|
+
if build is None:
|
|
292
|
+
build = getattr(process, "build", None)
|
|
293
|
+
if build is None or build == "auto":
|
|
294
|
+
build = (2, 1)
|
|
295
|
+
if isinstance(build, str):
|
|
296
|
+
build = parse_build_direction(build)
|
|
297
|
+
rot = rotation_to_z(build)
|
|
298
|
+
is_field = callable(part) and not hasattr(part, "faces")
|
|
299
|
+
if is_field:
|
|
300
|
+
if bounds is None:
|
|
301
|
+
raise ValueError("check_process on a field needs bounds = (lo, hi) in mm")
|
|
302
|
+
extents = np.asarray(bounds[1], float) - np.asarray(bounds[0], float)
|
|
303
|
+
else:
|
|
304
|
+
extents = np.asarray(part.extents, float)
|
|
305
|
+
ext_b = np.abs(rot) @ extents
|
|
306
|
+
p = _choose_pitch(ext_b, card["minWall"], pitch)
|
|
307
|
+
# pad past an escape ball's radius, so balls outside the part reach the
|
|
308
|
+
# grid's boundary however the part sits in its box
|
|
309
|
+
solid, origin = _grid(part, rot, bounds, p, pad=int(np.ceil(0.5 * card["escapeHole"] / p)) + 3)
|
|
310
|
+
vol = float(solid.sum() * p ** 3)
|
|
311
|
+
rep = {"process": kind, "rules": card, "pitch": round(p, 4), "volumeMm3": round(vol, 1),
|
|
312
|
+
"buildDirection": [round(float(c), 4) for c in rot.T @ np.array([0.0, 0.0, 1.0])],
|
|
313
|
+
"material": getattr(material, "name", material)}
|
|
314
|
+
warnings = []
|
|
315
|
+
mat = getattr(material, "name", material)
|
|
316
|
+
procs = getattr(material, "processes", None)
|
|
317
|
+
if procs and kind not in procs:
|
|
318
|
+
warnings.append(f"{mat} is not a {kind} material (made by {', '.join(procs)}): pick one of the "
|
|
319
|
+
f"{kind} cards in materials.MATERIALS")
|
|
320
|
+
|
|
321
|
+
rep["walls"] = _walls(solid, p, origin, card["minWall"])
|
|
322
|
+
if rep["walls"]["ok"] is False:
|
|
323
|
+
warnings.append(f"walls down to {rep['walls']['p1WallMm']:.2f} mm (1st percentile) are under the "
|
|
324
|
+
f"{card['minWall']:g} mm {kind} minimum ({rep['walls']['thinAreaMm2']:.0f} mm^2 of surface)")
|
|
325
|
+
|
|
326
|
+
esc = _escapes(solid, p, origin, card["escapeHole"], rot)
|
|
327
|
+
rep["escape"] = esc
|
|
328
|
+
what = "powder" if kind in POWDER else "resin"
|
|
329
|
+
if esc["enclosedCount"]:
|
|
330
|
+
warnings.append(f"{esc['enclosedCount']} enclosed void(s) ({esc['enclosedVolumeMm3']:.0f} mm^3) keep their "
|
|
331
|
+
f"{what}: add >= {card['escapeHoles']} holes of >= {card['escapeHole']:g} mm into each")
|
|
332
|
+
if esc["narrowExitCount"]:
|
|
333
|
+
warnings.append(f"{esc['narrowExitCount']} chamber(s) ({esc['narrowExitVolumeMm3']:.0f} mm^3) open only "
|
|
334
|
+
f"through gaps under {card['escapeHole']:g} mm: {what} will not clear; widen an exit")
|
|
335
|
+
rep["trappedVolumeMm3"] = round(esc["enclosedVolumeMm3"] + esc["narrowExitVolumeMm3"], 1)
|
|
336
|
+
|
|
337
|
+
if kind in POWDER:
|
|
338
|
+
rep["warp"] = _panels(solid, p, origin, rot, card["warpThickness"], card.get("warpSpan"))
|
|
339
|
+
if rep["warp"].get("ok") is False:
|
|
340
|
+
w = rep["warp"]["panels"][0]
|
|
341
|
+
warnings.append(f"a {w['span']:.0f} x {w['width']:.0f} mm panel only {w['thickness']:.1f} mm thick will "
|
|
342
|
+
f"warp in {kind}: thicken it past {card['warpThickness']:g} mm, rib it, or break it up")
|
|
343
|
+
else:
|
|
344
|
+
area, cups, worst = _layers(solid, p)
|
|
345
|
+
rep["peel"] = {"maxLayerArea": round(float(area.max()), 1),
|
|
346
|
+
"atHeight": round(float((int(area.argmax()) + 0.5) * p), 2), "limit": card["peelArea"]}
|
|
347
|
+
if area.max() > card["peelArea"]:
|
|
348
|
+
warnings.append(f"a {area.max():.0f} mm^2 layer pulls hard on every peel: tilt the part 15-30 degrees "
|
|
349
|
+
"off the plate")
|
|
350
|
+
rep["cups"] = {"maxArea": round(float(cups.max()), 1), "layers": int((cups > card["cupArea"]).sum()),
|
|
351
|
+
"volumeMm3": round(float(cups.sum() * p), 1)}
|
|
352
|
+
if worst is not None:
|
|
353
|
+
ij = np.r_[worst[2], worst[0]]
|
|
354
|
+
rep["cups"]["at"] = [round(float(c), 2) for c in rot.T @ (origin + (ij + 0.5) * p)]
|
|
355
|
+
if cups.max() > card["cupArea"]:
|
|
356
|
+
warnings.append(f"a {cups.max():.0f} mm^2 suction cup (a void sealed on the plate side, open to the "
|
|
357
|
+
"vat) pulls at every peel: vent it near the plate, or turn the part over")
|
|
358
|
+
if slicer and not is_field:
|
|
359
|
+
from .printcheck import check_print
|
|
360
|
+
|
|
361
|
+
pc = check_print(part, build, process=kind, material=material,
|
|
362
|
+
bridge_span=getattr(process, "bridge_span", None))
|
|
363
|
+
rep["supports"] = {k: pc[k] for k in ("supportArea", "supportVolume", "islands", "islandArea",
|
|
364
|
+
"maxBridgeSpan")}
|
|
365
|
+
if pc["islands"]:
|
|
366
|
+
warnings.append(f"{pc['islands']} island(s) start in mid-air: the slicer must put a support "
|
|
367
|
+
"under each (check they are not on a mating face)")
|
|
368
|
+
rep["warnings"] = warnings
|
|
369
|
+
probes = [rep["walls"]["ok"]]
|
|
370
|
+
if kind in POWDER and "ok" in rep["warp"]:
|
|
371
|
+
probes.append(rep["warp"]["ok"])
|
|
372
|
+
rep["probesRan"] = all(v is not None for v in probes)
|
|
373
|
+
rep["ok"] = bool(all(v is True for v in probes) and rep["trappedVolumeMm3"] < 1.0
|
|
374
|
+
and (kind in POWDER or (rep["cups"]["maxArea"] <= card["cupArea"]
|
|
375
|
+
and rep["peel"]["maxLayerArea"] <= card["peelArea"])))
|
|
376
|
+
return rep
|
loadpath/quick.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quick mode: every command on a coarse grid, for a first look in a minute or
|
|
3
|
+
two and for the tutorial tests (tests/test_tutorials.py), which run each
|
|
4
|
+
tutorial's commands this way so the docs can't drift from the code.
|
|
5
|
+
|
|
6
|
+
loadpath run study.json --quick # or LOADPATH_QUICK=1 for anything
|
|
7
|
+
|
|
8
|
+
What it lowers: a study's cells, optimizer iterations and outcome-search runs
|
|
9
|
+
(the evals' coarse tier, evals/run.py COARSE), a heat exchanger's grid and
|
|
10
|
+
whole-exchanger simulation, a field graph's mesh pitch (and its drain search's)
|
|
11
|
+
and the analysis behind its `result` nodes (no hotspot submodels), `tune`'s
|
|
12
|
+
analyses, `assembly`'s sampled poses, `verify`'s grid and `buildsim`'s voxels.
|
|
13
|
+
Numbers move (mass a few %, safety factor
|
|
14
|
+
more on thin members): read a quick run for direction, run without it to print.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
# the evals' coarse tier, so a quick run and `evals.run --tier coarse` agree
|
|
22
|
+
STUDY = {"maxElements": 6000, "maxIterations": 30, "searchRuns": 3, "confirmRuns": 0}
|
|
23
|
+
HX_STEP = 1.6 # x the heat exchanger's grid step (default wall / 3)
|
|
24
|
+
HX_SIM_VOXEL = 1.5 # x the whole-exchanger simulation's voxel
|
|
25
|
+
FIELD_CELLS = 600_000 # mesh grid cells for a field graph without its own pitch
|
|
26
|
+
FIELD_PITCH = 1.6 # x a field graph's own mesh pitch
|
|
27
|
+
RESULT_CELLS = 30_000 # analysis cells behind a field graph's `result` node (default 120k)
|
|
28
|
+
TUNE_EVALUATIONS = 6
|
|
29
|
+
ASSEMBLY_SAMPLES = 150
|
|
30
|
+
VERIFY_CELLS = 20_000 # `verify`'s analysis grid
|
|
31
|
+
BUILDSIM_CELLS = 1_500 # `buildsim`'s part cells (default 60k; the plate adds as many again)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def on() -> bool:
|
|
35
|
+
return os.environ.get("LOADPATH_QUICK", "").strip().lower() not in ("", "0", "false", "no")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def enable():
|
|
39
|
+
"""Quick mode for this process and everything it starts."""
|
|
40
|
+
os.environ["LOADPATH_QUICK"] = "1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def study(d: dict) -> dict:
|
|
44
|
+
"""A study dict as quick mode runs it (a copy; `d` itself when off).
|
|
45
|
+
Also lowers study.py's search limits for this process."""
|
|
46
|
+
if not on() or not isinstance(d, dict):
|
|
47
|
+
return d
|
|
48
|
+
d = copy.deepcopy(d)
|
|
49
|
+
cells = STUDY["maxElements"]
|
|
50
|
+
want = ((d.get("expect") or {}).get("coarse") or {}).get("maxElements")
|
|
51
|
+
if want:
|
|
52
|
+
cells = max(cells, int(want))
|
|
53
|
+
res = dict(d.get("resolution") or {})
|
|
54
|
+
res.pop("pitch", None)
|
|
55
|
+
res["maxElements"] = min(int(res.get("maxElements", 80000)), cells)
|
|
56
|
+
d["resolution"] = res
|
|
57
|
+
d["maxIterations"] = min(int(d.get("maxIterations", 120)), STUDY["maxIterations"])
|
|
58
|
+
from . import study as st
|
|
59
|
+
st._MAX_SEARCH_RUNS = min(st._MAX_SEARCH_RUNS, STUDY["searchRuns"])
|
|
60
|
+
st._CONFIRM_RUNS = min(st._CONFIRM_RUNS, STUDY["confirmRuns"])
|
|
61
|
+
return d
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def heatx(hx, sim_opts):
|
|
65
|
+
"""Coarsen a HeatExchanger's grid in place; the simulation options as
|
|
66
|
+
quick mode runs them."""
|
|
67
|
+
if not on():
|
|
68
|
+
return sim_opts
|
|
69
|
+
# coarser, but at most half the wall: heatx.py refuses a grid past that
|
|
70
|
+
hx.step = min((hx.step or hx.wall / 3) * HX_STEP, hx.wall * 0.45)
|
|
71
|
+
if sim_opts is not None and sim_opts.get("voxel"):
|
|
72
|
+
sim_opts = {**sim_opts, "voxel": float(sim_opts["voxel"]) * HX_SIM_VOXEL}
|
|
73
|
+
return sim_opts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def mesh_pitch(pitch, bounds):
|
|
77
|
+
"""A field graph's mesh pitch in quick mode: its own x FIELD_PITCH, or
|
|
78
|
+
the box over FIELD_CELLS cells (the default is 4M)."""
|
|
79
|
+
if not on():
|
|
80
|
+
return pitch
|
|
81
|
+
if pitch:
|
|
82
|
+
return float(pitch) * FIELD_PITCH
|
|
83
|
+
import numpy as np
|
|
84
|
+
ext = np.asarray(bounds[1], float) - np.asarray(bounds[0], float)
|
|
85
|
+
return float((np.prod(ext) / FIELD_CELLS) ** (1 / 3))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def result_cells(n):
|
|
89
|
+
"""Analysis cells behind a field graph's `result` node."""
|
|
90
|
+
return min(int(n), RESULT_CELLS) if on() else n
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def result_refine(n):
|
|
94
|
+
"""Hotspot submodels behind a `result` node (verify_part's refine): none
|
|
95
|
+
in quick mode. They sharpen the reported peak, not the field the graph
|
|
96
|
+
reads, and cost most of the analysis."""
|
|
97
|
+
return 0 if on() else n
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tune_evaluations(n):
|
|
101
|
+
return min(int(n), TUNE_EVALUATIONS) if on() else n
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def assembly_samples(n: int) -> int:
|
|
105
|
+
return min(int(n), ASSEMBLY_SAMPLES) if on() else n
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify_cells(n: int) -> int:
|
|
109
|
+
return min(int(n), VERIFY_CELLS) if on() else n
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def buildsim_cells(n: int) -> int:
|
|
113
|
+
return min(int(n), BUILDSIM_CELLS) if on() else n
|