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/drains.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drain and escape holes for field parts with sealed voids.
|
|
3
|
+
|
|
4
|
+
A closed skin over a lattice (or any hollow in a field graph) seals the
|
|
5
|
+
powder in (SLS, MJF, metal powder bed) or the uncured resin (SLA, DLP,
|
|
6
|
+
volumetric). `drain_graph` finds every sealed void of a FieldGraph's
|
|
7
|
+
output on a voxel grid of the field itself and opens each one:
|
|
8
|
+
|
|
9
|
+
- voids too small to hold a hole (under the escape hole's own ball by
|
|
10
|
+
default) are filled solid;
|
|
11
|
+
- every other void gets holes well over the process's minimum escape hole
|
|
12
|
+
(1.5 x it or 2 mm over, whichever is larger: SLS 6 mm, MJF 7.5, resin
|
|
13
|
+
5.5, volumetric 5; LPBF 6.8, an M8 tap drill, so it can be plugged), two
|
|
14
|
+
per void, then more until no powder is over 60 mm (`maxPath`) from one:
|
|
15
|
+
the calmest flat site first, lowest along the build, then the calmest one
|
|
16
|
+
well away from it, highest along the build, so air or rinse flows
|
|
17
|
+
through (and resin has its drain at the bottom and its vent at the top);
|
|
18
|
+
- a site must be just under the skin with a straight way out, on a face
|
|
19
|
+
flat out past the boss (a hole on an edge loses half its boss and sits
|
|
20
|
+
where the stress gathers), clear of anything named in `keepClear`;
|
|
21
|
+
- each hole gets a solid boss (a hole is a stress raiser, about 3x in a
|
|
22
|
+
plate, and the lattice walls it cuts would otherwise be loose slivers),
|
|
23
|
+
smooth-unioned in;
|
|
24
|
+
- then the grid is labelled again, and voids a boss walled off get the
|
|
25
|
+
same treatment, for up to four rounds.
|
|
26
|
+
|
|
27
|
+
The calm is the graph's own stress: the first `result` node (von Mises),
|
|
28
|
+
or the node named by `stress`. Without one, sites are ranked by height
|
|
29
|
+
along the build alone.
|
|
30
|
+
|
|
31
|
+
The holes and bosses are ordinary graph nodes (cylinders), added to the
|
|
32
|
+
graph, so the drilled part meshes, verifies and saves like any other.
|
|
33
|
+
Verification on the drilled field: no sealed void left (the same grid
|
|
34
|
+
labelling), the powder path from every point of each void to its nearest
|
|
35
|
+
hole through the void (heatx's geodesic; nothing unreached), and
|
|
36
|
+
processcheck's escape check (enclosed voids, chambers that only open
|
|
37
|
+
through gaps narrower than the hole). The mesh run_field writes is checked
|
|
38
|
+
again for sealed shells.
|
|
39
|
+
|
|
40
|
+
Units are millimetres.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import math
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
from scipy import ndimage
|
|
49
|
+
|
|
50
|
+
__all__ = ["DrainOptions", "drain_graph", "drains_wanted", "hole_defaults"]
|
|
51
|
+
|
|
52
|
+
# processes with something to drain, and the ones processcheck has no card for
|
|
53
|
+
_DRAINED = ("sls", "mjf", "sla", "dlp", "volumetric", "lpbf")
|
|
54
|
+
_EXTRA = {"volumetric": 3.0, "lpbf": 4.0} # minimum escape holes processcheck has no card for
|
|
55
|
+
_LPBF_DRILL = 6.8 # an M8 tap drill: big, and it can be plugged
|
|
56
|
+
_TINY = 1.0 # mm^3: smaller is aliasing, not a void
|
|
57
|
+
_ROUNDS = 6 # relabellings; holes in the first four, then fill only
|
|
58
|
+
_DRILL_ROUNDS = 4
|
|
59
|
+
_MAX_HOLES = 8 # a void, with maxPath
|
|
60
|
+
_BUDGET = 16_000_000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class DrainOptions:
|
|
65
|
+
diameter: float | None = None # mm (default see hole_defaults: SLS 6, MJF 7.5, resin 5.5, LPBF 6.8)
|
|
66
|
+
count: int = 2 # holes per void
|
|
67
|
+
stress: str | None = "auto" # node whose value ranks the sites: "auto" = the first result node, None = none
|
|
68
|
+
boss: float | None = None # mm of solid round each hole (default max(1.5 x min wall, 0.4 x radius))
|
|
69
|
+
blend: float | None = None # mm fillet where the boss meets the part (default the boss wall)
|
|
70
|
+
max_depth: float = 6.0 # mm: sites at most this far under the outside
|
|
71
|
+
fill_below: float | None = None # mm^3: fill smaller voids solid (default the escape ball's volume)
|
|
72
|
+
keep_clear: list = field(default_factory=list) # solid nodes the bosses stay out of
|
|
73
|
+
clearance: float = 1.0 # mm from those
|
|
74
|
+
max_path: float | None = 60.0 # mm: more holes (up to 8 a void) while powder is farther than this from one (None: off)
|
|
75
|
+
pitch: float | None = None # mm grid (default 0.75 x min wall, at most diameter / 8)
|
|
76
|
+
|
|
77
|
+
_KEYS = {"diameter": "diameter", "count": "count", "stress": "stress", "boss": "boss", "blend": "blend",
|
|
78
|
+
"maxDepth": "max_depth", "fillBelow": "fill_below", "keepClear": "keep_clear",
|
|
79
|
+
"clearance": "clearance", "pitch": "pitch",
|
|
80
|
+
"maxPath": "max_path"}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_spec(cls, spec) -> "DrainOptions":
|
|
84
|
+
o = cls()
|
|
85
|
+
if spec in (None, True, "auto", "yes"):
|
|
86
|
+
return o
|
|
87
|
+
if not isinstance(spec, dict):
|
|
88
|
+
raise ValueError("drains is 'auto', true, false or an object")
|
|
89
|
+
for k, v in spec.items():
|
|
90
|
+
if k == "enabled":
|
|
91
|
+
continue
|
|
92
|
+
if k not in cls._KEYS:
|
|
93
|
+
raise ValueError(f"drains: unknown key {k!r} ({', '.join(cls._KEYS)})")
|
|
94
|
+
setattr(o, cls._KEYS[k], v)
|
|
95
|
+
if o.diameter is not None and not float(o.diameter) > 0:
|
|
96
|
+
raise ValueError("drains.diameter is a hole diameter in mm, > 0")
|
|
97
|
+
if int(o.count) < 1:
|
|
98
|
+
raise ValueError("drains.count is at least 1")
|
|
99
|
+
o.count = int(o.count)
|
|
100
|
+
if isinstance(o.keep_clear, str):
|
|
101
|
+
o.keep_clear = [o.keep_clear]
|
|
102
|
+
return o
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def drains_wanted(spec, process) -> bool:
|
|
106
|
+
"""`drains` in a field graph: 'auto' (default) drills for powder and
|
|
107
|
+
resin processes and LPBF; true/an object always; false never."""
|
|
108
|
+
if spec is False or spec == "no":
|
|
109
|
+
return False
|
|
110
|
+
if isinstance(spec, dict):
|
|
111
|
+
return spec.get("enabled", True) is not False
|
|
112
|
+
if spec is True or spec == "yes":
|
|
113
|
+
return True
|
|
114
|
+
return process is not None and process.kind in _DRAINED
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def hole_defaults(process):
|
|
118
|
+
"""(drilled diameter mm, the process's minimum escape hole mm, holes per
|
|
119
|
+
void) for a process (spec.Process or name).
|
|
120
|
+
|
|
121
|
+
Powder removal is where a part fails in production, so the drilled hole
|
|
122
|
+
is well over the bare minimum: 1.5 x the minimum or 2 mm over it,
|
|
123
|
+
whichever is larger, rounded up to half a millimetre (SLS 6, MJF 7.5,
|
|
124
|
+
SLA/DLP 5.5, volumetric 5). A hole at the minimum is a narrow exit to
|
|
125
|
+
processcheck's escape ball on any grid, and to powder that has caked or
|
|
126
|
+
resin that has thickened. LPBF drills 6.8 mm, an M8 tap drill, so it
|
|
127
|
+
can still be plugged."""
|
|
128
|
+
from .processcheck import RULES
|
|
129
|
+
|
|
130
|
+
kind = str(getattr(process, "kind", process) or "").lower()
|
|
131
|
+
esc = getattr(process, "escape_hole", None)
|
|
132
|
+
if kind in RULES:
|
|
133
|
+
least, per = float(esc or RULES[kind]["escapeHole"]), int(RULES[kind]["escapeHoles"])
|
|
134
|
+
else:
|
|
135
|
+
least, per = float(esc or _EXTRA.get(kind, 4.0)), 2
|
|
136
|
+
if kind == "lpbf" and esc is None:
|
|
137
|
+
return _LPBF_DRILL, least, per
|
|
138
|
+
return math.ceil(2.0 * max(1.5 * least, least + 2.0)) / 2.0, least, per
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _as_process(process):
|
|
142
|
+
if process is None or hasattr(process, "kind"):
|
|
143
|
+
return process
|
|
144
|
+
from .spec import _process
|
|
145
|
+
|
|
146
|
+
return _process(process)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _up(build, process):
|
|
150
|
+
from .am import parse_build_direction
|
|
151
|
+
|
|
152
|
+
b = build if build is not None else getattr(process, "build", None)
|
|
153
|
+
if b is None or b == "auto":
|
|
154
|
+
return np.array([0.0, 0.0, 1.0])
|
|
155
|
+
b = parse_build_direction(b) if not (isinstance(b, tuple) and len(b) == 2) else b
|
|
156
|
+
if len(b) == 2:
|
|
157
|
+
u = np.zeros(3)
|
|
158
|
+
u[b[0]] = b[1]
|
|
159
|
+
return u
|
|
160
|
+
return np.asarray(b, float)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sample(fn, lo, dims, p, progress=None):
|
|
164
|
+
"""fn at lo + ijk * p over the grid, float32."""
|
|
165
|
+
F = np.empty(tuple(dims), np.float32)
|
|
166
|
+
ys = lo[1] + np.arange(dims[1]) * p
|
|
167
|
+
zs = lo[2] + np.arange(dims[2]) * p
|
|
168
|
+
Y, Z = np.meshgrid(ys, zs, indexing="ij")
|
|
169
|
+
per = max(1, 1_000_000 // (dims[1] * dims[2]))
|
|
170
|
+
for i0 in range(0, dims[0], per):
|
|
171
|
+
i1 = min(dims[0], i0 + per)
|
|
172
|
+
xs = lo[0] + np.arange(i0, i1) * p
|
|
173
|
+
P = np.stack([np.repeat(xs, Y.size), np.tile(Y.ravel(), len(xs)), np.tile(Z.ravel(), len(xs))], axis=1)
|
|
174
|
+
F[i0:i1] = np.asarray(fn(P), np.float32).reshape(i1 - i0, dims[1], dims[2])
|
|
175
|
+
if progress:
|
|
176
|
+
progress({"stage": "drains", "done": i1, "of": int(dims[0])})
|
|
177
|
+
return F
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _box(lo, p, dims, a, b, reach):
|
|
181
|
+
"""Grid block round segment a-b out to `reach`, and its points."""
|
|
182
|
+
i0 = np.floor((np.minimum(a, b) - reach - lo) / p).astype(int).clip(0)
|
|
183
|
+
i1 = (np.ceil((np.maximum(a, b) + reach - lo) / p).astype(int) + 1).clip(None, np.asarray(dims))
|
|
184
|
+
sl = tuple(slice(x0, x1) for x0, x1 in zip(i0, i1))
|
|
185
|
+
ii = np.stack(np.meshgrid(*[np.arange(x0, x1) for x0, x1 in zip(i0, i1)], indexing="ij"), -1)
|
|
186
|
+
return sl, lo + ii.reshape(-1, 3) * p, ii.shape[:3]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mask_sdf(mask, p):
|
|
190
|
+
"""Signed distance (negative inside) of a voxel mask, boundary halfway
|
|
191
|
+
between voxel centres."""
|
|
192
|
+
return np.where(mask, -(ndimage.distance_transform_edt(mask) - 0.5) * p,
|
|
193
|
+
(ndimage.distance_transform_edt(~mask) - 0.5) * p).astype(np.float32)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _labels(F, p):
|
|
197
|
+
"""The sealed voids, labelled (6-connected), how many, the outside (a
|
|
198
|
+
mask) and the voids' sizes in voxels. What reaches the outside is
|
|
199
|
+
worked out on the void shrunk by half a voxel, so a wall thinner than a
|
|
200
|
+
voxel (where a lattice wall grazes the skin) still seals as it does in
|
|
201
|
+
the mesh; the fringe of open space (three voxels of void out from it:
|
|
202
|
+
a blend's field is not a true distance, so its fringe runs wider) is
|
|
203
|
+
open too. Everything
|
|
204
|
+
else that is not solid is sealed, down to the thinnest sliver."""
|
|
205
|
+
core, n = ndimage.label(F >= 0.5 * p)
|
|
206
|
+
border = set()
|
|
207
|
+
for face in (core[0], core[-1], core[:, 0], core[:, -1], core[:, :, 0], core[:, :, -1]):
|
|
208
|
+
border.update(np.unique(face).tolist())
|
|
209
|
+
border.discard(0)
|
|
210
|
+
outside = np.isin(core, list(border))
|
|
211
|
+
del core
|
|
212
|
+
void = F >= 0
|
|
213
|
+
outside = ndimage.binary_dilation(outside, iterations=3, mask=void) | outside
|
|
214
|
+
lbl, n = ndimage.label(void & ~outside)
|
|
215
|
+
sizes = np.bincount(lbl.ravel(), minlength=n + 1)
|
|
216
|
+
return lbl, n, outside, sizes
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def drain_graph(g, process=None, opts: DrainOptions | None = None, build=None, min_wall=None, bounds=None,
|
|
220
|
+
pitch=None, progress=None) -> dict:
|
|
221
|
+
"""Drill the sealed voids of `g`'s output (see the module docstring).
|
|
222
|
+
Adds the fill, boss and bore nodes to `g` and points its output at the
|
|
223
|
+
drilled part ('drained'); returns the report. `process` is a name, a
|
|
224
|
+
study-style process object or a spec.Process; `build` the build
|
|
225
|
+
direction (default the process's, else +z); `pitch` the grid (default
|
|
226
|
+
drains.pitch, else 0.75 x the minimum wall, at most diameter / 8): give
|
|
227
|
+
the mesh's pitch so the specks the mesh would see are seen here too."""
|
|
228
|
+
from .fields import _smin
|
|
229
|
+
from .heatx import _TAPS, _geodesic
|
|
230
|
+
from .processcheck import _escapes
|
|
231
|
+
from .shapes import Cylinder
|
|
232
|
+
|
|
233
|
+
proc = _as_process(process)
|
|
234
|
+
o = opts or DrainOptions()
|
|
235
|
+
dia, least, per_void = hole_defaults(proc)
|
|
236
|
+
if o.diameter is not None:
|
|
237
|
+
dia = float(o.diameter)
|
|
238
|
+
count = int(o.count) if o.count is not None else per_void
|
|
239
|
+
r = 0.5 * dia
|
|
240
|
+
min_wall = float(min_wall if min_wall is not None else getattr(proc, "min_feature", None) or 0.8)
|
|
241
|
+
bw = float(o.boss) if o.boss is not None else max(1.5 * min_wall, 0.4 * r)
|
|
242
|
+
k = float(o.blend) if o.blend is not None else bw
|
|
243
|
+
fill_below = float(o.fill_below) if o.fill_below is not None else 4.0 / 3.0 * np.pi * r ** 3
|
|
244
|
+
up = _up(build, proc)
|
|
245
|
+
for name in o.keep_clear:
|
|
246
|
+
if name not in g.nodes:
|
|
247
|
+
raise ValueError(f"drains.keepClear: {name!r} is not a node")
|
|
248
|
+
sname = o.stress
|
|
249
|
+
if sname == "auto":
|
|
250
|
+
sname = next((n for n, nd in g.nodes.items() if nd.op == "result"
|
|
251
|
+
and nd.spec.get("quantity", "vonMises") == "vonMises"), None)
|
|
252
|
+
elif sname is not None and sname not in g.nodes:
|
|
253
|
+
raise ValueError(f"drains.stress: {sname!r} is not a node")
|
|
254
|
+
|
|
255
|
+
if bounds is None:
|
|
256
|
+
if "bounds" in g.doc:
|
|
257
|
+
bounds = (g.doc["bounds"]["min"], g.doc["bounds"]["max"])
|
|
258
|
+
else:
|
|
259
|
+
bounds = g.bounds()
|
|
260
|
+
if bounds is None:
|
|
261
|
+
raise ValueError("drains need the part's bounds: give the graph 'bounds'")
|
|
262
|
+
blo, bhi = np.asarray(bounds[0], float), np.asarray(bounds[1], float)
|
|
263
|
+
p = float(o.pitch or pitch or min(0.75 * min_wall, dia / 8.0))
|
|
264
|
+
pad = (r + bw + k) + 3 * p
|
|
265
|
+
p = max(p, float((np.prod(bhi - blo + 2 * pad) / _BUDGET) ** (1 / 3)))
|
|
266
|
+
pad = (r + bw + k) + 3 * p
|
|
267
|
+
lo = blo - pad
|
|
268
|
+
dims = np.ceil((bhi + pad - lo) / p).astype(int) + 1
|
|
269
|
+
vox = p ** 3
|
|
270
|
+
base = g.output
|
|
271
|
+
F0 = _sample(g.field(base), lo, dims, p, progress)
|
|
272
|
+
|
|
273
|
+
# the stress the sites are ranked by, and its scale (the part's own peak)
|
|
274
|
+
def stress_at(P):
|
|
275
|
+
return g.evaluate(P, sname) if sname else np.zeros(len(P))
|
|
276
|
+
|
|
277
|
+
s_ref = 1.0
|
|
278
|
+
if sname:
|
|
279
|
+
ijk = np.argwhere(F0 < 0)
|
|
280
|
+
if len(ijk) > 20_000:
|
|
281
|
+
ijk = ijk[np.random.default_rng(0).choice(len(ijk), 20_000, replace=False)]
|
|
282
|
+
s_ref = float(np.percentile(stress_at(lo + ijk * p), 99.5)) or 1.0
|
|
283
|
+
|
|
284
|
+
lbl, n, outside0, sizes = _labels(F0, p)
|
|
285
|
+
every = list(range(1, n + 1))
|
|
286
|
+
sealed0 = [i for i in every if sizes[i] * vox >= _TINY]
|
|
287
|
+
voids = []
|
|
288
|
+
if sealed0:
|
|
289
|
+
cent = ndimage.center_of_mass(np.ones_like(lbl, bool), lbl, sealed0)
|
|
290
|
+
voids = [{"volume": round(float(sizes[i] * vox), 1), "center": np.round(lo + np.asarray(c) * p, 2).tolist(),
|
|
291
|
+
"id": i} for i, c in zip(sealed0, cent)]
|
|
292
|
+
rep = {"process": getattr(proc, "kind", None), "diameter": dia, "minimumHole": least, "holesPerVoid": count, "boss": round(bw, 3),
|
|
293
|
+
"pitch": round(p, 4), "stressNode": sname, "sealedBefore": len(voids),
|
|
294
|
+
"sealedVolumeBefore": round(float(sum(v["volume"] for v in voids)), 1),
|
|
295
|
+
# specks: sealed bits under a cubic millimetre (where a wall grazes the skin), filled
|
|
296
|
+
"specks": len(every) - len(voids)}
|
|
297
|
+
if not every:
|
|
298
|
+
rep.update({"holes": [], "voids": [], "filledVolume": 0.0, "sealedAfter": 0, "sealedVolumeAfter": 0.0,
|
|
299
|
+
"ok": True})
|
|
300
|
+
return rep
|
|
301
|
+
|
|
302
|
+
holes, fill = [], np.zeros(tuple(dims), bool)
|
|
303
|
+
lbl0 = lbl
|
|
304
|
+
F = F0
|
|
305
|
+
rounds = 0
|
|
306
|
+
for rnd in range(_ROUNDS + 1):
|
|
307
|
+
outside = outside0
|
|
308
|
+
if rnd:
|
|
309
|
+
lbl, n, outside, sizes = _labels(F, p)
|
|
310
|
+
sealed = list(range(1, n + 1))
|
|
311
|
+
if not sealed or rnd == _ROUNDS:
|
|
312
|
+
break
|
|
313
|
+
rounds = rnd + 1
|
|
314
|
+
dist_out, near = ndimage.distance_transform_edt(~outside, return_indices=True)
|
|
315
|
+
dist_out = (dist_out * p).astype(np.float32)
|
|
316
|
+
objs = ndimage.find_objects(lbl)
|
|
317
|
+
placed = 0
|
|
318
|
+
for i in sorted(sealed, key=lambda i: -sizes[i]):
|
|
319
|
+
vol = float(sizes[i] * vox)
|
|
320
|
+
if vol < fill_below:
|
|
321
|
+
fill |= lbl == i
|
|
322
|
+
placed += 1
|
|
323
|
+
continue
|
|
324
|
+
if rnd >= _DRILL_ROUNDS: # out of drilling rounds: reported as undrained
|
|
325
|
+
continue
|
|
326
|
+
new = _place(i, lbl, objs[i - 1], F, outside, dist_out, near, lo, p, r, bw, count, o, up,
|
|
327
|
+
stress_at, s_ref, holes, g)
|
|
328
|
+
for h in new:
|
|
329
|
+
h["void"] = int(lbl0[tuple(np.floor((h["site"] - lo) / p + 0.5).astype(int))])
|
|
330
|
+
holes += new
|
|
331
|
+
placed += len(new)
|
|
332
|
+
if not placed:
|
|
333
|
+
break
|
|
334
|
+
F = _apply(F0, fill, holes, lo, p, dims, k, _smin, Cylinder, outside0)
|
|
335
|
+
|
|
336
|
+
# what is left sealed, after everything
|
|
337
|
+
sealed_after = list(range(1, n + 1))
|
|
338
|
+
rep["rounds"] = rounds
|
|
339
|
+
rep["sealedAfter"] = len(sealed_after)
|
|
340
|
+
rep["sealedVolumeAfter"] = round(float(sum(sizes[i] for i in sealed_after) * vox), 1)
|
|
341
|
+
if sealed_after:
|
|
342
|
+
cent = ndimage.center_of_mass(np.ones_like(lbl, bool), lbl, sealed_after)
|
|
343
|
+
rep["undrained"] = [{"volume": round(float(sizes[i] * vox), 1),
|
|
344
|
+
"center": np.round(lo + np.asarray(c) * p, 2).tolist()}
|
|
345
|
+
for i, c in zip(sealed_after, cent)][:10]
|
|
346
|
+
rep["filledVolume"] = round(float(fill.sum() * vox), 1)
|
|
347
|
+
|
|
348
|
+
# powder path: from every point of each original void, through it, to the nearest hole
|
|
349
|
+
mask = (F >= 0) & ~outside0
|
|
350
|
+
starts = []
|
|
351
|
+
for h in holes:
|
|
352
|
+
t = np.arange(0.0, h["depth"], 0.5 * p)
|
|
353
|
+
ij = np.floor((h["site"] + np.outer(t, h["axis"]) - lo) / p + 0.5).astype(int)
|
|
354
|
+
starts += [tuple(x) for x in ij if mask[tuple(x)]]
|
|
355
|
+
geo = _geodesic(mask, starts, p) if starts else np.full(mask.shape, np.inf)
|
|
356
|
+
for v in voids:
|
|
357
|
+
m = (lbl0 == v["id"]) & mask
|
|
358
|
+
reach = geo[m]
|
|
359
|
+
fin = np.isfinite(reach)
|
|
360
|
+
v["holes"] = sum(1 for h in holes if h.get("void") == v["id"])
|
|
361
|
+
v["filled"] = bool(fill[lbl0 == v["id"]].all())
|
|
362
|
+
v["farthestFromOpening"] = round(float(reach[fin].max()), 1) if fin.any() else None
|
|
363
|
+
v["unreachedVolume"] = round(float((~fin).sum() * vox), 1)
|
|
364
|
+
rep["voids"] = [{k_: v_ for k_, v_ in v.items() if k_ != "id"} for v in voids]
|
|
365
|
+
rep["unreachedVolume"] = round(float(sum(v["unreachedVolume"] for v in voids)), 1)
|
|
366
|
+
|
|
367
|
+
# processcheck's escape probe on the drilled field: 26-connected, with
|
|
368
|
+
# the process's minimum escape ball
|
|
369
|
+
esc = _escapes(F < 0, p, lo - 0.5 * p, min(least, dia), np.eye(3))
|
|
370
|
+
rep["escape"] = {k_: esc[k_] for k_ in ("enclosedCount", "enclosedVolumeMm3", "narrowExitCount",
|
|
371
|
+
"narrowExitVolumeMm3")}
|
|
372
|
+
|
|
373
|
+
tap = min(_TAPS, key=lambda t: abs(t[0] - dia))
|
|
374
|
+
out = []
|
|
375
|
+
for j, h in enumerate(holes):
|
|
376
|
+
e = {"at": np.round(h["surface"], 2).tolist(), "axis": np.round(h["axis"], 3).tolist(), "diameter": dia,
|
|
377
|
+
"depth": round(float(h["depth"]), 2), "stress": round(float(h["stress"]), 4),
|
|
378
|
+
"stressRatio": round(float(h["stress"] / s_ref), 3) if sname else None}
|
|
379
|
+
if rep["process"] == "lpbf" and abs(tap[0] - dia) < 0.25:
|
|
380
|
+
e["seal"] = (f"leave open, or tap {tap[1]} into the boss and plug it with a set screw and thread "
|
|
381
|
+
"sealant after de-powdering")
|
|
382
|
+
out.append(e)
|
|
383
|
+
rep["holes"] = out
|
|
384
|
+
rep["ok"] = rep["sealedAfter"] == 0 and rep["unreachedVolume"] < 1.0
|
|
385
|
+
|
|
386
|
+
# into the graph: fill, bosses (smooth union), bores
|
|
387
|
+
cur = base
|
|
388
|
+
if fill.any():
|
|
389
|
+
sl, sdf = _fill_grid(fill, p, outside0)
|
|
390
|
+
org = lo + np.array([s.start for s in sl]) * p
|
|
391
|
+
g.sources["drain-fill"] = {"origin": org, "pitch": p, "values": sdf}
|
|
392
|
+
hi_ = org + (np.array(sdf.shape) - 1) * p
|
|
393
|
+
g._add("drain-fill", {"op": "grid", "source": "drain-fill", "bounds": {"min": org.tolist(),
|
|
394
|
+
"max": hi_.tolist()}})
|
|
395
|
+
cur = g._add("drain-filled", {"op": "union", "inputs": [cur, "drain-fill"]})
|
|
396
|
+
if holes:
|
|
397
|
+
bosses, bores = [], []
|
|
398
|
+
for j, h in enumerate(holes):
|
|
399
|
+
bosses.append(g._add(f"drain-boss-{j + 1}", {"op": "shape", "shape": _cyl(*h["boss"])}))
|
|
400
|
+
bores.append(g._add(f"drain-bore-{j + 1}", {"op": "shape", "shape": _cyl(*h["bore"])}))
|
|
401
|
+
cur = g._add("drain-bosses", {"op": "union", "inputs": [cur] + bosses, "blend": k})
|
|
402
|
+
cur = g._add("drained", {"op": "subtract", "inputs": [cur] + bores})
|
|
403
|
+
elif cur != base:
|
|
404
|
+
cur = g._add("drained", {"op": "union", "inputs": [cur]})
|
|
405
|
+
g.output = cur
|
|
406
|
+
g._check_refs()
|
|
407
|
+
rep["output"] = cur
|
|
408
|
+
return rep
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _cyl(a, b, rad):
|
|
412
|
+
return {"type": "cylinder", "p0": np.round(a, 4).tolist(), "p1": np.round(b, 4).tolist(), "r": round(float(rad), 4)}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _fill_grid(mask, p, outside=None):
|
|
416
|
+
"""The filled pockets as a local signed-distance grid, grown two voxels
|
|
417
|
+
(the labelling's half voxel, and one more) into the walls round them:
|
|
418
|
+
they are sealed, so that stays inside, and a fill that stopped short
|
|
419
|
+
would leave a thin sealed shell of its own."""
|
|
420
|
+
sl = ndimage.find_objects(mask.astype(np.int8))[0]
|
|
421
|
+
sl = tuple(slice(max(s.start - 3, 0), min(s.stop + 3, n)) for s, n in zip(sl, mask.shape))
|
|
422
|
+
sdf = _mask_sdf(mask[sl], p) - 2.0 * p
|
|
423
|
+
if outside is not None and outside[sl].any():
|
|
424
|
+
# never past the part's own outside: grown into a wall thinner than
|
|
425
|
+
# two voxels (a speck where a wall grazes the skin) the fill came
|
|
426
|
+
# through it, and stood 0.4-0.55 mm proud of a box the graph had
|
|
427
|
+
# already clipped the part to (an MJF riser, 2026-11-01)
|
|
428
|
+
sdf = np.maximum(sdf, _mask_sdf(~outside[sl], p))
|
|
429
|
+
return sl, sdf
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _apply(F0, fill, holes, lo, p, dims, k, _smin, Cylinder, outside=None):
|
|
433
|
+
"""F0 with the fill, bosses and bores applied, as the graph will do it."""
|
|
434
|
+
F = F0.copy()
|
|
435
|
+
if fill.any():
|
|
436
|
+
sl, sdf = _fill_grid(fill, p, outside)
|
|
437
|
+
F[sl] = np.minimum(F[sl], sdf)
|
|
438
|
+
for h in holes:
|
|
439
|
+
a, b, rad = h["boss"]
|
|
440
|
+
sl, P, shp = _box(lo, p, dims, a, b, rad + k + p)
|
|
441
|
+
F[sl] = _smin(F[sl], Cylinder(a, b, rad).sdf(P).reshape(shp), k)
|
|
442
|
+
for h in holes:
|
|
443
|
+
a, b, rad = h["bore"]
|
|
444
|
+
sl, P, shp = _box(lo, p, dims, a, b, rad + p)
|
|
445
|
+
F[sl] = np.maximum(F[sl], -Cylinder(a, b, rad).sdf(P).reshape(shp))
|
|
446
|
+
return F
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _place(i, lbl, sl, F, outside, dist_out, near, lo, p, r, bw, count, o, up, stress_at, s_ref, holes, g):
|
|
450
|
+
"""Up to `count` holes into void i: the calmest site lowest along the
|
|
451
|
+
build, then the calmest well away from it highest along the build, then
|
|
452
|
+
the calmest farthest from both."""
|
|
453
|
+
off = np.array([s.start for s in sl])
|
|
454
|
+
comp = lbl[sl] == i
|
|
455
|
+
dsub = dist_out[sl]
|
|
456
|
+
# sites on the void's own wall: the boss then ends just inside the
|
|
457
|
+
# void, not as a plug half way across a channel
|
|
458
|
+
wall = comp & ndimage.binary_dilation(F[sl] < 0)
|
|
459
|
+
cand = wall & (dsub <= o.max_depth)
|
|
460
|
+
if not cand.any():
|
|
461
|
+
cand = comp & (dsub <= dsub[comp].min() + p)
|
|
462
|
+
idx = np.argwhere(cand) + off
|
|
463
|
+
if len(idx) > 40_000:
|
|
464
|
+
idx = idx[np.sort(np.random.default_rng(0).choice(len(idx), 40_000, replace=False))]
|
|
465
|
+
pts = lo + idx * p
|
|
466
|
+
# the stress the hole sees: the worse of the site's and its mouth's
|
|
467
|
+
# (the nearest outside; a bending part is calm at the core, not at the skin)
|
|
468
|
+
mouth = lo + near[(slice(None),) + tuple(idx.T)].T * p
|
|
469
|
+
sv = np.maximum(stress_at(pts), stress_at(mouth))
|
|
470
|
+
bins = np.floor(np.maximum(sv, 0.0) / (0.05 * s_ref)) if s_ref > 0 else np.zeros(len(pts))
|
|
471
|
+
# height in bands of a hole radius; inside a band, a shuffled order, so
|
|
472
|
+
# the tries spread over the face instead of walking along one edge
|
|
473
|
+
h = np.round(pts @ up / r)
|
|
474
|
+
shuffle = np.random.default_rng(i).random(len(pts))
|
|
475
|
+
extent = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0)))
|
|
476
|
+
sep = max(4.0 * 2 * r, 0.5 * extent / max(1, count - 1))
|
|
477
|
+
clear = 2.0 * (r + bw) + p # bosses of two holes do not overlap
|
|
478
|
+
picks = []
|
|
479
|
+
tried = np.zeros(len(pts), bool)
|
|
480
|
+
key = bins * 100_000 + h # a (stress bin, height band) group
|
|
481
|
+
for slot in range(count):
|
|
482
|
+
if slot == 0:
|
|
483
|
+
order = np.lexsort((shuffle, h, bins))
|
|
484
|
+
elif slot == 1:
|
|
485
|
+
order = np.lexsort((shuffle, -h, bins))
|
|
486
|
+
else:
|
|
487
|
+
dmin = np.min([np.linalg.norm(pts - q["site"], axis=1) for q in picks], axis=0)
|
|
488
|
+
order = np.lexsort((shuffle, -np.round(dmin / (2 * r)), bins))
|
|
489
|
+
# calm first (within 10% of the peak of the calmest site left), well
|
|
490
|
+
# apart if it can be, else anywhere the bosses do not touch; only
|
|
491
|
+
# then the rest of the void
|
|
492
|
+
calm = bins <= bins[~tried].min() + 2 if (~tried).any() else bins >= 0
|
|
493
|
+
for subset, apart in ((calm, sep), (calm, clear), (None, sep), (None, clear)):
|
|
494
|
+
site = _first_site(order if subset is None else order[subset[order]], pts, idx, key, tried, picks,
|
|
495
|
+
holes, apart, clear, F, outside, dist_out, lo, p, r, bw, o, g)
|
|
496
|
+
if site is not None:
|
|
497
|
+
break
|
|
498
|
+
if site is None:
|
|
499
|
+
break
|
|
500
|
+
site["stress"] = float(sv[site.pop("j")])
|
|
501
|
+
picks.append(site)
|
|
502
|
+
# powder farther than max_path from every hole (through the void): one
|
|
503
|
+
# more hole, calm first, farthest along the void, until it is not
|
|
504
|
+
while o.max_path and picks and len(picks) < _MAX_HOLES:
|
|
505
|
+
from .heatx import _geodesic
|
|
506
|
+
|
|
507
|
+
geo = _geodesic(comp, [tuple(np.floor((q["site"] - lo) / p + 0.5).astype(int) - off) for q in picks], p)
|
|
508
|
+
reach = geo[comp & np.isfinite(geo)]
|
|
509
|
+
if not reach.size or reach.max() <= o.max_path:
|
|
510
|
+
break
|
|
511
|
+
gc = geo[tuple((idx - off).T)]
|
|
512
|
+
gc = np.where(np.isfinite(gc), gc, 0.0)
|
|
513
|
+
order = np.lexsort((shuffle, -np.round(gc / (2 * r)), bins))
|
|
514
|
+
far = gc > 0.5 * o.max_path
|
|
515
|
+
calm = (bins <= bins[far & ~tried].min() + 2) & far if (far & ~tried).any() else far
|
|
516
|
+
site = _first_site(order[calm[order]], pts, idx, key, tried, picks, holes, clear, clear, F, outside,
|
|
517
|
+
dist_out, lo, p, r, bw, o, g)
|
|
518
|
+
if site is None:
|
|
519
|
+
break
|
|
520
|
+
site["stress"] = float(sv[site.pop("j")])
|
|
521
|
+
picks.append(site)
|
|
522
|
+
return picks
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _first_site(order, pts, idx, key, tried, picks, holes, apart, clear, F, outside, dist_out, lo, p, r, bw, o, g):
|
|
526
|
+
"""The first workable site in `order`, at most 40 tries per (stress bin,
|
|
527
|
+
height band) group so a face that cannot take a hole (an edge, a
|
|
528
|
+
keepClear) does not use up the search, 3000 in all."""
|
|
529
|
+
per = {}
|
|
530
|
+
n_try = 0
|
|
531
|
+
for j in order:
|
|
532
|
+
if tried[j] or per.get(key[j], 0) >= 40:
|
|
533
|
+
continue
|
|
534
|
+
q = pts[j]
|
|
535
|
+
if any(np.linalg.norm(q - x["site"]) < apart for x in picks):
|
|
536
|
+
continue
|
|
537
|
+
if any(np.linalg.norm(q - x["site"]) < clear for x in holes):
|
|
538
|
+
continue
|
|
539
|
+
tried[j] = True
|
|
540
|
+
per[key[j]] = per.get(key[j], 0) + 1
|
|
541
|
+
site = _site(tuple(idx[j]), F, outside, dist_out, lo, p, r, bw, o, g)
|
|
542
|
+
if site is not None:
|
|
543
|
+
site["j"] = j
|
|
544
|
+
return site
|
|
545
|
+
n_try += 1
|
|
546
|
+
if n_try >= 3000:
|
|
547
|
+
return None
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _site(ijk, F, outside, dist_out, lo, p, r, bw, o, g):
|
|
552
|
+
"""A hole from void voxel ijk straight out, if there is one: the line
|
|
553
|
+
leaves the part once within the skin's depth and stays out; the face
|
|
554
|
+
round the mouth is flat out past the boss; the boss stays inside the
|
|
555
|
+
part and clear of the keepClear nodes. Returns the hole (site, axis,
|
|
556
|
+
surface point, depth, boss and bore cylinders) or None."""
|
|
557
|
+
dims = np.asarray(F.shape)
|
|
558
|
+
ijk = np.asarray(ijk)
|
|
559
|
+
a0 = np.clip(ijk - 1, 0, dims - 1)
|
|
560
|
+
a1 = np.clip(ijk + 1, 0, dims - 1)
|
|
561
|
+
grad = np.array([dist_out[tuple(np.where(np.arange(3) == ax, a1, ijk))] -
|
|
562
|
+
dist_out[tuple(np.where(np.arange(3) == ax, a0, ijk))] for ax in range(3)], float)
|
|
563
|
+
if np.linalg.norm(grad) < 1e-6:
|
|
564
|
+
return None
|
|
565
|
+
nrm = -grad / np.linalg.norm(grad)
|
|
566
|
+
pt = lo + ijk * p
|
|
567
|
+
depth = float(dist_out[tuple(ijk)])
|
|
568
|
+
t = np.arange(0.0, 1.5 * depth + 2.0 * r + 3.0 * p, 0.5 * p)
|
|
569
|
+
fi = (pt + np.outer(t, nrm) - lo) / p
|
|
570
|
+
past = ((fi < 0) | (fi > dims - 1)).any(axis=1) # off the grid is outside too
|
|
571
|
+
fi = np.clip(fi, 0, dims - 1)
|
|
572
|
+
ii = np.floor(fi + 0.5).astype(int)
|
|
573
|
+
out = outside[tuple(ii.T)] | past
|
|
574
|
+
if not out.any():
|
|
575
|
+
return None
|
|
576
|
+
e = int(np.argmax(out))
|
|
577
|
+
if t[e] > 1.5 * depth + 2.0 * p or not out[e:].all():
|
|
578
|
+
return None
|
|
579
|
+
Fv = ndimage.map_coordinates(F, fi.T, order=1, mode="nearest")
|
|
580
|
+
js = np.nonzero(Fv[:e + 1] < 0)[0]
|
|
581
|
+
if not len(js) or js[-1] + 1 >= len(t):
|
|
582
|
+
return None
|
|
583
|
+
j = js[-1]
|
|
584
|
+
t_surf = t[j] + (t[j + 1] - t[j]) * Fv[j] / (Fv[j] - Fv[j + 1])
|
|
585
|
+
skin = t_surf - t[js[0]] + 0.5 * p
|
|
586
|
+
surf = pt + nrm * t_surf
|
|
587
|
+
cap = 0.5 * skin # the boss ends half way through the skin
|
|
588
|
+
u = np.cross(nrm, [1.0, 0.0, 0.0] if abs(nrm[0]) < 0.9 else [0.0, 1.0, 0.0])
|
|
589
|
+
u /= np.linalg.norm(u)
|
|
590
|
+
v = np.cross(nrm, u)
|
|
591
|
+
ang = np.linspace(0.0, 2.0 * np.pi, 16, endpoint=False)
|
|
592
|
+
circ = np.cos(ang)[:, None] * u + np.sin(ang)[:, None] * v
|
|
593
|
+
reach = r + bw
|
|
594
|
+
ring = surf + reach * circ
|
|
595
|
+
fv = ndimage.map_coordinates(F, ((ring - lo) / p).T, order=1, mode="nearest")
|
|
596
|
+
# convex past the boss's cap pokes it out; concave is a bore, a seat or
|
|
597
|
+
# a fillet: a functional face, or a corner the stress gathers in
|
|
598
|
+
if fv.max() > 0.8 * cap or fv.min() < -0.5 * p:
|
|
599
|
+
return None
|
|
600
|
+
boss = (surf - nrm * cap, pt - nrm * bw, reach)
|
|
601
|
+
bore = (pt - nrm * (bw + r), surf + nrm * (1.0 + p), r)
|
|
602
|
+
# the boss's side and the bore's inner end stay inside the part
|
|
603
|
+
axial = np.linspace(cap, t_surf + bw + r, 6)
|
|
604
|
+
P = (surf[None, None, :] - axial[:, None, None] * nrm + reach * circ[None, :, :]).reshape(-1, 3)
|
|
605
|
+
fp = (P - lo) / p
|
|
606
|
+
if (fp < 0).any() or (fp > dims - 1).any():
|
|
607
|
+
return None
|
|
608
|
+
if outside[tuple(np.floor(fp + 0.5).astype(int).T)].any():
|
|
609
|
+
return None
|
|
610
|
+
if o.keep_clear:
|
|
611
|
+
P2 = np.vstack([P, surf - np.outer(axial, nrm)])
|
|
612
|
+
for name in o.keep_clear:
|
|
613
|
+
if (g.evaluate(P2, name) < o.clearance).any():
|
|
614
|
+
return None
|
|
615
|
+
return {"site": pt, "axis": nrm, "surface": surf, "depth": t_surf, "boss": boss, "bore": bore}
|