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/buildsim.py ADDED
@@ -0,0 +1,1490 @@
1
+ """
2
+ Build-process simulation: the residual stress and distortion a part picks
3
+ up while it is printed, and what to do about it.
4
+
5
+ One mechanism covers the three processes this handles. Each layer goes
6
+ down hot and stress-free, bonds to the (already distorted) part below, and
7
+ then shrinks as it cools; what it is bonded to stops part of that shrink,
8
+ so the layer is left in tension, the part below it bends, and when the part
9
+ comes off the plate or bed it springs into a new shape. The solver adds the
10
+ part a layer at a time on the voxel grid fem.py uses, gives each new layer
11
+ an *inherent strain* (the shrink it would take if it were free), solves for
12
+ the displacement increment with only the cells printed so far, and carries
13
+ every cell's stress forward. A new layer is stress-free in the shape it is
14
+ laid into: its bottom nodes sit where the layer below has moved to, its top
15
+ is the nominal plane (the recoater or nozzle levels it). Then the part is
16
+ released (cut off the plate, or peeled off the bed) and springs back.
17
+
18
+ What sets the inherent strain:
19
+
20
+ lpbf Each melt track shrinks from the solidus and is held by the solid
21
+ metal around it, so it yields; what it keeps is roughly the yield
22
+ strain. eps = -k * yield / E in the layer plane (and `kz` times that
23
+ along the build), with k fitted to a published distortion benchmark
24
+ (validated in bench/buildsim_validate.py and docs/BUILDSIM.md). The
25
+ commercial tools (Ansys Additive's assumed-uniform-strain mode,
26
+ Netfabb, Simufact) do the same with a strain scaling factor they
27
+ calibrate per machine and material.
28
+ fdm A road freezes at its glass transition and cools to the temperature
29
+ the part sits at during the print, set by the bed and the chamber:
30
+ T(z) = T_chamber + (T_bed - T_chamber) exp(-z / lambda). Polymer
31
+ relaxes while warm, so only `relax` of that shrink is kept. After
32
+ the print the whole part cools on the bed to room temperature
33
+ (stage two), then comes off. The bed holds the first layer until
34
+ the peel traction beats the adhesion, so the report gives the peel
35
+ stress at the bed: that, not the final warp, is what lifts corners.
36
+ sls The part sinters in a hot powder cake and cools as a whole, so there
37
+ is no layer stepping. The bulk shrink (3-4%) is taken out by the
38
+ printer's scale factor; what warps a part is the difference between
39
+ thin walls, which cool fast, and thick sections, which crystallize
40
+ more and shrink more. eps = -ds * min(depth / depth_sat, 1),
41
+ depth = distance to the nearest surface. That model is a heuristic
42
+ (stated in the report), sized to the 0.2-0.5% differential shrink
43
+ nylon sintering reports, not fitted to a measured part.
44
+
45
+ Layers are lumped: one analysis layer is `layer` mm (default one voxel) of
46
+ real layers, which is how every inherent-strain code runs a part in finite
47
+ time. Lumping is not free: a thick slab laid at once on a compliant part
48
+ bends it less than the same slab laid in thin layers (on the free strip of
49
+ tests/test_buildsim.py, 0.5 mm lumps give 72% of the thin-layer curvature,
50
+ progressive_beam). On a part held by a thick plate the effect is smaller;
51
+ the LPBF factor was fitted at the lumping the validation runs, and
52
+ docs/BUILDSIM.md gives the pitch study.
53
+
54
+ What it gives back (BuildResult): the displacement field before and after
55
+ release (best-fit rigid motion removed after a full release, as a CMM
56
+ reading of a free part would), the residual stress, the peak von Mises
57
+ against the material's yield, the upward lift at the build plane during
58
+ the build (an LPBF recoater-contact indicator), the FDM bed peel stress,
59
+ and a sampler of the displacement anywhere, which `compensate` uses to
60
+ pre-distort exported geometry so the print comes out nominal.
61
+
62
+ `optimize_warp` puts the as-built distortion into topology optimization:
63
+ weighted compliance plus distortion, with the gradient of the layer-by-
64
+ layer solve by adjoint (two solves per analysis layer and iteration on a
65
+ factorization shared between them).
66
+ """
67
+ from __future__ import annotations
68
+
69
+ import time
70
+ from dataclasses import dataclass, field
71
+
72
+ import numpy as np
73
+ import scipy.sparse as sp
74
+
75
+ from .fem import HexMesh, Stiffness, _b_matrix, amg_solver, elastic_c, hex8_b_center, rigid_body_modes, \
76
+ von_mises
77
+
78
+ __all__ = ["BUILD", "BuildSetup", "BuildResult", "inherent_strain", "simulate_build", "simulate_part",
79
+ "compensate", "compensate_mesh", "to_build_frame", "progressive_beam", "optimize_warp",
80
+ "WarpOptions", "WarpResult"]
81
+
82
+
83
+ # Process parameters by material. Every number carries its source; the
84
+ # fitted ones say what they were fitted to. Values a datasheet does not
85
+ # give are marked "estimate" with why.
86
+ BUILD = {
87
+ # LPBF: k is the share of the yield strain a layer keeps (fit: see
88
+ # LPBF_K below); kz the build-direction strain relative to in-plane.
89
+ "lpbf": {"k": None, "kz": 1.0},
90
+ # FDM: tg is where an amorphous road stops flowing (DSC Tg); tc, for a
91
+ # semi-crystalline one (PP, nylon), where it crystallizes on cooling
92
+ # and turns solid, far above its Tg. relax is the share of the thermal
93
+ # shrink below that the polymer keeps as stress (the rest creeps out
94
+ # while the part is warm). bed/chamber: typical settings.
95
+ "fdm": {
96
+ "abs": {"tg": (101.1, "polylite-abs DSC"), "bed": 100.0, "chamber": 35.0},
97
+ "asa": {"tg": (97.8, "polylite-asa DSC"), "bed": 100.0, "chamber": 35.0},
98
+ "petg": {"tg": (80.0, "estimate: PETG copolyester Tg 78-81 C (Eastman Eastar 6763, DSC)"),
99
+ "bed": 80.0, "chamber": 30.0},
100
+ "pla": {"tg": (60.0, "estimate: PLA Tg 55-65 C"), "bed": 60.0, "chamber": 30.0},
101
+ "pa-cf": {"tg": (70.0, "bambu-paht-cf DSC"), "bed": 90.0, "chamber": 40.0},
102
+ "pp": {"tc": (115.0, "estimate: isotactic PP crystallizes at 110-120 C on cooling (DSC, 10 K/min)"),
103
+ "bed": 100.0, "chamber": 25.0},
104
+ "pa12": {"tc": (150.0, "estimate: PA12 crystallizes at 145-155 C on cooling (DSC, 10 K/min)"),
105
+ "bed": 90.0, "chamber": 40.0},
106
+ },
107
+ # SLS: ds the extra linear shrink of a thick section over a thin wall;
108
+ # depth_sat the depth (mm) past which a section counts as thick.
109
+ "sls": {"ds": 0.003, "depth_sat": 4.0},
110
+ }
111
+
112
+ # LPBF strain factor k (eps = -k * yield / E), fitted to the NIST AM-Bench
113
+ # 2018 bridges at 0.5 mm voxels, one analysis layer per voxel, the 12.7 mm
114
+ # plate reduced to 2 voxel layers (plate_cells; on the whole plate the fit
115
+ # was 0.909) (bench/buildsim_validate.py; docs/BUILDSIM.md). The yield-strain
116
+ # scaling does not carry across alloy classes: with IN625's k, 15-5 PH
117
+ # came out 60% high (its martensite forms on cooling and expands, which
118
+ # undoes part of the shrink), so 15-5 PH has its own fit. Alloys without a
119
+ # fit use IN625's and the report says so.
120
+ LPBF_K = 0.839
121
+ LPBF_K_FIT = {"in625": 0.839, "15-5ph": 0.522}
122
+ # FDM retained share of the sub-Tg shrink, and the bed's hold (N/mm^3 of
123
+ # elastic foundation): fitted together on the nine 20 mm-wide ABS bars of
124
+ # Armillotta et al. 2018 (bench/buildsim_fdm.py; rms 0.16 mm, mean 24%;
125
+ # the bed pinned only in plane, so corners can lift). The decay length
126
+ # (mm) of the bed's heat up the part is an estimate. FDM_ADHESION: MPa of
127
+ # bond tension before the first layer lets go; off by default, since
128
+ # letting go made the Armillotta fit worse (it flattens the tall bars and
129
+ # leaves the thin ones), see docs/BUILDSIM.md.
130
+ FDM_RELAX = 0.895
131
+ FDM_BED = 4.0
132
+ FDM_DECAY = 6.0
133
+ FDM_ADHESION = float("inf")
134
+ # FDM: the printed layer (mm) builds are extrapolated to when their analysis
135
+ # layers are thicker (layer_extrapolation); 0.2 mm is the common setting.
136
+ FDM_PRINT_LAYER = 0.2
137
+ ROOM = 23.0
138
+
139
+
140
+ @dataclass
141
+ class BuildSetup:
142
+ """How the part is made. Lengths mm, temperatures C, strains signed
143
+ (negative = shrink). Everything not given comes from BUILD and the
144
+ material library."""
145
+ process: str = "lpbf"
146
+ material: str = "ti64"
147
+ layer: float | None = None # mm per analysis layer (default: one voxel)
148
+ plate: float = 10.0 # mm of build plate under the part (lpbf); 0 = rigid plate
149
+ plate_margin: float = 2.0 # mm the plate extends past the part's footprint
150
+ plate_fixed: bool = True # plate bolted down along its underside; False: free (3-2-1)
151
+ # at most this many cells of plate; a thinner model plate is made as
152
+ # compliant as the real one through the thickness (modulus scaled by
153
+ # the thickness ratio), for a plate held along its underside only.
154
+ # None = model the whole plate. On AM-Bench at 0.5 mm voxels, 2 cells
155
+ # ran in 65 s against 181 s and rose 8% more; LPBF_K is fitted with 2.
156
+ plate_cells: int | None = 2
157
+ strain: tuple | None = None # inherent strain override (exx, eyy, ezz), build frame
158
+ scale: float = 1.0 # multiplies the inherent strain (a calibration)
159
+ k: float | None = None # lpbf: share of the yield strain kept (default LPBF_K)
160
+ kz: float | None = None # lpbf: build-direction strain / in-plane
161
+ bed: float | None = None # fdm: bed temperature
162
+ chamber: float | None = None # fdm: chamber / ambient air during the print
163
+ room: float = ROOM
164
+ relax: float | None = None # fdm: share of the sub-Tg shrink kept
165
+ decay: float | None = None # fdm: mm the bed's heat reaches up the part
166
+ ds: float | None = None # sls: differential shrink, thick vs thin
167
+ depth_sat: float | None = None # sls: mm
168
+ release: str = "full" # "full" (off the plate, free), "none" (stay on), "cut"
169
+ cut: tuple | None = None # release="cut": ((x0,y0,z0),(x1,y1,z1)) box of cells removed
170
+ E: float | None = None # MPa override
171
+ nu: float | None = None
172
+ yield_strength: float | None = None # MPa override (lpbf strain, stress check)
173
+ alpha: float | None = None # 1/K override (fdm)
174
+ # fdm: how the bed holds the first layer, N/mm^3 (stiffness per unit
175
+ # area of an elastic foundation in z and in plane); None = FDM_BED
176
+ # (fitted), inf = rigid (glued), 0 = not at all (curls freely)
177
+ bed_stiffness: float | None = None
178
+ # fdm: peel strength of the bed bond, MPa: where the bed's pull on the
179
+ # first layer passes it, that spot lets go for good (corner lift).
180
+ # None = FDM_ADHESION, inf = never
181
+ bed_adhesion: float | None = None
182
+ recoater_gap: float = 0.05 # mm: lift at the build plane that can touch the recoater
183
+ # fdm: the printed layer, mm (None: FDM_PRINT_LAYER; 0: no extrapolation)
184
+ print_layer: float | None = None
185
+ # fdm: brim width, mm (0: none): more bed hold under the footprint's edge
186
+ brim: float = 0.0
187
+
188
+
189
+ @dataclass
190
+ class BuildResult:
191
+ mesh: HexMesh
192
+ part: np.ndarray # bool per element: the printed part (not plate)
193
+ layer_of: np.ndarray # analysis layer per element (-1 = plate)
194
+ u_built: np.ndarray # (n_nodes, 3) mm, at the end of the build, still on the plate/bed
195
+ u: np.ndarray # (n_nodes, 3) mm, after release (rigid motion removed if free)
196
+ stress_built: np.ndarray # (ne, 6) MPa
197
+ stress: np.ndarray # (ne, 6) MPa after release
198
+ strain: np.ndarray # (ne, 6) inherent strain applied
199
+ report: dict = field(default_factory=dict)
200
+ node_active: np.ndarray | None = None # bool per node: belongs to the released part
201
+
202
+ def sampler(self, released=True):
203
+ return _NodeField(self.mesh, self.u if released else self.u_built,
204
+ self.node_active if released else None)
205
+
206
+ def displacement_at(self, points, released=True):
207
+ """Displacement (n, 3) at world points, trilinear in the cell the
208
+ point is in; points outside the part take the nearest node's."""
209
+ return self.sampler(released)(points)
210
+
211
+
212
+ # ------------------------------------------------------------ the strain
213
+
214
+
215
+ def _material(name):
216
+ from .materials import material_from_spec
217
+ return material_from_spec(name)
218
+
219
+
220
+ def _lib(name, key, default=None):
221
+ from .material_library import value
222
+ return value(name, key, default)
223
+
224
+
225
+ def inherent_strain(setup: BuildSetup, z=None, depth=None) -> tuple[np.ndarray, dict]:
226
+ """Per-cell inherent strain (n, 6) for heights `z` (mm above the plate,
227
+ fdm) or depths below the surface (mm, sls), and a dict of what set it.
228
+ lpbf is uniform: one row broadcast."""
229
+ proc = setup.process.lower()
230
+ mat = _material(setup.material)
231
+ e = float(setup.E or mat.E)
232
+ n = 1 if z is None and depth is None else len(z if z is not None else depth)
233
+ info = {"process": proc, "material": setup.material, "E": e}
234
+ if setup.strain is not None:
235
+ s = np.asarray(setup.strain, float) * setup.scale
236
+ out = np.zeros((n, 6))
237
+ out[:, :3] = s
238
+ info.update({"model": "given", "strain": s.tolist()})
239
+ return out, info
240
+ if proc == "lpbf":
241
+ sy = float(setup.yield_strength or mat.strength)
242
+ name = str(setup.material).lower()
243
+ k = LPBF_K_FIT.get(name, LPBF_K) if setup.k is None else float(setup.k)
244
+ info["kSource"] = ("given" if setup.k is not None else
245
+ "fitted to AM-Bench 2018 (this alloy)" if name in LPBF_K_FIT else
246
+ "AM-Bench 2018 IN625 fit, not calibrated for this alloy: could be off 2x (calibrate with a "
247
+ "cantilever cut test and set buildSim.scale)")
248
+ kz = BUILD["lpbf"]["kz"] if setup.kz is None else float(setup.kz)
249
+ ep = -k * sy / e * setup.scale
250
+ out = np.zeros((n, 6))
251
+ out[:, :2] = ep
252
+ out[:, 2] = kz * ep
253
+ info.update({"model": "yield-strain", "k": k, "kz": kz, "yield": sy, "strain": [ep, ep, kz * ep]})
254
+ return out, info
255
+ if proc == "fdm":
256
+ p = _fdm_params(setup)
257
+ tz = _fdm_temperature(p, np.zeros(n) if z is None else np.asarray(z, float))
258
+ ep = -p["relax"] * p["alpha"] * np.maximum(p["tg"] - tz, 0.0) * setup.scale
259
+ out = np.zeros((n, 6))
260
+ out[:, :3] = ep[:, None]
261
+ info.update({"model": "solidification shrink", **{k: v for k, v in p.items() if k != "tgSource"},
262
+ "tgSource": p["tgSource"]})
263
+ return out, info
264
+ if proc in ("sls", "mjf"):
265
+ ds = BUILD["sls"]["ds"] if setup.ds is None else float(setup.ds)
266
+ dsat = BUILD["sls"]["depth_sat"] if setup.depth_sat is None else float(setup.depth_sat)
267
+ d = np.zeros(n) if depth is None else np.asarray(depth, float)
268
+ ep = -ds * np.minimum(d / dsat, 1.0) * setup.scale
269
+ out = np.zeros((n, 6))
270
+ out[:, :3] = ep[:, None]
271
+ info.update({"model": "differential cooling shrink (heuristic)", "ds": ds, "depthSat": dsat})
272
+ return out, info
273
+ raise ValueError(f"build simulation: process must be lpbf, fdm or sls, got {setup.process!r}")
274
+
275
+
276
+ def _fdm_params(setup):
277
+ """The FDM shrink model's numbers. 'tg' is the temperature the road
278
+ turns solid at: Tg for an amorphous polymer, the crystallization
279
+ temperature for a semi-crystalline one (the BUILD row's 'tc')."""
280
+ from .thermal import expansion
281
+ name = str(setup.material).lower()
282
+ row = BUILD["fdm"].get(name)
283
+ if row is not None and "tc" in row:
284
+ tg, src = row["tc"]
285
+ kind = "semi-crystalline: solid below crystallization"
286
+ else:
287
+ kind = "amorphous: solid below Tg"
288
+ tg = _lib(name, "tg")
289
+ src = "material library DSC"
290
+ if row is not None and tg is None:
291
+ tg, src = row["tg"]
292
+ if tg is None:
293
+ raise ValueError(f"fdm build simulation: no glass transition or crystallization temperature for {name!r}; "
294
+ "give BuildSetup.strain")
295
+ alpha = float(setup.alpha) if setup.alpha is not None else float(expansion(name))
296
+ bed = setup.bed if setup.bed is not None else (row or {}).get("bed", 60.0)
297
+ ch = setup.chamber if setup.chamber is not None else (row or {}).get("chamber", 30.0)
298
+ return {"tg": float(tg), "tgSource": src, "solidifies": kind, "alpha": alpha, "bed": float(bed),
299
+ "chamber": float(ch), "room": float(setup.room),
300
+ "relax": FDM_RELAX if setup.relax is None else float(setup.relax),
301
+ "decay": FDM_DECAY if setup.decay is None else float(setup.decay)}
302
+
303
+
304
+ def _fdm_temperature(p, z):
305
+ """Where each height sits during the print: bed temperature at the bed,
306
+ the chamber's far up, never above Tg (a layer below Tg is solid)."""
307
+ t = p["chamber"] + (p["bed"] - p["chamber"]) * np.exp(-np.maximum(z, 0.0) / p["decay"])
308
+ return np.minimum(t, p["tg"])
309
+
310
+
311
+ # ------------------------------------------------------------ element kernels
312
+
313
+
314
+ def _eig_unit(nu) -> np.ndarray:
315
+ """(24, 6): integral of B^T C over the unit cube at E = 1. An element's
316
+ load from an inherent strain eps (6,) is E h^2 this @ eps."""
317
+ c = elastic_c(1.0, nu)
318
+ g = 1.0 / np.sqrt(3.0)
319
+ out = np.zeros((24, 6))
320
+ for xi in (-g, g):
321
+ for eta in (-g, g):
322
+ for zeta in (-g, g):
323
+ out += _b_matrix(xi, eta, zeta).T @ c * 0.125
324
+ return out
325
+
326
+
327
+ class _Kernel:
328
+ def __init__(self, mesh: HexMesh, nu, fixed, springs=None):
329
+ self.mesh = mesh
330
+ self.springs = springs if springs is not None and np.any(springs) else None
331
+ self.nu = nu
332
+ self.st = Stiffness(mesh, nu, fixed)
333
+ self.g = _eig_unit(nu) * mesh.pitch ** 2
334
+ self.bc = hex8_b_center() / mesh.pitch
335
+ self.c1 = elastic_c(1.0, nu)
336
+
337
+ def k(self, scale):
338
+ """Stiffness at per-element moduli, plus the bed's springs."""
339
+ k = self.st.assemble(scale)
340
+ return k if self.springs is None else k + sp.diags(self.springs * ~self.st.fixed)
341
+
342
+ def load(self, e, eps, elems):
343
+ """Global load of inherent strains `eps` (len(elems), 6) at moduli e."""
344
+ fe = (e[elems, None] * eps) @ self.g.T
345
+ return np.bincount(self.mesh.edof[elems].ravel(), weights=fe.ravel(), minlength=self.mesh.n_dof)
346
+
347
+ def internal(self, e, ue, eps, elems):
348
+ """Global internal force of elements with displacement-since-
349
+ activation ue (len, 24) and inherent strain eps."""
350
+ fe = e[elems, None] * (ue @ self.st.ke.T - eps @ self.g.T)
351
+ return np.bincount(self.mesh.edof[elems].ravel(), weights=fe.ravel(), minlength=self.mesh.n_dof)
352
+
353
+ def stress(self, e, ue, eps):
354
+ return ((ue @ self.bc.T - eps) @ self.c1.T) * e[:, None]
355
+
356
+
357
+ _DIRECT = 90_000 # dofs: a direct factorization up to here
358
+
359
+
360
+ def _solve(k, f, xyz, d=None, direct=_DIRECT, rtol=1e-7):
361
+ """Sparse SPD solve: a direct factorization for small systems, CG on
362
+ smoothed-aggregation AMG (rigid-body near null space of the nodes
363
+ `xyz`, restricted to the dofs `d` the system keeps) beyond."""
364
+ from scipy.sparse.linalg import cg, splu
365
+ n = k.shape[0]
366
+ if n <= direct:
367
+ return splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A").solve(f)
368
+ b = rigid_body_modes(xyz)
369
+ if d is not None:
370
+ b = b[d]
371
+ ml = amg_solver(k.tocsr(), B=b, symmetry="hermitian", strength="symmetric", max_coarse=400,
372
+ coarse_solver="splu")
373
+ u, info = cg(k, f, rtol=rtol, maxiter=2000, M=ml.aspreconditioner(cycle="V"))
374
+ if info != 0:
375
+ raise RuntimeError("build simulation: linear solve did not converge (is every piece attached?)")
376
+ return u
377
+
378
+
379
+ def _dofs(nodes, fixed):
380
+ d = (3 * np.flatnonzero(nodes)[:, None] + np.arange(3)).ravel()
381
+ return d[~fixed[d]]
382
+
383
+
384
+ def _three_two_one(mesh, nodes):
385
+ """Statically determinate support of a free part on its own nodes:
386
+ one node held in x, y, z, a second (far along x) in y and z, a third
387
+ (far along y) in z. A self-equilibrated load leaves no reaction."""
388
+ idx = np.flatnonzero(nodes)
389
+ xyz = mesh.node_xyz[idx]
390
+ lo = xyz.min(axis=0)
391
+ a = idx[np.argmin(np.linalg.norm(xyz - lo, axis=1))]
392
+ pa = mesh.node_xyz[a]
393
+ b = idx[np.argmax(xyz[:, 0] - 1e-3 * np.abs(xyz[:, 1] - pa[1]) - 1e-3 * np.abs(xyz[:, 2] - pa[2]))]
394
+ pb = mesh.node_xyz[b]
395
+ ab = (pb - pa) / max(np.linalg.norm(pb - pa), 1e-12)
396
+ off = np.linalg.norm(np.cross(xyz - pa, ab), axis=1) - 1e-3 * np.abs(xyz[:, 2] - pa[2])
397
+ c = idx[np.argmax(off)]
398
+ fixed = np.zeros(mesh.n_dof, bool)
399
+ fixed[3 * a:3 * a + 3] = True
400
+ fixed[3 * b + 1:3 * b + 3] = True
401
+ fixed[3 * c + 2] = True
402
+ return fixed
403
+
404
+
405
+ def _in_plane_pins(mesh, nodes):
406
+ """Pins for a part on an elastic bed: the springs carry z, so only the
407
+ in-plane drift is held, at the node nearest the footprint's centre
408
+ (x, y) and one far from it along x (y). Pinning z at corners, as a
409
+ 3-2-1 does, would hold down the corners that lift."""
410
+ idx = np.flatnonzero(nodes)
411
+ xyz = mesh.node_xyz[idx]
412
+ a = idx[np.argmin(np.linalg.norm(xyz[:, :2] - xyz[:, :2].mean(axis=0), axis=1))]
413
+ pa = mesh.node_xyz[a]
414
+ b = idx[np.argmax(np.abs(xyz[:, 0] - pa[0]) - 1e-3 * np.abs(xyz[:, 1] - pa[1]))]
415
+ fixed = np.zeros(mesh.n_dof, bool)
416
+ fixed[3 * a:3 * a + 2] = True
417
+ fixed[3 * b + 1] = True
418
+ return fixed
419
+
420
+
421
+ def _remove_rigid(xyz, u):
422
+ """u minus its least-squares rigid motion (a best-fit alignment)."""
423
+ b = rigid_body_modes(xyz)
424
+ coef, *_ = np.linalg.lstsq(b, u.ravel(), rcond=None)
425
+ return (u.ravel() - b @ coef).reshape(-1, 3)
426
+
427
+
428
+ # ------------------------------------------------------------ the solve
429
+
430
+
431
+ def _grid_mesh(solid, pitch, origin, setup):
432
+ """HexMesh of the part's cells plus (lpbf, plate > 0) a plate under its
433
+ footprint. Returns mesh, part mask, layer per element, fixed dofs and
434
+ each element's height above the plate."""
435
+ solid = np.asarray(solid, bool)
436
+ ijk = np.argwhere(solid)
437
+ if len(ijk) == 0:
438
+ raise ValueError("build simulation: the part is empty")
439
+ k0 = ijk[:, 2].min()
440
+ plate_cells = 0
441
+ if setup.process.lower() == "lpbf" and setup.plate > 0:
442
+ plate_cells = max(1, int(round(setup.plate / pitch)))
443
+ if setup.plate_cells is not None and setup.plate_fixed:
444
+ # the scaled modulus keeps a held plate's through-thickness
445
+ # compliance (E / h); a free plate bends (E h^3), so it stays whole
446
+ plate_cells = min(plate_cells, max(1, int(setup.plate_cells)))
447
+ margin = int(round(setup.plate_margin / pitch)) if plate_cells else 0
448
+ shift = np.array([margin, margin, plate_cells - k0])
449
+ part_ijk = ijk + shift
450
+ if plate_cells:
451
+ lo = ijk[:, :2].min(axis=0)
452
+ hi = ijk[:, :2].max(axis=0) + 2 * margin
453
+ pi, pj, pk = np.meshgrid(np.arange(lo[0], hi[0] + 1), np.arange(lo[1], hi[1] + 1), np.arange(plate_cells),
454
+ indexing="ij")
455
+ plate_ijk = np.stack([pi.ravel(), pj.ravel(), pk.ravel()], axis=1)
456
+ all_ijk = np.concatenate([plate_ijk, part_ijk])
457
+ part = np.concatenate([np.zeros(len(plate_ijk), bool), np.ones(len(part_ijk), bool)])
458
+ else:
459
+ all_ijk = part_ijk
460
+ part = np.ones(len(part_ijk), bool)
461
+ org = np.asarray(origin, float) - shift * pitch
462
+ mesh = HexMesh(all_ijk, pitch, org)
463
+ lump = max(1, int(round((setup.layer or pitch) / pitch)))
464
+ layer_of = np.where(part, (mesh.elem_ijk[:, 2] - plate_cells) // lump, -1)
465
+ # the plate (or, without one, the bed) is held at its underside
466
+ bottom = mesh.node_ijk[:, 2] == 0
467
+ fixed = np.repeat(bottom, 3)
468
+ if plate_cells and not setup.plate_fixed:
469
+ on = np.zeros(mesh.n_nodes, bool)
470
+ on[mesh.enode[~part].ravel()] = True
471
+ fixed = _three_two_one(mesh, on)
472
+ springs = np.zeros(mesh.n_dof)
473
+ kb = setup.bed_stiffness
474
+ if kb is None and setup.process.lower() == "fdm":
475
+ kb = FDM_BED
476
+ if not plate_cells and kb is not None and np.isfinite(kb):
477
+ # an elastic bed: springs on the underside's nodes (a quarter cell
478
+ # of area per touching cell), 3-2-1 against rigid motion
479
+ fixed = _in_plane_pins(mesh, bottom)
480
+ area = np.zeros(mesh.n_nodes)
481
+ under = mesh.elem_ijk[:, 2] == 0
482
+ touch = np.zeros(mesh.n_nodes)
483
+ for c in range(4):
484
+ np.add.at(area, mesh.enode[under, c], 0.25 * pitch ** 2)
485
+ np.add.at(touch, mesh.enode[under, c], 1.0)
486
+ if setup.brim:
487
+ # a brim: a bonded skirt brim mm wide around the footprint,
488
+ # counted as more bed under the edge nodes (an estimate: a thin
489
+ # skirt holds less than the bed under the part)
490
+ edge = (touch > 0) & (touch < 4)
491
+ area[edge] += float(setup.brim) * pitch * (4 - touch[edge]) / 2
492
+ springs = np.repeat(area * float(kb), 3)
493
+ z_above = (mesh.elem_ijk[:, 2] - plate_cells + 0.5) * pitch
494
+ return mesh, part, layer_of, fixed, z_above, plate_cells, springs
495
+
496
+
497
+ def layer_extrapolation(t, setup) -> float:
498
+ """k for extrapolating an FDM build at analysis layers t mm to the
499
+ printed layer: u = (1 + k) u(t) - k u(2t), k = log2(t / printed).
500
+
501
+ FDM warp grows as the analysis layers thin and does not settle: a
502
+ 60 x 4 x 3 mm nylon bar on the bed lifts 0.30, 0.46, 0.62, 0.80 mm at
503
+ 1, 0.5, 0.25, 0.125 mm layers, whatever the voxel size (0.125-0.5 mm
504
+ voxels agree within 1%), about +0.16 mm per halving. Each layer goes
505
+ down stress-free on a part already curled by the ones below, so thin
506
+ layers keep adding curl. The model was fitted at the printed layers
507
+ (Armillotta, 0.254 mm), so that is where it must be read: the gain per
508
+ halving between t and 2t, carried log-linearly down to the printed
509
+ layer, lands within 4% of the direct thin-layer runs (0.61 against
510
+ 0.62, and 0.92 against 0.96 on a 5.5 mm bar)."""
511
+ if setup is None or setup.process.lower() != "fdm":
512
+ return 0.0
513
+ tp = FDM_PRINT_LAYER if setup.print_layer is None else float(setup.print_layer)
514
+ if tp <= 0 or t <= 1.05 * tp:
515
+ return 0.0
516
+ return float(np.log2(t / tp))
517
+
518
+
519
+ def simulate_build(solid, pitch, origin=(0.0, 0.0, 0.0), setup: BuildSetup | None = None, on_layer=None):
520
+ """Simulate printing a voxel part (bool grid, build direction +z, the
521
+ lowest cells sit on the plate or bed) and releasing it.
522
+
523
+ solid: bool (nx, ny, nz); pitch mm; origin: world position of the grid's
524
+ min corner. Returns a BuildResult; its mesh is the part's cells (plus a
525
+ plate, lpbf), in the same world frame. FDM analysis layers thicker than
526
+ the printed layer are extrapolated to it (layer_extrapolation): the
527
+ build runs twice, at its layers and at twice them."""
528
+ setup = setup or BuildSetup()
529
+ t = float(setup.layer or pitch)
530
+ kx = layer_extrapolation(t, setup)
531
+ r1 = _simulate_build(solid, pitch, origin, setup, on_layer)
532
+ if not kx:
533
+ return r1
534
+ from dataclasses import replace
535
+ r2 = _simulate_build(solid, pitch, origin, replace(setup, layer=2 * t))
536
+ lin = (lambda a, b: (1.0 + kx) * a - kx * b)
537
+ res = replace(r1, u_built=lin(r1.u_built, r2.u_built), u=lin(r1.u, r2.u),
538
+ stress_built=lin(r1.stress_built, r2.stress_built), stress=lin(r1.stress, r2.stress))
539
+ rep = res.report
540
+ m, part = res.mesh, res.part
541
+ pn = np.flatnonzero(res.node_active)
542
+ dmag = np.linalg.norm(res.u[pn], axis=1)
543
+ rep["distortion"] = {"max": float(dmag.max()), "rms": float(np.sqrt((dmag ** 2).mean())),
544
+ "maxZ": float(np.abs(res.u[pn, 2]).max()),
545
+ "at": [float(c) for c in m.node_xyz[pn[np.argmax(dmag)]]]}
546
+ rep["builtDistortionMax"] = float(np.linalg.norm(res.u_built[m.enode[part].ravel()], axis=1).max())
547
+ keep = np.abs(res.stress).sum(axis=1) > 0
548
+ vm_b, vm = von_mises(res.stress_built[part]), von_mises(res.stress[keep & part] if (keep & part).any()
549
+ else res.stress[part])
550
+ rs = rep["residualStress"]
551
+ rs.update(vmMaxBuilt=float(vm_b.max()), vmP95Built=float(np.percentile(vm_b, 95)), vmMax=float(vm.max()),
552
+ vmP95=float(np.percentile(vm, 95)), overYieldShare=float((vm > rs["yield"]).mean()))
553
+ rep["layerExtrapolation"] = {"analysisLayer": t, "printedLayer": t / 2 ** kx, "k": kx,
554
+ "distortionMaxUnextrapolated": r1.report["distortion"]["max"],
555
+ "note": "linear in log(layer) from builds at the analysis layer and twice it"}
556
+ rep["warnings"] = _warnings(rep, setup)
557
+ return res
558
+
559
+
560
+ def _simulate_build(solid, pitch, origin=(0.0, 0.0, 0.0), setup: BuildSetup | None = None, on_layer=None):
561
+ """Simulate printing a voxel part (bool grid, build direction +z, the
562
+ lowest cells sit on the plate or bed) and releasing it.
563
+
564
+ solid: bool (nx, ny, nz); pitch mm; origin: world position of the grid's
565
+ min corner. Returns a BuildResult; its mesh is the part's cells (plus a
566
+ plate, lpbf), in the same world frame."""
567
+ setup = setup or BuildSetup()
568
+ t0 = time.time()
569
+ proc = setup.process.lower()
570
+ mat = _material(setup.material)
571
+ e0 = float(setup.E or mat.E)
572
+ nu = float(setup.nu if setup.nu is not None else mat.nu)
573
+ solid, floating = _grounded(solid, proc)
574
+ mesh, part, layer_of, fixed, z_above, plate_cells, springs = _grid_mesh(solid, pitch, origin, setup)
575
+ ne = mesh.n_elem
576
+ kern = _Kernel(mesh, nu, fixed, springs)
577
+ e = np.full(ne, e0)
578
+ if plate_cells and plate_cells * pitch < setup.plate - 1e-9:
579
+ e[~part] *= plate_cells * pitch / setup.plate
580
+ depth = None
581
+ if proc in ("sls", "mjf"):
582
+ from scipy import ndimage
583
+ g = np.zeros(tuple(mesh.elem_ijk.max(axis=0) + 3), bool)
584
+ g[tuple((mesh.elem_ijk + 1).T)] = True
585
+ depth = ndimage.distance_transform_edt(g)[tuple((mesh.elem_ijk + 1).T)] * pitch - 0.5 * pitch
586
+ eps, info = inherent_strain(setup, z=z_above if proc == "fdm" else None, depth=depth)
587
+ eps = np.broadcast_to(eps, (ne, 6)).copy()
588
+ eps[~part] = 0.0
589
+ u = np.zeros(mesh.n_dof)
590
+ ue = np.zeros((ne, 24)) # each element's displacement since it was laid
591
+ lift = []
592
+ xyz = mesh.node_xyz
593
+ layered = proc in ("lpbf", "fdm")
594
+ if layered:
595
+ layer_of = _joined_layers(mesh, part, layer_of)
596
+ n_layers = int(layer_of.max()) + 1
597
+ active = ~part
598
+ node_on = np.zeros(mesh.n_nodes, bool)
599
+ if active.any():
600
+ node_on[mesh.enode[active].ravel()] = True
601
+ # Big parts solve on mg.py's matrix-free multigrid (cells not yet
602
+ # printed get modulus 0, which drops them; its setup per layer is a
603
+ # reshape, where pyamg's was 60% of the time on AM-Bench); small
604
+ # ones factor each layer exactly.
605
+ big = mesh.n_dof > _DIRECT
606
+ detached = 0
607
+ solver = None
608
+ if big:
609
+ from .mg import GridElasticity
610
+ solver = GridElasticity(mesh, nu, fixed, springs=sp.diags(springs) if np.any(springs) else None,
611
+ rtol=1e-7, maxiter=3000, device="cpu")
612
+ for li in range(n_layers):
613
+ new = part & (layer_of == li)
614
+ if not new.any():
615
+ continue
616
+ active = active | new
617
+ node_on[mesh.enode[new].ravel()] = True
618
+ f = kern.load(e, eps[new], np.flatnonzero(new))
619
+ du = np.zeros(mesh.n_dof)
620
+ if big:
621
+ f[fixed] = 0.0
622
+ solver.set_moduli(e * active)
623
+ du = solver.solve_many([f])[0]
624
+ du[~np.repeat(node_on, 3)] = 0.0 # unprinted nodes go down at nominal
625
+ else:
626
+ k = kern.k(e * active)
627
+ d = _dofs(node_on, fixed)
628
+ du[d] = _solve(k[d][:, d], f[d], xyz, d)
629
+ u += du
630
+ act = np.flatnonzero(active)
631
+ ue[act] += du[mesh.edof[act]]
632
+ lost = _detach(kern, setup, u, ue, act, lambda g: _resolve(kern, e * active, node_on, fixed, xyz, g))
633
+ if lost and big:
634
+ solver = GridElasticity(mesh, nu, fixed, springs=sp.diags(kern.springs), rtol=1e-7, maxiter=3000,
635
+ device="cpu")
636
+ detached += lost
637
+ # lift at the build plane (the top of the cells just laid)
638
+ top_z = mesh.elem_ijk[new, 2].max() + 1
639
+ top = node_on & (mesh.node_ijk[:, 2] == top_z)
640
+ lz = u[3 * np.flatnonzero(top) + 2]
641
+ lift.append(float(lz.max()) if len(lz) else 0.0)
642
+ if on_layer is not None:
643
+ on_layer(li, n_layers)
644
+ else:
645
+ # sls/mjf: the part cools as a whole, free in the powder
646
+ node_on = np.zeros(mesh.n_nodes, bool)
647
+ node_on[mesh.enode.ravel()] = True
648
+ n_layers = 0
649
+ report = {"process": proc, "material": setup.material, "strain": info, "pitch": float(pitch),
650
+ "cells": int(part.sum()), "analysisLayers": n_layers,
651
+ "layerMm": float((setup.layer or pitch)) if layered else None}
652
+
653
+ if proc == "fdm":
654
+ # stage two: the finished part cools on the bed to room temperature
655
+ p = _fdm_params(setup)
656
+ tz = _fdm_temperature(p, z_above)
657
+ eps2 = np.zeros((ne, 6))
658
+ eps2[:, :3] = (-p["alpha"] * (tz - p["room"]) * setup.scale)[:, None]
659
+ report["peel"] = _bed_peel(mesh, kern, e, ue, eps, part, fixed, u)
660
+ k = kern.k(e)
661
+ d = _dofs(node_on, fixed)
662
+ f = kern.load(e, eps2, np.arange(ne))
663
+ du = np.zeros(mesh.n_dof)
664
+ du[d] = _solve(k[d][:, d], f[d], xyz, d)
665
+ u += du
666
+ ue += du[mesh.edof]
667
+ detached += _detach(kern, setup, u, ue, np.arange(ne), lambda g: _resolve(kern, e, node_on, fixed, xyz, g))
668
+ eps = eps + eps2
669
+ report["detachedArea"] = float(detached)
670
+ report["bedFailed"] = bool(getattr(kern, "bed_failed", False))
671
+ report["peelCooled"] = _bed_peel(mesh, kern, e, ue, eps, part, fixed, u)
672
+
673
+ u_built = u.reshape(-1, 3).copy()
674
+ stress_built = kern.stress(e, ue, eps)
675
+ if proc in ("sls", "mjf"):
676
+ release = "full"
677
+ else:
678
+ release = setup.release
679
+ keep = part.copy() if release in ("full", "none") else part & ~_in_box(mesh.elem_centers(), setup.cut)
680
+ if release == "none":
681
+ u_rel, stress = u_built.copy(), stress_built.copy()
682
+ node_act = node_on.copy()
683
+ else:
684
+ if release == "cut" and setup.cut is None:
685
+ raise ValueError("release='cut' needs BuildSetup.cut, the box of cells the cut removes")
686
+ if release == "cut" and plate_cells:
687
+ keep = keep | ~part # the plate stays, the cut slab goes
688
+ node_act = np.zeros(mesh.n_nodes, bool)
689
+ node_act[mesh.enode[keep].ravel()] = True
690
+ comps = _components(mesh, keep & part) if release == "full" else []
691
+ if release == "full":
692
+ # each piece the release frees gets its own 3-2-1
693
+ fx = np.zeros(mesh.n_dof, bool)
694
+ for c in comps:
695
+ fx |= _three_two_one(mesh, c)
696
+ else:
697
+ fx = fixed
698
+ kel = np.flatnonzero(keep)
699
+ r = kern.internal(e, ue[kel], eps[kel], kel)
700
+ # assembled without Dirichlet rows: the 3-2-1 set is not `fixed`,
701
+ # and the submatrix over the free dofs is all the solve needs
702
+ k = _assemble_free(kern, e * keep)
703
+ d = _dofs(node_act, fx)
704
+ du = np.zeros(mesh.n_dof)
705
+ du[d] = _solve(k[d][:, d], -r[d], xyz, d)
706
+ u_tot = u + du
707
+ ue[kel] += du[mesh.edof[kel]]
708
+ stress = kern.stress(e, ue, eps)
709
+ stress[~keep] = 0.0
710
+ u_rel = u_tot.reshape(-1, 3).copy()
711
+ u_rel[~node_act] = 0.0
712
+ for c in comps:
713
+ ids = np.flatnonzero(c)
714
+ u_rel[ids] = _remove_rigid(xyz[ids], u_rel[ids])
715
+ vm_b = von_mises(stress_built[part])
716
+ vm = von_mises(stress[keep])
717
+ sy = float(setup.yield_strength or mat.strength)
718
+ pn = np.flatnonzero(node_act)
719
+ dmag = np.linalg.norm(u_rel[pn], axis=1)
720
+ report.update({
721
+ "release": release,
722
+ "floatingCells": floating,
723
+ "distortion": {"max": float(dmag.max()), "rms": float(np.sqrt((dmag ** 2).mean())),
724
+ "maxZ": float(np.abs(u_rel[pn, 2]).max()),
725
+ "at": [float(c) for c in xyz[pn[np.argmax(dmag)]]]},
726
+ "builtDistortionMax": float(np.linalg.norm(u_built[mesh.enode[part].ravel()], axis=1).max()),
727
+ "residualStress": {"vmMaxBuilt": float(vm_b.max()), "vmP95Built": float(np.percentile(vm_b, 95)),
728
+ "vmMax": float(vm.max()), "vmP95": float(np.percentile(vm, 95)),
729
+ "yield": sy, "overYieldShare": float((vm > sy).mean())},
730
+ "seconds": round(time.time() - t0, 2),
731
+ })
732
+ if proc == "lpbf" and lift:
733
+ report["recoater"] = {"maxLift": float(max(lift)), "gap": setup.recoater_gap,
734
+ "layer": int(np.argmax(lift)), "risk": bool(max(lift) > setup.recoater_gap)}
735
+ report["warnings"] = _warnings(report, setup)
736
+ res = BuildResult(mesh=mesh, part=part, layer_of=layer_of, u_built=u_built, u=u_rel,
737
+ stress_built=stress_built, stress=stress, strain=eps, report=report, node_active=node_act)
738
+ return res
739
+
740
+
741
+ def _joined_layers(mesh, part, layer_of):
742
+ """The analysis layer at which each part cell joins the plate or bed
743
+ through faces. A cell laid in mid-air (the low tip of a drooping,
744
+ pre-deformed arm; anything printed on supports) has nothing to hold it
745
+ in the solve until the layer that bonds it to the part: it goes in then,
746
+ stress-free in its place, as if its supports had held it."""
747
+ from scipy import ndimage
748
+ ijk = mesh.elem_ijk
749
+ g = np.full(tuple(ijk.max(axis=0) + 1), np.iinfo(np.int64).max, np.int64)
750
+ g[tuple(ijk.T)] = layer_of # the plate is -1
751
+ out = layer_of.copy()
752
+ pending = part.copy()
753
+ for li in range(int(layer_of.max()) + 1):
754
+ lbl, _ = ndimage.label(g <= li)
755
+ roots = np.unique(lbl[:, :, 0])
756
+ on = np.isin(lbl[tuple(ijk.T)], roots[roots > 0])
757
+ out[pending & on] = li
758
+ pending &= ~on
759
+ if not pending.any():
760
+ break
761
+ return out
762
+
763
+
764
+ def _grounded(solid, proc):
765
+ """The part's cells that reach the plate or bed through faces (lpbf,
766
+ fdm), and how many were dropped: a piece printed in mid-air has
767
+ nothing to hold it, in the machine or in the solve. Powder-bed polymer
768
+ keeps every piece (the cake holds them)."""
769
+ from scipy import ndimage
770
+ solid = np.asarray(solid, bool)
771
+ if proc in ("sls", "mjf") or not solid.any():
772
+ return solid, 0
773
+ lbl, _ = ndimage.label(solid)
774
+ k0 = np.argwhere(solid)[:, 2].min()
775
+ keep = np.isin(lbl, np.unique(lbl[:, :, k0][solid[:, :, k0]]))
776
+ return solid & keep, int((solid & ~keep).sum())
777
+
778
+
779
+ def _components(mesh, cells):
780
+ """Node masks of the face-connected pieces of `cells` (bool per element)."""
781
+ from scipy import ndimage
782
+ g = np.zeros(tuple(mesh.elem_ijk.max(axis=0) + 1), int)
783
+ idx = np.flatnonzero(cells)
784
+ g[tuple(mesh.elem_ijk[idx].T)] = 1
785
+ lbl, n = ndimage.label(g)
786
+ el = lbl[tuple(mesh.elem_ijk[idx].T)]
787
+ out = []
788
+ for i in range(1, n + 1):
789
+ on = np.zeros(mesh.n_nodes, bool)
790
+ on[mesh.enode[idx[el == i]].ravel()] = True
791
+ out.append(on)
792
+ return out
793
+
794
+
795
+ def _detach(kern, setup, u, ue, act, solve):
796
+ """FDM bed bond failure: every bonded bed node whose bond pulls harder
797
+ than the adhesion (tension kb * u_z, MPa) lets go, its springs removed,
798
+ and the part re-balances; repeat until none fails. `solve(load)` solves
799
+ the current stiffness (it sees the springs as they are now). Updates u
800
+ and ue in place; returns the bed area let go (mm^2)."""
801
+ if kern.springs is None or setup.process.lower() != "fdm":
802
+ return 0
803
+ adh = FDM_ADHESION if setup.bed_adhesion is None else float(setup.bed_adhesion)
804
+ if not np.isfinite(adh):
805
+ return 0
806
+ kb = float(setup.bed_stiffness if setup.bed_stiffness is not None else FDM_BED)
807
+ mesh = kern.mesh
808
+ bed = np.flatnonzero(mesh.node_ijk[:, 2] == 0)
809
+ lost = 0.0
810
+ for _ in range(60):
811
+ bonded = kern.springs[3 * bed + 2] > 0
812
+ fail = bed[bonded & (kb * u[3 * bed + 2] > adh)]
813
+ if len(fail) == 0:
814
+ break
815
+ if bonded.sum() - len(fail) < max(4, 0.02 * len(bed)):
816
+ # the whole footprint lets go: the part pops off the bed (a
817
+ # failed print); keep the last bonds so the solve stays posed
818
+ kern.bed_failed = True
819
+ break
820
+ lost += float(kern.springs[3 * fail + 2].sum()) / kb # their bed area, mm^2
821
+ dof = (3 * fail[:, None] + np.arange(3)).ravel()
822
+ load = np.zeros(mesh.n_dof)
823
+ load[dof] = kern.springs[dof] * u[dof] # what the bond held, now let go
824
+ kern.springs[dof] = 0.0
825
+ du = solve(load)
826
+ u += du
827
+ ue[act] += du[mesh.edof[act]]
828
+ return lost
829
+
830
+
831
+ def _resolve(kern, scale, node_on, fixed, xyz, load):
832
+ """One solve of the current stiffness (springs as they stand) on the
833
+ printed nodes: direct when small, multigrid when big."""
834
+ mesh = kern.mesh
835
+ du = np.zeros(mesh.n_dof)
836
+ if mesh.n_dof > _DIRECT:
837
+ from .mg import GridElasticity
838
+ sv = GridElasticity(mesh, kern.nu, fixed, springs=sp.diags(kern.springs) if kern.springs is not None else None,
839
+ rtol=1e-7, maxiter=3000, device="cpu")
840
+ sv.set_moduli(scale)
841
+ g = load.copy()
842
+ g[fixed] = 0.0
843
+ du = sv.solve_many([g])[0]
844
+ du[~np.repeat(node_on, 3)] = 0.0
845
+ return du
846
+ k = kern.k(scale)
847
+ d = _dofs(node_on, fixed)
848
+ du[d] = _solve(k[d][:, d], load[d], xyz, d)
849
+ return du
850
+
851
+
852
+ def _assemble_free(kern, scale):
853
+ """Stiffness without Dirichlet rows (a free part's)."""
854
+ st = kern.st
855
+ data = np.zeros(st.nnz)
856
+ s = np.asarray(scale, float)[:, None]
857
+ for slots, blk in st._pairs:
858
+ data[slots] += s * blk
859
+ return sp.csr_matrix((data, st.indices, st.indptr), shape=(kern.mesh.n_dof, kern.mesh.n_dof))
860
+
861
+
862
+ def _in_box(pts, box):
863
+ if box is None:
864
+ return np.zeros(len(pts), bool)
865
+ lo, hi = (np.asarray(b, float) for b in box)
866
+ return np.all((pts >= lo) & (pts <= hi), axis=1)
867
+
868
+
869
+ def _bed_peel(mesh, kern, e, ue, eps, part, fixed, u_now=None):
870
+ """FDM: the bed's pull on the part (MPa, positive = the part trying to
871
+ lift off) per bed node, from the reactions of the fixed underside."""
872
+ el = np.flatnonzero(part)
873
+ r = kern.internal(e, ue[el], eps[el], el)
874
+ bed = np.flatnonzero(mesh.node_ijk[:, 2] == 0)
875
+ fz = r[3 * bed + 2] # internal force; the bed's reaction is -fz
876
+ if kern.springs is not None:
877
+ # an elastic bed: its pull is the spring's stretch (the 3-2-1
878
+ # nodes that hold the rigid motion carry no bed)
879
+ fz = -kern.springs[3 * bed + 2] * u_now[3 * bed + 2]
880
+ # tributary area: a quarter cell per touching cell
881
+ touch = np.zeros(mesh.n_nodes)
882
+ bottom = el[mesh.elem_ijk[el, 2] == 0]
883
+ for c in range(4): # the four bottom corners
884
+ np.add.at(touch, mesh.enode[bottom, c], 0.25)
885
+ area = np.maximum(touch[bed], 1e-9) * mesh.pitch ** 2
886
+ peel = -fz / area # reaction pulling down (on a part that wants up) -> positive
887
+ pos = peel > 0
888
+ lift = float((peel[pos] * area[pos]).sum())
889
+ # the max sits at an edge, where a bonded joint's peel stress is
890
+ # singular (it grows as the voxels shrink); the mean over the area in
891
+ # tension is the steadier number
892
+ return {"maxPeelStress": float(peel.max()), "meanPeelStress": lift / max(float(area[pos].sum()), 1e-12),
893
+ "peelArea": float(area[pos].sum()),
894
+ "at": [float(c) for c in mesh.node_xyz[bed[np.argmax(peel)]]],
895
+ "netLiftForce": lift}
896
+
897
+
898
+ def _warnings(rep, setup):
899
+ w = []
900
+ d = rep["distortion"]
901
+ if rep.get("recoater", {}).get("risk"):
902
+ w.append(f"build: the build plane lifts {rep['recoater']['maxLift']:.3f} mm "
903
+ f"(> {setup.recoater_gap} mm) at analysis layer {rep['recoater']['layer']}: recoater contact "
904
+ "risk; support the overhang or change the orientation")
905
+ rs = rep["residualStress"]
906
+ if rs["vmMaxBuilt"] > rs["yield"]:
907
+ w.append(f"build: residual stress on the plate reaches {rs['vmMaxBuilt']:.0f} MPa "
908
+ f"(yield {rs['yield']:.0f}); expect local yielding and cracking risk at sharp corners "
909
+ "(a linear model overstates the peak where it passes yield)")
910
+ if rep["process"] == "fdm":
911
+ pk = rep.get("peel", {}).get("meanPeelStress", 0.0)
912
+ if pk > 0.5:
913
+ w.append(f"build: the part pulls on the bed at {pk:.2f} MPa during the print: corners may lift "
914
+ "(PEI/glue hold roughly 0.3-1 MPa); add brim/mouse ears or a hotter chamber")
915
+ if rep.get("floatingCells"):
916
+ w.append(f"build: {rep['floatingCells']} cells are not connected to the plate or bed and were left out")
917
+ if d["max"] > 0.2:
918
+ w.append(f"build: distortion after release up to {d['max']:.2f} mm; consider compensate()")
919
+ return w
920
+
921
+
922
+ # --------------------------------------------------------- mesh entry points
923
+
924
+
925
+ def to_build_frame(points, build):
926
+ """Rotation (3x3) taking a build direction ('+z', (axis, sign) or a
927
+ vector) to +z, and the points rotated by it."""
928
+ from .am import build_vector, parse_build_direction
929
+ v = build_vector(parse_build_direction(build) if isinstance(build, str) else build)
930
+ z = np.array([0.0, 0.0, 1.0])
931
+ c = float(v @ z)
932
+ if c > 1 - 1e-12:
933
+ r = np.eye(3)
934
+ elif c < -1 + 1e-12:
935
+ r = np.diag([1.0, -1.0, -1.0])
936
+ else:
937
+ ax = np.cross(v, z)
938
+ s = np.linalg.norm(ax)
939
+ ax /= s
940
+ kx = np.array([[0, -ax[2], ax[1]], [ax[2], 0, -ax[0]], [-ax[1], ax[0], 0]])
941
+ r = np.eye(3) + s * kx + (1 - c) * kx @ kx
942
+ pts = None if points is None else np.asarray(points, float) @ r.T
943
+ return r, pts
944
+
945
+
946
+ def simulate_part(mesh, setup: BuildSetup | None = None, pitch=None, build="+z", cells=60_000):
947
+ """Simulate a triangle mesh (trimesh) printed along `build`. Voxelizes
948
+ at `pitch` (default: about `cells` cells in the part). The BuildResult
949
+ is in the build frame; result.report["rotation"] is the world->build
950
+ rotation."""
951
+ import trimesh
952
+ setup = setup or BuildSetup()
953
+ from .voxels import mesh_solid
954
+ r, v = to_build_frame(mesh.vertices, build)
955
+ m = trimesh.Trimesh(v, mesh.faces, process=False)
956
+ if pitch is None:
957
+ pitch = float((abs(m.volume) / cells) ** (1 / 3))
958
+ solid, origin, _ = mesh_solid(m, pitch, pad=1)
959
+ res = simulate_build(solid, pitch, origin, setup)
960
+ res.report["rotation"] = r.tolist()
961
+ return res
962
+
963
+
964
+ class _NodeField:
965
+ """Trilinear sampler of a nodal field on the voxel grid; nodes outside
966
+ the part (or not in `active`) are filled from their nearest part node."""
967
+
968
+ def __init__(self, mesh: HexMesh, u, active=None):
969
+ from scipy import ndimage
970
+ dims = tuple(mesh.node_dims)
971
+ on = np.zeros(dims, bool)
972
+ ok = np.ones(mesh.n_nodes, bool) if active is None else np.asarray(active, bool)
973
+ ijk = mesh.node_ijk[ok]
974
+ on[tuple(ijk.T)] = True
975
+ grid = np.zeros(dims + (3,))
976
+ grid[tuple(ijk.T)] = np.asarray(u)[ok]
977
+ _, ind = ndimage.distance_transform_edt(~on, return_indices=True)
978
+ self.grid = grid[tuple(ind)]
979
+ self.origin = mesh.origin
980
+ self.pitch = mesh.pitch
981
+
982
+ def __call__(self, pts):
983
+ from scipy.ndimage import map_coordinates
984
+ q = (np.asarray(pts, float) - self.origin) / self.pitch
985
+ return np.stack([map_coordinates(self.grid[..., c], q.T, order=1, mode="nearest") for c in range(3)],
986
+ axis=1)
987
+
988
+
989
+ # --------------------------------------------------------- compensation
990
+
991
+
992
+ def compensate(points, sample, factor=1.0):
993
+ """Pre-distort `points` against a displacement sampler: the inverse
994
+ of x -> x + d(x), by fixed point (y = x - factor * d(y), a few rounds),
995
+ so that printing y lands on x."""
996
+ x = np.asarray(points, float)
997
+ y = x.copy()
998
+ for _ in range(4):
999
+ y = x - factor * sample(y)
1000
+ return y
1001
+
1002
+
1003
+ def refine_mesh(mesh, size):
1004
+ """The mesh with every edge longer than `size` mm split, so a
1005
+ displacement sampled at its vertices can bend it (a CAD export's long
1006
+ triangles move only at their corners)."""
1007
+ import trimesh
1008
+ v, f = trimesh.remesh.subdivide_to_size(mesh.vertices, mesh.faces, max_edge=float(size), max_iter=20)
1009
+ return trimesh.Trimesh(v, f, process=True)
1010
+
1011
+
1012
+ def printed_shape(mesh, res: BuildResult, build="+z"):
1013
+ """The mesh as printed: every vertex moved by the simulated
1014
+ displacement (released), in the mesh's own frame."""
1015
+ import trimesh
1016
+ r = np.asarray(res.report.get("rotation", np.eye(3)), float)
1017
+ v = np.asarray(mesh.vertices, float) @ r.T
1018
+ return trimesh.Trimesh((v + res.displacement_at(v)) @ r, mesh.faces, process=False)
1019
+
1020
+
1021
+ def deviation(printed, nominal):
1022
+ """How far a printed shape is from the design, as a scan comparison
1023
+ reads it: `printed` best-fit onto `nominal` (rigid ICP on the
1024
+ surface), then the distance between the two surfaces both ways.
1025
+ Returns ({"max", "rms", "p95"} mm, the aligned printed mesh)."""
1026
+ import trimesh
1027
+ m, _, _ = trimesh.registration.icp(np.asarray(printed.vertices), nominal, max_iterations=40, scale=False)
1028
+ aligned = printed.copy()
1029
+ aligned.apply_transform(m)
1030
+ _, d1, _ = trimesh.proximity.closest_point(nominal, aligned.vertices)
1031
+ _, d2, _ = trimesh.proximity.closest_point(aligned, nominal.vertices)
1032
+ d = np.concatenate([d1, d2])
1033
+ return {"max": float(d.max()), "rms": float(np.sqrt((d ** 2).mean())),
1034
+ "p95": float(np.percentile(d, 95))}, aligned
1035
+
1036
+
1037
+ def compensate_mesh(mesh, setup: BuildSetup, pitch=None, build="+z", iterations=2, cells=60_000, factor=1.0,
1038
+ refine=True, first: BuildResult | None = None):
1039
+ """Iterative pre-distortion of a triangle mesh: simulate, move every
1040
+ vertex against its predicted displacement, simulate the compensated
1041
+ shape, correct by what is left, and so on. Returns (compensated
1042
+ trimesh, report with the residual distortion after each round; round 0
1043
+ is the uncompensated part). `refine` first splits edges longer than
1044
+ the voxel pitch, so long flat triangles can take the shape's curl.
1045
+ `first`: simulate_part's result for this mesh at this pitch and build,
1046
+ if already run (round 0 reuses it)."""
1047
+ import trimesh
1048
+ if pitch is None:
1049
+ pitch = float((abs(mesh.volume) / cells) ** (1 / 3))
1050
+ if refine:
1051
+ mesh = refine_mesh(mesh, pitch)
1052
+ r, v_nom = to_build_frame(mesh.vertices, build)
1053
+ nominal = trimesh.Trimesh(v_nom, mesh.faces, process=False)
1054
+ from .voxels import mesh_solid
1055
+ cur = v_nom.copy()
1056
+ rounds = []
1057
+ for it in range(iterations + 1):
1058
+ if it == 0 and first is not None:
1059
+ res = first
1060
+ else:
1061
+ m = trimesh.Trimesh(cur, mesh.faces, process=False)
1062
+ solid, origin, _ = mesh_solid(m, pitch, pad=1)
1063
+ res = simulate_build(solid, pitch, origin, setup)
1064
+ printed = cur + res.displacement_at(cur)
1065
+ if res.report["release"] == "full":
1066
+ printed = _align(printed, v_nom)
1067
+ err = np.linalg.norm(printed - v_nom, axis=1)
1068
+ rounds.append({"round": it, "maxDeviation": float(err.max()), "rmsDeviation": float(np.sqrt((err ** 2).mean())),
1069
+ "simDistortionMax": res.report["distortion"]["max"]})
1070
+ if it == iterations:
1071
+ break
1072
+ cur = cur - factor * (printed - v_nom)
1073
+ out = trimesh.Trimesh(cur @ r, mesh.faces, process=False) # back to the world frame
1074
+ return out, {"rounds": rounds, "pitch": pitch, "process": setup.process, "material": setup.material}
1075
+
1076
+
1077
+ def _align(p, q):
1078
+ """p rigidly moved onto q (Kabsch, least squares)."""
1079
+ pc, qc = p.mean(axis=0), q.mean(axis=0)
1080
+ h = (p - pc).T @ (q - qc)
1081
+ uu, _, vt = np.linalg.svd(h)
1082
+ d = np.sign(np.linalg.det(vt.T @ uu.T))
1083
+ rot = vt.T @ np.diag([1, 1, d]) @ uu.T
1084
+ return (p - pc) @ rot.T + qc
1085
+
1086
+
1087
+ # --------------------------------------------------------- analytic check
1088
+
1089
+
1090
+ def progressive_beam(e, h_sub, layers, eps):
1091
+ """Curvature of a free beam (unit width, modulus e, uniaxial) after
1092
+ depositing `layers` (thicknesses) on a substrate of thickness h_sub,
1093
+ each layer stress-free when laid and then given misfit strain `eps`
1094
+ (Tsui & Clyne 1997, progressive deposition, one material). Returns
1095
+ (curvature 1/mm, positive = concave up, per-layer stress list)."""
1096
+ zs = [0.0, h_sub]
1097
+ kappa = 0.0
1098
+ for t in layers:
1099
+ z0, z1 = zs[-1], zs[-1] + t
1100
+ a = z1 - 0.0 # current composite spans [0, z1]
1101
+ sz = 0.5 * z1 ** 2
1102
+ szz = z1 ** 3 / 3.0
1103
+ # new layer's misfit force and moment
1104
+ fn = e * eps * t
1105
+ mn = e * eps * 0.5 * (z1 ** 2 - z0 ** 2)
1106
+ m = np.array([[a, sz], [sz, szz]]) * e
1107
+ d0, dk = np.linalg.solve(m, [fn, mn])
1108
+ kappa += dk
1109
+ zs.append(z1)
1110
+ return kappa
1111
+
1112
+
1113
+ # --------------------------------------------------------- optimization
1114
+
1115
+
1116
+ @dataclass
1117
+ class WarpOptions:
1118
+ volume_fraction: float
1119
+ filter_radius: float
1120
+ process: BuildSetup = field(default_factory=BuildSetup)
1121
+ warp_weight: float = 1.0 # weight of normalized distortion against normalized compliance
1122
+ layer: float | None = None # mm per analysis layer (default 4 cells)
1123
+ max_iterations: int = 80
1124
+ beta_max: float = 16.0
1125
+ beta_every: int = 12
1126
+ eta: float = 0.5
1127
+ erode: float = 0.15
1128
+ move: float = 0.1
1129
+ tol: float = 0.01
1130
+ x0: np.ndarray | None = None
1131
+ extrusion: int | None = None
1132
+ target: np.ndarray | None = None # bool per node whose distortion counts (default every node)
1133
+
1134
+
1135
+ @dataclass
1136
+ class WarpResult:
1137
+ x: np.ndarray
1138
+ xphys: np.ndarray
1139
+ compliance: float
1140
+ distortion: float # mean squared as-built displacement over the target, mm^2
1141
+ volume_fraction: float
1142
+ iterations: int
1143
+ converged: bool
1144
+ seconds: float
1145
+ history: list = field(default_factory=list)
1146
+
1147
+
1148
+ def _warp_problem(grid, supports, force, opts: WarpOptions):
1149
+ """evaluate(xe) -> (objective, gradient, record) of optimize_warp, on
1150
+ the eroded density xe."""
1151
+ from .simp import E_MIN
1152
+
1153
+ mesh = grid.mesh
1154
+ setup = opts.process
1155
+ mat = _material(setup.material)
1156
+ e0 = float(setup.E or mat.E)
1157
+ nu = float(setup.nu if setup.nu is not None else mat.nu)
1158
+ p = 3.0
1159
+ warp = WarpTerm(mesh, setup, build=(2, 1), layer=opts.layer, target=opts.target, nu=nu)
1160
+ st_s = Stiffness(mesh, nu, np.asarray(supports, bool))
1161
+ f_s = np.asarray(force, float)
1162
+ ref = {}
1163
+
1164
+ def evaluate(xe):
1165
+ e = e0 * (E_MIN + (1 - E_MIN) * xe ** p)
1166
+ de = e0 * (1 - E_MIN) * p * xe ** (p - 1)
1167
+ # service compliance
1168
+ us = _solve(st_s.assemble(e), f_s, mesh.node_xyz)
1169
+ comp = float(f_s @ us)
1170
+ dc = -de * st_s.strain_energy(us)
1171
+ dist, dd = warp.evaluate(e, de)
1172
+ if not ref:
1173
+ ref["c"], ref["d"] = comp, max(dist, 1e-30)
1174
+ obj = comp / ref["c"] + opts.warp_weight * dist / ref["d"]
1175
+ grad = dc / ref["c"] + opts.warp_weight * dd / ref["d"]
1176
+ return obj, grad, {"compliance": comp, "distortion": dist}
1177
+
1178
+ return evaluate
1179
+
1180
+
1181
+ def optimize_warp(grid, supports, force, opts: WarpOptions, on_iter=None, should_stop=None):
1182
+ """Topology optimization against service compliance and build
1183
+ distortion. `grid`: a thermal.ThermalGrid (the design cells, build
1184
+ direction +z, the lowest layer on the plate, held at its nodes there);
1185
+ `supports`: bool per dof fixed in service; `force`: service load
1186
+ (n_dof). Objective: c / c0 + warp_weight * D / D0, D the mean squared
1187
+ displacement of the target nodes at the end of the layer-by-layer
1188
+ build (inherent strain from opts.process, lumped to opts.layer), c0
1189
+ and D0 the first iterate's. warp_weight 0 is plain compliance."""
1190
+ from types import SimpleNamespace
1191
+
1192
+ from .thermal import _mma_loop
1193
+
1194
+ evaluate = _warp_problem(grid, supports, force, opts)
1195
+ o = SimpleNamespace(volume_fraction=opts.volume_fraction, filter_radius=opts.filter_radius,
1196
+ max_iterations=opts.max_iterations, beta_max=opts.beta_max, beta_every=opts.beta_every,
1197
+ eta=opts.eta, erode=opts.erode, move=opts.move, tol=opts.tol, x0=opts.x0,
1198
+ extrusion=opts.extrusion)
1199
+ pres = grid.preserve.copy()
1200
+ x, xp, it, conv, hist, secs = _mma_loop(grid, o, pres, evaluate, on_iter, should_stop)
1201
+ last = hist[-1] if hist else {}
1202
+ return WarpResult(x=x, xphys=xp, compliance=float(last.get("compliance", np.nan)),
1203
+ distortion=float(last.get("distortion", np.nan)),
1204
+ volume_fraction=float(xp[~pres].mean()), iterations=it, converged=conv, seconds=secs,
1205
+ history=hist)
1206
+
1207
+
1208
+ # --------------------------------------------------------- the warp term
1209
+
1210
+
1211
+ class WarpTerm:
1212
+ """The as-built distortion of a density design and its gradient: the
1213
+ layer-by-layer build of buildsim on a fixed voxel mesh, every cell
1214
+ present at modulus e (SIMP), the lowest layer's underside held by the
1215
+ plate. D = mean over the target nodes of |u|^2, u the displacement at
1216
+ the end of the build (still on the plate). Shared by optimize_warp and
1217
+ the study optimizer (simp, buildSim.warpWeight).
1218
+
1219
+ build: (axis, sign), the direction layers stack (a grid axis; a tilted
1220
+ build has no layers on this grid); layer: mm per analysis layer
1221
+ (default 4 cells)."""
1222
+
1223
+ DIRECT = 90_000 # dofs: factor each layer once for the forward and adjoint solves
1224
+
1225
+ def __init__(self, mesh: HexMesh, setup: BuildSetup, build=(2, 1), layer=None, target=None, nu=None):
1226
+ if build is None or len(build) != 2:
1227
+ raise ValueError("the warp term needs a build direction along a grid axis ('+z', '-y', ...); "
1228
+ "a tilted build direction has no layers on the voxel grid")
1229
+ axis, sign = int(build[0]), int(build[1])
1230
+ self.mesh = mesh
1231
+ self.axis = axis
1232
+ mat = _material(setup.material)
1233
+ nu = float(nu if nu is not None else (setup.nu if setup.nu is not None else mat.nu))
1234
+ hgt = sign * mesh.elem_ijk[:, axis]
1235
+ hgt = hgt - hgt.min()
1236
+ hn = sign * mesh.node_ijk[:, axis]
1237
+ self.sign = sign
1238
+ self.hgt = hgt
1239
+ self.hn = hn - hn.min()
1240
+ self.fdm = setup.process.lower() == "fdm"
1241
+ inplane = [a for a in range(3) if a != axis]
1242
+ springs = None
1243
+ if self.fdm:
1244
+ # FDM: the elastic bed of simulate_build (springs on the first
1245
+ # layer's underside, a quarter cell of area per touching cell),
1246
+ # pinned only against drift in its plane, so corners can lift
1247
+ kb = float(setup.bed_stiffness if setup.bed_stiffness is not None else FDM_BED)
1248
+ area = np.zeros(mesh.n_nodes)
1249
+ under = hgt == 0
1250
+ for c in range(8):
1251
+ nd = mesh.enode[under, c]
1252
+ on = self.hn[nd] == 0
1253
+ np.add.at(area, nd[on], 0.25 * mesh.pitch ** 2)
1254
+ springs = np.repeat(area * kb, 3)
1255
+ bottom = np.flatnonzero(self.hn == 0)
1256
+ pc = mesh.node_xyz[bottom][:, inplane]
1257
+ a = bottom[np.argmin(np.linalg.norm(pc - pc.mean(axis=0), axis=1))]
1258
+ b = bottom[np.argmax(np.abs(pc[:, 0] - mesh.node_xyz[a, inplane[0]]))]
1259
+ self.fixed = np.zeros(mesh.n_dof, bool)
1260
+ self.fixed[3 * a + inplane[0]] = self.fixed[3 * a + inplane[1]] = True
1261
+ self.fixed[3 * b + inplane[1]] = True
1262
+ else:
1263
+ self.fixed = np.repeat(hn == hn.min(), 3)
1264
+ self.kern = _Kernel(mesh, nu, self.fixed, springs)
1265
+ # the distortion measure: |u| for a plate-held metal build; along
1266
+ # the build only for FDM, where the in-plane shrink is uniform (the
1267
+ # slicer scales it out) and the warp is the lift off the bed
1268
+ self.mask = np.ones(3)
1269
+ if self.fdm:
1270
+ self.mask = np.zeros(3)
1271
+ self.mask[axis] = 1.0
1272
+ self.cool = None
1273
+ if self.fdm:
1274
+ p = _fdm_params(setup)
1275
+ tz = _fdm_temperature(p, (hgt + 0.5) * mesh.pitch)
1276
+ self.cool = np.zeros((mesh.n_elem, 6))
1277
+ self.cool[:, :3] = (-p["alpha"] * (tz - p["room"]) * setup.scale)[:, None]
1278
+ eps, self.info = inherent_strain(setup, z=(hgt + 0.5) * mesh.pitch if setup.process == "fdm" else None)
1279
+ eps = np.broadcast_to(eps, (mesh.n_elem, 6))
1280
+ # build frame (in plane, in plane, along the build) -> grid axes
1281
+ world = np.zeros((mesh.n_elem, 6))
1282
+ world[:, inplane[0]] = eps[:, 0]
1283
+ world[:, inplane[1]] = eps[:, 1]
1284
+ world[:, axis] = eps[:, 2]
1285
+ self.eps = world
1286
+ # default lumps: 4 cells for metal; 1 for FDM, whose warp builds up
1287
+ # layer by layer on a bed that gives (4-cell lumps read it 2-3x low)
1288
+ lump = max(1, int(round((layer or (1 if setup.process.lower() == "fdm" else 4) * mesh.pitch) / mesh.pitch)))
1289
+ self.layer_of = hgt // lump
1290
+ self.n_layers = int(self.layer_of.max()) + 1
1291
+ # FDM: lumps thicker than the printed layer read the warp low (it
1292
+ # grows as the layers thin); extrapolate (layer_extrapolation)
1293
+ self.extrapolate = 0.0
1294
+ if self.fdm:
1295
+ self.extrapolate = layer_extrapolation(lump * mesh.pitch, setup)
1296
+ self.layer_of2 = hgt // (2 * lump)
1297
+ target = np.ones(mesh.n_nodes, bool) if target is None else np.asarray(target, bool)
1298
+ self.wdof = np.repeat(target, 3).astype(float) / max(int(target.sum()), 1)
1299
+ self.last_u = None
1300
+
1301
+ def evaluate(self, e, de=None):
1302
+ """(D, dD/dx) for moduli e (per cell) and their derivative de with
1303
+ respect to the design density (None: D only)."""
1304
+ vals, grad = self.measures(e, de, lambda v: {"mean": 1.0})
1305
+ return vals["mean"], grad
1306
+
1307
+ def _forward(self, e, layer_of, keep):
1308
+ """One build at the given layer lumps: the end displacement, each
1309
+ step's (dofs, solve, cells, du, active, eps) when keep, and the
1310
+ build-plane rises (step, dofs, rise)."""
1311
+ from scipy.sparse.linalg import splu
1312
+ mesh, kern = self.mesh, self.kern
1313
+ ne = mesh.n_elem
1314
+ u = np.zeros(mesh.n_dof)
1315
+ steps, lifts = [], []
1316
+ node_on = np.zeros(mesh.n_nodes, bool)
1317
+ active = np.zeros(ne, bool)
1318
+
1319
+ def step(scale, f):
1320
+ d = _dofs(node_on, self.fixed)
1321
+ k = kern.k(scale)[d][:, d]
1322
+ du = np.zeros(mesh.n_dof)
1323
+ if len(d) <= self.DIRECT:
1324
+ lu = splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A")
1325
+ du[d] = lu.solve(f[d])
1326
+ return d, lu.solve, du
1327
+ du[d] = _solve(k, f[d], mesh.node_xyz, d)
1328
+ return d, (lambda kk, dd: (lambda r: _solve(kk, r, mesh.node_xyz, dd)))(k, d), du
1329
+
1330
+ for li in range(int(layer_of.max()) + 1):
1331
+ new = layer_of == li
1332
+ if not new.any():
1333
+ continue
1334
+ active |= new
1335
+ node_on[mesh.enode[new].ravel()] = True
1336
+ el = np.flatnonzero(new)
1337
+ d, solve, du = step(e * active, kern.load(e, self.eps[el], el))
1338
+ u += du
1339
+ plane = np.flatnonzero(node_on & (self.hn == self.hgt[el].max() + 1))
1340
+ ldof = 3 * plane + self.axis
1341
+ lifts.append((len(steps), ldof, self.sign * u[ldof]))
1342
+ steps.append((d, solve, el, du, np.flatnonzero(active), self.eps) if keep else None)
1343
+ if self.cool is not None:
1344
+ # FDM: the finished part cools on the bed to room temperature
1345
+ el = np.arange(ne)
1346
+ d, solve, du = step(e, kern.load(e, self.cool, el))
1347
+ u += du
1348
+ steps.append((d, solve, el, du, el, self.cool) if keep else None)
1349
+ return u, steps, lifts
1350
+
1351
+ def measures(self, e, de=None, weights=None):
1352
+ """The as-built measures of moduli e: "mean" (D), "max" (the largest
1353
+ |u| at the end of the build, mm; along the build only for FDM) and
1354
+ "lift" (the largest rise along the build at the build plane, over
1355
+ the layers as each goes down: the recoater's clearance, mm). With
1356
+ de, also the gradient with respect to the design density of
1357
+ sum(w[m] * measure m), w = weights(vals) (a callable, so weights can
1358
+ depend on this design's values). The maxima are steered through
1359
+ 16-norms rescaled to the true maximum at this design (adaptive
1360
+ normalization), so the gradient is that of the maximum to first
1361
+ order. FDM builds at lumps thicker than the printed layer are
1362
+ extrapolated to it from a second build at twice the lumps (see
1363
+ layer_extrapolation)."""
1364
+ mesh, kern = self.mesh, self.kern
1365
+ ne = mesh.n_elem
1366
+ keep = de is not None
1367
+ u1, steps, lifts = self._forward(e, self.layer_of, keep)
1368
+ kx = self.extrapolate
1369
+ if kx:
1370
+ u2, steps2, _ = self._forward(e, self.layer_of2, keep)
1371
+ u = (1.0 + kx) * u1 - kx * u2
1372
+ else:
1373
+ u = u1
1374
+ self.last_u = u
1375
+ uu = u.reshape(-1, 3) * self.mask
1376
+ un = np.linalg.norm(uu, axis=1)
1377
+ allrise = np.concatenate([r for _, _, r in lifts]) if lifts else np.zeros(1)
1378
+ vals = {"mean": float((self.wdof * u * u).sum()), "max": float(un.max()),
1379
+ "lift": float(max(allrise.max(), 0.0))}
1380
+ # the 16-norms the gradient steers (checks)
1381
+ vals["_maxP"] = float(np.sum(un ** 16) ** (1 / 16))
1382
+ vals["_liftP"] = float(np.sum(np.maximum(allrise, 0.0) ** 16) ** (1 / 16))
1383
+ if de is None:
1384
+ return vals, None
1385
+ w = weights(vals) if callable(weights) else (weights or {"mean": 1.0})
1386
+ q = 16.0
1387
+ # dJ/du at the end of the build, and per step (the lift)
1388
+ gu = 2.0 * w.get("mean", 0.0) * self.wdof * u
1389
+ if w.get("max") and vals["max"] > 0:
1390
+ r = un / vals["max"]
1391
+ c = r ** (q - 1) / (r ** q).sum()
1392
+ gu += w["max"] * ((c / np.maximum(un, 1e-30))[:, None] * uu).ravel()
1393
+ gstep = {}
1394
+ if w.get("lift") and vals["lift"] > 0:
1395
+ tot = float(np.sum((np.maximum(allrise, 0.0) / vals["lift"]) ** q))
1396
+ for si, ldof, rs in lifts:
1397
+ g = np.zeros(mesh.n_dof)
1398
+ g[ldof] = w["lift"] * self.sign * (np.maximum(rs, 0.0) / vals["lift"]) ** (q - 1) / tot
1399
+ gstep[si] = g
1400
+ # adjoint: J depends on the final u = sum of the layer increments
1401
+ # and on each step's u_k (the lift), so dJ/d du_j = gu + the sum of
1402
+ # the step terms from j on; K_j lam_j = that, and
1403
+ # dJ/de = sum_j lam_j^T (df_j/de - dK_j/de du_j). Extrapolated, u is
1404
+ # (1 + k) u1 - k u2, so each build's adjoint takes its share.
1405
+ dd = np.zeros(ne)
1406
+ ke, g = kern.st.ke, kern.g
1407
+
1408
+ def adjoint(steps, scale, with_lift):
1409
+ acc = np.zeros(mesh.n_dof)
1410
+ for si in range(len(steps) - 1, -1, -1):
1411
+ d, solve, el, du, ia, eps = steps[si]
1412
+ if with_lift and si in gstep:
1413
+ acc = acc + gstep[si]
1414
+ rhs = scale * gu + acc
1415
+ lam = np.zeros(mesh.n_dof)
1416
+ lam[d] = solve(rhs[d])
1417
+ dd[el] += np.einsum("ij,ij->i", lam[mesh.edof[el]], eps[el] @ g.T)
1418
+ dd[ia] -= np.einsum("ij,jk,ik->i", lam[mesh.edof[ia]], ke, du[mesh.edof[ia]])
1419
+
1420
+ adjoint(steps, 1.0 + kx, True)
1421
+ if kx:
1422
+ adjoint(steps2, -kx, False)
1423
+ return vals, dd * de
1424
+
1425
+
1426
+ class CoarseWarp:
1427
+ """A WarpTerm on a grid `factor` times coarser than the optimizer's:
1428
+ each coarse cell's modulus is the mean of its fine cells' (absent ones
1429
+ count as void), and the gradient goes back to each fine cell as its
1430
+ share. factor 2 has 8x fewer cells and half the layers, so the build
1431
+ solves in the optimizer loop cost a small fraction of the fine ones."""
1432
+
1433
+ def __init__(self, mesh: HexMesh, setup: BuildSetup, factor=2, build=(2, 1), layer=None, nu=None):
1434
+ f = int(factor)
1435
+ cijk = mesh.elem_ijk // f
1436
+ uniq, self.parent = np.unique(cijk, axis=0, return_inverse=True)
1437
+ self.parent = self.parent.ravel()
1438
+ self.n = len(uniq)
1439
+ self.share = 1.0 / f ** 3
1440
+ self.mesh = HexMesh(uniq, mesh.pitch * f, mesh.origin)
1441
+ self.fine_mesh = mesh
1442
+ self._fine = None
1443
+ self.ratio = {"mean": 1.0, "max": 1.0, "lift": 1.0}
1444
+ self.layer = layer or (f if setup.process.lower() == "fdm" else 4) * mesh.pitch
1445
+ self.setup, self.build = setup, build
1446
+ self.term = WarpTerm(self.mesh, setup, build=build, layer=self.layer, nu=nu)
1447
+ self.factor = f
1448
+
1449
+ def _coarse(self, e):
1450
+ return np.bincount(self.parent, weights=e, minlength=self.n) * self.share
1451
+
1452
+ def fine(self):
1453
+ """The same build model on the full grid."""
1454
+ if self._fine is None:
1455
+ self._fine = WarpTerm(self.fine_mesh, self.setup, build=self.build,
1456
+ layer=self.layer,
1457
+ nu=self.term.kern.nu)
1458
+ return self._fine
1459
+
1460
+ def measures(self, e, de=None, weights=None, refresh=False):
1461
+ """As WarpTerm.measures, on the coarse grid, with each measure scaled
1462
+ by its full-grid / coarse-grid ratio (refresh: re-measure that ratio
1463
+ on this design, one forward build on the full grid). Averaging the
1464
+ moduli makes thin members softer and stiffer by turns than they are,
1465
+ so the coarse grid alone misreads a design far from where the ratio
1466
+ was taken."""
1467
+ ec = np.maximum(self._coarse(e), 1e-9)
1468
+ if refresh:
1469
+ vf, _ = self.fine().measures(e)
1470
+ vc, _ = self.term.measures(ec)
1471
+ self.ratio = {k: (vf[k] / vc[k] if vc[k] > 1e-12 else 1.0) for k in ("mean", "max", "lift")}
1472
+ r = self.ratio
1473
+
1474
+ def scaled(v):
1475
+ return {**v, **{k: v[k] * r[k] for k in r}}
1476
+
1477
+ if de is None:
1478
+ v, _ = self.term.measures(ec)
1479
+ return scaled(v), None
1480
+
1481
+ def wc(v):
1482
+ w = weights(scaled(v)) if callable(weights) else (weights or {"mean": 1.0})
1483
+ return {k: w[k] * r.get(k, 1.0) for k in w}
1484
+
1485
+ vals, gc = self.term.measures(ec, np.ones(self.n), wc)
1486
+ return scaled(vals), gc[self.parent] * self.share * de
1487
+
1488
+ def evaluate(self, e, de=None):
1489
+ vals, g = self.measures(e, de, lambda v: {"mean": 1.0})
1490
+ return vals["mean"], g