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/escape.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Escape holes for optimized designs: open the voids the optimizer sealed.
|
|
3
|
+
|
|
4
|
+
Stiffness rewards a closed section. Given a fine enough grid the optimizer
|
|
5
|
+
will wrap a skin round an empty core or a keep-out (the 845k-cell arm link
|
|
6
|
+
came out a sealed box round its cable channel), and in a powder bed (SLS,
|
|
7
|
+
MJF, LPBF) or a resin vat that core is full of powder or liquid resin that
|
|
8
|
+
can never come out. drains.py opens the voids of field graphs; this opens
|
|
9
|
+
those of a density design, on the optimizer's own grid:
|
|
10
|
+
|
|
11
|
+
- a void traps powder when no face-connected path of empty cells leads from
|
|
12
|
+
it to the outside of the grid (keep-outs inside the part count as void, so
|
|
13
|
+
a cable channel the skin closed over is one), or when it is a room the
|
|
14
|
+
escape ball fits in but can only leave through gaps narrower than itself
|
|
15
|
+
(trapped_voids);
|
|
16
|
+
- a sealed void smaller than the escape hole's own ball is filled solid;
|
|
17
|
+
- every other one gets `count` straight holes (default 2, so air or rinse
|
|
18
|
+
can flow through), then more, up to 8, while any of it is farther than
|
|
19
|
+
`maxPath` (60 mm) from a hole, measured through the void;
|
|
20
|
+
- a hole runs straight out from the void along one of 26 directions, and is
|
|
21
|
+
sited where the cost is least: the length of wall it cuts, times one plus
|
|
22
|
+
the local strain energy density relative to the part's median (thin, calm
|
|
23
|
+
wall first); never through or next to preserved geometry (bosses, pads);
|
|
24
|
+
- the second hole goes as far through the void from the first as the costs
|
|
25
|
+
allow, each later one at the point farthest from every hole;
|
|
26
|
+
- holes are mirrored across the study's symmetry planes.
|
|
27
|
+
|
|
28
|
+
The holes are pinned void and the optimizer runs on from its design (simp.
|
|
29
|
+
optimize, opts.escape), so the part re-converges round them; a re-converged
|
|
30
|
+
design can seal a new void, so up to three rounds.
|
|
31
|
+
|
|
32
|
+
The default hole is drains.hole_defaults' (SLS 6 mm, MJF 7.5, SLA/DLP 5.5,
|
|
33
|
+
volumetric 5, LPBF 6.8: well over each process's minimum escape hole), 6 mm
|
|
34
|
+
without a process.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
from scipy import ndimage
|
|
43
|
+
|
|
44
|
+
__all__ = ["EscapeOptions", "drill", "sealed_voids", "trapped_voids"]
|
|
45
|
+
|
|
46
|
+
_FACE = ndimage.generate_binary_structure(3, 1)
|
|
47
|
+
_MAX_HOLES = 8
|
|
48
|
+
_SITES = 600 # candidate sites per void (subsampled)
|
|
49
|
+
_REACH = 40.0 # mm: the longest drill (a wall thicker than this is not a wall)
|
|
50
|
+
_STRESS = 2.0 # weight of the local strain energy in a site's cost
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class EscapeOptions:
|
|
55
|
+
diameter: float = 6.0 # mm, drilled
|
|
56
|
+
count: int = 2 # holes per sealed void
|
|
57
|
+
max_path: float | None = 60.0 # mm through the void to the nearest hole (None: off)
|
|
58
|
+
iterations: int = 20 # optimizer iterations after drilling
|
|
59
|
+
fill_below: float | None = None # mm^3: fill smaller voids (default the hole's ball)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def for_process(cls, process) -> "EscapeOptions":
|
|
63
|
+
if process is None:
|
|
64
|
+
return cls()
|
|
65
|
+
from .drains import hole_defaults
|
|
66
|
+
|
|
67
|
+
d, _, per = hole_defaults(process)
|
|
68
|
+
return cls(diameter=float(d), count=max(2, int(per)))
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_spec(cls, spec: dict, process) -> "EscapeOptions":
|
|
72
|
+
o = cls.for_process(process)
|
|
73
|
+
keys = {"diameter": "diameter", "count": "count", "maxPath": "max_path", "iterations": "iterations"}
|
|
74
|
+
for k, v in spec.items():
|
|
75
|
+
if k == "enabled":
|
|
76
|
+
continue
|
|
77
|
+
if k not in keys:
|
|
78
|
+
raise ValueError(f"drains: unknown key {k!r}; known: {sorted(keys) + ['enabled']}")
|
|
79
|
+
setattr(o, keys[k], v)
|
|
80
|
+
if not (float(o.diameter) > 0):
|
|
81
|
+
raise ValueError("drains.diameter is a hole diameter in mm, > 0")
|
|
82
|
+
if int(o.count) < 1:
|
|
83
|
+
raise ValueError("drains.count is at least 1")
|
|
84
|
+
o.diameter, o.count, o.iterations = float(o.diameter), int(o.count), int(o.iterations)
|
|
85
|
+
return o
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _dirs():
|
|
89
|
+
d = np.array([(i, j, k) for i in (-1, 0, 1) for j in (-1, 0, 1) for k in (-1, 0, 1) if (i, j, k) != (0, 0, 0)],
|
|
90
|
+
float)
|
|
91
|
+
return d / np.linalg.norm(d, axis=1, keepdims=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sealed_voids(solid: np.ndarray):
|
|
95
|
+
"""(labels, n) of the void components of a padded-by-one bool grid that
|
|
96
|
+
no face path of empty cells joins to its border."""
|
|
97
|
+
void = ~solid
|
|
98
|
+
lbl, n = ndimage.label(void, _FACE)
|
|
99
|
+
border = np.unique(np.concatenate([lbl[0].ravel(), lbl[-1].ravel(), lbl[:, 0].ravel(), lbl[:, -1].ravel(),
|
|
100
|
+
lbl[:, :, 0].ravel(), lbl[:, :, -1].ravel()]))
|
|
101
|
+
out = np.isin(lbl, border[border > 0])
|
|
102
|
+
sl, m = ndimage.label(void & ~out, _FACE)
|
|
103
|
+
return sl, m
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def trapped_voids(solid: np.ndarray, radius: float):
|
|
107
|
+
"""(labels, n) of the voids powder cannot leave, on a padded-by-one bool
|
|
108
|
+
grid: the sealed ones (sealed_voids), and every room an escape ball of
|
|
109
|
+
`radius` cells fits in but cannot roll out of, because it opens to the
|
|
110
|
+
outside only through gaps narrower than the ball (a box with a few small
|
|
111
|
+
holes in it is still a box full of powder: the 845k-cell arm link's
|
|
112
|
+
were 2-4 mm). The ball's reach is grown from the border through the
|
|
113
|
+
cells it fits in, then back out by its radius."""
|
|
114
|
+
void = ~solid
|
|
115
|
+
fits = ndimage.distance_transform_edt(np.pad(void, 1, constant_values=True))[1:-1, 1:-1, 1:-1] >= radius
|
|
116
|
+
lbl, n = ndimage.label(fits, _FACE)
|
|
117
|
+
border = np.unique(np.concatenate([lbl[0].ravel(), lbl[-1].ravel(), lbl[:, 0].ravel(), lbl[:, -1].ravel(),
|
|
118
|
+
lbl[:, :, 0].ravel(), lbl[:, :, -1].ravel()]))
|
|
119
|
+
free = np.isin(lbl, border[border > 0])
|
|
120
|
+
reach = ndimage.distance_transform_edt(~free) <= radius + 0.5
|
|
121
|
+
sealed, _ = sealed_voids(solid)
|
|
122
|
+
rest, m = ndimage.label(void & ~reach, _FACE)
|
|
123
|
+
rooms = np.unique(rest[fits & ~free])
|
|
124
|
+
keep = (sealed > 0) | np.isin(rest, rooms[rooms > 0])
|
|
125
|
+
return ndimage.label(keep, _FACE)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _geodesic(mask, starts, step):
|
|
129
|
+
from .heatx import _geodesic as g
|
|
130
|
+
|
|
131
|
+
return g(mask, starts, step)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def drill(domain, xphys, energy, o: EscapeOptions, void_prev=None) -> dict:
|
|
135
|
+
"""Find the sealed voids of a design and the holes that open them.
|
|
136
|
+
|
|
137
|
+
Returns {"sealed": voids found, "holes": [{site, exit, axis, diameter,
|
|
138
|
+
wallMm}], "void": bool per element to pin void, "fill": bool per element
|
|
139
|
+
to fill, "filledVolume": mm^3, "maxPath": mm (the farthest any void is
|
|
140
|
+
from a hole after drilling, or None), "unopened": voids left sealed}."""
|
|
141
|
+
p = float(domain.pitch)
|
|
142
|
+
ijk = domain.mesh.elem_ijk
|
|
143
|
+
n_el = len(ijk)
|
|
144
|
+
dims = np.asarray(domain.dims)
|
|
145
|
+
sh = tuple(dims + 2)
|
|
146
|
+
sel = tuple((ijk + 1).T)
|
|
147
|
+
solid = np.zeros(sh, bool)
|
|
148
|
+
solid[sel] = np.asarray(xphys) > 0.5
|
|
149
|
+
active = np.zeros(sh, bool)
|
|
150
|
+
active[sel] = True
|
|
151
|
+
pres = np.zeros(sh, bool)
|
|
152
|
+
pres[sel] = domain.preserve
|
|
153
|
+
r = 0.5 * o.diameter
|
|
154
|
+
lbl, m = trapped_voids(solid, r / p)
|
|
155
|
+
# Open but deep: powder farther than maxPath, through the void, from any
|
|
156
|
+
# opening (a tube open at one end, 100 mm long). The openings are the
|
|
157
|
+
# empty cells that see the grid's border straight along two axis
|
|
158
|
+
# directions or more.
|
|
159
|
+
base = {}
|
|
160
|
+
n_trapped = m
|
|
161
|
+
if o.max_path is not None:
|
|
162
|
+
void = ~solid & (lbl == 0)
|
|
163
|
+
seen = np.zeros(sh, np.int8)
|
|
164
|
+
for ax in range(3):
|
|
165
|
+
for flip in (False, True):
|
|
166
|
+
sv = np.flip(solid, ax) if flip else solid
|
|
167
|
+
hit = np.logical_or.accumulate(sv, axis=ax)
|
|
168
|
+
seen += np.flip(~hit, ax) if flip else ~hit
|
|
169
|
+
# seen along two directions: down the bore of a tube is only one
|
|
170
|
+
vis = seen >= 2
|
|
171
|
+
inner = void & ~vis
|
|
172
|
+
if inner.any():
|
|
173
|
+
seed = vis & void & ndimage.binary_dilation(inner, _FACE)
|
|
174
|
+
g = _geodesic(inner | seed, [tuple(q) for q in np.argwhere(seed)], p)
|
|
175
|
+
deep, nd = ndimage.label(inner & (g > o.max_path), _FACE)
|
|
176
|
+
for k in range(1, nd + 1):
|
|
177
|
+
lbl[deep == k] = m + k
|
|
178
|
+
base[m + k] = g
|
|
179
|
+
m += nd
|
|
180
|
+
out = {"sealed": int(n_trapped), "deep": int(m - n_trapped), "holes": [], "void": np.zeros(n_el, bool),
|
|
181
|
+
"fill": np.zeros(n_el, bool), "filledVolume": 0.0, "maxPath": None, "unopened": 0}
|
|
182
|
+
if m == 0:
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
ball = math.pi / 6.0 * o.diameter ** 3 if o.fill_below is None else float(o.fill_below)
|
|
186
|
+
sizes = np.bincount(lbl.ravel(), minlength=m + 1) * p ** 3
|
|
187
|
+
fill = np.zeros(sh, bool)
|
|
188
|
+
big = []
|
|
189
|
+
for c in range(1, m + 1):
|
|
190
|
+
cells = lbl == c
|
|
191
|
+
if c not in base and sizes[c] < ball and not (cells & ndimage.binary_dilation(~solid & (lbl != c), _FACE)).any():
|
|
192
|
+
# a small void sealed all round (not a room behind a narrow gap)
|
|
193
|
+
if (cells & ~active).any():
|
|
194
|
+
big.append(c) # a keep-out: cannot fill, must drill
|
|
195
|
+
else:
|
|
196
|
+
fill |= cells
|
|
197
|
+
else:
|
|
198
|
+
big.append(c)
|
|
199
|
+
out["fill"] = fill[sel]
|
|
200
|
+
out["filledVolume"] = float(fill.sum() * p ** 3)
|
|
201
|
+
if not big:
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
# where a drill may go: never within a hole's radius of preserved geometry
|
|
205
|
+
clear = ndimage.distance_transform_edt(~pres) * p >= r + p
|
|
206
|
+
e = np.zeros(sh)
|
|
207
|
+
if energy is not None:
|
|
208
|
+
e[sel] = np.asarray(energy, float)
|
|
209
|
+
k = max(3, int(round(o.diameter / p)) | 1)
|
|
210
|
+
e = ndimage.uniform_filter(e, size=k, mode="nearest")
|
|
211
|
+
med = float(np.median(e[solid])) if solid.any() else 0.0
|
|
212
|
+
e = e / med if med > 0 else np.zeros(sh)
|
|
213
|
+
outside = ~solid & (lbl == 0)
|
|
214
|
+
dirs = _dirs()
|
|
215
|
+
steps = np.arange(1, int(_REACH / (0.5 * p)) + 1) * 0.5 * p # mm along the ray
|
|
216
|
+
mirror_axes = ["xyz".index(a) for a in sorted(domain.mirror)] if domain.mirror else []
|
|
217
|
+
|
|
218
|
+
def cells_of(pts):
|
|
219
|
+
q = np.floor(pts / p).astype(int)
|
|
220
|
+
inside = np.all((q >= 0) & (q < np.asarray(sh)), axis=-1)
|
|
221
|
+
return np.clip(q, 0, np.asarray(sh) - 1), inside
|
|
222
|
+
|
|
223
|
+
def candidates(c):
|
|
224
|
+
"""(site cells, directions, costs, wall mm, exit points) for void c."""
|
|
225
|
+
cells = lbl == c
|
|
226
|
+
rim = cells & ndimage.binary_dilation(solid, _FACE)
|
|
227
|
+
idx = np.argwhere(rim)
|
|
228
|
+
if len(idx) > _SITES:
|
|
229
|
+
idx = idx[np.linspace(0, len(idx) - 1, _SITES).astype(int)]
|
|
230
|
+
best = []
|
|
231
|
+
for d in dirs:
|
|
232
|
+
pts = (idx[:, None, :] + 0.5) * p + steps[None, :, None] * d[None, None, :]
|
|
233
|
+
q, inside = cells_of(pts)
|
|
234
|
+
qi = tuple(np.moveaxis(q, -1, 0))
|
|
235
|
+
reached = ~inside | outside[qi]
|
|
236
|
+
first = np.where(reached.any(axis=1), reached.argmax(axis=1), -1)
|
|
237
|
+
ok = first >= 0
|
|
238
|
+
ahead = np.arange(len(steps))[None, :] < np.where(ok, first, 0)[:, None]
|
|
239
|
+
s_solid = solid[qi] & ahead
|
|
240
|
+
wall = s_solid.sum(axis=1) * 0.5 * p
|
|
241
|
+
# straight into the wall and out: not back across the void
|
|
242
|
+
bad = (~clear[qi] & ahead).any(axis=1) | (~solid[qi] & ahead & (steps[None, :] > p)).any(axis=1)
|
|
243
|
+
stress = np.where(s_solid.any(axis=1),
|
|
244
|
+
(e[qi] * s_solid).sum(axis=1) / np.maximum(s_solid.sum(axis=1), 1), 0.0)
|
|
245
|
+
ok &= ~bad
|
|
246
|
+
cost = np.where(ok, np.maximum(wall, p) * (1.0 + _STRESS * stress), np.inf)
|
|
247
|
+
ex = (idx + 0.5) * p + steps[np.maximum(first, 0)][:, None] * d[None, :]
|
|
248
|
+
best.append((cost, wall, ex))
|
|
249
|
+
cost = np.stack([b[0] for b in best], 1)
|
|
250
|
+
j = cost.argmin(axis=1)
|
|
251
|
+
rows = np.arange(len(idx))
|
|
252
|
+
return (idx, dirs[j], cost[rows, j], np.stack([b[1] for b in best], 1)[rows, j],
|
|
253
|
+
np.stack([b[2] for b in best], 1)[rows, j])
|
|
254
|
+
|
|
255
|
+
hole_grid = np.zeros(sh, bool)
|
|
256
|
+
|
|
257
|
+
def cylinder(a, b):
|
|
258
|
+
lo = np.maximum(np.floor((np.minimum(a, b) - r - p) / p).astype(int), 0)
|
|
259
|
+
hi = np.minimum(np.ceil((np.maximum(a, b) + r + p) / p).astype(int) + 1, np.asarray(sh))
|
|
260
|
+
g = np.stack(np.meshgrid(*[np.arange(lo[i], hi[i]) for i in range(3)], indexing="ij"), -1)
|
|
261
|
+
c = (g + 0.5) * p
|
|
262
|
+
ab = b - a
|
|
263
|
+
t = np.clip(((c - a) @ ab) / max(float(ab @ ab), 1e-12), 0.0, 1.0)
|
|
264
|
+
dist = np.linalg.norm(c - (a + t[..., None] * ab), axis=-1)
|
|
265
|
+
cyl = np.zeros(sh, bool)
|
|
266
|
+
cyl[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]] = dist <= r + 0.5 * p
|
|
267
|
+
return cyl
|
|
268
|
+
|
|
269
|
+
def mirrored(site, exit_):
|
|
270
|
+
"""The hole and its images across the symmetry planes (mm, padded
|
|
271
|
+
grid), as (site, exit) pairs. An image that would overlap its hole
|
|
272
|
+
(one sited near the plane) is merged into one hole on the plane."""
|
|
273
|
+
holes = [(np.asarray(site, float), np.asarray(exit_, float))]
|
|
274
|
+
span = (dims + 2) * p
|
|
275
|
+
for ax in mirror_axes:
|
|
276
|
+
nxt = []
|
|
277
|
+
for a_, b_ in holes:
|
|
278
|
+
wa, wb = a_.copy(), b_.copy()
|
|
279
|
+
wa[ax], wb[ax] = span[ax] - wa[ax], span[ax] - wb[ax]
|
|
280
|
+
if abs(wa[ax] - a_[ax]) < 2.0 * r + p:
|
|
281
|
+
a_, b_ = a_.copy(), b_.copy()
|
|
282
|
+
a_[ax] = b_[ax] = 0.5 * span[ax]
|
|
283
|
+
nxt.append((a_, b_))
|
|
284
|
+
else:
|
|
285
|
+
nxt += [(a_, b_), (wa, wb)]
|
|
286
|
+
holes = nxt
|
|
287
|
+
return holes
|
|
288
|
+
|
|
289
|
+
def _reach(comp, have, g0):
|
|
290
|
+
"""mm through the void from the nearest hole (or, for an open room,
|
|
291
|
+
opening), on the room's cells."""
|
|
292
|
+
geo = np.full(sh, np.inf) if g0 is None else np.where(comp, g0, np.inf)
|
|
293
|
+
if have:
|
|
294
|
+
starts = [tuple(np.clip(np.floor(s / p).astype(int), 0, np.asarray(sh) - 1)) for s in have]
|
|
295
|
+
geo = np.minimum(geo, _geodesic(comp, starts, p))
|
|
296
|
+
return geo
|
|
297
|
+
|
|
298
|
+
mouths = {c: [] for c in big}
|
|
299
|
+
worst = 0.0
|
|
300
|
+
unopened = 0
|
|
301
|
+
for c in big:
|
|
302
|
+
cand = None
|
|
303
|
+
for _ in range(_MAX_HOLES):
|
|
304
|
+
comp = lbl == c
|
|
305
|
+
have = mouths[c]
|
|
306
|
+
far = None
|
|
307
|
+
if have or c in base:
|
|
308
|
+
geo = _reach(comp, have, base.get(c))
|
|
309
|
+
gmax = float(np.max(geo[comp & np.isfinite(geo)], initial=0.0))
|
|
310
|
+
# an open room needs no count of holes, only the reach
|
|
311
|
+
if (c in base or len(have) >= o.count) and (o.max_path is None or gmax <= o.max_path):
|
|
312
|
+
break
|
|
313
|
+
far = (geo, gmax)
|
|
314
|
+
if cand is None:
|
|
315
|
+
cand = candidates(c)
|
|
316
|
+
idx, dvec, cost, wall, ex = cand
|
|
317
|
+
score = cost.copy()
|
|
318
|
+
if far is not None:
|
|
319
|
+
geo, gmax = far
|
|
320
|
+
g = geo[tuple(idx.T)]
|
|
321
|
+
g = np.where(np.isfinite(g), g, gmax)
|
|
322
|
+
# the farthest reach of the void from every hole: sites there only
|
|
323
|
+
score = np.where(g >= 0.8 * gmax, score, np.inf)
|
|
324
|
+
k = int(np.argmin(score))
|
|
325
|
+
if not np.isfinite(score[k]):
|
|
326
|
+
if not have and c not in base:
|
|
327
|
+
unopened += 1
|
|
328
|
+
break
|
|
329
|
+
site = (idx[k] + 0.5) * p
|
|
330
|
+
exit_ = ex[k] + dvec[k] * p
|
|
331
|
+
for s_img, x_img in mirrored(site, exit_):
|
|
332
|
+
a = s_img - (x_img - s_img) / max(np.linalg.norm(x_img - s_img), 1e-9) * r
|
|
333
|
+
hole_grid |= cylinder(a, x_img)
|
|
334
|
+
cq = tuple(np.clip(np.floor(s_img / p).astype(int), 0, np.asarray(sh) - 1))
|
|
335
|
+
ci = int(lbl[cq])
|
|
336
|
+
if ci in mouths:
|
|
337
|
+
mouths[ci].append(s_img)
|
|
338
|
+
out["holes"].append({
|
|
339
|
+
"site": [float(v) for v in (s_img - p + domain.origin)],
|
|
340
|
+
"exit": [float(v) for v in (x_img - p + domain.origin)],
|
|
341
|
+
"axis": [float(v) for v in (x_img - s_img) / max(np.linalg.norm(x_img - s_img), 1e-9)],
|
|
342
|
+
"diameter": o.diameter, "wallMm": float(wall[k])})
|
|
343
|
+
for c, have in mouths.items():
|
|
344
|
+
if have or c in base:
|
|
345
|
+
comp = lbl == c
|
|
346
|
+
geo = _reach(comp, have, base.get(c))
|
|
347
|
+
worst = max(worst, float(np.max(geo[comp & np.isfinite(geo)], initial=0.0)))
|
|
348
|
+
hole_grid &= active & ~pres
|
|
349
|
+
if void_prev is not None:
|
|
350
|
+
hole_grid[sel] &= ~void_prev
|
|
351
|
+
out["void"] = hole_grid[sel]
|
|
352
|
+
out["maxPath"] = worst if any(mouths.values()) or base else None
|
|
353
|
+
out["unopened"] = unopened
|
|
354
|
+
return out
|
|
File without changes
|