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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. 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)