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/lattice.py ADDED
@@ -0,0 +1,1065 @@
1
+ """
2
+ Field-driven lattices: fill a part's interior with a triply periodic minimal
3
+ surface (TPMS) sheet whose wall thickness follows the stress, keeping a solid
4
+ skin and solid material wherever the part works hard.
5
+
6
+ Everything is an implicit field:
7
+
8
+ - the part as a signed distance (negative inside), from a voxelization of
9
+ its mesh and distance transforms;
10
+ - the TPMS as an approximate distance, the level-set function divided by
11
+ its gradient's magnitude, so a sheet's wall comes out in millimetres;
12
+ - solid zones (the skin, collars round preserved geometry, keep-outs,
13
+ supports and loads, and cells the sizing turned solid) as distances too,
14
+ joined to the lattice by a smooth union, so every sheet meets the skin
15
+ and every boss with a fillet instead of a crease.
16
+
17
+ Two ways to grade the wall:
18
+
19
+ - `target_sf` set (the sizing that means something): the core is a
20
+ homogenized material, a sheet of relative density rho having modulus
21
+ E * C rho^n and a peak wall stress A rho^-m times the stress it carries
22
+ on average (unit-cell FEA, `homogenize`; the fits are in SHEETS). A
23
+ voxel FEA of the part with the skin solid and every core cell at its
24
+ sheet's modulus gives each cell's stress; each cell's density is set,
25
+ fully-stressed-design style, to the least that keeps its walls at the
26
+ target safety factor, cells that would need more than `wall_max` turn
27
+ solid, and the analysis repeats as the load moves to wherever it is
28
+ stiffest. That is the lattice an engineer can sign off on: the voids are
29
+ where the stress is not.
30
+ - otherwise, from a verification of the solid part (`fields`): the wall runs
31
+ from `wall_min` where the part idles to `wall_max`, and material above
32
+ `solid_above` utilization stays solid. Quick, but the solid part's stress
33
+ is not the lattice part's.
34
+
35
+ Printability is part of the design, not an afterthought:
36
+
37
+ - every wall is at least the process's minimum feature;
38
+ - powder (SLS, MJF) and resin (SLA, volumetric) would stay trapped in a
39
+ lattice under a closed skin, so each enclosed void gets drain holes
40
+ (lowest and highest along the build, away from the stress), pockets too
41
+ small to drain are filled solid, and the report says what is still
42
+ trapped (it should be nothing);
43
+ - FDM prints the closed skin like infill; the report gives the lattice's
44
+ own down-facing area (gyroid and diamond sheets self-support at the
45
+ usual cell sizes; Schwarz P has flat ceilings).
46
+
47
+ Units are millimetres throughout.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ from dataclasses import dataclass
52
+ from functools import lru_cache
53
+
54
+ import numpy as np
55
+ from scipy import ndimage
56
+
57
+ from .spec import Study
58
+
59
+ __all__ = ["LatticeOptions", "tpms", "lattice_infill", "TPMS", "SHEETS", "STRUTS", "STRUT_TABLES", "lattice_fit", "homogenize",
60
+ "relative_density", "wall_for_density", "envelope_mesh"]
61
+
62
+
63
+ def _gyroid(x, y, z):
64
+ return np.sin(x) * np.cos(y) + np.sin(y) * np.cos(z) + np.sin(z) * np.cos(x)
65
+
66
+
67
+ def _gyroid_grad(x, y, z):
68
+ return (np.cos(x) * np.cos(y) - np.sin(z) * np.sin(x),
69
+ np.cos(y) * np.cos(z) - np.sin(x) * np.sin(y),
70
+ np.cos(z) * np.cos(x) - np.sin(y) * np.sin(z))
71
+
72
+
73
+ def _schwarz_p(x, y, z):
74
+ return np.cos(x) + np.cos(y) + np.cos(z)
75
+
76
+
77
+ def _schwarz_p_grad(x, y, z):
78
+ return -np.sin(x), -np.sin(y), -np.sin(z)
79
+
80
+
81
+ def _diamond(x, y, z):
82
+ return (np.sin(x) * np.sin(y) * np.sin(z) + np.sin(x) * np.cos(y) * np.cos(z)
83
+ + np.cos(x) * np.sin(y) * np.cos(z) + np.cos(x) * np.cos(y) * np.sin(z))
84
+
85
+
86
+ def _diamond_grad(x, y, z):
87
+ sx, cx, sy, cy, sz, cz = np.sin(x), np.cos(x), np.sin(y), np.cos(y), np.sin(z), np.cos(z)
88
+ return (cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz,
89
+ sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz,
90
+ sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz)
91
+
92
+
93
+ TPMS = {"gyroid": (_gyroid, _gyroid_grad),
94
+ "schwarz-p": (_schwarz_p, _schwarz_p_grad),
95
+ "diamond": (_diamond, _diamond_grad)}
96
+
97
+ # Homogenized sheets, from `homogenize` (periodic unit cell, 40 voxels per
98
+ # cell, nu = 0.35, relative density 0.14-0.6; the fits are within 5% of
99
+ # every point): modulus E*/E = C rho^n (uniaxial; the cells are cubic, so
100
+ # that is every axis), and the peak wall von Mises (99.5th percentile of
101
+ # nodal values) over the macro von Mises, = A rho^-m, the larger of a
102
+ # uniaxial and a shear macro stress (uniaxial for gyroid and Schwarz P,
103
+ # shear for thin diamond). Elastic first yield: the walls start yielding
104
+ # there, well before the cell collapses, which is the right reading for a
105
+ # linear check. Per gram, diamond is the stiffest and strongest of the
106
+ # three and Schwarz P the weakest.
107
+ SHEETS = {
108
+ "gyroid": {"C": 0.648, "n": 1.43, "A": 3.01, "m": 1.10},
109
+ "diamond": {"C": 0.671, "n": 1.35, "A": 2.62, "m": 1.22},
110
+ "schwarz-p": {"C": 0.669, "n": 1.68, "A": 3.82, "m": 1.04},
111
+ }
112
+
113
+ # Homogenized strut cells (struts.py), from `homogenize` the same way
114
+ # (bench/strut_fits.py: periodic unit cell, 48 voxels a cell, relative
115
+ # density 0.06-0.34). Struts are cubic but anisotropic, so these are the
116
+ # conservative isotropic reading: the modulus is the lesser of the [100]
117
+ # and [111] moduli, the stress factor the largest of a [100] pull, a [111]
118
+ # pull and a shear. BCCz and FCCz are stiff along z and the octet along its
119
+ # struts; the fits do not count on it. The thickness is the strut diameter.
120
+ # sheet_modulus and sheet_stress_factor read STRUT_TABLES below instead,
121
+ # which run to rho 0.92; these power laws are the summary over 0.06-0.34.
122
+ STRUTS = {
123
+ "cubic": {"C": 0.739, "n": 2.38, "A": 3.09, "m": 1.84},
124
+ "bcc": {"C": 0.61, "n": 2.49, "A": 4.63, "m": 1.64},
125
+ "bccz": {"C": 0.488, "n": 2.5, "A": 4.12, "m": 2.3},
126
+ "fcc": {"C": 0.443, "n": 1.4, "A": 5.96, "m": 0.97},
127
+ "fccz": {"C": 0.435, "n": 1.44, "A": 5.26, "m": 1.19},
128
+ "octet": {"C": 0.363, "n": 1.36, "A": 7.22, "m": 0.91},
129
+ "kelvin": {"C": 0.937, "n": 2.07, "A": 3.28, "m": 1.62},
130
+ # one periodic draw of 8 relaxed seeds (struts._periodic_voronoi), the
131
+ # thickness over the seed spacing; a foam is near isotropic
132
+ "voronoi": {"C": 0.841, "n": 1.93, "A": 2.87, "m": 1.57},
133
+ "diamond-strut": {"C": 0.695, "n": 2.1, "A": 2.63, "m": 1.77},
134
+ }
135
+
136
+
137
+ # The power laws above were fitted over rho 0.06-0.34 and miss badly past
138
+ # it: at rho 0.8 the octet fit reads E*/E 0.27 against a solved 0.56 and a
139
+ # stress factor 8.8 against 4.3, so a dense strut core looked far weaker than
140
+ # it is. The same unit-cell solves from rho 0.06 to 0.92 (bench/strut_fits.py,
141
+ # n=48), tabulated as (rho, E*/E, stress factor), the conservative isotropic
142
+ # reading as above. Between rows the readings are interpolated log-log, up to
143
+ # the solid (1, 1, 1); under the first row the power law runs on with the
144
+ # slope of the first two rows.
145
+ STRUT_TABLES = {
146
+ "cubic": [
147
+ (0.061, 0.001, 525.18), (0.099, 0.003, 225.22), (0.159, 0.0092, 85.85), (0.237, 0.0237, 46.11), (0.343, 0.0605, 21.63),
148
+ (0.478, 0.1399, 10.45), (0.647, 0.3106, 5.63), (0.785, 0.5317, 3.59),
149
+ ],
150
+ "bcc": [
151
+ (0.058, 0.0005, 472.41), (0.093, 0.0016, 229.56), (0.158, 0.0058, 104.25), (0.232, 0.0158, 56.67), (0.327, 0.0404, 25.59),
152
+ (0.49, 0.1298, 10.7), (0.658, 0.3127, 5.89), (0.799, 0.551, 3.85), (0.921, 0.8148, 2.26),
153
+ ],
154
+ "bccz": [
155
+ (0.055, 0.0004, 3174.74), (0.104, 0.0017, 748.84), (0.158, 0.0046, 290.12), (0.235, 0.0125, 116.99), (0.348, 0.0373, 45.0),
156
+ (0.483, 0.0997, 18.63), (0.648, 0.258, 7.29), (0.799, 0.5184, 3.47), (0.921, 0.8014, 2.25),
157
+ ],
158
+ "fcc": [
159
+ (0.056, 0.0083, 94.57), (0.095, 0.0155, 58.45), (0.163, 0.0317, 35.92), (0.246, 0.0613, 24.25), (0.334, 0.1026, 16.2),
160
+ (0.487, 0.2017, 9.34), (0.655, 0.3516, 5.35), (0.798, 0.5517, 3.6), (0.919, 0.8046, 2.16),
161
+ ],
162
+ "fccz": [
163
+ (0.062, 0.0085, 134.39), (0.105, 0.0161, 79.55), (0.163, 0.0291, 47.25), (0.238, 0.0534, 30.62), (0.335, 0.0989, 17.6),
164
+ (0.481, 0.1808, 9.3), (0.644, 0.3172, 5.76), (0.805, 0.5462, 3.66), (0.92, 0.8037, 2.16),
165
+ ],
166
+ "octet": [
167
+ (0.073, 0.0108, 74.07), (0.107, 0.0173, 57.65), (0.178, 0.033, 36.12), (0.243, 0.0515, 27.9), (0.322, 0.0827, 18.73),
168
+ (0.48, 0.1702, 11.09), (0.652, 0.3379, 6.48), (0.79, 0.5612, 4.27), (0.922, 0.8359, 2.2),
169
+ ],
170
+ "kelvin": [
171
+ (0.056, 0.0023, 344.9), (0.094, 0.0071, 155.37), (0.16, 0.0213, 69.09), (0.239, 0.0482, 32.62), (0.331, 0.0945, 19.28),
172
+ (0.478, 0.207, 10.72), (0.646, 0.4007, 5.47), (0.803, 0.6402, 3.33), (0.922, 0.8481, 1.95),
173
+ ],
174
+ "diamond-strut": [
175
+ (0.058, 0.0018, 378.68), (0.093, 0.0047, 192.93), (0.157, 0.0144, 70.05), (0.232, 0.0321, 33.78), (0.34, 0.0721, 17.59),
176
+ (0.489, 0.1596, 9.47), (0.657, 0.3211, 5.96), (0.797, 0.5353, 4.3), (0.92, 0.8046, 2.91),
177
+ ],
178
+ "voronoi": [
179
+ (0.06, 0.0036, 236.04), (0.1, 0.0098, 106.78), (0.16, 0.0244, 52.1), (0.24, 0.0531, 26.99), (0.34, 0.104, 15.49),
180
+ (0.481, 0.2074, 8.75), (0.65, 0.3885, 5.01), (0.8, 0.6086, 3.21), (0.92, 0.8274, 2.16),
181
+ ],
182
+ }
183
+
184
+
185
+ def _table_reading(kind: str, rho, col: int):
186
+ """Log-log interpolation of a strut table column (1 = E*/E, 2 = stress
187
+ factor) at rho, None for a kind with no table."""
188
+ from .struts import canonical
189
+
190
+ k = kind if kind in SHEETS else canonical(kind)
191
+ rows = STRUT_TABLES.get(k)
192
+ if rows is None:
193
+ return None
194
+ t = np.array(rows + [(1.0, 1.0, 1.0)], float)
195
+ x, y = np.log(t[:, 0]), np.log(t[:, col])
196
+ lr = np.log(np.clip(np.asarray(rho, float), 1e-3, 1.0))
197
+ slope = (y[1] - y[0]) / (x[1] - x[0])
198
+ out = np.where(lr < x[0], y[0] + slope * (lr - x[0]), np.interp(lr, x, y))
199
+ return np.exp(out)
200
+
201
+
202
+ def lattice_fit(kind: str) -> dict:
203
+ """The homogenized fit (C, n, A, m) of a sheet or strut cell."""
204
+ from .struts import canonical
205
+
206
+ if kind in SHEETS:
207
+ return SHEETS[kind]
208
+ k = canonical(kind)
209
+ if k in STRUTS:
210
+ return STRUTS[k]
211
+ raise ValueError(f"no homogenized fit for lattice {kind!r} (have {', '.join(list(SHEETS) + list(STRUTS))})")
212
+
213
+
214
+ _SINGLE = [0]
215
+
216
+
217
+ class single_precision:
218
+ """Inside this block (from any thread) gyroid and diamond take their
219
+ trig in single precision, on a phase reduced to one period in double:
220
+ numpy's float32 sin/cos are SIMD and ~30x faster than float64's, and
221
+ the value is still good to ~1e-7 of a cell near the surface (1e-6 mm
222
+ on a 7 mm cell). For sampling a grid for marching cubes (which keeps
223
+ float32 samples anyway); not for finite differences on a 1e-4 pitch
224
+ step, as the crease pass takes, where that noise is a 3% gradient."""
225
+
226
+ def __enter__(self):
227
+ _SINGLE[0] += 1
228
+ return self
229
+
230
+ def __exit__(self, *exc):
231
+ _SINGLE[0] -= 1
232
+
233
+
234
+ def tpms(kind: str, pts: np.ndarray, cell: float) -> np.ndarray:
235
+ """Approximate signed distance (mm) to the TPMS mid-surface, `cell` mm per
236
+ period: the level-set value over its gradient's magnitude. Exact on the
237
+ surface, and good to a few percent out to a quarter cell, which is as far
238
+ as a sheet wall reaches."""
239
+ if kind not in TPMS:
240
+ raise ValueError(f"lattice type must be one of {sorted(TPMS)}, got {kind!r}")
241
+ if not _SINGLE[0] and np.ndim(cell) == 0:
242
+ from . import fieldops
243
+
244
+ if fieldops.available():
245
+ # double precision, one compiled pass (bit for bit the code below)
246
+ return fieldops.tpms(kind, pts, cell)
247
+ k = 2.0 * np.pi / cell
248
+ if kind == "schwarz-p":
249
+ x, y, z = (k * pts[:, i] for i in range(3))
250
+ f, grad = TPMS[kind]
251
+ g = np.stack(grad(x, y, z), axis=-1) * k
252
+ return f(x, y, z) / np.maximum(np.linalg.norm(g, axis=-1), 0.3 * k)
253
+ # gyroid and diamond from one set of sines and cosines (the separate
254
+ # value and gradient functions above take each three times)
255
+ if _SINGLE[0]:
256
+ two_pi = np.float32(2.0 * np.pi)
257
+ ph = []
258
+ for i in range(3):
259
+ u = pts[:, i] * (1.0 / cell)
260
+ u -= np.floor(u)
261
+ ph.append(u.astype(np.float32) * two_pi)
262
+ x, y, z = ph
263
+ k = np.float32(k)
264
+ else:
265
+ x, y, z = (k * pts[:, i] for i in range(3))
266
+ sx, cx, sy, cy, sz, cz = np.sin(x), np.cos(x), np.sin(y), np.cos(y), np.sin(z), np.cos(z)
267
+ if kind == "gyroid":
268
+ f = sx * cy + sy * cz + sz * cx
269
+ g = (cx * cy - sz * sx, cy * cz - sx * sy, cz * cx - sy * sz)
270
+ else:
271
+ f = sx * sy * sz + sx * cy * cz + cx * sy * cz + cx * cy * sz
272
+ g = (cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz,
273
+ sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz,
274
+ sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz)
275
+ gn = np.sqrt(g[0] * g[0] + g[1] * g[1] + g[2] * g[2]) * k
276
+ return np.asarray(f / np.maximum(gn, 0.3 * k), np.float64)
277
+
278
+
279
+ @lru_cache(maxsize=None)
280
+ def _density_table(kind: str):
281
+ """Relative density of a sheet |tpms| < wall/2 against wall/cell, sampled
282
+ on one cell (the distance approximation makes it a little sub-linear)."""
283
+ n = 96
284
+ c = (np.arange(n) + 0.5) / n
285
+ g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
286
+ d = np.sort(np.abs(tpms(kind, g, 1.0)))
287
+ t = np.linspace(0.0, 0.8, 161)
288
+ rho = np.searchsorted(d, 0.5 * t) / d.size
289
+ return t, rho
290
+
291
+
292
+ def relative_density(kind: str, wall, cell: float, blend: float = 0.0):
293
+ """Volume fraction of a `wall` mm sheet (or strut diameter) in `cell` mm
294
+ cells; `blend` (mm) is a strut cell's node fillet."""
295
+ from .struts import is_strut, strut_relative_density
296
+
297
+ if is_strut(kind):
298
+ return strut_relative_density(kind, wall, cell, blend=float(blend) / float(np.mean(cell)))
299
+ t, rho = _density_table(kind)
300
+ return np.interp(np.asarray(wall, float) / cell, t, rho)
301
+
302
+
303
+ def wall_for_density(kind: str, rho, cell: float):
304
+ """The sheet wall (mm) that gives relative density `rho`."""
305
+ t, r = _density_table(kind)
306
+ return np.interp(np.asarray(rho, float), r, t) * cell
307
+
308
+
309
+ def sheet_modulus(kind: str, rho):
310
+ """E*/E of a sheet or strut cell at relative density rho."""
311
+ s = lattice_fit(kind)
312
+ rho = np.asarray(rho, float)
313
+ t = _table_reading(kind, rho, 1)
314
+ if t is not None:
315
+ return np.where(rho >= 1.0, 1.0, t)
316
+ return np.where(rho >= 1.0, 1.0, s["C"] * np.clip(rho, 1e-3, 1.0) ** s["n"])
317
+
318
+
319
+ def sheet_stress_factor(kind: str, rho):
320
+ """Peak wall stress over the macro stress the lattice carries."""
321
+ s = lattice_fit(kind)
322
+ rho = np.asarray(rho, float)
323
+ t = _table_reading(kind, rho, 2)
324
+ if t is not None:
325
+ return np.where(rho >= 1.0, 1.0, t)
326
+ return np.where(rho >= 1.0, 1.0, s["A"] * np.clip(rho, 1e-3, 1.0) ** -s["m"])
327
+
328
+
329
+ def homogenize(kind: str, wall_over_cell: float, n: int = 40, nu: float = 0.35, blend: float = 0.0) -> dict:
330
+ """Effective properties of a TPMS sheet by periodic homogenization of one
331
+ unit cell on an n^3 voxel grid (only the solid voxels; their nodes wrap
332
+ across the cell faces). Returns relative density, uniaxial modulus and
333
+ Poisson ratio, shear modulus (all over the solid's E), the Zener
334
+ anisotropy ratio, and the peak wall stress factor under a uniaxial and a
335
+ shear macro stress (99.5th percentile and max of nodal von Mises over
336
+ the macro von Mises). Strut cells too (struts.py): `wall_over_cell` is
337
+ then the strut diameter over the cell and `blend` the node fillet over
338
+ the cell; they are cubic but far from isotropic, so the modulus along
339
+ the cube diagonal [111] and the wall stress under a [111] pull come
340
+ too (E111, stressFactor111). Slow (tens of seconds at n = 40): the fits
341
+ in SHEETS and STRUTS come from it."""
342
+ import pyamg
343
+ import scipy.sparse as sp
344
+ from scipy.sparse.linalg import cg
345
+
346
+ from .fem import CORNERS, _b_matrix, elastic_c, hex8_b, hex8_ke, von_mises
347
+
348
+ h = 1.0 / n
349
+ c = (np.arange(n) + 0.5) * h
350
+ g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
351
+ from .struts import is_strut, unit_cell_distance
352
+
353
+ if is_strut(kind):
354
+ solid = unit_cell_distance(kind, g, blend) < 0.5 * wall_over_cell
355
+ else:
356
+ solid = np.abs(tpms(kind, g, 1.0)) < 0.5 * wall_over_cell
357
+ eijk = np.argwhere(solid.reshape(n, n, n))
358
+ ne = len(eijk)
359
+ corner = (eijk[:, None, :] + CORNERS[None]) % n
360
+ used, inv = np.unique((corner[..., 0] * n + corner[..., 1]) * n + corner[..., 2], return_inverse=True)
361
+ enode = inv.reshape(ne, 8)
362
+ nn = len(used)
363
+ edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
364
+ ke = hex8_ke(nu) * h
365
+ k = sp.csr_matrix((np.tile(ke.ravel(), ne), (np.repeat(edof, 24, axis=1).ravel(),
366
+ np.tile(edof, (1, 24)).ravel())), shape=(3 * nn, 3 * nn))
367
+ free = np.ones(3 * nn, bool)
368
+ free[:3] = False # one node holds the cell still
369
+ kf = k[free][:, free]
370
+ ml = pyamg.smoothed_aggregation_solver(kf, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
371
+ xc = (eijk[:, None, :] + CORNERS[None]) * h
372
+ c0 = elastic_c(1.0, nu)
373
+ bc = _b_matrix(0, 0, 0) / h
374
+
375
+ def load(e):
376
+ # u = macro strain . x + periodic fluctuation
377
+ eps = np.array([[e[0], e[3] / 2, e[5] / 2], [e[3] / 2, e[1], e[4] / 2], [e[5] / 2, e[4] / 2, e[2]]])
378
+ u0 = (xc @ eps.T).reshape(ne, 24)
379
+ f = np.zeros(3 * nn)
380
+ np.add.at(f, edof, -(u0 @ ke.T))
381
+ uf, info = cg(kf, f[free], rtol=1e-8, maxiter=2000, M=ml.aspreconditioner())
382
+ if info != 0:
383
+ raise RuntimeError("unit cell solve did not converge")
384
+ u = np.zeros(3 * nn)
385
+ u[free] = uf
386
+ ue = u[edof] + u0
387
+ mean = ((ue @ bc.T) @ c0.T).sum(axis=0) / n ** 3
388
+ acc = np.zeros((nn, 6))
389
+ for kk, (a, b, cc) in enumerate(2.0 * CORNERS - 1.0):
390
+ np.add.at(acc, enode[:, kk], (ue @ (hex8_b(a, b, cc, nu) / h).T) @ c0.T)
391
+ vm = von_mises(acc / np.bincount(enode.ravel(), minlength=nn)[:, None])
392
+ return mean, vm
393
+
394
+ s1, _ = load([1, 0, 0, 0, 0, 0])
395
+ c11, c12 = s1[0], s1[1]
396
+ c44 = load([0, 0, 0, 1, 0, 0])[0][3]
397
+ e_uni = (c11 - c12) * (c11 + 2 * c12) / (c11 + c12)
398
+ nu_h = c12 / (c11 + c12)
399
+ _, vm_z = load([-nu_h / e_uni, -nu_h / e_uni, 1 / e_uni, 0, 0, 0])
400
+ _, vm_s = load([0, 0, 0, 1 / c44, 0, 0])
401
+ # [111]: macro stress n n^T (unit von Mises) through the cubic compliance
402
+ s11 = (c11 + c12) / ((c11 - c12) * (c11 + 2 * c12))
403
+ s12 = -c12 / ((c11 - c12) * (c11 + 2 * c12))
404
+ s44 = 1.0 / c44
405
+ e111 = 1.0 / (s11 - 2 * (s11 - s12 - 0.5 * s44) / 3.0)
406
+ sig = np.full(6, 1.0 / 3.0)
407
+ eps = np.array([s11 * sig[0] + s12 * (sig[1] + sig[2]), s11 * sig[1] + s12 * (sig[0] + sig[2]),
408
+ s11 * sig[2] + s12 * (sig[0] + sig[1]), s44 * sig[3], s44 * sig[4], s44 * sig[5]])
409
+ _, vm_d = load(list(eps))
410
+ return {"rho": float(solid.mean()), "E": float(e_uni), "nu": float(nu_h), "G": float(c44),
411
+ "E111": float(e111), "stressFactor111": float(np.percentile(vm_d, 99.5)),
412
+ "zener": float(2 * c44 / (c11 - c12)),
413
+ "stressFactor": float(np.percentile(vm_z, 99.5)), "stressFactorMax": float(vm_z.max()),
414
+ "shearStressFactor": float(np.percentile(vm_s, 99.5) / np.sqrt(3.0))}
415
+
416
+
417
+ @dataclass
418
+ class LatticeOptions:
419
+ kind: str = "gyroid"
420
+ cell: float = 8.0 # mm per period
421
+ skin: float = 1.2 # mm of solid shell; 0 leaves the lattice open
422
+ wall_min: float = 0.8 # mm, where the part idles (>= the process's min feature)
423
+ wall_max: float = 2.0 # mm; a cell that needs more turns solid
424
+ solid_above: float = 0.5 # (solid-part grading) utilization above which material stays solid
425
+ safety_factor: float = 2.0 # (solid-part grading) the allowable is strength / this
426
+ target_sf: float | None = None # homogenized sizing to this safety factor (every load case)
427
+ collar: float = 2.0 # mm of solid round preserved shapes, keep-outs, supports and loads
428
+ blend: float | None = None # mm, smooth-union radius between solid and lattice (default wall_max)
429
+ drains: str = "auto" # "auto" (powder and resin processes), "yes", "no"
430
+ drain_diameter: float | None = None # mm (default the process's escape hole: SLS 4, MJF 5, resin 3.5)
431
+ fill_pockets: float | None = None # mm^3: enclosed voids smaller than this are filled (default cell^3/8)
432
+ iterations: int = 12 # sizing passes
433
+ analysis_cells: int = 120_000 # homogenized FEA size
434
+ step: float | None = None # field sampling, mm (default: wall_min / 2.5)
435
+ max_points: int = 12_000_000
436
+ exact_surface: bool = True # mesh the TPMS itself (projected), not its grid samples
437
+ anisotropic: bool | None = None # (homogenized sizing) full cell tensors (lattice_tensors); None: on
438
+ # where tables exist and LOADPATH_LATTICE_ANISO is not 0
439
+
440
+ @classmethod
441
+ def from_spec(cls, spec: dict, process=None, safety_factor: float | None = None) -> "LatticeOptions":
442
+ """Options from the JSON spec (schema.LATTICE_SCHEMA). `safety_factor`
443
+ is the study goal's, used when the spec names none."""
444
+ spec = dict(spec or {})
445
+ o = cls()
446
+ for key, attr in (("type", "kind"), ("cell", "cell"), ("skin", "skin"), ("minWall", "wall_min"),
447
+ ("maxWall", "wall_max"), ("solidAbove", "solid_above"), ("collar", "collar"),
448
+ ("blend", "blend"), ("drains", "drains"), ("drainDiameter", "drain_diameter"),
449
+ ("fillPockets", "fill_pockets"), ("step", "step")):
450
+ if key in spec:
451
+ setattr(o, attr, spec[key] if attr in ("kind", "drains") else float(spec[key]))
452
+ sizing = spec.get("sizing", "homogenized")
453
+ if sizing not in ("homogenized", "solid-stress"):
454
+ raise ValueError("lattice sizing must be 'homogenized' or 'solid-stress'")
455
+ sf = float(spec.get("safetyFactor") or safety_factor or 2.0)
456
+ if sizing == "homogenized":
457
+ o.target_sf = sf
458
+ else:
459
+ o.safety_factor = sf
460
+ if process is not None and o.wall_min < process.min_feature:
461
+ o.wall_min = process.min_feature
462
+ if o.wall_max < o.wall_min:
463
+ raise ValueError("lattice maxWall must be at least minWall")
464
+ if o.cell < 4.0 * o.wall_max:
465
+ raise ValueError(f"lattice cell {o.cell:g}mm is too small for {o.wall_max:g}mm walls "
466
+ "(at least 4 x maxWall, or the channels close)")
467
+ if o.drains not in ("auto", "yes", "no"):
468
+ raise ValueError("lattice drains must be 'auto', 'yes' or 'no'")
469
+ return o
470
+
471
+
472
+ def envelope_mesh(study: Study):
473
+ """The whole envelope as a solid: design space and preserved shapes, less
474
+ the keep-outs. A lattice part that keeps its outer shape starts here."""
475
+ import manifold3d as m3d
476
+
477
+ from .shapes import trimesh_from_manifold
478
+
479
+ add = m3d.Manifold.batch_boolean([s.to_manifold() for s in study.design_space]
480
+ + [r.shape.to_manifold() for r in study.preserve], m3d.OpType.Add)
481
+ if study.keep_out:
482
+ add = add - m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.keep_out], m3d.OpType.Add)
483
+ return trimesh_from_manifold(add)
484
+
485
+
486
+ def _part_distance(mesh, step, lo, dims):
487
+ """Signed distance to the mesh surface on the grid (negative inside),
488
+ from an inside/outside voxelization and two distance transforms."""
489
+ from .verify import voxelize
490
+
491
+ hi = lo + np.asarray(dims) * step
492
+ solid, origin = voxelize(mesh, step, box=(lo, hi))
493
+ if solid.shape != tuple(dims): # rounding at the last cell
494
+ out = np.zeros(dims, bool)
495
+ s = tuple(slice(0, min(a, b)) for a, b in zip(solid.shape, dims))
496
+ out[s] = solid[s]
497
+ solid = out
498
+ return _signed_distance(solid, step)
499
+
500
+
501
+ def _signed_distance(solid, step):
502
+ d = (ndimage.distance_transform_edt(~solid) - ndimage.distance_transform_edt(solid)) * step
503
+ return d + np.where(solid, 0.5 * step, -0.5 * step)
504
+
505
+
506
+ def _collar_distance(study: Study, pts, collar):
507
+ """Distance (negative inside) to the solid zones round everything the
508
+ user drew: preserved shapes, keep-outs (a bore's wall), and where loads
509
+ and supports attach. Nothing there should be lattice."""
510
+ shapes = [r.shape for r in study.preserve] + [r.shape for r in study.keep_out]
511
+ for s in study.supports:
512
+ shapes.append(s.shape if s.shape is not None else study.region_shape(s.region)[0])
513
+ for case in study.load_cases:
514
+ for ld in case.loads:
515
+ shapes.append(ld.shape if ld.shape is not None else study.region_shape(ld.region)[0])
516
+ d = np.full(len(pts), np.inf)
517
+ seen = set()
518
+ for s in shapes:
519
+ if id(s) in seen:
520
+ continue
521
+ seen.add(id(s))
522
+ d = np.minimum(d, s.sdf(pts) - collar)
523
+ return d
524
+
525
+
526
+ def _block_mean(a, r):
527
+ nx, ny, nz = (n // r for n in a.shape)
528
+ return a[:nx * r, :ny * r, :nz * r].reshape(nx, r, ny, r, nz, r).mean(axis=(1, 3, 5))
529
+
530
+
531
+ def _size(study: Study, o: LatticeOptions, lo, step, inside, solid_zone):
532
+ """Homogenized fully-stressed sizing on a grid r fine cells to the side.
533
+ Returns (rho per analysis cell, 1 = solid; the strain-equivalent stress
534
+ at full modulus, MPa; r; info)."""
535
+ from scipy.spatial import cKDTree
536
+
537
+ from .domain import boundary_conditions, gravity_force
538
+ from .fem import HexMesh, Solver, Stiffness, von_mises
539
+
540
+ mat = study.material
541
+ kind = o.kind
542
+ vol = inside.sum() * step ** 3
543
+ pitch = max(o.cell / 5.0, (vol / o.analysis_cells) ** (1.0 / 3.0))
544
+ r = max(1, int(round(pitch / step)))
545
+ pitch = r * step
546
+ f_in = _block_mean(inside.astype(np.float32), r)
547
+ f_solid = _block_mean((inside & solid_zone).astype(np.float32), r)
548
+ active = f_in >= 0.5
549
+ lbl, _ = ndimage.label(active)
550
+ ijk = np.argwhere(active)
551
+ hm = HexMesh(ijk, pitch, lo)
552
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
553
+ sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
554
+ keep = np.isin(lbl[tuple(ijk.T)], sup)
555
+ if not keep.all():
556
+ ijk = ijk[keep]
557
+ hm = HexMesh(ijk, pitch, lo)
558
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
559
+ fs = (f_solid[tuple(ijk.T)] / f_in[tuple(ijk.T)]).astype(float)
560
+ fc = 1.0 - fs
561
+ lattice_cell = fc > 0.05
562
+ rho_min = float(relative_density(kind, o.wall_min, o.cell))
563
+ rho_max = float(relative_density(kind, o.wall_max, o.cell))
564
+ rho = np.full(hm.n_elem, rho_min)
565
+ rho[~lattice_cell] = 1.0
566
+ stiff = Stiffness(hm, mat.nu, bc.fixed)
567
+ solver = Solver(hm, rtol=1e-6, maxiter=3000)
568
+ aniso = _use_tensors(o)
569
+ if aniso:
570
+ from .fem import elastic_c
571
+ from .homogenized import _center_strain, solve_anisotropic
572
+ from .lattice_tensors import stress_surface, tensors_at
573
+
574
+ c_solid = elastic_c(1.0, mat.nu)
575
+ # candidate densities a cell can be sized to, and their tensors
576
+ cand = np.geomspace(rho_min, rho_max, 28)
577
+ cand_t = tensors_at(kind, cand)
578
+ target = o.target_sf * 1.15 # verify's discretization allowance
579
+ allow = mat.strength / target
580
+ contact = bc.support_nodes.copy()
581
+ for ln in bc.case_load_nodes:
582
+ contact |= ln
583
+ band = 1.5 * pitch + o.collar
584
+ near = cKDTree(hm.node_xyz[contact]).query(hm.elem_centers(), k=1, distance_upper_bound=band)[0] < np.inf
585
+ # a stress concentration spreads over a cell before the lattice can
586
+ # follow it: grow each requirement over a quarter cell
587
+ grow = max(1, int(round(0.25 * o.cell / pitch)))
588
+ grid = np.zeros(active.shape)
589
+ history = []
590
+ analyzed = rho
591
+ for it in range(o.iterations):
592
+ e_cell = mat.E * (fs + fc * sheet_modulus(kind, rho))
593
+ fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else 0.0
594
+ rhs = [bc.loads[ci] + fg for ci in range(len(study.load_cases))]
595
+ if aniso:
596
+ c_lat = tensors_at(kind, rho)[0]
597
+ c_cell = mat.E * (fs[:, None, None] * c_solid + fc[:, None, None] * c_lat)
598
+ us = solve_anisotropic(hm, mat.nu, c_cell, bc, mat.E, rhs, proxy=e_cell)
599
+ need, wall_u, se = _tensor_need(kind, us, hm, mat, rho, rho_max, cand, cand_t, allow,
600
+ _center_strain, stress_surface, c_solid)
601
+ need = np.where(lattice_cell, need, 0.0)
602
+ grid[:] = 0.0
603
+ grid[tuple(ijk.T)] = need
604
+ need = ndimage.maximum_filter(grid, size=2 * grow + 1)[tuple(ijk.T)]
605
+ new = np.clip(np.maximum(need, 0.5 * np.minimum(rho, rho_max)), rho_min, None)
606
+ stay = (rho >= 1.0) & (need > 0.8 * rho_max)
607
+ new = np.where((new > rho_max) | stay, 1.0, np.minimum(new, rho_max))
608
+ new[~lattice_cell] = 1.0
609
+ wall_u = np.where(lattice_cell & (rho < 1.0), wall_u, 0.0)
610
+ solid_u = np.where(~lattice_cell | (rho >= 1.0), se, 0.0)
611
+ peak = float(np.max(np.where(near, 0.0, np.maximum(wall_u, solid_u))))
612
+ frac = float((fs + fc * rho).mean())
613
+ history.append({"iteration": it, "predictedSF": mat.strength / (1.15 * peak) if peak > 0 else np.inf,
614
+ "solidCells": float((rho >= 1).mean()), "fill": frac,
615
+ "compliance": float(sum(f @ u for f, u in zip(rhs, us)))})
616
+ short = history[-1]["predictedSF"] / o.target_sf
617
+ if it >= 2:
618
+ allow *= float(np.clip(short, 0.85, 1.15)) ** 0.5
619
+ if np.allclose(new, rho, atol=0.01) and abs(short - 1.0) < 0.05:
620
+ break
621
+ analyzed, rho = rho, new
622
+ continue
623
+ k = stiff.assemble(e_cell)
624
+ if bc.springs is not None:
625
+ k = (k + mat.E * bc.springs).tocsr()
626
+ us = solver.solve_many(k, rhs)
627
+ # stress at the solid's modulus for each cell's strain: what the
628
+ # skin carries, and E*/E of it is the lattice's macro stress
629
+ se = np.max([von_mises(stiff.stress(u, mat.E)) for u in us], axis=0)
630
+ sm = se * sheet_modulus(kind, rho)
631
+ # a solid cell is judged as the densest lattice under the same
632
+ # strain: whether it could go back to lattice (with some hysteresis,
633
+ # or cells flicker), not what it needs as solid, which would spread
634
+ # solid outwards a ring per pass
635
+ s = SHEETS[kind]
636
+ sm_eval = se * sheet_modulus(kind, np.minimum(rho, rho_max))
637
+ need = np.where(lattice_cell, (s["A"] * sm_eval / allow) ** (1.0 / s["m"]), 0.0)
638
+ grid[:] = 0.0
639
+ grid[tuple(ijk.T)] = need
640
+ need = ndimage.maximum_filter(grid, size=2 * grow + 1)[tuple(ijk.T)]
641
+ new = np.clip(np.maximum(need, 0.5 * np.minimum(rho, rho_max)), rho_min, None) # move limit
642
+ stay = (rho >= 1.0) & (need > 0.8 * rho_max)
643
+ new = np.where((new > rho_max) | stay, 1.0, np.minimum(new, rho_max))
644
+ new[~lattice_cell] = 1.0
645
+ wall_u = np.where(lattice_cell & (rho < 1.0), sheet_stress_factor(kind, rho) * sm, 0.0)
646
+ solid_u = np.where(~lattice_cell | (rho >= 1.0), se, 0.0)
647
+ peak = float(np.max(np.where(near, 0.0, np.maximum(wall_u, solid_u))))
648
+ frac = float((fs + fc * rho).mean())
649
+ history.append({"iteration": it, "predictedSF": mat.strength / (1.15 * peak) if peak > 0 else np.inf,
650
+ "solidCells": float((rho >= 1).mean()), "fill": frac,
651
+ "compliance": float(sum((bc.loads[ci] + fg) @ u for ci, u in enumerate(us)))})
652
+ # the sizing assumes each cell's macro stress stays put as it
653
+ # changes; when the analysis keeps reading under the target (the
654
+ # load moved onto what got stiffer), tighten the allowable to match
655
+ short = history[-1]["predictedSF"] / o.target_sf
656
+ if it >= 2:
657
+ allow *= float(np.clip(short, 0.85, 1.15)) ** 0.5
658
+ if np.allclose(new, rho, atol=0.01) and abs(short - 1.0) < 0.05:
659
+ break
660
+ analyzed, rho = rho, new
661
+ else:
662
+ rho = analyzed # report the field that was analyzed
663
+ rho_grid = np.zeros(active.shape)
664
+ rho_grid[tuple(ijk.T)] = rho
665
+ se_grid = np.zeros(active.shape)
666
+ se_grid[tuple(ijk.T)] = se
667
+ mask = np.zeros(active.shape, bool)
668
+ mask[tuple(ijk.T)] = True
669
+ info = {"analysisPitch": pitch, "analysisCells": int(hm.n_elem), "history": history,
670
+ "predictedSF": history[-1]["predictedSF"],
671
+ "solidCellFraction": float((rho[lattice_cell] >= 1).mean()) if lattice_cell.any() else 0.0}
672
+ return rho_grid, se_grid, mask, r, info
673
+
674
+
675
+ def _use_tensors(o) -> bool:
676
+ import os
677
+
678
+ from .lattice_tensors import has_tensors
679
+
680
+ on = o.anisotropic
681
+ if on is None:
682
+ on = os.environ.get("LOADPATH_LATTICE_ANISO", "1").strip() not in ("0", "false", "off")
683
+ return bool(on) and has_tensors(o.kind)
684
+
685
+
686
+ def _tensor_need(kind, us, hm, mat, rho, rho_max, cand, cand_t, allow, center_strain, stress_surface, c_solid):
687
+ """Fully-stressed sizing on the cell tensors: each cell's macro stress
688
+ (its lattice tensor at the density it was analyzed at, a solid cell as
689
+ the densest lattice) held while the density changes, the least
690
+ candidate density whose wall stress surface stays under `allow` in every
691
+ load case (log-interpolated between candidates; above rho_max where none
692
+ does). Also the walls' stress at the analyzed density and the solid's
693
+ von Mises, both worst over the cases."""
694
+ from .fem import von_mises
695
+ from .lattice_tensors import tensors_at
696
+
697
+ r_eval = np.minimum(rho, rho_max)
698
+ c_now, p_now, q_now, f_now = tensors_at(kind, r_eval)
699
+ Ck, Pk, Qk, Fk = cand_t
700
+ worst = np.zeros((len(rho), len(cand)))
701
+ wall_now = np.zeros(len(rho))
702
+ se = np.zeros(len(rho))
703
+ for u in us:
704
+ eps = center_strain(hm, u)
705
+ se = np.maximum(se, von_mises(mat.E * eps @ c_solid.T))
706
+ sig = mat.E * np.einsum("eab,eb->ea", c_now, eps)
707
+ wall_now = np.maximum(wall_now, stress_surface(sig, p_now, q_now, f_now))
708
+ for j in range(len(cand)):
709
+ worst[:, j] = np.maximum(worst[:, j], stress_surface(sig, Pk[j], Qk[j], Fk[j]))
710
+ ok = worst <= allow
711
+ first = np.where(ok.any(axis=1), ok.argmax(axis=1), len(cand))
712
+ need = np.full(len(rho), 1.5 * rho_max)
713
+ at = first < len(cand)
714
+ need[at] = cand[first[at]]
715
+ mid = at & (first > 0)
716
+ j = first[mid]
717
+ lw0, lw1 = np.log(worst[mid, j - 1]), np.log(worst[mid, j])
718
+ t = np.clip((lw0 - np.log(allow)) / np.maximum(lw0 - lw1, 1e-12), 0.0, 1.0)
719
+ need[mid] = np.exp(np.log(cand[j - 1]) + t * (np.log(cand[j]) - np.log(cand[j - 1])))
720
+ return need, wall_now, se
721
+
722
+
723
+ def _to_fine(coarse, mask, r, dims):
724
+ """Trilinear upsampling of a per-analysis-cell field to the fine grid,
725
+ the cells outside the analysis filled from their nearest neighbour."""
726
+ idx = ndimage.distance_transform_edt(~mask, return_distances=False, return_indices=True)
727
+ filled = coarse[tuple(idx)]
728
+ coords = [(np.arange(n) + 0.5) / r - 0.5 for n in dims]
729
+ out = np.empty(dims, np.float32)
730
+ gy, gz = np.meshgrid(coords[1], coords[2], indexing="ij")
731
+ for i, x in enumerate(coords[0]): # a slab at a time keeps the coordinates small
732
+ out[i] = ndimage.map_coordinates(filled, [np.full_like(gy, x), gy, gz], order=1, mode="nearest")
733
+ return out
734
+
735
+
736
+ def _capsule(pts, a, b, radius):
737
+ ab = b - a
738
+ t = np.clip(((pts - a) @ ab) / (ab @ ab), 0.0, 1.0)
739
+ return np.linalg.norm(pts - (a + t[:, None] * ab), axis=1) - radius
740
+
741
+
742
+ def _local(lo, step, shape, a, b, reach):
743
+ """The grid block round segment a-b out to `reach`, and its points."""
744
+ box_lo = np.floor((np.minimum(a, b) - reach - lo) / step - 1).astype(int).clip(0)
745
+ box_hi = np.ceil((np.maximum(a, b) + reach - lo) / step + 1).astype(int).clip(None, np.array(shape))
746
+ bs = tuple(slice(x0, x1) for x0, x1 in zip(box_lo, box_hi))
747
+ ii = np.stack(np.meshgrid(*[np.arange(x0, x1) for x0, x1 in zip(box_lo, box_hi)], indexing="ij"), -1)
748
+ return bs, lo + (ii.reshape(-1, 3) + 0.5) * step, ii.shape[:3]
749
+
750
+
751
+ def _drains(o, d, dp, d_collar, stress, lo, step, process):
752
+ """Pockets too small to drain (a mask, to fill solid), and drain holes
753
+ from every other enclosed void to the outside: (a, b, radius, report)
754
+ per hole, a running from inside the void out through the skin.
755
+
756
+ Holes go where the part is calmest, just under the skin on a flat face
757
+ clear of everything the user drew, two per void well apart so powder
758
+ can be blown and resin rinsed through. Each gets a solid boss."""
759
+ material = d < 0.0
760
+ lbl, n = ndimage.label(~material)
761
+ open_ids = set(np.unique(lbl[dp > 0.0]).tolist())
762
+ vox = step ** 3
763
+ sizes = ndimage.sum_labels(np.ones_like(lbl), lbl, index=np.arange(1, n + 1))
764
+ fill = o.fill_pockets if o.fill_pockets is not None else o.cell ** 3 / 8.0
765
+ trapped = [i for i in range(1, n + 1) if i not in open_ids]
766
+ small = [i for i in trapped if sizes[i - 1] * vox < fill]
767
+ pockets = np.isin(lbl, small) if small else None
768
+ big = [i for i in trapped if i not in small]
769
+ holes = []
770
+ if not big or o.drains == "no" or (o.drains == "auto" and (process is None or process.kind == "fdm")):
771
+ return pockets, holes
772
+ if o.drain_diameter is not None:
773
+ dia = o.drain_diameter
774
+ else:
775
+ from .processcheck import RULES
776
+
777
+ dia = getattr(process, "escape_hole", None) or \
778
+ (RULES[process.kind]["escapeHole"] if process.kind in RULES else 3.0 if process.kind == "volumetric" else 4.0)
779
+ rad = 0.5 * dia
780
+ axis, sign = process.build if process is not None and process.build is not None else (2, 1)
781
+ objs = ndimage.find_objects(lbl)
782
+ for i in big:
783
+ sl = objs[i - 1]
784
+ comp = lbl[sl] == i
785
+ depth = -dp[sl]
786
+ cand = comp & (depth < o.skin + 3.0 * step) & (d_collar[sl] > rad + o.collar + 1.0)
787
+ if not cand.any():
788
+ cand = comp & (depth <= depth[comp].min() + step)
789
+ idx = np.argwhere(cand)
790
+ sv = stress[sl][cand]
791
+ off = np.asarray([s_.start for s_ in sl])
792
+ # a hole is a stress raiser (about 3x in a plate): the calmest flat
793
+ # site first, then the calmest one well away from it, so air or
794
+ # rinse can flow through; on a tie, lower along the build drains
795
+ order = np.lexsort((idx[:, axis] * sign, np.round(sv, 1)))
796
+ reach = rad + max(o.collar, o.wall_max)
797
+ extent = float(np.linalg.norm((idx.max(axis=0) - idx.min(axis=0)) * step))
798
+ picks = []
799
+ for j in order[:2000]:
800
+ pt0 = lo + (off + idx[j] + 0.5) * step
801
+ if picks and np.linalg.norm(pt0 - picks[0][0]) < max(4.0 * dia, 0.5 * extent):
802
+ continue
803
+ site = _drain_site(dp, tuple(off + idx[j]), lo, step, reach)
804
+ if site is not None:
805
+ picks.append(site)
806
+ if len(picks) == 2:
807
+ break
808
+ for pt, nrm, g in picks:
809
+ b = pt + nrm * (-dp[g] + 1.5)
810
+ holes.append((pt, b, rad, {"at": (pt + nrm * -dp[g]).tolist(), "normal": nrm.tolist(),
811
+ "diameter": dia, "stress": float(stress[g])}))
812
+ return pockets, holes
813
+
814
+
815
+ def _drain_site(dp, g, lo, step, reach):
816
+ """The site's point and outward normal, if the surface over it is flat
817
+ out to `reach`: a hole on an edge or a corner loses half its boss to
818
+ the edge and is where the stress concentrates."""
819
+ nb = tuple(slice(max(c - 2, 0), c + 3) for c in g)
820
+ gr = np.array([np.gradient(dp[nb], step, axis=k).mean() for k in range(3)])
821
+ nrm = gr / max(np.linalg.norm(gr), 1e-9)
822
+ pt = lo + (np.asarray(g) + 0.5) * step
823
+ surf = pt + nrm * -dp[g]
824
+ u = np.cross(nrm, [1.0, 0.0, 0.0] if abs(nrm[0]) < 0.9 else [0.0, 1.0, 0.0])
825
+ u /= np.linalg.norm(u)
826
+ v = np.cross(nrm, u)
827
+ ang = np.linspace(0.0, 2.0 * np.pi, 12, endpoint=False)
828
+ ring = surf + reach * (np.cos(ang)[:, None] * u + np.sin(ang)[:, None] * v)
829
+ ijk = np.floor((ring - lo) / step).astype(int)
830
+ if (ijk < 0).any() or (ijk >= np.array(dp.shape)).any():
831
+ return None
832
+ if np.abs(dp[tuple(ijk.T)]).max() > 1.5 * step:
833
+ return None
834
+ return pt, nrm, g
835
+
836
+
837
+ def _trapped(d, dp, step):
838
+ lbl, n = ndimage.label(d >= 0.0)
839
+ open_ids = np.unique(lbl[dp > 0.0])
840
+ tr = (lbl > 0) & ~np.isin(lbl, open_ids)
841
+ return float(tr.sum() * step ** 3), int(len(np.setdiff1d(np.arange(1, n + 1), open_ids)))
842
+
843
+
844
+ def lattice_infill(study: Study, mesh, fields: dict | None = None, opts: LatticeOptions | None = None):
845
+ """Returns (trimesh.Trimesh, info). With `opts.target_sf` the wall is
846
+ sized by the homogenized analysis (see the module docstring); otherwise
847
+ `fields` is verify_part(..., fields=True)["fields"] for the solid part,
848
+ and without it the lattice is uniform at wall_min. `mesh` is the solid
849
+ to fill: a topology-optimized part, or `envelope_mesh(study)` to keep
850
+ the envelope's outer shape."""
851
+ import manifold3d as m3d
852
+ import trimesh
853
+ from skimage.measure import marching_cubes
854
+
855
+ from .reconstruct import _smooth_union
856
+ from .shapes import manifold_from_trimesh, trimesh_from_manifold
857
+
858
+ o = opts or LatticeOptions()
859
+ process = study.process
860
+ lo, hi = mesh.bounds
861
+ lo = lo - 2.0 * o.wall_max
862
+ hi = hi + 2.0 * o.wall_max
863
+ step = o.step or o.wall_min / 2.5
864
+ step = max(step, (float(np.prod(hi - lo)) / o.max_points) ** (1.0 / 3.0))
865
+ dims = tuple(np.maximum(np.ceil((hi - lo) / step).astype(int), 2))
866
+ d_part = _part_distance(mesh, step, lo, dims)
867
+ inside = d_part < 0
868
+
869
+ idx = np.stack(np.meshgrid(*[np.arange(n) for n in dims], indexing="ij"), -1).reshape(-1, 3)
870
+ pts = lo + (idx + 0.5) * step
871
+ del idx
872
+ flat_in = inside.ravel()
873
+ d_collar = np.full(len(pts), np.inf)
874
+ if o.collar > 0:
875
+ d_collar[flat_in] = _collar_distance(study, pts[flat_in], o.collar)
876
+ d_collar = d_collar.reshape(dims)
877
+ sizing = None
878
+ stress = np.zeros(dims, np.float32)
879
+ if o.target_sf:
880
+ zone = (d_part > -o.skin) | (d_collar < 0)
881
+ rho_c, se_c, mask, r, sizing = _size(study, o, lo, step, inside, zone)
882
+ rho = _to_fine(rho_c, mask, r, dims)
883
+ stress = _to_fine(se_c, mask, r, dims)
884
+ rho_max = float(relative_density(o.kind, o.wall_max, o.cell))
885
+ wall = np.clip(wall_for_density(o.kind, np.minimum(rho, rho_max), o.cell), o.wall_min, o.wall_max)
886
+ d_cells = _signed_distance(rho > 0.5 * (1.0 + rho_max), step)
887
+ util = None
888
+ else:
889
+ util = np.zeros(len(pts))
890
+ if fields is not None:
891
+ from scipy.spatial import cKDTree
892
+
893
+ vm = np.max([c["vonMises"] for c in fields["cases"]], axis=0)
894
+ allow = study.material.strength / o.safety_factor
895
+ tree = cKDTree(fields["nodes"])
896
+ dist, near = tree.query(pts[flat_in], k=4)
897
+ w = 1.0 / np.maximum(dist, 1e-6) ** 2
898
+ util[flat_in] = (w * vm[near]).sum(axis=1) / w.sum(axis=1) / allow
899
+ # wall from utilization, square-root graded so moderate stress already
900
+ # thickens it; solid above solid_above
901
+ u = np.clip(util / o.solid_above, 0.0, 1.0)
902
+ wall = (o.wall_min + (o.wall_max - o.wall_min) * np.sqrt(u)).reshape(dims)
903
+ d_cells = ((o.solid_above - util) * o.cell).reshape(dims)
904
+ stress = (util * study.material.strength / o.safety_factor).reshape(dims)
905
+ # marching cubes reads a 2.5-sample wall about 5% thin: a hair more
906
+ # keeps the thinnest wall at the process minimum
907
+ d_lat = (np.abs(tpms(o.kind, pts, o.cell)) - 0.5 * wall.ravel() - 0.15 * step).reshape(dims)
908
+ del pts
909
+ d_solid = np.minimum(d_cells, d_collar)
910
+ if o.skin > 0:
911
+ d_solid = np.minimum(d_solid, -d_part - o.skin)
912
+ blend = o.blend or o.wall_max
913
+ d = np.maximum(d_part, _smooth_union(d_lat, d_solid, blend))
914
+ # how much of d follows the lattice term (1 in the open lattice, fading
915
+ # through the blend, 0 in solid, skin, pockets and holes): the exact
916
+ # surface below corrects d by this much of the TPMS's own sampling error
917
+ sens = _lattice_share(d_lat, d_solid, d_part, blend) if o.exact_surface else None
918
+ del d_cells
919
+
920
+ # a boss can wall off a scrap of channel next to it: a second round
921
+ # fills or drains whatever the first left
922
+ filled = 0.0
923
+ all_holes = []
924
+ boss = max(o.collar, o.wall_max)
925
+ for _ in range(3):
926
+ pockets, holes = _drains(o, d, d_part, d_collar, stress, lo, step, process)
927
+ if pockets is not None:
928
+ d = np.where(pockets, -0.5 * step, d)
929
+ if sens is not None:
930
+ sens[pockets] = 0.0
931
+ filled += float(pockets.sum() * step ** 3)
932
+ if not holes:
933
+ break
934
+ for a, b, rad, _ in holes:
935
+ bs, hp, shp = _local(lo, step, d.shape, a, b, rad + boss + blend)
936
+ cap = _capsule(hp, a, b, rad).reshape(shp)
937
+ ds = np.minimum(d_solid[bs], cap - boss)
938
+ new = np.maximum(d_part[bs], _smooth_union(d_lat[bs], ds, blend))
939
+ if sens is not None:
940
+ sens[bs] = np.where(new < d[bs], _lattice_share(d_lat[bs], ds, d_part[bs], blend), sens[bs])
941
+ d[bs] = np.minimum(d[bs], new)
942
+ d_solid[bs] = ds
943
+ for a, b, rad, _ in all_holes + holes:
944
+ # through the boss's rounded end and on into the void
945
+ a = a - (b - a) / np.linalg.norm(b - a) * (boss + rad + blend)
946
+ bs, hp, shp = _local(lo, step, d.shape, a, b, rad)
947
+ cut = -_capsule(hp, a, b, rad).reshape(shp)
948
+ if sens is not None:
949
+ sens[bs] = np.where(cut > d[bs], 0.0, sens[bs])
950
+ d[bs] = np.maximum(d[bs], cut)
951
+ all_holes += holes
952
+ holes = all_holes
953
+ holes = [h[3] for h in holes]
954
+ del d_solid
955
+ trapped_vol, trapped_n = _trapped(d, d_part, step)
956
+
957
+ if o.exact_surface:
958
+ lat = _exact_mesh(o, d, d_lat, sens, wall, lo, step)
959
+ else:
960
+ verts, faces, _, _ = marching_cubes(d, 0.0, spacing=(step, step, step))
961
+ verts = verts + lo + 0.5 * step
962
+ lat = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
963
+ del d_lat, sens
964
+ if lat.volume < 0:
965
+ lat.invert()
966
+ part = manifold_from_trimesh(lat)
967
+ solid_vol = float(manifold_from_trimesh(mesh).volume())
968
+ # the user's geometry stays exact: preserved shapes solid, keep-outs clear
969
+ if study.preserve:
970
+ part = part + m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.preserve],
971
+ m3d.OpType.Add)
972
+ part = part ^ manifold_from_trimesh(mesh)
973
+ if study.keep_out:
974
+ part = part - m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.keep_out],
975
+ m3d.OpType.Add)
976
+ # Drop lattice fragments the trim cut loose. (decompose() also returns
977
+ # each enclosed cavity, as a negative volume: those stay.)
978
+ pieces = part.decompose()
979
+ big = max(b.volume() for b in pieces)
980
+ loose = [b for b in pieces if 0 < b.volume() < 0.01 * big]
981
+ for b in loose:
982
+ part = part - b
983
+ out = trimesh_from_manifold(part)
984
+ vol = float(part.volume())
985
+ info = {"kind": o.kind, "cell": o.cell, "skin": o.skin, "wallMin": o.wall_min, "wallMax": o.wall_max,
986
+ "step": step, "volume": vol, "solidVolume": solid_vol, "volumeRatio": vol / solid_vol,
987
+ "mass": vol * study.material.density / 1000.0, "graded": fields is not None or bool(o.target_sf),
988
+ "sizing": sizing, "droppedBodies": len(loose),
989
+ "solidFraction": float((util[flat_in] >= o.solid_above).mean()) if util is not None and flat_in.any()
990
+ else (sizing or {}).get("solidCellFraction", 0.0)}
991
+ info["printability"] = _printability(out, d, d_part, lo, step, o, process, holes, filled, trapped_vol, trapped_n)
992
+ # what stays walled in, where powder or resin would be trapped
993
+ info["enclosedVoid"] = trapped_vol
994
+ return out, info
995
+
996
+
997
+ def _lattice_share(d_lat, d_solid, d_part, blend, eps=1e-3):
998
+ """d(d) / d(d_lat) for d = max(d_part, smooth_union(d_lat, d_solid))."""
999
+ from .reconstruct import _smooth_union
1000
+
1001
+ su = _smooth_union(d_lat, d_solid, blend)
1002
+ share = (_smooth_union(d_lat + eps, d_solid, blend) - su) / eps
1003
+ return np.where(su >= d_part, share, 0.0).astype(np.float32)
1004
+
1005
+
1006
+ def _exact_mesh(o, d, d_lat, sens, wall, lo, step):
1007
+ """The lattice part meshed from its field with the TPMS evaluated
1008
+ exactly: the sampled field d, trilinear between samples, plus `sens`
1009
+ times the TPMS term's own error there (exact sheet minus its trilinear
1010
+ samples, which also drops the samples' 0.15 step allowance for
1011
+ marching cubes reading walls thin). The slow parts (part distance,
1012
+ collars, sizing, drains) stay on the grid; the sheet, which is what a
1013
+ grid gets wrong (facets, walls ~0.03 mm off), comes out on its exact
1014
+ surface, projected by fields.field_mesh."""
1015
+ from .fields import field_mesh
1016
+
1017
+ wall = np.broadcast_to(np.asarray(wall, np.float32), d.shape)
1018
+ grids = [np.asarray(g, np.float32) for g in (d, d_lat, sens, wall)]
1019
+ lo = np.asarray(lo, float)
1020
+
1021
+ def f(P):
1022
+ c = ((P - lo) / step - 0.5).T
1023
+ di, li, si, wi = (ndimage.map_coordinates(g, c, order=1, mode="nearest") for g in grids)
1024
+ le = np.abs(tpms(o.kind, P, o.cell)) - 0.5 * wi
1025
+ return di + si * (le - li)
1026
+
1027
+ hi = lo + (np.array(d.shape) - 0.5) * step
1028
+ return field_mesh(f, lo + 0.5 * step, hi, pitch=step)
1029
+
1030
+
1031
+ def _wall_thickness(d, step):
1032
+ """(p1, p5) wall thickness, mm, from the field itself: twice the depth of
1033
+ the medial ridge, interpolated by the field's own distance there. No ray
1034
+ casts: on a lattice mesh of millions of faces, trimesh's ray fallback
1035
+ (without embree) runs out of memory."""
1036
+ depth = np.maximum(-d, 0.0)
1037
+ ridge = (depth > 0) & (depth >= ndimage.maximum_filter(depth, size=3))
1038
+ vals = 2.0 * depth[ridge] + 0.5 * step
1039
+ if vals.size == 0:
1040
+ return None, None
1041
+ return float(np.percentile(vals, 1)), float(np.percentile(vals, 5))
1042
+
1043
+
1044
+ def _printability(mesh, d, dp, lo, step, o, process, holes, filled, trapped_vol, trapped_n):
1045
+ p1, p5 = _wall_thickness(d, step)
1046
+ min_feature = process.min_feature if process is not None else 0.8
1047
+ rep = {"process": process.kind if process is not None else None, "minFeature": min_feature,
1048
+ "wallP1": p1, "wallP5": p5, "wallOk": p1 is not None and p1 >= 0.9 * min_feature,
1049
+ "drainHoles": holes, "filledPocketVolume": filled,
1050
+ "trappedVolume": trapped_vol, "trappedVoids": trapped_n}
1051
+ if process is None or process.kind != "fdm":
1052
+ rep["drainOk"] = trapped_vol < 1.0
1053
+ # the lattice's own ceilings and the skin's over it (inside the part): FDM
1054
+ # bridges these, so report how much faces down past the 45 degree limit
1055
+ if process is not None and process.build is not None:
1056
+ axis, sign = process.build
1057
+ up = np.zeros(3)
1058
+ up[axis] = sign
1059
+ c = mesh.triangles_center
1060
+ ijk = np.clip(((c - lo) / step).astype(int), 0, np.array(dp.shape) - 1)
1061
+ interior = dp[tuple(ijk.T)] < -(0.5 * o.skin + step)
1062
+ down = -(mesh.face_normals @ up) > np.cos(np.radians(40.0))
1063
+ a = mesh.area_faces
1064
+ rep["internalOverhangFraction"] = float(a[interior & down].sum() / max(a[interior].sum(), 1e-9))
1065
+ return rep