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