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/checks/fea.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ fea_probe: coarse voxel linear-elastic critic for load-bearing parts. Seconds,
3
+ not minutes; advises, never generates — geometry always comes from the caller,
4
+ this just says "the root of that cantilever is the hotspot, thicken it".
5
+
6
+ The mesh is voxelized on a regular grid and every solid voxel becomes an
7
+ 8-node hexahedral element (full 2x2x2 Gauss integration, one shared stiffness
8
+ matrix since all elements are identical cubes). E=1.0, nu=0.3 — RELATIVE
9
+ results only: use displacements/stresses to compare variants of the same part
10
+ under the same loads, never as absolute MPa. Real materials, units, and safety
11
+ factors are out of scope by design.
12
+ """
13
+ import numpy as np
14
+ from scipy import ndimage
15
+ import scipy.sparse as sp
16
+ from scipy.sparse.linalg import cg
17
+
18
+ from ..sdf_kit import _solid_voxels
19
+
20
+ __all__ = ["fea_probe"]
21
+
22
+ _E, _NU = 1.0, 0.3
23
+ _MAX_SOLID = 200_000 # element-count refusal — keep the critic seconds-scale
24
+ _CG_MAXITER = 5000
25
+ _CG_RTOL = 1e-6
26
+ _HOTSPOTS = 5
27
+
28
+ # local corner order for the hex (matches the B-matrix DOF layout below)
29
+ _CORNERS = np.array([
30
+ [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
31
+ [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1],
32
+ ])
33
+ _CONN26 = np.ones((3, 3, 3), bool)
34
+
35
+
36
+ def _elastic_c(e, nu):
37
+ """Isotropic elasticity matrix (6x6, engineering shear strains)."""
38
+ lam = e * nu / ((1 + nu) * (1 - 2 * nu))
39
+ mu = e / (2 * (1 + nu))
40
+ c = np.zeros((6, 6))
41
+ c[:3, :3] = lam
42
+ c[np.arange(3), np.arange(3)] = lam + 2 * mu
43
+ c[np.arange(3, 6), np.arange(3, 6)] = mu
44
+ return c
45
+
46
+
47
+ def _b_matrix(xi, eta, zeta, h):
48
+ """Strain-displacement matrix (6x24) at a natural-coordinate point, for a
49
+ cube element of side h (Jacobian is diagonal h/2)."""
50
+ s = 2.0 * _CORNERS - 1.0 # corner signs
51
+ scale = 2.0 / h # dN/dx = dN/dxi * 2/h
52
+ dn = np.empty((8, 3))
53
+ dn[:, 0] = 0.125 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2]) * scale
54
+ dn[:, 1] = 0.125 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2]) * scale
55
+ dn[:, 2] = 0.125 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1]) * scale
56
+ b = np.zeros((6, 24))
57
+ for i in range(8):
58
+ dx, dy, dz = dn[i]
59
+ col = 3 * i
60
+ b[0, col] = dx
61
+ b[1, col + 1] = dy
62
+ b[2, col + 2] = dz
63
+ b[3, col], b[3, col + 1] = dy, dx
64
+ b[4, col + 1], b[4, col + 2] = dz, dy
65
+ b[5, col], b[5, col + 2] = dz, dx
66
+ return b
67
+
68
+
69
+ def _hex_ke(h, c):
70
+ """Element stiffness (24x24) for a cube of side h, 2x2x2 Gauss."""
71
+ g = 1.0 / np.sqrt(3.0)
72
+ det_j = (h / 2.0) ** 3
73
+ ke = np.zeros((24, 24))
74
+ for xi in (-g, g):
75
+ for eta in (-g, g):
76
+ for zeta in (-g, g):
77
+ b = _b_matrix(xi, eta, zeta, h)
78
+ ke += b.T @ c @ b * det_j
79
+ return ke
80
+
81
+
82
+ def _nodes_in_spheres(coords, spheres):
83
+ """Boolean mask of node coords within any {"point", "r"} sphere."""
84
+ hit = np.zeros(len(coords), bool)
85
+ for s in spheres:
86
+ pt = np.asarray(s["point"], float)
87
+ hit |= np.linalg.norm(coords - pt, axis=1) <= float(s["r"])
88
+ return hit
89
+
90
+
91
+ def _fail(out, msg):
92
+ out["error"] = msg
93
+ return out
94
+
95
+
96
+ def fea_probe(mesh, loads, supports, resolution=32):
97
+ """Coarse voxel FEA. loads: [{"point", "r", "force": [fx,fy,fz]}] — each
98
+ force split evenly over the nodes its sphere catches. supports:
99
+ [{"point", "r"}] — nodes inside are fully fixed.
100
+
101
+ Returns {"ok", "maxDisplacement", "compliance", "hotspots": top-5
102
+ [{"point", "vonMises"}], "resolution", "error"}. Comparative numbers only
103
+ (E=1): valid for "variant B is stiffer than variant A" and "the hotspot is
104
+ here", meaningless as absolute stress. Refuses (ok=False + error) on >200k
105
+ solid voxels, disconnected loads, or CG non-convergence.
106
+ """
107
+ out = {"ok": False, "maxDisplacement": 0.0, "compliance": 0.0,
108
+ "hotspots": [], "resolution": int(resolution), "error": None}
109
+ try:
110
+ pitch = float(np.max(mesh.extents)) / float(resolution)
111
+ solid, origin = _solid_voxels(mesh, pitch, pad=1)
112
+ n_solid = int(solid.sum())
113
+ if n_solid == 0:
114
+ return _fail(out, "voxelization produced no solid elements")
115
+ if n_solid > _MAX_SOLID:
116
+ return _fail(out, f"{n_solid} solid voxels exceeds the "
117
+ f"{_MAX_SOLID} element budget — lower resolution")
118
+
119
+ eidx = np.argwhere(solid) # (ne, 3) element cells
120
+ dims = np.asarray(solid.shape) + 1 # node grid dims
121
+ # node (i,j,k) sits at the voxel-corner lattice; voxel centers are at
122
+ # origin + idx*pitch, so corners are offset by -pitch/2.
123
+ node0 = origin - 0.5 * pitch
124
+
125
+ def nid(multi): # (…,3) node multi-index -> flat node id
126
+ return (multi[..., 0] * dims[1] + multi[..., 1]) * dims[2] + multi[..., 2]
127
+
128
+ # drop solid islands with no support: they make K singular, and any
129
+ # load on them is a modeling error worth surfacing, not solving.
130
+ comp_lbl, _ = ndimage.label(solid, structure=_CONN26)
131
+ all_nodes_multi = np.stack(np.meshgrid(*[np.arange(d) for d in dims],
132
+ indexing="ij"), axis=-1)
133
+ all_coords = node0 + all_nodes_multi.reshape(-1, 3) * pitch
134
+ sup_node = _nodes_in_spheres(all_coords, supports).reshape(*dims)
135
+ if not sup_node.any():
136
+ return _fail(out, "no nodes inside any support sphere")
137
+ enodes_multi = eidx[:, None, :] + _CORNERS[None, :, :] # (ne, 8, 3)
138
+ e_supported = sup_node[tuple(np.moveaxis(enodes_multi, -1, 0))].any(axis=1)
139
+ good_comps = np.unique(comp_lbl[tuple(eidx[e_supported].T)]) \
140
+ if e_supported.any() else np.array([], int)
141
+ keep = np.isin(comp_lbl[tuple(eidx.T)], good_comps)
142
+ if not keep.any():
143
+ return _fail(out, "no solid material connected to the supports")
144
+ eidx = eidx[keep]
145
+ enodes_multi = enodes_multi[keep]
146
+
147
+ # compact node numbering over used nodes only
148
+ enodes_flat = nid(enodes_multi) # (ne, 8)
149
+ used, enodes = np.unique(enodes_flat, return_inverse=True)
150
+ enodes = enodes.reshape(enodes_flat.shape)
151
+ coords = node0 + np.stack(np.unravel_index(used, dims), axis=1) * pitch
152
+ n_node = len(used)
153
+ edofs = (3 * enodes[:, :, None] + np.arange(3)).reshape(-1, 24)
154
+
155
+ # loads and supports on the compacted nodes
156
+ fixed = np.repeat(_nodes_in_spheres(coords, supports), 3)
157
+ f = np.zeros(3 * n_node)
158
+ for load in loads:
159
+ hit = _nodes_in_spheres(coords, [load])
160
+ k = int(hit.sum())
161
+ if k == 0:
162
+ return _fail(out, f"load at {load['point']} catches no nodes "
163
+ "connected to the supports")
164
+ f[np.repeat(hit, 3)] += np.tile(np.asarray(load["force"], float) / k, k)
165
+
166
+ c = _elastic_c(_E, _NU)
167
+ ke = _hex_ke(pitch, c)
168
+ n_dof = 3 * n_node
169
+ # chunked assembly: 576 triplets per element adds up fast at the top
170
+ # of the element budget, so build and fold in ~2M-triplet slabs.
171
+ k_mat = sp.csr_matrix((n_dof, n_dof))
172
+ chunk = 4000
173
+ for i in range(0, len(edofs), chunk):
174
+ ed = edofs[i:i + chunk]
175
+ rows = np.repeat(ed, 24, axis=1).ravel()
176
+ cols = np.tile(ed, (1, 24)).ravel()
177
+ data = np.tile(ke.ravel(), len(ed))
178
+ k_mat += sp.coo_matrix((data, (rows, cols)),
179
+ shape=(n_dof, n_dof)).tocsr()
180
+
181
+ free = ~fixed
182
+ kff = k_mat[free][:, free]
183
+ diag = kff.diagonal()
184
+ diag[diag <= 0] = 1.0 # paranoia; pruning should preclude zero rows
185
+ precond = sp.diags(1.0 / diag) # Jacobi
186
+ u_f, info = cg(kff, f[free], rtol=_CG_RTOL, maxiter=_CG_MAXITER, M=precond)
187
+ if info != 0:
188
+ return _fail(out, f"CG failed to converge (info={info}) — the "
189
+ "structure may be near-mechanism at this resolution")
190
+
191
+ u = np.zeros(n_dof)
192
+ u[free] = u_f
193
+ out["maxDisplacement"] = float(
194
+ np.linalg.norm(u.reshape(-1, 3), axis=1).max())
195
+ out["compliance"] = float(f @ u)
196
+
197
+ # von Mises from the strain at each element center
198
+ b0 = _b_matrix(0.0, 0.0, 0.0, pitch)
199
+ stress = (u[edofs] @ b0.T) @ c.T # (ne, 6)
200
+ sx, sy, sz, txy, tyz, tzx = stress.T
201
+ vm = np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2)
202
+ + 3.0 * (txy ** 2 + tyz ** 2 + tzx ** 2))
203
+ top = np.argsort(vm)[::-1][:_HOTSPOTS]
204
+ centers = origin + eidx * pitch # voxel centers
205
+ out["hotspots"] = [
206
+ {"point": [float(x) for x in centers[i]], "vonMises": float(vm[i])}
207
+ for i in top
208
+ ]
209
+ out["ok"] = True
210
+ return out
211
+ except Exception as exc: # critic must never take the run down with it
212
+ return _fail(out, f"{type(exc).__name__}: {exc}")
loadpath/checks/fit.py ADDED
@@ -0,0 +1,475 @@
1
+ """
2
+ Component-fit verifier : does the built enclosure actually FIT the
3
+ component the brief promised it would?
4
+
5
+ `check_fit(mesh, spec)` voxelizes the part (reusing sdf_kit's occupancy
6
+ helper), solves where the component actually sits inside the interior air,
7
+ and verifies three machine-checkable contracts per component:
8
+
9
+ fit_cavity — a placement exists where the component envelope (PCB footprint
10
+ + clearance, board top to tallest part + clearance) is fully in
11
+ air, and that placement is walled on all four sides (it's a
12
+ cavity, not empty space beside the part).
13
+ fit_bosses — at the solved placement, every mounting hole in the pattern
14
+ has a pilot bore (air) and boss/standoff material around it
15
+ just below the board seating plane.
16
+ fit_cutout — each verified port has a through-opening of (roughly) the
17
+ cutout size at the expected edge/offset/height.
18
+
19
+ Placement solve: the envelope box is slid over the air grid (uniform_filter =
20
+ "is every voxel under the box air?") for all four flat rotations; among
21
+ feasible poses we prefer the one matching the most mounting holes, then the
22
+ lowest seat. The cavity walls therefore PIN the board — bosses drilled 4 mm
23
+ off pattern cannot be rescued by sliding the board 4 mm, because the envelope
24
+ no longer fits.
25
+
26
+ Resolution honesty (same posture as networks.py): verified at voxel pitch p
27
+ means "no interference thicker than ~p" — sub-voxel interference can slip
28
+ through the occupancy grid. The report carries the pitch used; below-board
29
+ pin clearance is NOT yet asserted (bosses/standoffs already imply seating
30
+ height; a dedicated pins check is future work).
31
+
32
+ Spec shape:
33
+ {"components": [{
34
+ "id": "pico", "label": "Raspberry Pi Pico",
35
+ "boardMm": [L, W, T], "clearanceMm": c, "aboveMm": a, "belowMm": b,
36
+ "holes": {"positions": [[x, y], ...], "diaMm": d} | null,
37
+ "ports": [{"id": "micro-usb", "edge": "-x", "offsetMm": o,
38
+ "heightMm": h, "wMm": w, "hMm": hh},
39
+ {"id": "screen-window", "face": "+z", "xMm": x, "yMm": y,
40
+ "wMm": w, "hMm": hh}, ...]
41
+ }]}
42
+
43
+ Port kinds: lateral ports carry edge/offset/height and are checked with
44
+ outward rays through the wall; `face: "+z"` ports (screen windows, lenses,
45
+ shafts) carry a center in the component frame and are checked with
46
+ upward rays from the component top through the lid.
47
+
48
+ Component frame: origin at board center, +x along length, +y along width,
49
+ z up; port heights above the PCB TOP. Result ids: "fit:<id>:cavity",
50
+ "fit:<id>:bosses", "fit:<id>:port:<portId>" — matched by the TS side against
51
+ the DimensionTargets that requested them.
52
+ """
53
+ import numpy as np
54
+ from scipy import ndimage
55
+
56
+ from ..sdf_kit import _solid_voxels
57
+
58
+ __all__ = ["check_fit"]
59
+
60
+ # Feasibility: every sampled envelope voxel must be air.
61
+ _AIR_FRACTION_OK = 0.999
62
+ # A lateral face counts as "walled" when at least this fraction of its
63
+ # outward rays hit solid before leaving the grid (port cutouts legitimately
64
+ # open part of a wall).
65
+ _WALL_MIN_BLOCKED = 0.35
66
+ # Boss ring: sample points on a circle this far outside the hole radius...
67
+ _RING_EXTRA_MM = 1.0
68
+ # ...and require at least this many of the 8 ring points to be solid.
69
+ _RING_POINTS = 8
70
+ _RING_MIN_SOLID = 6
71
+ # Cap on candidate placements scored per rotation (subsampled by z-order).
72
+ _MAX_CANDIDATES = 4000
73
+ # Voxel budget — pitch grows until the padded grid fits.
74
+ _MAX_VOXELS = 4.5e6
75
+
76
+
77
+ def _rot_xy(pts, rot):
78
+ """Rotate (n, 2) component-frame XY points by rot degrees (multiple of 90)."""
79
+ pts = np.atleast_2d(np.asarray(pts, float))
80
+ if rot == 0:
81
+ return pts
82
+ if rot == 90:
83
+ return np.column_stack([-pts[:, 1], pts[:, 0]])
84
+ if rot == 180:
85
+ return -pts
86
+ return np.column_stack([pts[:, 1], -pts[:, 0]]) # 270
87
+
88
+
89
+ def _pitch_for(mesh, comps):
90
+ """Voxel pitch: fine enough to resolve pilot bores, coarse enough to fit
91
+ the voxel budget."""
92
+ ext = np.asarray(mesh.extents, float)
93
+ pitch = float(max(ext)) / 110.0
94
+ pitch = min(max(pitch, 0.35), 0.8)
95
+ dias = [
96
+ float(c["holes"]["diaMm"])
97
+ for c in comps
98
+ if c.get("holes") and c["holes"].get("diaMm")
99
+ ]
100
+ if dias:
101
+ pitch = min(pitch, min(dias) * 0.35)
102
+ while np.prod(np.ceil(ext / pitch) + 6) > _MAX_VOXELS:
103
+ pitch *= 1.25
104
+ return float(pitch)
105
+
106
+
107
+ class _AirGrid:
108
+ """Boolean air occupancy over the mesh bbox (padded). Points outside the
109
+ grid are air by definition (outside the part's bounding box)."""
110
+
111
+ def __init__(self, mesh, pitch):
112
+ solid, origin = _solid_voxels(mesh, pitch, pad=3)
113
+ self.air = ~solid
114
+ self.origin = np.asarray(origin, float)
115
+ self.pitch = float(pitch)
116
+
117
+ def is_air(self, pts):
118
+ """(n, 3) world points -> (n,) bool."""
119
+ pts = np.atleast_2d(np.asarray(pts, float))
120
+ idx = np.rint((pts - self.origin) / self.pitch).astype(int)
121
+ shape = np.asarray(self.air.shape)
122
+ inside = np.all((idx >= 0) & (idx < shape), axis=1)
123
+ out = np.ones(len(idx), bool)
124
+ ii = idx[inside]
125
+ if len(ii):
126
+ out[inside] = self.air[ii[:, 0], ii[:, 1], ii[:, 2]]
127
+ return out
128
+
129
+ def ray_clear(self, start, direction, step=None):
130
+ """True when marching from `start` along `direction` exits the grid
131
+ without ever touching solid — i.e. an opening goes all the way out."""
132
+ step = step or self.pitch * 0.9
133
+ d = np.asarray(direction, float)
134
+ d = d / np.linalg.norm(d)
135
+ span = float(np.linalg.norm(np.asarray(self.air.shape) * self.pitch))
136
+ n = int(span / step) + 2
137
+ ts = self.pitch * 0.6 + step * np.arange(n)
138
+ pts = np.asarray(start, float)[None, :] + ts[:, None] * d[None, :]
139
+ return bool(np.all(self.is_air(pts)))
140
+
141
+
142
+ def _feasible_centers(grid, box_mm):
143
+ """All voxel centers where an axis-aligned box of `box_mm` is entirely
144
+ air. Returns (centers_world (n,3), best_air_fraction)."""
145
+ airf = grid.air.astype(np.float32)
146
+ # Erode the sampled box by ~1 voxel per axis: surface voxelization already
147
+ # dilates the solid by ~half a pitch, and we must not double-count it.
148
+ size = np.maximum(np.rint(np.asarray(box_mm, float) / grid.pitch) - 1, 1).astype(int)
149
+ if np.any(size > np.asarray(grid.air.shape)):
150
+ return np.empty((0, 3)), 0.0
151
+ frac = ndimage.uniform_filter(airf, size=tuple(size), mode="constant", cval=0.0)
152
+ half = size // 2
153
+ valid = np.zeros_like(grid.air)
154
+ sx, sy, sz = grid.air.shape
155
+ valid[half[0]:sx - half[0], half[1]:sy - half[1], half[2]:sz - half[2]] = True
156
+ if not valid.any():
157
+ return np.empty((0, 3)), 0.0
158
+ best = float(frac[valid].max())
159
+ feas = (frac >= _AIR_FRACTION_OK) & valid
160
+ idxs = np.argwhere(feas)
161
+ centers = grid.origin + idxs * grid.pitch
162
+ return centers, best
163
+
164
+
165
+ def _boss_matches(grid, comp, centers, rot, env_z):
166
+ """(n_candidates,) matched-hole counts at each candidate box center.
167
+ A hole matches when its pilot is air and a ring around it is solid just
168
+ below the board seating plane (= envelope bottom)."""
169
+ holes = comp.get("holes")
170
+ if not holes or not holes.get("positions"):
171
+ return np.zeros(len(centers), int)
172
+ pos = _rot_xy(holes["positions"], rot) # (H, 2) world-frame offsets
173
+ r_ring = float(holes["diaMm"]) / 2.0 + _RING_EXTRA_MM
174
+ ang = np.linspace(0, 2 * np.pi, _RING_POINTS, endpoint=False)
175
+ ring = np.column_stack([np.cos(ang), np.sin(ang)]) * r_ring # (P, 2)
176
+ depth1 = max(1.5 * grid.pitch, 1.0)
177
+ depth2 = depth1 + 1.2
178
+
179
+ matched = np.zeros(len(centers), int)
180
+ zb = centers[:, 2] - env_z / 2.0 # board bottom = envelope bottom
181
+ for h in range(len(pos)):
182
+ cxy = centers[:, :2] + pos[h][None, :] # (n, 2)
183
+ ok_h = np.ones(len(centers), bool)
184
+ # Pilot bore: air at the hole axis just below the seat (either depth).
185
+ pilot_ok = np.zeros(len(centers), bool)
186
+ for d in (depth1, depth2):
187
+ pts = np.column_stack([cxy, zb - d])
188
+ pilot_ok |= grid.is_air(pts)
189
+ ok_h &= pilot_ok
190
+ # Boss material: ring of solid around the bore (either depth).
191
+ ring_ok = np.zeros(len(centers), bool)
192
+ for d in (depth1, depth2):
193
+ solid_count = np.zeros(len(centers), int)
194
+ for p in range(_RING_POINTS):
195
+ pts = np.column_stack([cxy + ring[p][None, :], zb - d])
196
+ solid_count += (~grid.is_air(pts)).astype(int)
197
+ ring_ok |= solid_count >= _RING_MIN_SOLID
198
+ ok_h &= ring_ok
199
+ matched += ok_h.astype(int)
200
+ return matched
201
+
202
+
203
+ def _walls_blocked(grid, center, box_mm):
204
+ """Per-face blocked fraction for the four lateral faces of the box at
205
+ `center`. Returns a dict face -> fraction of outward rays that hit solid."""
206
+ bx, by, bz = box_mm
207
+ zs = [center[2] - bz * 0.25, center[2] + bz * 0.25]
208
+ out = {}
209
+ for face, (axis, sign) in {
210
+ "+x": (0, 1), "-x": (0, -1), "+y": (1, 1), "-y": (1, -1),
211
+ }.items():
212
+ tangent = 1 - axis
213
+ half_t = (by if axis == 0 else bx) / 2.0 - grid.pitch
214
+ offs = np.linspace(-half_t, half_t, 5)
215
+ n_hit = 0
216
+ n_all = 0
217
+ d = np.zeros(3)
218
+ d[axis] = sign
219
+ start_axis = center[axis] + sign * ((bx if axis == 0 else by) / 2.0)
220
+ for z in zs:
221
+ for t in offs:
222
+ p = np.array(center, float)
223
+ p[axis] = start_axis
224
+ p[tangent] = center[tangent] + t
225
+ p[2] = z
226
+ n_all += 1
227
+ if not grid.ray_clear(p, d):
228
+ n_hit += 1
229
+ out[face] = n_hit / max(n_all, 1)
230
+ return out
231
+
232
+
233
+ def _port_open(grid, comp, center, rot, env_z, port):
234
+ """Is there a through-opening for `port` at the solved placement?
235
+ Five rays (center, ±width, ±height inside the cutout) must all exit the
236
+ grid without touching solid. Lateral ports shoot outward through the
237
+ wall; `face: "+z"` ports (screen windows) shoot upward through the lid."""
238
+ L, W, T = [float(v) for v in comp["boardMm"]]
239
+ zb = center[2] - env_z / 2.0
240
+ if port.get("face") == "+z":
241
+ # Top-face windows are usually cut at exactly the recommended size,
242
+ # and window-bearing components often have no bosses to pin the solved
243
+ # seat — allow 1 mm of seat slack on top of the voxel margin so a
244
+ # correct window over a slightly-slid seat still verifies.
245
+ margin = max(grid.pitch, 0.8) + 1.0
246
+ half_w = max(float(port["wMm"]) / 2.0 - margin, 0.0)
247
+ half_h = max(float(port["hMm"]) / 2.0 - margin, 0.0)
248
+ p_b = np.array([float(port.get("xMm") or 0.0), float(port.get("yMm") or 0.0)])
249
+ u_w = _rot_xy(np.array([1.0, 0.0]), rot)[0]
250
+ u_h = _rot_xy(np.array([0.0, 1.0]), rot)[0]
251
+ c_w = center[:2] + _rot_xy(p_b, rot)[0]
252
+ z0 = zb + T + grid.pitch
253
+ direction = np.array([0.0, 0.0, 1.0])
254
+ for dw, dh in ((0, 0), (half_w, 0), (-half_w, 0), (0, half_h), (0, -half_h)):
255
+ start = np.array([
256
+ c_w[0] + u_w[0] * dw + u_h[0] * dh,
257
+ c_w[1] + u_w[1] * dw + u_h[1] * dh,
258
+ z0,
259
+ ])
260
+ if not grid.ray_clear(start, direction):
261
+ return False
262
+ return True
263
+ edge = port["edge"]
264
+ off = float(port.get("offsetMm") or 0.0)
265
+ if edge in ("+x", "-x"):
266
+ p_b = np.array([(L / 2.0) * (1 if edge == "+x" else -1), off])
267
+ n_b = np.array([1.0 if edge == "+x" else -1.0, 0.0])
268
+ t_b = np.array([0.0, 1.0])
269
+ else:
270
+ p_b = np.array([off, (W / 2.0) * (1 if edge == "+y" else -1)])
271
+ n_b = np.array([0.0, 1.0 if edge == "+y" else -1.0])
272
+ t_b = np.array([1.0, 0.0])
273
+ p_w = center[:2] + _rot_xy(p_b, rot)[0]
274
+ n_w = _rot_xy(n_b, rot)[0]
275
+ t_w = _rot_xy(t_b, rot)[0]
276
+ z_c = zb + T + float(port.get("heightMm") or 0.0)
277
+ margin = max(grid.pitch, 0.8)
278
+ half_w = max(float(port["wMm"]) / 2.0 - margin, 0.0)
279
+ half_h = max(float(port["hMm"]) / 2.0 - margin, 0.0)
280
+ direction = np.array([n_w[0], n_w[1], 0.0])
281
+ for dw, dh in ((0, 0), (half_w, 0), (-half_w, 0), (0, half_h), (0, -half_h)):
282
+ start = np.array([
283
+ p_w[0] + t_w[0] * dw,
284
+ p_w[1] + t_w[1] * dw,
285
+ z_c + dh,
286
+ ])
287
+ if not grid.ray_clear(start, direction):
288
+ return False
289
+ return True
290
+
291
+
292
+ def _check_component(grid, comp):
293
+ """All fit results for one component spec. Never raises past the caller's
294
+ failure isolation — plain dict results only."""
295
+ cid = str(comp.get("id") or "component")
296
+ label = str(comp.get("label") or cid)
297
+ L, W, T = [float(v) for v in comp["boardMm"]]
298
+ c = float(comp.get("clearanceMm") or 1.0)
299
+ above = float(comp.get("aboveMm") or 0.0)
300
+ env = np.array([L + 2 * c, W + 2 * c, T + above + c])
301
+ holes = comp.get("holes")
302
+ ports = comp.get("ports") or []
303
+ n_holes = len(holes["positions"]) if holes and holes.get("positions") else 0
304
+
305
+ results = []
306
+
307
+ # ---- Placement solve over the four flat rotations ------------------
308
+ # The envelope box is 180°-symmetric, so rotations share feasible sets in
309
+ # pairs: {0, 180} use env as-is, {90, 270} use the XY-swapped box.
310
+ env_swapped = np.array([env[1], env[0], env[2]])
311
+ feas = {0: _feasible_centers(grid, env), 90: _feasible_centers(grid, env_swapped)}
312
+ best_frac = max(feas[0][1], feas[90][1])
313
+ best = None # ((matched, ports_open, -z), center, rot, box)
314
+ for rot in (0, 90, 180, 270):
315
+ box = env if rot % 180 == 0 else env_swapped
316
+ centers = feas[rot % 180][0]
317
+ if len(centers) == 0:
318
+ continue
319
+ if len(centers) > _MAX_CANDIDATES:
320
+ order = np.argsort(centers[:, 2], kind="stable")
321
+ centers = centers[order][:: max(1, len(centers) // _MAX_CANDIDATES)]
322
+ matched = _boss_matches(grid, comp, centers, rot, env[2])
323
+ # Best candidate for this rotation: most holes matched, then lowest.
324
+ order = np.lexsort((centers[:, 2], -matched))
325
+ cand = centers[order[0]]
326
+ m = int(matched[order[0]])
327
+ ports_open = sum(
328
+ 1 for p in ports if _port_open(grid, comp, cand, rot, env[2], p)
329
+ )
330
+ key = (m, ports_open, -cand[2])
331
+ if best is None or key > best[0]:
332
+ best = (key, cand, rot, box)
333
+
334
+ if best is None:
335
+ results.append({
336
+ "id": f"fit:{cid}:cavity",
337
+ "kind": "fit_cavity",
338
+ "ok": False,
339
+ "got": round(best_frac, 3),
340
+ "note": (
341
+ f"{label} envelope {env[0]:.1f} x {env[1]:.1f} x {env[2]:.1f} mm "
342
+ f"(board + clearance) has NO placement fully inside interior air — "
343
+ f"best candidate still overlaps solid (max air fraction "
344
+ f"{best_frac:.0%}). Enlarge the cavity so the board plus "
345
+ f"clearance actually fits."
346
+ ),
347
+ })
348
+ if n_holes:
349
+ results.append({
350
+ "id": f"fit:{cid}:bosses",
351
+ "kind": "fit_bosses",
352
+ "ok": None,
353
+ "note": "not evaluated — no feasible cavity placement",
354
+ })
355
+ for p in ports:
356
+ results.append({
357
+ "id": f"fit:{cid}:port:{p['id']}",
358
+ "kind": "fit_cutout",
359
+ "ok": None,
360
+ "note": "not evaluated — no feasible cavity placement",
361
+ })
362
+ return results
363
+
364
+ _key, center, rot, box = best
365
+
366
+ # ---- Cavity: feasible + actually enclosed ---------------------------
367
+ walls = _walls_blocked(grid, center, box)
368
+ open_faces = [f for f, blocked in walls.items() if blocked < _WALL_MIN_BLOCKED]
369
+ cavity_ok = len(open_faces) == 0
370
+ note = (
371
+ f"{label} seats at ({center[0]:.1f}, {center[1]:.1f}), board bottom "
372
+ f"z={center[2] - env[2] / 2:.1f}, rotated {rot} deg (verified at voxel "
373
+ f"pitch {grid.pitch:.2f} mm)"
374
+ )
375
+ if not cavity_ok:
376
+ note = (
377
+ f"{label} fits in the air volume but is not enclosed: side(s) "
378
+ f"{', '.join(sorted(open_faces))} of the solved seat have no wall "
379
+ f"(blocked fractions {', '.join(f'{f}={walls[f]:.0%}' for f in sorted(walls))}). "
380
+ f"Walls must surround the component seat."
381
+ )
382
+ results.append({
383
+ "id": f"fit:{cid}:cavity",
384
+ "kind": "fit_cavity",
385
+ "ok": bool(cavity_ok),
386
+ "got": 1.0 if cavity_ok else 0.0,
387
+ "note": note,
388
+ })
389
+
390
+ # ---- Bosses ----------------------------------------------------------
391
+ if n_holes:
392
+ matched = int(_boss_matches(grid, comp, center[None, :], rot, env[2])[0])
393
+ ok = matched == n_holes
394
+ if ok:
395
+ bnote = f"all {n_holes} mounting holes have a pilot bore and boss material at the seat"
396
+ else:
397
+ bnote = (
398
+ f"only {matched}/{n_holes} mounting holes align with boss/standoff "
399
+ f"material + pilot bore at the solved seat (pattern: "
400
+ f"{holes['positions']}, D{holes['diaMm']} mm, board frame from "
401
+ f"center, rotated {rot} deg). Bosses must sit exactly under the "
402
+ f"hole pattern with a pilot hole (~{float(holes['diaMm']) - 0.4:.1f} mm) each."
403
+ )
404
+ results.append({
405
+ "id": f"fit:{cid}:bosses",
406
+ "kind": "fit_bosses",
407
+ "ok": bool(ok),
408
+ "got": matched,
409
+ "note": bnote,
410
+ })
411
+
412
+ # ---- Ports -----------------------------------------------------------
413
+ for p in ports:
414
+ ok = _port_open(grid, comp, center, rot, env[2], p)
415
+ if p.get("face") == "+z":
416
+ where_ok = f"{p['id']} opening verified through the top face"
417
+ where_bad = (
418
+ f"no through-opening for {p['id']} ({p['wMm']} x {p['hMm']} mm) "
419
+ f"above the component: window center at ({p.get('xMm', 0)}, "
420
+ f"{p.get('yMm', 0)}) in the board frame. The lid there is "
421
+ f"solid, or the window is misplaced / too small."
422
+ )
423
+ else:
424
+ where_ok = f"{p['id']} opening verified through the {p['edge']} wall"
425
+ where_bad = (
426
+ f"no through-opening for {p['id']} ({p['wMm']} x {p['hMm']} mm) "
427
+ f"where the connector sits: board edge {p['edge']}, "
428
+ f"{p['offsetMm']} mm along the edge, center "
429
+ f"{p['heightMm']} mm above the board top. The wall there is "
430
+ f"solid, or the cutout is on the wrong face / misplaced / too small."
431
+ )
432
+ results.append({
433
+ "id": f"fit:{cid}:port:{p['id']}",
434
+ "kind": "fit_cutout",
435
+ "ok": bool(ok),
436
+ "got": 1.0 if ok else 0.0,
437
+ "note": where_ok if ok else where_bad,
438
+ })
439
+
440
+ return results
441
+
442
+
443
+ def check_fit(mesh, spec):
444
+ """Verify component fit of `mesh` against `spec` (see module docstring).
445
+
446
+ Returns {"pitchMm": p, "results": [{id, kind, ok, got?, note}, ...]}.
447
+ `ok: null` = that check could not be evaluated (reported honest-not-run
448
+ upstream). Raises only on a completely unusable spec/mesh — the caller
449
+ (_run_checks) converts that into {"error": ...}.
450
+ """
451
+ comps = list(spec.get("components") or [])
452
+ if not comps:
453
+ return {"pitchMm": None, "results": []}
454
+ if not bool(mesh.is_watertight):
455
+ # Occupancy classification needs a closed surface; report honestly.
456
+ return {
457
+ "pitchMm": None,
458
+ "results": [],
459
+ "error": "fit checks need a watertight mesh; run them after repair",
460
+ }
461
+ pitch = _pitch_for(mesh, comps)
462
+ grid = _AirGrid(mesh, pitch)
463
+ results = []
464
+ for comp in comps:
465
+ try:
466
+ results.extend(_check_component(grid, comp))
467
+ except Exception as err: # noqa: BLE001 — isolate per component
468
+ cid = str(comp.get("id") or "component")
469
+ results.append({
470
+ "id": f"fit:{cid}:cavity",
471
+ "kind": "fit_cavity",
472
+ "ok": None,
473
+ "note": f"fit check failed to run: {err}",
474
+ })
475
+ return {"pitchMm": pitch, "results": results}