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/render.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Headless clay renders of a result, for reports and for judging outcomes by
|
|
3
|
+
eye (or by a vision model). matplotlib Agg only: no GL context needed, so it
|
|
4
|
+
works headless and in CI. Keep-outs draw as translucent red, load
|
|
5
|
+
regions as arrows.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
__all__ = ["render_views", "render_stress", "render_interfaces"]
|
|
12
|
+
|
|
13
|
+
_VIEWS = [(24, -58), (24, 32), (78, -90)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _decimate(mesh, faces=40_000):
|
|
17
|
+
mesh = _outside(mesh)
|
|
18
|
+
if len(mesh.faces) <= faces:
|
|
19
|
+
return mesh
|
|
20
|
+
try:
|
|
21
|
+
return mesh.simplify_quadric_decimation(face_count=faces)
|
|
22
|
+
except Exception: # noqa: BLE001 - render proxy only
|
|
23
|
+
return mesh
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _outside(mesh):
|
|
27
|
+
"""The mesh without its enclosed cavities' surfaces (a sealed plenum's
|
|
28
|
+
inside): matplotlib sorts faces by depth only roughly, and a cavity's
|
|
29
|
+
few large faces were drawn over the outside."""
|
|
30
|
+
try:
|
|
31
|
+
parts = mesh.split(only_watertight=False)
|
|
32
|
+
keep = [p for p in parts if p.volume > 0]
|
|
33
|
+
if 0 < len(keep) < len(parts):
|
|
34
|
+
import trimesh
|
|
35
|
+
return trimesh.util.concatenate(keep)
|
|
36
|
+
except Exception: # noqa: BLE001 - render proxy only
|
|
37
|
+
pass
|
|
38
|
+
return mesh
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_views(mesh, path, study=None, title=None, views=_VIEWS, size=(5.0, 4.2)):
|
|
42
|
+
import matplotlib
|
|
43
|
+
matplotlib.use("Agg")
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
46
|
+
|
|
47
|
+
m = _decimate(mesh)
|
|
48
|
+
tris = m.triangles
|
|
49
|
+
lo, hi = np.asarray(mesh.bounds)
|
|
50
|
+
if study is not None:
|
|
51
|
+
slo, shi = study.bounds()
|
|
52
|
+
lo, hi = np.minimum(lo, slo), np.maximum(hi, shi)
|
|
53
|
+
span = (hi - lo).max()
|
|
54
|
+
mid = 0.5 * (lo + hi)
|
|
55
|
+
fig = plt.figure(figsize=(size[0] * len(views), size[1]), dpi=110)
|
|
56
|
+
for vi, (elev, azim) in enumerate(views):
|
|
57
|
+
ax = fig.add_subplot(1, len(views), vi + 1, projection="3d")
|
|
58
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
59
|
+
view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
60
|
+
key = view_dir + np.array([0.3, -0.2, 0.6])
|
|
61
|
+
key /= np.linalg.norm(key)
|
|
62
|
+
shade = np.clip(m.face_normals @ key, 0, 1)
|
|
63
|
+
rim = np.clip(m.face_normals @ view_dir, 0, 1)
|
|
64
|
+
base = np.array([0.80, 0.80, 0.82])
|
|
65
|
+
col = np.clip(base[None] * (0.28 + 0.62 * shade + 0.18 * rim)[:, None], 0, 1)
|
|
66
|
+
ax.add_collection3d(Poly3DCollection(tris, facecolors=col, edgecolor="none", linewidth=0))
|
|
67
|
+
if study is not None:
|
|
68
|
+
for r in study.keep_out:
|
|
69
|
+
try:
|
|
70
|
+
ko = r.shape.to_manifold().to_mesh()
|
|
71
|
+
v = np.asarray(ko.vert_properties)[:, :3]
|
|
72
|
+
f = np.asarray(ko.tri_verts)
|
|
73
|
+
ax.add_collection3d(Poly3DCollection(v[f], facecolors=(0.9, 0.25, 0.2, 0.12),
|
|
74
|
+
edgecolor="none"))
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
pass
|
|
77
|
+
for case in study.load_cases:
|
|
78
|
+
for ld in case.loads:
|
|
79
|
+
if not np.any(ld.force):
|
|
80
|
+
continue
|
|
81
|
+
shape = ld.shape if ld.shape is not None else study.region_shape(ld.region)[0]
|
|
82
|
+
blo, bhi = shape.bounds()
|
|
83
|
+
c = 0.5 * (blo + bhi)
|
|
84
|
+
d = ld.force / np.linalg.norm(ld.force) * span * 0.18
|
|
85
|
+
ax.quiver(*(c - d), *d, color=(0.15, 0.45, 0.95), linewidth=2, arrow_length_ratio=0.3)
|
|
86
|
+
ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
|
|
87
|
+
ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
|
|
88
|
+
ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
|
|
89
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.45)
|
|
90
|
+
ax.view_init(elev, azim)
|
|
91
|
+
ax.set_axis_off()
|
|
92
|
+
if title:
|
|
93
|
+
fig.suptitle(title, fontsize=10)
|
|
94
|
+
fig.tight_layout()
|
|
95
|
+
fig.savefig(path, facecolor="white")
|
|
96
|
+
plt.close(fig)
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_stress(mesh, fields, path, strength, title=None, views=_VIEWS[:2], size=(5.0, 4.2),
|
|
101
|
+
exaggerate=None, safety_factor=1.0, label=None):
|
|
102
|
+
"""Stress map of a verified part: each vertex coloured by the worst
|
|
103
|
+
nodal von Mises stress over the load cases, as a fraction of `strength`
|
|
104
|
+
(verify_part(..., fields=True)["fields"]), divided by `safety_factor`:
|
|
105
|
+
pass the study's goal and red means "at the allowable". `exaggerate` scales the worst
|
|
106
|
+
case's displacement into the drawn shape; by default the largest
|
|
107
|
+
displacement is drawn as 4% of the part's size, so the bend reads.
|
|
108
|
+
`label` replaces the colour bar's label (say when `strength` is not one)."""
|
|
109
|
+
import matplotlib
|
|
110
|
+
matplotlib.use("Agg")
|
|
111
|
+
import matplotlib.pyplot as plt
|
|
112
|
+
from matplotlib import cm, colors
|
|
113
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
114
|
+
from scipy.spatial import cKDTree
|
|
115
|
+
|
|
116
|
+
m = _decimate(mesh)
|
|
117
|
+
nodes = fields["nodes"]
|
|
118
|
+
_, idx = cKDTree(nodes).query(m.vertices, k=1)
|
|
119
|
+
vm = np.max([c["vonMises"][idx] for c in fields["cases"]], axis=0)
|
|
120
|
+
worst = max(fields["cases"], key=lambda c: np.linalg.norm(c["displacement"], axis=1).max())
|
|
121
|
+
disp = worst["displacement"][idx]
|
|
122
|
+
lo, hi = np.asarray(mesh.bounds)
|
|
123
|
+
span = (hi - lo).max()
|
|
124
|
+
dmax = np.linalg.norm(disp, axis=1).max()
|
|
125
|
+
k = exaggerate if exaggerate is not None else (0.04 * span / dmax if dmax > 0 else 0.0)
|
|
126
|
+
verts = m.vertices + k * disp
|
|
127
|
+
frac = np.clip(vm * safety_factor / strength, 0, 1)
|
|
128
|
+
face = frac[m.faces].max(axis=1)
|
|
129
|
+
norm = colors.Normalize(0, 1)
|
|
130
|
+
cmap = matplotlib.colormaps["turbo"]
|
|
131
|
+
mid = 0.5 * (lo + hi)
|
|
132
|
+
fig = plt.figure(figsize=(size[0] * len(views) + 0.8, size[1]), dpi=110)
|
|
133
|
+
for vi, (elev, azim) in enumerate(views):
|
|
134
|
+
ax = fig.add_subplot(1, len(views), vi + 1, projection="3d")
|
|
135
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
136
|
+
view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
137
|
+
key = view_dir + np.array([0.3, -0.2, 0.6])
|
|
138
|
+
key /= np.linalg.norm(key)
|
|
139
|
+
shade = 0.45 + 0.55 * np.clip(m.face_normals @ key, 0, 1)
|
|
140
|
+
col = cmap(norm(face))[:, :3] * shade[:, None]
|
|
141
|
+
ax.add_collection3d(Poly3DCollection(verts[m.faces], facecolors=np.clip(col, 0, 1),
|
|
142
|
+
edgecolor="none", linewidth=0))
|
|
143
|
+
ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
|
|
144
|
+
ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
|
|
145
|
+
ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
|
|
146
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.45)
|
|
147
|
+
ax.view_init(elev, azim)
|
|
148
|
+
ax.set_axis_off()
|
|
149
|
+
cb = fig.colorbar(cm.ScalarMappable(norm=norm, cmap=cmap), ax=fig.axes, shrink=0.7, pad=0.02)
|
|
150
|
+
cb.set_label(label if label else "von Mises / allowable (worst case)" if safety_factor != 1.0 else "von Mises / strength (worst case)")
|
|
151
|
+
if title:
|
|
152
|
+
fig.suptitle(title + (f" · shape {k:.0f}x exaggerated ({worst['name']})" if k else ""), fontsize=10)
|
|
153
|
+
fig.savefig(path, facecolor="white")
|
|
154
|
+
plt.close(fig)
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_interfaces(mesh, study, path, max_panels=16, cols=4, size=3.2, margin=6.0):
|
|
159
|
+
"""Close-ups of every place the part meets the outside world: each
|
|
160
|
+
preserved region and each keep-out the part touches, cropped to a box
|
|
161
|
+
round it and shot from two sides. Whole-part renders show the layout and
|
|
162
|
+
hide the finish: GRIP's wrist yoke passed every number with sawn-off
|
|
163
|
+
faces, hard creases at its bosses and material over its bolt seats, all
|
|
164
|
+
of them a few pixels in the overview (QA, 10/28). Look at these before
|
|
165
|
+
calling a part done."""
|
|
166
|
+
import matplotlib
|
|
167
|
+
matplotlib.use("Agg")
|
|
168
|
+
import matplotlib.pyplot as plt
|
|
169
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
170
|
+
|
|
171
|
+
tc = mesh.triangles_center
|
|
172
|
+
|
|
173
|
+
def on_part(c, sh):
|
|
174
|
+
# A ring's or a sleeve's box centre is in its empty bore: centre the
|
|
175
|
+
# panel on the part's surface where it meets the region nearest that
|
|
176
|
+
# centre instead, so the panel shows the ring and not the hole.
|
|
177
|
+
near = np.abs(sh.sdf(tc)) < 1.5
|
|
178
|
+
if not near.any():
|
|
179
|
+
return c
|
|
180
|
+
pts = tc[near]
|
|
181
|
+
return pts[np.argmin(np.linalg.norm(pts - c, axis=1))]
|
|
182
|
+
|
|
183
|
+
spots = [] # (label, centre, half-size)
|
|
184
|
+
for r in study.preserve:
|
|
185
|
+
shapes = getattr(r.shape, "shapes", [r.shape]) # a union: each piece is its own interface
|
|
186
|
+
for j, sh in enumerate(shapes):
|
|
187
|
+
lo, hi = sh.bounds()
|
|
188
|
+
c = 0.5 * (lo + hi)
|
|
189
|
+
half = min(0.5 * float(np.max(hi - lo)), 20.0) + margin
|
|
190
|
+
if 0.5 * float(np.max(hi - lo)) > half - margin: # bigger than a panel: find the ring
|
|
191
|
+
c = on_part(c, sh)
|
|
192
|
+
spots.append((r.id + (f"[{j}]" if len(shapes) > 1 else ""), c, half))
|
|
193
|
+
for r in study.keep_out:
|
|
194
|
+
# where the part comes near a keep-out: centre on that contact, not on
|
|
195
|
+
# the keep-out (a driver channel runs 25 mm out past the part)
|
|
196
|
+
near = np.abs(r.shape.sdf(tc)) < 1.5
|
|
197
|
+
if near.any():
|
|
198
|
+
pts = tc[near]
|
|
199
|
+
ext = float(np.max(np.ptp(pts, axis=0)))
|
|
200
|
+
c = pts.mean(axis=0)
|
|
201
|
+
# contact all round a bore averages to its axis: use the contact
|
|
202
|
+
# point nearest that mean
|
|
203
|
+
spots.append((r.id, pts[np.argmin(np.linalg.norm(pts - c, axis=1))], min(0.5 * ext, 20.0) + margin))
|
|
204
|
+
# one panel per place: spots closer than a few mm show the same thing
|
|
205
|
+
merged = []
|
|
206
|
+
for lab, c, h in spots:
|
|
207
|
+
for m in merged:
|
|
208
|
+
if np.linalg.norm(m[1] - c) < 4.0:
|
|
209
|
+
m[0].append(lab)
|
|
210
|
+
m[2] = max(m[2], h)
|
|
211
|
+
break
|
|
212
|
+
else:
|
|
213
|
+
merged.append([[lab], c, h])
|
|
214
|
+
merged = merged[:max_panels]
|
|
215
|
+
if not merged:
|
|
216
|
+
return None
|
|
217
|
+
rows = int(np.ceil(len(merged) / cols))
|
|
218
|
+
fig = plt.figure(figsize=(size * cols, size * rows), dpi=110)
|
|
219
|
+
for k, (labs, c, half) in enumerate(merged):
|
|
220
|
+
# crop exactly (boolean faces are long slivers: selecting whole
|
|
221
|
+
# triangles by centre drew planes across the panel)
|
|
222
|
+
crop = mesh
|
|
223
|
+
for ax_i in range(3):
|
|
224
|
+
for sgn in (1, -1):
|
|
225
|
+
n_ = np.zeros(3)
|
|
226
|
+
n_[ax_i] = sgn
|
|
227
|
+
crop = crop.slice_plane(c - half * n_, n_) # keeps the side n_ points to
|
|
228
|
+
if len(crop.faces) == 0:
|
|
229
|
+
break
|
|
230
|
+
ax = fig.add_subplot(rows, cols, k + 1, projection="3d")
|
|
231
|
+
if len(crop.faces):
|
|
232
|
+
tris = crop.triangles
|
|
233
|
+
nrm = crop.face_normals
|
|
234
|
+
# look from the side the nearby surface faces, tilted so relief
|
|
235
|
+
# (a crease, a step, a crater) shows instead of flattening out
|
|
236
|
+
vd = (nrm * crop.area_faces[:, None]).sum(axis=0)
|
|
237
|
+
vd = vd / np.linalg.norm(vd) if np.linalg.norm(vd) > 1e-6 else np.array([0.5, -0.5, 0.7])
|
|
238
|
+
elev = float(np.degrees(np.arcsin(np.clip(vd[2], -1, 1)))) + 25.0
|
|
239
|
+
azim = float(np.degrees(np.arctan2(vd[1], vd[0]))) + 30.0
|
|
240
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
241
|
+
view = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
242
|
+
key = view + np.array([0.3, -0.2, 0.6])
|
|
243
|
+
key /= np.linalg.norm(key)
|
|
244
|
+
shade = np.clip(nrm @ key, 0, 1)
|
|
245
|
+
col = np.clip(np.array([0.80, 0.80, 0.82])[None] * (0.25 + 0.7 * shade)[:, None], 0, 1)
|
|
246
|
+
ax.add_collection3d(Poly3DCollection(tris, facecolors=col, edgecolor="none"))
|
|
247
|
+
ax.view_init(elev, azim)
|
|
248
|
+
for i, axis in enumerate("xyz"):
|
|
249
|
+
getattr(ax, f"set_{axis}lim")(c[i] - half, c[i] + half)
|
|
250
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.25)
|
|
251
|
+
ax.set_axis_off()
|
|
252
|
+
title = ", ".join(labs[:3]) + (f" +{len(labs) - 3}" if len(labs) > 3 else "")
|
|
253
|
+
ax.set_title(title, fontsize=7)
|
|
254
|
+
fig.tight_layout()
|
|
255
|
+
fig.savefig(path, facecolor="white")
|
|
256
|
+
plt.close(fig)
|
|
257
|
+
return path
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render_heat_exchanger(mesh, spaces, core, path, title=None, size=(5.0, 4.6), cut=None, view=(28, -55)):
|
|
261
|
+
"""Four panels: the part, the part cut open, and each fluid's space cut
|
|
262
|
+
open at the same plane so the two interleaved networks show (A blue,
|
|
263
|
+
B orange), then fluid A alone. `cut` is (point, normal), by default
|
|
264
|
+
through the core's centre across z, keeping the side the normal points
|
|
265
|
+
to (point it away from the view); `core` is any shape (its bounds)."""
|
|
266
|
+
import matplotlib
|
|
267
|
+
matplotlib.use("Agg")
|
|
268
|
+
import matplotlib.pyplot as plt
|
|
269
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
270
|
+
|
|
271
|
+
lo, hi = np.asarray(mesh.bounds)
|
|
272
|
+
span = (hi - lo).max()
|
|
273
|
+
mid = 0.5 * (lo + hi)
|
|
274
|
+
clo, chi = (np.asarray(b, float) for b in core.bounds())
|
|
275
|
+
if cut is None:
|
|
276
|
+
cut = (0.5 * (clo + chi), np.array([0.0, 0.0, 1.0]))
|
|
277
|
+
c0, cn = np.asarray(cut[0], float), np.asarray(cut[1], float)
|
|
278
|
+
cn = cn / np.linalg.norm(cn)
|
|
279
|
+
grey = np.array([0.80, 0.80, 0.82])
|
|
280
|
+
colors = [np.array([0.20, 0.45, 0.85]), np.array([0.92, 0.50, 0.15])]
|
|
281
|
+
names = list(spaces)
|
|
282
|
+
panels = [("part", [(mesh, grey)]),
|
|
283
|
+
("part, cut open", [(mesh.slice_plane(c0, cn, cap=True), grey)])]
|
|
284
|
+
# keep the half beyond the cut, so the view looks into it
|
|
285
|
+
cutf = [(spaces[n].slice_plane(c0, cn, cap=True), colors[i % 2]) for i, n in enumerate(names)]
|
|
286
|
+
panels.append((" + ".join(names) + ", cut open", cutf))
|
|
287
|
+
panels.append((names[0], cutf[:1]))
|
|
288
|
+
fig = plt.figure(figsize=(size[0] * len(panels), size[1]), dpi=110)
|
|
289
|
+
elev, azim = view
|
|
290
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
291
|
+
view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
292
|
+
key = view_dir + np.array([0.3, -0.2, 0.6])
|
|
293
|
+
key /= np.linalg.norm(key)
|
|
294
|
+
for pi, (label, items) in enumerate(panels):
|
|
295
|
+
ax = fig.add_subplot(1, len(panels), pi + 1, projection="3d")
|
|
296
|
+
for m, base in items:
|
|
297
|
+
m = _decimate(m, 90_000)
|
|
298
|
+
shade = np.clip(m.face_normals @ key, 0, 1)
|
|
299
|
+
col = np.clip(base[None] * (0.35 + 0.65 * shade)[:, None], 0, 1)
|
|
300
|
+
ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
|
|
301
|
+
ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
|
|
302
|
+
ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
|
|
303
|
+
ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
|
|
304
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.6)
|
|
305
|
+
ax.view_init(elev, azim)
|
|
306
|
+
ax.set_axis_off()
|
|
307
|
+
ax.set_title(label, fontsize=9)
|
|
308
|
+
if title:
|
|
309
|
+
fig.suptitle(title, fontsize=10)
|
|
310
|
+
fig.tight_layout()
|
|
311
|
+
fig.savefig(path, facecolor="white")
|
|
312
|
+
plt.close(fig)
|
|
313
|
+
return path
|
|
314
|
+
|
|
315
|
+
def render_internal_ceilings(variants, up, path, title=None, size=(5.0, 4.6), view=(30, -55), flat=20.0):
|
|
316
|
+
"""One panel per variant (label, {fluid: space mesh}): the fluid spaces
|
|
317
|
+
seen from above, the top of each (where it meets metal above it, a
|
|
318
|
+
ceiling) red where it lies within `flat` degrees of level along `up`.
|
|
319
|
+
Inside a metal part nothing there can be supported."""
|
|
320
|
+
import matplotlib
|
|
321
|
+
matplotlib.use("Agg")
|
|
322
|
+
import matplotlib.pyplot as plt
|
|
323
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
324
|
+
|
|
325
|
+
up = np.asarray(up, float) / np.linalg.norm(up)
|
|
326
|
+
meshes = [m for _, sp in variants for m in sp.values()]
|
|
327
|
+
lo = np.min([m.bounds[0] for m in meshes], axis=0)
|
|
328
|
+
hi = np.max([m.bounds[1] for m in meshes], axis=0)
|
|
329
|
+
span, mid = (hi - lo).max(), 0.5 * (lo + hi)
|
|
330
|
+
colors = [np.array([0.20, 0.45, 0.85]), np.array([0.92, 0.65, 0.25])]
|
|
331
|
+
red = np.array([0.85, 0.08, 0.08])
|
|
332
|
+
fig = plt.figure(figsize=(size[0] * len(variants), size[1]), dpi=110)
|
|
333
|
+
elev, azim = view
|
|
334
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
335
|
+
view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
336
|
+
key = view_dir + np.array([0.3, -0.2, 0.6])
|
|
337
|
+
key /= np.linalg.norm(key)
|
|
338
|
+
for pi, (label, spaces) in enumerate(variants):
|
|
339
|
+
ax = fig.add_subplot(1, len(variants), pi + 1, projection="3d")
|
|
340
|
+
for i, m in enumerate(spaces.values()):
|
|
341
|
+
m = _decimate(m, 120_000)
|
|
342
|
+
n = m.face_normals
|
|
343
|
+
shade = np.clip(n @ key, 0, 1)
|
|
344
|
+
base = np.where((n @ up > np.cos(np.radians(flat)))[:, None], red[None], colors[i % 2][None])
|
|
345
|
+
col = np.clip(base * (0.35 + 0.65 * shade)[:, None], 0, 1)
|
|
346
|
+
ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
|
|
347
|
+
ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
|
|
348
|
+
ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
|
|
349
|
+
ax.set_zlim(mid[2] - span / 2, mid[2] + span / 2)
|
|
350
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.5)
|
|
351
|
+
ax.view_init(elev, azim)
|
|
352
|
+
ax.set_axis_off()
|
|
353
|
+
ax.set_title(label, fontsize=9)
|
|
354
|
+
if title:
|
|
355
|
+
fig.suptitle(title, fontsize=10)
|
|
356
|
+
fig.tight_layout()
|
|
357
|
+
fig.savefig(path, facecolor="white")
|
|
358
|
+
plt.close(fig)
|
|
359
|
+
return path
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def render_lattice(mesh, path, title=None, holes=(), size=(5.0, 4.6), view=(28, -55)):
|
|
363
|
+
"""A lattice part: whole, cut open across its length and its height so
|
|
364
|
+
the core and its grading show, and a close-up of the first drain hole
|
|
365
|
+
(`holes` as lattice_infill's printability["drainHoles"])."""
|
|
366
|
+
import matplotlib
|
|
367
|
+
matplotlib.use("Agg")
|
|
368
|
+
import matplotlib.pyplot as plt
|
|
369
|
+
import trimesh
|
|
370
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
371
|
+
|
|
372
|
+
lo, hi = np.asarray(mesh.bounds)
|
|
373
|
+
mid = 0.5 * (lo + hi)
|
|
374
|
+
ext = hi - lo
|
|
375
|
+
long_ax = int(np.argmax(ext))
|
|
376
|
+
tall = [a for a in np.argsort(ext) if a != long_ax][0]
|
|
377
|
+
side = 3 - long_ax - tall
|
|
378
|
+
n_side, n_tall = np.zeros(3), np.zeros(3)
|
|
379
|
+
n_side[side] = 1.0
|
|
380
|
+
n_tall[tall] = -1.0
|
|
381
|
+
panels = [("part", mesh, None),
|
|
382
|
+
("cut along its length", mesh.slice_plane(mid, n_side, cap=True), None),
|
|
383
|
+
("cut through its height", mesh.slice_plane(mid, n_tall, cap=True), None)]
|
|
384
|
+
if holes:
|
|
385
|
+
h = holes[0]
|
|
386
|
+
c, nrm = np.asarray(h["at"]), np.asarray(h["normal"])
|
|
387
|
+
r = 4.0 * h["diameter"]
|
|
388
|
+
panels.append((f"drain hole {h['diameter']:g} mm, cut in half", mesh.slice_plane(c, _perp(nrm), cap=True),
|
|
389
|
+
(c, r, nrm)))
|
|
390
|
+
fig = plt.figure(figsize=(size[0] * len(panels), size[1]), dpi=110)
|
|
391
|
+
grey = np.array([0.80, 0.80, 0.82])
|
|
392
|
+
for pi, (label, m, zoom) in enumerate(panels):
|
|
393
|
+
ax = fig.add_subplot(1, len(panels), pi + 1, projection="3d")
|
|
394
|
+
if zoom is not None:
|
|
395
|
+
c, r, nrm = zoom
|
|
396
|
+
keep = np.all(np.abs(m.triangles_center - c) < r, axis=1)
|
|
397
|
+
m = m.submesh([np.nonzero(keep)[0]], append=True) if keep.any() else m
|
|
398
|
+
# look at the cut face (it faces away from the kept side), a
|
|
399
|
+
# little from outside the part
|
|
400
|
+
v = -_perp(nrm) + 0.5 * nrm
|
|
401
|
+
v /= np.linalg.norm(v)
|
|
402
|
+
elev, azim = np.degrees(np.arcsin(np.clip(v[2], -1, 1))), np.degrees(np.arctan2(v[1], v[0]))
|
|
403
|
+
cen, span = c - nrm * 0.25 * r, 1.6 * r
|
|
404
|
+
else:
|
|
405
|
+
elev, azim = view
|
|
406
|
+
cen, span = mid, ext.max()
|
|
407
|
+
m = _decimate(m, 400_000)
|
|
408
|
+
# matplotlib paints back to front by face centre: the skin's long
|
|
409
|
+
# flat triangles would sort behind the lattice they cover
|
|
410
|
+
v, f = trimesh.remesh.subdivide_to_size(m.vertices, m.faces, max_edge=span / 60.0, max_iter=12)
|
|
411
|
+
m = trimesh.Trimesh(v, f, process=False)
|
|
412
|
+
e, a = np.radians(elev), np.radians(azim)
|
|
413
|
+
view_dir = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
414
|
+
key = view_dir + np.array([0.3, -0.2, 0.6])
|
|
415
|
+
key /= np.linalg.norm(key)
|
|
416
|
+
shade = np.clip(m.face_normals @ key, 0, 1)
|
|
417
|
+
col = np.clip(grey[None] * (0.30 + 0.70 * shade)[:, None], 0, 1)
|
|
418
|
+
ax.add_collection3d(Poly3DCollection(m.triangles, facecolors=col, edgecolor="none", linewidth=0))
|
|
419
|
+
for k in range(3):
|
|
420
|
+
getattr(ax, "set_" + "xyz"[k] + "lim")(cen[k] - span / 2, cen[k] + span / 2)
|
|
421
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.5)
|
|
422
|
+
ax.view_init(elev, azim)
|
|
423
|
+
ax.set_axis_off()
|
|
424
|
+
ax.set_title(label, fontsize=9)
|
|
425
|
+
if title:
|
|
426
|
+
fig.suptitle(title, fontsize=10)
|
|
427
|
+
fig.tight_layout()
|
|
428
|
+
fig.savefig(path, facecolor="white")
|
|
429
|
+
plt.close(fig)
|
|
430
|
+
return path
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _perp(n):
|
|
434
|
+
"""A unit vector across `n`: the plane that cuts a drain hole in half."""
|
|
435
|
+
t = np.cross(n, [1.0, 0.0, 0.0] if abs(n[0]) < 0.9 else [0.0, 1.0, 0.0])
|
|
436
|
+
return t / np.linalg.norm(t)
|
loadpath/resolved.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Full-resolution FE of a field graph's lattice part: every strut and sheet
|
|
3
|
+
voxelized at a pitch a few voxels across the thinnest wall and solved as
|
|
4
|
+
it is, no homogenization.
|
|
5
|
+
|
|
6
|
+
homogenized_check reads a lattice from its unit cell's tensors, which is
|
|
7
|
+
quick and right inside the lattice. Where cells are cut open at a bare face
|
|
8
|
+
(no skin), how much they keep depends on how the face cuts them: cells cut
|
|
9
|
+
square keep struts running along the face, cells turned and cut on the bias
|
|
10
|
+
leave dangling stubs. One knockdown cannot serve both, and an optimizer
|
|
11
|
+
comparing the two (lattice_orient) takes the difference for a gain:
|
|
12
|
+
bench/results/lattice-orient-confirm.json. `resolved_check` is the arbiter
|
|
13
|
+
for those comparisons. It costs minutes (0.5-1M voxels for a 48 mm bar of
|
|
14
|
+
6 mm cells at 0.15 mm), so it confirms a pick rather than drives a search.
|
|
15
|
+
|
|
16
|
+
The peak is read as bench/lattice_aniso_validate.py reads it and the unit-
|
|
17
|
+
cell tables are built: each lattice cell's 99.5th percentile of nodal von
|
|
18
|
+
Mises, the worst cell outside the contact band.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
__all__ = ["default_pitch", "resolved_check"]
|
|
27
|
+
|
|
28
|
+
# voxels across the thinnest wall or strut the default pitch aims at
|
|
29
|
+
VOXELS_PER_WALL = 6
|
|
30
|
+
MAX_CELLS = 4_000_000
|
|
31
|
+
# up to this many voxels the matrix is assembled and solved by AMG (about
|
|
32
|
+
# 13 GB at 1M cells), above it the matrix-free grid multigrid
|
|
33
|
+
ASSEMBLED_MAX = 600_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lattice_spec(g, name):
|
|
37
|
+
from .homogenized import lattice_nodes
|
|
38
|
+
|
|
39
|
+
lats = lattice_nodes(g, name)
|
|
40
|
+
return (lats[0], g.nodes[lats[0]]) if len(lats) == 1 else (None, None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_pitch(doc, node=None, base_dir=None):
|
|
44
|
+
"""Pitch (mm) VOXELS_PER_WALL voxels across the lattice's constant wall
|
|
45
|
+
(or strut); a fortieth of its cell when the wall is graded."""
|
|
46
|
+
from .fields import FieldGraph, expand_groups
|
|
47
|
+
|
|
48
|
+
full = expand_groups(doc)
|
|
49
|
+
name = node or full.get("output") or list(full["nodes"])[-1]
|
|
50
|
+
g = FieldGraph({**full, "output": name}, base_dir=base_dir)
|
|
51
|
+
_, nd = _lattice_spec(g, name)
|
|
52
|
+
if nd is None:
|
|
53
|
+
raise ValueError("resolved_check needs a pitch for a part with no single lattice node")
|
|
54
|
+
t, cell = nd.spec.get("thickness"), nd.spec.get("cell", 8.0)
|
|
55
|
+
if isinstance(t, (int, float)) and "density" not in nd.spec:
|
|
56
|
+
return float(t) / VOXELS_PER_WALL
|
|
57
|
+
return float(np.min(np.atleast_1d(cell))) / 40.0 if isinstance(cell, (int, float, list)) else 0.15
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolved_check(doc, study, pitch: float | None = None, node=None, base_dir=None, sources=None,
|
|
61
|
+
contact_band: float | None = None, cell_size=None, max_cells: int = MAX_CELLS,
|
|
62
|
+
rtol: float = 1e-7, keep_mesh: bool = False, solver: str = "auto") -> dict:
|
|
63
|
+
"""Voxel FE of the graph's part at `pitch` (default_pitch), its lattice
|
|
64
|
+
resolved. Returns {mass, volume, safetyFactor, cases (name, compliance,
|
|
65
|
+
deflection, peakStress, peakAt, peakFaceDistance, peak995), cells,
|
|
66
|
+
pitch, seconds}; with keep_mesh also hm, bc. `contact_band` (mm, default
|
|
67
|
+
one lattice cell): stress this close to a support or load is left out.
|
|
68
|
+
`cell_size` (mm, scalar or per axis): the bins the peak is read over,
|
|
69
|
+
default the lattice's cell. Refuses grids over `max_cells`. `solver`:
|
|
70
|
+
"mg" (matrix-free grid multigrid), "assembled" (AMG on the assembled
|
|
71
|
+
matrix), "auto" assembled up to ASSEMBLED_MAX voxels."""
|
|
72
|
+
from scipy import ndimage
|
|
73
|
+
from scipy.spatial import cKDTree
|
|
74
|
+
|
|
75
|
+
from .domain import boundary_conditions
|
|
76
|
+
from .fem import CORNERS, HexMesh, elastic_c, hex8_b, von_mises
|
|
77
|
+
from .fields import FieldGraph, expand_groups
|
|
78
|
+
from .mg import GridElasticity
|
|
79
|
+
from .verify import STRESS_ALLOWANCE
|
|
80
|
+
|
|
81
|
+
t0 = time.time()
|
|
82
|
+
full = expand_groups(doc)
|
|
83
|
+
name = node or full.get("output") or list(full["nodes"])[-1]
|
|
84
|
+
g = FieldGraph({**full, "output": name}, base_dir=base_dir, sources=sources)
|
|
85
|
+
lat, nd = _lattice_spec(g, name)
|
|
86
|
+
if pitch is None:
|
|
87
|
+
pitch = default_pitch(doc, node=node, base_dir=base_dir)
|
|
88
|
+
pitch = float(pitch)
|
|
89
|
+
if cell_size is None:
|
|
90
|
+
c = nd.spec.get("cell", 8.0) if nd is not None else 8.0
|
|
91
|
+
c = float(np.min(np.atleast_1d(c))) if isinstance(c, (int, float, list)) else 8.0
|
|
92
|
+
cell_size = np.asarray(nd.spec.get("aspect", (1, 1, 1)), float) * c if nd is not None else c
|
|
93
|
+
if contact_band is None:
|
|
94
|
+
contact_band = float(np.min(np.atleast_1d(cell_size)))
|
|
95
|
+
b = g.bounds(name)
|
|
96
|
+
if b is None:
|
|
97
|
+
raise ValueError(f"{name!r} is unbounded")
|
|
98
|
+
lo, hi = (np.asarray(v, float) for v in b)
|
|
99
|
+
dims = np.ceil((hi - lo) / pitch - 1e-9).astype(int)
|
|
100
|
+
if np.prod(dims.astype(float)) * 0.6 > max_cells:
|
|
101
|
+
raise ValueError(f"a resolved check at {pitch:g} mm is ~{np.prod(dims.astype(float)) * 0.6:.3g} voxels "
|
|
102
|
+
f"(max_cells {max_cells}): coarsen the pitch or check a smaller part")
|
|
103
|
+
origin = lo
|
|
104
|
+
keep = []
|
|
105
|
+
jj, kk = np.meshgrid(np.arange(dims[1]), np.arange(dims[2]), indexing="ij")
|
|
106
|
+
for i in range(dims[0]):
|
|
107
|
+
P = np.stack([np.full(jj.size, origin[0] + (i + 0.5) * pitch), origin[1] + (jj.ravel() + 0.5) * pitch,
|
|
108
|
+
origin[2] + (kk.ravel() + 0.5) * pitch], 1)
|
|
109
|
+
s = np.asarray(g.evaluate(P, name), float) < 0
|
|
110
|
+
keep.append(np.stack([np.full(s.sum(), i), jj.ravel()[s], kk.ravel()[s]], 1))
|
|
111
|
+
ijk = np.concatenate(keep)
|
|
112
|
+
grid = np.zeros(dims, bool)
|
|
113
|
+
grid[tuple(ijk.T)] = True
|
|
114
|
+
lbl, _ = ndimage.label(grid)
|
|
115
|
+
hm = HexMesh(ijk, pitch, origin)
|
|
116
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
|
|
117
|
+
# only what is joined to a support carries load
|
|
118
|
+
sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
|
|
119
|
+
sel = np.isin(lbl[tuple(ijk.T)], sup)
|
|
120
|
+
if not sel.all():
|
|
121
|
+
ijk = ijk[sel]
|
|
122
|
+
hm = HexMesh(ijk, pitch, origin)
|
|
123
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
|
|
124
|
+
mat = study.material
|
|
125
|
+
nu = study.elastic(mat)
|
|
126
|
+
rhs = [bc.loads[ci] for ci in range(len(study.load_cases))]
|
|
127
|
+
if solver == "auto":
|
|
128
|
+
solver = "mg" if hm.n_elem > ASSEMBLED_MAX else "assembled"
|
|
129
|
+
if solver == "assembled":
|
|
130
|
+
# smoothed-aggregation AMG on the assembled matrix: on bias-cut
|
|
131
|
+
# lattices (struts ending in thin stubs at every face) the grid
|
|
132
|
+
# multigrid's CG crawls (a turned BCCz bar took 5x as long as laid)
|
|
133
|
+
from .fem import Solver, Stiffness
|
|
134
|
+
|
|
135
|
+
stiff = Stiffness(hm, nu, bc.fixed)
|
|
136
|
+
k = stiff.assemble(np.full(hm.n_elem, mat.E))
|
|
137
|
+
if bc.springs is not None:
|
|
138
|
+
k = (k + mat.E * bc.springs).tocsr()
|
|
139
|
+
us = Solver(hm, rtol=rtol, maxiter=4000).solve_many(k, rhs)
|
|
140
|
+
else:
|
|
141
|
+
grid_solver = GridElasticity(hm, nu, bc.fixed, springs=mat.E * bc.springs if bc.springs is not None
|
|
142
|
+
else None, rtol=rtol, maxiter=4000)
|
|
143
|
+
grid_solver.set_moduli(np.full(hm.n_elem, mat.E))
|
|
144
|
+
us = grid_solver.solve_many(rhs)
|
|
145
|
+
c0 = elastic_c(mat.E, nu)
|
|
146
|
+
bcs = [hex8_b(a, b_, c, nu) / pitch for (a, b_, c) in 2.0 * CORNERS - 1.0]
|
|
147
|
+
counts = np.bincount(hm.enode.ravel(), minlength=hm.n_nodes)[:, None]
|
|
148
|
+
xyz = hm.node_xyz
|
|
149
|
+
dface = np.minimum(xyz - lo, hi - xyz).min(axis=1)
|
|
150
|
+
cases, worst = [], np.inf
|
|
151
|
+
for ci, case in enumerate(study.load_cases):
|
|
152
|
+
u = us[ci]
|
|
153
|
+
ue = u[hm.edof]
|
|
154
|
+
nodal = np.zeros((hm.n_nodes, 6))
|
|
155
|
+
for k in range(8):
|
|
156
|
+
np.add.at(nodal, hm.enode[:, k], (ue @ bcs[k].T) @ c0.T)
|
|
157
|
+
vm = von_mises(nodal / counts)
|
|
158
|
+
contact = bc.support_nodes | bc.case_load_nodes[ci]
|
|
159
|
+
far = cKDTree(xyz[contact]).query(xyz, k=1, distance_upper_bound=contact_band)[0] == np.inf
|
|
160
|
+
if not far.any():
|
|
161
|
+
far = np.ones(len(xyz), bool)
|
|
162
|
+
key = np.floor((xyz[far] - origin) / np.asarray(cell_size, float)).astype(int)
|
|
163
|
+
_, inv = np.unique(key, axis=0, return_inverse=True)
|
|
164
|
+
inv = inv.ravel()
|
|
165
|
+
v = vm[far]
|
|
166
|
+
order = np.argsort(inv, kind="stable")
|
|
167
|
+
edges = np.searchsorted(inv[order], np.arange(inv.max() + 2))
|
|
168
|
+
groups = [order[edges[j]:edges[j + 1]] for j in range(len(edges) - 1) if edges[j + 1] - edges[j] > 50]
|
|
169
|
+
if not groups:
|
|
170
|
+
groups = [np.arange(len(v))]
|
|
171
|
+
peaks = np.array([np.percentile(v[gr], 99.5) for gr in groups])
|
|
172
|
+
j = int(np.argmax(peaks))
|
|
173
|
+
top = groups[j][np.argmax(v[groups[j]])]
|
|
174
|
+
peak = float(peaks[j])
|
|
175
|
+
sf = mat.strength / (peak * STRESS_ALLOWANCE) if peak > 0 else np.inf
|
|
176
|
+
worst = min(worst, sf)
|
|
177
|
+
ln = bc.case_load_nodes[ci]
|
|
178
|
+
disp = u.reshape(-1, 3)
|
|
179
|
+
cases.append({"name": case.name, "compliance": float(bc.loads[ci] @ u),
|
|
180
|
+
"deflection": float(np.linalg.norm(disp[ln].mean(axis=0))) if ln.any() else 0.0,
|
|
181
|
+
"peakStress": peak, "safetyFactor": sf, "peakAt": xyz[far][top].tolist(),
|
|
182
|
+
"peakFaceDistance": float(dface[far][top]), "peak995": float(np.percentile(v, 99.5))})
|
|
183
|
+
volume = float(hm.n_elem * pitch ** 3)
|
|
184
|
+
rep = {"mass": volume * mat.density / 1000.0, "volume": volume, "safetyFactor": worst, "cases": cases,
|
|
185
|
+
"cells": int(hm.n_elem), "pitch": pitch, "lattice": lat, "stressAllowance": STRESS_ALLOWANCE,
|
|
186
|
+
"seconds": time.time() - t0}
|
|
187
|
+
if keep_mesh:
|
|
188
|
+
rep["hm"], rep["bc"] = hm, bc
|
|
189
|
+
return rep
|