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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|