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/domain.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turn a study's shapes into the voxel problem the solver works on: which
|
|
3
|
+
cells exist, which are pinned solid (preserve), and which nodes carry the
|
|
4
|
+
supports and loads.
|
|
5
|
+
|
|
6
|
+
Classification is by supersampling each cell at 8 interior points, so a
|
|
7
|
+
cell's membership is its covered fraction rather than whether its center
|
|
8
|
+
happens to fall inside:
|
|
9
|
+
|
|
10
|
+
- preserve wins over design space, and is pinned to full density;
|
|
11
|
+
- a keep-out removes any design cell it touches at all (the final part must
|
|
12
|
+
clear it), but only removes a preserve cell when it covers most of it, so
|
|
13
|
+
a thin bearing boss around a bore keeps its wall in the analysis;
|
|
14
|
+
- islands of material that cannot reach a support are dropped: they carry
|
|
15
|
+
no load and would make the stiffness matrix singular.
|
|
16
|
+
|
|
17
|
+
The exact keep-out and preserve geometry is restored after optimization by
|
|
18
|
+
mesh booleans, so the voxel classification only has to be right about load
|
|
19
|
+
paths, not about surfaces.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from scipy import ndimage
|
|
27
|
+
|
|
28
|
+
from .fem import HexMesh
|
|
29
|
+
from .shapes import Shape
|
|
30
|
+
from .spec import Study
|
|
31
|
+
|
|
32
|
+
__all__ = ["Domain", "build_domain", "choose_pitch"]
|
|
33
|
+
|
|
34
|
+
_SUB = np.array([[sx, sy, sz] for sx in (-0.25, 0.25) for sy in (-0.25, 0.25) for sz in (-0.25, 0.25)])
|
|
35
|
+
_CONN26 = np.ones((3, 3, 3), bool)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Domain:
|
|
40
|
+
pitch: float
|
|
41
|
+
origin: np.ndarray # min corner of cell (0, 0, 0), mm
|
|
42
|
+
dims: tuple # cells per axis
|
|
43
|
+
mesh: HexMesh
|
|
44
|
+
preserve: np.ndarray # bool per active element: pinned solid
|
|
45
|
+
fixed: np.ndarray # bool per dof
|
|
46
|
+
loads: list # per load case: force vector per dof (N)
|
|
47
|
+
case_names: list
|
|
48
|
+
case_weights: list
|
|
49
|
+
support_nodes: np.ndarray # bool per node
|
|
50
|
+
load_nodes: np.ndarray # bool per node (any case)
|
|
51
|
+
mirror: dict = field(default_factory=dict) # axis -> element index of the mirror cell (-1 none)
|
|
52
|
+
warnings: list = field(default_factory=list)
|
|
53
|
+
# Pin supports as penalty springs at unit modulus (scale by E), or None.
|
|
54
|
+
springs: object = None
|
|
55
|
+
# Self-weight: N per mm^3 of solid, or None (see BoundaryConditions).
|
|
56
|
+
gravity: np.ndarray | None = None
|
|
57
|
+
# per case, directions.Spread or None (see BoundaryConditions)
|
|
58
|
+
spreads: list = field(default_factory=list)
|
|
59
|
+
# The study's thermal block on this mesh (thermal.StudyHeat), or None.
|
|
60
|
+
thermal: object = None
|
|
61
|
+
# buildSim warp design (WarpSpec: warpWeight and/or limits), or None.
|
|
62
|
+
warp: object = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def n_elem(self):
|
|
66
|
+
return self.mesh.n_elem
|
|
67
|
+
|
|
68
|
+
def full_grid(self, values: np.ndarray, fill=0.0) -> np.ndarray:
|
|
69
|
+
g = np.full(self.dims, fill, float)
|
|
70
|
+
ijk = self.mesh.elem_ijk
|
|
71
|
+
g[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = values
|
|
72
|
+
return g
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fraction(shapes: list[Shape], centers: np.ndarray, pitch: float) -> np.ndarray:
|
|
76
|
+
if not shapes:
|
|
77
|
+
return np.zeros(len(centers))
|
|
78
|
+
hit = np.zeros((len(centers), len(_SUB)), bool)
|
|
79
|
+
for k, off in enumerate(_SUB):
|
|
80
|
+
pts = centers + off * pitch
|
|
81
|
+
for s in shapes:
|
|
82
|
+
hit[:, k] |= s.contains(pts)
|
|
83
|
+
return hit.mean(axis=1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cell_cap(study: Study) -> int:
|
|
87
|
+
"""The most cells a study's grid may have: maxElements, or up to 4x it
|
|
88
|
+
for the constrained method, whose members need 2.5 cells across (see
|
|
89
|
+
choose_pitch)."""
|
|
90
|
+
if study.goal.method == "constrained" and study.min_member:
|
|
91
|
+
return 4 * int(study.max_elements)
|
|
92
|
+
return int(study.max_elements)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def choose_pitch(study: Study) -> float:
|
|
96
|
+
"""Cell size: explicit, else the size that puts roughly maxElements cells
|
|
97
|
+
in the design space (estimated from a coarse occupancy sample)."""
|
|
98
|
+
if study.pitch:
|
|
99
|
+
return float(study.pitch)
|
|
100
|
+
lo, hi = study.bounds()
|
|
101
|
+
vol = float(np.prod(hi - lo))
|
|
102
|
+
# Coarse 24^3 sample of what fraction of the box is actually design space.
|
|
103
|
+
n = 24
|
|
104
|
+
axes = [np.linspace(lo[i], hi[i], n) for i in range(3)]
|
|
105
|
+
pts = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
|
|
106
|
+
inside = np.zeros(len(pts), bool)
|
|
107
|
+
for s in study.design_space + [r.shape for r in study.preserve]:
|
|
108
|
+
inside |= s.contains(pts)
|
|
109
|
+
frac = max(float(inside.mean()), 0.05)
|
|
110
|
+
pitch = (vol * frac / study.max_elements) ** (1.0 / 3.0)
|
|
111
|
+
budget = pitch
|
|
112
|
+
if study.min_member:
|
|
113
|
+
# No finer than two cells per filter radius (1.3 x minMember, see
|
|
114
|
+
# simp.filter_radius): the filter sets the feature scale, so finer
|
|
115
|
+
# cells only cost time. On the arm link this cut the cells from 35k to
|
|
116
|
+
# 18k and the study from ~255s to ~155s, and the design came out
|
|
117
|
+
# stiffer (0.58mm vs 1.06mm deflection at the same 30% volume): the
|
|
118
|
+
# finer grid had settled in a worse local optimum.
|
|
119
|
+
pitch = max(pitch, 1.3 * float(study.min_member) / 2.0)
|
|
120
|
+
if study.goal.method == "constrained":
|
|
121
|
+
# The stress-constrained method sits its members right at the
|
|
122
|
+
# minimum, and at two cells per filter radius that is 1.5 cells:
|
|
123
|
+
# the wall bracket's 4 mm members on a 2.6 mm grid meshed three
|
|
124
|
+
# times floppier than their voxels, its first run verified at SF
|
|
125
|
+
# 0.1 with the optimizer reading the limit, and four targets
|
|
126
|
+
# 2.45-2.6 ended at 55-75 g. With the member 2.5 cells across
|
|
127
|
+
# (1.6 mm) the optimizer's reading holds on the mesh (first run
|
|
128
|
+
# SF 1.7) and the four land at 29.2-30.1 g, in about 4x the time.
|
|
129
|
+
# Up to 4x maxElements cells for it, no more.
|
|
130
|
+
pitch = min(pitch, max(float(study.min_member) / 2.5, budget / 4.0 ** (1.0 / 3.0)))
|
|
131
|
+
return pitch
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _region_nodes(study: Study, mesh: HexMesh, region: str | None, shape: Shape | None, label: str,
|
|
135
|
+
tol_floor: float = 0.0):
|
|
136
|
+
if shape is None:
|
|
137
|
+
shape, kind = study.region_shape(region)
|
|
138
|
+
else:
|
|
139
|
+
kind = "inline"
|
|
140
|
+
# A keep-out is a hole: the nodes that load or hold it are the ones
|
|
141
|
+
# lining it, up to one cell away. Solid regions take nodes inside or on
|
|
142
|
+
# their surface.
|
|
143
|
+
# `tol_floor`: a verification grid finer than the optimization's still
|
|
144
|
+
# reaches as far as the optimization did. On the printed hook the design
|
|
145
|
+
# grid's support took the node plane 0.75 mm off the wall, so the part
|
|
146
|
+
# was (correctly) designed to start there, and a 0.6 mm verification
|
|
147
|
+
# grid then found nothing at the wall to hold.
|
|
148
|
+
# Only as a fallback: widening it everywhere clamped more of every bolt
|
|
149
|
+
# ring in verification and read the GRIP upper arm at SF 10 instead of 5.
|
|
150
|
+
tol = mesh.pitch * (1.0 if kind == "keepOut" else 0.5)
|
|
151
|
+
d = shape.sdf(mesh.node_xyz)
|
|
152
|
+
nodes = d <= tol
|
|
153
|
+
if kind != "keepOut" and (d <= 1e-9).sum() >= 4:
|
|
154
|
+
# A solid region with nodes inside it holds or loads those, not a
|
|
155
|
+
# plane half a cell outside it too. The extra plane let the printed
|
|
156
|
+
# hook's optimizer hang the part off nodes 0.75 mm short of the wall:
|
|
157
|
+
# it was designed not to touch the wall it is screwed to.
|
|
158
|
+
nodes = d <= 1e-9
|
|
159
|
+
if not nodes.any() and tol_floor > tol:
|
|
160
|
+
nodes = d <= tol_floor
|
|
161
|
+
if not nodes.any():
|
|
162
|
+
name = region or "inline shape"
|
|
163
|
+
raise ValueError(f"{label} on {name!r} touches no material: it lies outside the design space, "
|
|
164
|
+
"or every cell around it was removed. Move it onto the part.")
|
|
165
|
+
return nodes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Pin springs, relative to a cell's own stiffness: stiff enough to hold the
|
|
169
|
+
# pin to well under a percent of the part's deflection, soft enough not to
|
|
170
|
+
# wreck the solver's conditioning.
|
|
171
|
+
_PIN_STIFFNESS = 100.0
|
|
172
|
+
# Supports and loads touching at most this share of the cells are pinned
|
|
173
|
+
# solid (see build_domain).
|
|
174
|
+
_ANCHOR_MAX_FRACTION = 0.03
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class BoundaryConditions:
|
|
179
|
+
fixed: np.ndarray
|
|
180
|
+
springs: object # scipy CSR at unit modulus, or None
|
|
181
|
+
support_nodes: np.ndarray
|
|
182
|
+
loads: list # per case, force per dof (N)
|
|
183
|
+
case_load_nodes: list # per case, bool per node
|
|
184
|
+
load_nodes: np.ndarray
|
|
185
|
+
gravity: np.ndarray | None # N per mm^3 of solid material
|
|
186
|
+
regions: list = field(default_factory=list) # node mask of every support and load region
|
|
187
|
+
# per case, bool per node: the walls a pressure acts on (pressure.py).
|
|
188
|
+
# Not contact: a pressure is a real distributed load, and the peak stress
|
|
189
|
+
# of a pressure vessel is on its wetted wall.
|
|
190
|
+
case_pressure_nodes: list = field(default_factory=list)
|
|
191
|
+
# per case, |net force| / sum |nodal force| of its pressures (0 when sealed)
|
|
192
|
+
pressure_imbalance: list = field(default_factory=list)
|
|
193
|
+
# per case, directions.Spread for a force of unknown direction, or None;
|
|
194
|
+
# `loads` then holds the force as written
|
|
195
|
+
spreads: list = field(default_factory=list)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cylinder_of(study: Study, region, shape, label):
|
|
199
|
+
sh = shape if shape is not None else study.region_shape(region)[0]
|
|
200
|
+
if not hasattr(sh, "axis"):
|
|
201
|
+
raise ValueError(f"{label} needs a cylinder region (a bore keep-out or a cylinder) for its axis")
|
|
202
|
+
return sh.p0, sh.axis
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _wetted(study: Study, mesh: HexMesh) -> dict:
|
|
206
|
+
"""Per pressurized keep-out: (nodal force per unit pressure, node mask)."""
|
|
207
|
+
from .pressure import pressure_force
|
|
208
|
+
|
|
209
|
+
out = {}
|
|
210
|
+
for case in study.load_cases:
|
|
211
|
+
for ld in case.loads:
|
|
212
|
+
if ld.pressure and ld.region not in out:
|
|
213
|
+
out[ld.region] = pressure_force(mesh, study.region_shape(ld.region)[0], 1.0)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# A pressure-only study is held by a 3-2-1 support, which is right only for a
|
|
218
|
+
# balanced load: a sealed cavity's balances to rounding, an open duct's does
|
|
219
|
+
# not (the thrust on its missing end caps).
|
|
220
|
+
_IMBALANCE_MAX = 0.02
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _HeldPreload(ValueError):
|
|
224
|
+
"""The fasteners' preload case lands only on supported nodes."""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def boundary_conditions(study: Study, mesh: HexMesh, check_loads=True, tol_floor: float = 0.0) -> BoundaryConditions:
|
|
228
|
+
"""Supports, pins and loads on a mesh's nodes. Shared by the optimizer
|
|
229
|
+
and the verification, so both analyze exactly the same problem."""
|
|
230
|
+
xyz = mesh.node_xyz
|
|
231
|
+
support_nodes = np.zeros(mesh.n_nodes, bool)
|
|
232
|
+
fixed = np.zeros(mesh.n_dof, bool)
|
|
233
|
+
spring_blocks = []
|
|
234
|
+
regions = []
|
|
235
|
+
wet = _wetted(study, mesh)
|
|
236
|
+
for rid, (_, nodes) in wet.items():
|
|
237
|
+
if not nodes.any():
|
|
238
|
+
raise ValueError(f"pressure in {rid!r} pushes on no material: the keep-out is not lined by the part")
|
|
239
|
+
if study.self_equilibrated:
|
|
240
|
+
from .pressure import stabilize
|
|
241
|
+
|
|
242
|
+
fixed, support_nodes = stabilize(mesh, np.any([n for _, n in wet.values()], axis=0))
|
|
243
|
+
for i, s in enumerate(study.supports):
|
|
244
|
+
label = f"supports[{i}]"
|
|
245
|
+
nodes = _region_nodes(study, mesh, s.region, s.shape, label, tol_floor)
|
|
246
|
+
regions.append(nodes)
|
|
247
|
+
support_nodes |= nodes
|
|
248
|
+
idx = np.nonzero(nodes)[0]
|
|
249
|
+
for a in range(3):
|
|
250
|
+
if s.fix[a]:
|
|
251
|
+
fixed[3 * idx + a] = True
|
|
252
|
+
if s.pin:
|
|
253
|
+
p0, ax = _cylinder_of(study, s.region, s.shape, label)
|
|
254
|
+
d = xyz[idx] - p0
|
|
255
|
+
rad = d - (d @ ax)[:, None] * ax
|
|
256
|
+
rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
|
|
257
|
+
blocks = rad[:, :, None] * rad[:, None, :] + np.outer(ax, ax)[None]
|
|
258
|
+
spring_blocks.append((idx, blocks * _PIN_STIFFNESS * mesh.pitch))
|
|
259
|
+
if study.supports and all(s.pin for s in study.supports) and not study.self_equilibrated:
|
|
260
|
+
lines = [_cylinder_of(study, s.region, s.shape, f"supports[{i}]") for i, s in enumerate(study.supports)]
|
|
261
|
+
p0, ax = lines[0]
|
|
262
|
+
coaxial = all(abs(abs(float(a @ ax)) - 1) < 1e-3 and
|
|
263
|
+
np.linalg.norm((q - p0) - ((q - p0) @ ax) * ax) < mesh.pitch for q, a in lines[1:])
|
|
264
|
+
if coaxial:
|
|
265
|
+
# a brief-eval bell crank pinned at its pivot alone read SF 0.0004:
|
|
266
|
+
# nothing holds it from turning, so the solve is singular
|
|
267
|
+
raise ValueError("every support is a pin on one axis, so nothing stops the part turning about it and "
|
|
268
|
+
"the stiffness is singular (safety factors come out near zero). Hold what really "
|
|
269
|
+
"stops it turning: fix the link, arm or stop that reacts the load (\"fix\": \"all\" "
|
|
270
|
+
"on that bore), or fix the pivot itself with \"fix\": \"all\"")
|
|
271
|
+
springs = None
|
|
272
|
+
if spring_blocks:
|
|
273
|
+
import scipy.sparse as sp
|
|
274
|
+
rows, cols, vals = [], [], []
|
|
275
|
+
for idx, blocks in spring_blocks:
|
|
276
|
+
for i in range(3):
|
|
277
|
+
for j in range(3):
|
|
278
|
+
rows.append(3 * idx + i)
|
|
279
|
+
cols.append(3 * idx + j)
|
|
280
|
+
vals.append(blocks[:, i, j])
|
|
281
|
+
rows, cols, vals = (np.concatenate(v) for v in (rows, cols, vals))
|
|
282
|
+
keep = ~(fixed[rows] | fixed[cols])
|
|
283
|
+
springs = sp.csr_matrix((vals[keep], (rows[keep], cols[keep])), shape=(mesh.n_dof, mesh.n_dof))
|
|
284
|
+
|
|
285
|
+
load_nodes = np.zeros(mesh.n_nodes, bool)
|
|
286
|
+
loads, case_nodes, case_wet, imbalance, spreads = [], [], [], [], []
|
|
287
|
+
for ci, case in enumerate(study.load_cases):
|
|
288
|
+
f = np.zeros(mesh.n_dof)
|
|
289
|
+
spread = None
|
|
290
|
+
cn = np.zeros(mesh.n_nodes, bool)
|
|
291
|
+
cw = np.zeros(mesh.n_nodes, bool)
|
|
292
|
+
fp = np.zeros(mesh.n_dof)
|
|
293
|
+
for li, ld in enumerate(case.loads):
|
|
294
|
+
label = f"loadCases[{ci}].loads[{li}]"
|
|
295
|
+
if ld.pressure:
|
|
296
|
+
unit, nodes = wet[ld.region]
|
|
297
|
+
fp += ld.pressure * unit
|
|
298
|
+
cw |= nodes
|
|
299
|
+
continue
|
|
300
|
+
nodes = _region_nodes(study, mesh, ld.region, ld.shape, label, tol_floor)
|
|
301
|
+
regions.append(nodes)
|
|
302
|
+
idx = np.nonzero(nodes)[0]
|
|
303
|
+
cn[idx] = True
|
|
304
|
+
pts = xyz[idx]
|
|
305
|
+
force = ld.force
|
|
306
|
+
if ld.direction is not None:
|
|
307
|
+
# the uncertain force, spread evenly, one vector per axis
|
|
308
|
+
size = float(np.linalg.norm(force))
|
|
309
|
+
basis = []
|
|
310
|
+
for a in range(3):
|
|
311
|
+
b = np.zeros(mesh.n_dof)
|
|
312
|
+
b.reshape(-1, 3)[idx, a] = size / len(idx)
|
|
313
|
+
basis.append(b)
|
|
314
|
+
cos_half = -1.0 if ld.direction == "any" else float(np.cos(np.radians(ld.cone)))
|
|
315
|
+
spread = (basis, force / size, cos_half)
|
|
316
|
+
force = np.zeros(3)
|
|
317
|
+
if ld.distribution == "bearing" and np.any(force):
|
|
318
|
+
# A shaft pushes on the half of the bore that faces the force,
|
|
319
|
+
# most at the centre of that half: a cosine over it.
|
|
320
|
+
p0, ax = _cylinder_of(study, ld.region, ld.shape, label)
|
|
321
|
+
axial = (force @ ax) * ax
|
|
322
|
+
radial = force - axial
|
|
323
|
+
if np.any(radial):
|
|
324
|
+
d = pts - p0
|
|
325
|
+
rad = d - (d @ ax)[:, None] * ax
|
|
326
|
+
rad /= np.maximum(np.linalg.norm(rad, axis=1, keepdims=True), 1e-12)
|
|
327
|
+
w = np.maximum(rad @ (radial / np.linalg.norm(radial)), 0.0)
|
|
328
|
+
if w.sum() <= 0:
|
|
329
|
+
raise ValueError(f"{label}: no material on the loaded side of the bore")
|
|
330
|
+
f.reshape(-1, 3)[idx] += (w / w.sum())[:, None] * radial[None]
|
|
331
|
+
force = axial
|
|
332
|
+
if np.any(force):
|
|
333
|
+
f.reshape(-1, 3)[idx] += force / len(idx)
|
|
334
|
+
if np.any(ld.moment):
|
|
335
|
+
# Forces that carry the moment and nothing else: the rigid
|
|
336
|
+
# rotation field w x r, with w from the nodes' polar inertia.
|
|
337
|
+
r = pts - pts.mean(axis=0)
|
|
338
|
+
inertia = (r * r).sum() * np.eye(3) - r.T @ r
|
|
339
|
+
omega = np.linalg.lstsq(inertia, ld.moment, rcond=None)[0]
|
|
340
|
+
f.reshape(-1, 3)[idx] += np.cross(omega, r)
|
|
341
|
+
if cw.any():
|
|
342
|
+
fv = fp.reshape(-1, 3)
|
|
343
|
+
imb = float(np.linalg.norm(fv.sum(axis=0)) / max(np.abs(fv).sum(), 1e-300))
|
|
344
|
+
if study.self_equilibrated and imb > _IMBALANCE_MAX and check_loads:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"load case {case.name!r}: the pressure does not balance on itself (net force "
|
|
347
|
+
f"{np.linalg.norm(fv.sum(axis=0)):.1f} N): the cavity is open, so its ends' thrust has "
|
|
348
|
+
"to go somewhere. Add supports where the part is held (a flange, a clamp).")
|
|
349
|
+
imbalance.append(imb)
|
|
350
|
+
f += fp
|
|
351
|
+
else:
|
|
352
|
+
imbalance.append(0.0)
|
|
353
|
+
applied = bool(np.any(f))
|
|
354
|
+
f[fixed] = 0.0
|
|
355
|
+
if spread is not None:
|
|
356
|
+
from .directions import Spread
|
|
357
|
+
|
|
358
|
+
for b in spread[0]:
|
|
359
|
+
b[fixed] = 0.0
|
|
360
|
+
spread = Spread(f.copy(), spread[0], spread[1], spread[2])
|
|
361
|
+
f = spread.load(spread.nominal)
|
|
362
|
+
spreads.append(spread)
|
|
363
|
+
if check_loads and not np.any(f) and study.gravity is None:
|
|
364
|
+
if not applied and spread is None:
|
|
365
|
+
raise ValueError(f"load case {case.name!r} adds up to nothing at pitch {mesh.pitch:.3g} mm: its "
|
|
366
|
+
"forces cancel on the same grid nodes (a fastener's seat and face rings closer "
|
|
367
|
+
"than a cell, or equal and opposite loads on one region). Lengthen the grip, "
|
|
368
|
+
"use a finer pitch, or drop the case")
|
|
369
|
+
if case.name == "preload" and study.raw.get("fasteners"):
|
|
370
|
+
raise _HeldPreload(case.name)
|
|
371
|
+
raise ValueError(f"load case {case.name!r} acts only on fixed nodes; "
|
|
372
|
+
"a load applied where the part is held does nothing: load a region the supports "
|
|
373
|
+
"do not cover")
|
|
374
|
+
loads.append(f)
|
|
375
|
+
case_nodes.append(cn)
|
|
376
|
+
case_wet.append(cw)
|
|
377
|
+
load_nodes |= cn
|
|
378
|
+
|
|
379
|
+
gravity = None
|
|
380
|
+
if study.gravity is not None:
|
|
381
|
+
# g/cm^3 -> kg/mm^3 is 1e-6; times m/s^2 gives N per mm^3.
|
|
382
|
+
gravity = study.material.density * 1e-6 * study.gravity
|
|
383
|
+
return BoundaryConditions(fixed, springs, support_nodes, loads, case_nodes, load_nodes, gravity, regions,
|
|
384
|
+
case_wet, imbalance, spreads)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _near_cells(mesh: HexMesh, nodes: np.ndarray, layers: int = 2) -> np.ndarray:
|
|
388
|
+
"""Cells within `layers` cells of the given nodes: the band round
|
|
389
|
+
supports and loads that verification leaves out of the peak stress."""
|
|
390
|
+
near = np.asarray(nodes, bool).copy()
|
|
391
|
+
cells = np.zeros(mesh.n_elem, bool)
|
|
392
|
+
for _ in range(layers):
|
|
393
|
+
cells = near[mesh.enode].any(axis=1)
|
|
394
|
+
near[mesh.enode[cells].ravel()] = True
|
|
395
|
+
return cells
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def gravity_force(mesh: HexMesh, density: np.ndarray, gravity: np.ndarray, fixed: np.ndarray) -> np.ndarray:
|
|
399
|
+
"""Nodal self-weight for per-element solid fraction `density`."""
|
|
400
|
+
per_node = (np.asarray(density, float) * mesh.pitch ** 3 / 8.0)[:, None] * gravity[None, :]
|
|
401
|
+
f = np.zeros(mesh.n_dof)
|
|
402
|
+
for a in range(3):
|
|
403
|
+
f += np.bincount(mesh.edof[:, a::3].ravel(), weights=np.repeat(per_node[:, a], 8),
|
|
404
|
+
minlength=mesh.n_dof)
|
|
405
|
+
f[fixed] = 0.0
|
|
406
|
+
return f
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def build_domain(study: Study, pitch: float | None = None) -> Domain:
|
|
410
|
+
if pitch or study.pitch:
|
|
411
|
+
return _build_domain(study, float(pitch) if pitch else float(study.pitch))
|
|
412
|
+
# maxElements is a cap: the pitch is estimated from a coarse occupancy
|
|
413
|
+
# sample, which came out 2-5% over in the brief evals (20988 cells for
|
|
414
|
+
# 20000); grow the cells until the grid fits
|
|
415
|
+
# (cell_cap: the constrained method's finer grid is allowed past it; the
|
|
416
|
+
# cap had undone that, and the arm link's first constrained run went back
|
|
417
|
+
# to verifying 1.32 mm against the optimizer's 0.48 mm)
|
|
418
|
+
p = choose_pitch(study)
|
|
419
|
+
cap = cell_cap(study)
|
|
420
|
+
for _ in range(4):
|
|
421
|
+
d = _build_domain(study, p)
|
|
422
|
+
if d.n_elem <= cap:
|
|
423
|
+
return d
|
|
424
|
+
p *= (d.n_elem / cap) ** (1.0 / 3.0) * 1.003
|
|
425
|
+
return d
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _build_domain(study: Study, pitch: float) -> Domain:
|
|
429
|
+
lo, hi = study.bounds()
|
|
430
|
+
center = 0.5 * (lo + hi)
|
|
431
|
+
dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-9).astype(int), 1)
|
|
432
|
+
# Centered grid, so a symmetry plane through the design space center is
|
|
433
|
+
# also a plane between cells and every cell has an exact mirror. (Starting
|
|
434
|
+
# the grid on the design space's minimum face along non-symmetric axes
|
|
435
|
+
# was tried for the printed hook's half-cell overhang at its wall; it
|
|
436
|
+
# moved every case's search path and lost 3 of 8 evals. Not worth it.)
|
|
437
|
+
origin = center - 0.5 * dims * pitch
|
|
438
|
+
ii, jj, kk = np.meshgrid(*[np.arange(d) for d in dims], indexing="ij")
|
|
439
|
+
ijk = np.stack([ii.ravel(), jj.ravel(), kk.ravel()], axis=1)
|
|
440
|
+
centers = origin + (ijk + 0.5) * pitch
|
|
441
|
+
|
|
442
|
+
design_f = _fraction(study.design_space, centers, pitch)
|
|
443
|
+
pres_f = _fraction([r.shape for r in study.preserve], centers, pitch)
|
|
444
|
+
keep_f = _fraction([r.shape for r in study.keep_out], centers, pitch)
|
|
445
|
+
preserve = (pres_f >= 0.5) & (keep_f < 0.5)
|
|
446
|
+
design = (design_f >= 0.5) & ~preserve & (keep_f == 0)
|
|
447
|
+
active = preserve | design
|
|
448
|
+
if not active.any():
|
|
449
|
+
raise ValueError("the design space is empty after removing keep-outs")
|
|
450
|
+
|
|
451
|
+
warnings = []
|
|
452
|
+
for rid, t in study.shells:
|
|
453
|
+
if t < 2.0 * pitch:
|
|
454
|
+
warnings.append(
|
|
455
|
+
f"{rid} is {t:g}mm thick, {t / pitch:.1f} cells: a wall under two cells thick is too stiff in "
|
|
456
|
+
f"bending on this grid (and under one is not there at all). Set resolution.pitch to {t / 2:.2g}mm "
|
|
457
|
+
"or less, or raise resolution.maxElements.")
|
|
458
|
+
# Only for a minMember the study asked for: a process's own floor is what
|
|
459
|
+
# the printer can make, not a member the design needs, and at a first-look
|
|
460
|
+
# resolution it would warn on every powder or FDM study.
|
|
461
|
+
if study.min_member is not None and study.min_member < 1.5 * pitch and study.raw.get("minMember") is not None:
|
|
462
|
+
warnings.append(
|
|
463
|
+
f"minMember {study.min_member:g}mm is under 1.5 cells of this {pitch:.2f}mm grid ({1.5 * pitch:.2f}mm); "
|
|
464
|
+
"members that thin cannot be resolved. Raise resolution.maxElements or set resolution.pitch "
|
|
465
|
+
"(raising minMember coarsens the default pitch, which follows it).")
|
|
466
|
+
|
|
467
|
+
if study.max_member is not None and study.max_member < 3.0 * pitch:
|
|
468
|
+
warnings.append(
|
|
469
|
+
f"maxMember {study.max_member:g}mm is under 3 cells ({3 * pitch:.2f}mm); the thickness limit is too "
|
|
470
|
+
"coarse to hold on this grid. Raise resolution.maxElements or set resolution.pitch.")
|
|
471
|
+
access = getattr(study, "access", None)
|
|
472
|
+
if access is not None and access.tool_radius and access.tool_radius < pitch:
|
|
473
|
+
warnings.append(
|
|
474
|
+
f"milling.toolDiameter {2 * access.tool_radius:g}mm is under two cells ({2 * pitch:.2f}mm); slots "
|
|
475
|
+
"that narrow are left to the grid, which is coarser.")
|
|
476
|
+
|
|
477
|
+
# Support nodes on a provisional mesh, then drop islands that do not
|
|
478
|
+
# reach one, then rebuild the mesh over what remains.
|
|
479
|
+
mesh = HexMesh(ijk[active], pitch, origin)
|
|
480
|
+
support_nodes = np.zeros(mesh.n_nodes, bool)
|
|
481
|
+
for i, s in enumerate(study.supports):
|
|
482
|
+
support_nodes |= _region_nodes(study, mesh, s.region, s.shape, f"supports[{i}]")
|
|
483
|
+
if study.self_equilibrated:
|
|
484
|
+
# held by its own wetted walls (pressure.stabilize)
|
|
485
|
+
for _, nodes in _wetted(study, mesh).values():
|
|
486
|
+
support_nodes |= nodes
|
|
487
|
+
grid = np.zeros(dims, bool)
|
|
488
|
+
grid[tuple(ijk[active].T)] = True
|
|
489
|
+
lbl, _ = ndimage.label(grid, structure=_CONN26)
|
|
490
|
+
el_lbl = lbl[tuple(mesh.elem_ijk.T)]
|
|
491
|
+
touches = np.zeros(mesh.n_elem, bool)
|
|
492
|
+
touches |= support_nodes[mesh.enode].any(axis=1)
|
|
493
|
+
keep_lbls = np.unique(el_lbl[touches])
|
|
494
|
+
keep = np.isin(el_lbl, keep_lbls)
|
|
495
|
+
if (~keep).any():
|
|
496
|
+
dropped = int((~keep).sum())
|
|
497
|
+
if preserve[active][~keep].any():
|
|
498
|
+
warnings.append("some preserved geometry is not connected to any support and was left out of the "
|
|
499
|
+
"analysis; it will still be in the final part but carries no load")
|
|
500
|
+
warnings.append(f"{dropped} cells not connected to a support were removed")
|
|
501
|
+
act_idx = np.nonzero(active)[0]
|
|
502
|
+
active[act_idx[~keep]] = False
|
|
503
|
+
mesh = HexMesh(ijk[active], pitch, origin)
|
|
504
|
+
|
|
505
|
+
pres_el = preserve[active]
|
|
506
|
+
try:
|
|
507
|
+
bc = boundary_conditions(study, mesh)
|
|
508
|
+
except _HeldPreload:
|
|
509
|
+
# Supports that hold a fastener's rings along the screw (a bolt hole
|
|
510
|
+
# fixed in every direction, a bearing face under a thin flange) take
|
|
511
|
+
# the clamp force themselves, so the preload case would load nothing.
|
|
512
|
+
# Drop it and say how to keep it, rather than refuse a study `check`
|
|
513
|
+
# passed and the user never wrote that case for.
|
|
514
|
+
study.load_cases = [c for c in study.load_cases if c.name != "preload"]
|
|
515
|
+
warnings.append("the fasteners' preload case was dropped: the supports hold its seat and face rings along "
|
|
516
|
+
"the screw, so it would load nothing. To design for the clamp, hold the bolt holes across "
|
|
517
|
+
"the screw only (fix 'xy' for a screw along z) or support elsewhere")
|
|
518
|
+
bc = boundary_conditions(study, mesh)
|
|
519
|
+
support_nodes, fixed, load_nodes = bc.support_nodes, bc.fixed, bc.load_nodes
|
|
520
|
+
loads = bc.loads
|
|
521
|
+
names = [c.name for c in study.load_cases]
|
|
522
|
+
weights = [c.weight for c in study.load_cases]
|
|
523
|
+
|
|
524
|
+
# A small load or support (a bolt hole, a pin, a corner support) needs
|
|
525
|
+
# material to act through: the cells touching it are pinned solid, as if
|
|
526
|
+
# preserved, or the boundary-padded filter (simp.density_filter) makes
|
|
527
|
+
# them too expensive to fill and the support is optimized away (the
|
|
528
|
+
# textbook MBB beam's corners). A large one (a whole wall face, a shelf
|
|
529
|
+
# surface) is left to the optimizer: pinning it forced a full skin over
|
|
530
|
+
# the face, which read as part of the design not being optimized at all.
|
|
531
|
+
# A face is told apart by its shape as well as its share of the cells: a
|
|
532
|
+
# region at most two cells thick that spans more than two filter
|
|
533
|
+
# diameters both ways is a face however small the part is around it. By
|
|
534
|
+
# share alone the printed hook's wall face (1.6%) and the wall bracket's
|
|
535
|
+
# shelf (2.5%) were pinned one cell thick: the optimizer never had to
|
|
536
|
+
# thicken that plate, and it was delivered 1.5 and 1.9 mm thick against a
|
|
537
|
+
# 2.4 and 3.0 mm minimum member. A supporting face is left to the
|
|
538
|
+
# optimizer, like any large one. A loaded face is pinned at the minimum
|
|
539
|
+
# member's thickness: its load is spread over every node of it, so the
|
|
540
|
+
# part has to cover it anyway, and left unpinned the optimizer could
|
|
541
|
+
# empty it (the fastener bracket's shelf, which the part then no longer
|
|
542
|
+
# reached). The MBB beam's load line and the motor mount's pads are
|
|
543
|
+
# under two diameters and stay pinned as they were.
|
|
544
|
+
radius = 1.3 * float(study.min_member) if study.min_member else 1.5 * pitch
|
|
545
|
+
active_grid = np.zeros(dims, bool)
|
|
546
|
+
active_grid[tuple(mesh.elem_ijk.T)] = True
|
|
547
|
+
for ri, nodes in enumerate(bc.regions):
|
|
548
|
+
touch = nodes[mesh.enode].any(axis=1)
|
|
549
|
+
if not touch.any():
|
|
550
|
+
continue
|
|
551
|
+
ext = np.ptp(mesh.elem_ijk[touch], axis=0) + 1
|
|
552
|
+
thin = int(np.argmin(ext))
|
|
553
|
+
face = ext[thin] <= 2 and np.sort(ext)[1] * pitch > 4.0 * radius
|
|
554
|
+
# A loaded face is pinned however much of the part it is: a deck
|
|
555
|
+
# under a spread load. Capped like the rest, the 3D bridge benchmark
|
|
556
|
+
# (a load over its whole top, 6% of the cells) left deck nodes on
|
|
557
|
+
# void and its compliance went up a million-fold.
|
|
558
|
+
if touch.sum() > _ANCHOR_MAX_FRACTION * mesh.n_elem and not (face and ri >= len(study.supports)):
|
|
559
|
+
continue
|
|
560
|
+
if face:
|
|
561
|
+
if ri < len(study.supports):
|
|
562
|
+
continue
|
|
563
|
+
layers = int(np.ceil(float(study.min_member or 0.0) / pitch))
|
|
564
|
+
if layers > ext[thin]:
|
|
565
|
+
g = np.zeros(dims, bool)
|
|
566
|
+
g[tuple(mesh.elem_ijk[touch].T)] = True
|
|
567
|
+
step = np.zeros((3, 3, 3), bool)
|
|
568
|
+
step[1, 1, 1] = True
|
|
569
|
+
step[tuple(np.where(np.arange(3) == thin, [0, 0, 0], 1))] = True
|
|
570
|
+
step[tuple(np.where(np.arange(3) == thin, [2, 2, 2], 1))] = True
|
|
571
|
+
g = ndimage.binary_dilation(g, structure=step, iterations=layers - int(ext[thin])) & active_grid
|
|
572
|
+
touch = g[tuple(mesh.elem_ijk.T)]
|
|
573
|
+
pres_el = pres_el | touch
|
|
574
|
+
|
|
575
|
+
mirror = {}
|
|
576
|
+
for axis in study.symmetry:
|
|
577
|
+
a = "xyz".index(axis)
|
|
578
|
+
m_ijk = mesh.elem_ijk.copy()
|
|
579
|
+
m_ijk[:, a] = dims[a] - 1 - m_ijk[:, a]
|
|
580
|
+
lut = np.full(dims, -1, np.int64)
|
|
581
|
+
lut[tuple(mesh.elem_ijk.T)] = np.arange(mesh.n_elem)
|
|
582
|
+
mirror[axis] = lut[tuple(m_ijk.T)]
|
|
583
|
+
unmatched = float((mirror[axis] < 0).mean())
|
|
584
|
+
if unmatched > 0.02:
|
|
585
|
+
warnings.append(f"symmetry '{axis}': {unmatched:.0%} of cells have no mirror; the design space, "
|
|
586
|
+
"preserve or keep-outs are not symmetric about that plane")
|
|
587
|
+
|
|
588
|
+
from .thermal import study_heat
|
|
589
|
+
|
|
590
|
+
return Domain(pitch=pitch, origin=origin, dims=tuple(int(d) for d in dims), mesh=mesh,
|
|
591
|
+
preserve=pres_el, fixed=fixed, loads=loads, case_names=names, case_weights=weights,
|
|
592
|
+
support_nodes=support_nodes, load_nodes=load_nodes, mirror=mirror, warnings=warnings,
|
|
593
|
+
springs=bc.springs, gravity=bc.gravity, spreads=bc.spreads,
|
|
594
|
+
thermal=study_heat(study, mesh, study.elastic(design=True), fixed,
|
|
595
|
+
near=_near_cells(mesh, support_nodes | load_nodes)),
|
|
596
|
+
warp=_warp_term(study, mesh))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _warp_term(study, mesh):
|
|
600
|
+
"""buildSim.warpWeight > 0 or a warp limit (maxDistortion,
|
|
601
|
+
maxRecoaterLift): the as-built build model the optimizer carries
|
|
602
|
+
(buildsim.WarpTerm, on a coarser grid when the mesh is big) and what it
|
|
603
|
+
does with it (WarpSpec)."""
|
|
604
|
+
bs = getattr(study, "build_sim", None)
|
|
605
|
+
if not bs or not (bs.get("warpWeight") or bs.get("maxDistortion") or bs.get("maxRecoaterLift")):
|
|
606
|
+
return None
|
|
607
|
+
if bs["setup"].process.lower() == "fdm" and not bs.get("warpWeight") and not bs.get("warpInOptimizer"):
|
|
608
|
+
# FDM limits are checked on the delivered part (study.py); pushing
|
|
609
|
+
# them through the optimizer cost the load path and did not lower
|
|
610
|
+
# the warp (docs/BUILDSIM.md)
|
|
611
|
+
return None
|
|
612
|
+
from .buildsim import CoarseWarp, WarpTerm
|
|
613
|
+
nu = float(study.elastic(design=True))
|
|
614
|
+
f = bs.get("warpCoarsen") or (2 if mesh.n_elem >= 4000 else 1)
|
|
615
|
+
layer = bs.get("warpLayer")
|
|
616
|
+
if f > 1:
|
|
617
|
+
term = CoarseWarp(mesh, bs["setup"], f, build=study.process.build, layer=layer, nu=nu)
|
|
618
|
+
else:
|
|
619
|
+
term = WarpTerm(mesh, bs["setup"], build=study.process.build, layer=layer, nu=nu)
|
|
620
|
+
return WarpSpec(term, float(bs.get("warpWeight") or 0.0), bs.get("maxDistortion"), bs.get("maxRecoaterLift"),
|
|
621
|
+
f)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@dataclass
|
|
625
|
+
class WarpSpec:
|
|
626
|
+
term: object # buildsim.WarpTerm or CoarseWarp: measures(e, de, weights)
|
|
627
|
+
weight: float # buildSim.warpWeight
|
|
628
|
+
max_distortion: float | None # mm limits, held as constraints
|
|
629
|
+
max_lift: float | None
|
|
630
|
+
coarsen: int = 1
|