loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/__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())