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