loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/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