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/multimat.py
ADDED
|
@@ -0,0 +1,1788 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-material topology optimization: the optimizer decides where each
|
|
3
|
+
material goes, as well as where material goes at all.
|
|
4
|
+
|
|
5
|
+
A multi-material FDM printer (Bambu AMS, Prusa MMU / XL, an IDEX) or a
|
|
6
|
+
material jetter prints a stiff plastic and a rubber in one part. The
|
|
7
|
+
interesting designs use each where it pays: a PETG or PA-CF skeleton that
|
|
8
|
+
carries the load and a TPU pad that conforms to what it grips, or a bumper
|
|
9
|
+
that turns a knock into a soft, long stop instead of a peak force the
|
|
10
|
+
bracket cannot take. This module optimizes that layout.
|
|
11
|
+
|
|
12
|
+
Design variables, per cell: a density x (material or void, as in simp.py)
|
|
13
|
+
and N - 1 material fractions y (stick-breaking: w_0 = y_0, w_1 = (1 - y_0)
|
|
14
|
+
y_1, ..., the last material takes what is left), so the N weights w_i are
|
|
15
|
+
non-negative and sum to one. Both are cone-filtered and projected with the
|
|
16
|
+
same Heaviside continuation, which makes the material map crisp as well as
|
|
17
|
+
the topology. The cell's Lame parameters are
|
|
18
|
+
|
|
19
|
+
lambda_e = lambda_min + x^p sum_i w_i^p lambda_i (and mu the same)
|
|
20
|
+
|
|
21
|
+
so every material keeps its own Poisson's ratio (TPU's 0.45 is not PETG's
|
|
22
|
+
0.38, and a pad's indentation depends on it): fem.hex8_ke is linear in
|
|
23
|
+
(lambda, mu), so the element matrix is lambda_e K_lambda + mu_e K_mu and
|
|
24
|
+
one assembly carries a different material in every cell. (With the
|
|
25
|
+
condensed bubble element fem.py uses, K is homogeneous in (lambda, mu)
|
|
26
|
+
rather than linear: K_lambda and K_mu are then its derivatives at the cell's
|
|
27
|
+
Lame ratio, tabulated and interpolated, LameStiffness.) The power on w
|
|
28
|
+
makes a 50/50 blend cost the material of both and give the stiffness of
|
|
29
|
+
neither, so the optimizer picks one. Mass is linear: rho_e = x sum w_i rho_i.
|
|
30
|
+
|
|
31
|
+
What a design can be asked for (MMA, mma.py, carries the constraints):
|
|
32
|
+
|
|
33
|
+
* the usual: compliance of the load cases, at a volume fraction (or the
|
|
34
|
+
lightest that meets a safety factor / deflection, by a search like
|
|
35
|
+
study.py's);
|
|
36
|
+
* how much of each material: `maxFraction` / `minFraction` of the design;
|
|
37
|
+
* soft where it has to be. A knock case (`"knock": {"energy": J,
|
|
38
|
+
"maxForce": N}`) wants the part soft at the knock point: a mass that hits
|
|
39
|
+
with energy E stops on the part as on a spring, with peak force
|
|
40
|
+
F = sqrt(2 E / c), c the part's compliance there (mm/N), so the peak
|
|
41
|
+
force limit is a lower limit on c (impact.py's solid_impact, as a
|
|
42
|
+
constraint). An indentation case (`"indentation": {"relativeTo": case,
|
|
43
|
+
"atLeast": mm}`) asks a small patch to sink at least that far more under
|
|
44
|
+
its own load than the same patch moves when the same force is spread
|
|
45
|
+
over the pad (relativeTo): the grip pad's conformity, read at the patch
|
|
46
|
+
so the finger's bending there cancels;
|
|
47
|
+
* interfaces that hold. Two materials meet at a bond that is weaker than
|
|
48
|
+
either (interface_library.py sources it per pair: PETG/TPU bonds,
|
|
49
|
+
PLA/PETG barely does). The traction across each interface (normal pull
|
|
50
|
+
and shear, on the interface's own normal, from the material field's
|
|
51
|
+
gradient) is checked against the pair's strength. Once the layout has
|
|
52
|
+
converged, a bond stage freezes the topology and moves only the materials
|
|
53
|
+
to take stress off the bond. Verification then reads the bond on the
|
|
54
|
+
delivered voxel model face by face. With `interlock`, the materials
|
|
55
|
+
interpenetrate in a beam lattice where they meet (interlock.py) and the
|
|
56
|
+
bond is credited with the measured gain of an interlocked joint.
|
|
57
|
+
|
|
58
|
+
Delivery: the solid is meshed by reconstruct.py as any study is, then cut
|
|
59
|
+
into one body per material with exact booleans (manifold3d) against a
|
|
60
|
+
mesh of the material field, so neighbouring bodies share their interface
|
|
61
|
+
surface exactly: no gaps, no overlap, which is what a slicer needs to give
|
|
62
|
+
each body its own extruder. Outputs are one STL per material and a
|
|
63
|
+
multi-material 3MF (threemf.write_multimaterial_3mf).
|
|
64
|
+
|
|
65
|
+
The analysis is linear elastic. TPU at small strain is a fair spring for a
|
|
66
|
+
pad or a bumper's first millimetres; large strokes belong to nonlinear.py.
|
|
67
|
+
"""
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import time
|
|
71
|
+
from dataclasses import dataclass, field
|
|
72
|
+
|
|
73
|
+
import numpy as np
|
|
74
|
+
from scipy import ndimage
|
|
75
|
+
|
|
76
|
+
from .fem import CORNERS, HexMesh, Solver, Stiffness, _b_matrix
|
|
77
|
+
from .materials import Material, material_from_spec
|
|
78
|
+
|
|
79
|
+
__all__ = ["MultiMaterial", "Requirement", "parse_multimaterial", "optimize_multimaterial",
|
|
80
|
+
"run_multimaterial", "verify_multimaterial", "split_bodies", "lame", "LameStiffness",
|
|
81
|
+
"MultiMaterialResult", "write_bodies_3mf"]
|
|
82
|
+
|
|
83
|
+
PENALTY = 3.0
|
|
84
|
+
E_FLOOR = 1e-6 # void stiffness, as a fraction of the stiffest material
|
|
85
|
+
_CONN26 = np.ones((3, 3, 3), bool)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------- parsing
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Requirement:
|
|
92
|
+
"""A load case that asks the part to be soft somewhere."""
|
|
93
|
+
case: str
|
|
94
|
+
kind: str # "knock" | "indentation"
|
|
95
|
+
energy: float | None = None # J (knock)
|
|
96
|
+
max_force: float | None = None # N (knock)
|
|
97
|
+
relative_to: str | None = None # case name (indentation)
|
|
98
|
+
at_least: float | None = None # mm (indentation)
|
|
99
|
+
safety_factor: float | None = None # on the stored energy's stress (knock)
|
|
100
|
+
|
|
101
|
+
def to_dict(self):
|
|
102
|
+
if self.kind == "knock":
|
|
103
|
+
return {"case": self.case, "knock": {"energy": self.energy, "maxForce": self.max_force}}
|
|
104
|
+
return {"case": self.case, "indentation": {"relativeTo": self.relative_to, "atLeast": self.at_least}}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class MultiMaterial:
|
|
109
|
+
materials: list # Material, at least two
|
|
110
|
+
assign: list = field(default_factory=list) # (kind, payload, material index)
|
|
111
|
+
max_fraction: dict = field(default_factory=dict) # index -> fraction of the solid
|
|
112
|
+
min_fraction: dict = field(default_factory=dict)
|
|
113
|
+
requirements: list = field(default_factory=list)
|
|
114
|
+
interface_safety_factor: float | None = None
|
|
115
|
+
interfaces: dict = field(default_factory=dict) # "a+b" -> {"tensile", "shear"} override
|
|
116
|
+
interface_penalty: bool = True
|
|
117
|
+
extruders: dict = field(default_factory=dict) # material name -> extruder (1-based)
|
|
118
|
+
slicer: str = "all"
|
|
119
|
+
interlock: object = None # interlock.Interlock, or None: flat bonds
|
|
120
|
+
bond_in_topology: bool = False # experimental: bond constrained from beta 4
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def names(self):
|
|
124
|
+
return [m.name for m in self.materials]
|
|
125
|
+
|
|
126
|
+
def index(self, name):
|
|
127
|
+
key = str(name).strip().lower()
|
|
128
|
+
for i, m in enumerate(self.materials):
|
|
129
|
+
if m.name.lower() == key:
|
|
130
|
+
return i
|
|
131
|
+
try:
|
|
132
|
+
want = material_from_spec(name).name
|
|
133
|
+
except ValueError:
|
|
134
|
+
want = None
|
|
135
|
+
for i, m in enumerate(self.materials):
|
|
136
|
+
if want is not None and m.name == want:
|
|
137
|
+
return i
|
|
138
|
+
raise ValueError(f"multiMaterial: {name!r} is not one of the materials {self.names}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_multimaterial(d, study=None, base_dir=None) -> MultiMaterial | None:
|
|
142
|
+
"""The study's `multiMaterial` block (see schema.py), or None."""
|
|
143
|
+
if d is None or d is False:
|
|
144
|
+
return None
|
|
145
|
+
if not isinstance(d, dict):
|
|
146
|
+
raise ValueError("multiMaterial is an object: {materials: [..], assign?, maxFraction?, ...}")
|
|
147
|
+
mats_in = d.get("materials")
|
|
148
|
+
if not isinstance(mats_in, list) or len(mats_in) < 2:
|
|
149
|
+
raise ValueError("multiMaterial.materials lists two to four materials (names or custom objects)")
|
|
150
|
+
if len(mats_in) > 4:
|
|
151
|
+
raise ValueError("multiMaterial takes at most four materials")
|
|
152
|
+
mats = [material_from_spec(m) for m in mats_in]
|
|
153
|
+
if len({m.name for m in mats}) != len(mats):
|
|
154
|
+
raise ValueError("multiMaterial.materials has the same material twice")
|
|
155
|
+
mm = MultiMaterial(mats)
|
|
156
|
+
for i, a in enumerate(d.get("assign") or []):
|
|
157
|
+
if not isinstance(a, dict) or "material" not in a:
|
|
158
|
+
raise ValueError(f"multiMaterial.assign[{i}] needs a material and a region, shape or near")
|
|
159
|
+
k = mm.index(a["material"])
|
|
160
|
+
if "region" in a:
|
|
161
|
+
mm.assign.append(("region", str(a["region"]), k))
|
|
162
|
+
elif "shape" in a:
|
|
163
|
+
from .shapes import shape_from_spec
|
|
164
|
+
mm.assign.append(("shape", shape_from_spec(a["shape"], base_dir), k))
|
|
165
|
+
elif "near" in a:
|
|
166
|
+
n = a["near"]
|
|
167
|
+
if not isinstance(n, dict) or "distance" not in n or not ("region" in n or "shape" in n):
|
|
168
|
+
raise ValueError(f"multiMaterial.assign[{i}].near is {{region or shape, distance (mm)}}")
|
|
169
|
+
if float(n["distance"]) <= 0:
|
|
170
|
+
raise ValueError(f"multiMaterial.assign[{i}].near.distance must be > 0 (mm)")
|
|
171
|
+
src = ("region", str(n["region"])) if "region" in n else ("shape", n["shape"])
|
|
172
|
+
within = a.get("within")
|
|
173
|
+
mm.assign.append(("near", (src, float(n["distance"]), within), k))
|
|
174
|
+
else:
|
|
175
|
+
raise ValueError(f"multiMaterial.assign[{i}] needs region, shape or near")
|
|
176
|
+
for key, dest in (("maxFraction", mm.max_fraction), ("minFraction", mm.min_fraction)):
|
|
177
|
+
for name, v in (d.get(key) or {}).items():
|
|
178
|
+
if not (0.0 < float(v) <= 1.0):
|
|
179
|
+
raise ValueError(f"multiMaterial.{key}.{name} is a fraction of the part's volume (0..1]")
|
|
180
|
+
dest[mm.index(name)] = float(v)
|
|
181
|
+
for i, r in enumerate(d.get("requirements") or []):
|
|
182
|
+
if not isinstance(r, dict) or "case" not in r:
|
|
183
|
+
raise ValueError(f"multiMaterial.requirements[{i}] needs a case and knock or indentation")
|
|
184
|
+
if "knock" in r:
|
|
185
|
+
k = r["knock"]
|
|
186
|
+
if float(k.get("energy", 0)) <= 0 or float(k.get("maxForce", 0)) <= 0:
|
|
187
|
+
raise ValueError(f"multiMaterial.requirements[{i}].knock needs energy (J) and maxForce (N) > 0")
|
|
188
|
+
mm.requirements.append(Requirement(str(r["case"]), "knock", energy=float(k["energy"]),
|
|
189
|
+
max_force=float(k["maxForce"]),
|
|
190
|
+
safety_factor=float(k["safetyFactor"]) if k.get("safetyFactor")
|
|
191
|
+
else None))
|
|
192
|
+
elif "indentation" in r:
|
|
193
|
+
k = r["indentation"]
|
|
194
|
+
if "relativeTo" not in k or float(k.get("atLeast", 0)) <= 0:
|
|
195
|
+
raise ValueError(f"multiMaterial.requirements[{i}].indentation needs relativeTo (a case) and "
|
|
196
|
+
"atLeast (mm) > 0")
|
|
197
|
+
mm.requirements.append(Requirement(str(r["case"]), "indentation", relative_to=str(k["relativeTo"]),
|
|
198
|
+
at_least=float(k["atLeast"])))
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError(f"multiMaterial.requirements[{i}] needs knock or indentation")
|
|
201
|
+
if d.get("interfaceSafetyFactor") is not None:
|
|
202
|
+
mm.interface_safety_factor = float(d["interfaceSafetyFactor"])
|
|
203
|
+
if mm.interface_safety_factor <= 0:
|
|
204
|
+
raise ValueError("multiMaterial.interfaceSafetyFactor must be positive")
|
|
205
|
+
for pair, v in (d.get("interfaces") or {}).items():
|
|
206
|
+
if not isinstance(v, dict) or "tensile" not in v:
|
|
207
|
+
raise ValueError(f"multiMaterial.interfaces.{pair} is {{tensile (MPa), shear? (MPa)}}")
|
|
208
|
+
names = [s.strip() for s in str(pair).split("+")]
|
|
209
|
+
if len(names) != 2:
|
|
210
|
+
raise ValueError(f"multiMaterial.interfaces key {pair!r} is 'a+b'")
|
|
211
|
+
a, b = sorted(mm.materials[mm.index(n)].name for n in names)
|
|
212
|
+
c = {"tensile": float(v["tensile"]), "shear": float(v.get("shear", v["tensile"])), "source": "study"}
|
|
213
|
+
for k_ in ("tensileZ", "shearZ"):
|
|
214
|
+
if k_ in v:
|
|
215
|
+
c[k_] = float(v[k_])
|
|
216
|
+
mm.interfaces[f"{a}+{b}"] = c
|
|
217
|
+
mm.interface_penalty = bool(d.get("interfacePenalty", True))
|
|
218
|
+
mm.bond_in_topology = bool(d.get("bondInTopology", False))
|
|
219
|
+
if d.get("interlock") not in (None, False):
|
|
220
|
+
from .interlock import parse_interlock
|
|
221
|
+
mm.interlock = parse_interlock(d["interlock"], getattr(study, "process", None))
|
|
222
|
+
for p in mm.interlock.pairs or []:
|
|
223
|
+
for n_ in p.split("+"):
|
|
224
|
+
mm.index(n_)
|
|
225
|
+
ex = d.get("export") or {}
|
|
226
|
+
for name, e in (ex.get("extruders") or {}).items():
|
|
227
|
+
mm.extruders[mats[mm.index(name)].name] = int(e)
|
|
228
|
+
mm.slicer = str(ex.get("slicer", "all"))
|
|
229
|
+
if study is not None:
|
|
230
|
+
names = [c.name for c in study.load_cases]
|
|
231
|
+
for r in mm.requirements:
|
|
232
|
+
if r.case not in names:
|
|
233
|
+
raise ValueError(f"multiMaterial requirement names case {r.case!r}; cases: {names}")
|
|
234
|
+
if r.relative_to is not None and r.relative_to not in names:
|
|
235
|
+
raise ValueError(f"multiMaterial indentation relativeTo {r.relative_to!r}; cases: {names}")
|
|
236
|
+
for kind, payload, _ in mm.assign:
|
|
237
|
+
if kind == "region":
|
|
238
|
+
study.region_shape(payload)
|
|
239
|
+
return mm
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------ interfaces
|
|
243
|
+
|
|
244
|
+
def interface_card(mm: MultiMaterial, i: int, j: int, process=None, interlock: bool = True) -> dict:
|
|
245
|
+
"""Bond strength (MPa) between materials i and j: the study's override,
|
|
246
|
+
else interface_library's sourced or conservative card; with the
|
|
247
|
+
study's interlock credited (interlock.interlocked_card) unless
|
|
248
|
+
`interlock` is False."""
|
|
249
|
+
card = _flat_card(mm, i, j, process)
|
|
250
|
+
il = mm.interlock
|
|
251
|
+
if interlock and il is not None and il.applies(mm.materials[i].name, mm.materials[j].name):
|
|
252
|
+
from .interlock import interlocked_card
|
|
253
|
+
return interlocked_card(card, mm.materials[i], mm.materials[j], il)
|
|
254
|
+
return card
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _flat_card(mm, i, j, process=None) -> dict:
|
|
258
|
+
a, b = sorted((mm.materials[i].name, mm.materials[j].name))
|
|
259
|
+
if f"{a}+{b}" in mm.interfaces:
|
|
260
|
+
c = dict(mm.interfaces[f"{a}+{b}"])
|
|
261
|
+
c.setdefault("tensileZ", c["tensile"])
|
|
262
|
+
c.setdefault("shearZ", c["shear"])
|
|
263
|
+
return c
|
|
264
|
+
try:
|
|
265
|
+
from .interface_library import interface_strength
|
|
266
|
+
card = interface_strength(mm.materials[i], mm.materials[j], getattr(process, "kind", process))
|
|
267
|
+
return {"tensile": card.tensile, "shear": card.shear,
|
|
268
|
+
"tensileZ": card.tensile_z if card.tensile_z is not None else card.tensile,
|
|
269
|
+
"shearZ": card.shear_z if card.shear_z is not None else card.shear,
|
|
270
|
+
"bond": card.bond, "source": card.source, "conservative": card.conservative}
|
|
271
|
+
except (ImportError, ValueError):
|
|
272
|
+
# no card: a quarter of the weaker material's across-layer strength
|
|
273
|
+
weak = min((m.strength_z or m.strength) for m in (mm.materials[i], mm.materials[j]))
|
|
274
|
+
return {"tensile": 0.25 * weak, "shear": 0.25 * weak, "tensileZ": 0.25 * weak, "shearZ": 0.25 * weak,
|
|
275
|
+
"bond": "unknown", "source": "default", "conservative": True}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------- per-cell elastics
|
|
279
|
+
|
|
280
|
+
def lame(E, nu):
|
|
281
|
+
E, nu = np.asarray(E, float), np.asarray(nu, float)
|
|
282
|
+
return E * nu / ((1 + nu) * (1 - 2 * nu)), E / (2 * (1 + nu))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _ke_parts(q: float = 0.5, element: str | None = None):
|
|
286
|
+
"""Unit-cube hex8 stiffness split as K = lambda K_l + mu K_m, at the
|
|
287
|
+
Lame ratio q = lambda / (lambda + mu).
|
|
288
|
+
|
|
289
|
+
For the plain trilinear hex8 K is linear in (lambda, mu) and the split
|
|
290
|
+
does not depend on q. The element fem.py uses (fem.hex8_element) has
|
|
291
|
+
its bubble modes condensed out, which divides by C, so K is only
|
|
292
|
+
homogeneous of degree one: K_l and K_m are its partial derivatives at
|
|
293
|
+
the cell's ratio (Euler: lambda dK/dlambda + mu dK/dmu = K exactly)."""
|
|
294
|
+
from .fem import _b_bubble, hex8_element
|
|
295
|
+
|
|
296
|
+
element = element or hex8_element()
|
|
297
|
+
cl = np.zeros((6, 6))
|
|
298
|
+
cl[:3, :3] = 1.0
|
|
299
|
+
cm = np.diag([2.0, 2.0, 2.0, 1.0, 1.0, 1.0])
|
|
300
|
+
g = 1.0 / np.sqrt(3.0)
|
|
301
|
+
parts = {}
|
|
302
|
+
for name, c in (("l", cl), ("m", cm)):
|
|
303
|
+
kuu, kua, kaa = np.zeros((24, 24)), np.zeros((24, 9)), np.zeros((9, 9))
|
|
304
|
+
for xi in (-g, g):
|
|
305
|
+
for eta in (-g, g):
|
|
306
|
+
for zeta in (-g, g):
|
|
307
|
+
b = _b_matrix(xi, eta, zeta)
|
|
308
|
+
ba = _b_bubble(xi, eta, zeta)
|
|
309
|
+
kuu += b.T @ c @ b * 0.125
|
|
310
|
+
kua += b.T @ c @ ba * 0.125
|
|
311
|
+
kaa += ba.T @ c @ ba * 0.125
|
|
312
|
+
parts[name] = (kuu, kua, kaa)
|
|
313
|
+
if element == "plain":
|
|
314
|
+
kl, km = parts["l"][0], parts["m"][0]
|
|
315
|
+
else:
|
|
316
|
+
kua = q * parts["l"][1] + (1 - q) * parts["m"][1]
|
|
317
|
+
kaa = q * parts["l"][2] + (1 - q) * parts["m"][2]
|
|
318
|
+
r = -np.linalg.solve(kaa, kua.T)
|
|
319
|
+
# dK = dKuu + dKua R + R^T dKau + R^T dKaa R, R = -Kaa^-1 Kau
|
|
320
|
+
kl, km = ((p[0] + p[1] @ r + r.T @ p[1].T + r.T @ p[2] @ r) for p in (parts["l"], parts["m"]))
|
|
321
|
+
return 0.5 * (kl + kl.T), 0.5 * (km + km.T)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# Lame ratios at which the split is tabulated (q = lambda / (lambda + mu),
|
|
325
|
+
# 0.41 at nu = 0.3, 0.9 at nu = 0.45); between them it is interpolated
|
|
326
|
+
# linearly, and every material's own ratio is a node, so a crisp cell's
|
|
327
|
+
# matrix is exact.
|
|
328
|
+
_Q_GRID = np.linspace(0.0, 0.98, 50)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class LameStiffness:
|
|
332
|
+
"""fem.Stiffness's CSR layout with a (lambda, mu) pair per element.
|
|
333
|
+
`ratios` are the Lame ratios lambda / (lambda + mu) of the materials (and
|
|
334
|
+
the void floor) the cells will mix, tabulated exactly."""
|
|
335
|
+
|
|
336
|
+
def __init__(self, mesh: HexMesh, fixed: np.ndarray, ratios=()):
|
|
337
|
+
from .fem import hex8_element
|
|
338
|
+
|
|
339
|
+
self.mesh = mesh
|
|
340
|
+
self.base = Stiffness(mesh, 0.3, fixed)
|
|
341
|
+
self.element = hex8_element()
|
|
342
|
+
if self.element == "plain":
|
|
343
|
+
q = np.zeros(1)
|
|
344
|
+
else:
|
|
345
|
+
q = np.unique(np.concatenate([_Q_GRID, np.asarray(ratios, float).ravel()]))
|
|
346
|
+
self.q = q
|
|
347
|
+
self.kl, self.km = [], []
|
|
348
|
+
self._blocks = []
|
|
349
|
+
for qq in q:
|
|
350
|
+
kl, km = _ke_parts(qq, self.element)
|
|
351
|
+
self.kl.append(kl * mesh.pitch)
|
|
352
|
+
self.km.append(km * mesh.pitch)
|
|
353
|
+
self._blocks.append([(self.kl[-1][3 * a:3 * a + 3, 3 * b:3 * b + 3].ravel(),
|
|
354
|
+
self.km[-1][3 * a:3 * a + 3, 3 * b:3 * b + 3].ravel())
|
|
355
|
+
for a in range(8) for b in range(8)])
|
|
356
|
+
self._lm = None
|
|
357
|
+
|
|
358
|
+
def _bins(self, lam, mu):
|
|
359
|
+
lam = np.broadcast_to(np.asarray(lam, float), (self.mesh.n_elem,))
|
|
360
|
+
mu = np.broadcast_to(np.asarray(mu, float), (self.mesh.n_elem,))
|
|
361
|
+
s = lam + mu
|
|
362
|
+
if len(self.q) == 1:
|
|
363
|
+
z = np.zeros(len(lam), np.int64)
|
|
364
|
+
return lam, mu, s, z, np.zeros(len(lam)), np.ones(len(lam))
|
|
365
|
+
q = np.clip(lam / s, self.q[0], self.q[-1])
|
|
366
|
+
j = np.clip(np.searchsorted(self.q, q, side="right") - 1, 0, len(self.q) - 2)
|
|
367
|
+
dq = self.q[j + 1] - self.q[j]
|
|
368
|
+
return lam, mu, s, j, (q - self.q[j]) / dq, dq
|
|
369
|
+
|
|
370
|
+
def _nodes(self, j, w):
|
|
371
|
+
"""(node, element indices, weight) for every tabulated ratio in use."""
|
|
372
|
+
out = []
|
|
373
|
+
for g in np.unique(np.concatenate([j, j + 1]) if len(self.q) > 1 else j):
|
|
374
|
+
lo, hi = np.nonzero(j == g)[0], np.nonzero(j + 1 == g)[0] if len(self.q) > 1 else np.zeros(0, int)
|
|
375
|
+
el = np.concatenate([lo, hi])
|
|
376
|
+
wt = np.concatenate([1.0 - w[lo], w[hi]])
|
|
377
|
+
keep = wt != 0.0
|
|
378
|
+
if keep.any():
|
|
379
|
+
out.append((int(g), el[keep], wt[keep]))
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
def assemble(self, lam, mu, springs=None):
|
|
383
|
+
st = self.base
|
|
384
|
+
data = np.zeros(st.nnz)
|
|
385
|
+
lam, mu, _, j, w, _ = self._bins(lam, mu)
|
|
386
|
+
self._lm = (np.array(lam), np.array(mu))
|
|
387
|
+
for g, el, wt in self._nodes(j, w):
|
|
388
|
+
a, b = (wt * lam[el])[:, None], (wt * mu[el])[:, None]
|
|
389
|
+
for (slots, _), (bl, bm) in zip(st._pairs, self._blocks[g]):
|
|
390
|
+
data[slots[el]] += a * bl + b * bm
|
|
391
|
+
k = st.finish(data)
|
|
392
|
+
if springs is not None:
|
|
393
|
+
k = (k + springs).tocsr()
|
|
394
|
+
return k
|
|
395
|
+
|
|
396
|
+
def apply(self, ue, lam=None, mu=None):
|
|
397
|
+
"""Per element K_e u_e (ne, 24)."""
|
|
398
|
+
lam, mu = (lam, mu) if lam is not None else self._lm
|
|
399
|
+
lam, mu, _, j, w, _ = self._bins(lam, mu)
|
|
400
|
+
out = np.zeros_like(ue)
|
|
401
|
+
for g, el, wt in self._nodes(j, w):
|
|
402
|
+
out[el] += (wt * lam[el])[:, None] * (ue[el] @ self.kl[g]) + (wt * mu[el])[:, None] * (ue[el] @ self.km[g])
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
def pair(self, x, y, lam=None, mu=None):
|
|
406
|
+
"""Per element (x^T dK/dlambda y, x^T dK/dmu y), the derivatives of
|
|
407
|
+
the tabulated K (interpolation slope included), at the moduli last
|
|
408
|
+
assembled unless given."""
|
|
409
|
+
lam, mu = (lam, mu) if lam is not None else self._lm
|
|
410
|
+
lam, mu, s, j, w, dq = self._bins(lam, mu)
|
|
411
|
+
n = len(lam)
|
|
412
|
+
fl = np.zeros((2, n))
|
|
413
|
+
fm = np.zeros((2, n))
|
|
414
|
+
for g in np.unique(j):
|
|
415
|
+
el = np.nonzero(j == g)[0]
|
|
416
|
+
for side in (0, 1) if len(self.q) > 1 else (0,):
|
|
417
|
+
kl, km = self.kl[g + side], self.km[g + side]
|
|
418
|
+
fl[side, el] = np.einsum("ij,jk,ik->i", x[el], kl, y[el])
|
|
419
|
+
fm[side, el] = np.einsum("ij,jk,ik->i", x[el], km, y[el])
|
|
420
|
+
if len(self.q) == 1:
|
|
421
|
+
return fl[0], fm[0]
|
|
422
|
+
el_ = (1 - w) * fl[0] + w * fl[1]
|
|
423
|
+
em_ = (1 - w) * fm[0] + w * fm[1]
|
|
424
|
+
d = (lam * (fl[1] - fl[0]) + mu * (fm[1] - fm[0])) / dq # x^T dK/dq y
|
|
425
|
+
return el_ + d * mu / s ** 2, em_ - d * lam / s ** 2
|
|
426
|
+
|
|
427
|
+
def energies(self, u, lam=None, mu=None):
|
|
428
|
+
"""Per element (u^T dK/dlambda u, u^T dK/dmu u); lambda times the
|
|
429
|
+
first plus mu times the second is the element's u^T K u."""
|
|
430
|
+
ue = u[self.mesh.edof]
|
|
431
|
+
return self.pair(ue, ue, lam, mu)
|
|
432
|
+
|
|
433
|
+
def stress(self, u, lam, mu):
|
|
434
|
+
"""Element-centre stress (ne, 6), engineering shear strain (the
|
|
435
|
+
bubbles have no strain at the centre)."""
|
|
436
|
+
b = _b_matrix(0.0, 0.0, 0.0) / self.mesh.pitch
|
|
437
|
+
eps = u[self.mesh.edof] @ b.T
|
|
438
|
+
tr = eps[:, :3].sum(axis=1)
|
|
439
|
+
s = np.empty_like(eps)
|
|
440
|
+
s[:, :3] = 2.0 * mu[:, None] * eps[:, :3] + (lam * tr)[:, None]
|
|
441
|
+
s[:, 3:] = mu[:, None] * eps[:, 3:]
|
|
442
|
+
return s
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _von_mises(s):
|
|
446
|
+
sx, sy, sz, txy, tyz, tzx = s.T
|
|
447
|
+
return np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2) + 3.0 * (txy ** 2 + tyz ** 2 + tzx ** 2))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_VOIGT_T = np.array([[0, 3, 5], [3, 1, 4], [5, 4, 2]]) # (xx yy zz xy yz zx) -> tensor
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _traction_util(s, n, card, build_axis=None):
|
|
454
|
+
"""Interface utilization (>= 0; 1 = at the bond's strength) of stress s
|
|
455
|
+
(k, 6) on unit normals n (k, 3): (pull / S_t)^2 + (shear / S_s)^2,
|
|
456
|
+
square-rooted. Compression across a bond does not open it. A normal
|
|
457
|
+
along the build axis is a stacked interface (one material printed on
|
|
458
|
+
the other) and takes the card's across-layer values; in between, a
|
|
459
|
+
blend by n_z^2."""
|
|
460
|
+
t = np.einsum("kij,kj->ki", s[:, _VOIGT_T], n)
|
|
461
|
+
sn = np.einsum("ki,ki->k", t, n)
|
|
462
|
+
tau = np.linalg.norm(t - sn[:, None] * n, axis=1)
|
|
463
|
+
st, ss = card["tensile"], card["shear"]
|
|
464
|
+
if build_axis is not None:
|
|
465
|
+
c2 = n[:, build_axis] ** 2
|
|
466
|
+
st = st + (card["tensileZ"] - st) * c2
|
|
467
|
+
ss = ss + (card["shearZ"] - ss) * c2
|
|
468
|
+
return np.sqrt((np.maximum(sn, 0.0) / st) ** 2 + (tau / ss) ** 2)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _build_axis(study):
|
|
472
|
+
pd = getattr(study, "print_direction", None)
|
|
473
|
+
if pd is None and study.process is not None and study.process.build not in (None, "auto"):
|
|
474
|
+
pd = study.process.build
|
|
475
|
+
if pd is None:
|
|
476
|
+
return 2 if (study.process is not None and study.process.kind == "fdm") else None
|
|
477
|
+
if isinstance(pd, tuple) and len(pd) == 2 and isinstance(pd[0], (int, np.integer)):
|
|
478
|
+
return int(pd[0])
|
|
479
|
+
v = np.abs(np.asarray(pd, float).ravel())
|
|
480
|
+
return int(np.argmax(v)) if v.size == 3 else 2
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ----------------------------------------------------------- weights
|
|
484
|
+
|
|
485
|
+
def _weights(y):
|
|
486
|
+
"""Stick-breaking: y (N-1, n) in [0,1] -> w (N, n) and dw/dy (N, N-1, n)."""
|
|
487
|
+
m, n = y.shape
|
|
488
|
+
N = m + 1
|
|
489
|
+
w = np.empty((N, n))
|
|
490
|
+
dw = np.zeros((N, m, n))
|
|
491
|
+
one = 1.0 - y
|
|
492
|
+
for i in range(N):
|
|
493
|
+
prev = np.prod(one[:i], axis=0) if i else np.ones(n)
|
|
494
|
+
w[i] = prev * (y[i] if i < m else 1.0)
|
|
495
|
+
for j in range(min(i + 1, m)):
|
|
496
|
+
if j < i:
|
|
497
|
+
others = np.prod(np.delete(one[:i], j, axis=0), axis=0) if i > 1 else np.ones(n)
|
|
498
|
+
dw[i, j] = -others * (y[i] if i < m else 1.0)
|
|
499
|
+
else: # j == i < m
|
|
500
|
+
dw[i, j] = prev
|
|
501
|
+
return w, dw
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _y_for(k, N):
|
|
505
|
+
"""The y that puts all of a cell into material k."""
|
|
506
|
+
y = np.zeros(N - 1)
|
|
507
|
+
if k < N - 1:
|
|
508
|
+
y[k] = 1.0
|
|
509
|
+
return y
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _project(xt, beta, eta=0.5):
|
|
513
|
+
den = np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
|
|
514
|
+
return (np.tanh(beta * eta) + np.tanh(beta * (xt - eta))) / den
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _dproject(xt, beta, eta=0.5):
|
|
518
|
+
den = np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
|
|
519
|
+
return beta * (1 - np.tanh(beta * (xt - eta)) ** 2) / den
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------------ optimizer
|
|
523
|
+
|
|
524
|
+
def assignment(study, domain, mm: MultiMaterial) -> np.ndarray:
|
|
525
|
+
"""Per cell the material index the study fixes there, or -1."""
|
|
526
|
+
from .domain import _fraction
|
|
527
|
+
|
|
528
|
+
centers = domain.mesh.elem_centers()
|
|
529
|
+
out = np.full(domain.n_elem, -1, np.int64)
|
|
530
|
+
for kind, payload, k in mm.assign:
|
|
531
|
+
if kind == "region":
|
|
532
|
+
hit = _fraction([study.region_shape(payload)[0]], centers, domain.pitch) >= 0.5
|
|
533
|
+
elif kind == "shape":
|
|
534
|
+
hit = _fraction([payload], centers, domain.pitch) >= 0.5
|
|
535
|
+
else:
|
|
536
|
+
(src_kind, src), dist, within = payload
|
|
537
|
+
if src_kind == "region":
|
|
538
|
+
shape = study.region_shape(src)[0]
|
|
539
|
+
else:
|
|
540
|
+
from .shapes import shape_from_spec
|
|
541
|
+
shape = shape_from_spec(src)
|
|
542
|
+
hit = shape.sdf(centers) <= dist
|
|
543
|
+
if within is not None:
|
|
544
|
+
hit &= study.region_shape(str(within))[0].contains(centers)
|
|
545
|
+
out[hit & (out < 0)] = k
|
|
546
|
+
return out
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _case_force(f):
|
|
550
|
+
return float(np.linalg.norm([f[0::3].sum(), f[1::3].sum(), f[2::3].sum()]))
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@dataclass
|
|
554
|
+
class MMOptimum:
|
|
555
|
+
x: np.ndarray # design density variables, per cell
|
|
556
|
+
y: np.ndarray # (N-1, n) material variables
|
|
557
|
+
xphys: np.ndarray # projected density
|
|
558
|
+
w: np.ndarray # (N, n) projected material weights
|
|
559
|
+
compliance: list
|
|
560
|
+
iterations: int
|
|
561
|
+
seconds: float
|
|
562
|
+
history: list
|
|
563
|
+
interface_weight: float
|
|
564
|
+
requirement_values: dict
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def optimize_multimaterial(study, domain, mm: MultiMaterial, volume_fraction: float, radius: float,
|
|
568
|
+
max_iterations: int | None = None, x0=None, y0=None, beta_max: float = 16.0,
|
|
569
|
+
on_iter=None, should_stop=None) -> MMOptimum:
|
|
570
|
+
from .mma import MMA
|
|
571
|
+
from .simp import _symmetrize, density_filter
|
|
572
|
+
|
|
573
|
+
t0 = time.time()
|
|
574
|
+
N = len(mm.materials)
|
|
575
|
+
n = domain.n_elem
|
|
576
|
+
mesh = domain.mesh
|
|
577
|
+
max_iterations = int(max_iterations or study.max_iterations)
|
|
578
|
+
Es = np.array([m.E for m in mm.materials])
|
|
579
|
+
lam_i, mu_i = lame(Es, np.array([m.nu for m in mm.materials]))
|
|
580
|
+
rho_i = np.array([m.density for m in mm.materials])
|
|
581
|
+
e_max = Es.max()
|
|
582
|
+
lam_min, mu_min = lame(E_FLOOR * e_max, 0.3)
|
|
583
|
+
springs = e_max * domain.springs if domain.springs is not None else None
|
|
584
|
+
stiff = LameStiffness(mesh, domain.fixed, ratios=np.concatenate([lam_i / (lam_i + mu_i),
|
|
585
|
+
[lam_min / (lam_min + mu_min)]]))
|
|
586
|
+
solver = Solver(mesh, rtol=1e-6, maxiter=2000)
|
|
587
|
+
h, hs = density_filter(domain, radius)
|
|
588
|
+
hs_y = np.asarray(h @ np.ones(n)).ravel()
|
|
589
|
+
pres = domain.preserve
|
|
590
|
+
design = ~pres
|
|
591
|
+
fixed_mat = assignment(study, domain, mm)
|
|
592
|
+
free_y = fixed_mat < 0
|
|
593
|
+
nx, ny = int(design.sum()), int(free_y.sum())
|
|
594
|
+
m_y = N - 1
|
|
595
|
+
names = list(domain.case_names)
|
|
596
|
+
loads = list(domain.loads)
|
|
597
|
+
forces = [_case_force(f) for f in loads]
|
|
598
|
+
req_cases = {r.case for r in mm.requirements}
|
|
599
|
+
weights = [0.0 if names[c] in req_cases else float(domain.case_weights[c]) for c in range(len(loads))]
|
|
600
|
+
if not any(weights):
|
|
601
|
+
raise ValueError("multiMaterial: every load case is a softness requirement; one has to be the stiffness "
|
|
602
|
+
"objective")
|
|
603
|
+
build_axis = _build_axis(study)
|
|
604
|
+
pairs = [(i, j) for i in range(N) for j in range(i + 1, N)]
|
|
605
|
+
cards = {p: interface_card(mm, *p, study.process) for p in pairs}
|
|
606
|
+
flat_cards = {p: interface_card(mm, *p, study.process, interlock=False) for p in pairs}
|
|
607
|
+
sf_int = mm.interface_safety_factor or (study.goal.safety_factor if study.goal.safety_factor else 2.0)
|
|
608
|
+
# a knock with its own safetyFactor is judged at it (an accidental
|
|
609
|
+
# load, at its peak), every other case at the interface's
|
|
610
|
+
case_sf = [sf_int] * len(domain.loads)
|
|
611
|
+
for r in mm.requirements:
|
|
612
|
+
if r.kind == "knock" and r.safety_factor:
|
|
613
|
+
case_sf[list(domain.case_names).index(r.case)] = r.safety_factor
|
|
614
|
+
mirror = domain.mirror or {}
|
|
615
|
+
|
|
616
|
+
# the y a fixed cell holds
|
|
617
|
+
y_fixed = np.zeros((m_y, n))
|
|
618
|
+
for k in range(N):
|
|
619
|
+
cells = fixed_mat == k
|
|
620
|
+
if cells.any():
|
|
621
|
+
y_fixed[:, cells] = _y_for(k, N)[:, None]
|
|
622
|
+
x = np.full(n, volume_fraction) if x0 is None else np.asarray(x0, float).copy()
|
|
623
|
+
x[pres] = 1.0
|
|
624
|
+
if y0 is None:
|
|
625
|
+
y = np.vstack([np.full(n, 1.0 / (N - i)) for i in range(m_y)])
|
|
626
|
+
else:
|
|
627
|
+
y = np.asarray(y0, float).reshape(m_y, n).copy()
|
|
628
|
+
y[:, ~free_y] = y_fixed[:, ~free_y]
|
|
629
|
+
|
|
630
|
+
nvar = nx + m_y * ny
|
|
631
|
+
# constraints: volume, material caps/floors, requirements
|
|
632
|
+
cons_names = ["volume"] + [f"max:{mm.materials[k].name}" for k in mm.max_fraction] + \
|
|
633
|
+
[f"min:{mm.materials[k].name}" for k in mm.min_fraction] + [f"req:{r.case}" for r in mm.requirements] + \
|
|
634
|
+
[f"energy:{r.case}" for r in mm.requirements if r.kind == "knock"]
|
|
635
|
+
use_int = bool(mm.interface_penalty)
|
|
636
|
+
if use_int:
|
|
637
|
+
cons_names.insert(len(cons_names) - sum(r.kind == "knock" for r in mm.requirements), "bondStage:stiffness")
|
|
638
|
+
# stress scale per case: a knock is judged at its peak force (updated each solve)
|
|
639
|
+
scales = [1.0] * len(loads)
|
|
640
|
+
# what a cell can store elastically before it fails, mJ/mm^3: S^2 / 2E.
|
|
641
|
+
# TPU stores ~6x what PETG does per volume, which is why bumpers are TPU.
|
|
642
|
+
resil = np.array([m.strength ** 2 / (2.0 * m.E) for m in mm.materials])
|
|
643
|
+
def new_mma(nvar=nvar, move=0.15):
|
|
644
|
+
return MMA(nvar, len(cons_names), move=move)
|
|
645
|
+
|
|
646
|
+
opt = new_mma()
|
|
647
|
+
beta = 1.0 if x0 is None else 4.0
|
|
648
|
+
beta_every = max(8, max_iterations // 6)
|
|
649
|
+
us = [None] * len(loads)
|
|
650
|
+
c0 = None
|
|
651
|
+
history = []
|
|
652
|
+
gamma, p0 = 0.0, None
|
|
653
|
+
# the cases a softness requirement reads, solved on the Voigt model
|
|
654
|
+
req_idx = sorted({names.index(r.case) for r in mm.requirements} |
|
|
655
|
+
{names.index(r.relative_to) for r in mm.requirements if r.relative_to})
|
|
656
|
+
solver_v = Solver(mesh, rtol=1e-6, maxiter=2000)
|
|
657
|
+
uv = {}
|
|
658
|
+
# The stored-energy limit skips the cells that touch the knock's loaded
|
|
659
|
+
# nodes or a support: a load spread over a face's nodes concentrates
|
|
660
|
+
# there as no real contact does (verification leaves the same band out).
|
|
661
|
+
# Without this the limit could never be met under a face load, and MMA,
|
|
662
|
+
# chasing it at full projection, emptied the bracket.
|
|
663
|
+
away = {}
|
|
664
|
+
for r in mm.requirements:
|
|
665
|
+
if r.kind == "knock":
|
|
666
|
+
ci = names.index(r.case)
|
|
667
|
+
nodes = np.abs(loads[ci].reshape(-1, 3)).sum(axis=1) > 0
|
|
668
|
+
nodes |= domain.support_nodes
|
|
669
|
+
away[ci] = (~nodes[mesh.enode].any(axis=1)).astype(float)
|
|
670
|
+
frozen = False
|
|
671
|
+
f0_frozen = None
|
|
672
|
+
beta_max_at = 0 if beta >= beta_max else None
|
|
673
|
+
stage_b_start = None
|
|
674
|
+
# iterations kept for the bond stage at the end
|
|
675
|
+
stage_b = max_iterations - max(10, max_iterations // 5) if use_int else max_iterations
|
|
676
|
+
s_lag = None
|
|
677
|
+
it = 0
|
|
678
|
+
req_vals = {}
|
|
679
|
+
for it in range(1, max_iterations + 1):
|
|
680
|
+
xt = (h @ x) / hs
|
|
681
|
+
xb = _project(xt, beta)
|
|
682
|
+
dxb = _dproject(xt, beta)
|
|
683
|
+
xb[pres], dxb[pres] = 1.0, 0.0
|
|
684
|
+
yt = np.vstack([(h @ y[j]) / hs_y for j in range(m_y)])
|
|
685
|
+
yb = _project(yt, beta)
|
|
686
|
+
dyb = _dproject(yt, beta)
|
|
687
|
+
yb[:, ~free_y], dyb[:, ~free_y] = y_fixed[:, ~free_y], 0.0
|
|
688
|
+
w, dw = _weights(yb)
|
|
689
|
+
wp = w ** PENALTY
|
|
690
|
+
xpp = xb ** PENALTY
|
|
691
|
+
lam_e = lam_min + xpp * (wp.T @ lam_i)
|
|
692
|
+
mu_e = mu_min + xpp * (wp.T @ mu_i)
|
|
693
|
+
k = stiff.assemble(lam_e, mu_e, springs)
|
|
694
|
+
us = solver.solve_many(k, loads, us)
|
|
695
|
+
comp = [float(f @ u) for f, u in zip(loads, us)]
|
|
696
|
+
if c0 is None:
|
|
697
|
+
c0 = [max(c, 1e-30) for c in comp]
|
|
698
|
+
for r in mm.requirements:
|
|
699
|
+
if r.kind == "knock":
|
|
700
|
+
ci = names.index(r.case)
|
|
701
|
+
scales[ci] = float(np.sqrt(2.0 * r.energy * 1000.0 * comp[ci] / max(comp[ci], 1e-30) ** 2))
|
|
702
|
+
# d(lam_e)/d xb, d(lam_e)/d w_i
|
|
703
|
+
dlam_dx = PENALTY * xb ** (PENALTY - 1) * (wp.T @ lam_i)
|
|
704
|
+
dmu_dx = PENALTY * xb ** (PENALTY - 1) * (wp.T @ mu_i)
|
|
705
|
+
dwp = PENALTY * w ** (PENALTY - 1) # (N, n)
|
|
706
|
+
energies = [stiff.energies(u, lam_e, mu_e) for u in us]
|
|
707
|
+
|
|
708
|
+
def grad_c(ci):
|
|
709
|
+
"""dC_ci / d(xb) and / d(yb) (m_y, n)."""
|
|
710
|
+
el, em = energies[ci]
|
|
711
|
+
gx = -(el * dlam_dx + em * dmu_dx)
|
|
712
|
+
gw = -(xpp * dwp) * (el[None, :] * lam_i[:, None] + em[None, :] * mu_i[:, None]) # (N, n)
|
|
713
|
+
gy = np.einsum("in,ijn->jn", gw, dw)
|
|
714
|
+
return gx, gy
|
|
715
|
+
|
|
716
|
+
grads = [grad_c(ci) for ci in range(len(loads))]
|
|
717
|
+
f0 = sum(wc * c / c0i for wc, c, c0i in zip(weights, comp, c0))
|
|
718
|
+
gx0 = sum(wc / c0i * g[0] for wc, c0i, g in zip(weights, c0, grads))
|
|
719
|
+
gy0 = sum(wc / c0i * g[1] for wc, c0i, g in zip(weights, c0, grads))
|
|
720
|
+
|
|
721
|
+
# interfaces: a lagged constraint. The bond utilization of every cell
|
|
722
|
+
# (traction on the interface normal, from the last solve) is held
|
|
723
|
+
# fixed; the constraint is its p-mean over the interface band
|
|
724
|
+
# (x * 4 w_i w_j), so the optimizer meets it by moving the interface
|
|
725
|
+
# to where the bond is not loaded, or by having less of it there.
|
|
726
|
+
int_g = None
|
|
727
|
+
if use_int and (frozen or (mm.bond_in_topology and beta >= 4.0)):
|
|
728
|
+
# every case: a softness case is a real load too (the poke's
|
|
729
|
+
# edge presses on the pad's bond as hard as anything)
|
|
730
|
+
stress = [stiff.stress(u, lam_e, mu_e) * sc for u, sc in zip(us, scales)]
|
|
731
|
+
normals = _material_normals(domain, w)
|
|
732
|
+
num, den = 0.0, 0.0
|
|
733
|
+
gnum_x, gden_x = np.zeros(n), np.zeros(n)
|
|
734
|
+
gnum_w, gden_w = np.zeros((N, n)), np.zeros((N, n))
|
|
735
|
+
worst = 0.0
|
|
736
|
+
# the interlock's lattice stays off the outer surface: there the
|
|
737
|
+
# bond is flat, as verification judges it
|
|
738
|
+
shallow = None
|
|
739
|
+
if mm.interlock is not None:
|
|
740
|
+
sg = domain.full_grid((xb > 0.5).astype(float)) > 0.5
|
|
741
|
+
dep = (ndimage.distance_transform_edt(np.pad(sg, 1))[1:-1, 1:-1, 1:-1] - 0.5) * domain.pitch
|
|
742
|
+
shallow = dep[tuple(mesh.elem_ijk.T)] < (mm.interlock.avoid or mm.interlock.beam_width)
|
|
743
|
+
for p in pairs:
|
|
744
|
+
util = np.zeros(n)
|
|
745
|
+
for s_, sfc in zip(stress, case_sf):
|
|
746
|
+
u_ = _traction_util(s_, normals[p], cards[p], build_axis)
|
|
747
|
+
if shallow is not None and "interlock" in cards[p] and shallow.any():
|
|
748
|
+
u_[shallow] = _traction_util(s_[shallow], normals[p][shallow], flat_cards[p], build_axis)
|
|
749
|
+
util = np.maximum(util, sfc * u_)
|
|
750
|
+
uq = util ** PNORM
|
|
751
|
+
i, j = p
|
|
752
|
+
band = 4.0 * w[i] * w[j]
|
|
753
|
+
num += float((xb * band * uq).sum())
|
|
754
|
+
den += float((xb * band).sum())
|
|
755
|
+
gnum_x += band * uq
|
|
756
|
+
gden_x += band
|
|
757
|
+
gnum_w[i] += xb * 4.0 * w[j] * uq
|
|
758
|
+
gnum_w[j] += xb * 4.0 * w[i] * uq
|
|
759
|
+
gden_w[i] += xb * 4.0 * w[j]
|
|
760
|
+
gden_w[j] += xb * 4.0 * w[i]
|
|
761
|
+
sel = band > 0.5
|
|
762
|
+
if sel.any():
|
|
763
|
+
worst = max(worst, float(util[sel].max()) * sf_int)
|
|
764
|
+
if den > 1e-6:
|
|
765
|
+
Pm = (num / den) ** (1.0 / PNORM)
|
|
766
|
+
dP = Pm / (PNORM * num) if num > 0 else 0.0
|
|
767
|
+
# ln P: a bond many times over its strength must not swamp
|
|
768
|
+
# the other constraints' scale in MMA
|
|
769
|
+
dP = dP / max(Pm, 1e-12)
|
|
770
|
+
int_g = (float(np.log(max(Pm, 1e-12))),
|
|
771
|
+
dP * (gnum_x - num / den * gden_x),
|
|
772
|
+
np.einsum("in,ijn->jn", dP * (gnum_w - num / den * gden_w), dw))
|
|
773
|
+
gamma = worst
|
|
774
|
+
else:
|
|
775
|
+
worst = None
|
|
776
|
+
|
|
777
|
+
# constraints in (xb, yb) space
|
|
778
|
+
g_vals, g_x, g_y = [], [], []
|
|
779
|
+
ntot = float(n)
|
|
780
|
+
g_vals.append(xb.mean() / volume_fraction - 1.0)
|
|
781
|
+
g_x.append(np.full(n, 1.0 / (ntot * volume_fraction)))
|
|
782
|
+
g_y.append(np.zeros((m_y, n)))
|
|
783
|
+
solid = max(float(xb.sum()), 1e-9)
|
|
784
|
+
for kk, cap in mm.max_fraction.items():
|
|
785
|
+
v = float((xb * w[kk]).sum())
|
|
786
|
+
g_vals.append(v / (cap * solid) - 1.0)
|
|
787
|
+
g_x.append((w[kk] * solid - v) / (cap * solid ** 2))
|
|
788
|
+
g_y.append(np.einsum("n,jn->jn", xb / (cap * solid), dw[kk]))
|
|
789
|
+
for kk, lo in mm.min_fraction.items():
|
|
790
|
+
v = float((xb * w[kk]).sum())
|
|
791
|
+
g_vals.append(1.0 - v / (lo * solid))
|
|
792
|
+
g_x.append(-(w[kk] * solid - v) / (lo * solid ** 2))
|
|
793
|
+
g_y.append(-np.einsum("n,jn->jn", xb / (lo * solid), dw[kk]))
|
|
794
|
+
# Softness on the penalized model (as the objective) by default. The
|
|
795
|
+
# Voigt bound (density and material mixed linearly, so grey is as
|
|
796
|
+
# stiff as it could be) was tried so grey core could not fake a soft
|
|
797
|
+
# pad: on the bracket it met the knock by geometry instead and left a
|
|
798
|
+
# bolt boss floating, so it is off (VOIGT_SOFTNESS).
|
|
799
|
+
if req_idx and VOIGT_SOFTNESS:
|
|
800
|
+
lam_v = lam_min + xb * (w.T @ lam_i)
|
|
801
|
+
mu_v = mu_min + xb * (w.T @ mu_i)
|
|
802
|
+
kv = stiff.assemble(lam_v, mu_v, springs)
|
|
803
|
+
got = solver_v.solve_many(kv, [loads[c] for c in req_idx], [uv.get(c) for c in req_idx])
|
|
804
|
+
uv = dict(zip(req_idx, got))
|
|
805
|
+
dlv_dx, dmv_dx = w.T @ lam_i, w.T @ mu_i
|
|
806
|
+
wfac, sol_v = np.broadcast_to(xb, w.shape), solver_v
|
|
807
|
+
elif req_idx:
|
|
808
|
+
lam_v, mu_v, kv, sol_v = lam_e, mu_e, k, solver
|
|
809
|
+
uv = {c: us[c] for c in req_idx}
|
|
810
|
+
dlv_dx, dmv_dx, wfac = dlam_dx, dmu_dx, xpp * dwp
|
|
811
|
+
if req_idx:
|
|
812
|
+
|
|
813
|
+
def grad_v(ca, cb):
|
|
814
|
+
"""d(f_ca . u_cb) on the Voigt model = -u_ca^T dK u_cb."""
|
|
815
|
+
ua, ub = uv[ca][mesh.edof], uv[cb][mesh.edof]
|
|
816
|
+
el, em = stiff.pair(ua, ub, lam_v, mu_v)
|
|
817
|
+
gx = -(el * dlv_dx + em * dmv_dx)
|
|
818
|
+
gw = -wfac * (el[None, :] * lam_i[:, None] + em[None, :] * mu_i[:, None])
|
|
819
|
+
return gx, np.einsum("in,ijn->jn", gw, dw)
|
|
820
|
+
|
|
821
|
+
for r in mm.requirements:
|
|
822
|
+
ci = names.index(r.case)
|
|
823
|
+
F = max(forces[ci], 1e-12)
|
|
824
|
+
cv = float(loads[ci] @ uv[ci])
|
|
825
|
+
if r.kind == "knock":
|
|
826
|
+
# c = C / F^2 (mm/N) >= 2 E / Fmax^2 ; E in J = 1000 N mm
|
|
827
|
+
cmin = 2.0 * r.energy * 1000.0 / r.max_force ** 2
|
|
828
|
+
c = cv / F ** 2
|
|
829
|
+
req_vals[r.case] = {"compliance": c, "peakForce": float(np.sqrt(2.0 * r.energy * 1000.0 / c))}
|
|
830
|
+
gcx, gcy = grad_v(ci, ci)
|
|
831
|
+
g_vals.append(1.0 - c / cmin)
|
|
832
|
+
g_x.append(-gcx / (F ** 2 * cmin))
|
|
833
|
+
g_y.append(-gcy / (F ** 2 * cmin))
|
|
834
|
+
else:
|
|
835
|
+
ri = names.index(r.relative_to)
|
|
836
|
+
Fr = max(forces[ri], 1e-12)
|
|
837
|
+
# how far the patch sinks under its own load, less how far the
|
|
838
|
+
# same patch moves when the force is spread (relativeTo) and
|
|
839
|
+
# scaled to the same size: both read at the patch, so the
|
|
840
|
+
# finger's bending there cancels. (Comparing with the spread
|
|
841
|
+
# case's own mean displacement mixed in the tip's larger
|
|
842
|
+
# bending.)
|
|
843
|
+
mut = float(loads[ci] @ uv[ri])
|
|
844
|
+
dind = cv / F - mut / Fr
|
|
845
|
+
req_vals[r.case] = {"indentation": dind}
|
|
846
|
+
gcx, gcy = grad_v(ci, ci)
|
|
847
|
+
gmx, gmy = grad_v(ci, ri)
|
|
848
|
+
tgt = INDENT_MARGIN * r.at_least
|
|
849
|
+
g_vals.append(1.0 - dind / tgt)
|
|
850
|
+
g_x.append(-(gcx / F - gmx / Fr) / tgt)
|
|
851
|
+
g_y.append(-(gcy / F - gmy / Fr) / tgt)
|
|
852
|
+
if use_int:
|
|
853
|
+
if int_g is not None and not frozen:
|
|
854
|
+
# experimental (bondInTopology): the bond as a constraint while
|
|
855
|
+
# the layout forms, now that the stored-energy limit keeps MMA
|
|
856
|
+
# from cutting the load path to unload it
|
|
857
|
+
g_vals.append(int_g[0])
|
|
858
|
+
g_x.append(int_g[1])
|
|
859
|
+
g_y.append(int_g[2])
|
|
860
|
+
elif int_g is None or f0_frozen is None:
|
|
861
|
+
g_vals.append(-1.0)
|
|
862
|
+
g_x.append(np.zeros(n))
|
|
863
|
+
g_y.append(np.zeros((m_y, n)))
|
|
864
|
+
else:
|
|
865
|
+
# the bond stage: the bond's p-mean is the objective, and the
|
|
866
|
+
# stiffness objective may get at most STIFFNESS_GIVE worse
|
|
867
|
+
lim = f0_frozen * (1.0 + STIFFNESS_GIVE)
|
|
868
|
+
g_vals.append(f0 / lim - 1.0)
|
|
869
|
+
g_x.append(gx0 / lim)
|
|
870
|
+
g_y.append(gy0 / lim)
|
|
871
|
+
f0, gx0, gy0 = int_g
|
|
872
|
+
for r in mm.requirements:
|
|
873
|
+
if r.kind != "knock":
|
|
874
|
+
continue
|
|
875
|
+
ci = names.index(r.case)
|
|
876
|
+
gv, gxx, gyy, peak_r = _energy_constraint(
|
|
877
|
+
stiff, sol_v, kv, loads[ci], uv[ci], float(loads[ci] @ uv[ci]), r.energy * 1000.0, lam_v, mu_v,
|
|
878
|
+
xb, w, dlv_dx, dmv_dx, *((xb, np.ones_like(w)) if VOIGT_SOFTNESS else (xpp, dwp)), dw, lam_i, mu_i, resil,
|
|
879
|
+
(r.safety_factor or sf_int) ** 2, max(volume_fraction * n, 1.0), away[ci])
|
|
880
|
+
req_vals[r.case]["energyUtilization"] = peak_r
|
|
881
|
+
# as ln P, for the same reason as the interface
|
|
882
|
+
pv = max(gv + 1.0, 1e-12)
|
|
883
|
+
g_vals.append(float(np.log(pv)))
|
|
884
|
+
g_x.append(gxx / pv)
|
|
885
|
+
g_y.append(gyy / pv)
|
|
886
|
+
|
|
887
|
+
# chain to the variables: projection, then the filter's transpose
|
|
888
|
+
def to_vars(gx, gy):
|
|
889
|
+
vx = (h.T @ (gx * dxb / hs))[design]
|
|
890
|
+
vy = np.concatenate([(h.T @ (gy[j] * dyb[j] / hs_y))[free_y] for j in range(m_y)]) if m_y else []
|
|
891
|
+
return np.concatenate([vx, vy])
|
|
892
|
+
|
|
893
|
+
df0 = to_vars(gx0, gy0)
|
|
894
|
+
df = np.vstack([to_vars(a, b) for a, b in zip(g_x, g_y)])
|
|
895
|
+
if frozen:
|
|
896
|
+
df0, df = df0[nx:], df[:, nx:]
|
|
897
|
+
rec = {"iteration": it, "beta": beta, "objective": f0, "compliance": comp,
|
|
898
|
+
"volume": float(xb.mean()),
|
|
899
|
+
"fractions": {mm.materials[i].name: float((xb * w[i]).sum() / solid) for i in range(N)},
|
|
900
|
+
"constraints": dict(zip(cons_names, map(float, g_vals))),
|
|
901
|
+
"interfaceWeight": gamma, **({"interfaceUtil": worst} if worst is not None else {}),
|
|
902
|
+
**({"requirements": {k: dict(v) for k, v in req_vals.items()}} if req_vals else {})}
|
|
903
|
+
history.append(rec)
|
|
904
|
+
if on_iter is not None:
|
|
905
|
+
on_iter(rec, xb)
|
|
906
|
+
if should_stop is not None and should_stop():
|
|
907
|
+
break
|
|
908
|
+
vars_now = np.concatenate(([] if frozen else [x[design]]) + [y[j][free_y] for j in range(m_y)])
|
|
909
|
+
new = opt.update(vars_now, f0, df0, np.asarray(g_vals), df)
|
|
910
|
+
change = float(np.max(np.abs(new - vars_now))) if len(new) else 0.0
|
|
911
|
+
off = 0 if frozen else nx
|
|
912
|
+
if not frozen:
|
|
913
|
+
x[design] = new[:nx]
|
|
914
|
+
for j in range(m_y):
|
|
915
|
+
y[j][free_y] = new[off + j * ny: off + (j + 1) * ny]
|
|
916
|
+
if mirror:
|
|
917
|
+
x = _symmetrize(x, mirror)
|
|
918
|
+
y = np.vstack([_symmetrize(y[j], mirror) for j in range(m_y)])
|
|
919
|
+
x[pres] = 1.0
|
|
920
|
+
if frozen:
|
|
921
|
+
if it >= max_iterations or (change < 0.005 and all(g <= 0.01 for g in g_vals)):
|
|
922
|
+
break
|
|
923
|
+
continue
|
|
924
|
+
if (it % beta_every == 0 or change < 0.01) and beta < beta_max:
|
|
925
|
+
beta = min(2.0 * beta, beta_max)
|
|
926
|
+
if beta >= beta_max:
|
|
927
|
+
beta_max_at = it
|
|
928
|
+
opt = new_mma() # fresh asymptotes for the sharper problem
|
|
929
|
+
elif beta >= beta_max and it - beta_max_at >= 8 and \
|
|
930
|
+
(it >= stage_b or (change < 0.005 and all(g <= 0.01 for g in g_vals))):
|
|
931
|
+
if use_int and ny and pairs:
|
|
932
|
+
# The bond stage: topology frozen, only the material map
|
|
933
|
+
# moves. Constraining the bond while the topology was still
|
|
934
|
+
# forming (tried first) let MMA cut a weak bond's load path
|
|
935
|
+
# instead: the bracket's shelf pad came off the part, and a
|
|
936
|
+
# beam's TPU tip off the beam. With the layout fixed it can
|
|
937
|
+
# only slide the interface out of the stress, or trade a
|
|
938
|
+
# little stiffness for less interface where the bond is loaded.
|
|
939
|
+
frozen = True
|
|
940
|
+
f0_frozen = f0
|
|
941
|
+
opt = new_mma(nvar=m_y * ny, move=0.1)
|
|
942
|
+
stage_b_start = it
|
|
943
|
+
else:
|
|
944
|
+
break
|
|
945
|
+
# final, at the delivered projection
|
|
946
|
+
xt = (h @ x) / hs
|
|
947
|
+
xb = _project(xt, beta)
|
|
948
|
+
xb[pres] = 1.0
|
|
949
|
+
yt = np.vstack([(h @ y[j]) / hs_y for j in range(m_y)])
|
|
950
|
+
yb = _project(yt, beta)
|
|
951
|
+
yb[:, ~free_y] = y_fixed[:, ~free_y]
|
|
952
|
+
w, _ = _weights(yb)
|
|
953
|
+
out = MMOptimum(x, y, xb, w, history[-1]["compliance"] if history else [], it, time.time() - t0, history,
|
|
954
|
+
gamma, req_vals)
|
|
955
|
+
out.bond_stage = stage_b_start
|
|
956
|
+
return out
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
PNORM = 10.0
|
|
960
|
+
# how much stiffness the bond stage may give up to unload the interfaces
|
|
961
|
+
STIFFNESS_GIVE = 0.10
|
|
962
|
+
INDENT_MARGIN = 1.1
|
|
963
|
+
VOIGT_SOFTNESS = False
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _energy_constraint(stiff, solver, k, f, u, comp, energy, lam_e, mu_e, xb, w, dlam_dx, dmu_dx, xpp, dwp,
|
|
967
|
+
dw, lam_i, mu_i, resil, sf2, n_solid, mask=None):
|
|
968
|
+
"""A knock stops when the part has stored its energy E: at peak force
|
|
969
|
+
the strain energy density in a cell is sed_e * 2E / C (the case's
|
|
970
|
+
field scaled to the peak). Each cell can store R_e = x_e sum_i w_i
|
|
971
|
+
S_i^2 / (2 E_i) before it fails (void stores nothing); the constraint
|
|
972
|
+
is the p-norm of r_e = SF^2 * sed_peak / R_e over every cell, at most
|
|
973
|
+
1. Thin necks that buy compliance by geometry fail it, and so does a
|
|
974
|
+
knock carried through void (its strain is huge where nothing can hold
|
|
975
|
+
it: the cut-off bumper that weighting by density let through); a TPU
|
|
976
|
+
bumper, storing the knock in rubber, passes. sed in void falls as x^3
|
|
977
|
+
and R as x, so empty cells away from the load read near zero.
|
|
978
|
+
Returns (g, dg/dxb, dg/dyb, max r over the solid)."""
|
|
979
|
+
h3 = stiff.mesh.pitch ** 3
|
|
980
|
+
el, em = stiff.energies(u, lam_e, mu_e)
|
|
981
|
+
sed = 0.5 * (lam_e * el + mu_e * em) / h3
|
|
982
|
+
Rw = w.T @ resil
|
|
983
|
+
R = np.maximum(xb * Rw, 1e-12)
|
|
984
|
+
a = sf2 * 2.0 * energy / comp
|
|
985
|
+
m = np.ones_like(R) if mask is None else mask
|
|
986
|
+
r = a * sed / R * m
|
|
987
|
+
q = PNORM
|
|
988
|
+
S = float((r ** q).sum()) / n_solid
|
|
989
|
+
P = S ** (1.0 / q)
|
|
990
|
+
g = P - 1.0
|
|
991
|
+
dP_dr = P ** (1.0 - q) * r ** (q - 1.0) / n_solid
|
|
992
|
+
coef = dP_dr * a / R / h3 * m
|
|
993
|
+
ue = u[stiff.mesh.edof]
|
|
994
|
+
ke_u = stiff.apply(ue, lam_e, mu_e)
|
|
995
|
+
rhs = np.bincount(stiff.mesh.edof.ravel(), weights=(coef[:, None] * ke_u).ravel(),
|
|
996
|
+
minlength=stiff.mesh.n_dof)
|
|
997
|
+
rhs -= float((dP_dr * r).sum()) / comp * f
|
|
998
|
+
rhs[stiff.base.fixed] = 0.0
|
|
999
|
+
psi = solver.solve_many(k, [rhs])[0]
|
|
1000
|
+
pe = psi[stiff.mesh.edof]
|
|
1001
|
+
cl, cm = stiff.pair(pe, ue, lam_e, mu_e)
|
|
1002
|
+
g_lam = dP_dr * a / R * m * 0.5 * el / h3 - cl
|
|
1003
|
+
g_mu = dP_dr * a / R * m * 0.5 * em / h3 - cm
|
|
1004
|
+
gx = g_lam * dlam_dx + g_mu * dmu_dx - dP_dr * r / R * Rw
|
|
1005
|
+
gw = (xpp * dwp) * (g_lam[None, :] * lam_i[:, None] + g_mu[None, :] * mu_i[:, None])
|
|
1006
|
+
gw -= (dP_dr * r / R * xb)[None, :] * resil[:, None]
|
|
1007
|
+
gy = np.einsum("in,ijn->jn", gw, dw)
|
|
1008
|
+
return g, gx, gy, float(r[xb > 0.5].max()) if np.any(xb > 0.5) else float(r.max())
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _material_normals(domain, w):
|
|
1012
|
+
"""Per pair (i, j): unit normal per cell of the interface between i and
|
|
1013
|
+
j, from the gradient of w_i - w_j on the grid (smoothed one cell)."""
|
|
1014
|
+
out = {}
|
|
1015
|
+
N = len(w)
|
|
1016
|
+
ijk = tuple(domain.mesh.elem_ijk.T)
|
|
1017
|
+
for i in range(N):
|
|
1018
|
+
for j in range(i + 1, N):
|
|
1019
|
+
g = domain.full_grid(w[i] - w[j])
|
|
1020
|
+
g = ndimage.gaussian_filter(g, 0.8)
|
|
1021
|
+
gr = np.stack([np.gradient(g, axis=a)[ijk] for a in range(3)], axis=1)
|
|
1022
|
+
nrm = np.linalg.norm(gr, axis=1)
|
|
1023
|
+
gr[nrm < 1e-12] = (0.0, 0.0, 1.0)
|
|
1024
|
+
nrm[nrm < 1e-12] = 1.0
|
|
1025
|
+
out[(i, j)] = gr / nrm[:, None]
|
|
1026
|
+
return out
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# ------------------------------------------------------------ delivery
|
|
1030
|
+
|
|
1031
|
+
def material_labels(domain, xphys, w, min_cells: int = 6) -> np.ndarray:
|
|
1032
|
+
"""Per cell the material it delivers (argmax w), -1 where void; material
|
|
1033
|
+
specks under `min_cells` take the label of what surrounds them."""
|
|
1034
|
+
lab = np.argmax(w, axis=0).astype(np.int64)
|
|
1035
|
+
lab[xphys <= 0.5] = -1
|
|
1036
|
+
g = domain.full_grid(lab.astype(float), fill=-1).astype(np.int64)
|
|
1037
|
+
for k in range(len(w)):
|
|
1038
|
+
cc, ncc = ndimage.label(g == k, structure=_CONN26)
|
|
1039
|
+
if ncc <= 1:
|
|
1040
|
+
continue
|
|
1041
|
+
sizes = np.bincount(cc.ravel())
|
|
1042
|
+
small = np.isin(cc, np.nonzero(sizes < min_cells)[0]) & (cc > 0)
|
|
1043
|
+
if small.any():
|
|
1044
|
+
other = np.where((g >= 0) & (g != k) & ~small, g, -1)
|
|
1045
|
+
# nearest other-material cell
|
|
1046
|
+
dist, idx = ndimage.distance_transform_edt(other < 0, return_indices=True)
|
|
1047
|
+
near = other[tuple(idx)]
|
|
1048
|
+
g[small & (near >= 0)] = near[small & (near >= 0)]
|
|
1049
|
+
return g[tuple(domain.mesh.elem_ijk.T)]
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _region_mesh(domain, labels, k, upsample=3):
|
|
1053
|
+
"""Closed mesh of the region where material k is the delivered label,
|
|
1054
|
+
extended through void (nearest material) and a pad round the grid, so
|
|
1055
|
+
an exact boolean with the solid gives material k's body."""
|
|
1056
|
+
import trimesh
|
|
1057
|
+
from skimage.measure import marching_cubes
|
|
1058
|
+
|
|
1059
|
+
g = domain.full_grid(labels.astype(float), fill=-1)
|
|
1060
|
+
have = g >= 0
|
|
1061
|
+
_, idx = ndimage.distance_transform_edt(~have, return_indices=True)
|
|
1062
|
+
g = g[tuple(idx)]
|
|
1063
|
+
ind = (g == k).astype(float)
|
|
1064
|
+
pad = 2
|
|
1065
|
+
ind = np.pad(ind, pad, mode="edge")
|
|
1066
|
+
fine = ndimage.zoom(ind, upsample, order=1)
|
|
1067
|
+
fine = ndimage.gaussian_filter(fine, 0.6 * upsample)
|
|
1068
|
+
fine = np.pad(fine, 1, constant_values=0.0)
|
|
1069
|
+
if fine.max() <= 0.5:
|
|
1070
|
+
return None
|
|
1071
|
+
step = domain.pitch / upsample
|
|
1072
|
+
verts, faces, _, _ = marching_cubes(fine, 0.5, spacing=(step,) * 3)
|
|
1073
|
+
# zoom aligns corners: zoomed sample a (fine index a + 1, after the pad
|
|
1074
|
+
# of one) sits at coarse index a (s - 1) / (S - 1), s and S the coarse
|
|
1075
|
+
# and zoomed sizes, and coarse cell c's centre is at
|
|
1076
|
+
# origin + (c - pad + 0.5) * pitch
|
|
1077
|
+
scale = ((np.array(ind.shape) - 1) * domain.pitch) / np.maximum(np.array(fine.shape) - 2 - 1, 1)
|
|
1078
|
+
verts = (verts / step - 1.0) * scale + domain.origin - pad * domain.pitch + 0.5 * domain.pitch
|
|
1079
|
+
m = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
|
|
1080
|
+
if m.volume < 0:
|
|
1081
|
+
m.invert()
|
|
1082
|
+
return m
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _region_mesh_interlocked(domain, labels, k, il, build_axis=2, phase=None, names=None):
|
|
1086
|
+
"""As _region_mesh, with the interlock's beam lattice (interlock.py) in
|
|
1087
|
+
a band round material k's boundary, on a grid fine enough for its
|
|
1088
|
+
beams (half a beam wide in plane, half a beam layer along the build
|
|
1089
|
+
axis)."""
|
|
1090
|
+
import trimesh
|
|
1091
|
+
from skimage.measure import marching_cubes
|
|
1092
|
+
|
|
1093
|
+
from .interlock import interlocked_indicator
|
|
1094
|
+
|
|
1095
|
+
g = domain.full_grid(labels.astype(float), fill=-1)
|
|
1096
|
+
have = g >= 0
|
|
1097
|
+
_, idx = ndimage.distance_transform_edt(~have, return_indices=True)
|
|
1098
|
+
g = g[tuple(idx)].astype(np.int64)
|
|
1099
|
+
pad = 2
|
|
1100
|
+
gp = np.pad(g, pad, mode="edge")
|
|
1101
|
+
ind = ndimage.gaussian_filter((gp == k).astype(float), 0.6)
|
|
1102
|
+
solid = ndimage.gaussian_filter(np.pad(have, pad).astype(float), 0.6)
|
|
1103
|
+
# the material on the other side of k's boundary, for il.pairs
|
|
1104
|
+
other = np.where(gp != k, gp, -1)
|
|
1105
|
+
_, oidx = ndimage.distance_transform_edt(other < 0, return_indices=True)
|
|
1106
|
+
near_other = other[tuple(oidx)]
|
|
1107
|
+
spacing = np.full(3, 0.5 * il.beam_width)
|
|
1108
|
+
spacing[build_axis] = 0.5 * il.layer_height
|
|
1109
|
+
lo = domain.origin - pad * domain.pitch
|
|
1110
|
+
hi = lo + np.array(gp.shape) * domain.pitch
|
|
1111
|
+
n = np.maximum(np.ceil((hi - lo) / spacing).astype(int), 2)
|
|
1112
|
+
axes = [lo[a] + (np.arange(n[a]) + 0.5) * spacing[a] for a in range(3)]
|
|
1113
|
+
# coarse index (fractional) of each fine point: coarse cell c's centre is lo + (c + 0.5) pitch
|
|
1114
|
+
ci = [(ax - lo[a]) / domain.pitch - 0.5 for a, ax in enumerate(axes)]
|
|
1115
|
+
grid = np.meshgrid(*ci, indexing="ij")
|
|
1116
|
+
fine = ndimage.map_coordinates(ind, grid, order=1, mode="nearest") > 0.5
|
|
1117
|
+
# boundary avoidance (as Cura's): no beams within a beam (and half a
|
|
1118
|
+
# cell, the coarse solid's error) of the part's surface, where the band
|
|
1119
|
+
# would leave slivers; the bond there stays flat
|
|
1120
|
+
fsolid = ndimage.map_coordinates(solid, grid, order=1, mode="constant") > 0.5
|
|
1121
|
+
avoid = il.avoid if il.avoid is not None else il.beam_width + 0.5 * domain.pitch
|
|
1122
|
+
mask = ndimage.distance_transform_edt(fsolid, sampling=spacing) >= avoid
|
|
1123
|
+
del fsolid
|
|
1124
|
+
if names is not None and il.pairs:
|
|
1125
|
+
ok = np.array([j >= 0 and j != k and il.applies(names[k], names[j]) for j in range(len(names))] + [False])
|
|
1126
|
+
nn = ndimage.map_coordinates(near_other.astype(float), grid, order=0, mode="nearest").astype(np.int64)
|
|
1127
|
+
mask &= ok[np.where(nn >= 0, nn, len(names))]
|
|
1128
|
+
del grid
|
|
1129
|
+
origin = np.array([ax[0] for ax in axes])
|
|
1130
|
+
if phase is None:
|
|
1131
|
+
phase = domain.origin
|
|
1132
|
+
fine = interlocked_indicator(fine, spacing, origin, il, build_axis, phase, mask)
|
|
1133
|
+
if not fine.any():
|
|
1134
|
+
return None
|
|
1135
|
+
f = ndimage.gaussian_filter(fine.astype(np.float32), 0.35)
|
|
1136
|
+
f = np.pad(f, 1, constant_values=0.0)
|
|
1137
|
+
verts, faces, _, _ = marching_cubes(f, 0.5, spacing=tuple(spacing))
|
|
1138
|
+
verts = verts - spacing + origin
|
|
1139
|
+
m = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
|
|
1140
|
+
if m.volume < 0:
|
|
1141
|
+
m.invert()
|
|
1142
|
+
return m
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def split_bodies(domain, labels, solid_mesh, names, interlock=None, build_axis=2) -> list:
|
|
1146
|
+
"""Cut the delivered solid into one body per material with exact
|
|
1147
|
+
booleans: [(material index, trimesh)], bodies sharing their interfaces.
|
|
1148
|
+
With an interlock (interlock.Interlock), the bodies interpenetrate in
|
|
1149
|
+
its beam lattice where they meet."""
|
|
1150
|
+
from .shapes import manifold_from_trimesh, trimesh_from_manifold
|
|
1151
|
+
|
|
1152
|
+
whole = manifold_from_trimesh(solid_mesh)
|
|
1153
|
+
present = [k for k in range(len(names)) if np.any(labels == k)]
|
|
1154
|
+
if len(present) <= 1:
|
|
1155
|
+
return [(present[0] if present else 0, solid_mesh)]
|
|
1156
|
+
rest = whole
|
|
1157
|
+
out = []
|
|
1158
|
+
phase = np.asarray(solid_mesh.bounds[0], float)
|
|
1159
|
+
for k in present[:-1]:
|
|
1160
|
+
if interlock is not None:
|
|
1161
|
+
reg = _region_mesh_interlocked(domain, labels, k, interlock, build_axis, phase, names)
|
|
1162
|
+
else:
|
|
1163
|
+
reg = _region_mesh(domain, labels, k)
|
|
1164
|
+
if reg is None:
|
|
1165
|
+
continue
|
|
1166
|
+
body = rest ^ manifold_from_trimesh(reg)
|
|
1167
|
+
rest = rest - manifold_from_trimesh(reg)
|
|
1168
|
+
if body.volume() > 0:
|
|
1169
|
+
out.append((k, trimesh_from_manifold(body)))
|
|
1170
|
+
if rest.volume() > 0:
|
|
1171
|
+
out.append((present[-1], trimesh_from_manifold(rest)))
|
|
1172
|
+
return out
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
# ----------------------------------------------------------- verification
|
|
1176
|
+
|
|
1177
|
+
def verify_multimaterial(study, mm: MultiMaterial, bodies, max_cells: int = 90_000, min_pitch=None,
|
|
1178
|
+
contact_band: float | None = None) -> dict:
|
|
1179
|
+
"""Voxel FEA of the delivered bodies, each cell its own material:
|
|
1180
|
+
per-material peak stress against that material's strength (and across
|
|
1181
|
+
layers for FDM), every interface face's bond utilization, deflection,
|
|
1182
|
+
mass, and what each softness requirement reads."""
|
|
1183
|
+
from scipy.spatial import cKDTree
|
|
1184
|
+
|
|
1185
|
+
from .domain import boundary_conditions
|
|
1186
|
+
from .verify import voxelize
|
|
1187
|
+
|
|
1188
|
+
t0 = time.time()
|
|
1189
|
+
vol = sum(float(m.volume) for _, m in bodies)
|
|
1190
|
+
pitch = (vol / max_cells) ** (1.0 / 3.0)
|
|
1191
|
+
if min_pitch:
|
|
1192
|
+
pitch = max(pitch, float(min_pitch))
|
|
1193
|
+
lo = np.min([m.bounds[0] for _, m in bodies], axis=0)
|
|
1194
|
+
hi = np.max([m.bounds[1] for _, m in bodies], axis=0)
|
|
1195
|
+
label = None
|
|
1196
|
+
origin = None
|
|
1197
|
+
for k, m in bodies:
|
|
1198
|
+
s, origin = voxelize(m, pitch, box=(lo, hi))
|
|
1199
|
+
if label is None:
|
|
1200
|
+
label = np.full(s.shape, -1, np.int64)
|
|
1201
|
+
label[s & (label < 0)] = k
|
|
1202
|
+
solid = label >= 0
|
|
1203
|
+
lbl, _ = ndimage.label(solid)
|
|
1204
|
+
ijk = np.argwhere(solid)
|
|
1205
|
+
hm = HexMesh(ijk, pitch, origin)
|
|
1206
|
+
tol = 0.5 * (contact_band or 0.0) + 0.5 * pitch
|
|
1207
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=tol)
|
|
1208
|
+
sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
|
|
1209
|
+
keep = np.isin(lbl[tuple(ijk.T)], sup)
|
|
1210
|
+
if not keep.all():
|
|
1211
|
+
ijk = ijk[keep]
|
|
1212
|
+
hm = HexMesh(ijk, pitch, origin)
|
|
1213
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=tol)
|
|
1214
|
+
mat = label[tuple(ijk.T)]
|
|
1215
|
+
Es = np.array([m.E for m in mm.materials])
|
|
1216
|
+
nus = np.array([m.nu for m in mm.materials])
|
|
1217
|
+
lam_i, mu_i = lame(Es, nus)
|
|
1218
|
+
lam_e, mu_e = lam_i[mat], mu_i[mat]
|
|
1219
|
+
stiff = LameStiffness(hm, bc.fixed, ratios=lam_i / (lam_i + mu_i))
|
|
1220
|
+
springs = Es.max() * bc.springs if bc.springs is not None else None
|
|
1221
|
+
k = stiff.assemble(lam_e, mu_e, springs)
|
|
1222
|
+
solver = Solver(hm, rtol=1e-6, maxiter=4000, rebuild_after_iters=10_000)
|
|
1223
|
+
us = solver.solve_many(k, list(bc.loads))
|
|
1224
|
+
band = max(1.5 * pitch, contact_band or 0.0)
|
|
1225
|
+
build_axis = _build_axis(study)
|
|
1226
|
+
fdm = study.process is not None and study.process.kind == "fdm"
|
|
1227
|
+
names = [c.name for c in study.load_cases]
|
|
1228
|
+
N = len(mm.materials)
|
|
1229
|
+
pairs = [(i, j) for i in range(N) for j in range(i + 1, N)]
|
|
1230
|
+
cards = {p: interface_card(mm, *p, study.process) for p in pairs}
|
|
1231
|
+
flat = {p: interface_card(mm, *p, study.process, interlock=False) for p in pairs}
|
|
1232
|
+
req = {r.case: r for r in mm.requirements}
|
|
1233
|
+
# interlocked bond only where the lattice is: avoid (mm) inside the surface
|
|
1234
|
+
il = mm.interlock
|
|
1235
|
+
depth = (ndimage.distance_transform_edt(np.pad(label >= 0, 1))[1:-1, 1:-1, 1:-1] - 0.5) * pitch
|
|
1236
|
+
depth_c = depth[tuple(ijk.T)]
|
|
1237
|
+
il_avoid = (il.avoid if il.avoid is not None else il.beam_width) if il is not None else np.inf
|
|
1238
|
+
|
|
1239
|
+
# interface faces: neighbours along each axis with different materials
|
|
1240
|
+
lut = np.full(label.shape, -1, np.int64)
|
|
1241
|
+
lut[tuple(ijk.T)] = np.arange(len(ijk))
|
|
1242
|
+
faces = [] # (cell a, cell b, axis)
|
|
1243
|
+
for ax in range(3):
|
|
1244
|
+
nb = ijk.copy()
|
|
1245
|
+
nb[:, ax] += 1
|
|
1246
|
+
ok = nb[:, ax] < label.shape[ax]
|
|
1247
|
+
j = np.full(len(ijk), -1, np.int64)
|
|
1248
|
+
j[ok] = lut[tuple(nb[ok].T)]
|
|
1249
|
+
hit = (j >= 0)
|
|
1250
|
+
hit[hit] &= mat[hit] != mat[j[hit]]
|
|
1251
|
+
a = np.nonzero(hit)[0]
|
|
1252
|
+
faces.append((a, j[a], ax))
|
|
1253
|
+
face_area = sum(len(a) for a, _, _ in faces) * pitch ** 2
|
|
1254
|
+
deep_area = sum(int((np.minimum(depth_c[a], depth_c[b]) >= il_avoid).sum()) for a, b, _ in faces) * pitch ** 2
|
|
1255
|
+
pair_area = {}
|
|
1256
|
+
for a, b, _ in faces:
|
|
1257
|
+
for p in pairs:
|
|
1258
|
+
sel = ((mat[a] == p[0]) & (mat[b] == p[1])) | ((mat[a] == p[1]) & (mat[b] == p[0]))
|
|
1259
|
+
pair_area[p] = pair_area.get(p, 0.0) + float(sel.sum()) * pitch ** 2
|
|
1260
|
+
|
|
1261
|
+
cases = []
|
|
1262
|
+
worst = {"material": np.inf, "interface": np.inf}
|
|
1263
|
+
centres = hm.elem_centers()
|
|
1264
|
+
warnings = []
|
|
1265
|
+
narrowed = {} # what the contact band would have hidden -> the band it was checked at
|
|
1266
|
+
# The stress next to a support or a spread load node is a voxel
|
|
1267
|
+
# artifact, so cells within `band` of them are left out. A body thinner
|
|
1268
|
+
# than the band (a 2 mm pad loaded on its face) would then go unchecked,
|
|
1269
|
+
# its bond with it; there the band narrows (to half, then to cells
|
|
1270
|
+
# touching no contact node, then none) until something is read.
|
|
1271
|
+
tiers = [band, 0.5 * band, 0.0, None]
|
|
1272
|
+
|
|
1273
|
+
def _beyond(d, t):
|
|
1274
|
+
return d >= t if t > 0 else d > 0
|
|
1275
|
+
|
|
1276
|
+
def _away(el_d, ok):
|
|
1277
|
+
for t in tiers:
|
|
1278
|
+
sel = ok if t is None else ok & _beyond(el_d, t)
|
|
1279
|
+
if sel.any():
|
|
1280
|
+
return sel, t
|
|
1281
|
+
return ok, None
|
|
1282
|
+
|
|
1283
|
+
for ci, case in enumerate(study.load_cases):
|
|
1284
|
+
f, u = bc.loads[ci], us[ci]
|
|
1285
|
+
F = _case_force(f)
|
|
1286
|
+
comp = float(f @ u)
|
|
1287
|
+
contact = bc.support_nodes | bc.case_load_nodes[ci]
|
|
1288
|
+
node_d = cKDTree(hm.node_xyz[contact]).query(hm.node_xyz, k=1)[0] if contact.any() \
|
|
1289
|
+
else np.full(len(hm.node_xyz), np.inf)
|
|
1290
|
+
el_d = node_d[hm.enode].min(axis=1)
|
|
1291
|
+
scale = 1.0
|
|
1292
|
+
rec = {"name": case.name, "force": F, "compliance": comp}
|
|
1293
|
+
r = req.get(case.name)
|
|
1294
|
+
if r is not None and r.kind == "knock":
|
|
1295
|
+
c = comp / max(F, 1e-12) ** 2
|
|
1296
|
+
peak_f = float(np.sqrt(2.0 * r.energy * 1000.0 / c))
|
|
1297
|
+
scale = peak_f / max(F, 1e-12)
|
|
1298
|
+
rec["knock"] = {"energy": r.energy, "compliance": c, "peakForce": peak_f, "maxForce": r.max_force,
|
|
1299
|
+
"meets": bool(peak_f <= r.max_force * 1.0001),
|
|
1300
|
+
"stopDistance": float(peak_f * c)}
|
|
1301
|
+
s = stiff.stress(u, lam_e, mu_e) * scale
|
|
1302
|
+
vm = _von_mises(s)
|
|
1303
|
+
disp = np.linalg.norm(u.reshape(-1, 3), axis=1) * scale
|
|
1304
|
+
rec["maxDisplacement"] = float(disp.max())
|
|
1305
|
+
per = {}
|
|
1306
|
+
for kk in range(N):
|
|
1307
|
+
if not (mat == kk).any():
|
|
1308
|
+
continue
|
|
1309
|
+
sel, t = _away(el_d, mat == kk)
|
|
1310
|
+
m = mm.materials[kk]
|
|
1311
|
+
if t != band:
|
|
1312
|
+
narrowed[(case.name, m.name)] = t
|
|
1313
|
+
peak = float(vm[sel].max())
|
|
1314
|
+
sf = m.strength / peak if peak > 0 else np.inf
|
|
1315
|
+
entry = {"peakStress": peak, "safetyFactor": sf,
|
|
1316
|
+
"at": centres[sel][np.argmax(vm[sel])].tolist(),
|
|
1317
|
+
**({"contactBand": t} if t != band else {})}
|
|
1318
|
+
if fdm and build_axis is not None and m.strength_z:
|
|
1319
|
+
sz = float(np.maximum(s[sel][:, build_axis], 0).max())
|
|
1320
|
+
entry["acrossLayers"] = {"stress": sz, "safetyFactor": m.strength_z / sz if sz > 0 else np.inf}
|
|
1321
|
+
sf = min(sf, entry["acrossLayers"]["safetyFactor"])
|
|
1322
|
+
entry["safetyFactor"] = sf
|
|
1323
|
+
per[m.name] = entry
|
|
1324
|
+
worst["material"] = min(worst["material"], sf)
|
|
1325
|
+
rec["materials"] = per
|
|
1326
|
+
# interfaces: traction from the two cells' mean stress, on the axis normal
|
|
1327
|
+
ints = {}
|
|
1328
|
+
for p in pairs:
|
|
1329
|
+
utils, where = [], []
|
|
1330
|
+
on_p = [(((mat[a] == p[0]) & (mat[b] == p[1])) | ((mat[a] == p[1]) & (mat[b] == p[0])))
|
|
1331
|
+
for a, b, _ in faces]
|
|
1332
|
+
if not any(o.any() for o in on_p):
|
|
1333
|
+
continue
|
|
1334
|
+
fd = np.concatenate([np.minimum(el_d[a], el_d[b]) for a, b, _ in faces])
|
|
1335
|
+
_, t = _away(fd, np.concatenate(on_p))
|
|
1336
|
+
for (a, b, ax), op in zip(faces, on_p):
|
|
1337
|
+
sel = op if t is None else op & _beyond(np.minimum(el_d[a], el_d[b]), t)
|
|
1338
|
+
if not sel.any():
|
|
1339
|
+
continue
|
|
1340
|
+
sm = 0.5 * (s[a[sel]] + s[b[sel]])
|
|
1341
|
+
nrm = np.zeros((int(sel.sum()), 3))
|
|
1342
|
+
nrm[:, ax] = 1.0
|
|
1343
|
+
ut_ = _traction_util(sm, nrm, cards[p], build_axis)
|
|
1344
|
+
shallow = np.minimum(depth_c[a[sel]], depth_c[b[sel]]) < il_avoid
|
|
1345
|
+
if shallow.any() and cards[p] is not flat[p] and "interlock" in cards[p]:
|
|
1346
|
+
ut_[shallow] = _traction_util(sm[shallow], nrm[shallow], flat[p], build_axis)
|
|
1347
|
+
utils.append(ut_)
|
|
1348
|
+
where.append(0.5 * (centres[a[sel]] + centres[b[sel]]))
|
|
1349
|
+
if not utils:
|
|
1350
|
+
continue
|
|
1351
|
+
ut = np.concatenate(utils)
|
|
1352
|
+
wh = np.concatenate(where)
|
|
1353
|
+
# The face-by-face peak sits on the voxel staircase's re-entrant
|
|
1354
|
+
# corners, a grid artifact the smooth delivered interface does not
|
|
1355
|
+
# have; the 99th percentile is what the bond as a whole sees.
|
|
1356
|
+
p99 = float(np.percentile(ut, 99.0)) if len(ut) >= 50 else float(ut.max())
|
|
1357
|
+
name = f"{mm.materials[p[0]].name}+{mm.materials[p[1]].name}"
|
|
1358
|
+
ints[name] = {"faces": int(len(ut)), "peakUtilization": float(ut.max()),
|
|
1359
|
+
"utilization": p99, "safetyFactor": 1.0 / p99 if p99 > 0 else np.inf,
|
|
1360
|
+
"at": wh[np.argmax(ut)].tolist(), **({"contactBand": t} if t != band else {})}
|
|
1361
|
+
if t != band:
|
|
1362
|
+
narrowed[(case.name, name)] = t
|
|
1363
|
+
worst["interface"] = min(worst["interface"], ints[name]["safetyFactor"])
|
|
1364
|
+
rec["interfaces"] = ints
|
|
1365
|
+
cases.append(rec)
|
|
1366
|
+
for r in mm.requirements:
|
|
1367
|
+
if r.kind != "indentation":
|
|
1368
|
+
continue
|
|
1369
|
+
ci, ri = names.index(r.case), names.index(r.relative_to)
|
|
1370
|
+
F, Fr = _case_force(bc.loads[ci]), _case_force(bc.loads[ri])
|
|
1371
|
+
d = cases[ci]["compliance"] / F - float(bc.loads[ci] @ us[ri]) / Fr
|
|
1372
|
+
cases[ci]["indentation"] = {"value": d, "atLeast": r.at_least, "meets": bool(d >= r.at_least * 0.999),
|
|
1373
|
+
"relativeTo": r.relative_to}
|
|
1374
|
+
|
|
1375
|
+
volumes = {}
|
|
1376
|
+
for k, m in bodies:
|
|
1377
|
+
volumes[mm.materials[k].name] = volumes.get(mm.materials[k].name, 0.0) + float(m.volume)
|
|
1378
|
+
mass = {n_: v * mm.materials[mm.index(n_)].density / 1000.0 for n_, v in volumes.items()}
|
|
1379
|
+
rep = {
|
|
1380
|
+
"analysisPitch": pitch, "cells": int(len(ijk)), "seconds": time.time() - t0,
|
|
1381
|
+
"volume": vol, "mass": float(sum(mass.values())),
|
|
1382
|
+
"bodies": [{"material": mm.materials[k].name, "volume": float(m.volume),
|
|
1383
|
+
"mass": float(m.volume) * mm.materials[k].density / 1000.0,
|
|
1384
|
+
"watertight": bool(m.is_watertight)} for k, m in bodies],
|
|
1385
|
+
"materialMass": mass,
|
|
1386
|
+
"materialFraction": {k_: v / vol for k_, v in volumes.items()} if vol > 0 else {},
|
|
1387
|
+
"interfaceArea": {f"{mm.materials[p[0]].name}+{mm.materials[p[1]].name}": a for p, a in pair_area.items()},
|
|
1388
|
+
"interfaceCards": {f"{mm.materials[p[0]].name}+{mm.materials[p[1]].name}": cards[p] for p in pairs},
|
|
1389
|
+
"cases": cases,
|
|
1390
|
+
"safetyFactor": float(worst["material"]),
|
|
1391
|
+
"interfaceSafetyFactor": float(worst["interface"]),
|
|
1392
|
+
"maxDisplacement": max((c["maxDisplacement"] for c in cases if c["name"] not in req
|
|
1393
|
+
or req[c["name"]].kind == "knock"), default=0.0),
|
|
1394
|
+
"requirementsMet": all(c.get("knock", c.get("indentation", {"meets": True}))["meets"] for c in cases),
|
|
1395
|
+
}
|
|
1396
|
+
rep["voxelInterfaceArea"] = face_area
|
|
1397
|
+
for (cname, what), t in narrowed.items():
|
|
1398
|
+
warnings.append(f"case {cname!r}: {what} lies within the {band:.2g} mm contact band of the supports or "
|
|
1399
|
+
"loads; checked " + ("on every cell, contact concentrations included" if t is None else
|
|
1400
|
+
"on the cells touching no support or load node" if t == 0 else
|
|
1401
|
+
f"at {t:.2g} mm from them") + " instead")
|
|
1402
|
+
# a material or bond the delivered bodies have that no case read: the
|
|
1403
|
+
# report must not pass on a check it never made
|
|
1404
|
+
unchecked = [f"{mm.materials[p[0]].name}+{mm.materials[p[1]].name}" for p in pairs
|
|
1405
|
+
if pair_area.get(p, 0.0) > 0 and not any(
|
|
1406
|
+
f"{mm.materials[p[0]].name}+{mm.materials[p[1]].name}" in c["interfaces"] for c in cases)]
|
|
1407
|
+
unchecked += [mm.materials[k_].name for k_ in sorted(set(mat.tolist()))
|
|
1408
|
+
if not any(mm.materials[k_].name in c["materials"] for c in cases)]
|
|
1409
|
+
if unchecked:
|
|
1410
|
+
rep["unchecked"] = unchecked
|
|
1411
|
+
warnings.append(f"not checked: {', '.join(unchecked)} (no cell or interface face was read); "
|
|
1412
|
+
"the design does not pass on it")
|
|
1413
|
+
if warnings:
|
|
1414
|
+
rep["warnings"] = warnings
|
|
1415
|
+
if il is not None:
|
|
1416
|
+
rep["interlockedFraction"] = deep_area / face_area if face_area > 0 else 0.0
|
|
1417
|
+
return rep
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
# ------------------------------------------------------------- the study
|
|
1421
|
+
|
|
1422
|
+
@dataclass
|
|
1423
|
+
class MultiMaterialResult:
|
|
1424
|
+
study: object
|
|
1425
|
+
mm: MultiMaterial
|
|
1426
|
+
bodies: list # (material index, trimesh)
|
|
1427
|
+
report: dict
|
|
1428
|
+
outcomes: list # every fraction tried: report summaries
|
|
1429
|
+
optimum: MMOptimum | None = None
|
|
1430
|
+
domain: object = None
|
|
1431
|
+
|
|
1432
|
+
def write(self, out_dir, name=None, coordinates: str = "bed") -> dict:
|
|
1433
|
+
"""One STL per material (study coordinates), a multi-material 3MF and
|
|
1434
|
+
report.json. `coordinates`: "bed" (default) places the 3MF for
|
|
1435
|
+
printing (build direction to +z, lowest point at z = 0, centred on
|
|
1436
|
+
the bed at 128, 128); "study" keeps the study's coordinates, as the
|
|
1437
|
+
STLs have them. report["3mf"] says which, with the transform."""
|
|
1438
|
+
import json
|
|
1439
|
+
from pathlib import Path
|
|
1440
|
+
|
|
1441
|
+
out = Path(out_dir)
|
|
1442
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1443
|
+
name = name or self.study.name
|
|
1444
|
+
files = {}
|
|
1445
|
+
for k, m in self.bodies:
|
|
1446
|
+
p = out / f"{name}-{self.mm.materials[k].name}.stl"
|
|
1447
|
+
m.export(p)
|
|
1448
|
+
files[self.mm.materials[k].name] = str(p)
|
|
1449
|
+
bodies = [{"name": f"{name} {self.mm.materials[k].name}", "mesh": m, "material": self.mm.materials[k],
|
|
1450
|
+
**({"extruder": self.mm.extruders[self.mm.materials[k].name]}
|
|
1451
|
+
if self.mm.materials[k].name in self.mm.extruders else {})}
|
|
1452
|
+
for k, m in self.bodies]
|
|
1453
|
+
tmf = None
|
|
1454
|
+
try:
|
|
1455
|
+
tmf = write_bodies_3mf(out / f"{name}.3mf", bodies, direction=self.study.print_direction,
|
|
1456
|
+
name=name, process=self.study.process, slicer=self.mm.slicer,
|
|
1457
|
+
coordinates=coordinates)
|
|
1458
|
+
files["3mf"] = tmf["path"]
|
|
1459
|
+
except ImportError:
|
|
1460
|
+
pass
|
|
1461
|
+
if self.optimum is not None:
|
|
1462
|
+
# the design fields, so the bodies can be re-split without re-optimizing
|
|
1463
|
+
np.savez_compressed(out / "optimum.npz", x=self.optimum.x, y=self.optimum.y,
|
|
1464
|
+
xphys=self.optimum.xphys, w=self.optimum.w)
|
|
1465
|
+
files["optimum"] = str(out / "optimum.npz")
|
|
1466
|
+
rep = dict(self.report)
|
|
1467
|
+
rep["files"] = files
|
|
1468
|
+
self.delivered_3mf = tmf
|
|
1469
|
+
if tmf is not None:
|
|
1470
|
+
rep["3mf"] = tmf
|
|
1471
|
+
rep["outcomes"] = self.outcomes
|
|
1472
|
+
(out / "report.json").write_text(json.dumps(_jsonable(rep), indent=2))
|
|
1473
|
+
files["report"] = str(out / "report.json")
|
|
1474
|
+
return files
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def write_bodies_3mf(path, bodies, direction=None, name="part", process=None, slicer="all",
|
|
1478
|
+
coordinates: str = "bed") -> dict:
|
|
1479
|
+
"""threemf.write_multimaterial_3mf, with a choice of where the parts sit:
|
|
1480
|
+
"bed" (its default: the print transform, build direction to +z, lowest
|
|
1481
|
+
point at z = 0, footprint centred on (128, 128)) or "study" (the build
|
|
1482
|
+
item's transform set to identity, so the 3MF's coordinates are the
|
|
1483
|
+
study's, as the STLs and verification have them; a slicer drops it to
|
|
1484
|
+
the bed itself). Returns {path, coordinates, transform (4x4, study ->
|
|
1485
|
+
file coordinates), note}."""
|
|
1486
|
+
import re
|
|
1487
|
+
import zipfile
|
|
1488
|
+
from types import SimpleNamespace
|
|
1489
|
+
|
|
1490
|
+
from . import threemf
|
|
1491
|
+
|
|
1492
|
+
if coordinates not in ("bed", "study"):
|
|
1493
|
+
raise ValueError(f"coordinates is 'bed' or 'study', got {coordinates!r}")
|
|
1494
|
+
p = threemf.write_multimaterial_3mf(path, bodies, direction=direction, name=name, process=process, slicer=slicer)
|
|
1495
|
+
d = direction if direction is not None else (2, 1)
|
|
1496
|
+
if coordinates == "bed":
|
|
1497
|
+
allv = np.vstack([np.asarray(b["mesh"].vertices, float) for b in bodies])
|
|
1498
|
+
tf = threemf.print_transform(SimpleNamespace(vertices=allv), d)
|
|
1499
|
+
note = ("placed for printing: build direction to +z, lowest point at z = 0, centred on the bed at "
|
|
1500
|
+
"(128, 128); `transform` maps study coordinates to the file's (the STLs keep the study's)")
|
|
1501
|
+
else:
|
|
1502
|
+
# the writer always applies the print transform on the build item;
|
|
1503
|
+
# put identity there instead (the meshes themselves are in study
|
|
1504
|
+
# coordinates in both slicer layouts)
|
|
1505
|
+
with zipfile.ZipFile(p) as z:
|
|
1506
|
+
parts = {i.filename: z.read(i.filename) for i in z.infolist()}
|
|
1507
|
+
model = parts["3D/3dmodel.model"].decode()
|
|
1508
|
+
ident = "1 0 0 0 1 0 0 0 1 0 0 0"
|
|
1509
|
+
model = re.sub(r"(<build\b.*?</build>)",
|
|
1510
|
+
lambda m: re.sub(r'transform="[^"]*"', f'transform="{ident}"', m.group(1)), model, flags=re.S)
|
|
1511
|
+
parts["3D/3dmodel.model"] = model.encode()
|
|
1512
|
+
with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as z:
|
|
1513
|
+
for k, v in parts.items():
|
|
1514
|
+
z.writestr(k, v)
|
|
1515
|
+
tf = np.eye(4)
|
|
1516
|
+
note = "in the study's coordinates (identity transform), as the STLs; the slicer places it on the bed"
|
|
1517
|
+
return {"path": str(p), "coordinates": coordinates, "transform": tf.tolist(), "note": note}
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _jsonable(o):
|
|
1521
|
+
if isinstance(o, dict):
|
|
1522
|
+
return {str(k): _jsonable(v) for k, v in o.items()}
|
|
1523
|
+
if isinstance(o, (list, tuple)):
|
|
1524
|
+
return [_jsonable(v) for v in o]
|
|
1525
|
+
if isinstance(o, (np.floating, float)):
|
|
1526
|
+
v = float(o)
|
|
1527
|
+
return None if not np.isfinite(v) else v
|
|
1528
|
+
if isinstance(o, np.integer):
|
|
1529
|
+
return int(o)
|
|
1530
|
+
if isinstance(o, np.bool_):
|
|
1531
|
+
return bool(o)
|
|
1532
|
+
if isinstance(o, np.ndarray):
|
|
1533
|
+
return _jsonable(o.tolist())
|
|
1534
|
+
return o
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _margin(study, mm, rep):
|
|
1538
|
+
"""The design's margin on its goals (>= 1 passes): per case, the
|
|
1539
|
+
materials against the goal's safety factor and the bonds against the
|
|
1540
|
+
interface's, except a knock with its own safetyFactor, judged at it."""
|
|
1541
|
+
g = study.goal
|
|
1542
|
+
ms = []
|
|
1543
|
+
sfi = mm.interface_safety_factor or g.safety_factor
|
|
1544
|
+
own = {r.case: r.safety_factor for r in mm.requirements if r.kind == "knock" and r.safety_factor}
|
|
1545
|
+
if rep.get("cases"):
|
|
1546
|
+
for c in rep["cases"]:
|
|
1547
|
+
sm, si = own.get(c["name"], g.safety_factor), own.get(c["name"], sfi)
|
|
1548
|
+
if sm:
|
|
1549
|
+
for v in c.get("materials", {}).values():
|
|
1550
|
+
ms.append(v["safetyFactor"] / sm)
|
|
1551
|
+
if si:
|
|
1552
|
+
for v in c.get("interfaces", {}).values():
|
|
1553
|
+
if v["safetyFactor"] < np.inf:
|
|
1554
|
+
ms.append(v["safetyFactor"] / si)
|
|
1555
|
+
else:
|
|
1556
|
+
if g.safety_factor:
|
|
1557
|
+
ms.append(rep["safetyFactor"] / g.safety_factor)
|
|
1558
|
+
if sfi and rep["interfaceSafetyFactor"] < np.inf:
|
|
1559
|
+
ms.append(rep["interfaceSafetyFactor"] / sfi)
|
|
1560
|
+
if g.max_deflection:
|
|
1561
|
+
ms.append(g.max_deflection / max(rep["maxDisplacement"], 1e-12))
|
|
1562
|
+
return min(ms) if ms else None
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _attempt_summary(vf, rep) -> dict:
|
|
1566
|
+
"""One fraction tried, as the progress event and report.outcomes give it."""
|
|
1567
|
+
out = {"volumeFraction": vf, "mass": rep["mass"], "safetyFactor": rep["safetyFactor"],
|
|
1568
|
+
"interfaceSafetyFactor": rep["interfaceSafetyFactor"], "margin": rep["margin"],
|
|
1569
|
+
"passes": rep["passes"], "materialFraction": rep["materialFraction"],
|
|
1570
|
+
"requirementsMet": rep.get("requirementsMet")}
|
|
1571
|
+
# the worst case per material and per bond, so a failed step says which
|
|
1572
|
+
per, bond = {}, {}
|
|
1573
|
+
for c in rep.get("cases") or []:
|
|
1574
|
+
for k, v in (c.get("materials") or {}).items():
|
|
1575
|
+
if k not in per or v["safetyFactor"] < per[k]["safetyFactor"]:
|
|
1576
|
+
per[k] = {"safetyFactor": v["safetyFactor"], "case": c["name"]}
|
|
1577
|
+
for k, v in (c.get("interfaces") or {}).items():
|
|
1578
|
+
if k not in bond or v["safetyFactor"] < bond[k]["safetyFactor"]:
|
|
1579
|
+
bond[k] = {"safetyFactor": v["safetyFactor"], "case": c["name"]}
|
|
1580
|
+
if per:
|
|
1581
|
+
out["materials"] = per
|
|
1582
|
+
if bond:
|
|
1583
|
+
out["interfaces"] = bond
|
|
1584
|
+
for k in ("unchecked", "warnings"):
|
|
1585
|
+
if rep.get(k):
|
|
1586
|
+
out[k] = rep[k]
|
|
1587
|
+
return out
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _write_partial(mm, vf, bodies, summary):
|
|
1591
|
+
"""With LOADPATH_PARTIAL_DIR set (`loadpath run` sets it to out/partial),
|
|
1592
|
+
each fraction's bodies (an STL per material) and summary as the search
|
|
1593
|
+
verifies it, so a run killed later keeps what it found (study.py does
|
|
1594
|
+
the same for one material)."""
|
|
1595
|
+
import json
|
|
1596
|
+
import os
|
|
1597
|
+
|
|
1598
|
+
d = os.environ.get("LOADPATH_PARTIAL_DIR")
|
|
1599
|
+
if not d:
|
|
1600
|
+
return
|
|
1601
|
+
try:
|
|
1602
|
+
os.makedirs(d, exist_ok=True)
|
|
1603
|
+
stem = f"vf{vf:.4f}".replace(".", "p")
|
|
1604
|
+
stls = {}
|
|
1605
|
+
for k, m in bodies:
|
|
1606
|
+
name = mm.materials[k].name
|
|
1607
|
+
m.export(os.path.join(d, f"{stem}-{name}.stl"))
|
|
1608
|
+
stls[name] = f"{stem}-{name}.stl"
|
|
1609
|
+
with open(os.path.join(d, "outcomes.jsonl"), "a") as fh:
|
|
1610
|
+
fh.write(json.dumps(_jsonable({"stl": stls, **summary})) + "\n")
|
|
1611
|
+
except Exception: # noqa: BLE001 (a convenience: never fail the run for it)
|
|
1612
|
+
pass
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _print_check(study, mm, bodies, rep):
|
|
1616
|
+
"""printcheck.check_print on the union of the bodies (support, bridges,
|
|
1617
|
+
islands, bed contact), as verify.py gives a single-material part, when
|
|
1618
|
+
the study has a build direction; None without one."""
|
|
1619
|
+
proc = study.process
|
|
1620
|
+
build = study.print_direction if study.print_direction is not None else \
|
|
1621
|
+
(proc.build if proc is not None and proc.build not in (None, "auto") else None)
|
|
1622
|
+
if build is None or not bodies:
|
|
1623
|
+
return None
|
|
1624
|
+
try:
|
|
1625
|
+
from .printcheck import check_print
|
|
1626
|
+
from .shapes import manifold_from_trimesh, trimesh_from_manifold
|
|
1627
|
+
|
|
1628
|
+
whole = manifold_from_trimesh(bodies[0][1])
|
|
1629
|
+
for _, m in bodies[1:]:
|
|
1630
|
+
whole = whole + manifold_from_trimesh(m)
|
|
1631
|
+
vols = {}
|
|
1632
|
+
for k, m in bodies:
|
|
1633
|
+
vols[k] = vols.get(k, 0.0) + float(m.volume)
|
|
1634
|
+
main = mm.materials[max(vols, key=vols.get)]
|
|
1635
|
+
pc = check_print(trimesh_from_manifold(whole), build, process=proc.kind if proc is not None else "fdm",
|
|
1636
|
+
material=main, bridge_span=getattr(proc, "bridge_span", None))
|
|
1637
|
+
return {k: v for k, v in pc.items() if k != "bridgeList"}
|
|
1638
|
+
except Exception as exc: # noqa: BLE001 - the parts and the report stand without it
|
|
1639
|
+
rep.setdefault("warnings", []).append(f"print check not run: {type(exc).__name__}: {exc}")
|
|
1640
|
+
return None
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def run_multimaterial(study, on_progress=None, should_stop=None, max_runs: int = 5) -> MultiMaterialResult:
|
|
1644
|
+
"""Optimize, mesh, split and verify a multi-material study. With a
|
|
1645
|
+
volumeFraction goal, one design; with a safety factor or deflection,
|
|
1646
|
+
the lightest of a short search over fractions that meets it (and its
|
|
1647
|
+
interfaces' safety factor, and every softness requirement)."""
|
|
1648
|
+
from .domain import build_domain
|
|
1649
|
+
from .reconstruct import ReconstructOptions, reconstruct
|
|
1650
|
+
from .simp import filter_radius
|
|
1651
|
+
|
|
1652
|
+
mm = study.multi_material
|
|
1653
|
+
emit = on_progress or (lambda *a, **k: None)
|
|
1654
|
+
if study.gravity is not None:
|
|
1655
|
+
emit({"stage": "warning", "message": "multiMaterial leaves self-weight out of the optimization"})
|
|
1656
|
+
domain = build_domain(study)
|
|
1657
|
+
if mm.interlock is not None and mm.interlock.avoid is None:
|
|
1658
|
+
mm.interlock.avoid = mm.interlock.beam_width + 0.5 * domain.pitch
|
|
1659
|
+
radius = filter_radius(domain, study.length_scale, study.min_hole)
|
|
1660
|
+
emit({"stage": "domain", "cells": domain.n_elem, "pitch": domain.pitch, "warnings": domain.warnings})
|
|
1661
|
+
tried = {}
|
|
1662
|
+
|
|
1663
|
+
def attempt(vf, start=None):
|
|
1664
|
+
def on_iter(rec, _):
|
|
1665
|
+
emit({"stage": "optimize", "volumeFraction": vf, **rec})
|
|
1666
|
+
|
|
1667
|
+
try:
|
|
1668
|
+
res = optimize_multimaterial(study, domain, mm, vf, radius, x0=start[0] if start else None,
|
|
1669
|
+
y0=start[1] if start else None, on_iter=on_iter, should_stop=should_stop)
|
|
1670
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError) as exc:
|
|
1671
|
+
# MMA can diverge on a badly violated start; that fraction fails, the search goes on
|
|
1672
|
+
rep = {"mass": np.inf, "volume": 0.0, "safetyFactor": 0.0, "interfaceSafetyFactor": 0.0,
|
|
1673
|
+
"maxDisplacement": np.inf, "requirementsMet": False, "materialFraction": {}, "cases": [],
|
|
1674
|
+
"bodies": [], "warnings": [f"optimization failed: {exc}"], "volumeFraction": vf,
|
|
1675
|
+
"margin": 0.0, "passes": False}
|
|
1676
|
+
tried[vf] = (None, [], rep)
|
|
1677
|
+
return rep
|
|
1678
|
+
emit({"stage": "mesh", "volumeFraction": vf})
|
|
1679
|
+
solid, _ = reconstruct(study, domain, res.xphys, ReconstructOptions())
|
|
1680
|
+
labels = material_labels(domain, res.xphys, res.w)
|
|
1681
|
+
bodies = split_bodies(domain, labels, solid, mm.names)
|
|
1682
|
+
emit({"stage": "verify", "volumeFraction": vf})
|
|
1683
|
+
try:
|
|
1684
|
+
rep = verify_multimaterial(study, mm, bodies, min_pitch=0.5 * domain.pitch, contact_band=domain.pitch)
|
|
1685
|
+
except (RuntimeError, ValueError) as exc:
|
|
1686
|
+
# a design the bodies' voxel model cannot hold (a load left on a
|
|
1687
|
+
# piece the supports do not reach) fails; the search goes on
|
|
1688
|
+
vol = sum(float(m.volume) for _, m in bodies)
|
|
1689
|
+
rep = {"mass": sum(float(m.volume) * mm.materials[k_].density / 1000.0 for k_, m in bodies),
|
|
1690
|
+
"volume": vol, "safetyFactor": 0.0, "interfaceSafetyFactor": 0.0, "maxDisplacement": np.inf,
|
|
1691
|
+
"requirementsMet": False, "materialFraction": {}, "cases": [],
|
|
1692
|
+
"bodies": [{"material": mm.materials[k_].name, "volume": float(m.volume)} for k_, m in bodies],
|
|
1693
|
+
"warnings": [f"verification failed: {exc}"]}
|
|
1694
|
+
rep["optimization"] = {"iterations": res.iterations, "seconds": res.seconds,
|
|
1695
|
+
"compliance": res.compliance, "interfaceWeight": res.interface_weight,
|
|
1696
|
+
"requirements": res.requirement_values,
|
|
1697
|
+
"fractions": res.history[-1]["fractions"] if res.history else {}}
|
|
1698
|
+
rep["volumeFraction"] = vf
|
|
1699
|
+
m = _margin(study, mm, rep)
|
|
1700
|
+
rep["margin"] = m
|
|
1701
|
+
# a bond or material verification could not read passes nothing
|
|
1702
|
+
rep["passes"] = bool((m is None or m >= 1.0) and rep["requirementsMet"] and not rep.get("unchecked"))
|
|
1703
|
+
tried[vf] = (res, bodies, rep)
|
|
1704
|
+
summary = _attempt_summary(vf, rep)
|
|
1705
|
+
emit({"stage": "outcome", **summary})
|
|
1706
|
+
_write_partial(mm, vf, bodies, summary)
|
|
1707
|
+
return rep
|
|
1708
|
+
|
|
1709
|
+
g = study.goal
|
|
1710
|
+
# Preserved cells are solid at any fraction, so a fraction at or under
|
|
1711
|
+
# their share of the design space cannot be met: the volume constraint
|
|
1712
|
+
# stays violated and MMA runs a step that means nothing.
|
|
1713
|
+
pres_share = float(np.mean(domain.preserve))
|
|
1714
|
+
vf_floor = min(0.9, pres_share + 0.05)
|
|
1715
|
+
search_warnings = []
|
|
1716
|
+
|
|
1717
|
+
def _warn(msg):
|
|
1718
|
+
search_warnings.append(msg)
|
|
1719
|
+
emit({"stage": "warning", "message": msg})
|
|
1720
|
+
|
|
1721
|
+
if g.safety_factor is None and g.max_deflection is None:
|
|
1722
|
+
vf = float(g.volume_fraction)
|
|
1723
|
+
if vf <= pres_share:
|
|
1724
|
+
_warn(f"goal.volumeFraction {vf:.2f} is at or under the preserved share of the design space "
|
|
1725
|
+
f"({pres_share:.2f}): the volume constraint cannot be met; raise it above {pres_share:.2f}")
|
|
1726
|
+
attempt(vf)
|
|
1727
|
+
else:
|
|
1728
|
+
vf = float(g.volume_fraction or 0.3)
|
|
1729
|
+
if vf < vf_floor:
|
|
1730
|
+
_warn(f"preserved regions are {pres_share:.0%} of the design space; the search starts at a volume "
|
|
1731
|
+
f"fraction of {vf_floor:.2f} instead of {vf:.2f}")
|
|
1732
|
+
vf = vf_floor
|
|
1733
|
+
lo, hi = None, None # highest failing, lowest passing
|
|
1734
|
+
for _ in range(max_runs):
|
|
1735
|
+
above = [v for v in tried if v > vf]
|
|
1736
|
+
src = next((tried[v][0] for v in sorted(above) if tried[v][0] is not None), None)
|
|
1737
|
+
rep = attempt(vf, (src.x, src.y) if src is not None else None)
|
|
1738
|
+
if rep["passes"]:
|
|
1739
|
+
hi = vf if hi is None else min(hi, vf)
|
|
1740
|
+
else:
|
|
1741
|
+
lo = vf if lo is None else max(lo, vf)
|
|
1742
|
+
if hi is None:
|
|
1743
|
+
if vf >= 0.9:
|
|
1744
|
+
break
|
|
1745
|
+
vf = min(0.9, vf * 1.5)
|
|
1746
|
+
elif lo is None:
|
|
1747
|
+
vf = max(0.03, vf_floor, vf * 0.7)
|
|
1748
|
+
else:
|
|
1749
|
+
if hi - lo < 0.01:
|
|
1750
|
+
break
|
|
1751
|
+
vf = 0.5 * (lo + hi)
|
|
1752
|
+
if vf in tried:
|
|
1753
|
+
break
|
|
1754
|
+
passing = [v for v, t in tried.items() if t[2]["passes"]]
|
|
1755
|
+
if passing:
|
|
1756
|
+
pick = min(passing, key=lambda v: tried[v][2]["mass"])
|
|
1757
|
+
else:
|
|
1758
|
+
ran = [v for v in tried if tried[v][0] is not None] or list(tried)
|
|
1759
|
+
pick = max(ran, key=lambda v: (tried[v][2]["margin"] or 0.0))
|
|
1760
|
+
res, bodies, rep = tried[pick]
|
|
1761
|
+
if res is None:
|
|
1762
|
+
raise RuntimeError("multiMaterial: the optimizer failed at every fraction tried: "
|
|
1763
|
+
+ "; ".join(w for t in tried.values() for w in t[2].get("warnings", [])))
|
|
1764
|
+
rep = dict(rep)
|
|
1765
|
+
if search_warnings:
|
|
1766
|
+
rep["warnings"] = search_warnings + list(rep.get("warnings") or [])
|
|
1767
|
+
if mm.interlock is not None and len(bodies) > 1:
|
|
1768
|
+
# verification judged the smooth interface against the interlocked
|
|
1769
|
+
# card; the delivered bodies carry the lattice
|
|
1770
|
+
emit({"stage": "interlock", "volumeFraction": pick})
|
|
1771
|
+
solid, _ = reconstruct(study, domain, res.xphys, ReconstructOptions())
|
|
1772
|
+
labels = material_labels(domain, res.xphys, res.w)
|
|
1773
|
+
try:
|
|
1774
|
+
locked = split_bodies(domain, labels, solid, mm.names, interlock=mm.interlock,
|
|
1775
|
+
build_axis=_build_axis(study) or 2)
|
|
1776
|
+
rep["interlock"] = {**mm.interlock.to_dict(), "verifiedOn": "smooth interface, interlocked card",
|
|
1777
|
+
"bodies": [{"material": mm.materials[k_].name, "volume": float(m_.volume),
|
|
1778
|
+
"watertight": bool(m_.is_watertight)} for k_, m_ in locked]}
|
|
1779
|
+
bodies = locked
|
|
1780
|
+
except Exception as exc: # noqa: BLE001 - a failed lattice leaves the smooth bodies
|
|
1781
|
+
rep.setdefault("warnings", []).append(f"interlock not built ({exc}); bodies have flat interfaces")
|
|
1782
|
+
rep["print"] = _print_check(study, mm, bodies, rep)
|
|
1783
|
+
rep["multiMaterial"] = {"materials": [m.to_dict() for m in mm.materials],
|
|
1784
|
+
"requirements": [r.to_dict() for r in mm.requirements],
|
|
1785
|
+
"interfaceSafetyFactor": mm.interface_safety_factor,
|
|
1786
|
+
"interlock": mm.interlock.to_dict() if mm.interlock is not None else None}
|
|
1787
|
+
summary = [_attempt_summary(v, t[2]) for v, t in sorted(tried.items())]
|
|
1788
|
+
return MultiMaterialResult(study, mm, bodies, rep, summary, res, domain)
|