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,593 @@
1
+ """
2
+ Multi-material briefs: a part printed in two (or more) materials, such as a
3
+ rigid core with a TPU grip pad, a bracket with a rubber isolating layer, or
4
+ a PolyJet Vero body with an Agilus30 skin.
5
+
6
+ The agent hands back one body per material (a multi-material 3MF, or one
7
+ STL per material). The scorer reads the bodies and re-analyses them under
8
+ the GOLD study, ignoring whatever the agent claims:
9
+
10
+ pass = the bodies are in materials the brief allows (at least
11
+ pass.minMaterials of them), do not overlap, and form one
12
+ connected part; the part is inside the envelope, clear of every
13
+ gold keep-out and has every gold interface; every gold load case
14
+ reads each material body's safety factor (multimat.
15
+ verify_multimaterial: per-cell material, across-layer strength
16
+ for FDM) >= SF_SLACK x pass.safetyFactor and every interface
17
+ between materials (the bond card from interface_library, face
18
+ by face) >= SF_SLACK x pass.interfaceSafetyFactor; each
19
+ pass.zones entry (where the brief needs a soft or a rigid
20
+ material) is filled and made of the materials it names; each
21
+ pass.isolate entry has no rigid path between its two regions;
22
+ and the mass is under pass.maxMass.
23
+
24
+ The gold answer: brief["reference"], a list of {material, shape} solids
25
+ (earlier ones win where they overlap, keep-outs are cut away), is the
26
+ minimum defensible design; gold(brief) builds it, writes it as a 3MF the
27
+ way loadpath does and scores it, so the gold passes its own scorer.
28
+
29
+ Fields of a multimat brief beyond the default kind:
30
+ gold a study with a multiMaterial block (the materials
31
+ the reading uses, any interface override)
32
+ reference the gold design as [{material, shape}]
33
+ pass.safetyFactor, pass.interfaceSafetyFactor, pass.maxMass
34
+ pass.materials materials the delivery may use (any others fail)
35
+ pass.minMaterials default 2
36
+ pass.zones [{id, region | shape, materials, minShare, minFill?}]
37
+ minShare: of the part inside the zone, the volume
38
+ share in `materials`; minFill: of the zone (less
39
+ keep-outs), the share that is part at all
40
+ pass.isolate [{id, from, to, materials}]: no connected body of
41
+ `materials` touches both regions (a rubber layer
42
+ that must break every rigid path)
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import glob
47
+ import os
48
+ import re
49
+ import tempfile
50
+ import time
51
+ import xml.etree.ElementTree as ET
52
+ import zipfile
53
+
54
+ import numpy as np
55
+
56
+ from ..briefs import (
57
+ ASSETS,
58
+ MASS_SLACK,
59
+ MAX_INTRUSION,
60
+ MAX_MISSING,
61
+ MAX_OUTSIDE,
62
+ SF_SLACK,
63
+ gold_study,
64
+ )
65
+
66
+ DELIVER = """ part.3mf the part as one multi-material 3MF, one body per material, in the coordinates
67
+ the brief uses (loadpath writes it so); or, instead, one STL per material named
68
+ part-<material>.stl (e.g. part-petg.stl, part-tpu.stl), also in the brief's coordinates
69
+ study.json the study that produced it
70
+ notes.md two or three lines: the mass, the safety factor of each material and of the bond
71
+ between them, which material is where, and your assumptions
72
+ """
73
+
74
+ VERIFY_CELLS = 60_000
75
+ MAX_OVERLAP = 30.0 # mm^3 two bodies may share (a mesh's facets, not a double-printed volume)
76
+ MIN_CONNECTED = 0.97 # share of the delivered volume the analysis must keep (attached to the supports)
77
+ VOXEL_CELLS = 400_000 # connectivity grid (pieces, rigid paths)
78
+ INTERLOCK_RATIO = 1.8 # raw / smoothed interface area that reads as an interlocking lattice
79
+ INTERLOCK_SMOOTH = 2.5 # mm: the majority-vote box (a lattice period is two 0.8 mm beams)
80
+
81
+ _UNITS = {"micron": 1e-3, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0}
82
+
83
+
84
+ # ---------------------------------------------------------------- reading
85
+
86
+ def _material_key(name):
87
+ """A material name, alias or file-name fragment as a library key, or None."""
88
+ from ...materials import material_from_spec
89
+
90
+ try:
91
+ return material_from_spec(str(name).strip()).name
92
+ except (ValueError, TypeError):
93
+ return None
94
+
95
+
96
+ def _stem_material(stem):
97
+ """The material a file name ends with: part-petg, finger_tpu95a, x-pa-cf."""
98
+ from ...materials import _ALIASES, MATERIALS
99
+
100
+ s = stem.lower()
101
+ best = None
102
+ for key in list(MATERIALS) + list(_ALIASES):
103
+ for k in {key, key.replace(" ", "-"), key.replace(" ", "_"), key.replace(" ", "")}:
104
+ hit = s == k or re.search(rf"(^|[-_. ]){re.escape(k)}($|[-_. ]t?\d*$)", s)
105
+ if hit and (best is None or len(k) > len(best[0])):
106
+ best = (k, _material_key(key))
107
+ return best[1] if best else None
108
+
109
+
110
+ def _matrix(text):
111
+ """A 3MF transform attribute (12 numbers, row vectors) as a 4x4 column-vector matrix."""
112
+ if not text:
113
+ return np.eye(4)
114
+ v = np.array([float(x) for x in text.split()], float).reshape(4, 3)
115
+ m = np.eye(4)
116
+ m[:3, :3] = v[:3].T
117
+ m[:3, 3] = v[3]
118
+ return m
119
+
120
+
121
+ def _local(tag):
122
+ return tag.rsplit("}", 1)[-1]
123
+
124
+
125
+ def read_3mf(path):
126
+ """The bodies of a 3MF: [(material key or None, vertices in object space,
127
+ faces, transform from object space through components, build transform)].
128
+ Materials come from core basematerials (object pid/pindex, triangle
129
+ pid/p1), else from the object's name or loadpath:material metadata.
130
+ Production-extension components (p:path into another model file, the
131
+ Bambu layout) are followed."""
132
+ with zipfile.ZipFile(path) as z:
133
+ models = {"/" + n.lstrip("/"): z.read(n) for n in z.namelist() if n.lower().endswith(".model")}
134
+ cfg = z.read("Metadata/Slic3r_PE_model.config") if "Metadata/Slic3r_PE_model.config" in z.namelist() \
135
+ else None
136
+ volumes = {} # PrusaSlicer: object id -> [(first, last) triangle of each volume]
137
+ if cfg is not None:
138
+ for o in ET.fromstring(cfg):
139
+ if _local(o.tag) == "object":
140
+ volumes[o.get("id")] = [(int(v.get("firstid")), int(v.get("lastid"))) for v in o
141
+ if _local(v.tag) == "volume"]
142
+ roots, objects, bases, units = {}, {}, {}, {}
143
+ for name, data in models.items():
144
+ root = ET.fromstring(data)
145
+ roots[name] = root
146
+ units[name] = _UNITS.get(root.get("unit", "millimeter"), 1.0)
147
+ for res in root:
148
+ if _local(res.tag) != "resources":
149
+ continue
150
+ for el in res:
151
+ tag = _local(el.tag)
152
+ if tag == "basematerials":
153
+ bases[(name, el.get("id"))] = [b.get("name") for b in el if _local(b.tag) == "base"]
154
+ elif tag == "object":
155
+ objects[(name, el.get("id"))] = el
156
+
157
+ def mat_of(model, pid, idx, fallback):
158
+ names = bases.get((model, pid))
159
+ if names is not None and idx is not None and 0 <= int(idx) < len(names):
160
+ k = _material_key(names[int(idx)]) or _stem_material(names[int(idx)])
161
+ if k:
162
+ return k
163
+ return fallback
164
+
165
+ out = []
166
+
167
+ def expand(model, oid, tf, build):
168
+ el = objects.get((model, oid))
169
+ if el is None:
170
+ raise ValueError(f"3MF object {oid} not found in {model}")
171
+ meta = {(_local(m.tag), m.get("name")): (m.text or "") for m in el.iter() if _local(m.tag) == "metadata"}
172
+ fallback = None
173
+ for (_, k), v in meta.items():
174
+ if k and k.endswith("material"):
175
+ fallback = _material_key(v) or fallback
176
+ fallback = fallback or _stem_material(el.get("name", "") or "")
177
+ for child in el:
178
+ tag = _local(child.tag)
179
+ if tag == "mesh":
180
+ vs, ts = None, []
181
+ for part in child:
182
+ if _local(part.tag) == "vertices":
183
+ vs = np.array([[float(v.get("x")), float(v.get("y")), float(v.get("z"))] for v in part],
184
+ float) * units[model]
185
+ elif _local(part.tag) == "triangles":
186
+ ts = [(int(t.get("v1")), int(t.get("v2")), int(t.get("v3")),
187
+ t.get("pid") or el.get("pid"), t.get("p1") if t.get("pid") or t.get("p1")
188
+ else el.get("pindex")) for t in part]
189
+ if vs is None or not ts:
190
+ continue
191
+ faces = np.array([t[:3] for t in ts], np.int64)
192
+ keys = np.array([str(mat_of(model, t[3], t[4], fallback)) for t in ts])
193
+ segs = volumes.get(el.get("id")) if model == "/3D/3dmodel.model" else None
194
+ for a, z_ in segs or [(0, len(ts) - 1)]:
195
+ for k in sorted(set(keys[a:z_ + 1])):
196
+ sel = np.zeros(len(ts), bool)
197
+ sel[a:z_ + 1] = keys[a:z_ + 1] == k
198
+ out.append((None if k == "None" else k, vs, faces[sel], tf, build))
199
+ elif tag == "components":
200
+ for c in child:
201
+ ref = None
202
+ for a, v in c.attrib.items():
203
+ if _local(a) == "path":
204
+ ref = "/" + v.lstrip("/")
205
+ expand(ref or model, c.get("objectid"), tf @ _matrix(c.get("transform")), build)
206
+
207
+ for name, root in roots.items():
208
+ for b in root:
209
+ if _local(b.tag) != "build":
210
+ continue
211
+ for item in b:
212
+ if _local(item.tag) == "item":
213
+ expand(name, item.get("objectid"), np.eye(4), _matrix(item.get("transform")))
214
+ return out
215
+
216
+
217
+ def _mesh(vs, faces, tf):
218
+ import trimesh
219
+
220
+ used, inv = np.unique(faces.ravel(), return_inverse=True)
221
+ v = vs[used] @ tf[:3, :3].T + tf[:3, 3]
222
+ m = trimesh.Trimesh(v, inv.reshape(-1, 3), process=True)
223
+ if m.volume < 0:
224
+ m.invert()
225
+ return m
226
+
227
+
228
+ def read_delivery(folder, envelope=None):
229
+ """[(material key, trimesh)] from the agent's folder: part.3mf (any .3mf
230
+ if there is no part.3mf), else STLs named by material. A 3MF keeps its
231
+ meshes in design coordinates and the print orientation in the build
232
+ item (as loadpath writes it); one that baked the bed placement into the
233
+ vertices is read with the build transform when that lands it inside the
234
+ envelope instead. Returns (bodies, source, warnings)."""
235
+ warn = []
236
+ tmf = [os.path.join(folder, "part.3mf")] if os.path.exists(os.path.join(folder, "part.3mf")) else \
237
+ sorted(glob.glob(os.path.join(folder, "*.3mf")))
238
+ if tmf:
239
+ raw = read_3mf(tmf[0])
240
+ if not raw:
241
+ raise ValueError(f"{os.path.basename(tmf[0])} has no mesh")
242
+
243
+ def place(with_build):
244
+ return [(k, _mesh(v, f, (b @ t) if with_build else t)) for k, v, f, t, b in raw]
245
+
246
+ bodies = place(False)
247
+ if envelope is not None and any(not np.allclose(b, np.eye(4)) for *_, b in raw):
248
+ lo, hi = envelope
249
+
250
+ def inside(bs):
251
+ v = np.vstack([m.vertices for _, m in bs])
252
+ return float(np.all((v >= lo - 1.0) & (v <= hi + 1.0), axis=1).mean())
253
+
254
+ alt = place(True)
255
+ if inside(alt) > inside(bodies) + 0.2:
256
+ bodies = alt
257
+ warn.append("3MF vertices read through the build transform (they are not in design coordinates)")
258
+ return bodies, os.path.basename(tmf[0]), warn
259
+ import trimesh
260
+
261
+ bodies = []
262
+ for p in sorted(glob.glob(os.path.join(folder, "*.stl"))):
263
+ k = _stem_material(os.path.splitext(os.path.basename(p))[0])
264
+ if k is None:
265
+ warn.append(f"{os.path.basename(p)}: no material in the file name, ignored")
266
+ continue
267
+ bodies.append((k, trimesh.load(p, force="mesh")))
268
+ if not bodies:
269
+ raise ValueError("no part.3mf and no STL named by material (part-<material>.stl)")
270
+ return bodies, "stl", warn
271
+
272
+
273
+ # ---------------------------------------------------------------- analysis
274
+
275
+ def _man(mesh):
276
+ from ...shapes import manifold_from_trimesh
277
+
278
+ return manifold_from_trimesh(mesh)
279
+
280
+
281
+ def _solids(bodies):
282
+ """[(material, manifold, trimesh)] for each delivered body with volume.
283
+ Bodies of one material stay separate: two that share a face do not fuse
284
+ into one closed shell, and a shell pair would break ray-parity voxels."""
285
+ from ...shapes import trimesh_from_manifold
286
+
287
+ out = []
288
+ for k, m in bodies:
289
+ man = _man(m)
290
+ if man.volume() > 1e-6:
291
+ out.append((k, man, trimesh_from_manifold(man)))
292
+ return out
293
+
294
+
295
+ def _voxels(solids, cells=VOXEL_CELLS):
296
+ """The bodies on one grid: per cell the index into `solids` (-1 void),
297
+ earlier bodies first. Returns (label, origin, pitch)."""
298
+ from ...verify import voxelize
299
+
300
+ lo = np.min([m.bounds[0] for _, _, m in solids], axis=0)
301
+ hi = np.max([m.bounds[1] for _, _, m in solids], axis=0)
302
+ pitch = max(float(np.prod(hi - lo) / cells) ** (1.0 / 3.0), 0.1)
303
+ label, origin = None, None
304
+ for i, (_, _, m) in enumerate(solids):
305
+ s, origin = voxelize(m, pitch, box=(lo, hi))
306
+ if label is None:
307
+ label = np.full(s.shape, -1, np.int64)
308
+ label[s & (label < 0)] = i
309
+ return label, origin, pitch
310
+
311
+
312
+ def _interlock_ratio(solids, label, pitch, width=INTERLOCK_SMOOTH):
313
+ """How much more interface the bodies have than their smoothed layout:
314
+ material faces between different materials on the voxel grid, over the
315
+ same count once each material's indicator is box-averaged over `width`
316
+ mm (a majority vote). About 1 for a flat or curved joint, several times
317
+ that for an interlocking beam lattice. None with one material."""
318
+ from scipy import ndimage
319
+
320
+ if len({k for k, _, _ in solids}) < 2:
321
+ return None
322
+ keys = sorted({k for k, _, _ in solids})
323
+ mat = np.full(label.shape, -1, np.int64)
324
+ for i, (k, _, _) in enumerate(solids):
325
+ mat[label == i] = keys.index(k)
326
+ solid = mat >= 0
327
+ n = max(3, round(width / pitch) | 1)
328
+ den = ndimage.uniform_filter(solid.astype(np.float32), n)
329
+ votes = np.stack([ndimage.uniform_filter((mat == j).astype(np.float32), n) / np.maximum(den, 1e-6)
330
+ for j in range(len(keys))])
331
+ smooth = np.where(solid, votes.argmax(axis=0), -1)
332
+
333
+ def faces(m):
334
+ c = 0
335
+ for ax in range(3):
336
+ a = np.moveaxis(m, ax, 0)
337
+ x, y = a[1:], a[:-1]
338
+ c += int(((x >= 0) & (y >= 0) & (x != y)).sum())
339
+ return c
340
+
341
+ raw, sm = faces(mat), faces(smooth)
342
+ return raw / sm if sm else None
343
+
344
+
345
+ def _shape_of(study, z):
346
+ from ...shapes import shape_from_spec
347
+
348
+ return study.region_shape(z["region"])[0] if "region" in z else shape_from_spec(z["shape"], ASSETS)
349
+
350
+
351
+ def _zone_shape(study, z):
352
+ from ...sharp import exact_manifold
353
+
354
+ return exact_manifold(_shape_of(study, z))
355
+
356
+
357
+ def analyse(brief, bodies, max_cells=VERIFY_CELLS):
358
+ """Re-analyse delivered [(material key, trimesh)] under the gold study.
359
+ Returns the score row (without "pass")."""
360
+ import manifold3d as m3d
361
+
362
+ from ...multimat import parse_multimaterial, verify_multimaterial
363
+ from ...verify import _preserve_missing, _union
364
+
365
+ want = brief["pass"]
366
+ study = gold_study(brief)
367
+ gmm = brief["gold"].get("multiMaterial") or {}
368
+ row = {"failures": []}
369
+ f = row["failures"]
370
+ merged = _solids(bodies)
371
+ if not merged:
372
+ f.append("no solid body")
373
+ return row
374
+ unknown = [k for k, _, _ in merged if k is None]
375
+ if unknown:
376
+ f.append(f"{len(unknown)} body/bodies with no material")
377
+ merged = [b for b in merged if b[0] is not None]
378
+ names = list(dict.fromkeys(k for k, _, _ in merged))
379
+ row["materials"] = names
380
+ allowed = [_material_key(m) for m in want.get("materials", [])]
381
+ if allowed:
382
+ bad = [k for k in names if k not in allowed]
383
+ if bad:
384
+ f.append(f"material(s) {bad} not what the brief asks for ({sorted(set(allowed))})")
385
+ if len(names) < want.get("minMaterials", 2):
386
+ f.append(f"{len(names)} material(s); the brief needs {want.get('minMaterials', 2)}")
387
+
388
+ # geometry on the whole part
389
+ part = m3d.Manifold.batch_boolean([m for _, m, _ in merged], m3d.OpType.Add) if len(merged) > 1 \
390
+ else merged[0][1]
391
+ vol = float(part.volume())
392
+ body_vol = sum(float(m.volume()) for _, m, _ in merged)
393
+ row["overlap"] = round(body_vol - vol, 1)
394
+ if row["overlap"] > MAX_OVERLAP:
395
+ f.append(f"bodies overlap by {row['overlap']} mm^3")
396
+ # connectivity on voxels: bodies that only share a face stay apart in manifold3d
397
+ from scipy import ndimage
398
+
399
+ label, origin, vpitch = _voxels(merged)
400
+ comp, _ = ndimage.label(label >= 0)
401
+ sizes = np.bincount(comp.ravel())[1:]
402
+ row["bodies"] = int((sizes * vpitch ** 3 > 1.0).sum())
403
+ if row["bodies"] != 1:
404
+ f.append(f"{row['bodies']} separate pieces")
405
+ design = _union(study.design_space + [r.shape for r in study.preserve])
406
+ keep = _union([r.shape for r in study.keep_out])
407
+ pres = _union([r.shape for r in study.preserve])
408
+ row["keepOutIntrusion"] = round(float((part ^ keep).volume()) if keep is not None else 0.0, 1)
409
+ row["outsideDesignSpace"] = round(float((part - design).volume()), 1)
410
+ if pres is not None:
411
+ from ...shapes import trimesh_from_manifold
412
+ row["preserveMissing"] = round(_preserve_missing(pres - keep if keep is not None else pres, part,
413
+ trimesh_from_manifold(part)), 1)
414
+ else:
415
+ row["preserveMissing"] = 0.0
416
+ if row["outsideDesignSpace"] > MAX_OUTSIDE:
417
+ f.append(f"{row['outsideDesignSpace']} mm^3 outside the envelope")
418
+ if row["keepOutIntrusion"] > MAX_INTRUSION:
419
+ f.append(f"{row['keepOutIntrusion']} mm^3 in keep-outs")
420
+ if row["preserveMissing"] > MAX_MISSING:
421
+ f.append(f"{row['preserveMissing']} mm^3 of interfaces missing")
422
+
423
+ # where each material has to be
424
+ zones = {}
425
+ for z in want.get("zones", []):
426
+ zm = _zone_shape(study, z)
427
+ if keep is not None:
428
+ zm = zm - keep
429
+ ok = {_material_key(m) for m in z["materials"]}
430
+ inside = {}
431
+ for k, m, _ in merged:
432
+ inside[k] = inside.get(k, 0.0) + float((m ^ zm).volume())
433
+ tot = sum(inside.values())
434
+ share = sum(v for k, v in inside.items() if k in ok) / tot if tot > 0 else 0.0
435
+ fill = tot / max(float(zm.volume()), 1e-9)
436
+ zones[z["id"]] = {"share": round(share, 3), "fill": round(fill, 3)}
437
+ if share < z.get("minShare", 0.9):
438
+ f.append(f"zone '{z['id']}': {share:.0%} {'/'.join(sorted(ok))} (needs {z.get('minShare', 0.9):.0%})")
439
+ if z.get("minFill") and fill < z["minFill"]:
440
+ f.append(f"zone '{z['id']}': only {fill:.0%} filled (needs {z['minFill']:.0%})")
441
+ row["zones"] = zones
442
+ centres = None
443
+ for iso in want.get("isolate", []):
444
+ ok = {_material_key(m) for m in iso["materials"]}
445
+ hard = np.isin(label, [i for i, (k, _, _) in enumerate(merged) if k in ok])
446
+ comp, _ = ndimage.label(hard)
447
+ if centres is None:
448
+ idx = np.argwhere(label >= 0)
449
+ centres = (idx, origin + (idx + 0.5) * vpitch)
450
+ idx, pts = centres
451
+ c = comp[tuple(idx.T)]
452
+ a = c[_shape_of(study, iso["from"]).contains(pts) & (c > 0)]
453
+ b = c[_shape_of(study, iso["to"]).contains(pts) & (c > 0)]
454
+ bridged = bool(np.intersect1d(np.unique(a), np.unique(b)).size)
455
+ row.setdefault("isolated", {})[iso["id"]] = not bridged
456
+ if bridged:
457
+ f.append(f"'{iso['id']}': a rigid path joins the two sides ({'/'.join(sorted(ok))})")
458
+
459
+ # the FEA: every material and every bond, per gold case
460
+ mats = list(dict.fromkeys(names + [_material_key(m) for m in gmm.get("materials", [])]))[:4]
461
+ if len(mats) < 2:
462
+ mats.append("tpu" if mats[0] != "tpu" else "petg")
463
+ block = {"materials": mats}
464
+ for k in ("interfaces", "interlock", "interfaceSafetyFactor", "requirements"):
465
+ if gmm.get(k) is not None:
466
+ block[k] = gmm[k]
467
+ # An interlocked joint (the materials interpenetrate in a beam lattice)
468
+ # is credited the way loadpath credits it, but only when the geometry
469
+ # shows one: the agent saying so is not enough.
470
+ ratio = _interlock_ratio(merged, label, vpitch)
471
+ row["interfaceRoughness"] = round(ratio, 2) if ratio else None
472
+ if ratio and ratio >= INTERLOCK_RATIO and "interlock" not in block:
473
+ block["interlock"] = True
474
+ row["interlockDetected"] = True
475
+ mm = parse_multimaterial(block, study, ASSETS)
476
+ order = sorted(range(len(merged)), key=lambda i: -mm.materials[mm.index(merged[i][0])].E)
477
+ vb = [(mm.index(merged[i][0]), merged[i][2]) for i in order] # stiff first: shared cells go rigid
478
+ t0 = time.time()
479
+ try:
480
+ rep = verify_multimaterial(study, mm, vb, max_cells=max_cells)
481
+ except (ValueError, RuntimeError) as exc:
482
+ # verify keeps only what is attached to the supports: a load left on a loose piece lands here
483
+ msg = str(exc).strip().splitlines()[-1] if str(exc).strip() else type(exc).__name__
484
+ f.append(f"analysis failed (is the loaded part attached to the supports?): {msg[:200]}")
485
+ return row
486
+ row["verifySeconds"] = round(time.time() - t0, 1)
487
+ row["analysisPitch"] = round(rep["analysisPitch"], 3)
488
+ kept = rep["cells"] * rep["analysisPitch"] ** 3 / max(vol, 1e-9)
489
+ row["analysedShare"] = round(kept, 3)
490
+ if kept < MIN_CONNECTED:
491
+ f.append(f"only {kept:.0%} of the part is attached to the supports")
492
+ row["mass"] = round(rep["mass"], 2)
493
+ row["materialMass"] = {k: round(v, 2) for k, v in rep["materialMass"].items()}
494
+ row["interfaceArea"] = {k: round(v, 1) for k, v in rep["interfaceArea"].items() if v > 0}
495
+ sf, sfi = want.get("safetyFactor"), want.get("interfaceSafetyFactor", want.get("safetyFactor"))
496
+ row["cases"] = []
497
+ for c in rep["cases"]:
498
+ rc = {"name": c["name"],
499
+ "materials": {k: round(v["safetyFactor"], 2) for k, v in c["materials"].items()},
500
+ "interfaces": {k: round(min(v["safetyFactor"], 1e3), 2) for k, v in c["interfaces"].items()}}
501
+ row["cases"].append(rc)
502
+ for k, v in rc["materials"].items():
503
+ if sf and v < SF_SLACK * sf:
504
+ f.append(f"{k} SF {v:.2f} < {sf} in '{c['name']}'")
505
+ for k, v in rc["interfaces"].items():
506
+ if sfi and v < SF_SLACK * sfi:
507
+ f.append(f"{k} bond SF {v:.2f} < {sfi} in '{c['name']}'")
508
+ for key in ("knock", "indentation"):
509
+ if key in c and not c[key]["meets"]:
510
+ f.append(f"'{c['name']}' {key} requirement not met")
511
+ row["safetyFactor"] = round(rep["safetyFactor"], 3)
512
+ row["interfaceSafetyFactor"] = round(min(rep["interfaceSafetyFactor"], 1e3), 3)
513
+ if len(names) >= 2 and not row["interfaceArea"]:
514
+ f.append("the materials never meet (no interface)")
515
+ mmax = want.get("maxMass")
516
+ if mmax and rep["mass"] > MASS_SLACK * mmax:
517
+ f.append(f"{rep['mass']:.1f} g > {mmax} g")
518
+ return row
519
+
520
+
521
+ def score(brief, folder, max_cells=VERIFY_CELLS) -> dict:
522
+ """Read the agent's bodies from `folder` and re-analyse them under the
523
+ gold study (see the module docstring)."""
524
+ study = gold_study(brief)
525
+ lo, hi = study.bounds()
526
+ try:
527
+ bodies, source, warn = read_delivery(folder, (np.asarray(lo), np.asarray(hi)))
528
+ except (ValueError, OSError, zipfile.BadZipFile, ET.ParseError) as exc:
529
+ return {"pass": False, "failures": [f"no multi-material part: {exc}"]}
530
+ row = analyse(brief, bodies, max_cells)
531
+ row["source"] = source
532
+ if warn:
533
+ row["warnings"] = warn
534
+ row["pass"] = not row["failures"]
535
+ return row
536
+
537
+
538
+ # ---------------------------------------------------------------- the gold
539
+
540
+ def reference_bodies(brief):
541
+ """brief["reference"] as [(material key, trimesh)]: each solid less the
542
+ earlier ones and the gold keep-outs."""
543
+ import manifold3d as m3d
544
+
545
+ from ...shapes import shape_from_spec, trimesh_from_manifold
546
+ from ...sharp import exact_manifold
547
+ from ...verify import _union
548
+
549
+ study = gold_study(brief)
550
+ keep = _union([r.shape for r in study.keep_out])
551
+ taken, out = None, []
552
+ for r in brief["reference"]:
553
+ m = exact_manifold(shape_from_spec(r["shape"], ASSETS))
554
+ if keep is not None:
555
+ m = m - keep
556
+ if taken is not None:
557
+ m = m - taken
558
+ taken = m if taken is None else m3d.Manifold.batch_boolean([taken, m], m3d.OpType.Add)
559
+ if m.volume() > 1e-6:
560
+ out.append((_material_key(r["material"]), trimesh_from_manifold(m)))
561
+ return out
562
+
563
+
564
+ def write_delivery(bodies, folder, name="part", process=None, direction=None, slicer="all"):
565
+ """Bodies as the agent would leave them: part.3mf (loadpath's writer)."""
566
+ from ...threemf import write_multimaterial_3mf
567
+
568
+ os.makedirs(folder, exist_ok=True)
569
+ spec = [{"name": f"{name} {k}", "mesh": m, "material": k} for k, m in bodies]
570
+ return write_multimaterial_3mf(os.path.join(folder, "part.3mf"), spec, direction=direction, name=name,
571
+ process=process, slicer=slicer)
572
+
573
+
574
+ def gold(brief, max_cells=VERIFY_CELLS) -> dict:
575
+ """The gold study lints and builds, and the reference design, written as
576
+ a 3MF and read back, passes the scorer."""
577
+ from ...tasks import check_study
578
+
579
+ res = check_study(brief["gold"], base_dir=ASSETS)
580
+ if not res["ok"]:
581
+ return res
582
+ if not brief.get("reference"):
583
+ return {"ok": False, "error": "multimat brief has no reference design"}
584
+ study = gold_study(brief)
585
+ with tempfile.TemporaryDirectory() as d:
586
+ write_delivery(reference_bodies(brief), d, process=study.process, direction=study.print_direction)
587
+ row = score(brief, d, max_cells)
588
+ keys = ("mass", "safetyFactor", "interfaceSafetyFactor", "zones", "verifySeconds")
589
+ out = {"ok": row["pass"], "cells": res.get("cells", ""), "warnings": res.get("warnings", []),
590
+ "score": {k: row.get(k) for k in keys}}
591
+ if not row["pass"]:
592
+ out["error"] = "reference fails its own scorer: " + "; ".join(row["failures"])
593
+ return out