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/voxels.py ADDED
@@ -0,0 +1,402 @@
1
+ """
2
+ Occupancy grids and wall-thickness probes that scale to multi-million
3
+ triangle parts.
4
+
5
+ The checks used to voxelize through trimesh (`mesh.voxelized`, then a ray
6
+ parity vote per enclosed pocket) and measure walls with one ray per sampled
7
+ face. Both go through trimesh's ray broad phase, which on a 6.6M-triangle
8
+ TPMS heat exchanger hands every ray a candidate set covering most of the
9
+ mesh: the printability check reached 63 GB and the isolation check took 30
10
+ minutes. Everything here is plain numpy and bounded in memory:
11
+
12
+ mesh_solid — scanline rasterizer. Every voxel column along an axis is a
13
+ ray; each triangle is binned straight to the columns its
14
+ projection covers (no tree), the crossing heights are kept,
15
+ and parity along the column fills the solid. Cost is
16
+ O(triangles + crossings), memory is a bool grid plus 8 bytes
17
+ per crossing. Orientation independent, so inverted cavity
18
+ shells and nested bodies come out right; a mesh that is not
19
+ watertight gets a majority vote over the three axes.
20
+ field_solid — the same grid from an implicit field f(P) < 0, evaluated a
21
+ slab at a time. For parts built from a field this skips the
22
+ mesh altogether, and is exact at the voxel centres.
23
+ wall_thickness — member thickness on the medial ridge of the grid (twice
24
+ the distance to void at its local maxima), from a distance
25
+ transform computed in overlapping tiles so it never holds
26
+ more than one tile of floats.
27
+
28
+ Grids follow one convention: (bool grid, origin) where origin is the world
29
+ position of the CENTRE of voxel (0, 0, 0), with `pad` void voxels on every
30
+ side.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import numpy as np
35
+ from scipy import ndimage
36
+
37
+ __all__ = ["grid_for", "mesh_solid", "field_solid", "solid_grid", "wall_thickness",
38
+ "tiled_edt_min", "choose_pitch"]
39
+
40
+ # Column-centre jitter, as a share of the pitch: keeps every scanline off the
41
+ # exact edges and vertices of the mesh, where a crossing could be counted by
42
+ # both triangles sharing the edge (or by neither) and flip the parity.
43
+ _JITTER = (0.000137, 0.000291)
44
+
45
+ # Candidate (triangle, column) pairs tested per batch.
46
+ _BATCH = 2_000_000
47
+
48
+ # Floats held per tile by the tiled distance transform (~20 bytes each).
49
+ _TILE_VOXELS = 16_000_000
50
+
51
+
52
+ def choose_pitch(extents, target, budget, pad=2):
53
+ """`target` pitch, coarsened by 1.25x steps until the grid fits `budget`
54
+ voxels. Returns the pitch actually used."""
55
+ extents = np.asarray(extents, float)
56
+ pitch = float(target)
57
+ while float(np.prod(np.ceil(extents / pitch) + 2 * pad + 1)) > budget:
58
+ pitch *= 1.25
59
+ return pitch
60
+
61
+
62
+ def grid_for(lo, hi, pitch, pad=2):
63
+ """(dims, origin) of a grid covering the box lo..hi with `pad` voxels of
64
+ margin. origin is the centre of voxel (0, 0, 0)."""
65
+ lo = np.asarray(lo, float)
66
+ hi = np.asarray(hi, float)
67
+ core = np.maximum(np.ceil((hi - lo) / pitch).astype(int), 1)
68
+ dims = core + 2 * pad
69
+ origin = lo - 0.5 * (core * pitch - (hi - lo)) + (0.5 - pad) * pitch
70
+ return dims, origin
71
+
72
+
73
+ def _crossings(tri, dims, origin, pitch, axis):
74
+ """Scanline crossings of triangles `tri` (T, 3, 3) with the voxel columns
75
+ along `axis`. Returns (flat column index, crossing position along the
76
+ axis in cells from voxel 0's centre, the sign of the triangle's projected
77
+ area: which way it faces along the axis, up to the axis's handedness)."""
78
+ u_ax, v_ax = [a for a in range(3) if a != axis]
79
+ nu, nv, nw = dims[u_ax], dims[v_ax], dims[axis]
80
+ ou = origin[u_ax] + _JITTER[0] * pitch
81
+ ov = origin[v_ax] + _JITTER[1] * pitch
82
+ ow = origin[axis]
83
+
84
+ U, V, W = tri[:, :, u_ax], tri[:, :, v_ax], tri[:, :, axis]
85
+ i0 = np.clip(np.ceil((U.min(1) - ou) / pitch), 0, nu).astype(np.int64)
86
+ i1 = np.clip(np.floor((U.max(1) - ou) / pitch), -1, nu - 1).astype(np.int64)
87
+ j0 = np.clip(np.ceil((V.min(1) - ov) / pitch), 0, nv).astype(np.int64)
88
+ j1 = np.clip(np.floor((V.max(1) - ov) / pitch), -1, nv - 1).astype(np.int64)
89
+ wi = np.maximum(i1 - i0 + 1, 0)
90
+ wj = np.maximum(j1 - j0 + 1, 0)
91
+ count = wi * wj
92
+ # 2x the projected signed area; a triangle seen edge-on crosses no column
93
+ den = (V[:, 1] - V[:, 2]) * (U[:, 0] - U[:, 2]) + (U[:, 2] - U[:, 1]) * (V[:, 0] - V[:, 2])
94
+ keep = (count > 0) & (np.abs(den) > 1e-14)
95
+ idx = np.nonzero(keep)[0]
96
+ cols_out, ks_out, sg_out = [], [], []
97
+ if idx.size == 0:
98
+ return np.zeros(0, np.int64), np.zeros(0), np.zeros(0, np.int8)
99
+ csum = np.cumsum(count[idx])
100
+ start = 0
101
+ while start < idx.size:
102
+ base = csum[start - 1] if start else 0
103
+ stop = int(np.searchsorted(csum, base + _BATCH, side="right"))
104
+ stop = max(stop, start + 1)
105
+ t = idx[start:stop]
106
+ n = count[t]
107
+ rep = np.repeat(np.arange(t.size), n)
108
+ off = np.arange(int(n.sum())) - np.repeat(np.cumsum(n) - n, n)
109
+ ti = t[rep]
110
+ ii = i0[ti] + off // wj[ti]
111
+ jj = j0[ti] + off % wj[ti]
112
+ x = ou + ii * pitch
113
+ y = ov + jj * pitch
114
+ Ut, Vt, Wt, d = U[ti], V[ti], W[ti], den[ti]
115
+ l0 = ((Vt[:, 1] - Vt[:, 2]) * (x - Ut[:, 2]) + (Ut[:, 2] - Ut[:, 1]) * (y - Vt[:, 2])) / d
116
+ l1 = ((Vt[:, 2] - Vt[:, 0]) * (x - Ut[:, 2]) + (Ut[:, 0] - Ut[:, 2]) * (y - Vt[:, 2])) / d
117
+ l2 = 1.0 - l0 - l1
118
+ hit = (l0 >= 0) & (l1 >= 0) & (l2 >= 0)
119
+ if hit.any():
120
+ w = l0[hit] * Wt[hit, 0] + l1[hit] * Wt[hit, 1] + l2[hit] * Wt[hit, 2]
121
+ cols_out.append(ii[hit] * nv + jj[hit])
122
+ ks_out.append((w - ow) / pitch)
123
+ sg_out.append(np.sign(d[hit]).astype(np.int8))
124
+ start = stop
125
+ if not cols_out:
126
+ return np.zeros(0, np.int64), np.zeros(0), np.zeros(0, np.int8)
127
+ return np.concatenate(cols_out), np.concatenate(ks_out), np.concatenate(sg_out)
128
+
129
+
130
+ def _parity_axis(tri, dims, origin, pitch, axis, touch=None):
131
+ """Solid by crossing parity along `axis`; returns (grid, odd-column
132
+ count, overlap cells). With `touch` (a bool grid in x, y, z order), also
133
+ marks there every voxel a crossing falls in.
134
+
135
+ Overlap cells are where the winding number (crossings counted by the way
136
+ their triangle faces) says solid and parity says void: two closed bodies
137
+ concatenated without a boolean, which parity cancels to void where they
138
+ overlap, or a cavity shell facing the wrong way. The grid stays parity
139
+ (it does not trust winding), but a caller can say the reading is off."""
140
+ u_ax, v_ax = [a for a in range(3) if a != axis]
141
+ nu, nv, nw = int(dims[u_ax]), int(dims[v_ax]), int(dims[axis])
142
+ cols, w, sg = _crossings(tri, dims, origin, pitch, axis)
143
+ # (u, v, axis) cyclic for x and z, anticyclic for y; entering the solid
144
+ # (an outward normal facing down the axis) counts +1
145
+ sg = -sg.astype(np.float64) * (-1.0 if axis == 1 else 1.0)
146
+ if touch is not None and cols.size:
147
+ k = np.rint(w).astype(np.int64)
148
+ ok = (k >= 0) & (k < nw)
149
+ ijk = np.empty((int(ok.sum()), 3), np.int64)
150
+ ijk[:, u_ax] = cols[ok] // nv
151
+ ijk[:, v_ax] = cols[ok] % nv
152
+ ijk[:, axis] = k[ok]
153
+ touch[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = True
154
+ ks = np.clip(np.ceil(w), 0, nw).astype(np.int64)
155
+ del w
156
+ # Columns that did not close (odd crossings) leak to the top of the grid;
157
+ # count them so a caller can tell a sound mesh from a holed one.
158
+ odd_cols = int((np.bincount(cols, minlength=nu * nv) & 1).sum()) if cols.size else 0
159
+ out = np.zeros((nu, nv, nw), bool)
160
+ # A slab of u-rows at a time keeps the toggle counts small.
161
+ rows = max(1, int(_BATCH // max(nv * (nw + 1), 1)))
162
+ order = np.argsort(cols, kind="stable")
163
+ cols, ks, sg = cols[order], ks[order], sg[order]
164
+ row_of = cols // nv
165
+ overlap = 0
166
+ for r0 in range(0, nu, rows):
167
+ r1 = min(nu, r0 + rows)
168
+ a, b = np.searchsorted(row_of, [r0, r1])
169
+ if a == b:
170
+ continue
171
+ flat = (cols[a:b] - r0 * nv) * (nw + 1) + ks[a:b]
172
+ tog = np.bincount(flat, minlength=(r1 - r0) * nv * (nw + 1)).astype(np.uint8)
173
+ tog = tog.reshape(r1 - r0, nv, nw + 1)[:, :, :nw]
174
+ par = (np.cumsum(tog, axis=2, dtype=np.uint8) & 1).astype(bool)
175
+ out[r0:r1] = par
176
+ wind = np.bincount(flat, weights=sg[a:b], minlength=(r1 - r0) * nv * (nw + 1))
177
+ wind = np.cumsum(wind.reshape(r1 - r0, nv, nw + 1)[:, :, :nw], axis=2)
178
+ overlap += int(((np.abs(wind) > 0.5) & ~par).sum())
179
+ # back to (x, y, z) order
180
+ perm = np.argsort([u_ax, v_ax, axis])
181
+ return np.transpose(out, perm), odd_cols, overlap
182
+
183
+
184
+ def mesh_solid(mesh, pitch, pad=2, bounds=None, axes=None, touch=False):
185
+ """(bool grid, origin) of a triangle mesh's solid material. `bounds`
186
+ (lo, hi) limits the grid (crossings outside it still count, so parity
187
+ stays right). `axes`: scanline axes to vote over; default z alone for a
188
+ watertight mesh, all three (majority) otherwise. Also returns a dict with
189
+ the number of unclosed columns per axis, which is 0 for a sound mesh.
190
+
191
+ touch: also count as solid every voxel the surface passes through (found
192
+ from the crossings of all three axes), like a surface voxelization. That
193
+ thickens walls by up to a cell, which is what an isolation check wants:
194
+ sampling only voxel centres, a wall two cells thick lets a diagonal
195
+ 26-connected chain of void through and reads as a phantom leak."""
196
+ tri = np.asarray(mesh.triangles, float)
197
+ lo, hi = (np.asarray(b, float) for b in (bounds if bounds is not None else mesh.bounds))
198
+ dims, origin = grid_for(lo, hi, pitch, pad)
199
+ if axes is None:
200
+ axes = (2,) if bool(getattr(mesh, "is_watertight", False)) else (0, 1, 2)
201
+ if bounds is not None:
202
+ # crossings below the grid still toggle parity: extend the grid down
203
+ # each scan axis to the mesh's own minimum, then crop
204
+ mlo = np.asarray(mesh.bounds, float)[0]
205
+ ext = np.maximum(np.ceil((lo - mlo) / pitch).astype(int) + 1, 0)
206
+ else:
207
+ ext = np.zeros(3, int)
208
+ votes = np.zeros(tuple(dims), np.uint8)
209
+ unclosed = {}
210
+ surf = np.zeros(tuple(dims), bool) if touch else None
211
+ overlaps = []
212
+ scan = list(axes) + ([a for a in (0, 1, 2) if a not in axes] if touch else [])
213
+ for ax in scan:
214
+ d2, o2 = dims.copy(), origin.copy()
215
+ d2[ax] += ext[ax]
216
+ o2[ax] -= ext[ax] * pitch
217
+ sl = [slice(None)] * 3
218
+ sl[ax] = slice(int(ext[ax]), None)
219
+ big = np.zeros(tuple(d2), bool) if touch else None
220
+ g, odd, over = _parity_axis(tri, d2, o2, pitch, ax, touch=big)
221
+ if touch:
222
+ surf |= big[tuple(sl)]
223
+ if ax in axes:
224
+ votes += g[tuple(sl)]
225
+ unclosed["xyz"[ax]] = odd
226
+ overlaps.append(over)
227
+ solid = votes * 2 > len(axes)
228
+ if touch:
229
+ solid |= surf
230
+ if pad:
231
+ solid[:pad] = solid[-pad:] = False
232
+ solid[:, :pad] = solid[:, -pad:] = False
233
+ solid[:, :, :pad] = solid[:, :, -pad:] = False
234
+ return solid, origin, {"unclosedColumns": unclosed, "axes": ["xyz"[a] for a in axes],
235
+ "overlapMm3": float(min(overlaps) * pitch ** 3) if overlaps else 0.0}
236
+
237
+
238
+ def _sample(field, origin, dims, pitch, chunk):
239
+ """field < 0 at the points origin + ijk * pitch, a slab at a time."""
240
+ nx, ny, nz = (int(d) for d in dims)
241
+ out = np.zeros((nx, ny, nz), bool)
242
+ ys = origin[1] + np.arange(ny) * pitch
243
+ zs = origin[2] + np.arange(nz) * pitch
244
+ gy, gz = np.meshgrid(ys, zs, indexing="ij")
245
+ plane = np.stack([np.zeros(gy.size), gy.ravel(), gz.ravel()], axis=1)
246
+ rows = max(1, chunk // max(plane.shape[0], 1))
247
+ for i0 in range(0, nx, rows):
248
+ i1 = min(nx, i0 + rows)
249
+ P = np.concatenate([plane + [origin[0] + i * pitch, 0.0, 0.0] for i in range(i0, i1)])
250
+ out[i0:i1] = np.asarray(field(P), float).reshape(i1 - i0, ny, nz) < 0.0
251
+ return out
252
+
253
+
254
+ def field_solid(field, lo, hi, pitch, pad=2, chunk=2_000_000, touch=False):
255
+ """(bool grid, origin) of an implicit solid: field(P) < 0 inside, P an
256
+ (N, 3) array in mm. Evaluated at voxel centres, a slab at a time.
257
+
258
+ touch: also count a voxel as solid when any of its eight corners is in
259
+ the solid, the field's version of mesh_solid's touch. Corners, not a
260
+ distance threshold: a field is often not a true distance (smooth unions,
261
+ seal ramps, TPMS sheets read 0.35-2.5 per mm of gradient), and a level
262
+ of pitch/2 shut real channel throats where the gradient is shallow
263
+ (counterflow exchanger_core read each fluid split in two)."""
264
+ dims, origin = grid_for(lo, hi, pitch, pad)
265
+ solid = _sample(field, origin, dims, pitch, chunk)
266
+ if touch:
267
+ corner = _sample(field, origin - 0.5 * pitch, dims + 1, pitch, chunk)
268
+ for dx in (0, 1):
269
+ for dy in (0, 1):
270
+ for dz in (0, 1):
271
+ solid |= corner[dx:dx + dims[0], dy:dy + dims[1], dz:dz + dims[2]]
272
+ del corner
273
+ # the pad is void by construction, whatever the field says out there
274
+ if pad:
275
+ solid[:pad] = solid[-pad:] = False
276
+ solid[:, :pad] = solid[:, -pad:] = False
277
+ solid[:, :, :pad] = solid[:, :, -pad:] = False
278
+ return solid, origin
279
+
280
+
281
+ def solid_grid(part, pitch, pad=2, bounds=None, touch=False):
282
+ """Grid for either a mesh or a field. A field needs `bounds` (lo, hi)."""
283
+ if callable(part) and not hasattr(part, "triangles"):
284
+ if bounds is None:
285
+ raise ValueError("an implicit field needs bounds=(lo, hi) in mm")
286
+ solid, origin = field_solid(part, bounds[0], bounds[1], pitch, pad, touch=touch)
287
+ return solid, origin, {"source": "field"}
288
+ solid, origin, info = mesh_solid(part, pitch, pad, bounds, touch=touch)
289
+ info["source"] = "mesh"
290
+ return solid, origin, info
291
+
292
+
293
+ def _tiles(n, cap):
294
+ """Slabs along axis 0: (core start, core stop, halo start, halo stop)."""
295
+ step = max(int(cap) * 4, 32)
296
+ for a in range(0, n, step):
297
+ b = min(n, a + step)
298
+ yield a, b, max(0, a - cap), min(n, b + cap)
299
+
300
+
301
+ def _tile_len(shape, cap):
302
+ plane = int(shape[1]) * int(shape[2])
303
+ return max(int(_TILE_VOXELS // max(plane, 1)) - 2 * cap, 8)
304
+
305
+
306
+ def wall_thickness(solid, pitch, cap_mm=None, limit_mm=None, spots=5, origin=None):
307
+ """Member thickness of a grid's solid, in mm, on the medial ridge (the
308
+ local maxima of the distance to void). The estimate 2*edt - 0.5 cells is
309
+ within half a cell of the true thickness either way, so a reading carries
310
+ +/- pitch/2 on top of the grid's own +/- pitch/2 staircase.
311
+
312
+ cap_mm: thicknesses above this are reported as the cap (the distance
313
+ transform only has to look that far, which is what lets it run in tiles).
314
+ limit_mm: also return the share of the surface lying on a member thinner
315
+ than this, and the thinnest spots (world coordinates when `origin` is
316
+ given). Returns a dict."""
317
+ shape = solid.shape
318
+ cap_mm = float(cap_mm) if cap_mm else float(max(shape) * pitch)
319
+ cap = int(np.ceil(cap_mm / (2.0 * pitch))) + 2 # edt cells to look
320
+ vals_all, where_all = [], []
321
+ thin_surf = 0
322
+ surf_total = 0
323
+ grow = int(np.ceil((limit_mm or 0.0) / (2.0 * pitch))) + 1 if limit_mm else 0
324
+ halo = cap + grow + 2
325
+ step = _tile_len(shape, halo)
326
+ for a in range(0, shape[0], step):
327
+ b = min(shape[0], a + step)
328
+ ha, hb = max(0, a - halo), min(shape[0], b + halo)
329
+ sub = solid[ha:hb]
330
+ # void outside the grid on the cross-section sides; along axis 0 the
331
+ # halo stands in for the neighbours (and the grid's pad is void)
332
+ padded = np.pad(sub, ((0, 0), (1, 1), (1, 1)))
333
+ if ha == 0:
334
+ padded = np.pad(padded, ((1, 0), (0, 0), (0, 0)))
335
+ if hb == shape[0]:
336
+ padded = np.pad(padded, ((0, 1), (0, 0), (0, 0)))
337
+ edt = ndimage.distance_transform_edt(padded).astype(np.float32)
338
+ edt = edt[(1 if ha == 0 else 0):edt.shape[0] - (1 if hb == shape[0] else 0), 1:-1, 1:-1]
339
+ np.minimum(edt, cap, out=edt)
340
+ ridge = sub & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
341
+ core = slice(a - ha, b - ha)
342
+ r = ridge[core]
343
+ e = edt[core][r]
344
+ vals_all.append((2.0 * e - 0.5) * pitch)
345
+ if spots and limit_mm:
346
+ ijk = np.argwhere(r)
347
+ ijk[:, 0] += a
348
+ where_all.append(ijk)
349
+ if limit_mm:
350
+ thin = ridge & ((2.0 * edt - 0.5) * pitch < limit_mm)
351
+ near = ndimage.binary_dilation(thin, iterations=grow) if thin.any() else thin
352
+ surf = sub & ~ndimage.binary_erosion(np.pad(sub, 1), border_value=0)[1:-1, 1:-1, 1:-1]
353
+ surf_total += int(surf[core].sum())
354
+ thin_surf += int((surf & near)[core].sum())
355
+ vals = np.concatenate(vals_all) if vals_all else np.zeros(0)
356
+ out = {"pitch": float(pitch), "capMm": round(2 * cap * pitch - 0.5 * pitch, 3), "ridgeCells": int(vals.size)}
357
+ if vals.size == 0:
358
+ out.update(minMm=None, p1Mm=None, p5Mm=None)
359
+ return out
360
+ out.update(minMm=float(vals.min()), p1Mm=float(np.percentile(vals, 1)), p5Mm=float(np.percentile(vals, 5)))
361
+ if limit_mm:
362
+ out["thinSurfaceFraction"] = thin_surf / surf_total if surf_total else 0.0
363
+ out["thinAreaMm2"] = float(thin_surf * pitch * pitch)
364
+ if spots:
365
+ ijk = np.concatenate(where_all)
366
+ order = np.argsort(vals, kind="stable")
367
+ picked, taken = [], []
368
+ sep = max(limit_mm * 4, 8 * pitch)
369
+ for k in order:
370
+ if vals[k] >= limit_mm or len(picked) >= spots:
371
+ break
372
+ p = ijk[k] * pitch + (origin if origin is not None else 0.0)
373
+ if all(np.linalg.norm(p - q) > sep for q in taken):
374
+ taken.append(p)
375
+ picked.append({"at": [round(float(c), 2) for c in p], "mm": round(float(vals[k]), 3)})
376
+ out["thinSpots"] = picked
377
+ return out
378
+
379
+
380
+ def tiled_edt_min(source, targets, cap):
381
+ """Smallest Euclidean distance, in cells, from any `source` cell to any
382
+ `targets` cell, looking at most `cap` cells (None beyond that). Tiled
383
+ along axis 0 so memory stays at one tile of floats."""
384
+ shape = source.shape
385
+ best = None
386
+ halo = int(cap) + 1
387
+ step = _tile_len(shape, halo)
388
+ for a in range(0, shape[0], step):
389
+ b = min(shape[0], a + step)
390
+ if not targets[a:b].any():
391
+ continue
392
+ ha, hb = max(0, a - halo), min(shape[0], b + halo)
393
+ src = source[ha:hb]
394
+ if not src.any():
395
+ continue
396
+ d = ndimage.distance_transform_edt(~src)
397
+ vals = d[a - ha:b - ha][targets[a:b]]
398
+ if vals.size:
399
+ m = float(vals.min())
400
+ if m <= cap and (best is None or m < best):
401
+ best = m
402
+ return best