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/threemf.py ADDED
@@ -0,0 +1,487 @@
1
+ """
2
+ Slicer-ready output: a 3MF with the part already standing the way it was
3
+ designed to print.
4
+
5
+ An STL has no orientation of its own, so a person drops it in a slicer and
6
+ lays it flat on whatever face the slicer guesses, which for a part designed
7
+ self-supporting along one direction can be the one way it isn't. The 3MF
8
+ keeps the part in its design coordinates (so it still lines up with the
9
+ assembly) and puts the print orientation in the build item's transform: the
10
+ build direction to +z, the lowest point on the bed, centred on the plate.
11
+ PrusaSlicer, OrcaSlicer, Bambu Studio and Cura all honour build transforms.
12
+
13
+ Core 3MF (3D Manufacturing Format, core spec 1.3): a model part, mm, plus
14
+ metadata naming the build direction, material and process. With
15
+ `object_settings` or `modifiers` it also carries PrusaSlicer's per-object
16
+ config and modifier volumes (Metadata/Slic3r_PE_model.config): extra meshes
17
+ in the same object that change print settings (infill density, perimeters)
18
+ wherever they overlap the part. That is how graded infill (infill.py)
19
+ reaches the slicer; PrusaSlicer reads it, and Orca Slicer and Bambu Studio
20
+ import PrusaSlicer 3MFs with their modifiers.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import io
25
+ import os
26
+ import re
27
+ import uuid
28
+ import zipfile
29
+ from types import SimpleNamespace
30
+ from xml.sax.saxutils import escape, quoteattr
31
+
32
+ import numpy as np
33
+
34
+ from .am import direction_label
35
+ from .printcheck import rotation_to_z
36
+
37
+ __all__ = ["write_3mf", "print_transform", "write_multimaterial_3mf", "write_material_stls"]
38
+
39
+ _CONTENT_TYPES = """<?xml version="1.0" encoding="UTF-8"?>
40
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
41
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
42
+ <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
43
+ </Types>
44
+ """
45
+ _RELS = """<?xml version="1.0" encoding="UTF-8"?>
46
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
47
+ <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
48
+ </Relationships>
49
+ """
50
+
51
+
52
+ def print_transform(mesh, direction, plate_center=(128.0, 128.0)) -> np.ndarray:
53
+ """4x4 (column-vector) transform from design coordinates to the print
54
+ bed: `direction` to +z, lowest point at z = 0, footprint centred on
55
+ `plate_center` (mm)."""
56
+ rot = rotation_to_z(direction)
57
+ pts = np.asarray(mesh.vertices) @ rot.T
58
+ lo, hi = pts.min(axis=0), pts.max(axis=0)
59
+ t = np.array([plate_center[0] - 0.5 * (lo[0] + hi[0]), plate_center[1] - 0.5 * (lo[1] + hi[1]), -lo[2]])
60
+ m = np.eye(4)
61
+ m[:3, :3] = rot
62
+ m[:3, 3] = t
63
+ return m
64
+
65
+
66
+ def _fmt(v):
67
+ return f"{v:.6g}"
68
+
69
+
70
+ def write_3mf(path, mesh, direction=None, name: str = "part", material=None, process=None,
71
+ plate_center=(128.0, 128.0), metadata: dict | None = None,
72
+ object_settings: dict | None = None, modifiers: list | None = None) -> str:
73
+ """Write `mesh` (trimesh, mm) to `path` as 3MF, placed to print along
74
+ `direction` ((axis, sign), a vector, or None for as-is with the lowest
75
+ point on the bed). `object_settings` ({PrusaSlicer key: value}) apply to
76
+ the whole part; each of `modifiers` ({"mesh", "settings", "name"}, in
77
+ design coordinates like the part) overrides them where it overlaps the
78
+ part, later ones winning. Returns the path."""
79
+ direction = direction if direction is not None else (2, 1)
80
+ tf = print_transform(mesh, direction, plate_center)
81
+ vols = [(name, mesh, None)] + [(m.get("name") or f"modifier {i + 1}", m["mesh"], m.get("settings") or {})
82
+ for i, m in enumerate(modifiers or [])]
83
+ vs, fs, ranges, off = [], [], [], 0
84
+ for _, m, _ in vols:
85
+ fi = np.asarray(m.faces, np.int64)
86
+ start = sum(len(a) for a in fs)
87
+ vs.append(np.asarray(m.vertices, float))
88
+ fs.append(fi + off)
89
+ ranges.append((start, start + len(fi) - 1))
90
+ off += len(m.vertices)
91
+ v, f = np.vstack(vs), np.vstack(fs)
92
+ prusa = bool(object_settings) or bool(modifiers)
93
+ buf = io.StringIO()
94
+ buf.write('<?xml version="1.0" encoding="UTF-8"?>\n')
95
+ buf.write('<model unit="millimeter" xml:lang="en-US" '
96
+ 'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
97
+ + (' xmlns:slic3rpe="http://schemas.slic3r.org/3mf/2017/06"' if prusa else '') + '>\n')
98
+ if prusa:
99
+ buf.write(' <metadata name="slic3rpe:Version3mf">1</metadata>\n')
100
+ meta = {"Title": name, "Application": "loadpath",
101
+ "loadpath:buildDirection": direction_label(direction)}
102
+ if material is not None:
103
+ meta["loadpath:material"] = getattr(material, "name", str(material))
104
+ if process is not None:
105
+ meta["loadpath:process"] = getattr(process, "kind", str(process))
106
+ meta.update(metadata or {})
107
+ for k, val in meta.items():
108
+ buf.write(f' <metadata name="{escape(str(k))}">{escape(str(val))}</metadata>\n')
109
+ buf.write(' <resources>\n')
110
+ buf.write(f' <object id="1" type="model" name="{escape(name)}">\n <mesh>\n <vertices>\n')
111
+ buf.write("".join(f' <vertex x="{_fmt(a)}" y="{_fmt(b)}" z="{_fmt(c)}"/>\n' for a, b, c in v))
112
+ buf.write(' </vertices>\n <triangles>\n')
113
+ buf.write("".join(f' <triangle v1="{a}" v2="{b}" v3="{c}"/>\n' for a, b, c in f))
114
+ buf.write(' </triangles>\n </mesh>\n </object>\n </resources>\n')
115
+ # 3MF transforms row vectors: [x y z 1] . M, M 4x3 = (R^T ; t)
116
+ rows = np.vstack([tf[:3, :3].T, tf[:3, 3]])
117
+ buf.write(f' <build>\n <item objectid="1" transform="{" ".join(_fmt(x) for x in rows.ravel())}"/>\n'
118
+ ' </build>\n</model>\n')
119
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
120
+ z.writestr("[Content_Types].xml", _CONTENT_TYPES)
121
+ z.writestr("_rels/.rels", _RELS)
122
+ z.writestr("3D/3dmodel.model", buf.getvalue())
123
+ if prusa:
124
+ z.writestr("Metadata/Slic3r_PE_model.config", _prusa_config(name, vols, ranges, object_settings))
125
+ return str(path)
126
+
127
+
128
+ def _prusa_config(name, vols, ranges, object_settings) -> str:
129
+ """PrusaSlicer's per-object config: object settings, then one <volume>
130
+ per triangle range, the first the part and the rest modifiers."""
131
+ def meta(kind, k, val):
132
+ return f' <metadata type="{kind}" key="{escape(str(k))}" value="{escape(str(val))}"/>\n'
133
+
134
+ out = ['<?xml version="1.0" encoding="UTF-8"?>\n<config>\n <object id="1" instances_count="1">\n',
135
+ meta("object", "name", name)]
136
+ out += [meta("object", k, val) for k, val in (object_settings or {}).items()]
137
+ for (vname, _, settings), (a, b) in zip(vols, ranges):
138
+ out.append(f' <volume firstid="{a}" lastid="{b}">\n')
139
+ out.append(meta("volume", "name", vname))
140
+ if settings is None:
141
+ out.append(meta("volume", "volume_type", "ModelPart"))
142
+ else:
143
+ out.append(meta("volume", "modifier", 1))
144
+ out.append(meta("volume", "volume_type", "ParameterModifier"))
145
+ out += [meta("volume", k, val) for k, val in settings.items()]
146
+ out.append(' </volume>\n')
147
+ out.append(' </object>\n</config>\n')
148
+ return "".join(out)
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Multi-material: several touching bodies, each its own material / extruder
153
+ # ---------------------------------------------------------------------------
154
+
155
+ # display colours by material name (lower case); soft/flexible materials dark,
156
+ # rigid ones light, so a PETG + TPU part reads at a glance in any viewer
157
+ _MATERIAL_COLORS = {
158
+ "tpu": "#3C3C3C", "tpe": "#4A4A4A", "flex": "#3C3C3C",
159
+ "petg": "#E6E9EC", "pla": "#F3F1EA", "abs": "#D9D4C7", "asa": "#CFCAB8",
160
+ "pa12": "#BDB8AE", "pa11": "#C4BCA8", "pa-cf": "#2B2B2B", "pc": "#DCE3EA",
161
+ "support": "#F5E6B8", "pva": "#F5E6B8",
162
+ }
163
+ # fallback colours, in order, for materials without one (or a clash)
164
+ _PALETTE = ["#E6E9EC", "#3C3C3C", "#D9534F", "#2E86DE", "#F0AD4E", "#5CB85C", "#8E44AD", "#16A085"]
165
+
166
+ _CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
167
+ _PROD_NS = "http://schemas.microsoft.com/3dmanufacturing/production/2015/06"
168
+ _BAMBU_NS = "http://schemas.bambulab.com/package/2021"
169
+ _MODEL_REL = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
170
+ _BAMBU_OBJECT_FILE = "3D/Objects/object_1.model"
171
+ _IDENTITY_3MF = "1 0 0 0 1 0 0 0 1 0 0 0"
172
+
173
+
174
+ def _material_name(m) -> str:
175
+ return str(getattr(m, "name", m))
176
+
177
+
178
+ def _resolve_bodies(bodies):
179
+ """Normalise the `bodies` list: name, mesh, material name, 1-based
180
+ extruder, #RRGGBB colour, settings. Extruders default to the order in
181
+ which each distinct material first appears (skipping numbers given
182
+ explicitly); colours default to `_MATERIAL_COLORS`, then `_PALETTE`."""
183
+ if not bodies:
184
+ raise ValueError("write_multimaterial_3mf needs at least one body")
185
+ out, mat_ext, used = [], {}, set()
186
+ for b in bodies:
187
+ if b.get("extruder") is not None:
188
+ e = int(b["extruder"])
189
+ if e < 1:
190
+ raise ValueError(f"extruder must be 1-based, got {e}")
191
+ used.add(e)
192
+ for i, b in enumerate(bodies):
193
+ mesh = b["mesh"]
194
+ if len(getattr(mesh, "faces", ())) == 0:
195
+ raise ValueError(f"body {i} has an empty mesh")
196
+ mat = _material_name(b.get("material", "material"))
197
+ if b.get("extruder") is not None:
198
+ ext = int(b["extruder"])
199
+ mat_ext.setdefault(mat, ext)
200
+ elif mat in mat_ext:
201
+ ext = mat_ext[mat]
202
+ else:
203
+ ext = 1
204
+ while ext in used:
205
+ ext += 1
206
+ used.add(ext)
207
+ mat_ext[mat] = ext
208
+ out.append({"name": str(b.get("name") or f"body {i + 1}"), "mesh": mesh, "material": mat,
209
+ "extruder": ext, "color": b.get("color"), "settings": dict(b.get("settings") or {})})
210
+ # one colour per material: explicit first, then by name, then palette
211
+ colors, taken = {}, set()
212
+ for b in out:
213
+ if b["color"] and b["material"] not in colors:
214
+ colors[b["material"]] = b["color"].upper()
215
+ taken.add(colors[b["material"]])
216
+ for b in out:
217
+ m = b["material"]
218
+ if m not in colors:
219
+ c = _MATERIAL_COLORS.get(m.lower())
220
+ if c is None or c in taken:
221
+ c = next((p for p in _PALETTE if p not in taken), _PALETTE[len(colors) % len(_PALETTE)])
222
+ colors[m] = c
223
+ taken.add(c)
224
+ for b in out:
225
+ b["color"] = (b["color"] or colors[b["material"]]).upper()
226
+ if not re.fullmatch(r"#[0-9A-F]{6}([0-9A-F]{2})?", b["color"]):
227
+ raise ValueError(f"color must be #RRGGBB, got {b['color']!r}")
228
+ materials = [] # distinct, first-appearance order
229
+ for b in out:
230
+ if b["material"] not in [m for m, _ in materials]:
231
+ materials.append((b["material"], colors[b["material"]]))
232
+ index = {m: k for k, (m, _) in enumerate(materials)}
233
+ for b in out:
234
+ b["pindex"] = index[b["material"]]
235
+ return out, materials
236
+
237
+
238
+ def _assembly_transform(bodies, direction, plate_center):
239
+ """One print transform for the whole assembly (union of every body's
240
+ vertices), so the bodies stay registered and the lowest point of the
241
+ assembly sits on the bed."""
242
+ allv = np.vstack([np.asarray(b["mesh"].vertices, float) for b in bodies])
243
+ return print_transform(SimpleNamespace(vertices=allv), direction, plate_center)
244
+
245
+
246
+ def _rows(tf):
247
+ # 3MF transforms row vectors: [x y z 1] . M, M 4x3 = (R^T ; t)
248
+ return " ".join(_fmt(x) for x in np.vstack([tf[:3, :3].T, tf[:3, 3]]).ravel())
249
+
250
+
251
+ def _mesh_xml(v, f, pid=None, pindex=None, indent=" "):
252
+ tri = (f'{indent} <triangle v1="{{}}" v2="{{}}" v3="{{}}" pid="{pid}" p1="{{}}"/>\n' if pid is not None
253
+ else f'{indent} <triangle v1="{{}}" v2="{{}}" v3="{{}}"/>\n')
254
+ parts = [f"{indent}<mesh>\n{indent} <vertices>\n"]
255
+ parts.append("".join(f'{indent} <vertex x="{_fmt(a)}" y="{_fmt(b)}" z="{_fmt(c)}"/>\n' for a, b, c in v))
256
+ parts.append(f"{indent} </vertices>\n{indent} <triangles>\n")
257
+ if pid is None:
258
+ parts.append("".join(tri.format(a, b, c) for a, b, c in f))
259
+ else:
260
+ parts.append("".join(tri.format(a, b, c, k) for (a, b, c), k in zip(f, pindex)))
261
+ parts.append(f"{indent} </triangles>\n{indent}</mesh>\n")
262
+ return "".join(parts)
263
+
264
+
265
+ def _basematerials_xml(mid, materials, indent=" "):
266
+ out = [f'{indent}<basematerials id="{mid}">\n']
267
+ out += [f'{indent} <base name={quoteattr(m)} displaycolor="{c}"/>\n' for m, c in materials]
268
+ out.append(f"{indent}</basematerials>\n")
269
+ return "".join(out)
270
+
271
+
272
+ def _model_meta(name, direction, bodies, materials, process, metadata, application):
273
+ meta = {"Title": name, "Application": application,
274
+ "loadpath:buildDirection": direction_label(direction),
275
+ "loadpath:materials": ", ".join(m for m, _ in materials)}
276
+ for b in bodies:
277
+ meta[f"loadpath:material.{b['name']}"] = b["material"]
278
+ if process is not None:
279
+ meta["loadpath:process"] = getattr(process, "kind", str(process))
280
+ meta.update(metadata or {})
281
+ return "".join(f' <metadata name={quoteattr(str(k))}>{escape(str(val))}</metadata>\n' for k, val in meta.items())
282
+
283
+
284
+ def _uuid(*parts) -> str:
285
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, "loadpath:" + "/".join(map(str, parts))))
286
+
287
+
288
+ def write_multimaterial_3mf(path, bodies, direction=None, name: str = "part", process=None,
289
+ plate_center=(128.0, 128.0), metadata: dict | None = None,
290
+ slicer: str = "all") -> str:
291
+ """Write a part made of several material bodies to `path` as one 3MF
292
+ object with one part (volume) per body, each assigned its own extruder,
293
+ for multi-material FDM (Bambu AMS, Prusa MMU/XL, IDEX: e.g. a PETG core
294
+ with a TPU pad) or multi-material jetting.
295
+
296
+ `bodies`: list of {"name", "mesh" (trimesh, mm, design coordinates; the
297
+ bodies touch along shared interfaces and must not overlap), "material"
298
+ (a materials.Material or a name), "extruder" (1-based; default: the
299
+ order in which each distinct material first appears), "color"
300
+ ("#RRGGBB"; default by material name, e.g. TPU dark grey, PETG light),
301
+ "settings" (optional {slicer key: value} for that body only)}.
302
+
303
+ All bodies share ONE print transform (`print_transform` of the union of
304
+ their vertices, `direction` as in `write_3mf`), carried by the single
305
+ build item, so they stay registered and the assembly's lowest point
306
+ sits on the bed. Every file also carries a core-3MF `<basematerials>`
307
+ group (one `<base>` per material, with its display colour) referenced
308
+ by pid/pindex, so any 3MF viewer shows the materials, and model
309
+ metadata `loadpath:material.<body name>` per body.
310
+
311
+ `slicer` picks the slicer-specific layout, since no single file is read
312
+ as a multi-part object by both families:
313
+
314
+ - "prusa", "all" or "generic" (default): one mesh object holding every
315
+ body, with Metadata/Slic3r_PE_model.config giving one
316
+ `<volume firstid lastid>` per body (volume_type ModelPart, name,
317
+ `extruder`, `settings`). PrusaSlicer reads it natively. Bambu Studio
318
+ and OrcaSlicer only hand a 3MF to their PrusaSlicer importer (which
319
+ reads that config) when the model's Application metadata contains
320
+ "PrusaSlicer", so this layout says "loadpath (PrusaSlicer 3MF)"; their
321
+ GUI opens it as one object with per-part filaments. (Their command
322
+ line uses the native importer, which ignores Slic3r_PE_model.config:
323
+ use "bambu" there.)
324
+ - "bambu" (also "orca", "bambustudio", "orcaslicer"): the Bambu Studio
325
+ layout (3MF production extension): 3D/3dmodel.model has one object
326
+ whose `<components>` point (p:path) at one mesh object per body in
327
+ 3D/Objects/object_1.model, and Metadata/model_settings.config gives
328
+ the object and each `<part id subtype="normal_part">` its name and
329
+ `extruder` (plus `settings`, which must then be Bambu/Orca keys).
330
+ PrusaSlicer does not read external p:path objects, so it can't open
331
+ this one.
332
+
333
+ Returns the path."""
334
+ kind = str(slicer).lower()
335
+ if kind in ("all", "generic", "prusa", "prusaslicer"):
336
+ bambu = False
337
+ elif kind in ("bambu", "bambustudio", "bambu studio", "orca", "orcaslicer"):
338
+ bambu = True
339
+ else:
340
+ raise ValueError(f"slicer must be 'all', 'generic', 'prusa' or 'bambu', got {slicer!r}")
341
+ direction = direction if direction is not None else (2, 1)
342
+ bodies, materials = _resolve_bodies(bodies)
343
+ tf = _assembly_transform(bodies, direction, plate_center)
344
+ if bambu:
345
+ files = _bambu_layout(name, direction, bodies, materials, process, metadata, tf)
346
+ else:
347
+ files = _prusa_layout(name, direction, bodies, materials, process, metadata, tf)
348
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
349
+ for arc, text in files:
350
+ z.writestr(arc, text)
351
+ return str(path)
352
+
353
+
354
+ def _prusa_layout(name, direction, bodies, materials, process, metadata, tf):
355
+ vs, fs, ranges, pidx, off, start = [], [], [], [], 0, 0
356
+ for b in bodies:
357
+ fi = np.asarray(b["mesh"].faces, np.int64)
358
+ vs.append(np.asarray(b["mesh"].vertices, float))
359
+ fs.append(fi + off)
360
+ pidx.append(np.full(len(fi), b["pindex"]))
361
+ ranges.append((start, start + len(fi) - 1))
362
+ start += len(fi)
363
+ off += len(b["mesh"].vertices)
364
+ v, f, pi = np.vstack(vs), np.vstack(fs), np.concatenate(pidx)
365
+ mat_id, obj_id = 1, 2
366
+ buf = io.StringIO()
367
+ buf.write('<?xml version="1.0" encoding="UTF-8"?>\n')
368
+ buf.write(f'<model unit="millimeter" xml:lang="en-US" xmlns="{_CORE_NS}" '
369
+ 'xmlns:slic3rpe="http://schemas.slic3r.org/3mf/2017/06">\n')
370
+ buf.write(' <metadata name="slic3rpe:Version3mf">1</metadata>\n')
371
+ buf.write(_model_meta(name, direction, bodies, materials, process, metadata, "loadpath (PrusaSlicer 3MF)"))
372
+ buf.write(" <resources>\n")
373
+ buf.write(_basematerials_xml(mat_id, materials))
374
+ buf.write(f' <object id="{obj_id}" type="model" name={quoteattr(name)} pid="{mat_id}" '
375
+ f'pindex="{bodies[0]["pindex"]}">\n')
376
+ buf.write(_mesh_xml(v, f, mat_id, pi))
377
+ buf.write(" </object>\n </resources>\n")
378
+ buf.write(f' <build>\n <item objectid="{obj_id}" transform="{_rows(tf)}"/>\n </build>\n</model>\n')
379
+
380
+ def meta(kind, k, val):
381
+ return f' <metadata type="{kind}" key={quoteattr(str(k))} value={quoteattr(str(val))}/>\n'
382
+
383
+ cfg = ['<?xml version="1.0" encoding="UTF-8"?>\n<config>\n',
384
+ f' <object id="{obj_id}" instances_count="1">\n',
385
+ meta("object", "name", name), meta("object", "extruder", bodies[0]["extruder"])]
386
+ for b, (a, z) in zip(bodies, ranges):
387
+ cfg.append(f' <volume firstid="{a}" lastid="{z}">\n')
388
+ cfg.append(meta("volume", "name", b["name"]))
389
+ cfg.append(meta("volume", "volume_type", "ModelPart"))
390
+ cfg.append(meta("volume", "extruder", b["extruder"]))
391
+ cfg += [meta("volume", k, val) for k, val in b["settings"].items() if k != "extruder"]
392
+ cfg.append(" </volume>\n")
393
+ cfg.append(" </object>\n</config>\n")
394
+ return [("[Content_Types].xml", _CONTENT_TYPES), ("_rels/.rels", _RELS),
395
+ ("3D/3dmodel.model", buf.getvalue()), ("Metadata/Slic3r_PE_model.config", "".join(cfg))]
396
+
397
+
398
+ def _bambu_layout(name, direction, bodies, materials, process, metadata, tf):
399
+ n = len(bodies)
400
+ mat_id, asm_id = n + 1, n + 2 # mesh objects are 1..n (Bambu's part ids)
401
+ sub = io.StringIO()
402
+ sub.write('<?xml version="1.0" encoding="UTF-8"?>\n')
403
+ sub.write(f'<model unit="millimeter" xml:lang="en-US" xmlns="{_CORE_NS}" '
404
+ f'xmlns:BambuStudio="{_BAMBU_NS}" xmlns:p="{_PROD_NS}" requiredextensions="p">\n')
405
+ sub.write(' <metadata name="BambuStudio:3mfVersion">1</metadata>\n')
406
+ sub.write(" <resources>\n")
407
+ sub.write(_basematerials_xml(mat_id, materials))
408
+ for k, b in enumerate(bodies, start=1):
409
+ sub.write(f' <object id="{k}" p:UUID="{_uuid(name, "object", k)}" type="model" '
410
+ f'name={quoteattr(b["name"])} pid="{mat_id}" pindex="{b["pindex"]}">\n')
411
+ sub.write(' <metadatagroup>\n'
412
+ f' <metadata name="loadpath:material">{escape(b["material"])}</metadata>\n'
413
+ ' </metadatagroup>\n')
414
+ sub.write(_mesh_xml(np.asarray(b["mesh"].vertices, float), np.asarray(b["mesh"].faces, np.int64)))
415
+ sub.write(" </object>\n")
416
+ sub.write(" </resources>\n <build/>\n</model>\n")
417
+
418
+ top = io.StringIO()
419
+ top.write('<?xml version="1.0" encoding="UTF-8"?>\n')
420
+ top.write(f'<model unit="millimeter" xml:lang="en-US" xmlns="{_CORE_NS}" '
421
+ f'xmlns:BambuStudio="{_BAMBU_NS}" xmlns:p="{_PROD_NS}" requiredextensions="p">\n')
422
+ top.write(' <metadata name="BambuStudio:3mfVersion">1</metadata>\n')
423
+ top.write(_model_meta(name, direction, bodies, materials, process, metadata, "loadpath"))
424
+ top.write(" <resources>\n")
425
+ top.write(f' <object id="{asm_id}" p:UUID="{_uuid(name, "assembly")}" type="model" name={quoteattr(name)}>\n'
426
+ " <components>\n")
427
+ for k in range(1, n + 1):
428
+ top.write(f' <component p:path="/{_BAMBU_OBJECT_FILE}" objectid="{k}" '
429
+ f'p:UUID="{_uuid(name, "component", k)}" transform="{_IDENTITY_3MF}"/>\n')
430
+ top.write(" </components>\n </object>\n </resources>\n")
431
+ top.write(f' <build p:UUID="{_uuid(name, "build")}">\n'
432
+ f' <item objectid="{asm_id}" p:UUID="{_uuid(name, "item")}" transform="{_rows(tf)}" printable="1"/>\n'
433
+ " </build>\n</model>\n")
434
+
435
+ def meta(k, val, ind=" "):
436
+ return f'{ind}<metadata key={quoteattr(str(k))} value={quoteattr(str(val))}/>\n'
437
+
438
+ cfg = ['<?xml version="1.0" encoding="UTF-8"?>\n<config>\n', f' <object id="{asm_id}">\n',
439
+ meta("name", name), meta("extruder", bodies[0]["extruder"])]
440
+ for k, b in enumerate(bodies, start=1):
441
+ cfg.append(f' <part id="{k}" subtype="normal_part">\n')
442
+ cfg.append(meta("name", b["name"], " "))
443
+ cfg.append(meta("matrix", "1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1", " "))
444
+ cfg.append(meta("extruder", b["extruder"], " "))
445
+ cfg += [meta(key, val, " ") for key, val in b["settings"].items() if key != "extruder"]
446
+ cfg.append(f' <mesh_stat face_count="{len(b["mesh"].faces)}" edges_fixed="0" degenerate_facets="0" '
447
+ 'facets_removed="0" facets_reversed="0" backwards_edges="0"/>\n')
448
+ cfg.append(" </part>\n")
449
+ cfg.append(" </object>\n</config>\n")
450
+ sub_rels = ('<?xml version="1.0" encoding="UTF-8"?>\n'
451
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n'
452
+ f' <Relationship Target="/{_BAMBU_OBJECT_FILE}" Id="rel-1" Type="{_MODEL_REL}"/>\n'
453
+ '</Relationships>\n')
454
+ return [("[Content_Types].xml", _CONTENT_TYPES), ("_rels/.rels", _RELS),
455
+ ("3D/3dmodel.model", top.getvalue()), ("3D/_rels/3dmodel.model.rels", sub_rels),
456
+ (_BAMBU_OBJECT_FILE, sub.getvalue()), ("Metadata/model_settings.config", "".join(cfg))]
457
+
458
+
459
+ def write_material_stls(directory, bodies, direction=None, name: str = "part",
460
+ plate_center=(128.0, 128.0)) -> list:
461
+ """Write one STL per material (all bodies of that material merged) into
462
+ `directory`, for slicers that take separate files per extruder. The
463
+ meshes are already placed on the bed with the same shared print
464
+ transform as `write_multimaterial_3mf` (print_transform of the union of
465
+ all bodies), so loaded together, without auto-arranging or re-centring
466
+ (e.g. answer "multi-part object" in PrusaSlicer/Bambu when asked), they
467
+ re-register. Files are `<name>_<material>_T<extruder>.stl` (T is
468
+ 1-based). Returns the paths, in extruder order."""
469
+ import trimesh
470
+
471
+ direction = direction if direction is not None else (2, 1)
472
+ bodies, _ = _resolve_bodies(bodies)
473
+ tf = _assembly_transform(bodies, direction, plate_center)
474
+ groups = {}
475
+ for b in bodies:
476
+ groups.setdefault((b["extruder"], b["material"]), []).append(b["mesh"])
477
+ os.makedirs(directory, exist_ok=True)
478
+ safe = (lambda s: re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "x")
479
+ paths = []
480
+ for (ext, mat), meshes in sorted(groups.items()):
481
+ m = trimesh.util.concatenate([trimesh.Trimesh(np.asarray(x.vertices, float), np.asarray(x.faces), process=False)
482
+ for x in meshes])
483
+ m.apply_transform(tf)
484
+ p = os.path.join(str(directory), f"{safe(name)}_{safe(mat)}_T{ext}.stl")
485
+ m.export(p)
486
+ paths.append(p)
487
+ return paths