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
@@ -0,0 +1,571 @@
1
+ """
2
+ Lattice briefs: a part that is a lattice for a reason the structural scorer
3
+ alone cannot see. The brief's pass block says which rules apply, and every
4
+ rule is judged on the delivered part.stl, not on what the agent says about it:
5
+
6
+ (gold study) part.stl re-verified under the gold study (briefs.score: one
7
+ body, envelope, keep-outs, interfaces, safety factor, mass)
8
+ cellular it really is a lattice: in a probe box inside the part, the
9
+ filled share is at most maxFill, lines through it cross at
10
+ least minWallsPer10mm walls per 10 mm (a lattice crosses a
11
+ wall every few mm, a solid web or a ribbed part hardly
12
+ ever), optionally a share of straight lines along X and Y
13
+ that see through (minSeeThrough: an open strut lattice), and
14
+ optionally the outside kept (minHull: convex hull volume over
15
+ the design space box)
16
+ drains no sealed cavity over maxEnclosed mm^3 (powder or resin
17
+ trapped inside)
18
+ print sliced along the build direction (printcheck.check_print with
19
+ the brief's bridge span): at most maxSupportArea mm^2 that
20
+ needs support (islands included)
21
+ impact the delivered pad, voxelized and homogenized (each voxel's
22
+ relative density is the part's local filled share, smoothed
23
+ over a few mm so the cells average out, solid face sheets
24
+ kept solid), stopped with the GOLD mass, speed and force
25
+ limit by loadpath's own impact model (impact.impact_report's
26
+ layer model, with the material's crush law and TPU's rate
27
+ stiffening, on a finely resampled curve; see _dense):
28
+ peak force under the limit, crush under maxCrush, inside the
29
+ pad's box, face sheets top and bottom. A field.json with an
30
+ impact block is re-run with the gold numbers too and
31
+ reported (not judged: its envelope and density are the
32
+ agent's claims about the part)
33
+ vibration natural frequencies of the delivered part (modal.py on the
34
+ part voxelized at `pitch`, each voxel at its filled share of
35
+ the modulus and density) clamped in the gold `fixed` box with
36
+ the gold point masses on the nodes in their boxes: first mode
37
+ at least FREQ_SLACK x minFrequency
38
+
39
+ Boxes in the pass block are {"min": [x, y, z], "max": [x, y, z]} in the
40
+ brief's coordinates.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+
47
+ import numpy as np
48
+
49
+ DELIVER = """ part.stl the part to print (your final, verified outcome)
50
+ field.json the field graph that produced it, if a field graph did (with its impact or vibration block)
51
+ study.json the study, if a study produced or checked it
52
+ notes.md two or three lines: the outcome's mass and how it meets the brief's numbers, and your assumptions
53
+ """
54
+
55
+ FREQ_SLACK = 0.95 # the agent's analysis grid and ours differ
56
+ FORCE_SLACK = 1.05 # homogenized crush from a voxelized part: a few percent either way
57
+ CRUSH_SLACK = 1.1
58
+ BOX_TOL = 0.3 # mm a delivered pad may stick out of its box (mesh noise)
59
+ MAX_ENCLOSED = 50.0 # mm^3 sealed inside before it counts as trapped powder
60
+ IMPACT_PITCH = 0.25 # mm voxels for the crush model (TPU walls are ~0.5 mm)
61
+ IMPACT_SMOOTH = 2.5 # mm Gaussian over which the local density is averaged (cells up to ~14 mm)
62
+
63
+ KNOWN = {"safetyFactor", "maxDeflection", "maxMass", "maxSolidFraction", "cellular", "drains", "print",
64
+ "impact", "vibration"}
65
+
66
+
67
+ def _load(folder):
68
+ import trimesh
69
+
70
+ stl = os.path.join(folder, "part.stl")
71
+ if not os.path.exists(stl):
72
+ return stl, None
73
+ return stl, trimesh.load(stl, force="mesh")
74
+
75
+
76
+ def _material(brief, spec):
77
+ from ...materials import material_from_spec
78
+
79
+ name = spec.get("material") or (brief.get("gold") or {}).get("material")
80
+ return material_from_spec(name)
81
+
82
+
83
+ def _box(b):
84
+ return np.asarray(b["min"], float), np.asarray(b["max"], float)
85
+
86
+
87
+ # --- cellular, drains, print --------------------------------------------------
88
+
89
+ def cellular(mesh, spec: dict, design_box=None) -> dict:
90
+ """Filled share, walls crossed per 10 mm along X, Y and Z, and the share
91
+ of X and Y lines that see through, in the probe box (0.4 mm voxels)."""
92
+ from ...verify import voxelize
93
+
94
+ lo, hi = _box(spec["probe"])
95
+ pitch = float(spec.get("pitch", 0.4))
96
+ solid, _ = voxelize(mesh, pitch, box=(lo, hi))
97
+ walls, see = [], []
98
+ for ax in range(3):
99
+ a = np.moveaxis(solid, ax, -1)
100
+ starts = (a[..., 1:] & ~a[..., :-1]).sum(axis=-1) + a[..., 0]
101
+ walls.append(float(starts.mean() / (a.shape[-1] * pitch) * 10.0))
102
+ if ax < 2:
103
+ see.append(float((~a.any(axis=-1)).mean()))
104
+ out = {"fill": round(float(solid.mean()), 3), "wallsPer10mm": [round(w, 2) for w in walls],
105
+ "seeThrough": round(float(np.mean(see)), 3)}
106
+ if spec.get("minHull") is not None and design_box is not None:
107
+ dlo, dhi = design_box
108
+ out["hull"] = round(float(mesh.convex_hull.volume / np.prod(dhi - dlo)), 3)
109
+ return out
110
+
111
+
112
+ def _cellular_failures(c, spec):
113
+ f = []
114
+ if c["fill"] > spec.get("maxFill", 0.6):
115
+ f.append(f"{c['fill']:.0%} solid inside: not a lattice (at most {spec.get('maxFill', 0.6):.0%})")
116
+ need = spec.get("minWallsPer10mm", 0.6)
117
+ if min(c["wallsPer10mm"]) < need:
118
+ f.append(f"{min(c['wallsPer10mm']):.2f} walls per 10 mm through the inside: not a lattice (at least {need})")
119
+ if spec.get("minSeeThrough") is not None and c["seeThrough"] < spec["minSeeThrough"]:
120
+ f.append(f"{c['seeThrough']:.0%} of lines see through the inside: not an open lattice "
121
+ f"(at least {spec['minSeeThrough']:.0%})")
122
+ if spec.get("minHull") is not None and c.get("hull") is not None and c["hull"] < spec["minHull"]:
123
+ f.append(f"the outside fills {c['hull']:.0%} of the block: its shape was not kept")
124
+ return f
125
+
126
+
127
+ def enclosed_volume(mesh) -> float:
128
+ """mm^3 sealed inside the part (manifold's negative-volume pieces)."""
129
+ from ...shapes import manifold_from_trimesh
130
+
131
+ return float(-sum(v for v in (b.volume() for b in manifold_from_trimesh(mesh).decompose()) if v < 0))
132
+
133
+
134
+ def _print(mesh, spec, material):
135
+ from ...printcheck import check_print
136
+
137
+ d = spec.get("direction", "+z")
138
+ direction = {"+x": (0, 1), "-x": (0, -1), "+y": (1, 1), "-y": (1, -1), "+z": (2, 1), "-z": (2, -1)}[d]
139
+ pr = check_print(mesh, direction, "fdm", pitch=spec.get("pitch"), bridge_span=float(spec["bridgeSpan"]),
140
+ material=material)
141
+ row = {k: pr[k] for k in ("pitch", "supportArea", "supportVolume", "islands", "islandArea", "bridges",
142
+ "maxBridgeSpan")}
143
+ f = []
144
+ # islands are in the support area too; a few mm^2 of strut tips cut by a
145
+ # hole or the envelope is voxel noise, not a print that fails
146
+ if pr["supportArea"] > spec.get("maxSupportArea", 25.0):
147
+ f.append(f"{pr['supportArea']:.0f} mm^2 needs support printed {d} ({pr['islands']} island(s); "
148
+ f"bridges up to {spec['bridgeSpan']:g} mm)")
149
+ return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in row.items()}, f
150
+
151
+
152
+ # --- impact ------------------------------------------------------------------
153
+
154
+ def measure_core(mesh, pitch: float = IMPACT_PITCH, axis: int = 2, smooth: float = IMPACT_SMOOTH):
155
+ """The delivered part as a homogenized core: (inside, rho, lo, fill per
156
+ layer, solid layer flags), crush axis last. `inside` is each layer's
157
+ footprint (convex hull of the material within 4 mm of it), rho the
158
+ filled share smoothed over `smooth` mm (normalized over the footprint,
159
+ face sheets left out of the average and kept solid)."""
160
+ from scipy import ndimage
161
+ from skimage.morphology import convex_hull_image
162
+
163
+ from ...verify import voxelize
164
+
165
+ solid, origin = voxelize(mesh, pitch)
166
+ occ = np.moveaxis(solid, axis, 2).astype(float)
167
+ nz = occ.shape[2]
168
+ win = max(1, int(round(4.0 / pitch)))
169
+ inside = np.zeros(occ.shape, bool)
170
+ for k in range(nz):
171
+ pr = occ[:, :, max(0, k - win):k + win + 1].any(axis=2)
172
+ if pr.sum() >= 3:
173
+ inside[:, :, k] = convex_hull_image(pr)
174
+ n = inside.sum(axis=(0, 1))
175
+ fill = np.where(n > 0, (occ * inside).sum(axis=(0, 1)) / np.maximum(n, 1), 0.0)
176
+ solid_layer = fill > 0.9
177
+ w = inside & ~solid_layer[None, None, :]
178
+ s = smooth / pitch
179
+ num = ndimage.gaussian_filter(occ * w, s, mode="constant")
180
+ den = ndimage.gaussian_filter(w.astype(float), s, mode="constant")
181
+ rho = np.where(w, num / np.maximum(den, 1e-9), 0.0)
182
+ rho[:, :, solid_layer] = 1.0
183
+ rho = np.where(inside, np.clip(rho, 0.0, 1.0), 0.0)
184
+ origin = np.asarray(origin, float)
185
+ lo = np.r_[np.delete(origin, axis), origin[axis]]
186
+ return inside, rho, lo, fill, solid_layer
187
+
188
+
189
+ def _impact(brief, mesh, spec, folder):
190
+ mat = _material(brief, spec)
191
+ axis = "xyz".index(spec.get("axis", "z"))
192
+ f = []
193
+ row = {}
194
+ lo, hi = _box(spec["box"])
195
+ blo, bhi = np.asarray(mesh.bounds, float)
196
+ out = float(max(np.max(lo - blo), np.max(bhi - hi)))
197
+ if out > BOX_TOL:
198
+ f.append(f"the pad sticks {out:.1f} mm out of its {np.round(hi - lo, 1).tolist()} mm box")
199
+ inside, rho, _, fill, solid_layer = measure_core(mesh, IMPACT_PITCH, axis)
200
+ core = ~solid_layer & (fill > 0)
201
+ row["coreDensity"] = [round(float(fill[core].min()), 3), round(float(fill[core].max()), 3)] if core.any() else None
202
+ if not core.any() or fill[core].max() > 0.7:
203
+ f.append("no lattice core: it is solid through its height")
204
+ if spec.get("faces"):
205
+ ends = [fill[np.flatnonzero(fill > 0)[0]], fill[np.flatnonzero(fill > 0)[-1]]]
206
+ row["faceFill"] = [round(float(e), 2) for e in ends]
207
+ if min(ends) < 0.85:
208
+ f.append("no solid face sheet on the " + ("bottom" if ends[0] < 0.85 else "top"))
209
+ gold = {"envelope": "envelope", "density": "density", "lattice": spec.get("lattice", "gyroid"), "axis": "z",
210
+ "mass": float(spec["mass"]), "velocity": float(spec["velocity"]),
211
+ "forceLimit": float(spec["forceLimit"]), "pitch": IMPACT_PITCH}
212
+ r, rate = stop_impact(inside, rho, IMPACT_PITCH, gold["lattice"], mat, gold["mass"], gold["velocity"])
213
+ row.update(energy=r["energy"], peakForce=r["peakForce"], crush=r["crush"], peakG=r.get("peakG"),
214
+ bottomedOut=r["bottomedOut"], rate=rate)
215
+ if r["bottomedOut"] or r["peakForce"] is None:
216
+ f.append(f"bottoms out: absorbs {r['capacity'] / 1000:.2f} J of {r['energy'] / 1000:.2f} J")
217
+ else:
218
+ if r["peakForce"] > FORCE_SLACK * spec["forceLimit"]:
219
+ f.append(f"peak force {r['peakForce']:.0f} N > {spec['forceLimit']:g} N")
220
+ if spec.get("maxCrush") and r["crush"] > CRUSH_SLACK * spec["maxCrush"]:
221
+ f.append(f"crushes {r['crush']:.1f} mm > {spec['maxCrush']:g} mm")
222
+ row["graph"] = _graph_impact(folder, gold, mat)
223
+ return row, f
224
+
225
+
226
+ def _dense(cv, step=0.01):
227
+ """The crush curve resampled every `step` mm. impact_response inverts
228
+ the energy by linear interpolation between the curve's corners, which on
229
+ an elastic ramp (energy quadratic in crush) reads far too little force
230
+ when the stop ends on it: a solid TPU block came out at 80 N instead of
231
+ about 2 kN. Fine sampling makes the inversion exact enough."""
232
+ from ...impact import CrushCurve
233
+
234
+ d = np.unique(np.r_[cv.d, np.arange(0.0, float(cv.d[-1]), step)])
235
+ return CrushCurve(d, np.interp(d, cv.d, cv.f), cv.stroke, cv.layer_force, cv.layer_area, cv.layer_rho,
236
+ cv.layer_stroke)
237
+
238
+
239
+ def stop_impact(inside, rho, h, kind, material, mass, velocity):
240
+ """impact.impact_report's layer model on a voxel core (crush along the
241
+ grid's last axis): the material's crush law, each layer's force the sum
242
+ of its cells' plateau, an elastomer stiffened for the crush time, and
243
+ the stop found on a finely resampled curve. Returns (response, rate
244
+ factor or None)."""
245
+ from ...impact import (
246
+ crush_curve,
247
+ crush_law,
248
+ impact_response,
249
+ layer_profile,
250
+ plateau_stress,
251
+ )
252
+
253
+ law, sy = crush_law(kind, material)
254
+ E = float(material.E)
255
+ area, mean = layer_profile(inside, rho, h, 2)
256
+ lf = (plateau_stress(law, rho, sy) * inside).sum(axis=(0, 1)) * h ** 2
257
+ energy = 1000.0 * 0.5 * mass * velocity ** 2
258
+ resp = impact_response(_dense(crush_curve(law, area, mean, h, sy, E, layer_force=lf)), energy, mass)
259
+ fac = None
260
+ if law.endswith(":elastic"):
261
+ from ...viscoelastic import creep_params, event_factor
262
+
263
+ if creep_params(material) is not None:
264
+ for _ in range(4):
265
+ t = 2.0 * float(resp["crush"]) * 1e-3 / velocity
266
+ fac = float(event_factor(material, t))
267
+ cv = crush_curve(law, area, mean, h, sy * fac, E * fac, layer_force=lf * fac)
268
+ resp = impact_response(_dense(cv), energy, mass)
269
+ return resp, (round(fac, 3) if fac else None)
270
+
271
+
272
+ def _graph_impact(folder, gold, mat):
273
+ """The agent's own field graph's impact block, re-run with the gold
274
+ mass, speed, force limit and axis (reported, not judged)."""
275
+ path = os.path.join(folder, "field.json")
276
+ if not os.path.exists(path):
277
+ return None
278
+ try:
279
+ from ...fields import FieldGraph
280
+ from ...impact import impact_report
281
+
282
+ with open(path) as fh:
283
+ doc = json.load(fh)
284
+ spec = doc.get("impact")
285
+ if not spec:
286
+ return {"note": "field.json has no impact block"}
287
+ spec = {k: v for k, v in spec.items() if k not in ("energy", "grade")}
288
+ spec.update({k: gold[k] for k in ("mass", "velocity", "forceLimit", "axis")})
289
+ r = impact_report(FieldGraph(doc, base_dir=folder), spec, mat)
290
+ return {"peakForce": r["response"]["peakForce"], "crush": r["response"]["crush"], "ok": r.get("ok")}
291
+ except Exception as exc: # noqa: BLE001 - the agent's graph is information here
292
+ return {"error": str(exc).strip().splitlines()[-1][:200] if str(exc).strip() else type(exc).__name__}
293
+
294
+
295
+ # --- vibration ---------------------------------------------------------------
296
+
297
+ def _vibration(brief, mesh, spec):
298
+ from ...fem import HexMesh
299
+ from ...modal import modal_analysis
300
+ from ...verify import voxelize
301
+
302
+ mat = _material(brief, spec)
303
+ h = float(spec.get("pitch", 1.0))
304
+ vol = float(abs(mesh.volume))
305
+ h = max(h, (vol / 30_000) ** (1.0 / 3.0)) # at most ~30k cells: a direct eigensolve
306
+ sub = int(spec.get("sub", 3))
307
+ lo = np.floor(np.asarray(mesh.bounds[0]) / h) * h - h
308
+ hi = np.ceil(np.asarray(mesh.bounds[1]) / h) * h + h
309
+ solid, origin = voxelize(mesh, h / sub, box=(lo, hi))
310
+ n = np.array(solid.shape) // sub
311
+ share = solid[:n[0] * sub, :n[1] * sub, :n[2] * sub].reshape(n[0], sub, n[1], sub, n[2], sub).mean(axis=(1, 3, 5))
312
+ keep = share >= 0.1
313
+ hm = HexMesh(np.argwhere(keep), h, origin)
314
+ s = share[keep]
315
+ xyz = hm.node_xyz
316
+
317
+ def nodes_in(b):
318
+ blo, bhi = _box(b)
319
+ return np.flatnonzero(np.all((xyz >= blo - 1e-6) & (xyz <= bhi + 1e-6), axis=1))
320
+
321
+ f = []
322
+ fn = nodes_in(spec["fixed"])
323
+ if len(fn) == 0:
324
+ return {}, ["nothing where the part is held"]
325
+ fixed = np.zeros(hm.n_dof, bool)
326
+ for a in range(3):
327
+ fixed[3 * fn + a] = True
328
+ pm = []
329
+ for p in spec.get("pointMasses", []):
330
+ nn = nodes_in(p)
331
+ if len(nn) == 0:
332
+ return {}, [f"nothing where the {p['mass']:g} kg mass sits"]
333
+ pm.append((nn, float(p["mass"])))
334
+ # parts not connected to the clamp float: drop them (their modes are 0 Hz)
335
+ from scipy import ndimage
336
+
337
+ lbl, _ = ndimage.label(keep)
338
+ held = np.unique(lbl[tuple(np.argwhere(keep)[np.isin(hm.enode, fn).any(axis=1)].T)])
339
+ if not np.isin(lbl[keep], held).all():
340
+ f.append("material not connected to the held face")
341
+ res = modal_analysis(hm, mat.E * s, mat.density * s, mat.nu, fixed, int(spec.get("modes", 3)), pm)
342
+ freqs = [round(float(x), 1) for x in res.frequencies]
343
+ row = {"frequencies": freqs, "cells": int(hm.n_elem), "pitch": round(h, 3)}
344
+ if freqs[0] < FREQ_SLACK * spec["minFrequency"]:
345
+ f.append(f"first mode {freqs[0]:.0f} Hz < {spec['minFrequency']:g} Hz")
346
+ return row, f
347
+
348
+
349
+ # --- the kind's interface ----------------------------------------------------
350
+
351
+ def score(brief: dict, folder: str, max_cells: int | None = None) -> dict:
352
+ from .. import briefs
353
+
354
+ want = brief["pass"]
355
+ stl, mesh = _load(folder)
356
+ if mesh is None:
357
+ return {"pass": False, "failures": ["no part.stl"]}
358
+ if "gold" in brief:
359
+ row = briefs.score(brief, stl, max_cells or briefs.VERIFY_CELLS)
360
+ row.pop("pass", None)
361
+ else:
362
+ row = {"failures": []}
363
+ mat = _material(brief, want.get("impact") or want.get("vibration") or {})
364
+ row["mass"] = round(float(abs(mesh.volume)) * mat.density / 1000.0, 2)
365
+ row["bodies"] = len(mesh.split(only_watertight=False))
366
+ if row["bodies"] != 1:
367
+ row["failures"].append(f"{row['bodies']} bodies")
368
+ if want.get("maxMass") and row["mass"] > briefs.MASS_SLACK * want["maxMass"]:
369
+ row["failures"].append(f"{row['mass']:.1f} g > {want['maxMass']} g")
370
+ f = row["failures"]
371
+ mat = _material(brief, {}) if "gold" in brief else None
372
+ if want.get("cellular"):
373
+ row["cellular"] = cellular(mesh, want["cellular"], _design_box(brief) if "gold" in brief else None)
374
+ f += _cellular_failures(row["cellular"], want["cellular"])
375
+ if want.get("drains"):
376
+ row["enclosedVolume"] = round(enclosed_volume(mesh), 1)
377
+ lim = want["drains"].get("maxEnclosed", MAX_ENCLOSED)
378
+ if row["enclosedVolume"] > lim:
379
+ f.append(f"{row['enclosedVolume']:.0f} mm^3 sealed inside: the powder cannot get out")
380
+ if want.get("print"):
381
+ row["print"], ff = _print(mesh, want["print"], mat)
382
+ f += ff
383
+ if want.get("impact"):
384
+ row["impact"], ff = _impact(brief, mesh, want["impact"], folder)
385
+ f += ff
386
+ if want.get("vibration"):
387
+ row["vibration"], ff = _vibration(brief, mesh, want["vibration"])
388
+ f += ff
389
+ row["pass"] = not f
390
+ return row
391
+
392
+
393
+ def gold(brief: dict) -> dict:
394
+ """The gold study builds (if there is one), the pass block is well formed,
395
+ and an impact brief is feasible: loadpath's own graded core for the
396
+ pad's box (less 1 mm face sheets) meets the force and crush limits."""
397
+ from .. import briefs
398
+
399
+ want = brief.get("pass") or {}
400
+ unknown = set(want) - KNOWN
401
+ if unknown:
402
+ return {"ok": False, "error": f"unknown pass keys {sorted(unknown)}"}
403
+ res = {"ok": True}
404
+ if "gold" in brief:
405
+ from ...tasks import check_study
406
+
407
+ res = check_study(brief["gold"], base_dir=briefs.ASSETS)
408
+ if not res["ok"]:
409
+ return res
410
+ elif not (want.get("impact") or want.get("vibration")):
411
+ return {"ok": False, "error": "no gold study and no impact or vibration block to judge by"}
412
+ try:
413
+ if want.get("cellular"):
414
+ _box(want["cellular"]["probe"])
415
+ if want.get("print") and "bridgeSpan" not in want["print"]:
416
+ return {"ok": False, "error": "print needs bridgeSpan"}
417
+ if want.get("vibration"):
418
+ v = want["vibration"]
419
+ _box(v["fixed"])
420
+ for p in v.get("pointMasses", []):
421
+ _box(p)
422
+ float(p["mass"])
423
+ float(v["minFrequency"])
424
+ if want.get("impact"):
425
+ res = {**res, **_impact_feasible(brief, want["impact"])}
426
+ except (KeyError, TypeError, ValueError) as exc:
427
+ return {"ok": False, "error": f"pass block: {exc}"}
428
+ return res
429
+
430
+
431
+ def _impact_feasible(brief, spec):
432
+ from ...fields import FieldGraph
433
+ from ...impact import impact_report
434
+
435
+ lo, hi = _box(spec["box"])
436
+ core_lo, core_hi = lo.copy(), hi.copy()
437
+ core_lo[2] += 1.0
438
+ core_hi[2] -= 1.0
439
+ doc = {"nodes": {"envelope": {"op": "shape", "shape": {"type": "box", "min": core_lo.tolist(),
440
+ "max": core_hi.tolist()}}}}
441
+ g = FieldGraph(doc)
442
+ s = {"envelope": "envelope", "density": 0.3, "lattice": spec.get("lattice", "gyroid"), "axis": "z",
443
+ "mass": spec["mass"], "velocity": spec["velocity"], "forceLimit": spec["forceLimit"], "grade": True,
444
+ "cell": spec.get("cell", 8.0), "minWall": spec.get("minWall", 0.5), "pitch": 0.5}
445
+ r = impact_report(g, s, _material(brief, spec))["graded"]
446
+ resp = r["response"]
447
+ ok = bool(r["ok"]) and resp["peakForce"] is not None and resp["crush"] <= spec.get("maxCrush", np.inf)
448
+ out = {"ok": ok, "gradedPeakForce": resp["peakForce"], "gradedCrush": resp["crush"],
449
+ "gradedCoreMass": r["coreMass"]}
450
+ if not ok:
451
+ out["error"] = f"no graded core meets it: {resp}"
452
+ return out
453
+
454
+
455
+ # --- reference deliveries (tests, and a sanity check of a brief's numbers) ---
456
+
457
+ def _mbox(lo, hi):
458
+ import manifold3d as m3d
459
+
460
+ lo, hi = np.asarray(lo, float), np.asarray(hi, float)
461
+ return m3d.Manifold.cube(tuple(hi - lo)).translate(tuple(lo))
462
+
463
+
464
+ def _mcyl(p0, p1, r, sections=24):
465
+ import trimesh
466
+
467
+ from ...shapes import manifold_from_trimesh
468
+
469
+ return manifold_from_trimesh(trimesh.creation.cylinder(radius=r, segment=[np.asarray(p0, float),
470
+ np.asarray(p1, float)],
471
+ sections=sections))
472
+
473
+
474
+ def _union(parts):
475
+ import manifold3d as m3d
476
+
477
+ return m3d.Manifold.batch_boolean(list(parts), m3d.OpType.Add)
478
+
479
+
480
+ def _prism_x(pts, x0, x1):
481
+ """A prism along X over the (y, z) polygon `pts` (counter-clockwise)."""
482
+ import trimesh
483
+
484
+ from ...shapes import manifold_from_trimesh
485
+
486
+ pts = np.asarray(pts, float)
487
+ n = len(pts)
488
+ V = np.vstack([np.c_[np.full(n, x0), pts], np.c_[np.full(n, x1), pts]])
489
+ F = [[0, i + 1, i] for i in range(1, n - 1)] + [[n, n + i, n + i + 1] for i in range(1, n - 1)]
490
+ for i in range(n):
491
+ j = (i + 1) % n
492
+ F += [[i, j, n + j], [i, n + j, n + i]]
493
+ t = trimesh.Trimesh(V, np.array(F), process=True)
494
+ if t.volume < 0:
495
+ t.invert()
496
+ return manifold_from_trimesh(t)
497
+
498
+
499
+ def _design_box(brief):
500
+ ds = brief["gold"]["designSpace"]
501
+ return np.min([s["min"] for s in ds], axis=0), np.max([s["max"] for s in ds], axis=0)
502
+
503
+
504
+ def bcc_struts(lo, hi, cell, cz, d, phase, sections=8):
505
+ """A BCC strut lattice (cell x cell x cz, strut diameter d) clipped to the
506
+ box, its body-centre nodes on `phase` + (cell, cell, cz) / 2."""
507
+ import itertools
508
+
509
+ lo, hi, o = (np.asarray(v, float) for v in (lo, hi, phase))
510
+ c = np.array([cell, cell, cz])
511
+ first = np.floor((lo - o) / c).astype(int) - 1
512
+ last = np.ceil((hi - o) / c).astype(int) + 1
513
+ struts = []
514
+ for idx in itertools.product(*(range(first[i], last[i]) for i in range(3))):
515
+ mid = o + (np.array(idx) + 0.5) * c
516
+ for s in itertools.product((-1, 1), repeat=3):
517
+ struts.append(_mcyl(mid, mid + 0.5 * np.array(s) * c, d / 2, sections))
518
+ return _union(struts) ^ _mbox(lo, hi)
519
+
520
+
521
+ def reference(brief: dict, folder: str, quick: bool = True) -> str:
522
+ """Write a reference delivery (part.stl) for one of this kind's briefs
523
+ into `folder`: a defensible answer, not the lightest. `quick` uses
524
+ coarser lattice and mesh settings (tests)."""
525
+ from ...shapes import trimesh_from_manifold
526
+
527
+ os.makedirs(folder, exist_ok=True)
528
+ bid = brief["id"]
529
+ if bid == "shoulder-riser-mjf":
530
+ from ...lattice import LatticeOptions, envelope_mesh, lattice_infill
531
+ from .. import briefs
532
+
533
+ study = briefs.gold_study(brief)
534
+ o = LatticeOptions.from_spec({"type": "gyroid", "cell": 12, "skin": 0, "minWall": 0.8, "maxWall": 3,
535
+ "collar": 1.0, "safetyFactor": 3.5}, study.process, 3.5)
536
+ o.analysis_cells, o.iterations = (12_000, 4) if quick else (30_000, 6)
537
+ if quick:
538
+ o.step = 0.45
539
+ mesh, _ = lattice_infill(study, envelope_mesh(study), None, o)
540
+ elif bid == "elbow-stop-pad-tpu":
541
+ from ...fields import FieldGraph
542
+
543
+ sh = lambda lo, hi: {"op": "shape", "shape": {"type": "box", "min": lo, "max": hi}}
544
+ doc = {"nodes": {"core": {"op": "lattice", "type": "gyroid", "cell": 8, "density": 0.19},
545
+ "env": sh([-15, -15, 1], [15, 15, 14]),
546
+ "clip": {"op": "intersect", "inputs": ["core", "env"]},
547
+ "part": {"op": "union", "inputs": ["clip", sh([-15, -15, 0], [15, 15, 1.2]),
548
+ sh([-15, -15, 13.8], [15, 15, 15])]}},
549
+ "output": "part"}
550
+ mesh = FieldGraph(doc).mesh(pitch=0.25)
551
+ elif bid == "tray-arm-lattice-fdm":
552
+ lat = bcc_struts([4, -15, 0], [74, 15, 40], 8.0, 12.0, 2.0, [4, -4, 0], 8 if quick else 12)
553
+ keel = _prism_x([(0, 30), (10, 40), (-10, 40)], 4, 74) # a 45-degree V top chord
554
+ body = _union([lat, keel, _mbox([0, -15, 0], [4, 15, 40]), _mbox([74, -15, 0], [80, 15, 40]),
555
+ _mbox([0, -15, 0], [80, 15, 2.5])])
556
+ holes = ([_mcyl([-1, 0, z], [5, 0, z], 2.2) for z in (10, 30)]
557
+ + [_mcyl([4, 0, z], [12, 0, z], 4.0) for z in (10, 30)]
558
+ + [_mcyl([73.5, y, 30], [81, y, 30], 2.0) for y in (-8, 8)])
559
+ mesh = trimesh_from_manifold(body - _union(holes))
560
+ elif bid == "camera-boom-mjf":
561
+ w = 1.2 # an open-ended square tube
562
+ tube = _mbox([3, -11, -11], [95, 11, 11]) - _mbox([3 + w, -11 + w, -11 + w], [100, 11 - w, 11 - w])
563
+ body = _union([_mbox([0, -18, -18], [3, 18, 18]), tube, _mbox([70, -11, 7], [95, 11, 11])])
564
+ holes = ([_mcyl([-1, y, z], [5, y, z], 1.7) for y in (-14, 14) for z in (-14, 14)]
565
+ + [_mcyl([x, 0, 5], [x, 0, 12], 1.1) for x in (72, 93)])
566
+ mesh = trimesh_from_manifold(body - _union(holes))
567
+ else:
568
+ raise ValueError(f"no reference delivery for {bid!r}")
569
+ path = os.path.join(folder, "part.stl")
570
+ mesh.export(path)
571
+ return path