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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|