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/__main__.py
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line: run a study file and write the parts, a report and renders.
|
|
3
|
+
|
|
4
|
+
python -m loadpath run study.json --out out/
|
|
5
|
+
python -m loadpath check study.json # validate + show the voxel problem, no solve
|
|
6
|
+
python -m loadpath verify study.json part.stl # check a finished part (or a run's folder) against a study
|
|
7
|
+
python -m loadpath schema [study|heatExchanger|lattice] # JSON Schema
|
|
8
|
+
python -m loadpath examples [name] # list the packaged examples, or print one
|
|
9
|
+
python -m loadpath lattice study.json part.stl --out out/ [--options lattice.json]
|
|
10
|
+
python -m loadpath heatx exchanger.json --out out/ [--size]
|
|
11
|
+
python -m loadpath field part.json --out out/ [--pitch 0.3] # a field graph (nTop-style implicit part)
|
|
12
|
+
python -m loadpath tune part.json --out out/ [--evaluations 20] # its `tune` knobs vs an FE check
|
|
13
|
+
python -m loadpath edit part.json [--html editor.html] # edit a field graph in the browser, live preview
|
|
14
|
+
python -m loadpath mcp # local MCP server for agents (needs loadpath[mcp])
|
|
15
|
+
python -m loadpath orient part.stl [--study study.json] [--3mf part.3mf]
|
|
16
|
+
# rank build orientations, write the best as 3MF
|
|
17
|
+
python -m loadpath calibrate kit --material petg --out calibration/ # coupons to print + predictions
|
|
18
|
+
python -m loadpath calibrate fit measurements.json [--predictions predictions.json] [--name petg-a1mini --save]
|
|
19
|
+
python -m loadpath assembly robot.urdf --link forearm --study forearm.json --region j4-mount --payload 1
|
|
20
|
+
# loads + neighbour keep-outs derived from the mechanism
|
|
21
|
+
python -m loadpath buildsim part.stl --material ti64 [--compensate 2] --out sim/
|
|
22
|
+
# simulate the print: warp, residual stress, recoater lift
|
|
23
|
+
|
|
24
|
+
Progress goes to stderr as one JSON object per line, so an agent can tail it.
|
|
25
|
+
Results go to stdout as one JSON document, and to report.json in --out.
|
|
26
|
+
Exit codes: 0 done, 2 the input is invalid.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from .tasks import jsonable as _jsonable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load(path):
|
|
40
|
+
try:
|
|
41
|
+
with open(path) as fh:
|
|
42
|
+
return json.load(fh)
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
print(json.dumps({"ok": False, "error": f"{path} is not valid JSON: {exc}"}))
|
|
45
|
+
sys.exit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _progress(event, _field=None):
|
|
49
|
+
if event.get("stage") == "optimize" and "iteration" in event and event["iteration"] % 5:
|
|
50
|
+
return
|
|
51
|
+
ev = {k: v for k, v in event.items() if k not in ("cases", "optimizer", "reconstruction")}
|
|
52
|
+
cases = event.get("cases") or []
|
|
53
|
+
if event.get("stage") == "outcome" and cases:
|
|
54
|
+
# the governing case and the worst deflection, so a failed step says why
|
|
55
|
+
sf = [c for c in cases if c.get("safetyFactor") is not None]
|
|
56
|
+
if sf:
|
|
57
|
+
g = min(sf, key=lambda c: c["safetyFactor"])
|
|
58
|
+
ev["governingCase"] = {"name": g.get("name"), "safetyFactor": g["safetyFactor"]}
|
|
59
|
+
dfl = [c for c in cases if c.get("deflection") is not None]
|
|
60
|
+
if dfl:
|
|
61
|
+
w = max(dfl, key=lambda c: c["deflection"])
|
|
62
|
+
ev["maxDeflection"] = {"name": w.get("name"), "deflection": w["deflection"]}
|
|
63
|
+
print(json.dumps(_jsonable(ev)), file=sys.stderr, flush=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_report(out_dir, summary):
|
|
67
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
68
|
+
with open(os.path.join(out_dir, "report.json"), "w") as fh:
|
|
69
|
+
json.dump(_jsonable(summary), fh, indent=2)
|
|
70
|
+
print(json.dumps(_jsonable(summary), indent=2))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _calibrate(args):
|
|
74
|
+
from . import calibration as cal
|
|
75
|
+
if args.action == "kit":
|
|
76
|
+
kit = cal.coupon_kit(args.material, args.process, crush=args.crush)
|
|
77
|
+
paths = cal.write_kit(kit, args.out, args.material, args.process)
|
|
78
|
+
print(json.dumps({"ok": True, "files": paths,
|
|
79
|
+
"coupons": {c.id: {"title": c.title, "test": c.test, "predicted": c.predicted}
|
|
80
|
+
for c in kit}}, indent=2, default=float))
|
|
81
|
+
return 0
|
|
82
|
+
if not args.measurements:
|
|
83
|
+
print(json.dumps({"ok": False, "error": "calibrate fit needs the measurements.json"}))
|
|
84
|
+
return 2
|
|
85
|
+
meas = _load(args.measurements)
|
|
86
|
+
pred = _load(args.predictions) if args.predictions else None
|
|
87
|
+
try:
|
|
88
|
+
card = cal.fit(meas, pred, args.name)
|
|
89
|
+
except (ValueError, KeyError) as exc:
|
|
90
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
91
|
+
return 2
|
|
92
|
+
os.makedirs(args.out, exist_ok=True)
|
|
93
|
+
path = os.path.join(args.out, f"{card['name']}.json")
|
|
94
|
+
with open(path, "w") as fh:
|
|
95
|
+
json.dump(card, fh, indent=2)
|
|
96
|
+
if args.save:
|
|
97
|
+
os.makedirs(cal.CALIBRATION_DIR, exist_ok=True)
|
|
98
|
+
with open(os.path.join(cal.CALIBRATION_DIR, f"{card['name']}.json"), "w") as fh:
|
|
99
|
+
json.dump(card, fh, indent=2)
|
|
100
|
+
print(json.dumps(card, indent=2))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _import(args):
|
|
105
|
+
from .cadimport import import_part
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if args.features_only:
|
|
109
|
+
r = import_part(args.part, tolerance=args.tolerance)
|
|
110
|
+
print(json.dumps({**r["summary"], "suggested": r["features"]["suggested"]}, indent=1))
|
|
111
|
+
return 0
|
|
112
|
+
loads = []
|
|
113
|
+
for item in args.load:
|
|
114
|
+
k, _, v = item.partition("=")
|
|
115
|
+
f = [float(x) for x in v.split(",")]
|
|
116
|
+
if len(f) != 3:
|
|
117
|
+
raise ValueError(f"--load {item!r}: write ID=FX,FY,FZ in N")
|
|
118
|
+
loads.append((k, f))
|
|
119
|
+
out = import_part(args.part, args.out, tolerance=args.tolerance, space=args.space, grow=args.grow,
|
|
120
|
+
material=args.material or "petg", force=args.force, pad=args.pad,
|
|
121
|
+
safety_factor=args.safety_factor, process=args.process, support=args.support,
|
|
122
|
+
loads=loads or None, ring=args.ring)
|
|
123
|
+
except (ValueError, RuntimeError) as exc:
|
|
124
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
125
|
+
return 2
|
|
126
|
+
f = out["features"]
|
|
127
|
+
stem = out["study"]["name"]
|
|
128
|
+
given = bool(loads)
|
|
129
|
+
todo = [] if given else [f"replace the placeholder load ({f['suggested'].get('note', 'none suggested')}): "
|
|
130
|
+
"--load ID=FX,FY,FZ with an interface id below, one load case per --load"]
|
|
131
|
+
if not args.material:
|
|
132
|
+
todo.append("material is the default (petg, FDM): pass --material/--process for anything else")
|
|
133
|
+
if args.space == "part" and not args.grow:
|
|
134
|
+
todo.append("the design space is the part as drawn, so the optimizer can only remove material from it: "
|
|
135
|
+
"to lighten within the same envelope, or let it find a new shape, use --space box (or hull)")
|
|
136
|
+
setup = {"setup": {"support": args.support or f["suggested"].get("support"),
|
|
137
|
+
"loads": [{"interface": k, "force": v} for k, v in loads]}} if given else \
|
|
138
|
+
{"suggested": f["suggested"]}
|
|
139
|
+
print(json.dumps({"ok": True, "study": os.path.join(args.out, f"{stem}.study.json"),
|
|
140
|
+
"features": os.path.join(args.out, f"{stem}.features.json"),
|
|
141
|
+
**out["summary"], **setup, "material": args.material or "petg",
|
|
142
|
+
"todo": todo, **({"healed": f["healed"]} if "healed" in f else {})}, indent=1))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _assembly(args):
|
|
147
|
+
from .assembly import Payload, Robot, derive_study
|
|
148
|
+
|
|
149
|
+
robot = Robot.from_urdf(args.urdf)
|
|
150
|
+
study = _load(args.study)
|
|
151
|
+
base = os.path.dirname(os.path.abspath(args.study))
|
|
152
|
+
payload = None
|
|
153
|
+
if args.payload > 0:
|
|
154
|
+
link = args.payload_link
|
|
155
|
+
if link is None:
|
|
156
|
+
# down the chain of revolute joints from the part to its end (the tool)
|
|
157
|
+
link = robot.joints[args.joint or robot.far_joint(args.link)].child
|
|
158
|
+
while True:
|
|
159
|
+
kids = [j for j in robot.order if j.parent == link and j.type in ("revolute", "continuous")]
|
|
160
|
+
if len(kids) != 1:
|
|
161
|
+
break
|
|
162
|
+
link = kids[0].child
|
|
163
|
+
payload = Payload(args.payload, link, tuple(float(v) for v in args.payload_at.split(",")),
|
|
164
|
+
radius=args.payload_radius, min_kg=0.0)
|
|
165
|
+
from . import quick
|
|
166
|
+
sweep_kw = {"samples": quick.assembly_samples(args.samples), "ramp": args.ramp, "push": args.push, "contact_max": args.contact_max,
|
|
167
|
+
"balanced": tuple(j for j in args.balanced.split(",") if j), "joint": args.joint}
|
|
168
|
+
os.makedirs(args.out, exist_ok=True)
|
|
169
|
+
derived, report = derive_study(robot, args.link, study, args.region, payload=payload, keep_cases=args.keep_cases,
|
|
170
|
+
sweep_space=not args.no_space, k=args.cases, method=args.method,
|
|
171
|
+
sweep_kw=sweep_kw, space_kw={"clearance": args.clearance}, out_dir=args.out,
|
|
172
|
+
base_dir=base, frame=args.frame, cycles={k: float(v) for k, v in
|
|
173
|
+
(kv.split("=") for kv in args.cycles.split(",") if kv)})
|
|
174
|
+
# the study's own mesh paths are relative to its folder: make them absolute
|
|
175
|
+
for key in ("designSpace", "preserve", "keepOut"):
|
|
176
|
+
for item in derived.get(key, []):
|
|
177
|
+
sh = item.get("shape", item)
|
|
178
|
+
if sh.get("type") == "mesh" and sh.get("path") and not os.path.isabs(sh["path"]) \
|
|
179
|
+
and not os.path.exists(os.path.join(args.out, sh["path"])):
|
|
180
|
+
sh["path"] = os.path.join(base, sh["path"])
|
|
181
|
+
name = os.path.splitext(os.path.basename(args.study))[0]
|
|
182
|
+
with open(os.path.join(args.out, f"{name}.derived.json"), "w") as fh:
|
|
183
|
+
json.dump(derived, fh, indent=1)
|
|
184
|
+
with open(os.path.join(args.out, "assembly.json"), "w") as fh:
|
|
185
|
+
json.dump(_jsonable(report), fh, indent=1)
|
|
186
|
+
print(json.dumps(_jsonable({k: v for k, v in report.items() if k != "states"}), indent=1))
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _verify(args):
|
|
191
|
+
"""A finished part (an STL, or a run's folder: its best outcome) checked
|
|
192
|
+
against a study: the same check `run` gives its outcomes."""
|
|
193
|
+
import trimesh
|
|
194
|
+
|
|
195
|
+
from . import quick
|
|
196
|
+
from .domain import choose_pitch
|
|
197
|
+
from .spec import study_from_dict
|
|
198
|
+
from .study import _margin
|
|
199
|
+
from .tasks import check_study
|
|
200
|
+
from .verify import verify_part
|
|
201
|
+
|
|
202
|
+
raw = _load(args.study)
|
|
203
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.study))
|
|
204
|
+
checked = check_study(raw, base)
|
|
205
|
+
if not checked["ok"]:
|
|
206
|
+
print(json.dumps(checked, indent=2))
|
|
207
|
+
return 2
|
|
208
|
+
part = args.part
|
|
209
|
+
if os.path.isdir(part):
|
|
210
|
+
rep = os.path.join(part, "report.json")
|
|
211
|
+
if not os.path.exists(rep):
|
|
212
|
+
print(json.dumps({"ok": False, "error": f"{part} is a folder with no report.json: name the STL"}))
|
|
213
|
+
return 2
|
|
214
|
+
r = _load(rep)
|
|
215
|
+
if r.get("best") is None:
|
|
216
|
+
print(json.dumps({"ok": False, "error": f"{rep} has no best outcome: name the STL"}))
|
|
217
|
+
return 2
|
|
218
|
+
# report.json names it as seen from where `run` ran: look beside the report
|
|
219
|
+
part = os.path.join(part, os.path.basename(r["outcomes"][r["best"]]["stl"]))
|
|
220
|
+
if not os.path.exists(part):
|
|
221
|
+
print(json.dumps({"ok": False, "error": f"no such part: {part}"}))
|
|
222
|
+
return 2
|
|
223
|
+
study = study_from_dict(raw, base_dir=base)
|
|
224
|
+
mesh = trimesh.load(part, force="mesh")
|
|
225
|
+
pitch = choose_pitch(study)
|
|
226
|
+
# the default grid matches the run's own check (no finer than half the
|
|
227
|
+
# design cell); --cells asks for a grid, so it gets one: it used to be
|
|
228
|
+
# floored to that same pitch, and 120k cells read exactly as 60k did
|
|
229
|
+
asked = args.cells is not None
|
|
230
|
+
rep = verify_part(study, mesh, max_cells=quick.verify_cells(args.cells if asked else 60_000),
|
|
231
|
+
min_pitch=None if asked else 0.5 * pitch, contact_band=pitch)
|
|
232
|
+
m = _margin(study, rep)
|
|
233
|
+
summary = {"ok": True, "part": part, "study": args.study, "mass": rep["mass"],
|
|
234
|
+
"safetyFactor": rep.get("safetyFactor"), "margin": m,
|
|
235
|
+
"passes": None if m is None else bool(m >= 1.0 and rep.get("bodies", 1) <= 1),
|
|
236
|
+
"safetyFactorMethod": "voxel", "analysisPitch": rep.get("analysisPitch"),
|
|
237
|
+
"keepOutIntrusion": rep.get("keepOutIntrusion"), "preserveMissing": rep.get("preserveMissing"),
|
|
238
|
+
"bodies": rep.get("bodies"),
|
|
239
|
+
**({"durabilitySafetyFactor": rep["durability"].get("safetyFactor")}
|
|
240
|
+
if isinstance(rep.get("durability"), dict) else {}),
|
|
241
|
+
"warnings": list(rep.get("warnings") or []),
|
|
242
|
+
"cases": {c["name"]: {"safetyFactor": c.get("safetyFactor"), "deflection": c.get("deflection"),
|
|
243
|
+
"peakStress": c.get("peakStress"), "peakAt": c.get("peakAt")}
|
|
244
|
+
for c in rep.get("cases", [])},
|
|
245
|
+
"report": rep}
|
|
246
|
+
if args.out:
|
|
247
|
+
os.makedirs(args.out, exist_ok=True)
|
|
248
|
+
with open(os.path.join(args.out, "verify.json"), "w") as fh:
|
|
249
|
+
json.dump(_jsonable(summary), fh, indent=2)
|
|
250
|
+
print(json.dumps(_jsonable({k: v for k, v in summary.items() if k != "report"}), indent=2))
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _materials(args):
|
|
255
|
+
from .calibration import list_cards
|
|
256
|
+
from .material_library import card_names, describe
|
|
257
|
+
from .materials import MATERIALS, _ALIASES
|
|
258
|
+
|
|
259
|
+
if args.name:
|
|
260
|
+
key = args.name.strip().lower()
|
|
261
|
+
out = describe(_ALIASES.get(key, key))
|
|
262
|
+
if key in list_cards():
|
|
263
|
+
out = {"calibrated": key, "note": "a calibrated card of this name overrides the library one",
|
|
264
|
+
"library": out}
|
|
265
|
+
if out is None:
|
|
266
|
+
print(f"unknown material {args.name!r}; known: {', '.join(card_names())}", file=sys.stderr)
|
|
267
|
+
return 1
|
|
268
|
+
print(json.dumps(out, indent=1))
|
|
269
|
+
return 0
|
|
270
|
+
rows = [{"name": k, "title": MATERIALS[k].note, "E": MATERIALS[k].E, "strength": MATERIALS[k].strength,
|
|
271
|
+
"strengthZ": MATERIALS[k].strength_z, "density": MATERIALS[k].density,
|
|
272
|
+
"processes": list(MATERIALS[k].processes)} for k in card_names()]
|
|
273
|
+
print(json.dumps({"library": rows, "calibrated": list_cards()}, indent=1))
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_status(out: str) -> dict:
|
|
278
|
+
"""out/status.json, with `running` checked against the process: a run
|
|
279
|
+
the OOM killer or a closed shell took down (SIGKILL) never gets to write
|
|
280
|
+
its own end, and its status kept reading `running` with a dead pid."""
|
|
281
|
+
path = os.path.join(out, "status.json")
|
|
282
|
+
if not os.path.exists(path):
|
|
283
|
+
return {"state": "none", "error": f"no {path}: the run has not started, or --out was another folder"}
|
|
284
|
+
with open(path) as fh:
|
|
285
|
+
st = json.load(fh)
|
|
286
|
+
part = os.path.join(out, "partial", "outcomes.jsonl")
|
|
287
|
+
if os.path.exists(part):
|
|
288
|
+
with open(part) as fh:
|
|
289
|
+
st["partialOutcomes"] = sum(1 for line in fh if line.strip())
|
|
290
|
+
if st.get("state") == "running" and st.get("pid"):
|
|
291
|
+
alive = True
|
|
292
|
+
try:
|
|
293
|
+
os.kill(int(st["pid"]), 0)
|
|
294
|
+
with open(f"/proc/{int(st['pid'])}/cmdline", "rb") as fh:
|
|
295
|
+
alive = b"loadpath" in fh.read()
|
|
296
|
+
except ProcessLookupError:
|
|
297
|
+
alive = False
|
|
298
|
+
except (OSError, ValueError):
|
|
299
|
+
pass
|
|
300
|
+
if not alive:
|
|
301
|
+
st["state"] = "died"
|
|
302
|
+
st["hint"] = ("the process is gone without writing an end: killed (out of memory, or by the shell "
|
|
303
|
+
"that launched it). Outcomes found so far are in out/partial/. Relaunch detached "
|
|
304
|
+
"(setsid nohup ... > run.log 2>&1 < /dev/null &); if it was memory, set "
|
|
305
|
+
"LOADPATH_CONFIRM_CELLS lower or resolution.maxElements")
|
|
306
|
+
if st.get("started") and st.get("state") == "running":
|
|
307
|
+
st["elapsedSeconds"] = round(time.time() - float(st["started"]))
|
|
308
|
+
return st
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def main(argv=None):
|
|
312
|
+
p = argparse.ArgumentParser(prog="loadpath", description="Generative design for printed parts.")
|
|
313
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
314
|
+
r = sub.add_parser("run", help="optimize, mesh and verify a study")
|
|
315
|
+
r.add_argument("study")
|
|
316
|
+
r.add_argument("--out", default="loadpath-out")
|
|
317
|
+
r.add_argument("--no-render", action="store_true")
|
|
318
|
+
r.add_argument("--viewer", choices=["best", "all", "none"], default="best",
|
|
319
|
+
help="interactive HTML viewer (viewer.html) for the best outcome, all of them, or none")
|
|
320
|
+
r.add_argument("--base-dir", help="folder relative mesh paths resolve against (default: the study's)")
|
|
321
|
+
r.add_argument("--step", action="store_true",
|
|
322
|
+
help="also write the best outcome as STEP: keep-outs, preserved shapes and the design space "
|
|
323
|
+
"exact (planes, cylinders), the organic body faceted (needs loadpath[step])")
|
|
324
|
+
r.add_argument("--3mf-coordinates", dest="mm_coordinates", choices=["bed", "study"], default="bed",
|
|
325
|
+
help="multiMaterial studies: the 3MF placed for printing (bed: build direction +z, on the bed "
|
|
326
|
+
"at 128,128; default) or in the study's coordinates (study), as the STLs are")
|
|
327
|
+
r.add_argument("--recall", action="store_true",
|
|
328
|
+
help="start from what earlier runs on the same design space found (recall.py; "
|
|
329
|
+
"or set LOADPATH_RECALL=1)")
|
|
330
|
+
stp = sub.add_parser("status", help="is a background run still going? reads out/status.json and checks its "
|
|
331
|
+
"process is alive (a killed run cannot update the file itself)")
|
|
332
|
+
stp.add_argument("out", nargs="?", default="out", help="the run's --out folder (default out)")
|
|
333
|
+
v = sub.add_parser("verify", help="check a finished part against a study: mass, SF per load case, keep-outs")
|
|
334
|
+
v.add_argument("study")
|
|
335
|
+
v.add_argument("part", help="the part's STL, or a `run` output folder (its best outcome)")
|
|
336
|
+
v.add_argument("--out", help="also write verify.json (with the full report) here")
|
|
337
|
+
v.add_argument("--cells", type=int, default=None,
|
|
338
|
+
help="analysis grid cells (default: 60000, no finer than half the study's design cell, as "
|
|
339
|
+
"`run` checks; given, the grid is about this many cells whatever the design cell)")
|
|
340
|
+
v.add_argument("--base-dir", help="folder relative mesh paths resolve against (default: the study's)")
|
|
341
|
+
c = sub.add_parser("check", help="validate a study and report the voxel problem (or a heat exchanger "
|
|
342
|
+
"spec: its design grid and memory; a lattice or field graph: lint)")
|
|
343
|
+
c.add_argument("study")
|
|
344
|
+
c.add_argument("--base-dir")
|
|
345
|
+
s = sub.add_parser("schema", help="print the JSON Schema for a study, heat exchanger, lattice or field graph")
|
|
346
|
+
s.add_argument("kind", nargs="?", default="study", choices=["study", "heatExchanger", "lattice", "field"])
|
|
347
|
+
e = sub.add_parser("examples", help="list the packaged examples, or print one")
|
|
348
|
+
e.add_argument("name", nargs="?")
|
|
349
|
+
lt = sub.add_parser("lattice", help="fill a finished part with a stress-graded TPMS lattice")
|
|
350
|
+
lt.add_argument("study")
|
|
351
|
+
lt.add_argument("part", help="the part's STL (an outcome of `run`)")
|
|
352
|
+
lt.add_argument("--options", help="lattice options JSON file (default: the study's \"lattice\")")
|
|
353
|
+
lt.add_argument("--out", default="loadpath-out")
|
|
354
|
+
lt.add_argument("--no-render", action="store_true")
|
|
355
|
+
lt.add_argument("--base-dir")
|
|
356
|
+
h = sub.add_parser("heatx", help="design (or --size) a two-fluid TPMS heat exchanger")
|
|
357
|
+
h.add_argument("spec")
|
|
358
|
+
h.add_argument("--out", help="output folder (default loadpath-out; with --size: also write sizing.json "
|
|
359
|
+
"and best.json there)")
|
|
360
|
+
h.add_argument("--size", action="store_true", help="only sweep cell and wall for the duty (seconds)")
|
|
361
|
+
h.add_argument("--objective", choices=["effectiveness", "mass", "pressure", "metal"],
|
|
362
|
+
help="--size ranking: best effectiveness (default), least mass meeting the limits (metal: the "
|
|
363
|
+
"same), or least pressure drop against the limits; overrides the spec's sizing.objective")
|
|
364
|
+
h.add_argument("--min-effectiveness", type=float, help="--size: drop candidates below this")
|
|
365
|
+
h.add_argument("--splits", help="--size: first fluid's shares of the void to sweep, e.g. 0.5,0.6,0.66")
|
|
366
|
+
h.add_argument("--max-pressure-drop", metavar="FLUID=PA,...",
|
|
367
|
+
help="--size: drop candidates over these pressure drops, e.g. coolant=2000,water=2000")
|
|
368
|
+
h.add_argument("--min-wall", type=float,
|
|
369
|
+
help="--size: thinnest wall to try, mm (default the process's minimum: fdm 0.8, lpbf 0.5)")
|
|
370
|
+
h.add_argument("--axes", choices=["xy", "yx"],
|
|
371
|
+
help="--size with an envelope: the first fluid runs along axes[0] (default: both tried; the "
|
|
372
|
+
"spec's axes pins it too)")
|
|
373
|
+
h.add_argument("--max-points", type=int,
|
|
374
|
+
help="design grid cap (default 16000000, about 6.6 GB at its peak); --size keeps only "
|
|
375
|
+
"designs that mesh within it")
|
|
376
|
+
h.add_argument("--force", action="store_true",
|
|
377
|
+
help="design even when the estimated peak memory is over what is free")
|
|
378
|
+
h.add_argument("--no-render", action="store_true")
|
|
379
|
+
h.add_argument("--no-viewer", action="store_true", help="skip the interactive HTML viewer")
|
|
380
|
+
h.add_argument("--base-dir")
|
|
381
|
+
fg = sub.add_parser("field", help="mesh a field graph: implicit part with field-driven walls, lattices, fillets")
|
|
382
|
+
fg.add_argument("graph")
|
|
383
|
+
fg.add_argument("--out", default="loadpath-out")
|
|
384
|
+
fg.add_argument("--pitch", type=float, help="mesh grid, mm (overrides the graph's mesh.pitch)")
|
|
385
|
+
fg.add_argument("--node", help="mesh this node instead of the graph's output (a copy's nodes: <copy>__<node>)")
|
|
386
|
+
fg.add_argument("--decimate", type=float, metavar="MM", help="thin the triangles where the mesh stays within "
|
|
387
|
+
"MM of the surface (e.g. 0.02)")
|
|
388
|
+
fg.add_argument("--stream", action="store_true", help="write the STL slab by slab in bounded memory "
|
|
389
|
+
"(huge lattices; skips the checks that need the mesh)")
|
|
390
|
+
fg.add_argument("--no-render", action="store_true")
|
|
391
|
+
fg.add_argument("--step", action="store_true", help="also write a STEP file (exact planes and cylinders; "
|
|
392
|
+
"needs loadpath[step])")
|
|
393
|
+
fg.add_argument("--lattice-3mf", action="store_true",
|
|
394
|
+
help="also write <name>-lattice.3mf with the lattice as a lattice (implicit TPMS level set or "
|
|
395
|
+
"beam lattice; needs loadpath[3mf])")
|
|
396
|
+
fg.add_argument("--base-dir")
|
|
397
|
+
tu = sub.add_parser("tune", help="tune a field graph's numbers (its `tune` block) for the lightest part "
|
|
398
|
+
"that still meets a study's safety factor and deflection")
|
|
399
|
+
tu.add_argument("graph")
|
|
400
|
+
tu.add_argument("--out", default="loadpath-tune")
|
|
401
|
+
tu.add_argument("--evaluations", type=int, help="analyses to spend (overrides tune.evaluations)")
|
|
402
|
+
tu.add_argument("--base-dir")
|
|
403
|
+
lo_ = sub.add_parser("lattice-orient", help="turn a field graph's lattice cells (their `orient`) to line "
|
|
404
|
+
"their stiff directions up with the load: best safety factor "
|
|
405
|
+
"or stiffness at the same mass")
|
|
406
|
+
lo_.add_argument("graph")
|
|
407
|
+
lo_.add_argument("--study", help="study JSON or example:<name> (default: the graph's tune.study)")
|
|
408
|
+
lo_.add_argument("--objective", choices=["safetyFactor", "stiffness"], default="safetyFactor")
|
|
409
|
+
lo_.add_argument("--cells", type=int, default=15000, help="homogenized analysis cells per evaluation")
|
|
410
|
+
lo_.add_argument("--refine", type=int, default=16, help="Nelder-Mead evaluations after the screen")
|
|
411
|
+
lo_.add_argument("--zones", type=int, default=1, help="split the lattice into this many zones by stress "
|
|
412
|
+
"direction and turn each its own way (orient.zones)")
|
|
413
|
+
lo_.add_argument("--seam", type=float, default=1.0, help="zones: mm solid plate between zones (orient.seam), "
|
|
414
|
+
"0 for cross-faded zones (unconfirmed, read too stiff)")
|
|
415
|
+
lo_.add_argument("--confirm", choices=["auto", "resolved", "none"], default="auto",
|
|
416
|
+
help="a lattice cut open at bare faces: auto keeps its cells as laid (the homogenized check "
|
|
417
|
+
"misreads turned against square-cut cells there), resolved checks the turn at full "
|
|
418
|
+
"resolution (minutes), none trusts the homogenized reading")
|
|
419
|
+
lo_.add_argument("--resolved-pitch", type=float, help="mm voxels for --confirm resolved (default wall/6)")
|
|
420
|
+
lo_.add_argument("--out", default="loadpath-lattice-orient")
|
|
421
|
+
lo_.add_argument("--base-dir")
|
|
422
|
+
rc = sub.add_parser("resolved-check", help="full-resolution voxel FE of a field graph's part, lattice "
|
|
423
|
+
"struts and sheets resolved (the arbiter for homogenized readings)")
|
|
424
|
+
rc.add_argument("graph")
|
|
425
|
+
rc.add_argument("--study", help="study JSON or example:<name> (default: the graph's tune.study)")
|
|
426
|
+
rc.add_argument("--pitch", type=float, help="mm (default: the lattice wall / 6)")
|
|
427
|
+
rc.add_argument("--band", type=float, help="contact band, mm (default one lattice cell)")
|
|
428
|
+
rc.add_argument("--max-cells", type=int, default=4_000_000)
|
|
429
|
+
rc.add_argument("--base-dir")
|
|
430
|
+
lg = sub.add_parser("lattice-grade", help="grade a field graph's lattice wall to the stress (fully stressed, "
|
|
431
|
+
"on the anisotropic homogenized check), optionally turning its cells")
|
|
432
|
+
lg.add_argument("graph")
|
|
433
|
+
lg.add_argument("--study", help="study JSON or example:<name> (default: the graph's tune.study)")
|
|
434
|
+
lg.add_argument("--target-sf", type=float, help="safety factor to size to (default: the study's goal)")
|
|
435
|
+
lg.add_argument("--orient", help="'uniform' (one direction) or N (zones) to turn the cells too")
|
|
436
|
+
lg.add_argument("--seam", type=float, default=1.0, help="zones: mm solid plate between zones (0: none)")
|
|
437
|
+
lg.add_argument("--confirm", choices=["auto", "none"], default="auto",
|
|
438
|
+
help="auto: a lattice cut open at bare faces is graded but not turned (unconfirmed there)")
|
|
439
|
+
lg.add_argument("--wall-min", type=float, help="mm (default: the process's minimum feature, at least 0.6)")
|
|
440
|
+
lg.add_argument("--wall-max", type=float, help="mm (default: density 0.6)")
|
|
441
|
+
lg.add_argument("--cells", type=int, default=15000)
|
|
442
|
+
lg.add_argument("--out", default="loadpath-lattice-grade", help="folder for graded.json and its density "
|
|
443
|
+
"grid (inside the graph's folder)")
|
|
444
|
+
lg.add_argument("--base-dir")
|
|
445
|
+
co = sub.add_parser("cores", help="tune each core option (hollow, ribbed, lattice...) of a part and pick the "
|
|
446
|
+
"lightest that meets its loads and can be built by each process")
|
|
447
|
+
co.add_argument("spec")
|
|
448
|
+
co.add_argument("--out", default="loadpath-cores")
|
|
449
|
+
co.add_argument("--process", help="comma-separated: fdm, sls, mjf (overrides the spec's processes)")
|
|
450
|
+
co.add_argument("--base-dir")
|
|
451
|
+
ed = sub.add_parser("edit", help="edit a field graph in the browser: node graph, sliders, live GPU preview, run")
|
|
452
|
+
ed.add_argument("graph")
|
|
453
|
+
ed.add_argument("--html", help="write one self-contained editor page (every bake inside) instead of serving")
|
|
454
|
+
ed.add_argument("--port", type=int, default=8765)
|
|
455
|
+
ed.add_argument("--host", default="127.0.0.1", help="interface to listen on (default: this machine only)")
|
|
456
|
+
ed.add_argument("--out", help="where runs go (default: loadpath-edit/ beside the graph)")
|
|
457
|
+
ed.add_argument("--no-open", action="store_true", help="don't open a browser")
|
|
458
|
+
ed.add_argument("--base-dir")
|
|
459
|
+
sub.add_parser("mcp", help="run a local MCP server (stdio) for agents")
|
|
460
|
+
mt = sub.add_parser("materials", help="list the material library, or print one card with its sources")
|
|
461
|
+
mt.add_argument("name", nargs="?")
|
|
462
|
+
o = sub.add_parser("orient", help="rank build orientations of a mesh (support, bed, layer bond)")
|
|
463
|
+
o.add_argument("mesh")
|
|
464
|
+
o.add_argument("--study", help="the part's study: adds the interlayer reading under its loads")
|
|
465
|
+
o.add_argument("--3mf", dest="threemf", help="write the best orientation as a slicer-ready 3MF")
|
|
466
|
+
o.add_argument("--process", default="fdm")
|
|
467
|
+
cal = sub.add_parser("calibrate", help="coupons to print, and a material card fitted from their measurements")
|
|
468
|
+
cal.add_argument("action", choices=["kit", "fit"])
|
|
469
|
+
cal.add_argument("measurements", nargs="?", help="fit: the filled-in measurements.json")
|
|
470
|
+
cal.add_argument("--material", default="petg", help="kit: the starting card")
|
|
471
|
+
cal.add_argument("--process", default="fdm")
|
|
472
|
+
cal.add_argument("--crush", action="store_true", help="kit: add gyroid crush blocks (impact.py's crush law)")
|
|
473
|
+
cal.add_argument("--out", default="calibration", help="kit: folder for the STLs; fit: folder for the card")
|
|
474
|
+
cal.add_argument("--predictions", help="fit: the kit's predictions.json (adds part-test model factors)")
|
|
475
|
+
cal.add_argument("--name", help="fit: the card's name, usable as a study's material once saved")
|
|
476
|
+
cal.add_argument("--save", action="store_true", help="fit: also save the card to loadpath/calibrations/")
|
|
477
|
+
sub.add_parser("guide", help="print the agent guide (SKILL.md: how to turn a request into a study)")
|
|
478
|
+
im = sub.add_parser("import", help="a STEP/STL part as a draft study: bores, bolt patterns and mounting "
|
|
479
|
+
"faces found, interfaces preserved, a support and load suggested")
|
|
480
|
+
im.add_argument("part", help="STEP (.step/.stp, needs loadpath[step]), STL, 3MF or OBJ, in mm")
|
|
481
|
+
im.add_argument("--out", default="loadpath-import", help="folder for the study, its STLs and features.json")
|
|
482
|
+
im.add_argument("--space", choices=["part", "hull", "box"], default="part",
|
|
483
|
+
help="design space: the part as drawn, its convex hull, or its bounding box")
|
|
484
|
+
im.add_argument("--grow", type=float, default=0.0, help="mm to grow the design space by")
|
|
485
|
+
im.add_argument("--material", help="material card (`loadpath materials`); without it the draft says petg")
|
|
486
|
+
im.add_argument("--process", help="fdm, sls, mjf, sla, dlp, lpbf, volumetric")
|
|
487
|
+
im.add_argument("--support", help="interface id that holds the part (default: the suggested one)")
|
|
488
|
+
im.add_argument("--load", action="append", default=[], metavar="ID=FX,FY,FZ",
|
|
489
|
+
help="N on an interface, e.g. bore-0=0,0,-250; repeat it for more load cases, one per --load; "
|
|
490
|
+
"replaces the placeholder")
|
|
491
|
+
im.add_argument("--ring", type=float, help="mm of wall kept round every bore, as exact tubes in the study "
|
|
492
|
+
"(default: 2 mm + a tenth of the radius, cut to the part)")
|
|
493
|
+
im.add_argument("--force", type=float, default=100.0, help="N: the suggested (placeholder) load")
|
|
494
|
+
im.add_argument("--pad", type=float, default=2.0, help="mm of material kept under each bolted face")
|
|
495
|
+
im.add_argument("--safety-factor", type=float, default=2.0)
|
|
496
|
+
im.add_argument("--tolerance", type=float, default=0.05, help="mm chord error tessellating a STEP")
|
|
497
|
+
im.add_argument("--features-only", action="store_true", help="print the features, write no study")
|
|
498
|
+
bs = sub.add_parser("buildsim", help="simulate printing a part (lpbf, fdm, sls): distortion, residual stress, "
|
|
499
|
+
"recoater lift; --compensate writes the pre-distorted part to print")
|
|
500
|
+
bs.add_argument("part", help="STL, 3MF or OBJ, in mm, in its build position")
|
|
501
|
+
bs.add_argument("--material", default="ti64", help="material card (`loadpath materials`)")
|
|
502
|
+
bs.add_argument("--process", choices=["lpbf", "fdm", "sls"], default="lpbf")
|
|
503
|
+
bs.add_argument("--build", default="+z", help="build direction in the part's frame (default +z)")
|
|
504
|
+
bs.add_argument("--plate", type=float, default=10.0, help="mm of build plate under the part (lpbf)")
|
|
505
|
+
bs.add_argument("--pitch", type=float, help="mm voxel (default: about --cells cells; the LPBF strain is "
|
|
506
|
+
"calibrated at 0.5 mm)")
|
|
507
|
+
bs.add_argument("--cells", type=int, default=60_000, help="part cells when --pitch is not given")
|
|
508
|
+
bs.add_argument("--scale", type=float, default=1.0, help="multiplies the inherent strain (a calibration)")
|
|
509
|
+
bs.add_argument("--recoater-gap", type=float, default=0.05, help="mm of build-plane lift that is a risk")
|
|
510
|
+
bs.add_argument("--compensate", type=int, default=0, metavar="N",
|
|
511
|
+
help="also write <part>-compensated.stl, pre-distorted over N rounds")
|
|
512
|
+
bs.add_argument("--nominal", help="the design the printed shape is compared with (default: the part itself; "
|
|
513
|
+
"give the design when simulating a compensated part)")
|
|
514
|
+
bs.add_argument("--out", default="loadpath-buildsim")
|
|
515
|
+
asm = sub.add_parser("assembly",help="derive a part's load cases and neighbour keep-outs from a URDF")
|
|
516
|
+
asm.add_argument("urdf")
|
|
517
|
+
asm.add_argument("--link", required=True, help="the URDF link the study designs")
|
|
518
|
+
asm.add_argument("--study", required=True, help="the part's study: interfaces, material, goal, design space")
|
|
519
|
+
asm.add_argument("--region", required=True, help="the study region the far joint loads (a socket, a mount)")
|
|
520
|
+
asm.add_argument("--joint", help="the far joint, if the link has more than one moving child joint")
|
|
521
|
+
asm.add_argument("--frame", choices=["world", "link"], default="world",
|
|
522
|
+
help="the study's frame: world (the URDF's world at the home pose, mm) or link (the "
|
|
523
|
+
"designed link's own frame, mm, origin at its joint)")
|
|
524
|
+
asm.add_argument("--payload", type=float, default=0.0, help="kg at the end of the chain")
|
|
525
|
+
asm.add_argument("--payload-link", help="link that carries the payload (default: the chain's last link)")
|
|
526
|
+
asm.add_argument("--payload-at", default="0,0,0", help="payload position in that link's frame, m: x,y,z")
|
|
527
|
+
asm.add_argument("--payload-radius", type=float, default=0.0, help="m: payload position scatters this far")
|
|
528
|
+
asm.add_argument("--samples", type=int, default=1500, help="random poses (on top of the limit corners)")
|
|
529
|
+
asm.add_argument("--ramp", type=float, default=0.2, help="s to full speed (before the effort limit)")
|
|
530
|
+
asm.add_argument("--push", type=float, default=20.0, help="N: contact at the payload, any direction (default 20; often sizes the part, 0 if nothing pushes)")
|
|
531
|
+
asm.add_argument("--contact-max", type=float, help="N: hardest an obstacle pushes back on a stalled joint "
|
|
532
|
+
"(default: --push)")
|
|
533
|
+
asm.add_argument("--balanced", default="", help="joints a counterbalance holds against gravity: j2,...")
|
|
534
|
+
asm.add_argument("--cases", type=int, default=8, help="most governing cases to keep")
|
|
535
|
+
asm.add_argument("--method", choices=["stress", "hull"], default="stress")
|
|
536
|
+
asm.add_argument("--keep-cases", action="store_true", help="keep the study's own load cases too")
|
|
537
|
+
asm.add_argument("--no-space", action="store_true", help="loads only: no swept keep-outs")
|
|
538
|
+
asm.add_argument("--clearance", type=float, default=1.0, help="mm round each swept neighbour")
|
|
539
|
+
asm.add_argument("--cycles", default="", help="life cycles by sample kind, for fatigue: "
|
|
540
|
+
"dynamic=1e6,pose=1e6,push=1e4,stall=1e3 (stall:j2=... for one joint)")
|
|
541
|
+
asm.add_argument("--out", default="loadpath-out")
|
|
542
|
+
for sp in (r, lt, h, fg, tu, ed, asm, v, bs):
|
|
543
|
+
sp.add_argument("--quick", action="store_true",
|
|
544
|
+
help="coarse grid, fewer iterations: a first look in a minute or two (quick.py)")
|
|
545
|
+
args = p.parse_args(argv)
|
|
546
|
+
if getattr(args, "quick", False):
|
|
547
|
+
from . import quick
|
|
548
|
+
quick.enable()
|
|
549
|
+
|
|
550
|
+
if args.cmd == "mcp":
|
|
551
|
+
from .mcp_server import serve
|
|
552
|
+
serve()
|
|
553
|
+
return 0
|
|
554
|
+
if args.cmd == "schema":
|
|
555
|
+
from .schema import schema
|
|
556
|
+
print(json.dumps(schema(args.kind), indent=2))
|
|
557
|
+
return 0
|
|
558
|
+
if args.cmd == "examples":
|
|
559
|
+
from .tasks import example, examples
|
|
560
|
+
print(json.dumps(example(args.name) if args.name else examples(), indent=2))
|
|
561
|
+
return 0
|
|
562
|
+
if args.cmd == "materials":
|
|
563
|
+
return _materials(args)
|
|
564
|
+
if args.cmd == "heatx":
|
|
565
|
+
return _heatx(args)
|
|
566
|
+
if args.cmd == "field":
|
|
567
|
+
return _field(args)
|
|
568
|
+
if args.cmd == "edit":
|
|
569
|
+
return _edit(args)
|
|
570
|
+
if args.cmd == "tune":
|
|
571
|
+
return _tune(args)
|
|
572
|
+
if args.cmd == "cores":
|
|
573
|
+
return _cores(args)
|
|
574
|
+
if args.cmd == "lattice-orient":
|
|
575
|
+
return _lattice_orient(args)
|
|
576
|
+
if args.cmd == "resolved-check":
|
|
577
|
+
return _resolved_check(args)
|
|
578
|
+
if args.cmd == "lattice-grade":
|
|
579
|
+
return _lattice_grade(args)
|
|
580
|
+
if args.cmd == "orient":
|
|
581
|
+
return _orient(args)
|
|
582
|
+
if args.cmd == "calibrate":
|
|
583
|
+
return _calibrate(args)
|
|
584
|
+
if args.cmd == "assembly":
|
|
585
|
+
return _assembly(args)
|
|
586
|
+
if args.cmd == "status":
|
|
587
|
+
print(json.dumps(run_status(args.out), indent=1))
|
|
588
|
+
return 0
|
|
589
|
+
if args.cmd == "verify":
|
|
590
|
+
return _verify(args)
|
|
591
|
+
if args.cmd == "import":
|
|
592
|
+
return _import(args)
|
|
593
|
+
if args.cmd == "buildsim":
|
|
594
|
+
return _buildsim(args)
|
|
595
|
+
if args.cmd == "guide":
|
|
596
|
+
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "SKILL.md")) as fh:
|
|
597
|
+
print(fh.read())
|
|
598
|
+
return 0
|
|
599
|
+
|
|
600
|
+
raw = _load(args.study)
|
|
601
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.study))
|
|
602
|
+
if args.cmd == "check":
|
|
603
|
+
from .tasks import check_document
|
|
604
|
+
checked = check_document(raw, base)
|
|
605
|
+
print(json.dumps(checked, indent=2))
|
|
606
|
+
return 0 if checked["ok"] else 2
|
|
607
|
+
if getattr(args, "recall", False):
|
|
608
|
+
os.environ.setdefault("LOADPATH_RECALL", "1")
|
|
609
|
+
from .tasks import check_study
|
|
610
|
+
checked = check_study(raw, base)
|
|
611
|
+
if not checked["ok"]:
|
|
612
|
+
print(json.dumps(checked, indent=2))
|
|
613
|
+
return 2
|
|
614
|
+
if args.cmd == "check":
|
|
615
|
+
print(json.dumps(checked, indent=2))
|
|
616
|
+
return 0
|
|
617
|
+
|
|
618
|
+
from .spec import study_from_dict
|
|
619
|
+
study = study_from_dict(raw, base_dir=base)
|
|
620
|
+
if args.cmd == "lattice":
|
|
621
|
+
import trimesh
|
|
622
|
+
opts = _load(args.options) if args.options else raw.get("lattice") or {}
|
|
623
|
+
mesh = trimesh.load(args.part, force="mesh")
|
|
624
|
+
stem = os.path.splitext(os.path.basename(args.part))[0]
|
|
625
|
+
try:
|
|
626
|
+
rep = _lattice(study, mesh, opts, args.out, stem, not args.no_render and _can_render())
|
|
627
|
+
except ValueError as exc:
|
|
628
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
629
|
+
return 2
|
|
630
|
+
_write_report(args.out, rep)
|
|
631
|
+
return 0
|
|
632
|
+
|
|
633
|
+
if study.multi_material is not None:
|
|
634
|
+
return _run_multimaterial(args, study)
|
|
635
|
+
if not args.no_render and not _can_render():
|
|
636
|
+
args.no_render = True
|
|
637
|
+
from .study import run_study
|
|
638
|
+
|
|
639
|
+
# a run can take half an hour: say which process it is, so a caller polling
|
|
640
|
+
# from elsewhere can tell it apart from other runs and see it finish
|
|
641
|
+
os.makedirs(args.out, exist_ok=True)
|
|
642
|
+
status_path = os.path.join(args.out, "status.json")
|
|
643
|
+
|
|
644
|
+
def _status(state, **kw):
|
|
645
|
+
with open(status_path, "w") as fh:
|
|
646
|
+
json.dump({"state": state, "pid": os.getpid(), "study": os.path.abspath(args.study), **kw}, fh)
|
|
647
|
+
|
|
648
|
+
_status("running", started=time.time())
|
|
649
|
+
import signal
|
|
650
|
+
|
|
651
|
+
def _killed(signum, _frame):
|
|
652
|
+
# a hang-up or kill from the shell that launched us (an agent's tool
|
|
653
|
+
# call ending takes its process group down): say so, then go
|
|
654
|
+
try:
|
|
655
|
+
_status("killed", signal=signal.Signals(signum).name, stage=seen.get("stage"),
|
|
656
|
+
partial=os.environ.get("LOADPATH_PARTIAL_DIR"),
|
|
657
|
+
hint="relaunch detached: setsid nohup loadpath run ... > run.log 2>&1 < /dev/null &")
|
|
658
|
+
finally:
|
|
659
|
+
os._exit(128 + signum)
|
|
660
|
+
|
|
661
|
+
for sig in (signal.SIGTERM, signal.SIGHUP):
|
|
662
|
+
try:
|
|
663
|
+
signal.signal(sig, _killed)
|
|
664
|
+
except (ValueError, OSError):
|
|
665
|
+
pass
|
|
666
|
+
# each outcome as the search finds it, kept if a later stage dies
|
|
667
|
+
os.environ.setdefault("LOADPATH_PARTIAL_DIR", os.path.join(args.out, "partial"))
|
|
668
|
+
rec = None
|
|
669
|
+
if args.viewer != "none" and not os.environ.get("LOADPATH_NO_RENDER"):
|
|
670
|
+
from .viewer import HistoryRecorder
|
|
671
|
+
rec = HistoryRecorder(chain=_progress)
|
|
672
|
+
seen = {"stage": None, "outcomes": 0}
|
|
673
|
+
|
|
674
|
+
def _tracked(event, *rest):
|
|
675
|
+
# status.json carries the stage and outcomes so far: one file to poll
|
|
676
|
+
st = event.get("stage")
|
|
677
|
+
if st == "outcome":
|
|
678
|
+
seen["outcomes"] += 1
|
|
679
|
+
if st and st != seen["stage"] or st == "outcome":
|
|
680
|
+
seen["stage"] = st
|
|
681
|
+
try:
|
|
682
|
+
_status("running", started=started, stage=st, outcomes=seen["outcomes"],
|
|
683
|
+
**({"volumeFraction": event["volumeFraction"]} if "volumeFraction" in event else {}))
|
|
684
|
+
except OSError:
|
|
685
|
+
pass
|
|
686
|
+
return (rec or _progress)(event, *rest)
|
|
687
|
+
|
|
688
|
+
started = time.time()
|
|
689
|
+
try:
|
|
690
|
+
result = run_study(study, on_progress=_tracked)
|
|
691
|
+
except BaseException as exc:
|
|
692
|
+
_status("failed", error=f"{type(exc).__name__}: {exc}",
|
|
693
|
+
partial=os.environ.get("LOADPATH_PARTIAL_DIR"))
|
|
694
|
+
raise
|
|
695
|
+
os.makedirs(args.out, exist_ok=True)
|
|
696
|
+
summary = result.to_dict()
|
|
697
|
+
stems = []
|
|
698
|
+
for i, o in enumerate(result.outcomes):
|
|
699
|
+
stem = f"{study.name}-{int(round(o.volume_fraction * 100)):02d}"
|
|
700
|
+
if stem in stems:
|
|
701
|
+
# two outcomes within half a percent (a search bisecting): one
|
|
702
|
+
# file each, or the second overwrites the first's STL
|
|
703
|
+
stem = f"{study.name}-{o.volume_fraction * 100:.1f}".replace(".", "p")
|
|
704
|
+
k = 2
|
|
705
|
+
while stem in stems:
|
|
706
|
+
stem, k = f"{study.name}-{int(round(o.volume_fraction * 100)):02d}-{k}", k + 1
|
|
707
|
+
stems.append(stem)
|
|
708
|
+
stl = os.path.join(args.out, stem + ".stl")
|
|
709
|
+
o.mesh.export(stl)
|
|
710
|
+
summary["outcomes"][i]["stl"] = stl
|
|
711
|
+
if getattr(o, "compensated", None) is not None:
|
|
712
|
+
# pre-distorted against the simulated build: print this one
|
|
713
|
+
cstl = os.path.join(args.out, stem + "-compensated.stl")
|
|
714
|
+
o.compensated.export(cstl)
|
|
715
|
+
summary["outcomes"][i]["compensatedStl"] = cstl
|
|
716
|
+
rs = result.study # with an "auto" build direction resolved
|
|
717
|
+
build = rs.print_direction if rs.print_direction is not None else \
|
|
718
|
+
(rs.process.build if rs.process is not None and rs.process.build != "auto" else None)
|
|
719
|
+
if build is not None:
|
|
720
|
+
# stood the way it was designed to print, for the slicer
|
|
721
|
+
from .threemf import write_3mf
|
|
722
|
+
summary["outcomes"][i]["3mf"] = write_3mf(os.path.join(args.out, stem + ".3mf"), o.mesh, build,
|
|
723
|
+
name=stem, material=rs.material, process=rs.process)
|
|
724
|
+
if o.grading is not None:
|
|
725
|
+
# the slicer grades it: infill and perimeters per region
|
|
726
|
+
summary["outcomes"][i]["graded3mf"] = write_3mf(
|
|
727
|
+
os.path.join(args.out, stem + "-graded.3mf"), o.mesh, build, name=stem, material=rs.material,
|
|
728
|
+
process=rs.process, object_settings=o.grading["objectSettings"],
|
|
729
|
+
modifiers=o.grading["modifiers"])
|
|
730
|
+
if not args.no_render:
|
|
731
|
+
from .render import render_views
|
|
732
|
+
png = os.path.join(args.out, stem + ".png")
|
|
733
|
+
sf = o.report.get("safetyFactor")
|
|
734
|
+
render_views(o.mesh, png, study,
|
|
735
|
+
title=f"{study.name} vf {o.volume_fraction:.2f} {o.report['mass']:.1f} g "
|
|
736
|
+
f"SF {sf:.2f}" if sf is not None else stem)
|
|
737
|
+
summary["outcomes"][i]["render"] = png
|
|
738
|
+
if args.step and result.outcomes:
|
|
739
|
+
from .step import export_step
|
|
740
|
+
|
|
741
|
+
bi = result.best if result.best is not None else len(result.outcomes) - 1
|
|
742
|
+
try:
|
|
743
|
+
st = export_step(result.outcomes[bi].mesh, os.path.join(args.out, stems[bi] + ".step"), name=stems[bi])
|
|
744
|
+
st.pop("shape", None)
|
|
745
|
+
summary["outcomes"][bi]["step"] = st
|
|
746
|
+
except Exception as exc: # noqa: BLE001 - the STL is the deliverable; say why STEP is missing
|
|
747
|
+
summary.setdefault("warnings", []).append(f"STEP not written: {type(exc).__name__}: {exc}")
|
|
748
|
+
if result.best is not None:
|
|
749
|
+
# a fixed name for the best outcome, for the next command in a script
|
|
750
|
+
best_stl = os.path.join(args.out, "best.stl")
|
|
751
|
+
result.outcomes[result.best].mesh.export(best_stl)
|
|
752
|
+
summary["bestStl"] = best_stl
|
|
753
|
+
if raw.get("lattice") is not None and result.best is not None:
|
|
754
|
+
best = result.outcomes[result.best]
|
|
755
|
+
summary["lattice"] = _lattice(study, best.mesh, raw["lattice"], args.out, stems[result.best],
|
|
756
|
+
not args.no_render)
|
|
757
|
+
if rec is not None and result.outcomes:
|
|
758
|
+
from .viewer import study_viewer
|
|
759
|
+
_progress({"stage": "viewer", "outcomes": args.viewer})
|
|
760
|
+
html = os.path.join(args.out, "viewer.html")
|
|
761
|
+
try:
|
|
762
|
+
study_viewer(result, html, history=rec, outcomes=args.viewer)
|
|
763
|
+
summary["viewer"] = html
|
|
764
|
+
except Exception as exc: # noqa: BLE001 - the parts and report matter more than the page
|
|
765
|
+
summary.setdefault("warnings", []).append(f"viewer not written: {exc}")
|
|
766
|
+
from .delivery import delivery
|
|
767
|
+
summary["deliver"] = delivery(summary, raw)
|
|
768
|
+
dv = summary["deliver"]
|
|
769
|
+
if dv and dv.get("safetyFactorMethod") != "tet":
|
|
770
|
+
# say why the body-fitted cross-check did not confirm it
|
|
771
|
+
try:
|
|
772
|
+
from .crosscheck import unsupported
|
|
773
|
+
why = unsupported(study)
|
|
774
|
+
except Exception as exc: # noqa: BLE001
|
|
775
|
+
why = f"{type(exc).__name__}: {exc}"
|
|
776
|
+
dv["tetCrossCheck"] = f"not run: {why}" if why else "not run: goal.confirm chose the voxel re-check"
|
|
777
|
+
_write_report(args.out, summary)
|
|
778
|
+
_status("done", report=os.path.join(os.path.abspath(args.out), "report.json"))
|
|
779
|
+
if not os.environ.get("LOADPATH_NO_HINT"):
|
|
780
|
+
print("Print or host this part: https://www.materialize.cc (silence with LOADPATH_NO_HINT=1)",
|
|
781
|
+
file=sys.stderr)
|
|
782
|
+
return 0
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _run_multimaterial(args, study):
|
|
786
|
+
"""`run` on a multiMaterial study (multimat.py): bodies, an STL per
|
|
787
|
+
material, a multi-material 3MF and report.json, with out/status.json
|
|
788
|
+
and out/partial/ kept as the single-material run keeps them."""
|
|
789
|
+
from .multimat import run_multimaterial
|
|
790
|
+
|
|
791
|
+
os.makedirs(args.out, exist_ok=True)
|
|
792
|
+
status_path = os.path.join(args.out, "status.json")
|
|
793
|
+
|
|
794
|
+
def _status(state, **kw):
|
|
795
|
+
with open(status_path, "w") as fh:
|
|
796
|
+
json.dump(_jsonable({"state": state, "pid": os.getpid(), "study": os.path.abspath(args.study),
|
|
797
|
+
"multiMaterial": True, **kw}), fh)
|
|
798
|
+
|
|
799
|
+
started = time.time()
|
|
800
|
+
_status("running", started=started)
|
|
801
|
+
os.environ.setdefault("LOADPATH_PARTIAL_DIR", os.path.join(args.out, "partial"))
|
|
802
|
+
seen = {"stage": None, "outcomes": []}
|
|
803
|
+
|
|
804
|
+
def _tracked(event, *_):
|
|
805
|
+
st = event.get("stage")
|
|
806
|
+
if st == "outcome":
|
|
807
|
+
# each verified fraction: mass and safety factors, per material and bond
|
|
808
|
+
seen["outcomes"].append({k: event.get(k) for k in (
|
|
809
|
+
"volumeFraction", "mass", "safetyFactor", "interfaceSafetyFactor", "margin", "passes")})
|
|
810
|
+
if st and st != seen["stage"] or st == "outcome":
|
|
811
|
+
seen["stage"] = st
|
|
812
|
+
try:
|
|
813
|
+
_status("running", started=started, stage=st, outcomes=len(seen["outcomes"]),
|
|
814
|
+
tried=seen["outcomes"],
|
|
815
|
+
**({"volumeFraction": event["volumeFraction"]} if "volumeFraction" in event else {}))
|
|
816
|
+
except OSError:
|
|
817
|
+
pass
|
|
818
|
+
_progress(event, None)
|
|
819
|
+
|
|
820
|
+
try:
|
|
821
|
+
mres = run_multimaterial(study, on_progress=_tracked)
|
|
822
|
+
files = mres.write(args.out, coordinates=getattr(args, "mm_coordinates", None) or "bed")
|
|
823
|
+
except BaseException as exc:
|
|
824
|
+
_status("failed", error=f"{type(exc).__name__}: {exc}", partial=os.environ.get("LOADPATH_PARTIAL_DIR"))
|
|
825
|
+
raise
|
|
826
|
+
rep = mres.report
|
|
827
|
+
print(json.dumps(_jsonable({"ok": True, "mass": rep.get("mass"), "materialMass": rep.get("materialMass"),
|
|
828
|
+
"safetyFactor": rep.get("safetyFactor"),
|
|
829
|
+
"interfaceSafetyFactor": rep.get("interfaceSafetyFactor"),
|
|
830
|
+
"requirementsMet": rep.get("requirementsMet"), "passes": rep.get("passes"),
|
|
831
|
+
**({"unchecked": rep["unchecked"]} if rep.get("unchecked") else {}),
|
|
832
|
+
**({"warnings": rep["warnings"]} if rep.get("warnings") else {}),
|
|
833
|
+
"3mf": {k: v for k, v in (getattr(mres, "delivered_3mf", None) or {}).items()
|
|
834
|
+
if k != "transform"} or None,
|
|
835
|
+
"files": files}), indent=2, default=str))
|
|
836
|
+
_status("done", report=os.path.abspath(files["report"]), passes=rep.get("passes"))
|
|
837
|
+
return 0
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _can_render():
|
|
841
|
+
if os.environ.get("LOADPATH_NO_RENDER"):
|
|
842
|
+
return False
|
|
843
|
+
try:
|
|
844
|
+
import matplotlib # noqa: F401
|
|
845
|
+
import shapely # noqa: F401
|
|
846
|
+
return True
|
|
847
|
+
except ImportError:
|
|
848
|
+
print(json.dumps({"stage": "warning", "message": "no renders: pip install \"loadpath[render]\""}),
|
|
849
|
+
file=sys.stderr, flush=True)
|
|
850
|
+
return False
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _lattice(study, mesh, opts, out, stem, render):
|
|
854
|
+
from .tasks import lattice_part
|
|
855
|
+
return lattice_part(study, mesh, opts, out, stem, render=render, on_progress=_progress)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _heatx(args):
|
|
859
|
+
from .tasks import heat_exchanger, size_heat_exchanger
|
|
860
|
+
spec = _load(args.spec)
|
|
861
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.spec))
|
|
862
|
+
status_path = None
|
|
863
|
+
|
|
864
|
+
def _status(state, **kw):
|
|
865
|
+
if status_path is None:
|
|
866
|
+
return
|
|
867
|
+
try:
|
|
868
|
+
with open(status_path, "w") as fh:
|
|
869
|
+
json.dump(_jsonable({"state": state, "pid": os.getpid(), "spec": os.path.abspath(args.spec),
|
|
870
|
+
"started": started, "updated": time.time(), **kw}), fh)
|
|
871
|
+
except OSError:
|
|
872
|
+
pass
|
|
873
|
+
|
|
874
|
+
started = time.time()
|
|
875
|
+
try:
|
|
876
|
+
if args.size:
|
|
877
|
+
splits = [float(x) for x in args.splits.split(",")] if args.splits else None
|
|
878
|
+
max_dp = None
|
|
879
|
+
if args.max_pressure_drop:
|
|
880
|
+
try:
|
|
881
|
+
pairs = (kv.split("=") for kv in args.max_pressure_drop.split(","))
|
|
882
|
+
max_dp = {k.strip(): float(v) for k, v in pairs}
|
|
883
|
+
except ValueError:
|
|
884
|
+
raise ValueError(f"--max-pressure-drop takes FLUID=PA pairs, e.g. water=2000,oil=5000; "
|
|
885
|
+
f"got {args.max_pressure_drop!r}") from None
|
|
886
|
+
fluids = list(spec.get("fluids") or ["A", "B"])
|
|
887
|
+
unknown = sorted(set(max_dp) - set(fluids))
|
|
888
|
+
if unknown:
|
|
889
|
+
raise ValueError(f"--max-pressure-drop names {unknown}; the fluids are {fluids}")
|
|
890
|
+
print(json.dumps(size_heat_exchanger(spec, base, max_pressure_drop=max_dp, min_wall=args.min_wall,
|
|
891
|
+
objective=args.objective, min_effectiveness=args.min_effectiveness,
|
|
892
|
+
splits=splits, axes=args.axes, max_points=args.max_points,
|
|
893
|
+
out_dir=args.out), indent=2))
|
|
894
|
+
return 0
|
|
895
|
+
out = args.out or "loadpath-out"
|
|
896
|
+
# a design can take minutes and gigabytes: status.json says which stage it is
|
|
897
|
+
# in (a run killed for memory leaves "running" with its stage and pid)
|
|
898
|
+
os.makedirs(out, exist_ok=True)
|
|
899
|
+
status_path = os.path.join(out, "status.json")
|
|
900
|
+
_status("running", stage="check")
|
|
901
|
+
|
|
902
|
+
seen = {}
|
|
903
|
+
|
|
904
|
+
def _tracked(event):
|
|
905
|
+
if event.get("stage"):
|
|
906
|
+
seen.update({k: v for k, v in event.items() if k in ("stage", "gridStep", "gridPoints",
|
|
907
|
+
"peakMemoryGB")})
|
|
908
|
+
_status("running", **seen)
|
|
909
|
+
_progress(event)
|
|
910
|
+
|
|
911
|
+
rep = heat_exchanger(spec, out, base, render=not args.no_render and _can_render(),
|
|
912
|
+
on_progress=_tracked, viewer=not args.no_viewer and not os.environ.get("LOADPATH_NO_RENDER"),
|
|
913
|
+
max_points=args.max_points, force=args.force)
|
|
914
|
+
except ValueError as exc:
|
|
915
|
+
_status("failed", error=str(exc))
|
|
916
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
917
|
+
return 2
|
|
918
|
+
except BaseException as exc:
|
|
919
|
+
_status("failed", error=f"{type(exc).__name__}: {exc}")
|
|
920
|
+
raise
|
|
921
|
+
_write_report(out, rep)
|
|
922
|
+
_status("done", report=os.path.join(out, "report.json"), passes=rep.get("passes"),
|
|
923
|
+
warnings=(rep.get("report") or {}).get("warnings"))
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _field(args):
|
|
928
|
+
from .fields import run_field
|
|
929
|
+
from .schema import format_issues, lint
|
|
930
|
+
|
|
931
|
+
doc = _load(args.graph)
|
|
932
|
+
issues = lint(doc, "field")
|
|
933
|
+
if any(i["level"] == "error" for i in issues):
|
|
934
|
+
print(json.dumps({"ok": False, "issues": issues, "error": format_issues(issues)}, indent=2))
|
|
935
|
+
return 2
|
|
936
|
+
if args.pitch:
|
|
937
|
+
doc = {**doc, "mesh": {**(doc.get("mesh") or {}), "pitch": args.pitch}}
|
|
938
|
+
if args.step and not doc.get("step"):
|
|
939
|
+
doc = {**doc, "step": True}
|
|
940
|
+
if getattr(args, "lattice_3mf", False) and not doc.get("lattice3mf"):
|
|
941
|
+
doc = {**doc, "lattice3mf": True}
|
|
942
|
+
if args.stream:
|
|
943
|
+
doc = {**doc, "mesh": {**(doc.get("mesh") or {}), "stream": True}}
|
|
944
|
+
if args.decimate:
|
|
945
|
+
doc = {**doc, "mesh": {**(doc.get("mesh") or {}), "decimate": args.decimate}}
|
|
946
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
947
|
+
try:
|
|
948
|
+
_, rep = run_field(doc, args.out, base, progress=_progress, render=not args.no_render and _can_render(),
|
|
949
|
+
node=args.node)
|
|
950
|
+
except ValueError as exc:
|
|
951
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
952
|
+
return 2
|
|
953
|
+
_write_report(args.out, {"ok": True, **rep, **({"warnings": issues} if issues else {})})
|
|
954
|
+
return 0
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _tune(args):
|
|
958
|
+
from .schema import format_issues, lint
|
|
959
|
+
from .tune import tune_field
|
|
960
|
+
|
|
961
|
+
doc = _load(args.graph)
|
|
962
|
+
issues = lint(doc, "field")
|
|
963
|
+
if any(i["level"] == "error" for i in issues):
|
|
964
|
+
print(json.dumps({"ok": False, "issues": issues, "error": format_issues(issues)}, indent=2))
|
|
965
|
+
return 2
|
|
966
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
967
|
+
try:
|
|
968
|
+
out = tune_field(doc, base, args.out, progress=_progress, evaluations=args.evaluations)
|
|
969
|
+
except ValueError as exc:
|
|
970
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
971
|
+
return 2
|
|
972
|
+
summary = {"ok": bool(out["best"] and out["best"].get("ok")), "best": out["best"], "start": out["start"],
|
|
973
|
+
"limits": out["limits"], "evaluations": len(out["history"]),
|
|
974
|
+
"tuned": os.path.join(args.out, "tuned.json")}
|
|
975
|
+
if out["best"] is not None:
|
|
976
|
+
# tuned.json keeps the graph's relative paths (its study): they resolve from the graph's folder
|
|
977
|
+
summary["next"] = f"loadpath field {summary['tuned']} --base-dir {os.path.relpath(base)}"
|
|
978
|
+
if out["best"] is None:
|
|
979
|
+
summary["error"] = "no candidate met the limits: widen the params' ranges or ease the limits"
|
|
980
|
+
elif not summary["ok"]:
|
|
981
|
+
summary["error"] = ("the lightest candidates met the limits on the search's grid but not on the confirming "
|
|
982
|
+
"one: widen the ranges, ease the limits or give more evaluations")
|
|
983
|
+
_write_report(args.out, summary)
|
|
984
|
+
return 0 if summary["ok"] else 1
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _lattice_orient(args):
|
|
988
|
+
from .fields import load_study
|
|
989
|
+
from .lattice_orient import optimize_orientation, optimize_zones
|
|
990
|
+
|
|
991
|
+
doc = _load(args.graph)
|
|
992
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
993
|
+
ref = args.study or (doc.get("tune") or {}).get("study")
|
|
994
|
+
if not ref:
|
|
995
|
+
print(json.dumps({"ok": False, "error": "give --study (or a tune.study in the graph)"}))
|
|
996
|
+
return 2
|
|
997
|
+
try:
|
|
998
|
+
study = load_study(ref, base, doc.get("material"))
|
|
999
|
+
if args.zones > 1:
|
|
1000
|
+
out = optimize_zones(doc, study, zones=args.zones, base_dir=base, cells=args.cells,
|
|
1001
|
+
objective=args.objective, progress=_progress, seam=args.seam,
|
|
1002
|
+
confirm=args.confirm, resolved_pitch=args.resolved_pitch)
|
|
1003
|
+
else:
|
|
1004
|
+
out = optimize_orientation(doc, study, base_dir=base, cells=args.cells, objective=args.objective,
|
|
1005
|
+
refine=args.refine, progress=_progress, confirm=args.confirm,
|
|
1006
|
+
resolved_pitch=args.resolved_pitch)
|
|
1007
|
+
except ValueError as exc:
|
|
1008
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
1009
|
+
return 2
|
|
1010
|
+
os.makedirs(args.out, exist_ok=True)
|
|
1011
|
+
path = os.path.join(args.out, "oriented.json")
|
|
1012
|
+
with open(path, "w") as fh:
|
|
1013
|
+
json.dump(out["doc"], fh, indent=1)
|
|
1014
|
+
summary = {"ok": True, **{k: v for k, v in out.items() if k not in ("doc", "history")},
|
|
1015
|
+
"oriented": path, "next": f"loadpath field {path} --base-dir {os.path.relpath(base)}"}
|
|
1016
|
+
_write_report(args.out, summary)
|
|
1017
|
+
return 0
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _resolved_check(args):
|
|
1021
|
+
from .fields import load_study
|
|
1022
|
+
from .resolved import resolved_check
|
|
1023
|
+
|
|
1024
|
+
doc = _load(args.graph)
|
|
1025
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
1026
|
+
ref = args.study or (doc.get("tune") or {}).get("study")
|
|
1027
|
+
if not ref:
|
|
1028
|
+
print(json.dumps({"ok": False, "error": "give --study (or a tune.study in the graph)"}))
|
|
1029
|
+
return 2
|
|
1030
|
+
try:
|
|
1031
|
+
study = load_study(ref, base, doc.get("material"))
|
|
1032
|
+
rep = resolved_check(doc, study, pitch=args.pitch, base_dir=base, contact_band=args.band,
|
|
1033
|
+
max_cells=args.max_cells)
|
|
1034
|
+
except ValueError as exc:
|
|
1035
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
1036
|
+
return 2
|
|
1037
|
+
print(json.dumps({"ok": True, **rep}, indent=1))
|
|
1038
|
+
return 0
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _lattice_grade(args):
|
|
1042
|
+
import numpy as np
|
|
1043
|
+
|
|
1044
|
+
from .fields import load_study
|
|
1045
|
+
from .lattice_grade import grade_lattice
|
|
1046
|
+
|
|
1047
|
+
doc = _load(args.graph)
|
|
1048
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
1049
|
+
ref = args.study or (doc.get("tune") or {}).get("study")
|
|
1050
|
+
if not ref:
|
|
1051
|
+
print(json.dumps({"ok": False, "error": "give --study (or a tune.study in the graph)"}))
|
|
1052
|
+
return 2
|
|
1053
|
+
out_dir = os.path.abspath(args.out if os.path.isabs(args.out) else os.path.join(base, args.out))
|
|
1054
|
+
if os.path.commonpath([os.path.realpath(base), os.path.realpath(out_dir)]) != os.path.realpath(base):
|
|
1055
|
+
print(json.dumps({"ok": False, "error": "--out must be inside the graph's folder (its density grid is "
|
|
1056
|
+
"read from there)"}))
|
|
1057
|
+
return 2
|
|
1058
|
+
orient = args.orient
|
|
1059
|
+
if orient and orient.isdigit():
|
|
1060
|
+
orient = int(orient)
|
|
1061
|
+
try:
|
|
1062
|
+
study = load_study(ref, base, doc.get("material"))
|
|
1063
|
+
out = grade_lattice(doc, study, target_sf=args.target_sf, wall_min=args.wall_min, wall_max=args.wall_max,
|
|
1064
|
+
cells=args.cells, orient=orient, base_dir=base, progress=_progress, seam=args.seam,
|
|
1065
|
+
confirm=args.confirm)
|
|
1066
|
+
except ValueError as exc:
|
|
1067
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
1068
|
+
return 2
|
|
1069
|
+
if out["best"] is None:
|
|
1070
|
+
print(json.dumps({"ok": False, "error": "no graded design met the target: raise --wall-max or lower "
|
|
1071
|
+
"--target-sf", "history": out["history"]}))
|
|
1072
|
+
return 1
|
|
1073
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1074
|
+
npz = os.path.join(out_dir, "density.npz")
|
|
1075
|
+
d = out["density"]
|
|
1076
|
+
np.savez(npz, origin=np.asarray(d["origin"]), pitch=d["pitch"], values=d["values"])
|
|
1077
|
+
g = out["doc"]
|
|
1078
|
+
g["nodes"][out["densityNode"]] = {"op": "grid", "path": os.path.relpath(npz, base)}
|
|
1079
|
+
path = os.path.join(out_dir, "graded.json")
|
|
1080
|
+
with open(path, "w") as fh:
|
|
1081
|
+
json.dump(g, fh, indent=1)
|
|
1082
|
+
summary = {"ok": True, "best": out["best"], "target": out["target"], "densityLimits": out["densityLimits"],
|
|
1083
|
+
"graded": path, "density": npz, "seconds": out["seconds"], "guard": out.get("guard"),
|
|
1084
|
+
"next": f"loadpath field {path} --base-dir {os.path.relpath(base)}"}
|
|
1085
|
+
_write_report(out_dir, summary)
|
|
1086
|
+
return 0
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _cores(args):
|
|
1090
|
+
from .cores import choose_core
|
|
1091
|
+
|
|
1092
|
+
spec = _load(args.spec)
|
|
1093
|
+
if args.process:
|
|
1094
|
+
spec["processes"] = args.process.split(",")
|
|
1095
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.spec))
|
|
1096
|
+
try:
|
|
1097
|
+
rep = choose_core(spec, base, args.out, progress=_progress)
|
|
1098
|
+
except (ValueError, KeyError) as exc:
|
|
1099
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
1100
|
+
return 2
|
|
1101
|
+
_write_report(args.out, {"ok": any(rep["winners"].values()), **rep})
|
|
1102
|
+
return 0 if all(rep["winners"].values()) else 1
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _edit(args):
|
|
1106
|
+
from .schema import lint
|
|
1107
|
+
|
|
1108
|
+
doc = _load(args.graph)
|
|
1109
|
+
base = args.base_dir or os.path.dirname(os.path.abspath(args.graph))
|
|
1110
|
+
if args.html:
|
|
1111
|
+
from .editor import field_editor
|
|
1112
|
+
|
|
1113
|
+
issues = lint(doc, "field")
|
|
1114
|
+
if any(i["level"] == "error" for i in issues):
|
|
1115
|
+
print(json.dumps({"ok": False, "issues": issues}, indent=2))
|
|
1116
|
+
return 2
|
|
1117
|
+
field_editor(doc, args.html, base, progress=_progress)
|
|
1118
|
+
print(json.dumps({"ok": True, "html": args.html}))
|
|
1119
|
+
return 0
|
|
1120
|
+
from .editor import serve_editor
|
|
1121
|
+
|
|
1122
|
+
serve_editor(args.graph, host=args.host, port=args.port, out_dir=args.out, base_dir=base,
|
|
1123
|
+
open_browser=not args.no_open)
|
|
1124
|
+
return 0
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _buildsim(args):
|
|
1128
|
+
"""Simulate printing a mesh (buildsim.py): distortion, residual stress,
|
|
1129
|
+
recoater lift; with --compensate, the pre-distorted mesh to print."""
|
|
1130
|
+
import trimesh
|
|
1131
|
+
|
|
1132
|
+
from . import quick
|
|
1133
|
+
from .buildsim import BuildSetup, compensate_mesh, deviation, printed_shape, refine_mesh, simulate_part
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
setup = BuildSetup(process=args.process, material=args.material, plate=args.plate,
|
|
1137
|
+
recoater_gap=args.recoater_gap, scale=args.scale)
|
|
1138
|
+
mesh = trimesh.load(args.part, force="mesh")
|
|
1139
|
+
nominal = trimesh.load(args.nominal, force="mesh") if args.nominal else mesh
|
|
1140
|
+
cells = quick.buildsim_cells(args.cells)
|
|
1141
|
+
pitch = args.pitch
|
|
1142
|
+
if pitch is None or quick.on():
|
|
1143
|
+
pitch = max(pitch or 0.0, float((abs(mesh.volume) / cells) ** (1 / 3)))
|
|
1144
|
+
stem = os.path.splitext(os.path.basename(args.part))[0]
|
|
1145
|
+
os.makedirs(args.out, exist_ok=True)
|
|
1146
|
+
res = simulate_part(mesh, setup, pitch=pitch, build=args.build)
|
|
1147
|
+
fine = refine_mesh(mesh, pitch)
|
|
1148
|
+
printed = printed_shape(fine, res, args.build)
|
|
1149
|
+
dev, aligned = deviation(printed, nominal)
|
|
1150
|
+
aligned.export(os.path.join(args.out, f"{stem}-printed.stl"))
|
|
1151
|
+
rep = {k: v for k, v in res.report.items() if k != "rotation"}
|
|
1152
|
+
summary = {"ok": True, "part": args.part, "nominal": args.nominal or args.part, "build": args.build,
|
|
1153
|
+
**rep, "deviation": dev, "printed": os.path.join(args.out, f"{stem}-printed.stl")}
|
|
1154
|
+
if args.compensate:
|
|
1155
|
+
comp, crep = compensate_mesh(mesh, setup, pitch=pitch, build=args.build, iterations=args.compensate,
|
|
1156
|
+
first=res)
|
|
1157
|
+
path = os.path.join(args.out, f"{stem}-compensated.stl")
|
|
1158
|
+
comp.export(path)
|
|
1159
|
+
summary["compensation"] = {**crep, "stl": path}
|
|
1160
|
+
except (ValueError, KeyError) as exc:
|
|
1161
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
1162
|
+
return 2
|
|
1163
|
+
_write_report(args.out, summary)
|
|
1164
|
+
return 0
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _orient(args):
|
|
1168
|
+
import trimesh
|
|
1169
|
+
|
|
1170
|
+
from .orient import rank_orientations
|
|
1171
|
+
from .spec import study_from_dict
|
|
1172
|
+
|
|
1173
|
+
mesh = trimesh.load(args.mesh, force="mesh")
|
|
1174
|
+
study = None
|
|
1175
|
+
if args.study:
|
|
1176
|
+
with open(args.study) as fh:
|
|
1177
|
+
study = study_from_dict(json.load(fh), base_dir=os.path.dirname(os.path.abspath(args.study)))
|
|
1178
|
+
rows = rank_orientations(mesh, study, process=args.process)
|
|
1179
|
+
out = [{k: v for k, v in r.items() if k != "build"} for r in rows]
|
|
1180
|
+
for r in out:
|
|
1181
|
+
r["print"] = {k: v for k, v in r["print"].items() if k != "bridgeList"}
|
|
1182
|
+
if args.threemf:
|
|
1183
|
+
from .threemf import write_3mf
|
|
1184
|
+
write_3mf(args.threemf, mesh, rows[0]["build"], name=os.path.splitext(os.path.basename(args.mesh))[0],
|
|
1185
|
+
material=study.material if study else None, process=study.process if study else args.process)
|
|
1186
|
+
print(json.dumps(_jsonable({"best": out[0]["direction"], "orientations": out,
|
|
1187
|
+
**({"3mf": args.threemf} if args.threemf else {})}), indent=2))
|
|
1188
|
+
return 0
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _main():
|
|
1192
|
+
# `loadpath check study.json | head` closes the pipe early: exit quietly
|
|
1193
|
+
try:
|
|
1194
|
+
return main()
|
|
1195
|
+
except BrokenPipeError:
|
|
1196
|
+
import os
|
|
1197
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
1198
|
+
return 0
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
if __name__ == "__main__":
|
|
1202
|
+
sys.exit(_main())
|