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/editor.py ADDED
@@ -0,0 +1,610 @@
1
+ """
2
+ Interactive field-graph editor: edit a `loadpath field` graph in the
3
+ browser with a live preview, then mesh it for real.
4
+
5
+ python -m loadpath edit part.json # local editor at http://127.0.0.1:8765
6
+ python -m loadpath edit part.json --html editor.html # one self-contained page, no server
7
+
8
+ The page compiles the graph to a GLSL signed-distance function and
9
+ raymarches it on the GPU, so the part redraws as a slider moves: every
10
+ number in the graph is a shader uniform, and only a change of structure
11
+ (an op, a wire, an expression) recompiles, which takes milliseconds. Nodes
12
+ the GPU cannot evaluate itself are baked to 3D textures here: a `mesh`
13
+ node's signed distance, a `result` node's stress (running its study when it
14
+ has one), a `grid`, and the coordinate warp of a lattice whose cell is a
15
+ field (the map the mesher uses, so the preview's cells are the part's). The
16
+ preview is a preview: baked nodes are as fine as their texture, and while a
17
+ bake is out of date the page draws the last one (a warp that is missing is
18
+ stood in for by cross-fading fixed periods). `Run` meshes the graph with
19
+ run_field exactly as `loadpath field` does, and shows the mesh and its
20
+ report beside the preview.
21
+
22
+ The graph view, sliders and inspector edit the same JSON the CLI reads.
23
+ Inline nodes are given names (`owner-key`) on load, which is the same
24
+ graph; node positions go in the graph's `editor` key, which nothing else
25
+ reads. Served, the editor saves back to the graph's file and runs jobs into
26
+ `--out`; as a standalone page it downloads the JSON and gives the command.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import json
32
+ import os
33
+ import threading
34
+ import time
35
+ import uuid
36
+ from collections import OrderedDict
37
+
38
+ import numpy as np
39
+
40
+ from .viewer import _clean, _esc, _mesh
41
+
42
+ __all__ = ["BAKED_OPS", "bake_node", "bake_warp", "editor_payload", "field_editor", "serve_editor"]
43
+
44
+ _TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.html")
45
+
46
+ # ops the browser cannot evaluate: sampled here onto a grid, drawn from a 3D texture
47
+ BAKED_OPS = ("mesh", "result", "grid", "image")
48
+ _BAKE_CELLS = {"mesh": 96, "result": 64, "grid": 64, "image": 64}
49
+
50
+
51
+ def _closure(doc, name):
52
+ """The node specs a bake of `name` depends on: itself, and for a result
53
+ analyzed on one of the graph's own nodes, everything under that node."""
54
+ nodes = doc["nodes"]
55
+ spec = nodes[name]
56
+ out = {name: spec}
57
+ if spec.get("op") == "result" and spec.get("part") in nodes:
58
+ todo = [spec["part"]]
59
+ while todo:
60
+ n = todo.pop()
61
+ if n in out or n not in nodes:
62
+ continue
63
+ out[n] = nodes[n]
64
+ todo += _refs(nodes[n])
65
+ return out
66
+
67
+
68
+ def _warp_closure(doc, name):
69
+ """What a lattice's cell warp depends on: where it is, its cell field and
70
+ everything under that (not its wall: that is applied after the warp)."""
71
+ nodes = doc["nodes"]
72
+ spec = nodes[name]
73
+ out = {"#lattice": {k: spec[k] for k in ("cell", "origin", "bounds", "grading") if k in spec}}
74
+ todo = [spec["cell"]] if isinstance(spec.get("cell"), str) else []
75
+ while todo:
76
+ n = todo.pop()
77
+ if n in out or n not in nodes:
78
+ continue
79
+ out[n] = nodes[n]
80
+ todo += _refs(nodes[n])
81
+ return out
82
+
83
+
84
+ def wants_warp(spec):
85
+ """A lattice whose cell is a field and is graded by the warp (the default)."""
86
+ return (isinstance(spec, dict) and spec.get("op") == "lattice" and isinstance(spec.get("cell"), str)
87
+ and spec.get("grading", "warp") == "warp" and not spec.get("frame"))
88
+
89
+
90
+ def bake_warp(doc, name, base_dir=None, graph=None, max_cells=120_000):
91
+ """The coordinate warp a graded lattice is drawn through: fields.py's
92
+ _CellWarp, the map the mesher uses (so the preview's walls are where
93
+ the part's will be), every few nodes of it, to about `max_cells`.
94
+ Shipped as u - q / c0 (c0 the cell at the map's anchor) in half floats,
95
+ so a trilinear lookup plus q / c0 gives u back."""
96
+ from .fields import FieldGraph
97
+
98
+ t0 = time.time()
99
+ g = graph or FieldGraph(doc, base_dir=base_dir)
100
+ nd = g.nodes[name]
101
+ if not wants_warp(nd.spec):
102
+ raise ValueError(f"node {name!r} is not a lattice graded by a warp")
103
+ # the mesher's own map (solved once, shared with the run's evaluation), every `step`-th node of it:
104
+ # the map is smooth, so the trilinear lookup between them loses little
105
+ w = g._resource(nd, lambda: g._warp(nd))
106
+ cell_fn = g._warp_domain(nd)[0]
107
+ step = 1
108
+ while np.prod([(d - 1) // step + 1 for d in w.dims]) > max_cells:
109
+ step += 1
110
+ u = np.stack(w.u, axis=-1)[::step, ::step, ::step] # nx, ny, nz, 3
111
+ lo, h = np.asarray(w.lo, float), float(w.h) * step
112
+ dims = np.asarray(u.shape[:3])
113
+ c0 = float(cell_fn(w.anchor()[None])[0]) # u = q / c0 there
114
+ axes = [lo[i] + np.arange(dims[i]) * h for i in range(3)]
115
+ Q = np.stack(np.meshgrid(*axes, indexing="ij"), -1)
116
+ r = (u - Q / c0).astype(np.float16)
117
+ rgba = np.zeros(tuple(dims) + (4,), np.float16)
118
+ rgba[..., :3] = r
119
+ data = np.ascontiguousarray(rgba.transpose(2, 1, 0, 3))
120
+ return {"node": name, "op": "warp", "lo": lo.tolist(), "hi": (lo + h * (dims - 1)).tolist(), "h": h,
121
+ "dims": [int(x) for x in dims], "c0": c0, "data": base64.b64encode(data.tobytes()).decode("ascii"),
122
+ "spec": _warp_closure(doc, name), "range": [float(r.min()), float(r.max())],
123
+ "seconds": round(time.time() - t0, 1)}
124
+
125
+
126
+ def _refs(spec):
127
+ """Node names a (hoisted) spec refers to, expr names included."""
128
+ out = []
129
+ for k, v in spec.items():
130
+ if k in ("op", "description", "expr", "shape", "frame", "rotate", "bounds", "path", "study"):
131
+ continue
132
+ if k == "vars" and isinstance(v, dict):
133
+ out += [x for x in v.values() if isinstance(x, str)]
134
+ elif k == "inputs" and isinstance(v, list):
135
+ out += [x for x in v if isinstance(x, str)]
136
+ elif isinstance(v, str) and k not in ("type", "form", "grading", "side", "curve", "quantity", "case",
137
+ "source", "axis", "scale"):
138
+ out.append(v)
139
+ if spec.get("op") == "expr" and isinstance(spec.get("expr"), str):
140
+ from .fields import _compile_expr
141
+
142
+ try:
143
+ out += sorted(_compile_expr(spec["expr"])[0] - {"x", "y", "z"} - set(spec.get("vars") or {}))
144
+ except ValueError:
145
+ pass
146
+ return out
147
+
148
+
149
+ def _box_grid(lo, hi, cells):
150
+ lo, hi = np.asarray(lo, float), np.asarray(hi, float)
151
+ ext = np.maximum(hi - lo, 1e-6)
152
+ h = ext.max() / (cells - 1)
153
+ dims = np.maximum(np.ceil(ext / h).astype(int) + 1, 2)
154
+ while np.prod(dims) > 1_500_000:
155
+ h *= 1.1
156
+ dims = np.maximum(np.ceil(ext / h).astype(int) + 1, 2)
157
+ hi = lo + (dims - 1) * h
158
+ axes = [np.linspace(lo[i], hi[i], dims[i]) for i in range(3)]
159
+ return lo, hi, dims, axes
160
+
161
+
162
+ def bake_node(doc, name, base_dir=None, graph=None, cells=None, progress=None):
163
+ """Sample one `mesh`, `result`, `grid` or `image` node on a grid for the editor's
164
+ GPU preview: {"node", "lo", "hi", "dims" [nx, ny, nz], "data" (float16,
165
+ x fastest, base64), "spec" (what was baked, so the page can tell when it
166
+ goes stale), "seconds"}. A result or grid whose samples already lie on
167
+ a regular grid (an .npz grid, a study's FE nodes) ships that grid; one
168
+ from scattered samples is resampled over the graph's output (the
169
+ nearest value carries on outside). A mesh is baked over its own box
170
+ with a margin; the page adds the distance to that box outside it."""
171
+ from .fields import FieldGraph
172
+
173
+ t0 = time.time()
174
+ g = graph or FieldGraph(doc, base_dir=base_dir)
175
+ nd = g.nodes[name]
176
+ if nd.op not in BAKED_OPS:
177
+ raise ValueError(f"node {name!r} is a {nd.op}: only {', '.join(BAKED_OPS)} nodes are baked")
178
+ if nd.op != "mesh":
179
+ smp = g._resource(nd, lambda: g._samples(nd))
180
+ if getattr(smp, "tree", None) is None and smp.grid.size <= 2_500_000 and min(smp.grid.shape) >= 2:
181
+ # samples on a regular grid (an .npz grid, verify's FE nodes): ship them as they are, and the
182
+ # GPU's trilinear filter is the same interpolation (nearest value outside) as fields.py's
183
+ grid = np.clip(np.nan_to_num(smp.grid, nan=0.0), -6e4, 6e4).astype(np.float16)
184
+ dims = np.asarray(grid.shape)
185
+ lo = np.asarray(smp.origin, float)
186
+ hi = lo + smp.pitch * (dims - 1)
187
+ return _bake_out(doc, g, name, nd, lo, hi, dims, grid, t0)
188
+ if nd.op == "mesh":
189
+ lo, hi = g.bounds(name)
190
+ pad = max(0.08 * float(np.max(hi - lo)), 3.0)
191
+ else:
192
+ b = g.bounds() if "bounds" not in doc else (doc["bounds"]["min"], doc["bounds"]["max"])
193
+ if b is None:
194
+ smp = g._resource(nd, lambda: g._samples(nd))
195
+ if getattr(smp, "tree", None) is not None:
196
+ pts = smp.tree.data
197
+ b = (pts.min(axis=0), pts.max(axis=0))
198
+ else:
199
+ b = (smp.origin, smp.origin + smp.pitch * (np.asarray(smp.grid.shape) - 1))
200
+ lo, hi = np.asarray(b[0], float), np.asarray(b[1], float)
201
+ pad = 0.06 * float(np.max(hi - lo))
202
+ lo, hi, dims, axes = _box_grid(np.asarray(lo) - pad, np.asarray(hi) + pad, cells or _BAKE_CELLS[nd.op])
203
+ if progress:
204
+ progress({"stage": "bake", "node": name, "cells": int(np.prod(dims))})
205
+ G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
206
+ vals = np.concatenate([g.evaluate(G[i:i + 250_000], name) for i in range(0, len(G), 250_000)])
207
+ vals = np.nan_to_num(vals, nan=0.0, posinf=6e4, neginf=-6e4)
208
+ grid = np.clip(vals, -6e4, 6e4).reshape(tuple(dims)).astype(np.float16)
209
+ return _bake_out(doc, g, name, nd, lo, hi, dims, grid, t0)
210
+
211
+
212
+ def _bake_out(doc, g, name, nd, lo, hi, dims, grid, t0):
213
+ data = np.ascontiguousarray(grid.transpose(2, 1, 0)) # z, y, x: x fastest, as a 3D texture wants
214
+ vals = grid.astype(float)
215
+ out = {"node": name, "op": nd.op, "lo": np.asarray(lo, float).tolist(), "hi": np.asarray(hi, float).tolist(),
216
+ "dims": [int(x) for x in dims], "data": base64.b64encode(data.tobytes()).decode("ascii"),
217
+ "spec": _closure(doc, name), "range": [float(vals.min()), float(vals.max())],
218
+ "seconds": round(time.time() - t0, 1)}
219
+ if name in g.analyses:
220
+ out["analysis"] = _clean({k: v for k, v in g.analyses[name].items() if isinstance(v, (int, float, str))})
221
+ return out
222
+
223
+
224
+ def _density_tables():
225
+ """wall / cell against relative density 0.05 .. 0.95 for each sheet
226
+ (lattice.py's homogenized tables), for `density`-driven lattices."""
227
+ from .lattice import wall_for_density
228
+
229
+ rho = np.linspace(0.05, 0.95, 19)
230
+ return {k: [round(float(x), 5) for x in wall_for_density(k, rho, 1.0)] for k in ("gyroid", "diamond", "schwarz-p")}
231
+
232
+
233
+ def _densities():
234
+ """g/cm^3 of every named material and alias, for the page's live mass."""
235
+ from .materials import _ALIASES, MATERIALS
236
+
237
+ out = {k: m.density for k, m in MATERIALS.items()}
238
+ out.update({a: MATERIALS[k].density for a, k in _ALIASES.items()})
239
+ return out
240
+
241
+
242
+ def _library():
243
+ """The material library for the page's picker: each card's numbers and
244
+ where they came from, plus calibrated cards by name."""
245
+ from .calibration import list_cards
246
+ from .material_library import SOURCES, card_names
247
+ from .material_library import card as get_card # noqa: I001 - `card` would shadow the loop's card
248
+ from .materials import _ALIASES, material_from_spec
249
+
250
+ out = []
251
+ for name in card_names() + [c for c in list_cards() if c not in card_names()]:
252
+ try:
253
+ m = material_from_spec(name)
254
+ except ValueError:
255
+ continue
256
+ c = get_card(name)
257
+ src = SOURCES[c["values"]["strength"][1]] if c else None
258
+ out.append({"name": name, "title": m.note or name, "E": m.E, "strength": m.strength,
259
+ "strengthZ": m.strength_z, "density": m.density, "processes": list(m.processes),
260
+ "calibrated": name in list_cards(), "aliases": sorted(a for a, k in _ALIASES.items() if k == name),
261
+ "source": src.title if src else "calibrated on your printer", "url": src.url if src else ""})
262
+ return out
263
+
264
+
265
+ def editor_payload(doc, base_dir=None, bake=True, server=False, graph_path=None, progress=None):
266
+ """The data the page starts from: the graph, the bakes it needs (all of
267
+ them for a standalone page; a served page asks for its own), and the
268
+ lattice density tables."""
269
+ from .schema import lint
270
+ from .spec import PROCESSES
271
+
272
+ doc = json.loads(json.dumps(doc))
273
+ bakes, errors = {}, []
274
+ if bake:
275
+ from .fields import FieldGraph, expand_groups
276
+
277
+ try:
278
+ g = FieldGraph(doc, base_dir=base_dir)
279
+ except ValueError as exc:
280
+ g, errors = None, [str(exc)]
281
+ if g is not None:
282
+ full = expand_groups(doc)
283
+ for name, spec in full["nodes"].items():
284
+ try:
285
+ if isinstance(spec, dict) and spec.get("op") in BAKED_OPS:
286
+ bakes[name] = bake_node(full, name, base_dir, graph=g, progress=progress)
287
+ elif wants_warp(spec):
288
+ bakes[f"{name}#warp"] = bake_warp(full, name, base_dir, graph=g)
289
+ except Exception as exc: # noqa: BLE001 - the page says which node could not be baked
290
+ errors.append(f"{name}: {exc}")
291
+ return {"version": 1, "doc": doc, "bakes": bakes, "errors": errors, "issues": lint(doc, "field"),
292
+ "tables": _density_tables(), "densities": _densities(), "materials": _library(),
293
+ "minWalls": {k: v["minFeature"] for k, v in PROCESSES.items()}, "server": bool(server),
294
+ "graphPath": os.path.basename(graph_path) if graph_path else None,
295
+ "created": time.strftime("%Y-%m-%d %H:%M")}
296
+
297
+
298
+ def _page(payload, title, standalone=True):
299
+ with open(_TEMPLATE) as fh:
300
+ page = fh.read()
301
+ data = json.dumps(_clean(payload), separators=(",", ":")).replace("</", "<\\/")
302
+ page = page.replace("/*__LOADPATH_DATA__*/null", data, 1)
303
+ page = page.replace("__LOADPATH_TITLE__", _esc(title), 1)
304
+ if standalone:
305
+ page = ("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
306
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n"
307
+ + page.replace("<!--/head-->", "</head>\n<body>", 1) + "\n</body>\n</html>\n")
308
+ else:
309
+ page = page.replace("<!--/head-->", "", 1)
310
+ return page
311
+
312
+
313
+ def field_editor(doc, path=None, base_dir=None, title=None, standalone=True, progress=None):
314
+ """A self-contained editor page for a field graph (every bake inside;
315
+ a result node with a study runs its analysis first). Returns the HTML,
316
+ or writes it to `path` and returns the path."""
317
+ if isinstance(doc, str):
318
+ doc = json.loads(doc)
319
+ payload = editor_payload(doc, base_dir, bake=True, progress=progress)
320
+ html = _page(payload, title or f"{doc.get('name', 'field')} · field editor", standalone)
321
+ if path is None:
322
+ return html
323
+ with open(path, "w") as fh:
324
+ fh.write(html)
325
+ return path
326
+
327
+
328
+ # ---- the local server -----------------------------------------------------------
329
+
330
+ class _Resources:
331
+ """What the server has built (an analysis behind a result, a mesh's
332
+ distance field, a loaded grid), kept between bakes and runs and keyed by
333
+ the node's closure, so an edit elsewhere in the graph reuses them: the
334
+ stress study runs once, not on every bake that touches the output's box."""
335
+
336
+ def __init__(self, cap=16):
337
+ self.items, self.cap = OrderedDict(), cap
338
+ self.lock = threading.Lock()
339
+
340
+ @staticmethod
341
+ def _keys(doc):
342
+ # a result node's study is checked in the graph's material, so that
343
+ # is part of its key
344
+ return {name: json.dumps([_closure(doc, name), doc.get("material") if spec.get("op") == "result" else None],
345
+ sort_keys=True)
346
+ for name, spec in doc["nodes"].items() if isinstance(spec, dict) and spec.get("op") in BAKED_OPS}
347
+
348
+ def prebuilt(self, doc):
349
+ with self.lock:
350
+ return {name: self.items[k] for name, k in self._keys(doc).items() if k in self.items}
351
+
352
+ def graph(self, doc, base_dir):
353
+ from .fields import FieldGraph
354
+
355
+ return FieldGraph(doc, base_dir=base_dir).seed(self.prebuilt(doc))
356
+
357
+ def keep(self, g, doc):
358
+ with self.lock:
359
+ for name, k in self._keys(doc).items():
360
+ if name in g._built:
361
+ self.items[k] = (g._built[name], g.analyses.get(name))
362
+ self.items.move_to_end(k)
363
+ while len(self.items) > self.cap:
364
+ self.items.popitem(last=False)
365
+
366
+
367
+ class _Jobs:
368
+ """Runs one field job at a time in a thread and keeps what it said."""
369
+
370
+ def __init__(self, out_dir, base_dir, resources=None):
371
+ self.out_dir, self.base_dir = out_dir, base_dir
372
+ self.resources = resources or _Resources()
373
+ self.jobs = {}
374
+ self.lock = threading.Lock()
375
+
376
+ def start(self, doc, node=None, tune=False):
377
+ jid = uuid.uuid4().hex[:8]
378
+ job = {"id": jid, "state": "running", "events": [], "started": time.time(), "node": node,
379
+ "kind": "tune" if tune else "run"}
380
+ self.jobs[jid] = job
381
+ threading.Thread(target=self._tune if tune else self._run, args=(job, doc, node), daemon=True).start()
382
+ return job
383
+
384
+ def _tune(self, job, doc, _node=None):
385
+ from .tune import tune_field
386
+
387
+ def progress(ev, _f=None):
388
+ job["events"].append(_clean({k: v for k, v in ev.items() if isinstance(v, (int, float, str, bool))}))
389
+ del job["events"][:-400]
390
+
391
+ with self.lock:
392
+ try:
393
+ out = tune_field(doc, self.base_dir, os.path.join(self.out_dir, job["id"]), progress=progress)
394
+ best = out["best"]
395
+ assign = best.pop("assign") if best is not None else []
396
+ job.update(state="done", best=_clean(best), start=_clean(out["start"]), assign=assign,
397
+ history=_clean(out["history"]), limits=out["limits"],
398
+ front=_clean(out["front"]), sweep=_clean(out.get("sweep")),
399
+ files={"tuned": f"/out/{job['id']}/tuned.json", "tune": f"/out/{job['id']}/tune.json"})
400
+ except Exception as exc: # noqa: BLE001 - reported to the page
401
+ job.update(state="failed", error=str(exc))
402
+ job["seconds"] = round(time.time() - job["started"], 1)
403
+
404
+ def _run(self, job, doc, node=None):
405
+ from .fields import expand_groups, run_field
406
+ from .viewer import Viewer
407
+
408
+ def progress(ev, _f=None):
409
+ job["events"].append(_clean({k: v for k, v in ev.items() if isinstance(v, (int, float, str, bool))}))
410
+ del job["events"][:-200]
411
+
412
+ with self.lock:
413
+ try:
414
+ out = os.path.join(self.out_dir, job["id"])
415
+ mesh, rep = run_field(doc, out, self.base_dir, progress=progress, render=False,
416
+ prebuilt=self.resources.prebuilt(expand_groups(doc)), node=node)
417
+ with open(os.path.join(out, "report.json"), "w") as fh:
418
+ json.dump(_clean({"ok": True, **rep}), fh, indent=2)
419
+ stats = {"mass": f"{rep['mass']:.1f} g" if "mass" in rep else None,
420
+ "volume": f"{rep['volume'] / 1000:.2f} cm³", "faces": rep["faces"]}
421
+ v = Viewer(rep["name"], "field graph · loadpath edit")
422
+ v.add_part(rep["name"], mesh, stats={k: x for k, x in stats.items() if x is not None},
423
+ max_faces=160_000)
424
+ v.save(os.path.join(out, "viewer.html"))
425
+ payload, _ = _mesh(mesh, 150_000)
426
+ job.update(state="done", report=_clean(rep), mesh=payload,
427
+ files={"stl": f"/out/{job['id']}/{rep['name']}.stl",
428
+ "viewer": f"/out/{job['id']}/viewer.html",
429
+ "report": f"/out/{job['id']}/report.json"})
430
+ except Exception as exc: # noqa: BLE001 - reported to the page
431
+ job.update(state="failed", error=str(exc))
432
+ job["seconds"] = round(time.time() - job["started"], 1)
433
+
434
+
435
+ def serve_editor(graph_path, host="127.0.0.1", port=8765, out_dir=None, base_dir=None, open_browser=True,
436
+ ready=None):
437
+ """Serve the editor for the graph at `graph_path` until interrupted.
438
+ POST /api/bake, /api/lint, /api/save, /api/run ({doc, node?}), /api/tune, /api/preview
439
+ ({doc, cells?}: the live coarse stress, preview.py); GET /api/job?id=,
440
+ /out/<job>/<file>. Binds to localhost unless told otherwise: it reads
441
+ and writes files in the graph's folder. `ready(server)` is called once
442
+ it listens (tests use port 0 and read server.server_port)."""
443
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
444
+ from urllib.parse import parse_qs, urlparse
445
+
446
+ from .fields import FieldGraph, expand_groups, node_doc
447
+ from .schema import lint
448
+
449
+ graph_path = os.path.abspath(graph_path)
450
+ base_dir = base_dir or os.path.dirname(graph_path)
451
+ out_dir = os.path.abspath(out_dir or os.path.join(base_dir, "loadpath-edit"))
452
+ resources = _Resources()
453
+ jobs = _Jobs(out_dir, base_dir, resources)
454
+ bake_cache, bake_lock = OrderedDict(), threading.Lock()
455
+ from .preview import PreviewState
456
+
457
+ # the live stress preview: one at a time (a slider's newest value is
458
+ # what the page sends next), its grid and last solve carried over
459
+ preview = {"state": PreviewState(), "lock": threading.Lock(), "studies": {}}
460
+
461
+ def load():
462
+ with open(graph_path) as fh:
463
+ return json.load(fh)
464
+
465
+ class Handler(BaseHTTPRequestHandler):
466
+ def log_message(self, *a): # quiet
467
+ pass
468
+
469
+ def _send(self, code, body, ctype="application/json"):
470
+ data = body if isinstance(body, bytes) else (json.dumps(_clean(body)) if ctype == "application/json"
471
+ else body).encode()
472
+ self.send_response(code)
473
+ self.send_header("Content-Type", ctype)
474
+ self.send_header("Content-Length", str(len(data)))
475
+ self.send_header("Cache-Control", "no-store")
476
+ self.end_headers()
477
+ self.wfile.write(data)
478
+
479
+ def _body(self):
480
+ n = int(self.headers.get("Content-Length") or 0)
481
+ return json.loads(self.rfile.read(n) or b"{}")
482
+
483
+ def do_GET(self): # noqa: N802
484
+ u = urlparse(self.path)
485
+ if u.path in ("/", "/index.html"):
486
+ try:
487
+ doc = load()
488
+ except (OSError, json.JSONDecodeError) as exc:
489
+ return self._send(500, f"cannot read {graph_path}: {exc}", "text/plain")
490
+ payload = editor_payload(doc, base_dir, bake=False, server=True, graph_path=graph_path)
491
+ title = f"{doc.get('name', 'field')} · field editor"
492
+ return self._send(200, _page(payload, title), "text/html; charset=utf-8")
493
+ if u.path == "/api/job":
494
+ jid = (parse_qs(u.query).get("id") or [""])[0]
495
+ job = jobs.jobs.get(jid)
496
+ if job is None:
497
+ return self._send(404, {"error": f"no job {jid!r}"})
498
+ since = int((parse_qs(u.query).get("since") or ["0"])[0])
499
+ out = {k: v for k, v in job.items() if k != "events"}
500
+ out["events"] = job["events"][since:]
501
+ out["eventCount"] = len(job["events"])
502
+ return self._send(200, out)
503
+ if u.path.startswith("/out/"):
504
+ full = os.path.realpath(os.path.join(out_dir, u.path[5:]))
505
+ if os.path.commonpath([os.path.realpath(out_dir), full]) != os.path.realpath(out_dir) \
506
+ or not os.path.isfile(full):
507
+ return self._send(404, {"error": "not found"})
508
+ ctype = {".html": "text/html; charset=utf-8", ".json": "application/json",
509
+ ".stl": "model/stl", ".png": "image/png"}.get(os.path.splitext(full)[1],
510
+ "application/octet-stream")
511
+ with open(full, "rb") as fh:
512
+ return self._send(200, fh.read(), ctype)
513
+ return self._send(404, {"error": "not found"})
514
+
515
+ def do_POST(self): # noqa: N802
516
+ u = urlparse(self.path)
517
+ try:
518
+ body = self._body()
519
+ except json.JSONDecodeError as exc:
520
+ return self._send(400, {"error": f"not JSON: {exc}"})
521
+ doc = body.get("doc")
522
+ if u.path == "/api/lint":
523
+ return self._send(200, {"issues": lint(doc, "field")})
524
+ if u.path == "/api/save":
525
+ issues = lint(doc, "field")
526
+ if any(i["level"] == "error" for i in issues):
527
+ return self._send(400, {"issues": issues, "error": "not saved: the graph has errors"})
528
+ with open(graph_path, "w") as fh:
529
+ json.dump(doc, fh, indent=2)
530
+ fh.write("\n")
531
+ return self._send(200, {"saved": graph_path, "issues": issues})
532
+ if u.path == "/api/bake":
533
+ name = body.get("node")
534
+ warp = body.get("kind") == "warp"
535
+ try:
536
+ doc = expand_groups(doc) # the page bakes nodes inside groups by their expanded names
537
+ spec = _warp_closure(doc, name) if warp else _closure(doc, name)
538
+ key = ("warp:" if warp else "") + json.dumps(spec, sort_keys=True)
539
+ with bake_lock:
540
+ if key not in bake_cache:
541
+ g = resources.graph(doc, base_dir)
542
+ bake_cache[key] = (bake_warp if warp else bake_node)(doc, name, base_dir, graph=g)
543
+ resources.keep(g, doc)
544
+ while len(bake_cache) > 48: # a slider dragged through many values
545
+ bake_cache.popitem(last=False)
546
+ bake_cache.move_to_end(key)
547
+ out = dict(bake_cache[key], spec=spec)
548
+ return self._send(200, out)
549
+ except Exception as exc: # noqa: BLE001
550
+ return self._send(400, {"error": str(exc), "node": name})
551
+ if u.path == "/api/preview":
552
+ from .fields import load_study
553
+ from .preview import PREVIEW_CELLS, study_ref, stress_preview
554
+
555
+ try:
556
+ doc = expand_groups(doc)
557
+ ref = study_ref(doc)
558
+ if ref is None:
559
+ raise ValueError("the stress preview needs a study: add one in the Tune panel "
560
+ "(or a result node)")
561
+ skey = json.dumps([ref, doc.get("material")])
562
+ with preview["lock"]:
563
+ if skey not in preview["studies"]:
564
+ preview["studies"] = {skey: load_study(ref, base_dir, doc.get("material"))}
565
+ g = resources.graph(doc, base_dir)
566
+ out = stress_preview(doc, base_dir, study=preview["studies"][skey], graph=g,
567
+ cells=int(body.get("cells") or PREVIEW_CELLS),
568
+ state=preview["state"])
569
+ resources.keep(g, doc)
570
+ return self._send(200, out)
571
+ except Exception as exc: # noqa: BLE001 - reported to the page
572
+ return self._send(400, {"error": str(exc)})
573
+ if u.path == "/api/tune":
574
+ issues = lint(doc, "field")
575
+ if any(i["level"] == "error" for i in issues):
576
+ return self._send(400, {"issues": issues, "error": "the graph has errors"})
577
+ if not isinstance(doc.get("tune"), dict) or not doc["tune"].get("params"):
578
+ return self._send(400, {"error": "add a study and at least one knob to tune"})
579
+ job = jobs.start(doc, tune=True)
580
+ return self._send(200, {"id": job["id"]})
581
+ if u.path == "/api/run":
582
+ issues = lint(doc, "field")
583
+ if any(i["level"] == "error" for i in issues):
584
+ return self._send(400, {"issues": issues, "error": "the graph has errors"})
585
+ node = body.get("node")
586
+ try:
587
+ FieldGraph(node_doc(doc, node) if node else doc, base_dir=base_dir)
588
+ except ValueError as exc:
589
+ return self._send(400, {"error": str(exc)})
590
+ job = jobs.start(doc, node)
591
+ return self._send(200, {"id": job["id"]})
592
+ return self._send(404, {"error": "not found"})
593
+
594
+ srv = ThreadingHTTPServer((host, port), Handler)
595
+ url = f"http://{host}:{srv.server_port}/"
596
+ if ready:
597
+ ready(srv)
598
+ else:
599
+ print(json.dumps({"editor": url, "graph": graph_path, "out": out_dir}), flush=True)
600
+ if open_browser:
601
+ import webbrowser
602
+
603
+ threading.Timer(0.3, lambda: webbrowser.open(url)).start()
604
+ try:
605
+ srv.serve_forever()
606
+ except KeyboardInterrupt:
607
+ pass
608
+ finally:
609
+ srv.server_close()
610
+ return url