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/heatx.py ADDED
@@ -0,0 +1,1067 @@
1
+ """
2
+ Two-fluid heat exchangers from a sheet TPMS, in any core shape.
3
+
4
+ A sheet gyroid (or diamond, Schwarz P) splits space into two interleaved
5
+ channel networks that never meet: one on each side of the sheet. Give each
6
+ network its own fluid and the sheet is the wall heat crosses, with a very
7
+ large area per volume and no straight path for either flow to short-circuit.
8
+ The work is in the headers: each fluid has to reach its own network and only
9
+ that one.
10
+
11
+ The core is any closed shape: a box, a cylinder, a sphere, a union, or a
12
+ mesh (anything with a signed distance). Its surface is split into zones,
13
+ two per fluid (in and out), by the direction the surface faces, optionally
14
+ within a window along an axis:
15
+
16
+ - "cross": fluid A enters through the faces looking along -axes[0] and
17
+ leaves through those looking along +axes[0]; fluid B the same along
18
+ axes[1]. On a box these are its faces; on a cylinder with axes "zx", A's
19
+ zones are the end caps and B's the two sides.
20
+ - "counter": A through the ends along axes[0]; B through the side (facing
21
+ -axes[1]) near A's outlet and out through the side (facing +axes[1]) near
22
+ A's inlet, so the two run against each other, the arrangement of a
23
+ printed shell-and-tube.
24
+ - or the zones themselves, as a list.
25
+
26
+ Zones are computed on the surface, with each surface point's lateral
27
+ distance to its zone's edge (ℓ, measured along the surface), and carried to
28
+ every point in space through its nearest surface point. Then:
29
+
30
+ - each fluid's network is sealed within `cap` of the surface everywhere
31
+ outside its own zones, so the other fluid's plenum only sees its own
32
+ channels' mouths;
33
+ - each zone gets a plenum: the space over the zone out to `header` from the
34
+ surface, inset `cap` from the zone's edge, its edges leaning in at 50
35
+ degrees from level (more along a top edge over surface tilted up) so they
36
+ never make a flat ceiling;
37
+ - a zone that faces up or down (along `build`) gets a vertical cone at 50
38
+ degrees instead, over a level circle inside the zone (so a tilted top
39
+ works too), rising (or falling) to its port: no plenum needs support in
40
+ FDM, an upward port is the top of a roof and a downward one the foot the
41
+ part stands on;
42
+ - sideways zones keep only surface facing at most 20 degrees above level
43
+ (a roof over more would lean over its fluid), their ports run level with
44
+ a teardrop bore and a 45 degree gusset under the tube, and with `skirt`
45
+ everything that overhangs the outside gets a 45 degree fill to the bed;
46
+ - each port is a tube on the zone's centre (its point farthest from the
47
+ edge);
48
+ - with `closure` (default, with a build axis), the channels never end on a
49
+ flat ceiling: under core surface that looks up they close over a gentle
50
+ ramp into peaked roofs, and over a plenum below the core they flare open,
51
+ so what hangs between the mouths leans (`report["internalCeilings"]`
52
+ measures what is left; in powder-bed metal, `process: "lpbf"`, nothing
53
+ inside can be supported, so it should be near zero);
54
+ - with `blend` (default a quarter cell), each plenum joins its channels by
55
+ a smooth union, so every channel mouth opens as a rounded
56
+ bellmouth instead of the sheet's square-cut end, never closer than a
57
+ wall to the other fluid.
58
+
59
+ Built implicitly on one grid (distances in mm, negative = solid), meshed by
60
+ marching cubes, then checked on that same grid: the fluid spaces are labeled,
61
+ each port must reach its partner and no port of the other fluid, the
62
+ thinnest walls are measured (between the fluids and to the outside), and
63
+ the overhangs a printer would have to bridge are counted.
64
+
65
+ hx = HeatExchangerSpec(core=Cylinder((0, 0, 0), (0, 0, 60), 20), axes=(2, 0), layout="counter")
66
+ mesh, report = design_heat_exchanger(hx)
67
+
68
+ Units are millimetres.
69
+ """
70
+ from __future__ import annotations
71
+
72
+ from dataclasses import dataclass, field
73
+
74
+ import numpy as np
75
+ from scipy import ndimage
76
+
77
+ from .lattice import tpms
78
+ from .reconstruct import _smooth_union
79
+ from .shapes import Box, Cylinder, Shape, shape_from_spec
80
+
81
+ __all__ = ["HeatExchangerSpec", "Zone", "design_heat_exchanger", "grid_plan", "heat_exchanger_from_spec",
82
+ "zones_for"]
83
+
84
+ _SQRT2 = np.sqrt(2.0)
85
+ # the design's peak memory: about this many bytes per grid point (measured on the
86
+ # design with fluid spaces and grids, 3-6M points: 350-390 B), plus the interpreter
87
+ _BYTES_PER_POINT = 400.0
88
+ _BASE_BYTES = 0.25e9
89
+ # report warnings, per process: an internal ceiling area (mm^2, faces within 20 degrees
90
+ # of level over a fluid) and trapped open space (mm^3) past which powder-bed metal
91
+ # needs a look (the eval briefs' limits)
92
+ _LPBF_FLAT_CEILING = 300.0
93
+ _LPBF_TRAPPED = 20.0
94
+ # plenum edges and roofs lean at 55 degrees from level, a margin on FDM's 45 (an edge's
95
+ # lean is set on the surface, and spreads out over a convex one)
96
+ _LEAN = 55.0
97
+ # a sideways zone keeps the surface facing at most 20 degrees above level
98
+ _MAX_UP = np.sin(np.radians(20.0))
99
+
100
+
101
+ @dataclass
102
+ class Zone:
103
+ """Where one fluid enters or leaves the core: the surface facing within
104
+ acos(min_cos) of `direction`, optionally only where the position along
105
+ `window[0]` (a unit vector) lies between the fractions window[1] and
106
+ window[2] of the core's extent along it. Where zones overlap, each
107
+ surface point goes to the zone it faces most."""
108
+ fluid: int
109
+ end: str # "in" or "out"
110
+ direction: np.ndarray
111
+ min_cos: float = 0.5
112
+ window: tuple | None = None
113
+
114
+
115
+ @dataclass
116
+ class HeatExchangerSpec:
117
+ core: Shape
118
+ cell: float = 10.0 # mm per TPMS period
119
+ wall: float = 0.8 # mm, the sheet between the fluids
120
+ kind: str = "gyroid"
121
+ shell: float = 1.6 # mm, outer walls
122
+ header: float = 6.0 # mm, plenum depth over the surface
123
+ cap: float | None = None # mm, sealing depth under the other fluid's zones (default: wall + 1 mm)
124
+ blend: float | None = None # mm, smooth-union radius from plenum into its channels (default cell / 4; 0: sharp)
125
+ port_diameter: float = 8.0 # mm; or one per fluid, (A, B): an air side wants a far bigger port
126
+ port_length: float = 8.0
127
+ axes: tuple = (0, 1) # cross: A along axes[0], B along axes[1]; counter: A along axes[0], B's side ports along axes[1]
128
+ layout: str = "cross"
129
+ zones: list | None = None # explicit zones, instead of the layout's
130
+ build: int | None = 2 # build axis for self-supporting roofs (None: no roof shaping)
131
+ skirt: bool = True # with build: fill under the outside at 45 degrees to the bed
132
+ process: str = "fdm" # "fdm" or "lpbf" (powder-bed metal: no skirt, ceilings must self-support)
133
+ split: float = 0.5 # fluid A's share of the void (the sheet moves toward B)
134
+ closure: bool = True # with build: channels close gently under surfaces that look up, and flare open over plenums below
135
+ cleanouts: int = 0 # powder clean-out ports per fluid, at the points farthest from its ports (plugged after)
136
+ cleanout_diameter: float = 4.2 # mm: an M5 tap drill
137
+ phase: tuple = (0.0, 0.0, 0.0) # mm: shifts the TPMS against the core's low corner (where its faces cut the channels)
138
+ step: float | None = None # grid, mm (default wall / 3)
139
+ max_points: int = 16_000_000
140
+ names: tuple = ("A", "B")
141
+ extra: dict = field(default_factory=dict)
142
+
143
+
144
+ def port_diameter(hx, fluid: int) -> float:
145
+ """The port bore of fluid 0 or 1 (mm)."""
146
+ d = hx.port_diameter
147
+ return float(d[fluid]) if isinstance(d, (tuple, list)) else float(d)
148
+
149
+
150
+ def _max_port(hx) -> float:
151
+ return max(port_diameter(hx, 0), port_diameter(hx, 1))
152
+
153
+
154
+ def _unit(v):
155
+ if isinstance(v, str):
156
+ sign = -1.0 if v.startswith("-") else 1.0
157
+ e = np.zeros(3)
158
+ e["xyz".index(v.lstrip("+-"))] = sign
159
+ return e
160
+ if isinstance(v, (int, np.integer)):
161
+ return np.eye(3)[int(v)]
162
+ a = np.asarray(v, float).reshape(3)
163
+ return a / np.linalg.norm(a)
164
+
165
+
166
+ def zones_for(hx: HeatExchangerSpec) -> list:
167
+ """The surface zones: hx.zones as given, else the layout's."""
168
+ if hx.zones:
169
+ for f in (0, 1):
170
+ ends = sorted(z.end for z in hx.zones if z.fluid == f)
171
+ if ends != ["in", "out"]:
172
+ raise ValueError(f"fluid {hx.names[f]} needs one \"in\" and one \"out\" zone, got {ends}")
173
+ return hx.zones
174
+ ea, eb = _unit(hx.axes[0]), _unit(hx.axes[1])
175
+ if hx.layout == "cross":
176
+ return [Zone(0, "in", -ea), Zone(0, "out", ea), Zone(1, "in", -eb), Zone(1, "out", eb)]
177
+ if hx.layout == "counter":
178
+ # A along ea through the ends; B in on one side near A's outlet and
179
+ # out on the other side near A's inlet
180
+ return [Zone(0, "in", -ea, 0.7), Zone(0, "out", ea, 0.7),
181
+ Zone(1, "in", -eb, 0.4, (ea, 0.55, 1.0)), Zone(1, "out", eb, 0.4, (ea, 0.0, 0.45))]
182
+ raise ValueError(f"heatExchanger.layout must be \"cross\" or \"counter\", got {hx.layout!r}")
183
+
184
+
185
+ def heat_exchanger_from_spec(spec: dict, base_dir=None) -> HeatExchangerSpec:
186
+ core = spec.get("core")
187
+ if not isinstance(core, dict):
188
+ raise ValueError("heatExchanger.core is a shape: a box, cylinder, sphere, union or mesh, "
189
+ "e.g. {\"type\": \"cylinder\", \"center\": [0, 0, 30], \"axis\": \"z\", \"length\": 60, \"r\": 20}")
190
+ hx = HeatExchangerSpec(core=shape_from_spec(core, base_dir))
191
+ for key, attr in (("cell", "cell"), ("wall", "wall"), ("shell", "shell"), ("header", "header"),
192
+ ("cap", "cap"), ("blend", "blend"), ("portLength", "port_length")):
193
+ if key in spec:
194
+ setattr(hx, attr, float(spec[key]))
195
+ if "portDiameter" in spec:
196
+ pd = spec["portDiameter"]
197
+ if isinstance(pd, dict):
198
+ names = tuple(str(n) for n in spec.get("fluids", hx.names))
199
+ if set(pd) != set(names):
200
+ raise ValueError(f"heatExchanger.portDiameter per fluid names each of {list(names)}, e.g. "
201
+ f"{{\"{names[0]}\": 8, \"{names[1]}\": 14}}")
202
+ hx.port_diameter = tuple(float(pd[n]) for n in names)
203
+ else:
204
+ hx.port_diameter = float(pd)
205
+ if "phase" in spec:
206
+ ph = spec["phase"]
207
+ if not (isinstance(ph, (list, tuple)) and len(ph) == 3):
208
+ raise ValueError("heatExchanger.phase is a shift of the TPMS in mm, [x, y, z]")
209
+ hx.phase = tuple(float(v) for v in ph)
210
+ if "type" in spec:
211
+ hx.kind = str(spec["type"])
212
+ if "layout" in spec:
213
+ hx.layout = str(spec["layout"])
214
+ if "axes" in spec:
215
+ hx.axes = tuple("xyz".index(a) for a in spec["axes"])
216
+ if len(set(hx.axes)) != 2:
217
+ raise ValueError("heatExchanger.axes names two different axes, e.g. \"xy\"")
218
+ if "fluids" in spec:
219
+ hx.names = tuple(str(n) for n in spec["fluids"])
220
+ if len(hx.names) != 2 or hx.names[0] == hx.names[1]:
221
+ raise ValueError("heatExchanger.fluids names two different fluids, e.g. [\"oil\", \"water\"]")
222
+ if "build" in spec:
223
+ b = spec["build"]
224
+ if b not in (None, False, "none", "x", "y", "z"):
225
+ raise ValueError("heatExchanger.build is the axis pointing up while printing: \"x\", \"y\" or \"z\" "
226
+ "(or null for no print shaping); turn the core to print it another way up")
227
+ hx.build = None if b in (None, False, "none") else "xyz".index(b)
228
+ if "process" in spec:
229
+ hx.process = str(spec["process"])
230
+ if hx.process not in ("fdm", "lpbf"):
231
+ raise ValueError("heatExchanger.process is \"fdm\" or \"lpbf\" (laser powder-bed metal)")
232
+ if hx.process == "lpbf":
233
+ # supports outside a metal part are cut off anyway; a skirt is only weight
234
+ hx.skirt = False
235
+ if "skirt" in spec:
236
+ hx.skirt = bool(spec["skirt"])
237
+ if "closure" in spec:
238
+ hx.closure = bool(spec["closure"])
239
+ if "split" in spec:
240
+ hx.split = float(spec["split"])
241
+ if not 0.05 < hx.split < 0.95:
242
+ raise ValueError("heatExchanger.split is the first fluid's share of the void, between 0.05 and 0.95")
243
+ if "cleanouts" in spec:
244
+ hx.cleanouts = int(spec["cleanouts"])
245
+ if "cleanoutDiameter" in spec:
246
+ hx.cleanout_diameter = float(spec["cleanoutDiameter"])
247
+ if "zones" in spec:
248
+ zs = []
249
+ for z in spec["zones"]:
250
+ try:
251
+ fl = hx.names.index(z["fluid"]) if isinstance(z["fluid"], str) else int(z["fluid"])
252
+ win = z.get("window")
253
+ if win is not None:
254
+ win = (_unit(win["axis"]), float(win.get("from", 0.0)), float(win.get("to", 1.0)))
255
+ zs.append(Zone(fl, str(z["end"]), _unit(z["direction"]), float(z.get("minCos", 0.5)), win))
256
+ except (KeyError, ValueError) as e:
257
+ raise ValueError(f"heatExchanger.zones entries are {{\"fluid\": \"A\", \"end\": \"in\", "
258
+ f"\"direction\": \"-z\", \"minCos\"?: 0.5, \"window\"?: {{\"axis\": \"z\", "
259
+ f"\"from\": 0, \"to\": 0.4}}}}: {e}") from None
260
+ hx.zones = zs
261
+ if hx.cell < 3.0 * hx.wall:
262
+ raise ValueError("heatExchanger.cell must be at least 3 x wall, or the channels close")
263
+ zones_for(hx) # checks explicit zones
264
+ return hx
265
+
266
+
267
+ def _grid(lo, hi, step):
268
+ dims = np.maximum(np.ceil((hi - lo) / step).astype(int), 2)
269
+ idx = np.stack(np.meshgrid(*[np.arange(n) for n in dims], indexing="ij"), -1).reshape(-1, 3)
270
+ return lo + (idx + 0.5) * step, dims
271
+
272
+
273
+ def _grid_points(lo, dims, step):
274
+ """The points of a grid already sized (dims), in _grid's order: rebuilding
275
+ it from lo + dims * step can round to one more cell."""
276
+ idx = np.stack(np.meshgrid(*[np.arange(n) for n in dims], indexing="ij"), -1).reshape(-1, 3)
277
+ return lo + (idx + 0.5) * step
278
+
279
+
280
+ def _surface_zones(hx, zones, pitch, lo, hi, up=None):
281
+ """On a coarse grid: label the core's surface by zone and give every grid
282
+ point its nearest surface point's lateral distance ℓ to each zone's edge
283
+ (mm, positive inside the zone). Returns (ℓ grids, grid origin, pitch,
284
+ per-zone info). With `up`, a zone that faces sideways keeps only the
285
+ surface facing level or down: a plenum over a surface that faces up
286
+ would have a roof leaning over its fluid, and its top edge a ceiling."""
287
+ pts, dims = _grid(lo, hi, pitch)
288
+ d = hx.core.sdf(pts).reshape(dims)
289
+ n = np.stack(np.gradient(d, pitch), -1)
290
+ n /= np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-9)
291
+ surf = np.abs(d) < 0.75 * pitch
292
+ # the core's distance far from its surface, from its inside/outside on this grid: a mesh
293
+ # core's own field is only good near its surface
294
+ inside = d < 0
295
+ far = (ndimage.distance_transform_edt(~inside) - ndimage.distance_transform_edt(inside)) * pitch
296
+ far -= 0.5 * pitch * np.sign(far)
297
+ sp, sn = pts.reshape(*dims, 3)[surf], n[surf]
298
+ clo, chi = hx.core.bounds()
299
+ score = np.full((len(sp), len(zones)), -np.inf)
300
+ for k, z in enumerate(zones):
301
+ s = sn @ z.direction
302
+ ok = s >= z.min_cos
303
+ if up is not None and abs(float(z.direction @ up)) <= 0.6:
304
+ ok &= sn @ up <= _MAX_UP
305
+ if z.window is not None:
306
+ ax, t0, t1 = z.window
307
+ # the core's extent along the window axis, from its bounding box corners
308
+ corners = np.array([[a, b, c] for a in (clo[0], chi[0]) for b in (clo[1], chi[1])
309
+ for c in (clo[2], chi[2])]) @ ax
310
+ proj_lo, proj_hi = corners.min(), corners.max()
311
+ t = (sp @ ax - proj_lo) / (proj_hi - proj_lo)
312
+ ok &= (t >= t0) & (t <= t1)
313
+ score[ok, k] = s[ok]
314
+ best = np.argmax(score, axis=1)
315
+ assigned = np.isfinite(score[np.arange(len(sp)), best])
316
+ lab = np.full(dims, -1)
317
+ lab[surf] = np.where(assigned, best, -1)
318
+ # nearest surface point of every grid point
319
+ near = ndimage.distance_transform_edt(~surf, return_distances=False, return_indices=True)
320
+ ell, slopes, info = [], [], []
321
+ for k, z in enumerate(zones):
322
+ zk = lab == k
323
+ if not zk.any():
324
+ raise ValueError(f"zone {k} ({z.end} of fluid {hx.names[z.fluid]}, facing "
325
+ f"{np.round(z.direction, 2).tolist()}) covers none of the core's surface; "
326
+ "check axes or the zone's direction and window")
327
+ inside = ndimage.distance_transform_edt(~(surf & ~zk)) * pitch
328
+ outside = ndimage.distance_transform_edt(~zk) * pitch
329
+ ls = np.where(zk, inside - 0.5 * pitch, -(outside - 0.5 * pitch))
330
+ lk = ls[near[0], near[1], near[2]]
331
+ lk = ndimage.gaussian_filter(lk, 1.0)
332
+ ell.append(lk)
333
+ if up is not None:
334
+ # how steeply this zone's plenum edge must lean out (mm in per mm out): 50
335
+ # degrees from level on a level surface, more where the edge is a top edge
336
+ # over a surface that faces up a little
337
+ ge = np.stack(np.gradient(lk, pitch), -1)
338
+ top_edge = np.clip(-(ge @ up) / np.maximum(np.linalg.norm(ge, axis=-1), 1e-9), 0.0, 1.0)
339
+ tilt = np.degrees(np.arcsin(np.clip(n[near[0], near[1], near[2]] @ up, 0.0, 1.0)))
340
+ slopes.append(np.tan(np.radians(np.minimum(_LEAN + top_edge * tilt, 75.0))))
341
+ # the zone's centre: its surface point farthest from the edge
342
+ # (the middle of its ridge, so a rectangle's port sits on its centre)
343
+ vals = np.where(zk, inside, -1.0)
344
+ top = vals >= vals.max() - pitch
345
+ c = (lo + (np.argwhere(top) + 0.5) * pitch).mean(axis=0)
346
+ for _ in range(3): # onto the surface
347
+ g = np.stack([hx.core.sdf((c + e * 1e-3)[None])[0] - hx.core.sdf((c - e * 1e-3)[None])[0]
348
+ for e in np.eye(3)]) / 2e-3
349
+ nc = g / np.linalg.norm(g)
350
+ c = c - hx.core.sdf(c[None])[0] * nc
351
+ i = tuple(np.clip(np.floor((c - lo) / pitch).astype(int), 0, dims - 1))
352
+ mean_n = n[zk].sum(axis=0)
353
+ mean_n /= np.linalg.norm(mean_n)
354
+ info.append({"center": c, "normal": nc, "meanNormal": mean_n, "ellMax": float(vals[top].mean() - 0.5 * pitch),
355
+ "surface": (sp, sn)})
356
+ return ell, slopes, far, lo, pitch, info
357
+
358
+
359
+ def design_heat_exchanger(hx: HeatExchangerSpec, fluid_meshes: bool = False, grids: bool = False):
360
+ """Returns (trimesh.Trimesh, report), and with `fluid_meshes` a third
361
+ item: each fluid's space as a mesh, by fluid name (for renders and
362
+ flow simulation). With `grids`, a last item: each fluid's space on the
363
+ design grid ({"masks": (A, B) bool arrays, "lo", "step", "dims"}), for
364
+ the whole-exchanger simulation (porous.py)."""
365
+ import trimesh
366
+ from skimage.measure import marching_cubes
367
+
368
+ zones = zones_for(hx)
369
+ blend = hx.cell * 0.25 if hx.blend is None else float(hx.blend)
370
+ cap = hx.cap if hx.cap is not None else hx.wall + 1.0
371
+ clo, chi = (np.asarray(b, float) for b in hx.core.bounds())
372
+ step = hx.step or hx.wall / 3.0
373
+
374
+ # zones on a coarse grid that reaches as far as any roof could
375
+ span = float((chi - clo).max())
376
+ reach_c = max(hx.header, 0.5 * span) + hx.shell + hx.port_length + 6.0
377
+ pitch = max(2.0 * step, 0.4, (float(np.prod(chi - clo + 2 * reach_c)) / 6e6) ** (1.0 / 3.0))
378
+ up = _unit(hx.build) if hx.build is not None else None
379
+ ell_grids, slope_grids, far, c_lo, pitch, zinfo = _surface_zones(hx, zones, pitch, clo - reach_c, chi + reach_c, up)
380
+
381
+ # roofs: a zone facing up along `build` gets a cone at 55 degrees rising to its port, a
382
+ # zone facing down the same cone upside down, standing on its port
383
+ warnings = []
384
+ tan_lean = np.tan(np.radians(_LEAN))
385
+ for k, (z, zi) in enumerate(zip(zones, zinfo)):
386
+ zi["portDiameter"] = port_diameter(hx, z.fluid)
387
+ r_port = 0.5 * zi["portDiameter"]
388
+ pyramid = up is not None and abs(float(zi["meanNormal"] @ up)) > 0.6
389
+ if pyramid:
390
+ s = np.sign(float(zi["meanNormal"] @ up))
391
+ # a level circle inside the zone, under a vertical cone: on a tilted top the
392
+ # circle's rim stays inside the zone, and the cone's walls stay at 55 degrees
393
+ cos_tilt = min(abs(float(zi["normal"] @ up)), 1.0)
394
+ tan_tilt = np.sqrt(max(1.0 - cos_tilt ** 2, 0.0)) / max(cos_tilt, 1e-6)
395
+ rad = (zi["ellMax"] - cap) * cos_tilt
396
+ if rad < r_port + 1.0:
397
+ raise ValueError(f"zone {hx.names[z.fluid]}-{z.end} is too small for a "
398
+ f"{zi['portDiameter']:g} mm port under a 55 degree roof; use a smaller port")
399
+ h = rad * tan_lean
400
+ # the cone's walls reach down (toward the core) to the lowest surface under them
401
+ # that faces the zone's way, so they always close on the core
402
+ sp, sn = zi["surface"]
403
+ rel = sp - zi["center"]
404
+ hs = s * (rel @ up)
405
+ under = (np.linalg.norm(rel - np.outer(hs * s, up), axis=1) <= rad + hx.shell + pitch) & \
406
+ (s * (sn @ up) > -0.2) & (hs > -rad * max(tan_tilt, 1.0) - 2 * hx.shell - span)
407
+ low = min(float(hs[under].min()) if under.any() else 0.0, -rad * tan_tilt) - hx.shell - 1.0
408
+ zi.update(sign=s, radius=rad, low=low)
409
+ a = s * up
410
+ p0 = zi["center"] + a * 1.0
411
+ zi["port"] = (p0, zi["center"] + a * (h + hx.shell + hx.port_length))
412
+ zi["exit"] = zi["center"] + a * h
413
+ else:
414
+ h = hx.header
415
+ if zi["ellMax"] - cap - 0.5 * h < r_port:
416
+ warnings.append(f"{hx.names[z.fluid]}-{z.end}: the {zi['portDiameter']:g} mm port is wider than "
417
+ f"the plenum at its centre ({2 * (zi['ellMax'] - cap - 0.5 * h):.1f} mm); "
418
+ "use a smaller port or a larger zone")
419
+ a = zi["normal"]
420
+ p0 = zi["center"] + a * 0.5 * h
421
+ if up is not None:
422
+ # sideways ports run level, for printing and plumbing alike
423
+ a = a - (a @ up) * up
424
+ a /= np.linalg.norm(a)
425
+ reach_out = 0.5 * h / max(float(a @ zi["normal"]), 0.3)
426
+ zi["port"] = (p0, p0 + a * (reach_out + hx.shell + hx.port_length))
427
+ zi["exit"] = p0 + a * reach_out
428
+ zi["roof"] = "pyramid" if pyramid else "flat"
429
+ zi["height"] = float(h)
430
+ zi["axis"] = a
431
+
432
+ reach = max(zi["height"] for zi in zinfo) + hx.shell + hx.port_length + 4.0
433
+ # off the round numbers a core's faces sit on, so no face lands on a sample plane
434
+ lo, hi = clo - reach - 0.137 * step, chi + reach
435
+ step = max(step, (float(np.prod(hi - lo)) / hx.max_points) ** (1.0 / 3.0))
436
+ if step > hx.wall / 2.0:
437
+ raise ValueError(_too_coarse(hx, float(np.prod(hi - lo)), step))
438
+ pts, dims = _grid(lo, hi, step)
439
+ # the lowest point of the part, where a port's gusset may stand
440
+ bed = None
441
+ if up is not None:
442
+ bed = min([float(clo @ up) - hx.shell] +
443
+ [float((zi["center"] + zi["normal"] * (zi["height"] + hx.shell)) @ up) for zi in zinfo] +
444
+ [float(zi["port"][1] @ up) for zi in zinfo])
445
+
446
+ coords = ((pts - c_lo) / pitch - 0.5).T
447
+ ell = [ndimage.map_coordinates(g, coords, order=1, mode="nearest") for g in ell_grids]
448
+ d = hx.core.sdf(pts)
449
+ d_far = ndimage.map_coordinates(far, coords, order=1, mode="nearest")
450
+ d = np.where(np.abs(d_far) > 3.0 * pitch, d_far, d)
451
+ del d_far
452
+ g = tpms(hx.kind, pts - clo - np.asarray(hx.phase, float), hx.cell)
453
+ sheet_c = 0.0
454
+ if abs(hx.split - 0.5) > 1e-9:
455
+ from .heatx_thermal import sheet_offset
456
+
457
+ # A is where g > offset + half: move the sheet so A gets `split` of the void
458
+ sheet_c = sheet_offset(lambda q: tpms(hx.kind, q, 1.0), hx.wall / hx.cell, hx.split) * hx.cell
459
+ g -= sheet_c
460
+ half = 0.5 * hx.wall
461
+ fluid = []
462
+ body = [d - hx.shell]
463
+ shape = _closure(hx, zones, ell, d, dims, step, up, cap) if up is not None and hx.closure else None
464
+ for f in (0, 1):
465
+ ks = [k for k, z in enumerate(zones) if z.fluid == f]
466
+ side = g - half if f == 0 else -g - half # > 0 inside this fluid's network
467
+ cap_f = cap
468
+ if shape is not None:
469
+ side = side - shape["close"][f]
470
+ cap_f = cap + shape["deep"][f]
471
+ ell_f = np.max([ell[k] for k in ks], axis=0)
472
+ # open in the core, but sealed within `cap` of the surface outside this fluid's zones
473
+ void = np.maximum(-side, np.maximum(d, np.minimum(cap_f + d, -ell_f)))
474
+ net, void = void, np.full_like(void, np.inf) # plenums and bores gather in `void`
475
+ for k in ks:
476
+ zi = zinfo[k]
477
+ if zi["roof"] == "pyramid":
478
+ # under a vertical cone over a level circle, in the build frame
479
+ hgt = zi["sign"] * ((pts - zi["center"]) @ up)
480
+ rh = np.linalg.norm((pts - zi["center"]) - np.outer(hgt * zi["sign"], up), axis=1)
481
+ cone = (hgt - zi["height"] + rh * tan_lean) / np.hypot(1.0, tan_lean)
482
+ column = rh - zi["radius"]
483
+ floor = zi["low"] - hgt # never past the zone's own side of the core
484
+ inzone = cap - ell[k] # and never past the zone's own edge
485
+ void = np.minimum(void, np.maximum(np.maximum(np.maximum(-d, floor), inzone),
486
+ np.maximum(cone, column)))
487
+ body.append(np.maximum(np.maximum(cone, column) - hx.shell, floor))
488
+ del hgt, rh, cone, column, floor, inzone
489
+ else:
490
+ # a sideways zone's top edge leans out over the wall below it at 55 degrees
491
+ slope = ndimage.map_coordinates(slope_grids[k], coords, order=1, mode="nearest") \
492
+ if up is not None else 1.0
493
+ lateral = (cap + slope * d - ell[k]) / np.hypot(1.0, slope)
494
+ void = np.minimum(void, np.maximum(np.maximum(-d, d - zi["height"]), lateral))
495
+ body.append(np.maximum(d - zi["height"] - hx.shell, lateral - hx.shell))
496
+ # evaluated only near the port: its fields are far positive elsewhere
497
+ sel = _port_box(hx, zi, pts, up, bed)
498
+ bore, tube = _port(hx, zi, pts[sel], up, bed)
499
+ # (a port's root stays sharp: rounded, it would leave a ceiling under the plenum's roof)
500
+ void[sel] = np.minimum(void[sel], bore)
501
+ body[-1][sel] = np.minimum(body[-1][sel], tube)
502
+ fluid.append((net, void))
503
+ del net, void
504
+ if shape is not None:
505
+ # flare each fluid's channels open toward a plenum below the core, never
506
+ # within a wall (and two grid steps) of the other fluid's space
507
+ for f in (0, 1):
508
+ pot = shape["flare"][f]
509
+ if not pot.any():
510
+ continue
511
+ other = (fluid[1 - f][0] < 0).reshape(dims)
512
+ room = ndimage.distance_transform_edt(~other).ravel() * step if other.any() else np.full(len(d), np.inf)
513
+ del other
514
+ fl = pot * np.clip((room - hx.wall - 2.0 * step) / step, 0.0, 1.0)
515
+ del room
516
+ side = (g - half if f == 0 else -g - half) - shape["close"][f] + fl
517
+ ell_f = np.max([ell[k] for k, z in enumerate(zones) if z.fluid == f], axis=0)
518
+ flared = np.maximum(-side, np.maximum(d, np.minimum(cap + shape["deep"][f] + d, -ell_f)))
519
+ fluid[f] = (np.minimum(fluid[f][0], flared), fluid[f][1])
520
+ del side, flared, fl
521
+ sharp = [np.minimum(net, void) for net, void in fluid]
522
+ if blend > 0:
523
+ # the plenum flows into its channels through rounded mouths (a bellmouth on every
524
+ # opening) rather than over the sheet's square-cut ends: the rounding only cuts into
525
+ # the core (the plenum's own walls keep their shapes), may eat the sheet's end, but
526
+ # never comes within a wall of the other fluid
527
+ out = []
528
+ for f, (net, void) in enumerate(fluid):
529
+ other = sharp[1 - f] if f == 0 else out[0]
530
+ # (a step over the wall: the fields are near-distances, the check is on the grid)
531
+ rounded = np.maximum(np.maximum(_smooth_union(net, void, blend), d), hx.wall + step - other)
532
+ out.append(np.minimum(sharp[f], rounded))
533
+ fluid = out
534
+ else:
535
+ fluid = sharp
536
+ del sharp
537
+ d_body = np.min(body, axis=0)
538
+ del body, ell, coords
539
+ grid = np.maximum(d_body, -np.minimum(fluid[0], fluid[1])).reshape(dims)
540
+ if up is not None and hx.skirt:
541
+ grid = _skirt(grid, d_body.reshape(dims), int(np.argmax(np.abs(up))), step)
542
+
543
+ cleanouts, powder = [], None
544
+ if hx.cleanouts > 0 or hx.process == "lpbf":
545
+ _, masks0 = _check(hx, zones, zinfo, grid, g - half, d_body, d, dims, step, lo, cap)
546
+ grid, cleanouts, powder = _cleanouts(hx, zones, zinfo, grid, masks0, d_body, d, lo, step, dims)
547
+ del masks0
548
+
549
+ grid[grid == 0.0] = 1e-6 # no vertex exactly on a grid point: keeps the mesh manifold
550
+ verts, faces, _, _ = marching_cubes(grid, 0.0, spacing=(step, step, step), allow_degenerate=False)
551
+ mesh = trimesh.Trimesh(verts + lo + 0.5 * step, faces[:, ::-1], process=True)
552
+ if mesh.volume < 0:
553
+ mesh.invert()
554
+
555
+ report, masks = _check(hx, zones, zinfo, grid, (side_a := g - half), d_body, d, dims, step, lo, cap,
556
+ mouths=[(c["plug"]["a"], c["plug"]["b"], c["diameter"] / 2) for c in cleanouts])
557
+ if powder is not None:
558
+ report["powder"] = powder
559
+ if cleanouts:
560
+ report["cleanouts"] = cleanouts
561
+ report.update(_overhangs(hx, mesh, up))
562
+ if up is not None:
563
+ report["internalCeilings"] = _internal_ceilings(hx, mesh, masks, lo, step, up, clo, cap, sheet_c)
564
+ report.update({"kind": hx.kind, "cell": hx.cell, "wall": hx.wall, "step": step, "cap": cap, "blend": blend,
565
+ "solidVolume": float(mesh.volume), "watertight": bool(mesh.is_watertight),
566
+ "bounds": [mesh.bounds[0].round(2).tolist(), mesh.bounds[1].round(2).tolist()],
567
+ "zones": {f"{hx.names[z.fluid]}-{z.end}": {
568
+ "roof": zi["roof"], "center": np.round(zi["center"], 2).tolist(), "plenumHeight": round(zi["height"], 2),
569
+ "port": [np.round(zi["port"][1], 2).tolist(), np.round(zi["axis"], 3).tolist()],
570
+ "portDiameter": zi["portDiameter"]}
571
+ for z, zi in zip(zones, zinfo)},
572
+ "warnings": warnings})
573
+ warnings += _process_warnings(hx, report)
574
+ del side_a
575
+ extra = ({"masks": masks, "lo": lo, "step": step, "dims": tuple(dims)},) if grids else ()
576
+ if not fluid_meshes:
577
+ return (mesh, report, *extra)
578
+ # Each space is the part's own void (grid > 0, inside the body), so its
579
+ # walls come from the same smooth field as the part; the voxel label
580
+ # only says which fluid, by a distance 1.5 voxels past its mask (inside
581
+ # metal there, where the field governs).
582
+ spaces = {}
583
+ inside = -d_body.reshape(dims)
584
+ for name, m in zip(hx.names, masks):
585
+ if m.any():
586
+ near = 1.5 * step - ndimage.distance_transform_edt(~m) * step
587
+ f = np.minimum(np.minimum(grid, inside), near).astype(np.float32)
588
+ f[f == 0.0] = -1e-6
589
+ v, fc, _, _ = marching_cubes(np.pad(f, 1, constant_values=-1.0), 0.0, spacing=(step, step, step),
590
+ allow_degenerate=False)
591
+ fm = trimesh.Trimesh(v + lo - 0.5 * step, fc[:, ::-1], process=True)
592
+ if fm.volume < 0:
593
+ fm.invert()
594
+ spaces[name] = fm
595
+ return (mesh, report, spaces, *extra)
596
+
597
+
598
+ def _too_coarse(hx, volume, step):
599
+ need = volume / (0.5 * hx.wall) ** 3
600
+ return (f"the heat exchanger needs a {step:.2f} mm grid for its size within its point cap "
601
+ f"({hx.max_points / 1e6:.1f}M points), too coarse for {hx.wall:g} mm walls (at most wall / 2 = "
602
+ f"{0.5 * hx.wall:.2f} mm). Shrink the core, thicken the wall, or raise the cap (`loadpath heatx "
603
+ f"--max-points {int(np.ceil(need / 1e6)) * 1_000_000}`: about "
604
+ f"{(_BASE_BYTES + _BYTES_PER_POINT * need) / 1e9:.1f} GB at its peak)")
605
+
606
+
607
+ def _reach_estimate(hx, zones=None):
608
+ """How far past the core the design grid reaches (mm), without the zone
609
+ stage: a sideways zone's plenum is `header` deep, an up or down one a
610
+ 55 degree cone over (at most) the core's half-width."""
611
+ zones = zones if zones is not None else zones_for(hx)
612
+ clo, chi = (np.asarray(b, float) for b in hx.core.bounds())
613
+ ext = chi - clo
614
+ cap = hx.cap if hx.cap is not None else hx.wall + 1.0
615
+ up = _unit(hx.build) if hx.build is not None else None
616
+ heights = [hx.header]
617
+ for z in zones:
618
+ if up is not None and abs(float(_unit(z.direction) @ up)) > 0.6:
619
+ across = [ext[i] for i in range(3) if abs(up[i]) < 0.5] or [ext.min()]
620
+ heights.append(max(0.5 * min(across) - cap, 0.0) * np.tan(np.radians(_LEAN)))
621
+ return max(heights) + hx.shell + hx.port_length + 4.0
622
+
623
+
624
+ def grid_plan(hx) -> dict:
625
+ """The design grid before designing (milliseconds): its step (mm), point
626
+ count and estimated peak memory, and whether the point cap
627
+ (hx.max_points) leaves a grid fine enough for the wall (at most
628
+ wall / 2). The roofs' heights are estimated (a cone over the core's
629
+ half-width), so the real grid is no larger."""
630
+ clo, chi = (np.asarray(b, float) for b in hx.core.bounds())
631
+ reach = _reach_estimate(hx)
632
+ ext = chi - clo + 2.0 * reach
633
+ vol = float(np.prod(ext))
634
+ step = max(hx.step or hx.wall / 3.0, (vol / hx.max_points) ** (1.0 / 3.0))
635
+ points = float(np.prod(np.maximum(np.ceil(ext / step), 2)))
636
+ out = {"step": round(step, 4), "points": int(points), "maxPoints": int(hx.max_points),
637
+ "bounds": [np.round(clo - reach, 2).tolist(), np.round(chi + reach, 2).tolist()],
638
+ "peakMemoryGB": round((_BASE_BYTES + _BYTES_PER_POINT * points) / 1e9, 2),
639
+ "fits": bool(step <= hx.wall / 2.0 + 1e-12)}
640
+ if not out["fits"]:
641
+ out["error"] = _too_coarse(hx, vol, step)
642
+ out["maxPointsNeeded"] = int(np.ceil(vol / (0.5 * hx.wall) ** 3))
643
+ out["memoryNeededGB"] = round((_BASE_BYTES + _BYTES_PER_POINT * out["maxPointsNeeded"]) / 1e9, 1)
644
+ return out
645
+
646
+
647
+ def _process_warnings(hx, report) -> list[str]:
648
+ """What the report shows past the process's limits (sizing.PROCESS_LIMITS):
649
+ the outer wall away from the ports' mouths, and in powder-bed metal,
650
+ internal ceilings and powder no opening reaches."""
651
+ from .sizing import PROCESS_LIMITS
652
+
653
+ lim = PROCESS_LIMITS.get(hx.process, PROCESS_LIMITS["fdm"])
654
+ out = []
655
+ w = report.get("minOuterWall")
656
+ if w is not None and w < lim["minWall"]:
657
+ out.append(f"the outer wall is {w:.2f} mm at {report.get('minOuterWallAt')} (away from the ports' mouths), "
658
+ f"under {hx.process}'s {lim['minWall']:g} mm minimum: raise shell (now {hx.shell:g} mm)")
659
+ if hx.process == "lpbf":
660
+ ic = report.get("internalCeilings") or {}
661
+ if (ic.get("flatArea") or 0.0) > _LPBF_FLAT_CEILING:
662
+ out.append(f"{ic['flatArea']:.0f} mm^2 of internal ceiling within 20 degrees of level (worst near "
663
+ f"{ic.get('worstAt')}), over {_LPBF_FLAT_CEILING:g} mm^2: lpbf cannot support anything "
664
+ "inside. Keep closure on, try another build axis, a smaller cell or phase")
665
+ tv = report.get("trappedVolume") or 0.0
666
+ if tv > _LPBF_TRAPPED:
667
+ lt = report.get("largestTrapped") or {}
668
+ out.append(f"{tv:.1f} mm^3 of open space no port reaches ({report.get('trappedPockets')} pockets, the "
669
+ f"largest {lt.get('volume', 0):.1f} mm^3 from {lt.get('from')} to {lt.get('to')}), over "
670
+ f"{_LPBF_TRAPPED:g} mm^3: powder stays trapped there")
671
+ for n, p in (report.get("powder") or {}).items():
672
+ if (p.get("unreached") or 0.0) > 0:
673
+ out.append(f"{n}: {p['unreached']:.1f} mm^3 of its space no opening reaches: powder cannot get out")
674
+ return out
675
+
676
+
677
+ # A surface within this many degrees of level is a ceiling over the channels
678
+ # that end under it; the sheet closes toward it (or flares open over a plenum
679
+ # below) by _CLOSE_SLOPE mm of half-thickness per mm, as in exchanger_core.
680
+ _CLOSE_FACE = 40.0
681
+ _CLOSE_SLOPE = 1.0
682
+
683
+
684
+ def _closure(hx, zones, ell, d, dims, step, up, cap):
685
+ """How each fluid's network is reshaped for printing, per fluid:
686
+ "close" (mm the fluid's side of the sheet is pushed back: under core
687
+ surface that looks up and is not this fluid's zone, both networks close
688
+ over 0.45 cell / slope before the sealing depth `cap`, so every channel
689
+ ends in a peaked roof, not on a flat skin); "deep" (mm added to `cap`:
690
+ under the other fluid's zone where it looks down, this fluid is sealed
691
+ deeper, to make room); "flare" (mm the fluid may open toward its own zone
692
+ where that looks down, a plenum under the core, so what hangs between the
693
+ mouths leans instead of ending flat; applied only a wall clear of the
694
+ other fluid). Closing and sealing only add metal, so they can never open
695
+ a leak."""
696
+ grad = np.gradient(d.reshape(dims), step)
697
+ nu = sum(float(up[i]) * grad[i] for i in range(3) if abs(up[i]) > 1e-9).ravel()
698
+ del grad
699
+ c = np.cos(np.radians(_CLOSE_FACE))
700
+ w_up = np.clip((nu - c) / 0.1, 0.0, 1.0)
701
+ w_dn = np.clip((-nu - c) / 0.1, 0.0, 1.0)
702
+ del nu
703
+ reach = 0.45 * hx.cell / _CLOSE_SLOPE
704
+ close = _CLOSE_SLOPE * np.clip(reach + cap + d, 0.0, None) * w_up
705
+ flare_ramp = _CLOSE_SLOPE * np.clip(reach + d, 0.0, None) * w_dn
706
+ out = {"close": [], "deep": [None, None], "flare": []}
707
+ for f in (0, 1):
708
+ own = np.max([ell[k] for k, z in enumerate(zones) if z.fluid == f], axis=0)
709
+ inzone = np.clip(own / (2.0 * step) + 0.5, 0.0, 1.0)
710
+ out["close"].append(close * (1.0 - inzone))
711
+ out["flare"].append(flare_ramp * inzone)
712
+ out["deep"][1 - f] = (reach + hx.wall) * w_dn * inzone
713
+ return out
714
+
715
+
716
+ def _internal_ceilings(hx, mesh, masks, lo, step, up, clo, cap, sheet_c=0.0):
717
+ """Faces over either fluid's space leaning flatter than 45 degrees from
718
+ level (and those within 20 degrees: flat), leaving out the sheet itself,
719
+ which prints unsupported. Inside, no support can be
720
+ removed: in powder-bed metal these must be near zero."""
721
+ c = mesh.triangles_center
722
+ n = mesh.vertex_normals[mesh.faces].mean(axis=1)
723
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-9)
724
+ ijk = np.floor((c + n * step - lo) / step).astype(int)
725
+ dims = np.asarray(masks[0].shape)
726
+ ok = np.all((ijk >= 0) & (ijk < dims), axis=1)
727
+ wet = np.zeros(len(c), bool)
728
+ i = ijk[ok]
729
+ wet[ok] = masks[0][i[:, 0], i[:, 1], i[:, 2]] | masks[1][i[:, 0], i[:, 1], i[:, 2]]
730
+ # the sheet as the TPMS shapes it (not where a closure has moved it)
731
+ inside = hx.core.sdf(c) < -step
732
+ sheet = np.abs(np.abs(tpms(hx.kind, c - clo - np.asarray(hx.phase, float), hx.cell) - sheet_c) - 0.5 * hx.wall) < 1.5 * step
733
+ wet &= ~(inside & sheet)
734
+ down = -(n @ up)
735
+ a = mesh.area_faces
736
+ over = wet & (down > np.cos(np.radians(45.0)))
737
+ flat = wet & (down > np.cos(np.radians(20.0)))
738
+ out = {"overhangArea": float(a[over].sum()), "flatArea": float(a[flat].sum())}
739
+ if flat.any():
740
+ out["worstAt"] = _densest(c[flat], a[flat], hx.cell)
741
+ return out
742
+
743
+
744
+ def _densest(points, weights, size):
745
+ """The centre of the size-mm cube holding the most weight (where the
746
+ worst of a scattered set of faces is)."""
747
+ key = np.floor(points / size).astype(np.int64)
748
+ uniq, inv = np.unique(key, axis=0, return_inverse=True)
749
+ best = int(np.argmax(np.bincount(inv.ravel(), weights=weights)))
750
+ sel = inv.ravel() == best
751
+ return np.round((points[sel] * weights[sel, None]).sum(0) / weights[sel].sum(), 1).tolist()
752
+
753
+
754
+ # a clean-out's plug seat: the tap drill a clean-out diameter takes (mm -> thread)
755
+ _TAPS = ((2.5, "M3x0.5"), (3.3, "M4x0.7"), (4.2, "M5x0.8"), (5.0, "M6x1"), (6.8, "M8x1.25"), (8.5, "M10x1.5"))
756
+
757
+
758
+ def _geodesic(mask, starts, step):
759
+ """Distance (mm) from `starts` (voxel indices) through `mask`, inf where
760
+ unreached."""
761
+ from skimage.graph import MCP_Geometric
762
+
763
+ cost = np.where(mask, 1.0, np.inf)
764
+ starts = [tuple(s) for s in starts if mask[tuple(s)]]
765
+ if not starts:
766
+ return np.full(mask.shape, np.inf)
767
+ cum, _ = MCP_Geometric(cost, fully_connected=True).find_costs(starts)
768
+ return cum * step
769
+
770
+
771
+ def _cleanouts(hx, zones, zinfo, grid, masks, d_body, d_core, lo, step, dims):
772
+ """Powder clean-out ports: for each fluid, `hx.cleanouts` times, a bore
773
+ from the point of its space farthest (through the space) from every
774
+ opening, straight out through the seal and the outer wall, on a boss long
775
+ enough to tap for a plug (1.5 diameters of thread). The other fluid's
776
+ channels within a wall of the bore are filled (only metal is added, so
777
+ no leak can open; the bore never crosses a plenum). Also reports, per
778
+ fluid, the longest path powder travels to an opening (`powder`).
779
+ Returns (grid, cleanouts, powder)."""
780
+ r = 0.5 * hx.cleanout_diameter
781
+ solid = grid < 0
782
+ outside = ~solid & (d_body.reshape(dims) >= 0)
783
+ dist_out = ndimage.distance_transform_edt(~outside) * step
784
+ grad = np.stack(np.gradient(dist_out, step), -1)
785
+ core = d_core.reshape(dims) < 0
786
+ body = d_body.reshape(dims) < 0
787
+ del outside
788
+ out, powder = [], {}
789
+ pts_all = None
790
+ boss_len = 3.0 * r
791
+ boss_wall = max(hx.shell, 0.6 * r)
792
+ reach_in = hx.shell + (hx.cap if hx.cap is not None else hx.wall + 1.0) + hx.cell
793
+ for f, m in enumerate(masks):
794
+ if not m.any():
795
+ continue
796
+ starts = []
797
+ for z, zi in zip(zones, zinfo):
798
+ if z.fluid != f:
799
+ continue
800
+ p0, p1 = zi["port"]
801
+ ax = (p1 - p0) / np.linalg.norm(p1 - p0)
802
+ q = p1 - ax * (0.5 * hx.port_length)
803
+ starts.append(np.clip(np.floor((q - lo) / step).astype(int), 0, np.asarray(dims) - 1))
804
+ other = masks[1 - f]
805
+ ok = m & core & (dist_out <= reach_in)
806
+ for _ in range(hx.cleanouts):
807
+ geo = _geodesic(m, starts, step)
808
+ placed = False
809
+ while not placed:
810
+ cand = np.where(ok & np.isfinite(geo), geo, -1.0)
811
+ i = int(np.argmax(cand))
812
+ if cand.flat[i] <= 0:
813
+ break
814
+ ijk = np.asarray(np.unravel_index(i, dims))
815
+ ok[tuple(ijk)] = False
816
+ n = -grad[tuple(ijk)]
817
+ if np.linalg.norm(n) < 1e-6:
818
+ continue
819
+ n /= np.linalg.norm(n)
820
+ site = lo + (ijk + 0.5) * step
821
+ depth = float(dist_out[tuple(ijk)])
822
+ # out of the core the bore and its boss meet only metal and then
823
+ # air: no plenum, no port, and the straight line does reach out
824
+ along = np.arange(0.0, depth + 3.0 * r + 1.0, 0.5 * step)
825
+ idx = np.floor((site + np.outer(along, n) - lo) / step).astype(int)
826
+ if (idx < 0).any() or (idx >= np.asarray(dims)).any():
827
+ continue
828
+ ix, iy, iz = idx.T
829
+ if ((m | other)[ix, iy, iz] & ~core[ix, iy, iz]).any():
830
+ continue
831
+ if (body[ix, iy, iz] & (along > depth + 2.0 * step)).any():
832
+ continue
833
+ placed = True
834
+ if not placed:
835
+ break
836
+ b = site + n * (depth + boss_len)
837
+ if pts_all is None:
838
+ pts_all = _grid_points(lo, dims, step)
839
+ box_lo = np.minimum(site, b) - r - boss_wall - hx.wall - 2 * step
840
+ box_hi = np.maximum(site, b) + r + boss_wall + hx.wall + 2 * step
841
+ sel = np.all((pts_all >= box_lo) & (pts_all <= box_hi), axis=1)
842
+ q = pts_all[sel]
843
+ g_ = grid.reshape(-1)
844
+ # the other fluid kept a wall (and two grid steps) clear of the bore
845
+ collar = Cylinder(site - n * (r + hx.wall), b, r + hx.wall + 2 * step).sdf(q)
846
+ theirs = other.reshape(-1)[sel]
847
+ g_sel = g_[sel]
848
+ g_sel[theirs] = np.minimum(g_sel[theirs], collar[theirs])
849
+ boss = Cylinder(site + n * max(depth - hx.shell, step), b, r + boss_wall).sdf(q)
850
+ bore = Cylinder(site - n * step, b + n * 1.0, r).sdf(q)
851
+ g_[sel] = np.maximum(np.minimum(g_sel, boss), -bore)
852
+ grid = g_.reshape(dims)
853
+ starts.append(ijk)
854
+ tap = min(_TAPS, key=lambda t: abs(t[0] - hx.cleanout_diameter))[1]
855
+ plug_a = site + n * max(depth - hx.shell, step)
856
+ out.append({"fluid": hx.names[f], "at": np.round(site, 2).tolist(), "axis": np.round(n, 3).tolist(),
857
+ "diameter": hx.cleanout_diameter, "pathBefore": round(float(geo[tuple(ijk)]), 1),
858
+ "plug": {"a": np.round(plug_a, 3).tolist(), "b": np.round(b + n * 1.0, 3).tolist(),
859
+ "r": r, "ends": "flat"},
860
+ "seal": f"tap {tap} through the boss; after de-powdering, a set screw with thread "
861
+ "sealant, or weld it shut"})
862
+ near = np.zeros(dims, bool)
863
+ near[tuple(ijk)] = True
864
+ ok &= ndimage.distance_transform_edt(~near) * step > 4 * r # not beside this one
865
+ geo = _geodesic(m, starts, step)
866
+ reach = geo[m & np.isfinite(geo)]
867
+ powder[hx.names[f]] = {"farthestFromOpening": round(float(reach.max()), 1) if reach.size else None,
868
+ "unreached": float((m & ~np.isfinite(geo)).sum()) * step ** 3}
869
+ return grid, out, powder
870
+
871
+
872
+ def _skirt(grid, envelope, axis, step):
873
+ """Fill under the part's outside at 45 degrees, down to the bed: every
874
+ layer of the envelope is carried down one layer at a time, eroded by a
875
+ voxel, so whatever overhangs the outside (a rim, a horizontal cylinder's
876
+ underside) gets a chamfer or keel under it. Only outside the envelope:
877
+ plenums and channels keep their own shapes."""
878
+ m = np.moveaxis(envelope < 0, axis, -1)
879
+ k = m.copy()
880
+ cross = ndimage.generate_binary_structure(2, 1)
881
+ for i in range(k.shape[-1] - 2, -1, -1):
882
+ k[..., i] |= ndimage.binary_erosion(k[..., i + 1], cross)
883
+ filled = np.nonzero(m.any(axis=(0, 1)))[0]
884
+ k[..., :filled[0]] = False # nothing below the bed
885
+ sk = np.moveaxis(k & ~m, -1, axis)
886
+ if not sk.any():
887
+ return grid
888
+ # its signed distance, on the box around it
889
+ span = [np.nonzero(sk.any(axis=tuple(x for x in range(3) if x != ax)))[0] for ax in range(3)]
890
+ box = tuple(slice(max(n.min() - 3, 0), n.max() + 4) for n in span)
891
+ sub = sk[box]
892
+ d = (ndimage.distance_transform_edt(~sub) - ndimage.distance_transform_edt(sub)) * step
893
+ d += np.where(sub, 0.5 * step, -0.5 * step)
894
+ out = grid.copy()
895
+ out[box] = np.minimum(out[box], d)
896
+ return out
897
+
898
+
899
+ def _port_box(hx, zi, pts, up, bed):
900
+ """The grid points near a port, its gusset included."""
901
+ p0, p1 = zi["port"]
902
+ ax = (p1 - p0) / np.linalg.norm(p1 - p0)
903
+ ends = np.array([p0, p1 + ax * 3.0])
904
+ pad = _SQRT2 * 0.5 * zi["portDiameter"] + hx.shell + 1.0
905
+ blo, bhi = ends.min(axis=0) - pad, ends.max(axis=0) + pad
906
+ if up is not None and abs(float(ax @ up)) <= 0.7:
907
+ k = int(np.argmax(np.abs(up)))
908
+ blo[k] = min(blo[k], bed - 1.0)
909
+ return np.all((pts >= blo) & (pts <= bhi), axis=1)
910
+
911
+
912
+ def _port(hx, zi, pts, up, bed):
913
+ """A port's bore (void) and tube (solid) fields. A port that runs
914
+ sideways to the build direction gets a teardrop bore, peaked at 45
915
+ degrees, and a gusset under its tube that rises at 45 degrees from the
916
+ plenum wall, so FDM prints it without support."""
917
+ r = 0.5 * zi["portDiameter"]
918
+ ro = r + hx.shell
919
+ p0, p1 = zi["port"]
920
+ length = float(np.linalg.norm(p1 - p0))
921
+ ax = (p1 - p0) / length
922
+ bore = Cylinder(p0, p1 + ax * 2.0, r).sdf(pts)
923
+ tube = Cylinder(p0, p1, ro).sdf(pts)
924
+ if up is None or abs(float(ax @ up)) > 0.7:
925
+ return bore, tube
926
+ v = up - (up @ ax) * ax # up, across the port
927
+ v /= np.linalg.norm(v)
928
+ w = np.cross(ax, v)
929
+ q = pts - p0
930
+ a, u, l = q @ ax, q @ v, np.abs(q @ w)
931
+ del q
932
+ # teardrop: a 45 degree peak over the bore's top
933
+ peak = np.maximum(np.maximum((l + u) / _SQRT2 - r, (l - u) / _SQRT2), np.maximum(-a, a - length - 2.0))
934
+ bore = np.minimum(bore, peak)
935
+ # and the tube peaked the same over it, so its wall keeps its thickness there; it starts
936
+ # a shell back toward the core, so where the peak rises over a plenum's leaning top edge
937
+ # the bore's root is closed, not open to the air
938
+ back = hx.shell + 1.0
939
+ tube = np.minimum(np.minimum(tube, Cylinder(p0 - ax * back, p0, ro).sdf(pts)),
940
+ np.maximum(np.maximum((l + u) / _SQRT2 - ro, (l - u) / _SQRT2), np.maximum(-a - back, a - length)))
941
+ # gusset: under the tube, its underside rising 45 degrees from the wall to the tube's end
942
+ under = (a - length - u - ro) / _SQRT2 # < 0 above the 45 degree plane
943
+ gus = np.maximum(np.maximum(l - ro, u), np.maximum(np.maximum(-a, a - length), under))
944
+ if bed is not None:
945
+ gus = np.maximum(gus, bed - (pts @ up))
946
+ return bore, np.minimum(tube, gus)
947
+
948
+
949
+ def _check(hx, zones, zinfo, grid, side_a, d_body, d_core, dims, step, lo, cap, mouths=()):
950
+ """Isolation, walls and capacity, measured on the grid the part was
951
+ meshed from. `mouths` (a, b, r): clean-out bores, left out of the outer
952
+ wall like the ports' mouths."""
953
+ solid = grid < 0
954
+ inside_body = (d_body < 0).reshape(dims)
955
+ open_ = ~solid & inside_body
956
+ # 26-connected: fluids that touch only at a voxel's edge or corner count as touching
957
+ lbl, _ = ndimage.label(open_, np.ones((3, 3, 3), bool))
958
+ names = hx.names
959
+ ports = {}
960
+ for k, (z, zi) in enumerate(zip(zones, zinfo)):
961
+ # a point in the bore just inside the tube's mouth
962
+ p0, p1 = zi["port"]
963
+ ax = (p1 - p0) / np.linalg.norm(p1 - p0)
964
+ q = p1 - ax * (0.5 * hx.port_length)
965
+ ijk = np.clip(np.floor((q - lo) / step).astype(int), 0, np.asarray(dims) - 1)
966
+ ports[k] = (z.fluid, int(lbl[tuple(ijk)]))
967
+ report = {"ports": {f"{names[z.fluid]}-{z.end}": ports[k][1] for k, z in enumerate(zones)}}
968
+ a_lbls = {v for f, v in ports.values() if f == 0}
969
+ b_lbls = {v for f, v in ports.values() if f == 1}
970
+ report["isolated"] = bool(0 not in a_lbls | b_lbls and not (a_lbls & b_lbls))
971
+ report["connected"] = {names[0]: len(a_lbls) == 1 and 0 not in a_lbls,
972
+ names[1]: len(b_lbls) == 1 and 0 not in b_lbls}
973
+ a = np.isin(lbl, list(a_lbls - {0}))
974
+ b = np.isin(lbl, list(b_lbls - {0}))
975
+ cell_vol = step ** 3
976
+ # open space inside the part that no port reaches: dead pockets (they trap
977
+ # powder or resin, and in FDM they are just weight)
978
+ trapped = open_ & ~a & ~b
979
+ report["trappedVolume"] = float(trapped.sum()) * cell_vol
980
+ if trapped.any():
981
+ tl, n_t = ndimage.label(trapped)
982
+ sizes = np.bincount(tl.ravel())[1:]
983
+ big = int(np.argmax(sizes)) + 1
984
+ report["trappedPockets"] = int(n_t)
985
+ where = np.argwhere(tl == big)
986
+ report["largestTrapped"] = {"volume": float(sizes[big - 1]) * cell_vol,
987
+ "from": np.round(lo + where.min(axis=0) * step, 1).tolist(),
988
+ "to": np.round(lo + (where.max(axis=0) + 1) * step, 1).tolist()}
989
+ del tl
990
+ if a.any() and b.any():
991
+ # thinnest wall between the fluids: distance from B's space to A's
992
+ dist_to_a = ndimage.distance_transform_edt(~a) * step
993
+ report["minWallBetweenFluids"] = float(dist_to_a[b].min())
994
+ i = np.argmin(np.where(b, dist_to_a, np.inf))
995
+ report["minWallBetweenFluidsAt"] = np.round(lo + (np.asarray(np.unravel_index(i, dims)) + 0.5) * step, 1).tolist()
996
+ del dist_to_a
997
+ # thinnest outer wall: from either fluid to the outside, away from the port mouths
998
+ outside = ~solid & ~inside_body # air (the skirt is solid, though outside the body)
999
+ dist_out = ndimage.distance_transform_edt(~outside) * step
1000
+ near_mouth = np.zeros(int(np.prod(dims)), bool)
1001
+ pts = _grid_points(lo, dims, step)
1002
+ up = _unit(hx.build) if hx.build is not None else None
1003
+ for zi in zinfo:
1004
+ p0, p1 = zi["port"]
1005
+ ax = (p1 - p0) / np.linalg.norm(p1 - p0)
1006
+ # a sideways port's teardrop bore peaks r sqrt(2) over its axis (_port): its mouth
1007
+ # reaches that far, so the check leaves out that far too
1008
+ r_m = 0.5 * zi["portDiameter"] * (_SQRT2 if up is not None and abs(float(ax @ up)) <= 0.7 else 1.0)
1009
+ sel = _port_box(hx, zi, pts, None, None)
1010
+ near_mouth[sel] |= Cylinder(zi["exit"], p1 + ax * 3.0, r_m + hx.shell + 2 * step).sdf(pts[sel]) < 0
1011
+ for a_, b_, r_ in mouths:
1012
+ a_, b_ = np.asarray(a_, float), np.asarray(b_, float)
1013
+ ax_ = (b_ - a_) / np.linalg.norm(b_ - a_)
1014
+ near_mouth |= Cylinder(a_ - ax_ * (hx.shell + 2 * step), b_, r_ + hx.shell + 2 * step).sdf(pts) < 0
1015
+ del pts
1016
+ near_mouth = near_mouth.reshape(dims)
1017
+ wet = (a | b) & ~near_mouth
1018
+ report["minOuterWall"] = float(dist_out[wet].min()) if wet.any() else None
1019
+ if wet.any():
1020
+ i = np.argmin(np.where(wet, dist_out, np.inf))
1021
+ report["minOuterWallAt"] = np.round(lo + (np.asarray(np.unravel_index(i, dims)) + 0.5) * step, 1).tolist()
1022
+ del dist_out
1023
+ core = (d_core < 0).reshape(dims)
1024
+ side_a = (side_a > 0).reshape(dims)
1025
+ for f, m in ((0, a), (1, b)):
1026
+ vol = float(m.sum()) * cell_vol
1027
+ core_vol = float((m & core).sum()) * cell_vol
1028
+ # how much of this fluid's side of the sheet in the core its ports reach
1029
+ # (a flare can carry one fluid across the sheet's mid-surface: what it
1030
+ # takes there counts as its own side, and not the other's)
1031
+ own = (core & ~solid & (side_a if f == 0 else ~side_a) & ~(b if f == 0 else a)) | (m & core)
1032
+ reached = core_vol / max(float(own.sum()) * cell_vol, 1e-9)
1033
+ # wetted area in the core: faces between this fluid and solid
1034
+ area = 0.0
1035
+ mf = m & core
1036
+ for ax in range(3):
1037
+ s0 = [slice(None)] * 3
1038
+ s1 = [slice(None)] * 3
1039
+ s0[ax], s1[ax] = slice(0, -1), slice(1, None)
1040
+ area += float(((mf[tuple(s0)] & solid[tuple(s1)] & core[tuple(s1)]) |
1041
+ (mf[tuple(s1)] & solid[tuple(s0)] & core[tuple(s0)])).sum()) * step * step
1042
+ # a staircase over-reads a smooth surface's area by about 3/2
1043
+ area /= 1.5
1044
+ report[names[f]] = {"volume": vol, "coreVolume": core_vol, "coreWettedArea": area,
1045
+ "coreReached": round(reached, 4),
1046
+ "hydraulicDiameter": 4.0 * core_vol / area if area > 0 else None}
1047
+ core_total = float(core.sum()) * cell_vol
1048
+ report["coreVolume"] = core_total
1049
+ report["areaDensity"] = report[names[0]]["coreWettedArea"] / core_total * 1000.0 # m^2/m^3 = mm^2/mm^3 * 1000
1050
+ return report, (a, b)
1051
+
1052
+
1053
+ def _overhangs(hx, mesh, up):
1054
+ """Downward faces steeper than 45 degrees outside the core (plenums,
1055
+ roofs, ports), off the bed: what FDM would have to bridge or support.
1056
+ The core's own faces are left out: a sheet TPMS prints unsupported."""
1057
+ if up is None:
1058
+ return {}
1059
+ c = mesh.triangles_center
1060
+ # vertex-averaged normals: marching cubes' facets scatter a few degrees either way
1061
+ nz = mesh.vertex_normals[mesh.faces].mean(axis=1) @ up
1062
+ z = c @ up
1063
+ bed = z.min()
1064
+ out = (hx.core.sdf(c) > 0.3) & (z > bed + 0.5)
1065
+ hang = out & (nz < -np.cos(np.radians(45.0)) - 0.02)
1066
+ return {"overhangArea": float(mesh.area_faces[hang].sum()),
1067
+ "bedArea": float(mesh.area_faces[(z <= bed + 0.5) & (nz < -0.9)].sum())}