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/exchanger.py ADDED
@@ -0,0 +1,831 @@
1
+ """
2
+ Dual-fluid TPMS heat-exchanger generator: a rectangular sheet-TPMS core (two
3
+ isolated fluid labyrinths sharing every wall — maximum exchange area per
4
+ volume) wrapped in a solid skin, with per-fluid plenum chambers and port
5
+ bores. The manifold problem this solves: every planar cut of a TPMS exposes
6
+ BOTH labyrinths, so each fluid's plenum face must seal the other fluid's
7
+ openings. `dual_sheet` does that by ramp-swallowing the other labyrinth into
8
+ solid near the face (over ~half a cell), which keeps the isolation guarantee:
9
+ the solid always contains the midsurface, so the circuits can never connect.
10
+
11
+ Flow arrangements (set purely by port placement — the labyrinths themselves
12
+ interpenetrate everywhere):
13
+ "cross": fluid A along X (plenums on the two X faces), fluid B along Y.
14
+ "counter": both fluids along X; each X face splits into an upper (A, z>0)
15
+ and lower (B, z<0) plenum with a solid divider between them.
16
+
17
+ Verification: `check_networks(mesh, ports)` (networks.py) flood-fills the
18
+ voids and proves the two circuits are isolated at voxel resolution; pass it the
19
+ `ports` and `plugs` this returns.
20
+
21
+ Print notes: the off-fluid's channel stubs dead-end near each sealed face and
22
+ will hold powder/resin after printing; drain holes are deliberately NOT
23
+ provided (each one would be a leak), so prefer a process you can flush through
24
+ the ports — SLS/MJF powder or low-viscosity resin — and plan a flush cycle. Wall thickness: the thinnest point of a TPMS sheet
25
+ runs ~12% under nominal — keep `wall` >= 1.15x the process minimum
26
+ (>= ~0.5mm metal LPBF, >= ~1.0mm polymer).
27
+ """
28
+ import numpy as np
29
+
30
+ from itertools import permutations
31
+
32
+ from .sdf_kit import (
33
+ box, capsule, dual_sheet, mask, seal_ramp, smin, subtract, tpms_dist, union,
34
+ )
35
+
36
+ __all__ = ["exchanger_core"]
37
+
38
+
39
+ def _require(cond, msg):
40
+ """Refuse with a message that says what to change (to_mesh style)."""
41
+ if not cond:
42
+ raise ValueError(msg)
43
+
44
+
45
+ def exchanger_core(size, cell=8.0, wall=1.0, kind="gyroid", flow="cross",
46
+ skin=None, plenum=None, port_r=None, seal_len=None,
47
+ margin=None, hose=None, blend=None, a_face=None,
48
+ b_face=None, build=None, split=0.5):
49
+ """Build a complete dual-fluid TPMS heat-exchanger block.
50
+
51
+ size: (sx, sy, sz) mm — the TPMS core box, centered at the origin.
52
+ The overall part grows beyond this by plenum + skin on the
53
+ plenum faces and by skin elsewhere.
54
+ cell: TPMS lattice period, mm. 6-12mm prints and de-powders reliably.
55
+ wall: separator sheet thickness, mm (scalar or callable(P) for graded).
56
+ kind: "gyroid" (default; best all-round) | "schwarz_p" | "diamond".
57
+ flow: "cross" | "counter" (see module docstring).
58
+ skin: outer solid skin thickness, mm. Default max(wall, 1.2).
59
+ plenum: plenum chamber depth, mm. Default = cell.
60
+ port_r: port bore radius, mm. Default sized to the plenum window.
61
+ Also accepts a dict for asymmetric ports — keys are port names
62
+ ("a_in", "a_out", "b_in", "b_out") or fluid prefixes ("a",
63
+ "b"); missing keys get the auto default. E.g.
64
+ {"a_in": 6.0, "a_out": 5.0, "b": 3.0}.
65
+ seal_len: depth over which the off-fluid labyrinth seals shut at each
66
+ plenum face, mm. Default max(0.35*cell, 2.5*wall) — deep
67
+ enough to close the labyrinth throat and stay several walls
68
+ thick under pressure, without the old cell/2 default's
69
+ visibly massive end caps (dead volume, dead weight).
70
+ margin: solid margin separating plenums from each other / the skin
71
+ edges, mm. Default max(wall, skin).
72
+ blend: smooth-union radius (mm) where hood manifolds meet the body —
73
+ the exterior fillet that makes the loft read as one form
74
+ instead of a pipe glued to a box. Default adapts per hood
75
+ (~1/4 of its hose radius, floored at 2mm) so big hoods get a
76
+ proportionate fillet; a number applies uniformly; 0 disables.
77
+ Exterior-only: channels stay sharp, so it can only ADD
78
+ material and can never open a leak.
79
+ a_face: (w, h) mm — the DIMENSIONS of the two faces fluid A's ports
80
+ must sit on (order-insensitive), straight from the spec.
81
+ Validated against the axis layout; a mismatched `size`
82
+ ordering is refused with the corrected ordering in the
83
+ message. The cheap insurance against the recurring bug of
84
+ hot ports landing on the wrong faces.
85
+ b_face: same for fluid B.
86
+ split: fluid A's share of the void (default 0.5). The sheet moves off
87
+ the mid-surface toward B, as an air side wants against a
88
+ liquid (e.g. 0.66: air gets about twice the coolant's volume).
89
+ Isolation holds whatever the split: the sheet is still whole.
90
+ build: the direction layers stack while printing ("+z", "-x", a
91
+ vector [x, y, z], or None for no print shaping). Inside a closed
92
+ part nothing can be supported (in powder-bed metal a support in a
93
+ channel never comes out), so with a build direction:
94
+ - every plenum ceiling that would lean flatter than 50 degrees
95
+ from level is cut back by a roof plane at 50 degrees (a
96
+ mono-pitch roof falling away from the core, so the core's
97
+ window stays whole, or a gable when that loses less);
98
+ - core faces that look up (the channels' ceilings) close both
99
+ labyrinths over a gentle ramp instead of a flat skin, so
100
+ each channel ends in a peaked roof rather than a ceiling;
101
+ - port bores that run sideways get a 50 degree teardrop peak.
102
+ Built along a cube diagonal (e.g. [1, 1, 1]) every face of a box
103
+ plenum already leans 54.7 degrees and almost nothing changes.
104
+ `field.exclude` marks the untouched TPMS core, for
105
+ cavities.internal_ceilings / rank_build_directions.
106
+ hose: external hood-loft hose manifolds, keyed like port_r. Each
107
+ value is the hose INNER diameter (mm) or a dict
108
+ {"d": mm, "neck": mm, "barb": mm}. A hooded port opens its
109
+ ENTIRE rectangular plenum window through the skin (no small
110
+ bore — the header collects the whole core face) and lofts
111
+ that rectangle down to the round hose bore with a cubic-eased
112
+ (smoothstep = cubic-Bezier basis) cross-section morph: zero
113
+ slope leaving the face and zero slope arriving at the hose
114
+ stub, so the area contraction is gradual — the implicit
115
+ equivalent of a Bezier-swept loft, not a cone. Then a ringed
116
+ barb run for the hose to clamp onto. Wall = skin throughout.
117
+ Ports without an entry stay plain bores. E.g.
118
+ hose={"a_in": 63.0, "a_out": 50.0, "b": 12.7}.
119
+ Prefer this over hand-rolled manifolds: the axis/sign/face
120
+ math comes from the port layout, which kills the classic bug
121
+ of placing the "-X" manifold at +X and boring a leak path
122
+ straight through the core.
123
+
124
+ Returns (field, ports, plugs, bounds):
125
+ field(P) -> (N,) sdf for `to_mesh(field, *bounds, pitch<=wall/3)`,
126
+ ports -> [{"name": "a_in"|"a_out"|"b_in"|"b_out", "point", "r"}, ...]
127
+ probe spheres for check_networks,
128
+ plugs -> [{"a", "b", "r", "ends": "flat"}, ...] — the port bores (flat-ended, so a plug never bulges past the probes), for check_networks
129
+ to virtually refill (open ports otherwise read as a leak
130
+ through the outside air),
131
+ bounds -> (lo, hi) meshing bounds with clearance.
132
+
133
+ Verify isolation on the meshed part:
134
+ field, ports, plugs, bounds = exchanger_core(...)
135
+ mesh = to_mesh(field, *bounds, pitch=...)
136
+ check_networks(mesh, ports, plugs=plugs)
137
+ """
138
+ sx, sy, sz = (float(v) for v in size)
139
+ hx, hy, hz = 0.5 * sx, 0.5 * sy, 0.5 * sz
140
+ cell = float(cell)
141
+ skin = max(float(wall), 1.2) if skin is None else float(skin)
142
+ plenum = cell if plenum is None else float(plenum)
143
+ wall_ref = float(wall) if not callable(wall) else 1.0
144
+ # Seal caps: deep enough to close the off-fluid's labyrinth throat
145
+ # (~1/3 cell) and stay a few walls thick under pressure — the old
146
+ # cell/2 default read as a massive solid block at each sealed face.
147
+ seal_len = (max(0.35 * cell, 2.5 * wall_ref)
148
+ if seal_len is None else float(seal_len))
149
+ m = max(wall_ref, skin) if margin is None else float(margin)
150
+ # Hood-to-body fillet. None -> adaptive per hood (~1/4 of its hose
151
+ # radius, floored at 2mm): a fixed skin-scale radius read as a hard edge
152
+ # on big hoods — a 63mm hood on a 270mm part needs ~8mm of blend to look
153
+ # lofted, while a 12.7mm stub wants ~2-3mm. An explicit number applies
154
+ # uniformly; 0 disables.
155
+ blend_explicit = blend is not None
156
+ blend = 0.0 if blend is None else float(blend)
157
+
158
+ # Orientation contract: when the caller states which faces each fluid's
159
+ # ports live on (dimensions straight from the spec), refuse a `size`
160
+ # ordering that puts them elsewhere — with the corrected ordering in the
161
+ # message. Fluid A's plenums sit on the ±X faces (dims sy x sz); B's on
162
+ # ±Y (cross: dims sx x sz) or also ±X (counter).
163
+ if a_face is not None or b_face is not None:
164
+ dims = (sx, sy, sz)
165
+
166
+ def _matches(order):
167
+ s = [dims[i] for i in order]
168
+ a_dims = sorted((s[1], s[2]))
169
+ b_dims = sorted((s[1], s[2]) if flow == "counter" else (s[0], s[2]))
170
+ ok_a = a_face is None or all(
171
+ abs(x - y) < 1e-6
172
+ for x, y in zip(a_dims, sorted(float(v) for v in a_face)))
173
+ ok_b = b_face is None or all(
174
+ abs(x - y) < 1e-6
175
+ for x, y in zip(b_dims, sorted(float(v) for v in b_face)))
176
+ return ok_a and ok_b
177
+
178
+ if not _matches((0, 1, 2)):
179
+ fix = next(
180
+ (tuple(dims[i] for i in order)
181
+ for order in permutations(range(3)) if _matches(order)),
182
+ None,
183
+ )
184
+ cur_a = f"{sy:g}x{sz:g}"
185
+ _require(False, (
186
+ f"size {sx:g}x{sy:g}x{sz:g} puts fluid A's ports on the "
187
+ f"{cur_a} faces, but a_face={a_face} was requested"
188
+ + (f" — reorder size to "
189
+ f"{fix[0]:g}x{fix[1]:g}x{fix[2]:g} (A's plenums sit on "
190
+ f"the ±X faces, B's on ±Y)" if fix else
191
+ " — no ordering of these dimensions matches; re-check "
192
+ "the face sizes against the core dimensions")))
193
+
194
+ _require(flow in ("cross", "counter"),
195
+ f'flow must be "cross" or "counter", got {flow!r}')
196
+ _require(min(sx, sy, sz) >= 1.5 * cell,
197
+ f"core {sx:g}x{sy:g}x{sz:g}mm is under 1.5 cells ({cell:g}mm) on "
198
+ f"its smallest side — no room for labyrinths; shrink `cell` or "
199
+ f"grow the core")
200
+ _require(plenum >= 2.0, f"plenum depth {plenum:g}mm is too shallow to "
201
+ f"act as a header — use >= 2mm")
202
+
203
+ # Plenum cavity windows (per flow). Each plenum entry carries its cavity
204
+ # box, the port-bore axis, and the bore's inner mouth (where the tube
205
+ # through the skin begins — also where the verification plug starts).
206
+ if flow == "cross":
207
+ _require(hy - m > 1.0 and hz - m > 1.0 and hx - m > 1.0,
208
+ f"margin {m:g}mm leaves no plenum window on a "
209
+ f"{sx:g}x{sy:g}x{sz:g}mm core")
210
+ outer = (hx + plenum + skin, hy + plenum + skin, hz + skin)
211
+ wy, wz = hy - m, hz - m # A-window half-extents
212
+ wxb, wzb = hx - m, hz - m # B-window half-extents
213
+ # (name, plenum center, plenum half, probe point, window min half-dim)
214
+ # Probes sit at the diagonal corner of the window, OFF the bore axis,
215
+ # so the verification plug (a sphere cap reaching in from the bore's
216
+ # inner mouth) can never swallow them.
217
+ layout = [
218
+ ("a_in", (hx + 0.5 * plenum, 0.0, 0.0), (0.5 * plenum, wy, wz),
219
+ (hx + 0.5 * plenum, 0.5 * wy, 0.5 * wz), min(wy, wz)),
220
+ ("a_out", (-hx - 0.5 * plenum, 0.0, 0.0), (0.5 * plenum, wy, wz),
221
+ (-hx - 0.5 * plenum, 0.5 * wy, 0.5 * wz), min(wy, wz)),
222
+ ("b_in", (0.0, hy + 0.5 * plenum, 0.0), (wxb, 0.5 * plenum, wzb),
223
+ (0.5 * wxb, hy + 0.5 * plenum, 0.5 * wzb), min(wxb, wzb)),
224
+ ("b_out", (0.0, -hy - 0.5 * plenum, 0.0), (wxb, 0.5 * plenum, wzb),
225
+ (0.5 * wxb, -hy - 0.5 * plenum, 0.5 * wzb), min(wxb, wzb)),
226
+ ]
227
+ else: # counter
228
+ _require(hz - 2.0 * m > 1.0 and hy - m > 1.0,
229
+ f"margin {m:g}mm leaves no split plenum window on a "
230
+ f"{sx:g}x{sy:g}x{sz:g}mm core")
231
+ outer = (hx + plenum + skin, hy + skin, hz + skin)
232
+ zc = 0.5 * hz # centers of the upper (A) / lower (B) half-windows
233
+ zh = 0.5 * (hz - 2.0 * m) # their half-heights (divider = 2m at z=0)
234
+ wy = hy - m
235
+ # Counterflow: A runs +X -> -X while B runs -X -> +X.
236
+ layout = [
237
+ ("a_in", (hx + 0.5 * plenum, 0.0, zc), (0.5 * plenum, wy, zh),
238
+ (hx + 0.5 * plenum, 0.5 * wy, zc + 0.5 * zh), min(wy, zh)),
239
+ ("a_out", (-hx - 0.5 * plenum, 0.0, zc), (0.5 * plenum, wy, zh),
240
+ (-hx - 0.5 * plenum, 0.5 * wy, zc + 0.5 * zh), min(wy, zh)),
241
+ ("b_in", (-hx - 0.5 * plenum, 0.0, -zc), (0.5 * plenum, wy, zh),
242
+ (-hx - 0.5 * plenum, 0.5 * wy, -zc - 0.5 * zh), min(wy, zh)),
243
+ ("b_out", (hx + 0.5 * plenum, 0.0, -zc), (0.5 * plenum, wy, zh),
244
+ (hx + 0.5 * plenum, 0.5 * wy, -zc - 0.5 * zh), min(wy, zh)),
245
+ ]
246
+
247
+ up = _up_vector(build)
248
+ offset = 0.0
249
+ if abs(float(split) - 0.5) > 1e-9:
250
+ from .heatx_thermal import sheet_offset
251
+
252
+ _require(0.05 < float(split) < 0.95, f"split {split:g} is A's share of the void, between 0.05 and 0.95")
253
+ offset = sheet_offset(lambda Q: tpms_dist(Q, 1.0, kind), wall_ref / cell, float(split)) * cell
254
+ # Plenum roofs: chamfer planes per plenum box (none without a build
255
+ # direction), then each probe moved to the roofed cavity's roomiest
256
+ # point if a roof cut it off.
257
+ plenum_boxes = []
258
+ probe_room = {} # name -> room around a moved probe, mm
259
+ for li, (name, center, half, probe, wmin) in enumerate(layout):
260
+ c = np.asarray(center, float)
261
+ out = np.zeros(3)
262
+ k = int(np.argmax(np.abs(c) - np.asarray([hx, hy, hz])))
263
+ out[k] = np.sign(c[k])
264
+ planes = _roof_planes(c, np.asarray(half, float), out, up) if up is not None else []
265
+ hooded_here = bool(hose) and isinstance(hose, dict) and (name in hose or name.split("_")[0] in hose)
266
+ hip = _hip_roof(c, np.asarray(half, float), out, up) if up is not None and not hooded_here else None
267
+ plenum_boxes.append((center, half, planes, hip))
268
+ if planes:
269
+ probe_r0 = max(0.6, min(2.5, 0.3 * plenum, 0.45 * wmin))
270
+ cav = lambda Q, c=c, h=half, pl=planes: _cavity(np.atleast_2d(Q), c, h, pl)
271
+ if cav(probe)[0] > -probe_r0 - 0.5:
272
+ probe = _roomiest(cav, c, np.asarray(half, float), out)
273
+ probe_room[name] = -float(cav(probe)[0])
274
+ layout[li] = (name, center, half, tuple(probe), wmin)
275
+
276
+ # Geometry each port needs, independent of port_r: bore axis, the tube's
277
+ # inner mouth (on the plenum's outer wall), the off-axis probe, and the
278
+ # probe->mouth clearance that bounds how far a verification plug may
279
+ # reach back into the plenum without swallowing the probe.
280
+ resolved = []
281
+ min_budget = np.inf
282
+ for name, center, half, probe, wmin in layout:
283
+ c = np.asarray(center, float)
284
+ axis = np.array([np.sign(c[0]), 0.0, 0.0]) if abs(c[0]) > hx else \
285
+ np.array([0.0, np.sign(c[1]), 0.0])
286
+ mouth = c + axis * 0.5 * plenum
287
+ hip = plenum_boxes[[n for n, *_ in layout].index(name)][3]
288
+ if hip is not None:
289
+ mouth = hip["ridge"] # the bore leaves through the roof's ridge
290
+ probe_r = max(0.6, min(2.5, 0.3 * plenum, 0.45 * wmin, 0.8 * probe_room.get(name, np.inf)))
291
+ gap = float(np.linalg.norm(np.asarray(probe, float) - mouth))
292
+ budget = gap - probe_r - 1.2
293
+ resolved.append((name, c, axis, mouth, probe, probe_r, wmin, budget))
294
+ min_budget = min(min_budget, budget)
295
+
296
+ # External hood-loft manifolds (opt-in via `hose`). All axis/sign/face
297
+ # math derives from the port layout — the hand-rolled version of this
298
+ # once placed an "out" manifold on the wrong face and bored a leak path
299
+ # through the core. Wall thickness through the hood is constant (= skin)
300
+ # because the outer shell is the channel morph offset outward by skin.
301
+ # Profile constants (_RING_*) live at module level with the _hood_*
302
+ # helpers.
303
+ barbs = []
304
+ if hose:
305
+ _require(isinstance(hose, dict),
306
+ 'hose must be a dict like {"a_in": 63.0, "b": 12.7}')
307
+ # Window half-extents per port (cross-section of its plenum window,
308
+ # perpendicular to its axis) — the hood's rectangular end.
309
+ window_of = {}
310
+ for lname, center, half, _probe, _wmin in layout:
311
+ cvec = np.asarray(center, float)
312
+ li = int(np.argmax(np.abs(cvec) - np.asarray([hx, hy, hz])))
313
+ cross = [j for j in range(3) if j != li]
314
+ window_of[lname] = (half[cross[0]], half[cross[1]])
315
+ for name, c, axis, mouth, probe, probe_r, _wmin, _budget in resolved:
316
+ spec = hose.get(name, hose.get(name.split("_")[0]))
317
+ if spec is None:
318
+ continue
319
+ if isinstance(spec, dict):
320
+ d = float(spec.get("d", 0.0))
321
+ neck = spec.get("neck")
322
+ barb_len = spec.get("barb")
323
+ else:
324
+ d, neck, barb_len = float(spec), None, None
325
+ _require(d > 2.0,
326
+ f'hose["{name}"] diameter {d:g}mm is not a usable hose '
327
+ f"bore — pass the hose INNER diameter in mm")
328
+ r1 = 0.5 * d
329
+ i = int(np.argmax(np.abs(axis)))
330
+ sign = float(np.sign(axis[i]))
331
+ face = sign * outer[i]
332
+ cross = [j for j in range(3) if j != i]
333
+ win = window_of[name]
334
+ # Neck long enough for a gentle, printable contraction from the
335
+ # window's larger half-extent down to the hose bore — the eased
336
+ # profile spends its steepest slope mid-neck, so give it room.
337
+ neck = (max(8.0, 1.1 * max(max(win) - r1, abs(r1)))
338
+ if neck is None else float(neck))
339
+ barb_len = 14.0 if barb_len is None else float(barb_len)
340
+ _require(barb_len >= 6.0,
341
+ f'hose["{name}"] barb run {barb_len:g}mm is too short '
342
+ f"for a hose clamp — use >= 6mm")
343
+ barbs.append({
344
+ "name": name, "i": i, "sign": sign, "face": face,
345
+ "c": c, "win": win, "cross": cross,
346
+ "r1": r1, "neck": neck, "barb": barb_len,
347
+ })
348
+ # Two barbs sharing a face (counterflow splits each X face between
349
+ # the fluids) must not overlap — merged necks would join the two
350
+ # circuits OUTSIDE the core, a leak no seal can prevent.
351
+ for ai in range(len(barbs)):
352
+ for bi in range(ai + 1, len(barbs)):
353
+ A, B = barbs[ai], barbs[bi]
354
+ if A["i"] != B["i"] or A["sign"] != B["sign"]:
355
+ continue
356
+ dist = float(np.linalg.norm(A["c"] - B["c"]))
357
+ need = (A["r1"] + skin + _RING_AMP) + (B["r1"] + skin + _RING_AMP)
358
+ _require(
359
+ dist >= need + 1.0,
360
+ f'hose barbs "{A["name"]}" and "{B["name"]}" share a face '
361
+ f"and would merge ({dist:.1f}mm apart, need >= "
362
+ f"{need + 1.0:.1f}mm) — merged manifolds join the two "
363
+ f"circuits outside the core; shrink the hose diameters or "
364
+ f"grow the core")
365
+ hooded = {b["name"] for b in barbs}
366
+
367
+ max_r = 0.8 * min(w for _, _, _, _, w in layout)
368
+ auto_r = max(0.8, min(0.6 * plenum, 0.6 * max_r, min_budget))
369
+ if isinstance(port_r, dict):
370
+ # Asymmetric ports: per-name (or per-fluid-prefix) radii, validated
371
+ # against each port's own window and probe budget. Hooded ports have
372
+ # no bore at all (their whole window opens into the hood), so any
373
+ # port_r entry for them is ignored rather than validated against a
374
+ # bore that will never exist.
375
+ per_port_r = {}
376
+ for name, _c, _axis, _mouth, _probe, _pr, wmin_p, budget_p in resolved:
377
+ if name in hooded:
378
+ per_port_r[name] = auto_r
379
+ continue
380
+ r = port_r.get(name, port_r.get(name.split("_")[0]))
381
+ if r is None:
382
+ r = auto_r
383
+ else:
384
+ r = float(r)
385
+ _require(
386
+ r <= 0.8 * wmin_p,
387
+ f'port_r["{name}"] {r:g}mm does not fit its plenum '
388
+ f"window — use <= {0.8 * wmin_p:.1f}mm (a bore can never "
389
+ f"exceed the face's short side; for bigger hose sizes "
390
+ f"add an external lofted manifold that necks OUTSIDE "
391
+ f"the core)")
392
+ _require(
393
+ r <= budget_p + 0.2,
394
+ f'port_r["{name}"] {r:g}mm leaves no room to verify '
395
+ f"isolation (the bore plug would swallow the port "
396
+ f"probe) — use <= {max(budget_p, 0.8):.1f}mm or a "
397
+ f"deeper plenum")
398
+ per_port_r[name] = r
399
+ elif port_r is None:
400
+ per_port_r = {r[0]: auto_r for r in resolved}
401
+ else:
402
+ port_r = float(port_r)
403
+ _require(port_r <= max_r,
404
+ f"port_r {port_r:g}mm does not fit the plenum window — "
405
+ f"use <= {max_r:.1f}mm")
406
+ _require(port_r <= min_budget + 0.2,
407
+ f"port_r {port_r:g}mm leaves no room to verify isolation (the "
408
+ f"bore plug would swallow a port probe) — use <= "
409
+ f"{max(min_budget, 0.8):.1f}mm or a deeper plenum")
410
+ per_port_r = {r[0]: port_r for r in resolved}
411
+
412
+
413
+ # Port bores run outward along each plenum's axis, from mid-plenum through
414
+ # the outer skin (ends past both surfaces so the round caps never show).
415
+ # HOODED ports get no bore: their whole plenum window opens through the
416
+ # skin into the hood instead, so a bore (and its capsule plug) would be
417
+ # redundant geometry. Verification plugs: capsule per plain bore; box per
418
+ # hood (covering the hood's full envelope from mid-skin to past the tip —
419
+ # a capsule can't cap a rectangular window without bulging into the
420
+ # plenum and swallowing the probes).
421
+ ports, bores, plugs, tubes = [], [], [], []
422
+ hips = {n: plenum_boxes[i][3] for i, (n, *_) in enumerate(layout)}
423
+ for name, c, axis, mouth, probe, probe_r, _wmin, _budget in resolved:
424
+ ports.append({"name": name, "point": [float(v) for v in probe], "r": probe_r})
425
+ if name in hooded:
426
+ continue
427
+ r = per_port_r[name]
428
+ exit_pt = mouth + axis * (skin + 2.0)
429
+ # a teardrop bore's peak reaches r / cos(lean) over its axis: the plug covers it
430
+ tear = up is not None and abs(float(axis @ up)) < 0.7
431
+ bores.append((c, exit_pt, r, tear))
432
+ if hips[name] is not None:
433
+ # a wide bore cuts the roof's sides below the ridge: a tube walls it in
434
+ tubes.append((hips[name]["fc"], exit_pt, r + skin))
435
+ plugs.append({"a": mouth.tolist(), "b": exit_pt.tolist(),
436
+ "r": r / np.cos(np.radians(_ROOF_LEAN)) if tear else r, "ends": "flat"})
437
+ for b in barbs:
438
+ i, sign = b["i"], b["sign"]
439
+ half_len = 0.5 * (skin * 0.5 + b["neck"] + b["barb"] + 2.0)
440
+ center = b["c"].copy()
441
+ # Box spans from mid-skin (sealing inside the window tube, clear of
442
+ # the plenum probes) out past the barb tip.
443
+ center[i] = b["face"] - sign * skin * 0.5 + sign * half_len
444
+ h = [0.0, 0.0, 0.0]
445
+ h[i] = half_len
446
+ cross_r = max(max(b["win"]), b["r1"]) + skin + _RING_AMP
447
+ for j in b["cross"]:
448
+ h[j] = cross_r
449
+ plugs.append({"c": center.tolist(), "h": h})
450
+
451
+ core_half = (hx, hy, hz)
452
+
453
+ # core faces looking up along the build direction: the channels' ceilings
454
+ looks_up = {(i, sg): up is not None and sg * float(up[i]) > np.cos(np.radians(_SEAL_FACE))
455
+ for i in range(3) for sg in (1.0, -1.0)}
456
+ looks_down = {(i, sg): up is not None and sg * float(up[i]) < -np.cos(np.radians(_SEAL_FACE))
457
+ for i in range(3) for sg in (1.0, -1.0)}
458
+ # how far into the core a flare reaches (it opens fully, 0.45 cell, at the face)
459
+ flare_depth = _GENTLE_REACH * cell
460
+
461
+ def seals(P):
462
+ s_a, s_b, flare_a, flare_b = (np.zeros(len(P)) for _ in range(4))
463
+ for (i, sg), upward in looks_up.items():
464
+ dist = core_half[i] - sg * P[:, i]
465
+ # a face looking up closes its labyrinths gently (peaked channel
466
+ # ends); any other seals over seal_len as before
467
+ # (where it seals a fluid off the other's plenum, the usual steep
468
+ # seal stays under the gentle one: the gentle ramp's half-thickness
469
+ # is a distance, and the sheet's field overstates distance near its
470
+ # critical points, so on its own it can leave a throat open)
471
+ f = np.maximum(_gentle_seal(dist, cell), seal_ramp(dist, seal_len)) if upward \
472
+ else seal_ramp(dist, seal_len)
473
+ if i == 0 and flow == "cross":
474
+ # B's labyrinth seals shut near A's X faces and vice versa.
475
+ s_b = np.maximum(s_b, f)
476
+ elif i == 1 and flow == "cross":
477
+ s_a = np.maximum(s_a, f)
478
+ elif i == 0:
479
+ # Near the X faces: B seals in the upper half (A's plenums), A
480
+ # in the lower; both blend at z=0 into the solid divider band.
481
+ s_b = np.maximum(s_b, f * seal_ramp(-P[:, 2], 0.5 * cell))
482
+ s_a = np.maximum(s_a, f * seal_ramp(P[:, 2], 0.5 * cell))
483
+ if upward and not (i == 0 or (i == 1 and flow == "cross")):
484
+ # a face no plenum serves: both labyrinths end under it (dead
485
+ # ends against the skin, no leak path: gentle alone)
486
+ f = _gentle_seal(dist, cell)
487
+ s_a = np.maximum(s_a, f)
488
+ s_b = np.maximum(s_b, f)
489
+ elif upward and flow == "cross":
490
+ # the fluid whose plenum sits on this face stays open in its
491
+ # window, and closes gently beside it
492
+ s_open = f * (1.0 - _in_window(P, i, core_half, m))
493
+ if i == 0:
494
+ s_a = np.maximum(s_a, s_open)
495
+ else:
496
+ s_b = np.maximum(s_b, s_open)
497
+ if looks_down[(i, sg)] and flow == "cross" and i < 2:
498
+ # a plenum under the core: its fluid's channels flare open over
499
+ # the last few mm (the sheet's open side thins away), so what
500
+ # hangs over the plenum between the mouths leans instead of
501
+ # ending flat on the face. The other fluid is sealed solid a
502
+ # wall deeper than the flare reaches, so a wall still parts
503
+ # them whatever the flare's shape.
504
+ deep = seal_ramp(dist - flare_depth - wall_ref, seal_len)
505
+ # (and never within a seal and a wall of the face's neighbours,
506
+ # where the flaring fluid is sealed for the other's plenums)
507
+ g = _gentle_seal(dist, cell) * _in_window(P, i, core_half, max(m, seal_len + wall_ref + 1.0))
508
+ if i == 0:
509
+ s_b = np.maximum(s_b, deep)
510
+ flare_a = np.maximum(flare_a, g)
511
+ else:
512
+ s_a = np.maximum(s_a, deep)
513
+ flare_b = np.maximum(flare_b, g)
514
+ return s_a - flare_a, s_b - flare_b
515
+
516
+ def field(P):
517
+ P = np.asarray(P, float)
518
+ s_a, s_b = seals(P)
519
+ core = mask(
520
+ dual_sheet(P, cell, wall, kind, seal_a=s_a, seal_b=s_b, offset=offset),
521
+ box(P, (0.0, 0.0, 0.0), core_half),
522
+ )
523
+ # Everything between the core box and the outer box is solid until a
524
+ # plenum or bore carves it (Kim & Yoo-style implicit booleans) — the
525
+ # skin, plenum side walls, and corner blocks all come from this ring.
526
+ ring = subtract(box(P, (0.0, 0.0, 0.0), outer),
527
+ box(P, (0.0, 0.0, 0.0), core_half))
528
+ solid = union(core, ring)
529
+ for center, half, planes, hip in plenum_boxes:
530
+ if hip is not None:
531
+ solid = union(solid, _hip(P, hip, skin))
532
+ for a, b, rt in tubes:
533
+ solid = union(solid, capsule(P, a, b, rt))
534
+ for center, half, planes, hip in plenum_boxes:
535
+ hole = _cavity(P, np.asarray(center, float), half, planes)
536
+ if hip is not None:
537
+ hole = np.minimum(hole, _hip(P, hip, 0.0))
538
+ solid = subtract(solid, hole)
539
+ for a, b, r, tear in bores:
540
+ hole = capsule(P, a, b, r)
541
+ if tear:
542
+ hole = np.minimum(hole, _teardrop(P, np.asarray(a, float), np.asarray(b, float), r, up))
543
+ solid = subtract(solid, hole)
544
+ # Hoods join with a smooth union (exterior fillet) so the loft reads
545
+ # as one continuous form, not a pipe glued onto a box. Only ever ADDS
546
+ # material around the junction, so it cannot open a leak; the channel
547
+ # subtraction below stays sharp for exactly that reason.
548
+ for b in barbs:
549
+ hood = _barb_solid(P, b, skin)
550
+ k = (blend if blend_explicit
551
+ else max(2.0, skin, 0.25 * b["r1"]))
552
+ solid = smin(solid, hood, k) if k > 0 else union(solid, hood)
553
+ for b in barbs:
554
+ solid = subtract(solid, _barb_channel(P, b, skin))
555
+ return solid
556
+
557
+ pad = 1.0
558
+ reach_out = list(outer) # hip roofs stand out past the box
559
+ for *_, hip in plenum_boxes:
560
+ if hip is not None:
561
+ k = int(np.argmax(np.abs(hip["e"])))
562
+ reach_out[k] = max(reach_out[k], abs(float(hip["ridge"][k])) + skin + 3.0)
563
+ lo = [-(o + pad) for o in reach_out]
564
+ hi = [o + pad for o in reach_out]
565
+ for b in barbs:
566
+ i, sign = b["i"], b["sign"]
567
+ reach = abs(b["face"]) + b["neck"] + b["barb"] + 2.0 + pad
568
+ if sign > 0:
569
+ hi[i] = max(hi[i], reach)
570
+ else:
571
+ lo[i] = min(lo[i], -reach)
572
+ r_out = b["r1"] + skin + _RING_AMP + pad
573
+ for j in range(3):
574
+ if j == i:
575
+ continue
576
+ hi[j] = max(hi[j], float(b["c"][j]) + r_out)
577
+ lo[j] = min(lo[j], float(b["c"][j]) - r_out)
578
+ bounds = (tuple(lo), tuple(hi))
579
+
580
+ def exclude(P):
581
+ """The TPMS core as the sheet itself shapes it: a sheet gyroid prints
582
+ unsupported, so ceiling counts leave it out (but not its seals)."""
583
+ P = np.asarray(P, float)
584
+ s_a, s_b = seals(P)
585
+ return np.all(np.abs(P) < np.asarray(core_half) - 1.0, axis=1) & (s_a < 1e-3) & (s_b < 1e-3)
586
+
587
+ field.exclude = exclude
588
+ field.build = None if up is None else up.tolist()
589
+ return field, ports, plugs, bounds
590
+
591
+
592
+ # ---- print shaping along a build direction ----------------------------------
593
+ # roofs and teardrops lean at 50 degrees from level: a margin on the 45 that
594
+ # powder-bed metal and FDM hold unsupported (a cube-diagonal build leaves box
595
+ # faces at 54.7, which pass untouched)
596
+ _ROOF_LEAN = 50.0
597
+ # a core face within this many degrees of level is a ceiling over the channels
598
+ # that end on it (a 45 degree face, from an edge-diagonal build, is left be)
599
+ _SEAL_FACE = 40.0
600
+ # closing slope under such a face: the sheet's half-thickness grows this many
601
+ # mm per mm toward it. Measured on a 60x48x32 mm, 8 mm cell block built +z:
602
+ # flat ceiling 17.4 -> 5.0 cm^2 for +4.2 cm^3 at 1.0; 0.4 bought ~1 cm^2
603
+ # less for 1.5x the metal
604
+ _SEAL_SLOPE = 1.0
605
+
606
+
607
+ def _up_vector(build):
608
+ if build is None:
609
+ return None
610
+ from .am import build_vector, parse_build_direction
611
+
612
+ if isinstance(build, str) and build.strip().lower() == "auto":
613
+ raise ValueError('build="auto" is not a shape: rank directions first with '
614
+ "cavities.rank_build_directions(field, bounds, plugs=plugs), then pass the best")
615
+ return build_vector(parse_build_direction(build))
616
+
617
+
618
+ _GENTLE_REACH = 0.45 / _SEAL_SLOPE # cells: how deep a gentle closure starts
619
+
620
+
621
+ def _gentle_seal(dist, cell):
622
+ """Seal weight for dual_sheet that thickens the sheet by _SEAL_SLOPE mm per
623
+ mm toward a face, reaching ~0.45 cell of half-thickness at it (both
624
+ labyrinths closed) — the sheet's swallow scale is 3.5 cell."""
625
+ reach = _GENTLE_REACH * cell
626
+ return np.clip(_SEAL_SLOPE * (reach - np.asarray(dist, float)) / (3.5 * cell), 0.0, 1.0)
627
+
628
+
629
+ def _in_window(P, i, core_half, m):
630
+ """1 inside the plenum window on a core face normal to axis i (inset by
631
+ the margin m), falling to 0 over 2 mm outside it."""
632
+ w = np.ones(len(P))
633
+ for j in range(3):
634
+ if j != i:
635
+ w = np.minimum(w, np.clip((core_half[j] - m - np.abs(P[:, j])) / 2.0 + 1.0, 0.0, 1.0))
636
+ return w
637
+
638
+
639
+ def _roof_planes(c, half, out, up):
640
+ """Chamfer planes (point, unit normal) that cut a box cavity's faces
641
+ leaning flatter than 45 degrees from level down to _ROOF_LEAN. Per such face,
642
+ the cheapest of: a mono-pitch roof falling along any of its in-plane
643
+ axes (never toward the core: that would close the core's window), or a
644
+ gable on either axis."""
645
+ cos_lean = np.cos(np.radians(_ROOF_LEAN))
646
+ planes = []
647
+ for i in range(3):
648
+ for s in (1.0, -1.0):
649
+ e = np.zeros(3)
650
+ e[i] = s
651
+ if float(e @ up) <= np.cos(np.radians(45.0)) + 1e-3:
652
+ continue # self-supporting as it is
653
+ if abs(out[i]) > 0.5:
654
+ # the port face, or the window onto the core: nothing inside the
655
+ # box can roof the port face without closing the window; left
656
+ # flat (and counted)
657
+ continue
658
+ best = None
659
+ for j in range(3):
660
+ if j == i:
661
+ continue
662
+ k = 3 - i - j
663
+ for sg in (1.0, -1.0):
664
+ a = np.zeros(3)
665
+ a[j] = sg
666
+ t = _tilt(e, a, up, cos_lean)
667
+ if t is None:
668
+ continue
669
+ if float(a @ out) < -0.5:
670
+ continue # mono falling toward the core
671
+ edge = c + e * half[i] - a * half[j]
672
+ drop = min(t * 2 * half[j], 2 * half[i])
673
+ cost = 0.5 * 2 * half[j] * drop * 2 * half[k]
674
+ cand = [(edge, (e + t * a) / np.hypot(1.0, t))]
675
+ if best is None or cost < best[0]:
676
+ best = (cost, cand)
677
+ # gable on axis j: two planes through the ridge on the face's centre line
678
+ ts = [_tilt(e, sg * np.eye(3)[j], up, cos_lean) for sg in (1.0, -1.0)]
679
+ if None in ts:
680
+ continue
681
+ cost = sum(0.5 * half[j] * min(t * half[j], 2 * half[i]) * 2 * half[k] for t in ts)
682
+ if abs(float(np.eye(3)[j] @ out)) > 0.5:
683
+ cost *= 2.5 # it cuts into the core's window
684
+ ridge = c + e * half[i]
685
+ cand = [(ridge, (e + t * sg * np.eye(3)[j]) / np.hypot(1.0, t)) for t, sg in zip(ts, (1.0, -1.0))]
686
+ if best is None or cost < best[0]:
687
+ best = (cost, cand)
688
+ if best is not None:
689
+ planes.extend(best[1])
690
+ return planes
691
+
692
+
693
+ def _hip_roof(c, half, out, up):
694
+ """A plenum whose port face looks up gets a hip roof over that face,
695
+ outside the box: its four sides rise at _ROOF_LEAN from the face's
696
+ edges to a ridge, and the port bore leaves through the ridge. None when
697
+ the port face leans 45 degrees or more."""
698
+ if float(out @ up) <= np.cos(np.radians(45.0)) + 1e-3:
699
+ return None
700
+ i = int(np.argmax(np.abs(out)))
701
+ fc = c + out * half[i]
702
+ sa, ca = np.sin(np.radians(_ROOF_LEAN)), np.cos(np.radians(_ROOF_LEAN))
703
+ planes = []
704
+ for j in range(3):
705
+ if j == i:
706
+ continue
707
+ for sg in (1.0, -1.0):
708
+ a = np.zeros(3)
709
+ a[j] = sg
710
+ # through the face's edge on the +a side, leaning in at the roof angle
711
+ planes.append((fc + a * half[j], ca * a + sa * out))
712
+ rise = min(half[j] for j in range(3) if j != i) * np.tan(np.radians(_ROOF_LEAN))
713
+ return {"fc": fc, "e": out, "planes": planes, "ridge": fc + out * rise}
714
+
715
+
716
+ def _hip(P, hip, offset):
717
+ """The hip roof's space (offset 0) or its body (offset = skin)."""
718
+ d = -((P - hip["fc"]) @ hip["e"]) - 0.5 # from just under the face
719
+ for p, n in hip["planes"]:
720
+ d = np.maximum(d, (P - p) @ n - offset)
721
+ return d
722
+
723
+
724
+ def _tilt(e, a, up, cos_lean):
725
+ """Least t >= 0 with normalize(e + t a) . up <= cos_lean, or None."""
726
+ for t in np.linspace(0.0, 12.0, 2401):
727
+ if (float(e @ up) + t * float(a @ up)) / np.hypot(1.0, t) <= cos_lean:
728
+ return float(t)
729
+ return None
730
+
731
+
732
+ def _cavity(P, c, half, planes):
733
+ d = box(P, c, half)
734
+ for p, n in planes:
735
+ d = np.maximum(d, (P - p) @ n)
736
+ return d
737
+
738
+
739
+ def _roomiest(cav, c, half, out, n=9):
740
+ """The point of a roofed plenum farthest inside it (off the port axis)."""
741
+ g = [np.linspace(-0.8, 0.8, n) * h for h in half]
742
+ Q = np.stack(np.meshgrid(*g, indexing="ij"), -1).reshape(-1, 3) + c
743
+ d = cav(Q)
744
+ # stay off the bore axis through the plenum centre
745
+ lateral = np.linalg.norm((Q - c) - np.outer((Q - c) @ out, out), axis=1)
746
+ d = np.where(lateral > 0.3 * float(np.min(half[np.abs(out) < 0.5])), d, np.inf)
747
+ return Q[int(np.argmin(d))]
748
+
749
+
750
+ def _teardrop(P, a, b, r, up):
751
+ """The 50 degree peak over a sideways bore from a to b (the bore's own
752
+ capsule supplies the round part)."""
753
+ length = float(np.linalg.norm(b - a))
754
+ ax = (b - a) / length
755
+ v = up - (up @ ax) * ax
756
+ v /= np.linalg.norm(v)
757
+ w = np.cross(ax, v)
758
+ q = P - a
759
+ t, u, l = q @ ax, q @ v, np.abs(q @ w)
760
+ sa, ca = np.sin(np.radians(_ROOF_LEAN)), np.cos(np.radians(_ROOF_LEAN))
761
+ return np.maximum(np.maximum(l * sa + u * ca - r, -u), np.maximum(-t, t - length))
762
+
763
+
764
+ # Hood/barb geometry constants shared by the profile helpers below (defined
765
+ # here, not inside exchanger_core, so the helpers stay module-level and
766
+ # testable).
767
+ _RING_SPACING = 4.0
768
+ _RING_AMP = 1.0
769
+ _TIP_LEAD = 2.0
770
+
771
+
772
+ def _hood_frame(P, b):
773
+ """(u, dy, dz, rr) for a hood: u = mm outward from its skin face along
774
+ its port axis; (dy, dz) = cross offsets from the port centerline in the
775
+ window's own axes; rr = radial distance from that centerline."""
776
+ i, sign = b["i"], b["sign"]
777
+ u = sign * (P[:, i] - b["face"])
778
+ j, k = b["cross"]
779
+ dy = P[:, j] - b["c"][j]
780
+ dz = P[:, k] - b["c"][k]
781
+ rr = np.sqrt(dy * dy + dz * dz)
782
+ return u, dy, dz, rr
783
+
784
+
785
+ def _hood_section(u, dy, dz, rr, b):
786
+ """Cross-section field of the hood CHANNEL at each point: the plenum
787
+ window rectangle at the face morphing to the hose circle at the neck's
788
+ end, blended with a cubic ease (smoothstep — the cubic-Bezier basis, so
789
+ the section is tangent to the window at u=0 and tangent to the straight
790
+ hose stub at u=neck: a swept loft, not a cone). Negative inside the
791
+ channel. Inside the face (u<=0) it stays the pure window rectangle, so
792
+ the subtraction just opens the window through the skin — it can never
793
+ cut new paths into the labyrinths."""
794
+ wy, wz = b["win"]
795
+ t = np.clip(u / max(b["neck"], 1e-6), 0.0, 1.0)
796
+ s = t * t * (3.0 - 2.0 * t) # smoothstep: cubic ease, C1 at both ends
797
+ d_rect = np.maximum(np.abs(dy) - wy, np.abs(dz) - wz)
798
+ d_circ = rr - b["r1"]
799
+ return (1.0 - s) * d_rect + s * d_circ
800
+
801
+
802
+ def _barb_solid(P, b, skin):
803
+ """Outer solid of one hood manifold: the channel morph offset outward by
804
+ `skin` (constant wall) through the neck, then a ringed hose-barb run
805
+ with a chamfered lead-in tip."""
806
+ u, dy, dz, rr = _hood_frame(P, b)
807
+ neck, barb_len = b["neck"], b["barb"]
808
+ shell = _hood_section(u, dy, dz, rr, b) - skin
809
+ ub = u - neck
810
+ in_barb = (u > neck) & (u <= neck + barb_len)
811
+ # Sawtooth rings on the straight run: the hose slides on over the ramps
812
+ # and grips against the sharp faces.
813
+ ring = _RING_AMP * ((ub % _RING_SPACING) / _RING_SPACING)
814
+ R1 = b["r1"] + skin
815
+ shell = np.where(in_barb, rr - (R1 + ring), shell)
816
+ shell = np.where(u > neck + barb_len - _TIP_LEAD, rr - (R1 - 0.6), shell)
817
+ total = neck + barb_len
818
+ return np.maximum(shell, np.maximum(u - total, -u))
819
+
820
+
821
+ def _barb_channel(P, b, skin):
822
+ """Flow channel through one hood: the full plenum window just inside the
823
+ face (opening the whole core face into the header), the eased rect-to-
824
+ round morph over the neck, then the straight hose bore through the barb
825
+ run and out past the tip."""
826
+ u, dy, dz, rr = _hood_frame(P, b)
827
+ d = _hood_section(u, dy, dz, rr, b)
828
+ total = b["neck"] + b["barb"]
829
+ return np.maximum(
830
+ d, np.maximum(u - (total + 2.0), -(u + skin + 2.0))
831
+ )