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.html ADDED
@@ -0,0 +1,3135 @@
1
+ <title>__LOADPATH_TITLE__</title>
2
+ <meta name="claude:viewport-fit-sides" content="cover">
3
+ <link rel="preconnect" href="https://fonts.googleapis.com">
4
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
5
+ <style>
6
+ /* Layout: inspector on the left; the live preview over the node graph on the right (a divider between them). Phones stack all three. */
7
+ :root {
8
+ --ground: #e7ebf0;
9
+ --panel: #fafbfc;
10
+ --ink: #17202b;
11
+ --muted: #5a6575;
12
+ --line: #d3d9e1;
13
+ --chip: #edf0f4;
14
+ --accent: #2c56c9;
15
+ --accent-ink: #ffffff;
16
+ --warn: #c2410c;
17
+ --ok: #15803d;
18
+ --solid: #2c56c9;
19
+ --scalar: #b7791f;
20
+ --graph: #eef1f5;
21
+ --dot: #d5dbe3;
22
+ --display: "IBM Plex Sans Condensed", "Arial Narrow", system-ui, sans-serif;
23
+ --body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
24
+ --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root:not([data-theme="light"]) {
28
+ --ground: #10151c; --panel: #171e27; --ink: #e3e8ef; --muted: #8d99aa; --line: #283242;
29
+ --chip: #1f2833; --accent: #86a2f7; --accent-ink: #0d1424; --warn: #fb923c; --ok: #4ade80;
30
+ --solid: #86a2f7; --scalar: #e0b25c; --graph: #131a22; --dot: #222c39; color-scheme: dark;
31
+ }
32
+ }
33
+ :root[data-theme="dark"] {
34
+ --ground: #10151c; --panel: #171e27; --ink: #e3e8ef; --muted: #8d99aa; --line: #283242;
35
+ --chip: #1f2833; --accent: #86a2f7; --accent-ink: #0d1424; --warn: #fb923c; --ok: #4ade80;
36
+ --solid: #86a2f7; --scalar: #e0b25c; --graph: #131a22; --dot: #222c39; color-scheme: dark;
37
+ }
38
+ [hidden] { display: none !important; }
39
+ html, body { height: 100%; }
40
+ body { margin: 0; background: var(--ground); color: var(--ink); font: 13px/1.45 var(--body); overflow: hidden; }
41
+ #app { position: relative; height: 100%; display: grid; grid-template-columns: 320px 1fr; }
42
+ #panel {
43
+ background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; min-width: 0;
44
+ padding-block: 16px 24px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: 16px;
45
+ display: flex; flex-direction: column; gap: 16px;
46
+ }
47
+ #right { position: relative; min-width: 0; display: grid; grid-template-rows: var(--split, 62%) 6px 1fr; }
48
+ #stage { position: relative; min-width: 0; overflow: hidden; }
49
+ #stage > canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; touch-action: none; }
50
+ #divider { background: var(--line); cursor: row-resize; touch-action: none; }
51
+ #graphwrap { position: relative; min-height: 0; background: var(--graph); overflow: hidden; }
52
+ header h1 { font: 600 20px/1.15 var(--display); margin: 0; letter-spacing: 0.01em; text-wrap: balance; }
53
+ header h1 input { font: inherit; color: inherit; background: transparent; border: 0; padding: 0; width: 100%; }
54
+ header p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
55
+ section { display: flex; flex-direction: column; gap: 8px; }
56
+ section > h2 { margin: 0; font: 500 10.5px/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted);
57
+ display: flex; justify-content: space-between; align-items: center; }
58
+ .row { display: flex; gap: 8px; align-items: center; }
59
+ .row.wrap { flex-wrap: wrap; }
60
+ select, button, input, textarea { font: inherit; color: inherit; }
61
+ select, input[type="text"], input[type="number"], textarea {
62
+ background: var(--chip); border: 1px solid var(--line); border-radius: 6px; padding: 5px 7px; min-width: 0;
63
+ }
64
+ select { width: 100%; }
65
+ input[type="number"] { width: 74px; font: 12px var(--mono); font-variant-numeric: tabular-nums; padding: 4px 6px; }
66
+ input[type="text"] { width: 100%; }
67
+ textarea { width: 100%; font: 12px/1.5 var(--mono); resize: vertical; box-sizing: border-box; }
68
+ .seg { display: flex; background: var(--chip); border: 1px solid var(--line); border-radius: 7px; padding: 2px; gap: 2px; }
69
+ .seg button {
70
+ flex: 1 1 0; border: 0; background: transparent; padding: 5px 4px; border-radius: 5px; cursor: pointer;
71
+ font-size: 12px; white-space: nowrap; color: var(--muted);
72
+ }
73
+ .seg button[aria-pressed="true"] { background: var(--panel); color: var(--ink); box-shadow: 0 0 0 1px var(--line); font-weight: 500; }
74
+ button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
75
+ input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
76
+ .btn { border: 1px solid var(--line); background: var(--chip); border-radius: 6px; padding: 5px 10px; cursor: pointer; white-space: nowrap; }
77
+ .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 500; }
78
+ .btn.small { padding: 2px 7px; font-size: 12px; }
79
+ .btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
80
+ .btn:disabled { opacity: 0.45; cursor: default; }
81
+ .note { color: var(--muted); font-size: 12px; margin: 0; }
82
+ .note.warn { color: var(--warn); }
83
+ .note.ok { color: var(--ok); }
84
+ .param { display: flex; flex-direction: column; gap: 4px; }
85
+ .param > .lab { display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
86
+ .param > .lab b { font-weight: 500; color: var(--ink); font-family: var(--mono); font-size: 11.5px; }
87
+ .param .sub { padding-left: 10px; border-left: 2px solid var(--line); display: flex; flex-direction: column; gap: 6px; }
88
+ .vec { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
89
+ .vec label { display: flex; align-items: center; gap: 3px; font: 11px var(--mono); color: var(--muted); cursor: ew-resize; user-select: none; }
90
+ .vec input { width: 100%; }
91
+ .pick { display: flex; gap: 6px; align-items: center; }
92
+ .pick select { width: auto; flex: 1; }
93
+ .chipline { display: flex; gap: 6px; flex-wrap: wrap; }
94
+ .chip { font: 11px var(--mono); background: var(--chip); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; color: var(--muted); }
95
+ .chip.solid { color: var(--solid); } .chip.scalar { color: var(--scalar); }
96
+ .chip.warn { color: var(--warn); border-color: currentColor; }
97
+ dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; }
98
+ dt { color: var(--muted); }
99
+ dd { margin: 0; font: 12px var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
100
+ dd.good { color: var(--ok); } dd.bad { color: var(--warn); }
101
+ #log { font: 11px/1.5 var(--mono); color: var(--muted); max-height: 120px; overflow: auto; white-space: pre-wrap; margin: 0; }
102
+ #issues { margin: 0; padding-left: 16px; color: var(--warn); font-size: 12px; }
103
+
104
+ /* viewport overlays */
105
+ .overlay { position: absolute; z-index: 2; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
106
+ #legend { right: max(12px, env(safe-area-inset-right, 0px)); bottom: 12px; padding: 9px 11px; width: 210px; max-width: calc(100% - 24px); }
107
+ #legend .t { font-size: 12px; font-weight: 500; margin-bottom: 6px; }
108
+ #legend canvas { width: 100%; height: 10px; border-radius: 2px; display: block; }
109
+ #legend .lgn { font-size: 11px; color: var(--muted); margin-top: 6px; line-height: 1.35; }
110
+ #legend .ticks { display: flex; justify-content: space-between; font: 11px var(--mono); color: var(--muted); margin-top: 4px; font-variant-numeric: tabular-nums; }
111
+ #viewbar { top: 10px; right: max(10px, env(safe-area-inset-right, 0px)); display: flex; gap: 4px; background: transparent; border: 0; }
112
+ #viewbar button { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 9px; cursor: pointer; font-size: 12px; }
113
+ #viewbar button[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
114
+ #hud { left: 12px; top: 10px; padding: 5px 9px; font: 11px var(--mono); color: var(--muted); display: flex; gap: 10px; font-variant-numeric: tabular-nums; }
115
+ #notes { left: 12px; bottom: 12px; padding: 7px 10px; max-width: min(420px, calc(100% - 260px)); font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 2px; }
116
+ #probe { position: absolute; z-index: 3; pointer-events: none; font: 11.5px var(--mono); background: var(--ink); color: var(--panel);
117
+ border-radius: 5px; padding: 4px 7px; white-space: nowrap; transform: translate(14px, -50%); font-variant-numeric: tabular-nums; }
118
+ #glerr { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; color: var(--warn); }
119
+
120
+ /* graph */
121
+ #gtools { position: absolute; z-index: 2; left: 10px; top: 8px; right: 10px; display: flex; gap: 6px; align-items: center; pointer-events: none; }
122
+ #gtools > * { pointer-events: auto; }
123
+ #gtools select { width: auto; background: var(--panel); }
124
+ #gtools .btn { background: var(--panel); }
125
+ #gtools .spacer { flex: 1; pointer-events: none; }
126
+ #graph { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; user-select: none; cursor: grab;
127
+ background-image: radial-gradient(var(--dot) 1px, transparent 1px); background-size: 18px 18px; }
128
+ #graph.panning { cursor: grabbing; }
129
+ .gn rect.body { fill: var(--panel); stroke: var(--line); stroke-width: 1; }
130
+ .gn.sel rect.body { stroke: var(--accent); stroke-width: 2; }
131
+ .gn.bad rect.body { stroke: var(--warn); stroke-width: 1.5; }
132
+ .gn .stripe { stroke: none; }
133
+ .gn text { font: 11px var(--mono); fill: var(--muted); }
134
+ .gn text.name { font: 600 12px var(--body); fill: var(--ink); }
135
+ .gn .head { cursor: move; }
136
+ .gn .port { fill: var(--panel); stroke: var(--muted); stroke-width: 1.2; cursor: crosshair; }
137
+ .gn .port.on { fill: var(--muted); }
138
+ .gn .port.hot { stroke: var(--accent); stroke-width: 2.5; }
139
+ .gn .outp { stroke-width: 1.5; cursor: crosshair; }
140
+ .gn .badge { font: 600 9.5px var(--mono); letter-spacing: 0.06em; }
141
+ .wire { fill: none; stroke-width: 1.6; opacity: 0.75; }
142
+ .wire.solid { stroke: var(--solid); } .wire.scalar { stroke: var(--scalar); }
143
+ .wire.hl { stroke-width: 2.6; opacity: 1; }
144
+ #jsonpane { position: absolute; inset: 0; z-index: 3; background: var(--graph); padding: 44px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
145
+ #jsonpane textarea { flex: 1; resize: none; background: var(--panel); }
146
+
147
+ @media (max-width: 760px) {
148
+ body { overflow: auto; }
149
+ #app { grid-template-columns: 1fr; height: auto; }
150
+ #right { grid-template-rows: 58vh 6px 46vh; order: -1; }
151
+ #panel { border-right: 0; border-top: 1px solid var(--line); padding-right: max(16px, env(safe-area-inset-right, 0px)); overflow: visible; }
152
+ #notes { max-width: calc(100% - 24px); bottom: 64px; }
153
+ #legend { left: 12px; right: 12px; width: auto; }
154
+ }
155
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
156
+
157
+ #sweep-chart svg { width: 100%; height: auto; display: block; }
158
+ #sweep-chart .pt { cursor: pointer; }
159
+ #sweep-chart .pt:hover circle { r: 5; }
160
+ #sweep-chart text { font: 10px var(--mono); fill: var(--muted); }
161
+ #tune-box { border-top: 1px solid var(--line); margin-top: 10px; padding-top: 8px; }
162
+ #tune-params { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 4px; }
163
+ #tune-params li { display: flex; gap: 4px; align-items: center; font: 11px var(--mono); }
164
+ #tune-params li span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
165
+ #tune-params input { flex: 0 0 3.6em; width: 3.6em; min-width: 0; }
166
+ #tune-chart svg { width: 100%; height: auto; display: block; }
167
+ #tune-chart text { font: 10px var(--mono); fill: var(--muted); }
168
+ #tune-chart .pt { cursor: pointer; }
169
+
170
+ .curve-ed svg { width: 100%; height: auto; display: block; margin-top: 6px; background: var(--panel); border-radius: 6px; touch-action: none; }
171
+ .curve-ed .handle { cursor: grab; }
172
+ .curve-ed text.ax { font: 10px var(--mono); fill: var(--muted); }
173
+ </style>
174
+ <!--/head-->
175
+ <div id="app">
176
+ <aside id="panel">
177
+ <header><h1><input id="name" aria-label="Graph name" spellcheck="false"></h1><p id="desc"></p></header>
178
+
179
+ <section>
180
+ <h2>Preview</h2>
181
+ <div class="pick"><span class="note">Solid</span><select id="target" aria-label="Solid shown"></select></div>
182
+ <div class="pick"><span class="note">Colour</span><select id="colorby" aria-label="Colour by"></select></div>
183
+ <div class="seg" id="cut" role="group" aria-label="Section plane">
184
+ <button data-v="off" aria-pressed="true">No cut</button><button data-v="0">X</button><button data-v="1">Y</button><button data-v="2">Z</button>
185
+ </div>
186
+ <div class="row"><input type="range" id="cut-pos" min="0" max="1" step="0.001" value="0.5" aria-label="Section position" disabled>
187
+ <button class="btn small" id="cut-flip" disabled title="Keep the other side">Flip</button></div>
188
+ <div class="seg" id="quality" role="group" aria-label="Preview quality">
189
+ <button data-v="draft">Draft</button><button data-v="good" aria-pressed="true">Good</button><button data-v="fine">Fine</button>
190
+ </div>
191
+ </section>
192
+
193
+ <section id="inspector">
194
+ <h2><span>Node</span><span id="node-actions"></span></h2>
195
+ <div id="node-body"><p class="note">Pick a node in the graph.</p></div>
196
+ </section>
197
+
198
+ <section>
199
+ <h2>Part</h2>
200
+ <div class="pick"><span class="note">Output</span><select id="output" aria-label="Output node"></select></div>
201
+ <div class="param"><div class="lab"><span>Mesh pitch, mm</span></div>
202
+ <div class="row"><input type="range" id="pitch" min="0.1" max="2" step="0.05"><input type="number" id="pitch-n" step="0.05" min="0.05"></div></div>
203
+ <div class="row"><div class="pick" style="flex:1"><span class="note">Material</span><select id="material-pick" aria-label="Material from the library"></select></div></div>
204
+ <div class="row" id="material-custom" hidden><input type="text" id="material" style="flex:1" aria-label="Custom material" placeholder='a calibrated card, or {"base": "petg", "strength": 40}' list="materials"><datalist id="materials"></datalist></div>
205
+ <p class="note" id="material-card"></p>
206
+ <p class="note" id="mass-est" aria-live="polite"></p>
207
+ <p class="note" id="check-est" aria-live="polite"></p>
208
+ <div class="row wrap" id="fit-row"><div class="pick" style="flex:1"><span class="note">Fit mass, g</span><input type="number" id="fit-target" min="0" step="any" placeholder="target"></div>
209
+ <div class="pick" style="flex:1.4"><span class="note">by changing</span><select id="fit-param" aria-label="Parameter to change"></select></div>
210
+ <button class="btn" id="fit" title="Find the value of the parameter that gives the target mass (on the GPU estimate)">Fit</button>
211
+ <button class="btn" id="sweep" title="Mass across the parameter's range; click a point to use that value">Sweep</button></div>
212
+ <div id="sweep-chart" hidden></div>
213
+ <div id="tune-box">
214
+ <p class="note">Tune for least mass under a study's loads</p>
215
+ <div class="pick"><span class="note">Study</span><input type="text" id="tune-study" placeholder="example:arm-link or study.json" aria-label="Study to tune against"></div>
216
+ <div class="row"><div class="pick" style="flex:1"><span class="note">SF ≥</span><input type="number" id="tune-sf" min="0" step="any" placeholder="goal" aria-label="Least safety factor"></div>
217
+ <div class="pick" style="flex:1"><span class="note">Defl. ≤ mm</span><input type="number" id="tune-defl" min="0" step="any" placeholder="goal" aria-label="Most deflection"></div></div>
218
+ <ul id="tune-params" aria-label="Knobs to tune"></ul>
219
+ <div class="pick"><span class="note">Trade-off</span><input type="text" id="tune-sweep" placeholder="e.g. defl 0.4 0.6 0.8, or SF 2 4 6" aria-label="Limit values to sweep"></div>
220
+ <div class="row wrap"><button class="btn small" id="tune-add" title="Tune the parameter picked under 'by changing' too">+ knob</button>
221
+ <span class="note" style="flex:1"></span><span class="note">analyses</span><input type="number" id="tune-n" min="2" max="200" step="1" style="width:4em" aria-label="Analyses to spend">
222
+ <button class="btn" id="tune" title="Mesh and analyze candidates (FE, the study's loads) until the part is as light as it can be at the limits; the best values go into the graph">Tune</button></div>
223
+ <div id="tune-chart" hidden></div>
224
+ </div>
225
+ <div class="row"><div class="pick" style="flex:1"><span class="note">Process</span>
226
+ <select id="process"><option value="">none</option><option>fdm</option><option>sls</option><option>mjf</option><option>sla</option><option>dlp</option><option>volumetric</option><option>lpbf</option></select></div></div>
227
+ </section>
228
+
229
+ <section>
230
+ <h2>Build</h2>
231
+ <div class="row wrap">
232
+ <button class="btn primary" id="run">Run</button>
233
+ <button class="btn" id="save">Save</button>
234
+ <button class="btn" id="export">Export JSON</button>
235
+ <label class="btn" for="import">Import<input type="file" id="import" accept=".json,application/json" hidden></label>
236
+ </div>
237
+ <p class="note" id="status"></p>
238
+ <ul id="issues" hidden></ul>
239
+ <pre id="log" hidden></pre>
240
+ <dl id="report" hidden></dl>
241
+ <div class="row wrap" id="links" hidden></div>
242
+ </section>
243
+ <p class="note" id="foot"></p>
244
+ </aside>
245
+
246
+ <div id="right">
247
+ <main id="stage">
248
+ <canvas id="gl"></canvas>
249
+ <canvas id="mesh3" hidden></canvas>
250
+ <div id="hud" class="overlay"><span id="hud-mode">preview</span><span id="hud-ms"></span><span id="hud-res"></span></div>
251
+ <div id="viewbar" class="overlay">
252
+ <span id="modebar" hidden><button data-mode="preview" aria-pressed="true">Preview</button><button data-mode="mesh">Mesh</button></span>
253
+ <button data-view="iso">Iso</button><button data-view="front">Front</button><button data-view="top">Top</button><button id="fit">Fit</button>
254
+ </div>
255
+ <div id="legend" class="overlay" hidden>
256
+ <div class="t" id="lg-title"></div>
257
+ <canvas id="lg-bar" width="256" height="1"></canvas>
258
+ <div class="ticks"><span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>
259
+ <div class="lgn" id="lg-note" hidden></div>
260
+ </div>
261
+ <div id="notes" class="overlay" hidden></div>
262
+ <div id="probe" hidden></div>
263
+ <div id="glerr" hidden></div>
264
+ </main>
265
+ <div id="divider" role="separator" aria-orientation="horizontal" aria-label="Resize preview"></div>
266
+ <div id="graphwrap">
267
+ <div id="gtools">
268
+ <select id="add" aria-label="Add a node"><option value="">+ Add node</option></select>
269
+ <button class="btn small" id="layout">Tidy</button>
270
+ <button class="btn small" id="gfit">Fit</button>
271
+ <span class="spacer"></span>
272
+ <button class="btn small" id="undo" title="Undo (Ctrl+Z)">Undo</button>
273
+ <button class="btn small" id="redo" title="Redo (Ctrl+Shift+Z)">Redo</button>
274
+ <button class="btn small" id="jsonbtn" aria-pressed="false">JSON</button>
275
+ </div>
276
+ <svg id="graph" aria-label="Field graph"><g id="world"><g id="wires"></g><g id="nodes"></g><path id="dragwire" class="wire solid" hidden></path></g></svg>
277
+ <div id="jsonpane" hidden>
278
+ <textarea id="json" spellcheck="false" aria-label="Graph JSON"></textarea>
279
+ <div class="row"><button class="btn primary" id="json-apply">Apply</button><span class="note" id="json-note"></span></div>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+
285
+ <script type="application/json" id="loadpath-data">/*__LOADPATH_DATA__*/null</script>
286
+ <script type="importmap">
287
+ { "imports": {
288
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
289
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
290
+ } }
291
+ </script>
292
+ <script type="module">
293
+ import * as THREE from "three";
294
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
295
+
296
+ const DATA = JSON.parse(document.getElementById("loadpath-data").textContent);
297
+ const $ = (s) => document.querySelector(s);
298
+ const clone = (o) => JSON.parse(JSON.stringify(o));
299
+ function stable(o) {
300
+ if (Array.isArray(o)) return "[" + o.map(stable).join(",") + "]";
301
+ if (o && typeof o === "object") return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stable(o[k])).join(",") + "}";
302
+ return JSON.stringify(o);
303
+ }
304
+ function el(tag, attrs = {}, ...kids) {
305
+ const e = document.createElement(tag);
306
+ for (const [k, v] of Object.entries(attrs)) {
307
+ if (v == null || v === false) continue;
308
+ if (k === "class") e.className = v;
309
+ else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
310
+ else if (k === "text") e.textContent = v;
311
+ else e.setAttribute(k, v === true ? "" : v);
312
+ }
313
+ for (const c of kids.flat()) if (c != null) e.append(c.nodeType ? c : document.createTextNode(String(c)));
314
+ return e;
315
+ }
316
+ const svg = (tag, attrs = {}) => {
317
+ const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
318
+ for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, v);
319
+ return e;
320
+ };
321
+ const fmt = (v) => {
322
+ if (!isFinite(v)) return String(v);
323
+ const a = Math.abs(v);
324
+ return a >= 1000 ? v.toFixed(0) : a >= 100 ? v.toFixed(1) : a >= 1 ? v.toFixed(2) : a >= 0.01 ? v.toFixed(3) : v.toPrecision(2);
325
+ };
326
+ function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
327
+
328
+ // ---- the operations (schema.FIELD_SCHEMA, the editor's view of it) ----------------------------
329
+
330
+ const FIELD_KEYS = ["input", "value", "thickness", "distance", "blend", "cell", "density", "offset"];
331
+ const SOLID_OPS = new Set(["shape", "mesh", "lattice", "union", "intersect", "subtract", "shell", "offset"]);
332
+ const BAKED = new Set(["mesh", "result", "grid", "image"]);
333
+ const OPS = {
334
+ shape: { doc: "Exact primitive: box, cylinder, tube, sphere or a union of them.",
335
+ params: [{ k: "shape", t: "shape" }],
336
+ make: (c) => ({ shape: { type: "box", min: c.map((v) => +(v - 10).toFixed(2)), max: c.map((v) => +(v + 10).toFixed(2)) } }) },
337
+ mesh: { doc: "A closed STL/3MF/OBJ as a solid (baked for the preview).",
338
+ params: [{ k: "path", t: "text" }, { k: "pitch", t: "num", opt: true, def: 0.5 }, { k: "band", t: "num", opt: true, def: 3 }],
339
+ make: () => ({ path: "part.stl" }) },
340
+ lattice: { doc: "TPMS filling all of space: intersect it with a solid.",
341
+ params: [{ k: "type", t: "enum", opts: ["gyroid", "diamond", "schwarz-p"], def: "gyroid" },
342
+ { k: "form", t: "enum", opts: ["sheet", "network"], def: "sheet" },
343
+ { k: "cell", t: "field", def: 8, range: [1, 30] },
344
+ { k: "thickness", t: "field", def: 1, range: [0.1, 6], alt: "density" },
345
+ { k: "density", t: "field", def: 0.3, range: [0.05, 0.95], opt: true, alt: "thickness" },
346
+ { k: "offset", t: "field", def: 0, range: [-3, 3], opt: true },
347
+ { k: "grading", t: "enum", opts: ["warp", "blend"], def: "warp", opt: true },
348
+ { k: "origin", t: "vec3", opt: true, def: [0, 0, 0] },
349
+ { k: "frame", t: "frame", opt: true }],
350
+ make: () => ({ type: "gyroid", cell: 8, thickness: 1 }) },
351
+ union: { doc: "Join solids; blend is the fillet radius.", params: [{ k: "inputs", t: "list" }, { k: "blend", t: "field", def: 0, range: [0, 20] }], make: () => ({ inputs: [] }) },
352
+ intersect: { doc: "Keep what is inside every input; blend rounds the edges.", params: [{ k: "inputs", t: "list" }, { k: "blend", t: "field", def: 0, range: [0, 20] }], make: () => ({ inputs: [] }) },
353
+ subtract: { doc: "Cut the later inputs out of the first; blend fillets the cut.", params: [{ k: "inputs", t: "list" }, { k: "blend", t: "field", def: 0, range: [0, 20] }], make: () => ({ inputs: [] }) },
354
+ shell: { doc: "A wall of thickness on a solid's surface.",
355
+ params: [{ k: "input", t: "field", solid: true }, { k: "thickness", t: "field", def: 1, range: [0.1, 8] },
356
+ { k: "side", t: "enum", opts: ["inside", "outside", "center"], def: "inside" }], make: () => ({ thickness: 1 }) },
357
+ offset: { doc: "Grow (positive) or shrink a solid.", params: [{ k: "input", t: "field", solid: true }, { k: "distance", t: "field", def: 1, range: [-10, 10] }], make: () => ({ distance: 1 }) },
358
+ transform: { doc: "Move, rotate or scale the input.",
359
+ params: [{ k: "input", t: "field", solid: true }, { k: "translate", t: "vec3", opt: true, def: [0, 0, 0] },
360
+ { k: "rotate", t: "rotate", opt: true }, { k: "scale", t: "num", opt: true, def: 1, range: [0.1, 4] }], make: () => ({ translate: [0, 0, 0] }) },
361
+ constant: { doc: "A number everywhere.", params: [{ k: "value", t: "num", def: 1 }], make: () => ({ value: 1 }) },
362
+ coordinate: { doc: "Position along an axis, mm.", params: [{ k: "axis", t: "axis", def: "z" }, { k: "origin", t: "vec3", opt: true, def: [0, 0, 0] }], make: () => ({ axis: "z" }) },
363
+ distance: { doc: "Unsigned distance to a solid, points, a segment, a plane or a shape.", params: [{ k: "_mode", t: "distance" }], make: () => ({}) },
364
+ group: { doc: "A copy of one of the graph's groups (reusable sub-graphs) with its own knobs and inputs.",
365
+ params: [{ k: "group", t: "group" }], make: () => ({ group: Object.keys(S.doc.groups || {})[0] || "" }) },
366
+ image: { doc: "A picture as a field from 0 to 1, laid on a plane and carried straight through (baked on the server).",
367
+ params: [{ k: "path", t: "text" }, { k: "plane", t: "enum", opts: ["xy", "xz", "yz"], def: "xy" }, { k: "origin", t: "pair", def: [0, 0] },
368
+ { k: "size", t: "pair", def: [50, 50] }, { k: "channel", t: "enum", opts: ["luma", "r", "g", "b", "a"], def: "luma" }, { k: "invert", t: "bool", def: false }],
369
+ make: () => ({ path: "", plane: "xy", origin: [0, 0], size: [50, 50] }) },
370
+ remap: { doc: "Map input from [a, b] to [c, d] through a curve.",
371
+ params: [{ k: "input", t: "field", def: 0 }, { k: "from", t: "pair", def: [0, 1] }, { k: "to", t: "pair", def: [0, 1] },
372
+ { k: "curve", t: "curve", def: "linear" },
373
+ { k: "clamp", t: "bool", def: true }], make: () => ({ from: [0, 1], to: [0, 1] }) },
374
+ expr: { doc: "Arithmetic over node names, x, y, z: abs sqrt exp log sin cos tan floor ceil sign min max clamp lerp smoothstep step where.",
375
+ params: [{ k: "expr", t: "code" }, { k: "vars", t: "vars" }], make: () => ({ expr: "z" }) },
376
+ result: { doc: "A simulation result: von Mises or displacement under a study (baked: the analysis runs on the server).",
377
+ params: [{ k: "study", t: "text", opt: true, def: "example:arm-link" }, { k: "part", t: "part", opt: true }, { k: "path", t: "text", opt: true, def: "result.npz" },
378
+ { k: "quantity", t: "text", opt: true, def: "vonMises" }, { k: "case", t: "text", opt: true, def: "" },
379
+ { k: "scale", t: "scale", opt: true }, { k: "pitch", t: "num", opt: true, def: 1 }, { k: "cells", t: "num", opt: true, def: 120000 }],
380
+ make: () => ({ study: "example:arm-link", part: "", scale: "peak" }) },
381
+ grid: { doc: "A scalar field on a regular grid (.npz: origin, pitch, values); baked.", params: [{ k: "path", t: "text" }, { k: "quantity", t: "text", opt: true, def: "values" }], make: () => ({ path: "field.npz" }) },
382
+ };
383
+ const AXES = { x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] };
384
+
385
+ // ---- the graph model ---------------------------------------------------------------------------
386
+
387
+ const S = {
388
+ doc: null, sel: null, target: null, color: null, follow: true, bakes: {}, layout: {},
389
+ cut: "off", cutPos: 0.5, cutFlip: false, quality: "good", mode: "preview", issues: [], serverIssues: [],
390
+ undo: [], redo: [], version: 0, box: null, range: null, job: null,
391
+ stress: false, sp: { busy: false, again: false, result: null, error: null, ms: null },
392
+ };
393
+
394
+ /** Name every inline node (`owner-key`), placed before its owner so the last node (the default output) stays last. */
395
+ function hoist(doc) {
396
+ doc = clone(doc);
397
+ const used = new Set(Object.keys(doc.nodes || {}));
398
+ const out = {};
399
+ const uniq = (base) => { let n = base, i = 2; while (used.has(n)) n = `${base}-${i++}`; used.add(n); return n; };
400
+ const fix = (owner, spec) => {
401
+ const s = { ...spec };
402
+ const sub = (v, key) => {
403
+ if (v && typeof v === "object" && !Array.isArray(v) && v.op) { const nm = uniq(`${owner}-${key}`); out[nm] = fix(nm, v); return nm; }
404
+ return v;
405
+ };
406
+ for (const k of FIELD_KEYS) if (k in s && !(s.op === "constant" && k === "value")) s[k] = sub(s[k], k);
407
+ if (Array.isArray(s.inputs)) s.inputs = s.inputs.map((v, i) => sub(v, `in${i + 1}`));
408
+ if (s.vars && typeof s.vars === "object") for (const k of Object.keys(s.vars)) s.vars[k] = sub(s.vars[k], k);
409
+ return s;
410
+ };
411
+ for (const [name, spec] of Object.entries(doc.nodes || {})) out[name] = (spec && typeof spec === "object") ? fix(name, spec) : spec;
412
+ doc.nodes = out;
413
+ return doc;
414
+ }
415
+
416
+ // expressions: Python's arithmetic, as fields.py allows it
417
+ const FUNCS = { abs: 1, sqrt: 1, exp: 1, log: 1, sin: 1, cos: 1, tan: 1, floor: 1, ceil: 1, sign: 1, min: -1, max: -1,
418
+ clamp: 3, lerp: 3, smoothstep: 3, step: 2, where: 3 };
419
+ const exprCache = new Map();
420
+ function parseExpr(src) {
421
+ if (exprCache.has(src)) { const c = exprCache.get(src); if (c.err) throw new Error(c.err); return c.ast; }
422
+ try { const ast = parseExpr0(src); exprCache.set(src, { ast }); return ast; } catch (e) { exprCache.set(src, { err: e.message }); throw e; }
423
+ }
424
+ function parseExpr0(src) {
425
+ const toks = [];
426
+ const re = /\s*(?:(\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)|([A-Za-z_]\w*)|(\*\*|<=|>=|==|!=|[-+*/%<>(),]))/y;
427
+ let i = 0;
428
+ while (i < src.length) {
429
+ re.lastIndex = i;
430
+ const m = re.exec(src);
431
+ if (!m || (!m[1] && !m[2] && !m[3])) { if (/^\s*$/.test(src.slice(i))) break; throw new Error(`cannot read "${src.slice(i).trim()}"`); }
432
+ i = re.lastIndex;
433
+ toks.push(m[1] ? { t: "num", v: parseFloat(m[1]) } : m[2] ? { t: "id", v: m[2] } : { t: "op", v: m[3] });
434
+ }
435
+ let k = 0;
436
+ const peek = () => toks[k];
437
+ const isOp = (v) => peek() && peek().t === "op" && peek().v === v;
438
+ const isKw = (v) => peek() && peek().t === "id" && peek().v === v;
439
+ const expect = (v) => { if (!isOp(v)) throw new Error(`expected "${v}"`); k++; };
440
+ const CMP = ["<", "<=", ">", ">="];
441
+ function ternary() {
442
+ const a = comparison();
443
+ if (isKw("if")) { k++; const c = comparison(); if (!isKw("else")) throw new Error("'if' needs an 'else'"); k++; return { t: "if", c, a, b: ternary() }; }
444
+ return a;
445
+ }
446
+ function comparison() {
447
+ const a = additive();
448
+ const p = peek();
449
+ if (p && p.t === "op" && CMP.includes(p.v)) {
450
+ k++; const b = additive();
451
+ if (peek() && peek().t === "op" && CMP.includes(peek().v)) throw new Error("chained comparisons are not allowed");
452
+ return { t: "cmp", op: p.v, a, b };
453
+ }
454
+ if (p && p.t === "op" && (p.v === "==" || p.v === "!=")) throw new Error(`"${p.v}" is not allowed (< <= > >= only)`);
455
+ return a;
456
+ }
457
+ function additive() { let a = term(); while (isOp("+") || isOp("-")) { const op = toks[k++].v; a = { t: "bin", op, a, b: term() }; } return a; }
458
+ function term() { let a = unary(); while (isOp("*") || isOp("/") || isOp("%")) { const op = toks[k++].v; a = { t: "bin", op, a, b: unary() }; } return a; }
459
+ function unary() { if (isOp("-") || isOp("+")) { const op = toks[k++].v; return { t: "un", op, a: unary() }; } return power(); }
460
+ function power() { const a = atom(); if (isOp("**")) { k++; return { t: "bin", op: "**", a, b: unary() }; } return a; }
461
+ function atom() {
462
+ const p = toks[k++];
463
+ if (!p) throw new Error("the expression ends too soon");
464
+ if (p.t === "num") return { t: "num", v: p.v };
465
+ if (p.t === "id") {
466
+ if (p.v === "if" || p.v === "else") throw new Error(`unexpected "${p.v}"`);
467
+ if (isOp("(")) {
468
+ k++;
469
+ const args = [];
470
+ if (!isOp(")")) { args.push(ternary()); while (isOp(",")) { k++; args.push(ternary()); } }
471
+ expect(")");
472
+ if (!(p.v in FUNCS)) throw new Error(`${p.v}() is not allowed (${Object.keys(FUNCS).join(" ")})`);
473
+ if (FUNCS[p.v] > 0 && args.length !== FUNCS[p.v]) throw new Error(`${p.v}() takes ${FUNCS[p.v]} argument${FUNCS[p.v] > 1 ? "s" : ""}`);
474
+ if (!args.length) throw new Error(`${p.v}() needs arguments`);
475
+ return { t: "call", f: p.v, args };
476
+ }
477
+ return { t: "name", v: p.v };
478
+ }
479
+ if (p.v === "(") { const e = ternary(); expect(")"); return e; }
480
+ throw new Error(`unexpected "${p.v}"`);
481
+ }
482
+ const ast = ternary();
483
+ if (k < toks.length) throw new Error(`unexpected "${toks[k].v}"`);
484
+ return ast;
485
+ }
486
+ function exprNames(ast, out = new Set()) {
487
+ if (!ast) return out;
488
+ if (ast.t === "name") out.add(ast.v);
489
+ for (const c of [ast.a, ast.b, ast.c, ...(ast.args || [])]) if (c) exprNames(c, out);
490
+ return out;
491
+ }
492
+
493
+ /** The node names a spec refers to: [{key (a path under the node), target}]. */
494
+ function refsOf(spec, nodes) {
495
+ const r = [];
496
+ if (!spec || typeof spec !== "object") return r;
497
+ for (const k of FIELD_KEYS) if (typeof spec[k] === "string" && !(spec.op === "constant" && k === "value")) r.push({ key: k, target: spec[k] });
498
+ if (Array.isArray(spec.inputs)) spec.inputs.forEach((v, i) => { if (typeof v === "string") r.push({ key: `inputs.${i}`, target: v }); });
499
+ if (spec.vars && typeof spec.vars === "object") for (const [k, v] of Object.entries(spec.vars)) if (typeof v === "string") r.push({ key: `vars.${k}`, target: v });
500
+ if (spec.op === "group" && spec.inputs && typeof spec.inputs === "object") for (const [k, v] of Object.entries(spec.inputs)) if (typeof v === "string") r.push({ key: `gin.${k}`, target: v });
501
+ if (spec.op === "expr" && typeof spec.expr === "string") {
502
+ try {
503
+ for (const n of exprNames(parseExpr(spec.expr))) {
504
+ if (["x", "y", "z", "pi", "e"].includes(n) || (spec.vars && n in spec.vars)) continue;
505
+ r.push({ key: "expr", target: n });
506
+ }
507
+ } catch (e) { /* reported by validate */ }
508
+ }
509
+ if (spec.op === "result" && typeof spec.part === "string" && nodes && spec.part in nodes) r.push({ key: "part", target: spec.part });
510
+ return r;
511
+ }
512
+ const depsOf = (name) => refsOf(S.doc.nodes[name], S.doc.nodes).map((r) => r.target).filter((t) => t in S.doc.nodes);
513
+
514
+ function kindOf(name, seen = new Set()) {
515
+ const s = XN()[name];
516
+ if (!s) return "scalar";
517
+ if (SOLID_OPS.has(s.op)) return "solid";
518
+ if (s.op === "transform") {
519
+ if (seen.has(name)) return "scalar";
520
+ seen.add(name);
521
+ return typeof s.input === "string" ? kindOf(s.input, seen) : "solid";
522
+ }
523
+ return "scalar";
524
+ }
525
+
526
+ /** Problems the page can see itself (the server's lint has the last word). */
527
+ function validate() {
528
+ const out = [];
529
+ const nodes = S.doc.nodes;
530
+ for (const e of X().errors) out.push({ node: e.split(":")[0], message: e });
531
+ for (const [name, s] of Object.entries(nodes)) {
532
+ if (!s || typeof s !== "object" || !(s.op in OPS)) { out.push({ node: name, message: `${name}: unknown op ${s && s.op}` }); continue; }
533
+ if (s.op === "expr") { try { parseExpr(String(s.expr || "")); } catch (e) { out.push({ node: name, message: `${name}: ${e.message}` }); } }
534
+ for (const r of refsOf(s, nodes)) if (!(r.target in nodes)) out.push({ node: name, message: `${name}: ${r.key} refers to "${r.target}", which is not a node` });
535
+ const needIn = ["shell", "offset", "transform", "remap"].includes(s.op);
536
+ if (needIn && s.input == null) out.push({ node: name, message: `${name}: needs an input` });
537
+ if (["union", "intersect", "subtract"].includes(s.op) && !(s.inputs && s.inputs.length >= (s.op === "subtract" ? 2 : 1)))
538
+ out.push({ node: name, message: `${name}: needs ${s.op === "subtract" ? "two inputs" : "an input"}` });
539
+ }
540
+ // cycles
541
+ const state = {};
542
+ const visit = (n, path) => {
543
+ if (state[n] === 1) { out.push({ node: n, message: "cycle: " + [...path, n].join(" → ") }); return; }
544
+ if (state[n] === 2 || !(n in nodes)) return;
545
+ state[n] = 1;
546
+ for (const d of depsOf(n)) visit(d, [...path, n]);
547
+ state[n] = 2;
548
+ };
549
+ for (const n of Object.keys(nodes)) visit(n, []);
550
+ return out;
551
+ }
552
+
553
+ // ---- GLSL: the graph as a signed distance function ------------------------------------------------
554
+
555
+ const fl = (v) => {
556
+ v = Number(v);
557
+ if (!isFinite(v)) return v > 0 ? "1e30" : "-1e30";
558
+ let s = String(v);
559
+ if (!/[.eE]/.test(s)) s += ".0";
560
+ return s;
561
+ };
562
+ const KIND = { gyroid: 0, diamond: 1, "schwarz-p": 2, schwarz_p: 2, schwarzP: 2 };
563
+ const CURVES = { linear: 0, smoothstep: 1, smootherstep: 2, sqrt: 3, square: 4 };
564
+
565
+ function libGLSL(tables) {
566
+ const t = (k) => `float[19](${(tables && tables[k] ? tables[k] : Array.from({ length: 19 }, (_, i) => 0.05 + 0.05 * i)).map(fl).join(",")})`;
567
+ return `
568
+ float uAt(int i) { return U[i >> 2][i & 3]; }
569
+ float lp_smin(float a, float b, float k) {
570
+ if (k <= 1e-9) return min(a, b);
571
+ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
572
+ return b * (1.0 - h) + a * h - k * h * (1.0 - h);
573
+ }
574
+ float lp_box(vec3 p, vec3 lo, vec3 hi) {
575
+ vec3 q = abs(p - 0.5 * (lo + hi)) - 0.5 * (hi - lo);
576
+ return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
577
+ }
578
+ float lp_cyl(vec3 p, vec3 p0, vec3 p1, float r, float rin) {
579
+ vec3 ax = p1 - p0; float L = length(ax); ax /= max(L, 1e-9);
580
+ vec3 d = p - p0; float t = dot(d, ax); float radial = length(d - t * ax); float hl = 0.5 * L;
581
+ float rq = rin > 0.0 ? abs(radial - 0.5 * (r + rin)) - 0.5 * (r - rin) : radial - r;
582
+ vec2 q = vec2(rq, abs(t - hl) - hl);
583
+ return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
584
+ }
585
+ float lp_cylc(vec3 p, vec3 c, vec3 a, float L, float r, float rin) { a = normalize(a); return lp_cyl(p, c - 0.5 * L * a, c + 0.5 * L * a, r, rin); }
586
+ float lp_dseg(vec3 p, vec3 a, vec3 b) { vec3 ab = b - a; float t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-30), 0.0, 1.0); return length(p - (a + t * ab)); }
587
+ float lp_dpoints(vec3 p, int base, int n) {
588
+ float d = 1e30;
589
+ for (int i = 0; i < 4096; i++) { if (i >= n) break; vec3 q = vec3(uAt(base + 3 * i), uAt(base + 3 * i + 1), uAt(base + 3 * i + 2)); d = min(d, length(p - q)); }
590
+ return d;
591
+ }
592
+ float lp_ss(float e0, float e1, float x) { float u = clamp((x - e0) / (e1 == e0 ? 1e-12 : e1 - e0), 0.0, 1.0); return u * u * (3.0 - 2.0 * u); }
593
+ float lp_pow(float a, float b) {
594
+ if (a >= 0.0) return pow(a, b);
595
+ float fb = floor(b);
596
+ if (fb == b) { float r = pow(-a, b); return mod(fb, 2.0) == 0.0 ? r : -r; }
597
+ return 0.0;
598
+ }
599
+ float lp_remap(float x, float a, float b, float c, float d, int curve, int cl) {
600
+ float u = (x - a) / (b == a ? 1e-12 : b - a);
601
+ if (cl == 1) u = clamp(u, 0.0, 1.0);
602
+ if (curve == 1) { float v = clamp(u, 0.0, 1.0); u = v * v * (3.0 - 2.0 * v); }
603
+ else if (curve == 2) { float v = clamp(u, 0.0, 1.0); u = v * v * v * (v * (6.0 * v - 15.0) + 10.0); }
604
+ else if (curve == 3) u = sqrt(max(u, 0.0));
605
+ else if (curve == 4) u = u * u;
606
+ return c + (d - c) * u;
607
+ }
608
+ float lp_tpms_f(int k, vec3 x, out vec3 g) {
609
+ vec3 s = sin(x), c = cos(x);
610
+ if (k == 0) { g = vec3(c.x * c.y - s.z * s.x, c.y * c.z - s.x * s.y, c.z * c.x - s.y * s.z); return s.x * c.y + s.y * c.z + s.z * c.x; }
611
+ if (k == 2) { g = -s; return c.x + c.y + c.z; }
612
+ g = vec3(c.x * s.y * s.z + c.x * c.y * c.z - s.x * s.y * c.z - s.x * c.y * s.z,
613
+ s.x * c.y * s.z - s.x * s.y * c.z + c.x * c.y * c.z - c.x * s.y * s.z,
614
+ s.x * s.y * c.z - s.x * c.y * s.z - c.x * s.y * s.z + c.x * c.y * c.z);
615
+ return s.x * s.y * s.z + s.x * c.y * c.z + c.x * s.y * c.z + c.x * c.y * s.z;
616
+ }
617
+ float lp_tpms(int k, vec3 q, float cell) { float kk = 6.28318530718 / cell; vec3 g; float f = lp_tpms_f(k, kk * q, g); return f / max(length(g) * kk, 0.3 * kk); }
618
+ float lp_tpms_at(int k, vec3 q, float c, int cyl, float R) {
619
+ if (cyl == 1) { float n = max(1.0, floor(6.28318530718 * R / c + 0.5)); q = vec3(q.x, q.y * n * c / 6.28318530718, q.z); }
620
+ return lp_tpms(k, q, c);
621
+ }
622
+ vec3 lp_cylq(vec3 p, vec3 c, vec3 a) {
623
+ a = normalize(a);
624
+ vec3 e1 = normalize(cross(a, abs(a.x) < 0.9 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0))); vec3 e2 = cross(a, e1);
625
+ vec3 Q = p - c; float u = dot(Q, e1), v = dot(Q, e2);
626
+ return vec3(length(vec2(u, v)), atan(v, u), dot(Q, a));
627
+ }
628
+ // a field cell, previewed as fields.py's grading "blend": periods 1.25x apart, cross-faded
629
+ float lp_lat(int k, vec3 q, float cell, int graded, int cyl, vec3 c, vec3 a, float R) {
630
+ cell = max(cell, 1e-3);
631
+ if (cyl == 1) { q = lp_cylq(q, c, a); if (R <= 0.0) R = max(q.x, cell); }
632
+ if (graded == 0) return lp_tpms_at(k, q, cell, cyl, R);
633
+ float s = log(cell) / log(1.25); float lev = floor(s); float t = s - lev; t = t * t * (3.0 - 2.0 * t);
634
+ float c0 = pow(1.25, lev);
635
+ float d0 = lp_tpms_at(k, q, c0, cyl, R);
636
+ return t < 1e-4 ? d0 : mix(d0, lp_tpms_at(k, q, c0 * 1.25, cyl, R), t);
637
+ }
638
+ const float RHO_G[19] = ${t("gyroid")};
639
+ const float RHO_D[19] = ${t("diamond")};
640
+ const float RHO_P[19] = ${t("schwarz-p")};
641
+ float lp_wall_rho(int k, float rho, float cell) {
642
+ float x = (clamp(rho, 0.05, 0.95) - 0.05) / 0.05; int i = int(min(floor(x), 17.0)); float f = x - float(i);
643
+ float a = k == 0 ? RHO_G[i] : k == 1 ? RHO_D[i] : RHO_P[i];
644
+ float b = k == 0 ? RHO_G[i + 1] : k == 1 ? RHO_D[i + 1] : RHO_P[i + 1];
645
+ return mix(a, b, f) * cell;
646
+ }
647
+ vec3 lp_rot(vec3 q, vec3 a, float deg, vec3 c) {
648
+ a = normalize(a); float th = -radians(deg); vec3 v = q - c;
649
+ return v * cos(th) + cross(a, v) * sin(th) + a * dot(a, v) * (1.0 - cos(th)) + c;
650
+ }
651
+ float lp_tex(highp sampler3D T, vec3 p, vec3 lo, vec3 hi, vec3 n) {
652
+ vec3 u = clamp((p - lo) / (hi - lo), 0.0, 1.0);
653
+ return texture(T, (u * (n - 1.0) + 0.5) / n).r;
654
+ }
655
+ vec3 lp_warp(highp sampler3D W, vec3 q, vec3 lo, vec3 n, float h, float c0, float cell) {
656
+ vec3 idx = (q - lo) / h, cl = clamp(idx, vec3(0.0), n - 1.0);
657
+ return texture(W, (cl + 0.5) / n).rgb + (lo + cl * h) / c0 + (idx - cl) * h / max(cell, 1e-3);
658
+ }
659
+ float lp_texsdf(highp sampler3D T, vec3 p, vec3 lo, vec3 hi, vec3 n) { return lp_tex(T, p, lo, hi, n) + length(max(max(lo - p, p - hi), 0.0)); }
660
+ `;
661
+ }
662
+
663
+ class Compiler {
664
+ constructor(doc, bakes, origin = {}) {
665
+ this.doc = doc; this.nodes = doc.nodes; this.bakes = bakes; this.origin = origin;
666
+ this.uIndex = new Map(); this.uVals = []; this.ids = new Map();
667
+ this.textures = []; this.notes = new Set(); this.missing = new Set();
668
+ }
669
+ id(name) { if (!this.ids.has(name)) this.ids.set(name, this.ids.size); return this.ids.get(name); }
670
+ path(name, ...k) { const o = this.origin[name]; return o ? o(k) : ["nodes", name, ...k]; }
671
+ u(path, v) {
672
+ const key = path.join("/");
673
+ if (!this.uIndex.has(key)) { this.uIndex.set(key, this.uVals.length); this.uVals.push(Number(v) || 0); }
674
+ const i = this.uIndex.get(key);
675
+ return `U[${i >> 2}].${"xyzw"[i & 3]}`;
676
+ }
677
+ v3(path, a) { return `vec3(${[0, 1, 2].map((i) => this.u([...path, i], a[i])).join(", ")})`; }
678
+ axis(path, a) {
679
+ if (typeof a === "string") { const v = AXES[a] || AXES.z; return `vec3(${v.map(fl).join(", ")})`; }
680
+ return this.v3(path, a || [0, 0, 1]);
681
+ }
682
+ // a field parameter: number (a uniform), node name, or absent (default)
683
+ f(name, key, def, ref) {
684
+ const v = this.nodes[name][key];
685
+ if (typeof v === "number") return this.u(this.path(name, key), v);
686
+ if (typeof v === "string") return ref(v);
687
+ return fl(def);
688
+ }
689
+ shape(path, s, P) {
690
+ const t = s && s.type;
691
+ if (t === "box") {
692
+ if ("min" in s) return `lp_box(${P}, ${this.v3([...path, "min"], s.min)}, ${this.v3([...path, "max"], s.max)})`;
693
+ const c = this.v3([...path, "center"], s.center || [0, 0, 0]), sz = this.v3([...path, "size"], s.size || [1, 1, 1]);
694
+ return `lp_box(${P}, ${c} - 0.5 * ${sz}, ${c} + 0.5 * ${sz})`;
695
+ }
696
+ if (t === "cylinder" || t === "tube") {
697
+ const r = this.u([...path, "r"], s.r ?? 1);
698
+ const rin = "rInner" in s ? this.u([...path, "rInner"], s.rInner) : "r_inner" in s ? this.u([...path, "r_inner"], s.r_inner) : "0.0";
699
+ if ("p0" in s) return `lp_cyl(${P}, ${this.v3([...path, "p0"], s.p0)}, ${this.v3([...path, "p1"], s.p1 || [0, 0, 1])}, ${r}, ${rin})`;
700
+ return `lp_cylc(${P}, ${this.v3([...path, "center"], s.center || [0, 0, 0])}, ${this.axis([...path, "axis"], s.axis || "z")}, ${this.u([...path, "length"], s.length ?? 1)}, ${r}, ${rin})`;
701
+ }
702
+ if (t === "sphere") return `(length(${P} - ${this.v3([...path, "center"], s.center || [0, 0, 0])}) - ${this.u([...path, "r"], s.r ?? 1)})`;
703
+ if (t === "union") {
704
+ const parts = (s.shapes || []).map((x, i) => this.shape([...path, "shapes", i], x, P));
705
+ if (!parts.length) return "1e3";
706
+ return parts.reduce((a, b) => `min(${a}, ${b})`);
707
+ }
708
+ this.notes.add(`shape type "${t}" is not previewed`);
709
+ return "1e3";
710
+ }
711
+ texture(name, scalar) {
712
+ const b = this.bakes[name];
713
+ if (!b) { this.missing.add(name); return scalar ? "0.0" : "1e3"; }
714
+ let i = this.textures.indexOf(name);
715
+ if (i < 0) { i = this.textures.length; this.textures.push(name); }
716
+ return [`lp_${scalar ? "tex" : "texsdf"}(T${i}, `, `, vec3(${b.lo.map(fl).join(", ")}), vec3(${b.hi.map(fl).join(", ")}), vec3(${b.dims.map(fl).join(", ")}))`];
717
+ }
718
+ /** GLSL for node `name` at point P; ref(dep) is the GLSL for a dependency at P; fn(dep) the name of its function. */
719
+ expr(name, P, ref, fn) {
720
+ const s = this.nodes[name], op = s.op, N = (...k) => this.path(name, ...k);
721
+ switch (op) {
722
+ case "constant": return this.u(N("value"), s.value);
723
+ case "coordinate": {
724
+ const ax = s.axis ?? "z";
725
+ if (typeof ax === "string") return `${P}.${"xyz".includes(ax) ? ax : "z"}`;
726
+ return `dot(${P} - ${s.origin ? this.v3(N("origin"), s.origin) : "vec3(0.0)"}, normalize(${this.v3(N("axis"), ax)}))`;
727
+ }
728
+ case "distance": {
729
+ if (s.input != null) return s.signed ? this.f(name, "input", 0, ref) : `abs(${this.f(name, "input", 0, ref)})`;
730
+ if (Array.isArray(s.points)) {
731
+ s.points.forEach((pt, i) => [0, 1, 2].forEach((j) => this.u(N("points", i, j), pt[j])));
732
+ const base = this.uIndex.get(N("points", 0, 0).join("/"));
733
+ return `lp_dpoints(${P}, ${base ?? 0}, ${s.points.length})`;
734
+ }
735
+ if (Array.isArray(s.segment)) return `lp_dseg(${P}, ${this.v3(N("segment", 0), s.segment[0])}, ${this.v3(N("segment", 1), s.segment[1])})`;
736
+ if (s.plane) return `abs(dot(${P} - ${this.v3(N("plane", "point"), s.plane.point || [0, 0, 0])}, normalize(${this.v3(N("plane", "normal"), s.plane.normal || [0, 0, 1])})))`;
737
+ if (s.shape) return `abs(${this.shape(N("shape"), s.shape, P)})`;
738
+ return "0.0";
739
+ }
740
+ case "shape": return this.shape(N("shape"), s.shape, P);
741
+ case "mesh": case "result": case "grid": case "image": {
742
+ const t = this.texture(name, op !== "mesh");
743
+ return typeof t === "string" ? t : `${t[0]}${P}${t[1]}`;
744
+ }
745
+ case "remap": {
746
+ const fr = s.from || [0, 1], to = s.to || [0, 1];
747
+ // straight segments: y0 + sum of each segment's rise times how far along it x is (np.interp)
748
+ const pw = (x, xs, ys) => [ys[0], ...xs.slice(1).map((_, i) => `(${ys[i + 1]} - ${ys[i]}) * clamp((${x} - ${xs[i]}) / max(${xs[i + 1]} - ${xs[i]}, 1e-9), 0.0, 1.0)`)].join(" + ");
749
+ if (fr.length > 2 || to.length > 2) {
750
+ const x = `(${this.f(name, "input", 0, ref)})`;
751
+ return `(${pw(x, fr.map((v, i) => this.u(N("from", i), v)), to.map((v, i) => this.u(N("to", i), v)))})`;
752
+ }
753
+ if (Array.isArray(s.curve)) {
754
+ const xs = s.curve.map((q, i) => this.u(N("curve", i, 0), q[0])), ys = s.curve.map((q, i) => this.u(N("curve", i, 1), q[1]));
755
+ const a = this.u(N("from", 0), fr[0]), b = this.u(N("from", 1), fr[1]);
756
+ const uu = `clamp((${this.f(name, "input", 0, ref)} - ${a}) / (${b} - ${a}), ${xs[0]}, ${xs[xs.length - 1]})`;
757
+ const c = this.u(N("to", 0), to[0]), d = this.u(N("to", 1), to[1]);
758
+ return `(${c} + (${d} - ${c}) * (${pw(uu, xs, ys)}))`;
759
+ }
760
+ return `lp_remap(${this.f(name, "input", 0, ref)}, ${this.u(N("from", 0), fr[0])}, ${this.u(N("from", 1), fr[1])}, ${this.u(N("to", 0), to[0])}, ${this.u(N("to", 1), to[1])}, ${CURVES[s.curve || "linear"] ?? 0}, ${s.clamp === false ? 0 : 1})`;
761
+ }
762
+ case "expr": return this.exprGLSL(name, parseExpr(String(s.expr || "0")), P, ref);
763
+ case "union": case "intersect": case "subtract": {
764
+ const ins = (s.inputs || []).map((v, i) => typeof v === "number" ? this.u(N("inputs", i), v) : typeof v === "string" ? ref(v) : "1e3");
765
+ if (!ins.length) return "1e3";
766
+ const k = this.f(name, "blend", 0, ref);
767
+ if (op === "union") return ins.reduce((a, b) => `lp_smin(${a}, ${b}, ${k})`);
768
+ if (op === "intersect") return ins.reduce((a, b) => `(-lp_smin(-(${a}), -(${b}), ${k}))`);
769
+ return ins.reduce((a, b) => `(-lp_smin(-(${a}), ${b}, ${k}))`);
770
+ }
771
+ case "shell": {
772
+ const f = this.f(name, "input", 1e3, ref), t = this.f(name, "thickness", 1, ref), side = s.side || "inside";
773
+ if (side === "outside") return `max(-(${f}), (${f}) - (${t}))`;
774
+ if (side === "center") return `(abs(${f}) - 0.5 * (${t}))`;
775
+ return `max(${f}, -((${f}) + (${t})))`;
776
+ }
777
+ case "offset": return `((${this.f(name, "input", 1e3, ref)}) - (${this.f(name, "distance", 0, ref)}))`;
778
+ case "lattice": {
779
+ const kind = KIND[s.type || "gyroid"] ?? 0;
780
+ const Q = s.origin ? `(${P} - ${this.v3(N("origin"), s.origin)})` : P;
781
+ const cell = this.f(name, "cell", 8, ref);
782
+ const graded = typeof s.cell === "string" ? 1 : 0;
783
+ const fr = s.frame && s.frame.type === "cylindrical" ? s.frame : null;
784
+ if (graded && !(this.bakes[`${name}#warp`] && !fr && (s.grading || "warp") === "warp"))
785
+ this.notes.add(`${name}: graded cell drawn as a cross-fade${(s.grading || "warp") === "warp" && !fr ? " until its warp is baked" : ""}`);
786
+ const cyl = fr ? `1, ${this.v3(N("frame", "center"), fr.center || [0, 0, 0])}, ${this.axis(N("frame", "axis"), fr.axis || "z")}, ${this.u(N("frame", "radius"), fr.radius || 0)}`
787
+ : "0, vec3(0.0), vec3(0.0, 0.0, 1.0), 0.0";
788
+ const wb = graded && !fr && (s.grading || "warp") === "warp" ? this.bakes[`${name}#warp`] : null;
789
+ let d;
790
+ if (wb) {
791
+ const t = this.texture(`${name}#warp`, true);
792
+ d = `(lp_tpms(${kind}, lp_warp(T${this.textures.indexOf(`${name}#warp`)}, ${Q}, vec3(${wb.lo.map(fl).join(", ")}), vec3(${wb.dims.map(fl).join(", ")}), ${fl(wb.h)}, ${fl(wb.c0)}, ${cell}), 1.0) * (${cell}))`;
793
+ } else d = `lp_lat(${kind}, ${Q}, ${cell}, ${graded}, ${cyl})`;
794
+ if (s.offset != null) d = `(${d} - (${this.f(name, "offset", 0, ref)}))`;
795
+ let t = "1.0";
796
+ if (s.thickness != null) t = this.f(name, "thickness", 1, ref);
797
+ else if (s.density != null) t = `lp_wall_rho(${kind}, ${this.f(name, "density", 0.3, ref)}, ${cell})`;
798
+ return s.form === "network" ? `(${d} - 0.5 * (${t}))` : `(abs(${d}) - 0.5 * (${t}))`;
799
+ }
800
+ case "transform": {
801
+ let Q = P;
802
+ if (s.translate) Q = `(${Q} - ${this.v3(N("translate"), s.translate)})`;
803
+ if (s.rotate) Q = `lp_rot(${Q}, ${this.axis(N("rotate", "axis"), s.rotate.axis || "z")}, ${this.u(N("rotate", "degrees"), s.rotate.degrees ?? 0)}, ${this.v3(N("rotate", "center"), s.rotate.center || [0, 0, 0])})`;
804
+ const inner = typeof s.input === "string" ? `${fn(s.input)}` : null;
805
+ if (!inner) return "1e3";
806
+ if (s.scale != null) { const sc = this.u(N("scale"), s.scale); return `(${inner}((${Q}) / ${sc}) * ${sc})`; }
807
+ return `${inner}(${Q})`;
808
+ }
809
+ }
810
+ return "1e3";
811
+ }
812
+ exprGLSL(name, ast, P, ref) {
813
+ const s = this.nodes[name], vars = s.vars || {};
814
+ const go = (n) => {
815
+ switch (n.t) {
816
+ case "num": return fl(n.v);
817
+ case "name": {
818
+ if (n.v === "x" || n.v === "y" || n.v === "z") return `${P}.${n.v}`;
819
+ if (n.v === "pi") return "3.14159265359";
820
+ if (n.v === "e") return "2.71828182846";
821
+ if (n.v in vars) { const v = vars[n.v]; return typeof v === "number" ? this.u(this.path(name, "vars", n.v), v) : ref(v); }
822
+ return ref(n.v);
823
+ }
824
+ case "un": return n.op === "-" ? `(-${go(n.a)})` : go(n.a);
825
+ case "bin":
826
+ if (n.op === "**") return `lp_pow(${go(n.a)}, ${go(n.b)})`;
827
+ if (n.op === "%") return `mod(${go(n.a)}, ${go(n.b)})`;
828
+ return `(${go(n.a)} ${n.op} ${go(n.b)})`;
829
+ case "cmp": return `float(${go(n.a)} ${n.op} ${go(n.b)})`;
830
+ case "if": return `((${go(n.c)}) > 0.0 ? (${go(n.a)}) : (${go(n.b)}))`;
831
+ case "call": {
832
+ const a = n.args.map(go);
833
+ switch (n.f) {
834
+ case "sqrt": return `sqrt(max(${a[0]}, 0.0))`;
835
+ case "log": return `log(max(${a[0]}, 1e-30))`;
836
+ case "min": case "max": return a.reduce((x, y) => `${n.f}(${x}, ${y})`);
837
+ case "lerp": return `mix(${a[0]}, ${a[1]}, ${a[2]})`;
838
+ case "smoothstep": return `lp_ss(${a[0]}, ${a[1]}, ${a[2]})`;
839
+ case "where": return `((${a[0]}) > 0.0 ? (${a[1]}) : (${a[2]}))`;
840
+ default: return `${n.f}(${a.join(", ")})`;
841
+ }
842
+ }
843
+ }
844
+ return "0.0";
845
+ };
846
+ return go(ast);
847
+ }
848
+ /** Functions for every node a transform evaluates elsewhere, and scene functions (each node once per point) for `roots`. */
849
+ build(roots) {
850
+ const nodes = this.nodes;
851
+ const refsIn = (n) => refsOf(nodes[n], nodes).filter((r) => r.target in nodes && r.key !== "part");
852
+ // the nodes evaluated at the scene point, and the ones only through a transform
853
+ const scene = new Set(), fnSet = new Set();
854
+ const walkScene = (n) => {
855
+ if (scene.has(n) || !(n in nodes)) return;
856
+ scene.add(n);
857
+ for (const r of refsIn(n)) { if (nodes[n].op === "transform" && r.key === "input") walkFn(r.target); else walkScene(r.target); }
858
+ };
859
+ const walkFn = (n) => { if (fnSet.has(n) || !(n in nodes)) return; fnSet.add(n); for (const r of refsIn(n)) walkFn(r.target); };
860
+ for (const r of roots) if (r) walkScene(r);
861
+ const topo = (set) => {
862
+ const out = [], seen = new Set();
863
+ const visit = (n) => { if (seen.has(n)) return; seen.add(n); for (const r of refsIn(n)) if (set.has(r.target)) visit(r.target); out.push(n); };
864
+ for (const n of set) visit(n);
865
+ return out;
866
+ };
867
+ const fname = (n) => `f${this.id(n)}`;
868
+ let code = "";
869
+ for (const n of topo(fnSet)) {
870
+ code += `float ${fname(n)}(vec3 p) { return ${this.expr(n, "p", (d) => `${fname(d)}(p)`, fname)}; }\n`;
871
+ }
872
+ const order = topo(scene);
873
+ const sceneFn = (fnName, root) => {
874
+ if (!root || !(root in nodes)) return `float ${fnName}(vec3 p) { return 1e3; }\n`;
875
+ // only what this root needs, in dependency order
876
+ const need = new Set();
877
+ const mark = (n) => { if (need.has(n) || !scene.has(n)) return; need.add(n); for (const r of refsIn(n)) if (!(nodes[n].op === "transform" && r.key === "input")) mark(r.target); };
878
+ mark(root);
879
+ let body = "";
880
+ for (const n of order) if (need.has(n)) body += ` float v${this.id(n)} = ${this.expr(n, "p", (d) => `v${this.id(d)}`, fname)};\n`;
881
+ return `float ${fnName}(vec3 p) {\n${body} return v${this.id(root)};\n}\n`;
882
+ };
883
+ return { code, sceneFn };
884
+ }
885
+ }
886
+
887
+ /** Compile the preview (solid + colour) or a probe of one node. */
888
+ function compileGraph(doc, bakes, roots, origin) {
889
+ const c = new Compiler(doc, bakes, origin);
890
+ const { code, sceneFn } = c.build(Object.values(roots));
891
+ let scenes = "";
892
+ for (const [fnName, root] of Object.entries(roots)) scenes += sceneFn(fnName, root);
893
+ const nu = Math.max(1, Math.ceil(c.uVals.length / 4));
894
+ const head = `#version 300 es
895
+ precision highp float; precision highp int; precision highp sampler3D;
896
+ uniform vec4 U[${nu}];
897
+ ${c.textures.map((_, i) => `uniform highp sampler3D T${i};`).join("\n")}
898
+ ${libGLSL(DATA.tables)}
899
+ ${code}
900
+ ${scenes}`;
901
+ return { head, uIndex: c.uIndex, uVals: c.uVals, nu, textures: c.textures, notes: [...c.notes], missing: [...c.missing] };
902
+ }
903
+
904
+ const VERT = `#version 300 es
905
+ out vec2 vUv;
906
+ void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`;
907
+
908
+ const PREVIEW_MAIN = `
909
+ uniform mat4 uInvVP; uniform vec3 uLo, uHi, uKey, uClay, uCut; uniform vec4 uPlane; uniform int uSection, uColorOn, uMode, uMaxSteps, uWalls, uOver;
910
+ uniform float uStep, uPix, uAO, uMinWall, uOverCos, uBed; uniform vec3 uBuild; uniform vec2 uRange, uPickNdc;
911
+ uniform int uStressOn; uniform highp sampler3D uStressTex; uniform vec3 uSA, uSB; uniform float uSMax, uSGoal, uSStale;
912
+ in vec2 vUv; out vec4 frag;
913
+ float map(vec3 p) { float d = scene_solid(p); if (uSection == 1) d = max(d, dot(p, uPlane.xyz) - uPlane.w); return d; }
914
+ vec3 nrm(vec3 p, float e) {
915
+ const vec2 k = vec2(1.0, -1.0);
916
+ return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) + k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
917
+ }
918
+ vec3 turbo(float x) {
919
+ x = clamp(0.1 + 0.85 * x, 0.0, 1.0);
920
+ vec4 v4 = vec4(1.0, x, x * x, x * x * x); vec2 v2 = v4.zw * v4.z;
921
+ return clamp(vec3(
922
+ dot(v4, vec4(0.13572138, 4.6153926, -42.66032258, 132.13108234)) + dot(v2, vec2(-152.94239396, 59.28637943)),
923
+ dot(v4, vec4(0.09140261, 2.19418839, 4.84296658, -14.18503333)) + dot(v2, vec2(4.27729857, 2.82956604)),
924
+ dot(v4, vec4(0.1066733, 12.64194608, -60.58204836, 110.36276771)) + dot(v2, vec2(-89.90310912, 27.34824973))), 0.0, 1.0);
925
+ }
926
+ vec3 toLin(vec3 c) { return pow(c, vec3(2.2)); }
927
+ // wall thickness at a surface point: trace a ray in along -n until it leaves the solid (inside, -d is a
928
+ // safe step); past 3 x the process minimum it is thick enough and the trace stops
929
+ float wallAt(vec3 p, vec3 n) {
930
+ p -= n * scene_solid(p); // onto the surface: a grazing hit stops just short of it
931
+ float cap = 3.0 * uMinWall, t = max(0.02 * uMinWall, 1e-3);
932
+ bool in_ = false;
933
+ for (int i = 0; i < 64; i++) {
934
+ float d = scene_solid(p - n * t);
935
+ if (d < 0.0) in_ = true;
936
+ else if (in_) return max(t - d, 0.0);
937
+ t += max(-d, 0.04 * uMinWall);
938
+ if (t > cap) return cap;
939
+ }
940
+ return cap;
941
+ }
942
+ void main() {
943
+ vec2 ndc = uMode == 1 ? uPickNdc : vUv * 2.0 - 1.0;
944
+ vec4 a = uInvVP * vec4(ndc, -1.0, 1.0), b = uInvVP * vec4(ndc, 1.0, 1.0);
945
+ vec3 ro = a.xyz / a.w, rd = normalize(b.xyz / b.w - ro);
946
+ vec3 inv = 1.0 / rd, t0 = (uLo - ro) * inv, t1 = (uHi - ro) * inv, tmn = min(t0, t1), tmx = max(t0, t1);
947
+ float tn = max(max(tmn.x, tmn.y), max(tmn.z, 0.0)), tf = min(min(tmx.x, tmx.y), tmx.z);
948
+ bool hit = false; float t = tn, d = 1e3;
949
+ if (tn < tf) {
950
+ for (int i = 0; i < 2000; i++) {
951
+ if (i >= uMaxSteps) break;
952
+ d = map(ro + rd * t);
953
+ float eps = max(uPix * t, 1e-4);
954
+ if (d < eps) { hit = true; break; }
955
+ t += max(d * uStep, 0.5 * eps);
956
+ if (t > tf) break;
957
+ }
958
+ }
959
+ if (!hit) { frag = uMode == 1 ? vec4(1e30) : vec4(0.0); return; }
960
+ for (int j = 0; j < 2; j++) { t += map(ro + rd * t) * 0.9; }
961
+ vec3 p = ro + rd * t;
962
+ float solid = scene_solid(p);
963
+ bool cut = uSection == 1 && dot(p, uPlane.xyz) - uPlane.w > solid - max(uPix * t, 1e-4) * 0.5;
964
+ float val = uColorOn == 1 ? scene_color(p) : 0.0;
965
+ vec3 n = cut ? uPlane.xyz : nrm(p, max(0.75 * uPix * t, 2e-3));
966
+ // the cut plane faces whichever way the viewer is; a surface normal that leans away (finite differences
967
+ // across a hard edge seen edge-on) is tilted back to face the viewer, not flipped to face down
968
+ if (cut) { if (dot(n, rd) > 0.0) n = -n; }
969
+ else if (dot(n, rd) > 0.0) n = normalize(n - rd * (dot(n, rd) + 0.05));
970
+ float wall = uWalls == 1 && !cut ? wallAt(p, n) : 0.0;
971
+ if (uMode == 1) { frag = vec4(p, uWalls == 1 ? wall : val); return; }
972
+ vec3 base = uColorOn == 1 ? toLin(turbo(clamp((val - uRange.x) / max(uRange.y - uRange.x, 1e-12), 0.0, 1.0))) : (cut ? uCut : uClay);
973
+ if (uWalls == 1 && !cut) {
974
+ // under the process minimum: red; up to twice it: amber fading to clay
975
+ float r = wall / uMinWall;
976
+ base = r < 1.0 ? toLin(vec3(0.86, 0.16, 0.12)) : mix(toLin(vec3(0.95, 0.66, 0.12)), base, clamp(r - 1.0, 0.0, 1.0));
977
+ }
978
+ if (uOver == 1 && !cut) {
979
+ // facing down past the self-supporting angle (from vertical): red; within 10 degrees of it: amber;
980
+ // the face on the plate is supported
981
+ vec3 ns = nrm(p, max(0.75 * uPix * t, 2e-3));
982
+ float down = dot(ns, -uBuild);
983
+ bool bed = dot(p, uBuild) < uBed;
984
+ if (!bed && down > uOverCos) base = toLin(vec3(0.86, 0.16, 0.12));
985
+ else if (!bed && down > uOverCos - 0.13) base = mix(base, toLin(vec3(0.95, 0.66, 0.12)), 0.85);
986
+ }
987
+ if (uStressOn == 1) {
988
+ // the live preview's stress over allowed, scaled to its peak; deep red past the goal's safety factor.
989
+ // While a newer preview is on its way the old map fades toward clay.
990
+ float u = texture(uStressTex, p * uSA + uSB).r;
991
+ vec3 sc = u > uSGoal ? toLin(vec3(0.55, 0.0, 0.06)) : toLin(turbo(u / uSMax));
992
+ base = mix(sc, base, uSStale);
993
+ }
994
+ float occ = 0.0, w = 1.0;
995
+ for (int i = 1; i <= 4; i++) { float h = uAO * float(i); occ += w * max(h - map(p + n * h), 0.0); w *= 0.55; }
996
+ occ = clamp(1.0 - occ / uAO * 0.45, 0.25, 1.0);
997
+ float diff = max(dot(n, uKey), 0.0);
998
+ float hemi = 0.5 + 0.5 * n.z;
999
+ float spec = pow(max(dot(reflect(-uKey, n), -rd), 0.0), 36.0) * (cut ? 0.0 : 0.18);
1000
+ float rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * 0.12;
1001
+ float fill = max(dot(n, normalize(vec3(-uKey.x, -uKey.y, 0.35))), 0.0);
1002
+ vec3 col = base * (0.42 * occ * mix(0.6, 1.0, hemi) + 0.95 * diff * mix(0.65, 1.0, occ) + 0.22 * fill * occ) + spec + rim * base;
1003
+ // under a section, what is seen through the cut (a lattice's voids) darkens with depth below it
1004
+ if (uSection == 1 && !cut) {
1005
+ float f = clamp((uPlane.w - dot(p, uPlane.xyz)) / (0.4 * uAO), 0.0, 1.0);
1006
+ col = mix(col, vec3(dot(col, vec3(0.3, 0.59, 0.11))) * 0.3, 0.8 * f);
1007
+ }
1008
+ if (cut) col = base * (0.62 + 0.38 * max(dot(n, -rd), 0.0));
1009
+ frag = vec4(pow(col, vec3(1.0 / 2.2)), 1.0);
1010
+ }`;
1011
+
1012
+ const PROBE_MAIN = `
1013
+ uniform highp sampler2D uPts; out vec4 frag;
1014
+ void main() { vec3 p = texelFetch(uPts, ivec2(gl_FragCoord.xy), 0).xyz; frag = vec4(scene_probe(p), 0.0, 0.0, 1.0); }`;
1015
+
1016
+ const BLIT = `#version 300 es
1017
+ precision highp float; uniform sampler2D uTex; in vec2 vUv; out vec4 frag; void main() { frag = texture(uTex, vUv); }`;
1018
+
1019
+ // ---- WebGL ---------------------------------------------------------------------------------------
1020
+
1021
+ const glCanvas = $("#gl");
1022
+ const gl = glCanvas.getContext("webgl2", { antialias: false, alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true });
1023
+ const G = { ok: !!gl, floatRT: false, prog: null, probes: new Map(), tex: new Map(), vao: null, costPerPx: null };
1024
+ if (!gl) { $("#glerr").hidden = false; $("#glerr").textContent = "This browser has no WebGL2, which the live preview needs. Export the JSON and run it with loadpath field."; }
1025
+ else {
1026
+ G.floatRT = !!gl.getExtension("EXT_color_buffer_float");
1027
+ G.vao = gl.createVertexArray();
1028
+ }
1029
+
1030
+ function makeProgram(fs, vs = VERT) {
1031
+ const sh = (type, src) => {
1032
+ const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
1033
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { const log = gl.getShaderInfoLog(s); gl.deleteShader(s); throw new Error(log); }
1034
+ return s;
1035
+ };
1036
+ const p = gl.createProgram();
1037
+ const v = sh(gl.VERTEX_SHADER, vs), f = sh(gl.FRAGMENT_SHADER, fs);
1038
+ gl.attachShader(p, v); gl.attachShader(p, f); gl.linkProgram(p);
1039
+ gl.deleteShader(v); gl.deleteShader(f);
1040
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
1041
+ const locs = {};
1042
+ const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
1043
+ for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(p, i); const nm = info.name.replace(/\[0\]$/, ""); locs[nm] = gl.getUniformLocation(p, info.name); }
1044
+ return { p, locs };
1045
+ }
1046
+
1047
+ function b64ToU16(b64) {
1048
+ const bin = atob(b64), u8 = new Uint8Array(bin.length);
1049
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
1050
+ return new Uint16Array(u8.buffer);
1051
+ }
1052
+ function bakeTexture(name) {
1053
+ const b = S.bakes[name];
1054
+ const key = b && (b.dims.join("x") + b.data.length + b.data.slice(0, 64) + b.data.slice(-64));
1055
+ const have = G.tex.get(name);
1056
+ if (have && have.key === key) return have.tex;
1057
+ if (have) gl.deleteTexture(have.tex);
1058
+ const tex = gl.createTexture();
1059
+ gl.bindTexture(gl.TEXTURE_3D, tex);
1060
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
1061
+ const rgba = b.op === "warp";
1062
+ gl.texImage3D(gl.TEXTURE_3D, 0, rgba ? gl.RGBA16F : gl.R16F, b.dims[0], b.dims[1], b.dims[2], 0, rgba ? gl.RGBA : gl.RED, gl.HALF_FLOAT, b64ToU16(b.data));
1063
+ for (const [k, v] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE]])
1064
+ gl.texParameteri(gl.TEXTURE_3D, k, v);
1065
+ G.tex.set(name, { tex, key });
1066
+ return tex;
1067
+ }
1068
+ function bindCommon(prog, comp) {
1069
+ gl.useProgram(prog.p);
1070
+ const U = new Float32Array(comp.nu * 4); U.set(comp.uVals);
1071
+ if (prog.locs.U) gl.uniform4fv(prog.locs.U, U);
1072
+ comp.textures.forEach((name, i) => {
1073
+ gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_3D, bakeTexture(name));
1074
+ if (prog.locs[`T${i}`]) gl.uniform1i(prog.locs[`T${i}`], i);
1075
+ });
1076
+ }
1077
+
1078
+ // ---- the preview -----------------------------------------------------------------------------------
1079
+
1080
+ const stage = $("#stage");
1081
+ const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 1e5);
1082
+ camera.up.set(0, 0, 1);
1083
+ const controls = new OrbitControls(camera, stage);
1084
+ controls.enableDamping = true; controls.dampingFactor = 0.18;
1085
+ let interactUntil = 0, dirty = true, fullDone = false;
1086
+ const kick = (interactive = true) => { dirty = true; fullDone = false; if (interactive) interactUntil = performance.now() + 180; };
1087
+ controls.addEventListener("change", () => kick(true));
1088
+
1089
+ function previewRoots() {
1090
+ const t = S.target && S.target in S.doc.nodes ? S.target : null;
1091
+ return { scene_solid: t, scene_color: S.color && S.color in S.doc.nodes ? S.color : null };
1092
+ }
1093
+ function recompile() {
1094
+ if (!G.ok) return;
1095
+ const v = validate();
1096
+ S.issues = v;
1097
+ showIssues();
1098
+ if (v.some((i) => /cycle|unknown op|cannot read|unexpected|not allowed|takes|needs arguments|ends too soon|expected|'if'/.test(i.message))) { drawGraph(); return; }
1099
+ let comp;
1100
+ try {
1101
+ const roots = previewRoots();
1102
+ comp = compileGraph(X().doc, S.bakes, roots, X().origin);
1103
+ const t0 = performance.now();
1104
+ const prog = makeProgram(comp.head + PREVIEW_MAIN);
1105
+ if (G.prog) gl.deleteProgram(G.prog.p);
1106
+ G.prog = prog; G.comp = comp;
1107
+ G.compileMs = performance.now() - t0;
1108
+ $("#glerr").hidden = true;
1109
+ } catch (e) {
1110
+ console.error(e, comp && comp.head);
1111
+ $("#glerr").hidden = false; $("#glerr").textContent = "Preview shader did not compile: " + String(e.message || e).slice(0, 400);
1112
+ return;
1113
+ }
1114
+ for (const p of G.probes.values()) gl.deleteProgram(p.prog.p);
1115
+ G.probes.clear();
1116
+ S.version++;
1117
+ showNotes();
1118
+ fillFitParams();
1119
+ updateBounds();
1120
+ kick(false);
1121
+ drawGraph();
1122
+ requestBakes();
1123
+ stressSoon();
1124
+ }
1125
+ /** A slider moved: set the uniform if there is one, else it is a structural change. */
1126
+ function paramChanged(path) {
1127
+ S._x = null;
1128
+ const key = path.join("/");
1129
+ if (G.comp && G.comp.uIndex.has(key)) {
1130
+ const v = Number(getAt(S.doc, path));
1131
+ G.comp.uVals[G.comp.uIndex.get(key)] = isFinite(v) ? v : 0;
1132
+ for (const p of G.probes.values()) if (p.comp.uIndex.has(key)) p.comp.uVals[p.comp.uIndex.get(key)] = isFinite(v) ? v : 0;
1133
+ kick(true);
1134
+ boundsSoon();
1135
+ drawGraphSoon();
1136
+ bakesSoon();
1137
+ stressSoon();
1138
+ } else recompileSoon();
1139
+ commitSoon();
1140
+ }
1141
+ const recompileSoon = debounce(recompile, 60);
1142
+ const boundsSoon = debounce(() => updateBounds(), 140);
1143
+ const drawGraphSoon = debounce(() => drawGraph(), 120);
1144
+
1145
+ function quality() {
1146
+ return { draft: { steps: 140, step: 0.9, pix: 2.2 }, good: { steps: 320, step: 0.75, pix: 1.0 }, fine: { steps: 700, step: 0.55, pix: 0.6 } }[S.quality];
1147
+ }
1148
+ let rt = null;
1149
+ function renderPreview(scale) {
1150
+ const prog = G.prog, comp = G.comp;
1151
+ if (!prog || !S.box) return;
1152
+ const w = Math.max(1, Math.round(glCanvas.width * scale)), h = Math.max(1, Math.round(glCanvas.height * scale));
1153
+ bindCommon(prog, comp);
1154
+ const q = quality();
1155
+ camera.updateMatrixWorld();
1156
+ const ivp = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).invert();
1157
+ const L = prog.locs;
1158
+ gl.uniformMatrix4fv(L.uInvVP, false, ivp.elements);
1159
+ gl.uniform3fv(L.uLo, S.box[0]); gl.uniform3fv(L.uHi, S.box[1]);
1160
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0), up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1), back = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 2);
1161
+ const key = right.multiplyScalar(-0.45).add(up.multiplyScalar(0.7)).add(back.multiplyScalar(0.55)).normalize();
1162
+ gl.uniform3f(L.uKey, key.x, key.y, key.z);
1163
+ const dark = matchMedia("(prefers-color-scheme: dark)").matches && document.documentElement.dataset.theme !== "light";
1164
+ gl.uniform3fv(L.uClay, dark ? [0.42, 0.44, 0.48] : [0.55, 0.57, 0.61]);
1165
+ gl.uniform3fv(L.uCut, dark ? [0.2, 0.22, 0.26] : [0.24, 0.27, 0.32]);
1166
+ const pl = planeNow();
1167
+ gl.uniform1i(L.uSection, pl ? 1 : 0);
1168
+ gl.uniform4fv(L.uPlane, pl || [0, 0, 1, 0]);
1169
+ gl.uniform1i(L.uColorOn, comp && S.color ? 1 : 0);
1170
+ gl.uniform1i(L.uWalls, S.walls ? 1 : 0);
1171
+ gl.uniform1f(L.uMinWall, minWall().mm);
1172
+ const ov = overhang();
1173
+ gl.uniform1i(L.uOver, S.over ? 1 : 0);
1174
+ gl.uniform3fv(L.uBuild, ov.build);
1175
+ gl.uniform1f(L.uOverCos, Math.cos(THREE.MathUtils.degToRad(ov.deg)));
1176
+ gl.uniform1f(L.uBed, ov.bed);
1177
+ gl.uniform2fv(L.uRange, S.range || [0, 1]);
1178
+ const sp = S.stress && G.stress;
1179
+ gl.uniform1i(L.uStressOn, sp ? 1 : 0);
1180
+ if (sp) {
1181
+ gl.activeTexture(gl.TEXTURE15); gl.bindTexture(gl.TEXTURE_3D, G.stress.tex); gl.uniform1i(L.uStressTex, 15);
1182
+ gl.uniform3fv(L.uSA, G.stress.a); gl.uniform3fv(L.uSB, G.stress.b);
1183
+ gl.uniform1f(L.uSMax, G.stress.max); gl.uniform1f(L.uSGoal, G.stress.goal); gl.uniform1f(L.uSStale, S.sp.busy ? 0.35 : 0.0);
1184
+ }
1185
+ gl.uniform1i(L.uMode, 0);
1186
+ gl.uniform1i(L.uMaxSteps, scale < 1 ? Math.min(q.steps, 220) : q.steps);
1187
+ gl.uniform1f(L.uStep, q.step);
1188
+ const fov = THREE.MathUtils.degToRad(camera.fov);
1189
+ gl.uniform1f(L.uPix, q.pix * 2 * Math.tan(fov / 2) / h);
1190
+ const size = Math.max(...[0, 1, 2].map((i) => S.box[1][i] - S.box[0][i]));
1191
+ gl.uniform1f(L.uAO, size * 0.012);
1192
+ gl.bindVertexArray(G.vao);
1193
+ if (scale < 1) {
1194
+ if (!rt || rt.w !== w || rt.h !== h) {
1195
+ if (rt) { gl.deleteTexture(rt.tex); gl.deleteFramebuffer(rt.fb); }
1196
+ const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
1197
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1198
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1199
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1200
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
1201
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
1202
+ rt = { tex, fb, w, h };
1203
+ }
1204
+ gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fb);
1205
+ gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
1206
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1207
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1208
+ if (!G.blit) G.blit = makeProgram(BLIT);
1209
+ gl.useProgram(G.blit.p);
1210
+ gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, rt.tex); gl.uniform1i(G.blit.locs.uTex, 0);
1211
+ gl.viewport(0, 0, glCanvas.width, glCanvas.height); gl.clear(gl.COLOR_BUFFER_BIT);
1212
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1213
+ } else {
1214
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1215
+ gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
1216
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1217
+ }
1218
+ }
1219
+
1220
+ function resize() {
1221
+ const r = stage.getBoundingClientRect();
1222
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1223
+ const w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
1224
+ if (glCanvas.width !== w || glCanvas.height !== h) { glCanvas.width = w; glCanvas.height = h; }
1225
+ camera.aspect = Math.max(r.width, 1) / Math.max(r.height, 1); camera.updateProjectionMatrix();
1226
+ if (three) { three.renderer.setPixelRatio(dpr); three.renderer.setSize(r.width, r.height, false); }
1227
+ kick(false);
1228
+ }
1229
+ new ResizeObserver(resize).observe(stage);
1230
+
1231
+ let lastFrame = 0;
1232
+ function loop(now) {
1233
+ requestAnimationFrame(loop);
1234
+ controls.update();
1235
+ if (S.mode === "mesh" && three) { three.renderer.render(three.scene, camera); return; }
1236
+ if (!G.ok || !G.prog) return;
1237
+ const interacting = now < interactUntil;
1238
+ if (!dirty && (fullDone || interacting)) return;
1239
+ if (interacting) {
1240
+ // as many pixels as fit in ~28 ms, from the last full frame's cost
1241
+ const px = glCanvas.width * glCanvas.height;
1242
+ const scale = G.costPerPx ? Math.min(1, Math.max(0.2, Math.sqrt(28 / (G.costPerPx * px)))) : 0.5;
1243
+ renderPreview(scale);
1244
+ $("#hud-res").textContent = `${Math.round(scale * 100)}%`;
1245
+ dirty = false;
1246
+ } else {
1247
+ const t0 = performance.now();
1248
+ renderPreview(1);
1249
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); // wait for the GPU, to time it
1250
+ const ms = performance.now() - t0;
1251
+ G.costPerPx = ms / (glCanvas.width * glCanvas.height);
1252
+ $("#hud-ms").textContent = `${ms.toFixed(0)} ms`;
1253
+ $("#hud-res").textContent = "100%";
1254
+ fullDone = true; dirty = false;
1255
+ window.lpReady = true;
1256
+ }
1257
+ lastFrame = now;
1258
+ }
1259
+
1260
+ /** Evaluate node `name` at points (N x 3) on the GPU: what the preview draws, as numbers. */
1261
+ function probe(name, pts) {
1262
+ if (!G.ok || !G.floatRT) return null;
1263
+ let entry = G.probes.get(name);
1264
+ if (!entry) {
1265
+ const comp = compileGraph(X().doc, S.bakes, { scene_probe: name }, X().origin);
1266
+ entry = { comp, prog: makeProgram(comp.head + PROBE_MAIN) };
1267
+ G.probes.set(name, entry);
1268
+ }
1269
+ const n = pts.length / 3, W = Math.min(n, 512), H = Math.ceil(n / W);
1270
+ const data = new Float32Array(W * H * 4);
1271
+ for (let i = 0; i < n; i++) { data[4 * i] = pts[3 * i]; data[4 * i + 1] = pts[3 * i + 1]; data[4 * i + 2] = pts[3 * i + 2]; }
1272
+ const ptex = gl.createTexture();
1273
+ gl.activeTexture(gl.TEXTURE15); gl.bindTexture(gl.TEXTURE_2D, ptex);
1274
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, W, H, 0, gl.RGBA, gl.FLOAT, data);
1275
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1276
+ const otex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, otex);
1277
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, W, H, 0, gl.RGBA, gl.FLOAT, null);
1278
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1279
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
1280
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, otex, 0);
1281
+ bindCommon(entry.prog, entry.comp);
1282
+ gl.activeTexture(gl.TEXTURE15); gl.bindTexture(gl.TEXTURE_2D, ptex);
1283
+ gl.uniform1i(entry.prog.locs.uPts, 15);
1284
+ gl.viewport(0, 0, W, H); gl.bindVertexArray(G.vao);
1285
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1286
+ const out = new Float32Array(W * H * 4);
1287
+ gl.readPixels(0, 0, W, H, gl.RGBA, gl.FLOAT, out);
1288
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1289
+ gl.deleteFramebuffer(fb); gl.deleteTexture(ptex); gl.deleteTexture(otex);
1290
+ const vals = new Float32Array(n);
1291
+ for (let i = 0; i < n; i++) vals[i] = out[4 * i];
1292
+ return vals;
1293
+ }
1294
+ function gridPts(lo, hi, n) {
1295
+ const pts = new Float32Array(n * n * n * 3);
1296
+ let k = 0;
1297
+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) for (let l = 0; l < n; l++) {
1298
+ pts[k++] = lo[0] + (hi[0] - lo[0]) * (i + 0.5) / n; pts[k++] = lo[1] + (hi[1] - lo[1]) * (j + 0.5) / n; pts[k++] = lo[2] + (hi[2] - lo[2]) * (l + 0.5) / n;
1299
+ }
1300
+ return pts;
1301
+ }
1302
+
1303
+ // bounds, as fields.py works them out (field thicknesses: their largest value over the box, from a probe)
1304
+ function shapeBounds(s) {
1305
+ if (!s) return null;
1306
+ if (s.type === "box") {
1307
+ if (s.min) return [s.min.slice(), s.max.slice()];
1308
+ return [s.center.map((c, i) => c - s.size[i] / 2), s.center.map((c, i) => c + s.size[i] / 2)];
1309
+ }
1310
+ if (s.type === "cylinder" || s.type === "tube") {
1311
+ let p0 = s.p0, p1 = s.p1;
1312
+ if (!p0) {
1313
+ let a = typeof s.axis === "string" || !s.axis ? AXES[s.axis || "z"] : s.axis;
1314
+ const n = Math.hypot(...a); a = a.map((x) => x / n);
1315
+ p0 = s.center.map((c, i) => c - 0.5 * s.length * a[i]); p1 = s.center.map((c, i) => c + 0.5 * s.length * a[i]);
1316
+ }
1317
+ const ax = p1.map((v, i) => v - p0[i]), L = Math.hypot(...ax) || 1;
1318
+ const ext = ax.map((v) => s.r * Math.sqrt(Math.max(0, 1 - (v / L) ** 2)));
1319
+ return [p0.map((v, i) => Math.min(v, p1[i]) - ext[i]), p0.map((v, i) => Math.max(v, p1[i]) + ext[i])];
1320
+ }
1321
+ if (s.type === "sphere") return [s.center.map((c) => c - s.r), s.center.map((c) => c + s.r)];
1322
+ if (s.type === "union") {
1323
+ const b = (s.shapes || []).map(shapeBounds).filter(Boolean);
1324
+ if (!b.length) return null;
1325
+ return [[0, 1, 2].map((i) => Math.min(...b.map((x) => x[0][i]))), [0, 1, 2].map((i) => Math.max(...b.map((x) => x[1][i])))];
1326
+ }
1327
+ return null;
1328
+ }
1329
+ function maxOver(name, key, box) {
1330
+ const v = XN()[name][key];
1331
+ if (v == null) return 0;
1332
+ if (typeof v === "number") return v;
1333
+ if (typeof v !== "string") return 0;
1334
+ const vals = probe(v, gridPts(box[0], box[1], 12));
1335
+ if (!vals) return 0;
1336
+ let m = -Infinity; for (const x of vals) if (isFinite(x)) m = Math.max(m, x);
1337
+ return isFinite(m) ? m * 1.1 : 0;
1338
+ }
1339
+ function boundsOf(name, seen = new Set()) {
1340
+ if (seen.has(name) || !(name in XN())) return null;
1341
+ seen = new Set(seen); seen.add(name);
1342
+ const s = XN()[name];
1343
+ if (s.bounds) return [s.bounds.min, s.bounds.max];
1344
+ const sub = () => (s.inputs || []).map((x) => typeof x === "string" ? boundsOf(x, seen) : null);
1345
+ switch (s.op) {
1346
+ case "shape": return shapeBounds(s.shape);
1347
+ case "mesh": { const b = S.bakes[name]; return b ? [b.lo, b.hi] : null; }
1348
+ case "union": {
1349
+ const b = sub(); if (!b.length || b.some((x) => !x)) return null;
1350
+ const lo = [0, 1, 2].map((i) => Math.min(...b.map((x) => x[0][i]))), hi = [0, 1, 2].map((i) => Math.max(...b.map((x) => x[1][i])));
1351
+ const g = 0.25 * Math.max(maxOver(name, "blend", [lo, hi]), 0);
1352
+ return [lo.map((v) => v - g), hi.map((v) => v + g)];
1353
+ }
1354
+ case "intersect": {
1355
+ const b = sub().filter(Boolean); if (!b.length) return null;
1356
+ const lo = [0, 1, 2].map((i) => Math.max(...b.map((x) => x[0][i]))), hi = [0, 1, 2].map((i) => Math.min(...b.map((x) => x[1][i])));
1357
+ return [lo, hi.map((v, i) => Math.max(v, lo[i]))];
1358
+ }
1359
+ case "subtract": return sub()[0] || null;
1360
+ case "shell": case "offset": {
1361
+ if (typeof s.input !== "string") return null;
1362
+ const b = boundsOf(s.input, seen); if (!b) return null;
1363
+ let g = maxOver(name, s.op === "shell" ? "thickness" : "distance", b);
1364
+ if (s.op === "shell" && (s.side || "inside") === "inside") g = 0;
1365
+ g = Math.max(g, 0) + 1e-6;
1366
+ return [b[0].map((v) => v - g), b[1].map((v) => v + g)];
1367
+ }
1368
+ case "transform": {
1369
+ if (typeof s.input !== "string") return null;
1370
+ const b = boundsOf(s.input, seen); if (!b) return null;
1371
+ const sc = s.scale ?? 1;
1372
+ let pts = [];
1373
+ for (const i of [0, 1]) for (const j of [0, 1]) for (const k of [0, 1]) pts.push(new THREE.Vector3(b[i][0] * sc, b[j][1] * sc, b[k][2] * sc));
1374
+ if (s.rotate) {
1375
+ let a = typeof s.rotate.axis === "string" || !s.rotate.axis ? AXES[s.rotate.axis || "z"] : s.rotate.axis;
1376
+ const ax = new THREE.Vector3(...a).normalize(), c = new THREE.Vector3(...(s.rotate.center || [0, 0, 0]));
1377
+ pts = pts.map((p) => p.sub(c).applyAxisAngle(ax, THREE.MathUtils.degToRad(s.rotate.degrees || 0)).add(c));
1378
+ }
1379
+ const tr = new THREE.Vector3(...(s.translate || [0, 0, 0]));
1380
+ pts = pts.map((p) => p.add(tr));
1381
+ return [[0, 1, 2].map((i) => Math.min(...pts.map((p) => p.getComponent(i)))), [0, 1, 2].map((i) => Math.max(...pts.map((p) => p.getComponent(i))))];
1382
+ }
1383
+ }
1384
+ return null;
1385
+ }
1386
+ function updateBounds() {
1387
+ if (!G.prog) return;
1388
+ let b = null;
1389
+ try { b = S.target ? boundsOf(S.target) : null; } catch (e) { b = null; }
1390
+ if (!b && S.doc.bounds) b = [S.doc.bounds.min, S.doc.bounds.max];
1391
+ S.unbounded = !b;
1392
+ if (!b) b = [[-50, -50, -50], [50, 50, 50]];
1393
+ const size = Math.max(...[0, 1, 2].map((i) => b[1][i] - b[0][i]), 1);
1394
+ const pad = 0.02 * size + 0.3;
1395
+ const first = !S.box;
1396
+ S.solidBox = S.unbounded ? null : b;
1397
+ S.box = [b[0].map((v) => v - pad), b[1].map((v) => v + pad)];
1398
+ if (first) fitView("iso");
1399
+ updateRange();
1400
+ showNotes();
1401
+ massSoon();
1402
+ kick(false);
1403
+ }
1404
+ function updateRange() {
1405
+ if (!S.color || !G.floatRT || !S.box) { S.range = null; legend(); return; }
1406
+ let vals, inside;
1407
+ try {
1408
+ const pts = gridPts(S.box[0], S.box[1], 22);
1409
+ vals = probe(S.color, pts);
1410
+ inside = S.target ? probe(S.target, pts) : null;
1411
+ } catch (e) { vals = null; }
1412
+ if (!vals) { S.range = [0, 1]; legend(); return; }
1413
+ let use = [];
1414
+ for (let i = 0; i < vals.length; i++) if (isFinite(vals[i]) && (!inside || inside[i] < 0)) use.push(vals[i]);
1415
+ if (use.length < 20) use = Array.from(vals).filter(isFinite);
1416
+ use.sort((a, b) => a - b);
1417
+ const q = (f) => use[Math.min(use.length - 1, Math.max(0, Math.round(f * (use.length - 1))))];
1418
+ let lo = q(0.01), hi = q(0.99);
1419
+ if (!(hi > lo)) { lo = use[0] ?? 0; hi = (use[use.length - 1] ?? 1); if (!(hi > lo)) hi = lo + 1; }
1420
+ S.range = [lo, hi];
1421
+ legend();
1422
+ kick(false);
1423
+ }
1424
+ const rangeSoon = debounce(updateRange, 200);
1425
+
1426
+ // ---- live mass: the output's volume on the GPU --------------------------------------------------------
1427
+ // A grid over the output's box, each point counted by clamp(0.5 - d / h): a smooth step one sample wide,
1428
+ // so the count moves smoothly with a slider. Its error falls as h^2 (curved walls, saddle-shaped lattice
1429
+ // sheets), so it is taken at two spacings and extrapolated: within about 1% of the meshed part.
1430
+ // ---- material: the library's cards, and the last check rescaled to the one picked -----------------------
1431
+ function cardOf(m) {
1432
+ const L = DATA.materials || [];
1433
+ if (typeof m === "string") {
1434
+ const k = m.trim().toLowerCase();
1435
+ return L.find((c) => c.name === k || (c.aliases || []).includes(k)) || null;
1436
+ }
1437
+ if (m && typeof m === "object") {
1438
+ const b = m.base ? cardOf(m.base) : null;
1439
+ if (!b && !(m.E > 0 && m.strength > 0)) return null;
1440
+ return { ...(b || {}), ...m, name: m.name || (b ? `${b.name}-custom` : "custom"), title: m.note || (b ? `${b.title}, changed` : "custom material"), source: b ? b.source : "your numbers", url: b ? b.url : "" };
1441
+ }
1442
+ return null;
1443
+ }
1444
+ const PROCESS_ORDER = ["fdm", "sls", "mjf", "sla", "dlp", "lpbf"];
1445
+ function materialPanel() {
1446
+ const pick = $("#material-pick"), m = S.doc.material;
1447
+ if (!pick.options.length) {
1448
+ const L = DATA.materials || [], groups = {};
1449
+ for (const c of L) { const p = c.calibrated ? "calibrated" : (c.processes || [])[0] || "other"; (groups[p] = groups[p] || []).push(c); }
1450
+ const keys = Object.keys(groups).sort((a, b) => (PROCESS_ORDER.indexOf(a) + 1 || 99) - (PROCESS_ORDER.indexOf(b) + 1 || 99));
1451
+ pick.replaceChildren(el("option", { value: "" }, "none (no mass)"),
1452
+ ...keys.map((p) => { const g = el("optgroup", { label: p === "calibrated" ? "calibrated on your printer" : p.toUpperCase() });
1453
+ g.append(...groups[p].map((c) => el("option", { value: c.name }, `${c.name}: ${c.title.replace(/\s*\(values from ([^)]*)\).*/, " ($1)")}`))); return g; }),
1454
+ el("option", { value: "custom" }, "custom…"));
1455
+ }
1456
+ const c = typeof m === "string" ? cardOf(m) : null;
1457
+ pick.value = m == null ? "" : c ? c.name : "custom";
1458
+ $("#material-custom").hidden = pick.value !== "custom";
1459
+ const card = cardOf(m), out = $("#material-card");
1460
+ if (!card) { out.textContent = m != null ? "Not a library card: mass and checks need E, strength and density." : ""; updateCheck(); return; }
1461
+ out.replaceChildren(`E ${fmt(card.E)} MPa · strength ${fmt(card.strength)} MPa${card.strengthZ ? ` (${fmt(card.strengthZ)} across layers)` : ""} · ${fmt(card.density)} g/cm³ · `,
1462
+ card.url ? el("a", { href: card.url, target: "_blank", rel: "noopener" }, card.source) : card.source);
1463
+ updateCheck();
1464
+ }
1465
+ function setCheck(sf, defl) {
1466
+ const card = cardOf(S.doc.material);
1467
+ S.check = sf != null || defl != null ? { safetyFactor: sf, deflection: defl, card, material: S.doc.material } : null;
1468
+ updateCheck();
1469
+ }
1470
+ // A part of one material carries the same stress whatever its modulus (linear, loads not displacements),
1471
+ // so the last check's safety factor scales with strength and its deflection with 1 / E.
1472
+ function updateCheck() {
1473
+ const out = $("#check-est"), a = S.check, c = cardOf(S.doc.material);
1474
+ if (!a || !a.card || !c) { out.textContent = ""; return; }
1475
+ const same = JSON.stringify(a.material) === JSON.stringify(S.doc.material);
1476
+ const sf = a.safetyFactor != null ? a.safetyFactor * c.strength / a.card.strength : null;
1477
+ const d = a.deflection != null ? a.deflection * a.card.E / c.E : null;
1478
+ const vals = [sf != null ? `SF ${same ? "" : "≈ "}${fmt(sf)}` : "", d != null ? `deflection ${same ? "" : "≈ "}${fmt(d)} mm` : ""].filter(Boolean).join(" · ");
1479
+ out.textContent = same ? `${vals} · last check` :
1480
+ `${vals} in ${c.name}, scaled from the last check in ${a.card.name} (stress doesn't depend on the material; across-layer bonds may differ). Run to confirm.`;
1481
+ }
1482
+ function densityOf(m) {
1483
+ if (m && typeof m === "object") return Number(m.density) || (m.base ? densityOf(m.base) : null);
1484
+ const d = DATA.densities || {};
1485
+ return typeof m === "string" ? d[m.trim().toLowerCase()] ?? null : null;
1486
+ }
1487
+ function occupancy(name, b, h) {
1488
+ const ext = [0, 1, 2].map((i) => Math.max(b[1][i] - b[0][i], 1e-3));
1489
+ const lo = b[0].map((v) => v - h), n = ext.map((e) => Math.max(2, Math.ceil(e / h) + 2));
1490
+ const N = n[0] * n[1] * n[2], pts = new Float32Array(N * 3);
1491
+ let k = 0;
1492
+ for (let i = 0; i < n[0]; i++) for (let j = 0; j < n[1]; j++) for (let l = 0; l < n[2]; l++) {
1493
+ pts[k++] = lo[0] + (i + 0.5) * h; pts[k++] = lo[1] + (j + 0.5) * h; pts[k++] = lo[2] + (l + 0.5) * h;
1494
+ }
1495
+ const d = probe(name, pts);
1496
+ if (!d) return null;
1497
+ let occ = 0;
1498
+ for (let i = 0; i < N; i++) { const x = 0.5 - d[i] / h; occ += x <= 0 ? 0 : x >= 1 ? 1 : x; }
1499
+ return { volume: occ * h * h * h, points: N };
1500
+ }
1501
+ function volumeOf(name, budget) {
1502
+ if (!G.floatRT || !name || !(name in S.doc.nodes)) return null;
1503
+ let b = null;
1504
+ try { b = boundsOf(name); } catch (e) { b = null; }
1505
+ if (!b) return null;
1506
+ const ext = [0, 1, 2].map((i) => Math.max(b[1][i] - b[0][i], 1e-3));
1507
+ const h = Math.cbrt(ext[0] * ext[1] * ext[2] / budget), H = 1.6 * h;
1508
+ const fine = occupancy(name, b, h), coarse = occupancy(name, b, H);
1509
+ if (!fine || !coarse) return null;
1510
+ const volume = fine.volume + (fine.volume - coarse.volume) * h * h / (H * H - h * h);
1511
+ return { volume, raw: fine.volume, h, points: fine.points + coarse.points };
1512
+ }
1513
+ const MASS_BUDGET = { draft: 200000, good: 600000, fine: 2000000 };
1514
+ function updateMass() {
1515
+ if (S.probing) return; // a sweep or a fit is moving a value: it reads the mass itself
1516
+ const out = $("#mass-est");
1517
+ const v = volumeOf(S.doc.output, MASS_BUDGET[S.quality] || 600000);
1518
+ S.mass = v;
1519
+ if (!v) { out.textContent = ""; return; }
1520
+ const rho = densityOf(S.doc.material);
1521
+ const cm3 = v.volume / 1000;
1522
+ out.textContent = (rho ? `≈ ${(cm3 * rho).toFixed(1)} g · ` : "") + `${cm3.toFixed(2)} cm³` +
1523
+ (rho ? "" : " (name a material for mass)") + " · live estimate, about ±1%";
1524
+ }
1525
+ const massSoon = debounce(updateMass, 250);
1526
+
1527
+ function fitParams() {
1528
+ // every number the preview has as a uniform, the likely knobs first
1529
+ if (!G.comp) return [];
1530
+ // not what a bake depends on (a lattice's cell field, an analyzed part): the estimate would read the
1531
+ // stale bake until the server sends a new one
1532
+ const baked = new Set();
1533
+ for (const item of bakeItems()) for (const n of Object.keys(closure(item))) baked.add(n);
1534
+ const pref = /thickness|density|distance|blend|\/to\/|\/from\/|\/value$|\/offset/;
1535
+ const keys = [...G.comp.uIndex.keys()].filter((k) => k.startsWith("nodes/") && !baked.has(k.split("/")[1]) &&
1536
+ isFinite(Number(getAt(S.doc, k.split("/").map((x) => /^\d+$/.test(x) ? +x : x)))));
1537
+ const mine = (k) => S.sel && k.startsWith(`nodes/${S.sel}/`);
1538
+ return keys.sort((a, b) => (mine(b) - mine(a)) || (pref.test(b) - pref.test(a)) || a.localeCompare(b));
1539
+ }
1540
+ function fillFitParams() {
1541
+ const sel = $("#fit-param"), keep = sel.value;
1542
+ const keys = fitParams();
1543
+ sel.replaceChildren(...keys.map((k) => el("option", { value: k }, k.slice(6).replace(/\//g, " · "))));
1544
+ if (keys.includes(keep) && S.fitSel === S.sel) sel.value = keep; // a new selection offers its own knobs first
1545
+ S.fitSel = S.sel;
1546
+ $("#fit").disabled = $("#sweep").disabled = !keys.length;
1547
+ }
1548
+ function setUniform(path, v) {
1549
+ setAt(S.doc, path, v);
1550
+ const key = path.join("/");
1551
+ for (const c of [G.comp, ...[...G.probes.values()].map((p) => p.comp)]) if (c && c.uIndex.has(key)) c.uVals[c.uIndex.get(key)] = v;
1552
+ }
1553
+ /** The range a parameter's slider covers: the op's own range for it, else around its value. */
1554
+ function paramRange(key) {
1555
+ const parts = key.split("/"), node = S.doc.nodes[parts[1]];
1556
+ const p = node && OPS[node.op] && OPS[node.op].params.find((q) => q.k === parts[2]) || {};
1557
+ const v = Number(getAt(S.doc, parts.map((x) => /^\d+$/.test(x) ? +x : x)));
1558
+ return rangeFor(p.range && parts.length === 3 ? p : {}, v);
1559
+ }
1560
+ /** Mass across a parameter's range (n points, the GPU estimate), drawn as a chart; the value is put back. */
1561
+ async function sweepMass(key, n = 15) {
1562
+ const rho = densityOf(S.doc.material) || 1, named = !!densityOf(S.doc.material);
1563
+ const path = key.split("/").map((x) => /^\d+$/.test(x) ? +x : x);
1564
+ const v0 = Number(getAt(S.doc, path));
1565
+ const [lo, hi] = paramRange(key);
1566
+ const budget = Math.round((MASS_BUDGET[S.quality] || 600000) / 2);
1567
+ const pts = [];
1568
+ S.probing = true;
1569
+ try {
1570
+ for (let i = 0; i < n; i++) {
1571
+ const x = lo + (hi - lo) * i / (n - 1);
1572
+ setUniform(path, x);
1573
+ const r = volumeOf(S.doc.output, budget);
1574
+ if (r) pts.push([x, r.volume / 1000 * rho]);
1575
+ if (i % 3 === 2) await new Promise((res) => setTimeout(res, 0));
1576
+ }
1577
+ } finally { S.probing = false; setUniform(path, v0); }
1578
+ const cur = volumeOf(S.doc.output, budget);
1579
+ drawSweep(key, pts, [v0, cur ? cur.volume / 1000 * rho : null], named ? "g" : "cm³");
1580
+ updateMass();
1581
+ return pts;
1582
+ }
1583
+ function drawSweep(key, pts, cur, unit) {
1584
+ const box = $("#sweep-chart");
1585
+ if (pts.length < 2) { box.hidden = true; return; }
1586
+ const W = 288, H = 132, L = 40, R = 8, T = 16, B = 22;
1587
+ const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]).concat(cur[1] != null ? [cur[1]] : []);
1588
+ const tgt = Number($("#fit-target").value);
1589
+ if (tgt > 0 && unit === "g") ys.push(tgt);
1590
+ const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys) || 1;
1591
+ const X = (x) => L + (W - L - R) * (x - x0) / ((x1 - x0) || 1), Y = (y) => T + (H - T - B) * (1 - (y - y0) / ((y1 - y0) || 1));
1592
+ const ns = "http://www.w3.org/2000/svg", mk = (t, a, txt) => { const e = document.createElementNS(ns, t); for (const [k, v] of Object.entries(a)) e.setAttribute(k, v); if (txt != null) e.textContent = txt; return e; };
1593
+ const svg = mk("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `mass against ${key.slice(6).replace(/\//g, " ")}` });
1594
+ svg.append(mk("line", { x1: L, y1: H - B, x2: W - R, y2: H - B, stroke: "var(--line)" }), mk("line", { x1: L, y1: T, x2: L, y2: H - B, stroke: "var(--line)" }));
1595
+ svg.append(mk("text", { x: L - 4, y: T + 4, "text-anchor": "end" }, fmt(y1)), mk("text", { x: L - 4, y: H - B, "text-anchor": "end" }, fmt(y0)));
1596
+ svg.append(mk("text", { x: L, y: H - 6 }, fmt(x0)), mk("text", { x: W - R, y: H - 6, "text-anchor": "end" }, fmt(x1)));
1597
+ svg.append(mk("text", { x: L + 4, y: T - 5 }, `${unit} · ${key.slice(6).replace(/\//g, " · ")}`));
1598
+ if (tgt > 0 && unit === "g") svg.append(mk("line", { x1: L, x2: W - R, y1: Y(tgt), y2: Y(tgt), stroke: "var(--warn)", "stroke-dasharray": "4 3" }));
1599
+ svg.append(mk("polyline", { points: pts.map((p) => `${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(" "), fill: "none", stroke: "var(--accent)", "stroke-width": 2 }));
1600
+ if (cur[1] != null) svg.append(mk("circle", { cx: X(cur[0]), cy: Y(cur[1]), r: 4, fill: "var(--ink)" }));
1601
+ const path = key.split("/").map((x) => /^\d+$/.test(x) ? +x : x);
1602
+ for (const p of pts) {
1603
+ const g = mk("g", { class: "pt", tabindex: 0 });
1604
+ g.append(mk("title", {}, `${fmt(p[0])} → ${fmt(p[1])} ${unit}`), mk("circle", { cx: X(p[0]), cy: Y(p[1]), r: 3.5, fill: "var(--accent)" }));
1605
+ const use = () => { const v = Number(p[0].toPrecision(4)); setUniform(path, v); paramChanged(path); inspector(); updateMass(); drawSweep(key, pts, [v, p[1]], unit); };
1606
+ g.addEventListener("click", use); g.addEventListener("keydown", (e) => { if (e.key === "Enter") use(); });
1607
+ svg.append(g);
1608
+ }
1609
+ box.replaceChildren(svg); box.hidden = false;
1610
+ }
1611
+ /** Find the value of the parameter at `key` whose part weighs `grams` (bracket, then bisect on the estimate). */
1612
+ async function fitMass(key, grams) {
1613
+ const rho = densityOf(S.doc.material);
1614
+ if (!rho) { status("Name a material first: the fit needs its density.", "warn"); return null; }
1615
+ const path = key.split("/").map((x) => /^\d+$/.test(x) ? +x : x);
1616
+ const v0 = Number(getAt(S.doc, path));
1617
+ const target = grams / rho * 1000;
1618
+ const budget = MASS_BUDGET[S.quality] || 600000;
1619
+ const at = (v) => { setUniform(path, v); const r = volumeOf(S.doc.output, budget); return r ? r.volume - target : NaN; };
1620
+ S.probing = true;
1621
+ try {
1622
+ const tick = () => new Promise((r) => setTimeout(r, 0));
1623
+ status(`Fitting ${key.slice(6).replace(/\//g, " · ")} to ${grams} g…`);
1624
+ let a = v0, fa = at(a);
1625
+ if (!isFinite(fa)) { setUniform(path, v0); status("The part has no bounds to weigh.", "warn"); return null; }
1626
+ let step = Math.abs(v0) > 1e-6 ? Math.abs(v0) * 0.25 : 0.5, b = a + step, fb = at(b);
1627
+ if (Math.abs(fb) > Math.abs(fa)) { step = -step; b = a + step; fb = at(b); }
1628
+ let tries = 0;
1629
+ while (isFinite(fb) && Math.sign(fa) === Math.sign(fb) && tries++ < 12) {
1630
+ a = b; fa = fb; step *= 1.8; b = a + step; fb = at(b);
1631
+ await tick();
1632
+ }
1633
+ if (!isFinite(fb) || Math.sign(fa) === Math.sign(fb)) {
1634
+ setUniform(path, v0); paramChanged(path); inspector();
1635
+ status(`No value of ${key.slice(6).replace(/\//g, " · ")} reaches ${grams} g.`, "warn");
1636
+ return null;
1637
+ }
1638
+ for (let i = 0; i < 18 && Math.abs(b - a) > 1e-4 * (Math.abs(a) + Math.abs(b) + 1e-3); i++) {
1639
+ // Illinois-flavoured false position: the mass is smooth and nearly monotone in one knob
1640
+ const m = b - fb * (b - a) / (fb - fa);
1641
+ const c = isFinite(m) && Math.min(a, b) < m && m < Math.max(a, b) ? m : 0.5 * (a + b), fc = at(c);
1642
+ if (Math.sign(fc) === Math.sign(fb)) { fa *= 0.5; } else { a = b; fa = fb; }
1643
+ b = c; fb = fc;
1644
+ if (Math.abs(fc) < 2e-4 * target) break;
1645
+ await tick();
1646
+ }
1647
+ const v = Number(b.toPrecision(4));
1648
+ setUniform(path, v);
1649
+ S.probing = false;
1650
+ paramChanged(path); inspector(); updateMass();
1651
+ status(`${key.slice(6).replace(/\//g, " · ")} = ${v} gives ≈ ${grams} g (was ${v0}).`, "ok");
1652
+ return v;
1653
+ } finally { S.probing = false; }
1654
+ }
1655
+
1656
+ function planeNow() {
1657
+ if (S.cut === "off" || !S.box) return null;
1658
+ const ax = +S.cut, n = [0, 0, 0];
1659
+ n[ax] = S.cutFlip ? -1 : 1;
1660
+ const pos = S.box[0][ax] + (S.box[1][ax] - S.box[0][ax]) * S.cutPos;
1661
+ return [n[0], n[1], n[2], n[ax] * pos];
1662
+ }
1663
+ function fitView(view = "iso") {
1664
+ if (!S.box) return;
1665
+ const c = new THREE.Vector3(...[0, 1, 2].map((i) => 0.5 * (S.box[0][i] + S.box[1][i])));
1666
+ const r = 0.5 * Math.hypot(...[0, 1, 2].map((i) => S.box[1][i] - S.box[0][i]));
1667
+ const dir = { iso: [1, -1.3, 0.85], front: [0, -1, 0.0001], top: [0.0001, -0.0001, 1] }[view] || [1, -1.3, 0.85];
1668
+ const d = new THREE.Vector3(...dir).normalize();
1669
+ const dist = r / Math.sin(THREE.MathUtils.degToRad(camera.fov) / 2) * 1.02;
1670
+ camera.position.copy(c).addScaledVector(d, dist);
1671
+ camera.near = dist / 200; camera.far = dist * 20; camera.updateProjectionMatrix();
1672
+ controls.target.copy(c); controls.update();
1673
+ kick(false);
1674
+ }
1675
+
1676
+ // hover: what is under the pointer, and the colour field's value there
1677
+ let pickPending = null;
1678
+ stage.addEventListener("pointermove", (e) => { pickPending = e; requestAnimationFrame(pickNow); });
1679
+ stage.addEventListener("pointerleave", () => { pickPending = null; $("#probe").hidden = true; });
1680
+ function pickNow() {
1681
+ const e = pickPending; pickPending = null;
1682
+ if (!e || !G.prog || !G.floatRT || S.mode !== "preview" || e.buttons) { if (e && e.buttons) $("#probe").hidden = true; return; }
1683
+ const r = stage.getBoundingClientRect();
1684
+ const out = pickNdc([((e.clientX - r.left) / r.width) * 2 - 1, 1 - ((e.clientY - r.top) / r.height) * 2]);
1685
+ const box = $("#probe");
1686
+ if (out[0] > 1e29) { box.hidden = true; return; }
1687
+ box.hidden = false;
1688
+ box.style.left = `${e.clientX - r.left}px`; box.style.top = `${e.clientY - r.top}px`;
1689
+ box.textContent = `${[0, 1, 2].map((i) => out[i].toFixed(1)).join(", ")} mm` +
1690
+ (S.walls ? ` · wall ${out[3] >= 3 * minWall().mm - 1e-3 ? `over ${fmt(3 * minWall().mm)}` : fmt(out[3])} mm` : S.color ? ` · ${S.color} ${fmt(out[3])}` : "");
1691
+ }
1692
+ /** What the preview hits through a point of the screen (NDC): [x, y, z, colour value or wall], x > 1e29 for nothing. */
1693
+ function pickNdc(ndc) {
1694
+ if (!G.pick) {
1695
+ const tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
1696
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 1, 1, 0, gl.RGBA, gl.FLOAT, null);
1697
+ const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
1698
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
1699
+ G.pick = { tex, fb };
1700
+ }
1701
+ renderPreview(1); // sets the uniforms; the canvas is redrawn below
1702
+ const L = G.prog.locs;
1703
+ gl.uniform1i(L.uMode, 1); gl.uniform2f(L.uPickNdc, ndc[0], ndc[1]);
1704
+ gl.bindFramebuffer(gl.FRAMEBUFFER, G.pick.fb); gl.viewport(0, 0, 1, 1);
1705
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1706
+ const out = new Float32Array(4);
1707
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.FLOAT, out);
1708
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1709
+ return Array.from(out);
1710
+ }
1711
+
1712
+ // ---- mesh view (after a run): three.js -------------------------------------------------------------
1713
+
1714
+ let three = null;
1715
+ function showMesh(payload) {
1716
+ const canvas = $("#mesh3");
1717
+ if (!three) {
1718
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
1719
+ const scene = new THREE.Scene();
1720
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x8890a0, 1.1));
1721
+ const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(-0.5, 0.8, 1); camera.add(key); scene.add(camera);
1722
+ three = { renderer, scene, mesh: null };
1723
+ resize();
1724
+ }
1725
+ const dec = (o) => {
1726
+ const bin = atob(o.d), u8 = new Uint8Array(bin.length);
1727
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
1728
+ return o.t === "f32" ? new Float32Array(u8.buffer) : o.t === "u16" ? new Uint16Array(u8.buffer) : new Uint32Array(u8.buffer);
1729
+ };
1730
+ const geo = new THREE.BufferGeometry();
1731
+ geo.setAttribute("position", new THREE.BufferAttribute(dec(payload.p), 3));
1732
+ geo.setIndex(new THREE.BufferAttribute(dec(payload.i), 1));
1733
+ geo.computeVertexNormals();
1734
+ if (three.mesh) { three.scene.remove(three.mesh); three.mesh.geometry.dispose(); }
1735
+ three.mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0xa9aeb7, roughness: 0.6, metalness: 0.05 }));
1736
+ three.scene.add(three.mesh);
1737
+ $("#modebar").hidden = false;
1738
+ setMode("mesh");
1739
+ }
1740
+ function setMode(m) {
1741
+ S.mode = m;
1742
+ for (const b of document.querySelectorAll("#modebar button")) b.setAttribute("aria-pressed", String(b.dataset.mode === m));
1743
+ $("#mesh3").hidden = m !== "mesh"; glCanvas.hidden = m === "mesh";
1744
+ $("#hud-mode").textContent = m === "mesh" ? "meshed result" : "preview";
1745
+ $("#legend").hidden = m === "mesh" || !S.color;
1746
+ kick(false);
1747
+ }
1748
+
1749
+ // ---- doc edits, undo ------------------------------------------------------------------------------
1750
+
1751
+ function getAt(o, path) { for (const k of path) { if (o == null) return undefined; o = o[k]; } return o; }
1752
+ function setAt(o, path, v) {
1753
+ if (o === S.doc) S._x = null;
1754
+ for (let i = 0; i < path.length - 1; i++) { if (o[path[i]] == null || typeof o[path[i]] !== "object") o[path[i]] = typeof path[i + 1] === "number" ? [] : {}; o = o[path[i]]; }
1755
+ if (v === undefined) { if (Array.isArray(o)) o.splice(path[path.length - 1], 1); else delete o[path[path.length - 1]]; }
1756
+ else o[path[path.length - 1]] = v;
1757
+ }
1758
+ let lastCommitted = null;
1759
+ function commit() {
1760
+ const now = stable(S.doc);
1761
+ if (now === lastCommitted) return;
1762
+ if (lastCommitted != null) { S.undo.push(lastCommitted); if (S.undo.length > 200) S.undo.shift(); S.redo = []; }
1763
+ lastCommitted = now;
1764
+ $("#undo").disabled = !S.undo.length; $("#redo").disabled = !S.redo.length;
1765
+ if (!$("#jsonpane").hidden) $("#json").value = JSON.stringify(exportDoc(), null, 2);
1766
+ lintSoon();
1767
+ }
1768
+ const commitSoon = debounce(commit, 350);
1769
+ function structural() { S._x = null; commit(); recompile(); fillSelects(); inspector(); }
1770
+ function restore(snapshot) {
1771
+ S.doc = JSON.parse(snapshot); lastCommitted = snapshot; S._x = null;
1772
+ if (S.sel && !(S.sel in S.doc.nodes)) S.sel = null;
1773
+ if (S.target && !(S.target in S.doc.nodes)) S.target = S.doc.output;
1774
+ if (S.color && !(S.color in S.doc.nodes)) S.color = null;
1775
+ $("#undo").disabled = !S.undo.length; $("#redo").disabled = !S.redo.length;
1776
+ recompile(); fillSelects(); inspector(); partPanel();
1777
+ }
1778
+ function undo() { commit(); if (!S.undo.length) return; S.redo.push(lastCommitted); restore(S.undo.pop()); }
1779
+ function redo() { if (!S.redo.length) return; S.undo.push(lastCommitted); restore(S.redo.pop()); }
1780
+
1781
+ function exportDoc() {
1782
+ const d = clone(S.doc);
1783
+ d.editor = { ...(d.editor || {}), layout: Object.fromEntries(Object.entries(S.layout).filter(([k]) => k in d.nodes).map(([k, v]) => [k, [Math.round(v[0]), Math.round(v[1])]])) };
1784
+ return d;
1785
+ }
1786
+
1787
+ // ---- groups in the inspector ----------------------------------------------------------------------------
1788
+ function groupEditor(name, s) {
1789
+ const groups = S.doc.groups || {}, g = groups[s.group];
1790
+ const box = el("div", { class: "sub" });
1791
+ const sel = el("select", { "aria-label": "Group" }, ...Object.keys(groups).map((k) => el("option", { value: k, selected: k === s.group }, k)));
1792
+ sel.addEventListener("change", () => { S.doc.nodes[name] = { op: "group", group: sel.value }; structural(); });
1793
+ box.append(param("group", sel));
1794
+ if (!g) { box.append(el("p", { class: "note warn" }, `No group called "${s.group}".`)); return box; }
1795
+ if (g.description) box.append(el("p", { class: "note" }, g.description));
1796
+ for (const k of g.inputs || []) {
1797
+ const cur = (s.inputs || {})[k];
1798
+ const pick = el("select", { "aria-label": `Input ${k}` }, el("option", { value: "" }, "pick a node…"), ...nodeOptions(cur, (n) => n !== name));
1799
+ pick.addEventListener("change", () => { if (pick.value) connect(name, `gin.${k}`, pick.value); });
1800
+ box.append(param(`input ${k}`, pick));
1801
+ }
1802
+ for (const [p, def] of Object.entries(g.params || {})) {
1803
+ const v = s.params && p in s.params ? s.params[p] : def;
1804
+ box.append(param(p, slider(["nodes", name, "params", p], v)));
1805
+ }
1806
+ const n = Object.keys(g.nodes || {}).length;
1807
+ box.append(el("p", { class: "note" }, `${n} node${n === 1 ? "" : "s"} inside; ${Object.values(S.doc.nodes).filter((x) => x && x.op === "group" && x.group === s.group).length} cop${Object.values(S.doc.nodes).filter((x) => x && x.op === "group" && x.group === s.group).length === 1 ? "y" : "ies"} in this graph. Ungroup to edit them here; the definition is under "groups" in the JSON.`));
1808
+ return box;
1809
+ }
1810
+ /** Turn `name` and what only it uses into a group (constants inside become its params, nodes it shares with
1811
+ * the rest of the graph its inputs), and `name` into a copy of it. */
1812
+ function makeGroup(name, gname) {
1813
+ const nodes = S.doc.nodes;
1814
+ if (!(name in nodes) || nodes[name].op === "group") return null;
1815
+ const users = {};
1816
+ for (const [n, sp] of Object.entries(nodes)) for (const r of refsOf(sp, nodes)) (users[r.target] = users[r.target] || new Set()).add(n);
1817
+ // upstream of name, keeping a node only when everything that reads it is inside
1818
+ const up = new Set([name]), todo = refsOf(nodes[name], nodes).map((r) => r.target);
1819
+ const inner = new Set([name]);
1820
+ while (todo.length) { const n = todo.pop(); if (up.has(n) || !(n in nodes)) continue; up.add(n); todo.push(...refsOf(nodes[n], nodes).map((r) => r.target)); }
1821
+ let changed = true;
1822
+ while (changed) {
1823
+ changed = false;
1824
+ for (const n of up) if (!inner.has(n) && n !== S.doc.output && [...(users[n] || [])].every((u) => inner.has(u))) { inner.add(n); changed = true; }
1825
+ }
1826
+ const inputs = new Set();
1827
+ for (const n of inner) for (const r of refsOf(nodes[n], nodes)) if (!inner.has(r.target)) inputs.add(r.target);
1828
+ gname = gname || name;
1829
+ let gn = gname, i = 2; S.doc.groups = S.doc.groups || {};
1830
+ while (gn in S.doc.groups) gn = `${gname}${i++}`;
1831
+ const params = {}, gnodes = {};
1832
+ for (const n of Object.keys(nodes)) if (inner.has(n)) {
1833
+ if (n !== name && nodes[n].op === "constant" && typeof nodes[n].value === "number") params[n] = nodes[n].value;
1834
+ else gnodes[n] = clone(nodes[n]);
1835
+ }
1836
+ S.doc.groups[gn] = { nodes: gnodes, output: name, ...(Object.keys(params).length ? { params } : {}), ...(inputs.size ? { inputs: [...inputs] } : {}) };
1837
+ for (const n of inner) if (n !== name) { delete nodes[n]; delete S.layout[n]; }
1838
+ nodes[name] = { op: "group", group: gn, ...(inputs.size ? { inputs: Object.fromEntries([...inputs].map((k) => [k, k])) } : {}) };
1839
+ structural();
1840
+ status(`${name} is now a copy of group ${gn} (${Object.keys(gnodes).length} nodes, ${Object.keys(params).length} params, ${inputs.size} inputs).`, "ok");
1841
+ return gn;
1842
+ }
1843
+ /** Put a group's nodes back into the graph in place of the copy `name`. */
1844
+ function ungroup(name) {
1845
+ const s = S.doc.nodes[name];
1846
+ if (!s || s.op !== "group") return false;
1847
+ const ex = expandGroups({ ...S.doc, nodes: { [name]: s } });
1848
+ if (ex.errors.length) { status(ex.errors[0], "warn"); return false; }
1849
+ const nodes = {};
1850
+ for (const [k, v] of Object.entries(S.doc.nodes)) {
1851
+ if (k !== name) { nodes[k] = v; continue; }
1852
+ for (const [n, sp] of Object.entries(ex.doc.nodes)) if (n !== name) nodes[n] = sp;
1853
+ nodes[name] = ex.doc.nodes[name];
1854
+ }
1855
+ const at = S.layout[name] || [0, 0];
1856
+ Object.keys(ex.doc.nodes).forEach((n, i) => { if (n !== name) S.layout[n] = [at[0] - 214, at[1] + 40 * i]; });
1857
+ S.doc.nodes = nodes;
1858
+ structural();
1859
+ return true;
1860
+ }
1861
+
1862
+ // ---- the remap curve editor ----------------------------------------------------------------------------
1863
+ const CURVE_FN = { linear: (u) => u, smoothstep: (u) => u * u * (3 - 2 * u), smootherstep: (u) => u ** 3 * (u * (6 * u - 15) + 10), sqrt: Math.sqrt, square: (u) => u * u };
1864
+ /** The remap's curve: a preset, or points over the unit square to drag (click adds one, double-click removes). */
1865
+ function curveEditor(path, s) {
1866
+ const W = 288, H = 168, M = 14;
1867
+ const drawn = Array.isArray(s.curve);
1868
+ const sel = el("select", { "aria-label": "Curve" }, ...Object.keys(CURVE_FN).map((k) => el("option", { value: k, selected: !drawn && (s.curve || "linear") === k }, k)),
1869
+ el("option", { value: "#drawn", selected: drawn }, "drawn (drag points)"));
1870
+ sel.addEventListener("change", () => {
1871
+ if (sel.value === "#drawn") {
1872
+ const f = CURVE_FN[typeof s.curve === "string" ? s.curve : "linear"] || CURVE_FN.linear;
1873
+ setAt(S.doc, path, [0, 0.25, 0.5, 0.75, 1].map((u) => [u, +f(u).toFixed(4)]));
1874
+ } else setAt(S.doc, path, sel.value === "linear" ? undefined : sel.value);
1875
+ structural();
1876
+ });
1877
+ const ns = "http://www.w3.org/2000/svg", mk = (t, a) => { const e = document.createElementNS(ns, t); for (const [k, v] of Object.entries(a)) e.setAttribute(k, v); return e; };
1878
+ const svg = mk("svg", { viewBox: `0 0 ${W} ${H}`, class: "curve", role: "img", "aria-label": "Remap curve" });
1879
+ const X = (u) => M + (W - 2 * M) * u, Y = (v) => H - M - (H - 2 * M) * v;
1880
+ const U = (px) => (px - M) / (W - 2 * M), V = (py) => (H - M - py) / (H - 2 * M);
1881
+ const line = mk("polyline", { fill: "none", stroke: "var(--accent)", "stroke-width": 2 });
1882
+ svg.append(mk("rect", { x: M, y: M, width: W - 2 * M, height: H - 2 * M, fill: "none", stroke: "var(--line)" }));
1883
+ for (const g of [0.25, 0.5, 0.75]) svg.append(mk("line", { x1: X(g), x2: X(g), y1: M, y2: H - M, stroke: "var(--line)", "stroke-dasharray": "2 3" }), mk("line", { x1: M, x2: W - M, y1: Y(g), y2: Y(g), stroke: "var(--line)", "stroke-dasharray": "2 3" }));
1884
+ const fr = s.from || [0, 1], to = s.to || [0, 1];
1885
+ const lab = (x, y, t, a = "start") => { const e = mk("text", { x, y, "text-anchor": a, class: "ax" }); e.textContent = t; return e; };
1886
+ svg.append(lab(M, H - 2, fmt(fr[0])), lab(W - M, H - 2, fmt(fr[fr.length - 1]), "end"), lab(W - M - 3, M + 11, fmt(to[to.length - 1]), "end"), lab(W - M - 3, H - M - 4, fmt(to[0]), "end"));
1887
+ svg.append(line);
1888
+ const pts = () => (Array.isArray(s.curve) ? s.curve : null);
1889
+ const draw = () => {
1890
+ const p = pts();
1891
+ if (p) line.setAttribute("points", p.map((q) => `${X(q[0]).toFixed(1)},${Y(q[1]).toFixed(1)}`).join(" "));
1892
+ else { const f = CURVE_FN[s.curve || "linear"] || CURVE_FN.linear; line.setAttribute("points", Array.from({ length: 49 }, (_, i) => `${X(i / 48).toFixed(1)},${Y(f(i / 48)).toFixed(1)}`).join(" ")); }
1893
+ };
1894
+ draw();
1895
+ if (drawn) {
1896
+ const toLocal = (e) => { const r = svg.getBoundingClientRect(); return [(e.clientX - r.left) * W / r.width, (e.clientY - r.top) * H / r.height]; };
1897
+ s.curve.forEach((q, i) => {
1898
+ const h = mk("circle", { cx: X(q[0]), cy: Y(q[1]), r: 6, fill: "var(--panel)", stroke: "var(--accent)", "stroke-width": 2, class: "handle", tabindex: 0 });
1899
+ const end = i === 0 || i === s.curve.length - 1;
1900
+ h.addEventListener("pointerdown", (e) => {
1901
+ e.preventDefault(); e.stopPropagation(); h.setPointerCapture(e.pointerId);
1902
+ const mv = (ev) => {
1903
+ const [px, py] = toLocal(ev), c = s.curve;
1904
+ let u = end ? q[0] : Math.min(Math.max(U(px), c[i - 1][0] + 0.005), c[i + 1][0] - 0.005);
1905
+ const v = Math.min(Math.max(V(py), -0.25), 1.25);
1906
+ c[i] = [+u.toFixed(4), +v.toFixed(4)]; q = c[i];
1907
+ h.setAttribute("cx", X(u)); h.setAttribute("cy", Y(v)); draw();
1908
+ paramChanged([...path, i, 0]); paramChanged([...path, i, 1]);
1909
+ };
1910
+ const up = () => { h.removeEventListener("pointermove", mv); h.removeEventListener("pointerup", up); commit(); };
1911
+ h.addEventListener("pointermove", mv); h.addEventListener("pointerup", up);
1912
+ });
1913
+ h.addEventListener("dblclick", (e) => { e.stopPropagation(); if (!end && s.curve.length > 2) { s.curve.splice(i, 1); structural(); } });
1914
+ svg.append(h);
1915
+ });
1916
+ svg.addEventListener("click", (e) => {
1917
+ if (e.target.classList && e.target.classList.contains("handle")) return;
1918
+ const [px, py] = toLocal(e), u = U(px), v = V(py), c = s.curve;
1919
+ if (!(u > c[0][0] && u < c[c.length - 1][0])) return;
1920
+ const at = c.findIndex((q) => q[0] > u);
1921
+ if (Math.abs(c[at][0] - u) < 0.01 || Math.abs(c[at - 1][0] - u) < 0.01) return;
1922
+ c.splice(at, 0, [+u.toFixed(4), +Math.min(Math.max(v, -0.25), 1.25).toFixed(4)]);
1923
+ structural();
1924
+ });
1925
+ }
1926
+ return el("div", { class: "curve-ed" }, sel, svg, el("p", { class: "note" }, drawn ? "Drag a point; click to add one, double-click to remove it." : "Pick “drawn” to shape the curve by hand."));
1927
+ }
1928
+ // ---- groups: reusable sub-graphs, expanded as fields.py expands them --------------------------------------
1929
+ /** A spec with references renamed (old -> new), expr names read directly turned into vars, inline nodes too. */
1930
+ function renameSpec(spec, names) {
1931
+ const s = clone(spec);
1932
+ const ref = (v) => typeof v === "string" ? (names[v] ?? v) : v && typeof v === "object" && !Array.isArray(v) && v.op ? renameSpec(v, names) : v;
1933
+ for (const k of FIELD_KEYS) if (k in s && !(s.op === "constant" && k === "value")) s[k] = ref(s[k]);
1934
+ if (Array.isArray(s.inputs)) s.inputs = s.inputs.map(ref);
1935
+ else if (s.op === "group" && s.inputs && typeof s.inputs === "object") for (const k of Object.keys(s.inputs)) s.inputs[k] = ref(s.inputs[k]);
1936
+ if (s.vars && typeof s.vars === "object") for (const k of Object.keys(s.vars)) s.vars[k] = ref(s.vars[k]);
1937
+ if (s.op === "result" && typeof s.part === "string") s.part = names[s.part] ?? s.part;
1938
+ if (s.op === "expr" && typeof s.expr === "string") {
1939
+ for (const m of new Set(s.expr.match(/[A-Za-z_]\w*/g) || [])) if (m in names) { s.vars = s.vars || {}; if (!(m in s.vars)) s.vars[m] = names[m]; }
1940
+ }
1941
+ return s;
1942
+ }
1943
+ /** {doc, origin, errors}: group nodes replaced by their definitions (<instance>__<node>, params as constants
1944
+ * <instance>__<param>, the group's output under the instance's name). origin[name](k) is the path in the
1945
+ * edited doc a value of that node lives at, so a slider inside a group moves the definition's value. */
1946
+ function expandGroups(doc) {
1947
+ let nodes = doc.nodes || {};
1948
+ const origin = {}, errors = [], groups = doc.groups || {};
1949
+ for (let depth = 0; depth < 9 && Object.values(nodes).some((v) => v && v.op === "group"); depth++) {
1950
+ const out = {};
1951
+ for (const [name, spec] of Object.entries(nodes)) {
1952
+ if (!spec || spec.op !== "group") { out[name] = spec; continue; }
1953
+ const g = groups[spec.group];
1954
+ if (!g || !g.nodes || !Object.keys(g.nodes).length) { errors.push(`${name}: no group "${spec.group}"`); out[name] = { op: "constant", value: 1e3 }; continue; }
1955
+ const gout = g.output && g.output in g.nodes ? g.output : Object.keys(g.nodes).pop();
1956
+ const params = { ...(g.params || {}), ...(spec.params || {}) }, given = spec.inputs || {};
1957
+ const names = {};
1958
+ for (const n of Object.keys(g.nodes)) names[n] = n === gout ? name : `${name}__${n}`;
1959
+ for (const p of Object.keys(params)) names[p] = `${name}__${p}`;
1960
+ for (const i of g.inputs || []) { if (!(i in given)) errors.push(`${name}: group "${spec.group}" needs input "${i}"`); names[i] = given[i] ?? i; }
1961
+ const instPath = origin[name] ? origin[name]([]) : ["nodes", name]; // an instance inside a group lives in that group
1962
+ for (const [p, v] of Object.entries(params)) {
1963
+ out[`${name}__${p}`] = { op: "constant", value: Number(v) || 0 };
1964
+ const at = spec.params && p in spec.params ? [...instPath, "params", p] : ["groups", spec.group, "params", p];
1965
+ origin[`${name}__${p}`] = (k) => (k[0] === "value" ? at : ["#", name, p, ...k]);
1966
+ }
1967
+ for (const [n, ns] of Object.entries(g.nodes)) {
1968
+ out[names[n]] = renameSpec(ns, names);
1969
+ origin[names[n]] = (k) => ["groups", spec.group, "nodes", n, ...k];
1970
+ }
1971
+ }
1972
+ nodes = out;
1973
+ }
1974
+ return { doc: { ...doc, nodes }, origin, errors };
1975
+ }
1976
+ function X() {
1977
+ if (!S._x) {
1978
+ const has = Object.values(S.doc.nodes || {}).some((v) => v && v.op === "group");
1979
+ if (has) { const ex = expandGroups(S.doc); S._x = { ...ex, doc: hoist(ex.doc) }; } // inline nodes inside groups get names too
1980
+ else S._x = { doc: S.doc, origin: {}, errors: [] };
1981
+ }
1982
+ return S._x;
1983
+ }
1984
+ const XN = () => X().doc.nodes;
1985
+ /** Point one node spec's references to `old` at `nu` (fields, inputs, vars, an analyzed part, expr text). */
1986
+ function renameRefs(s, old, nu) {
1987
+ for (const k of FIELD_KEYS) if (s[k] === old && !(s.op === "constant" && k === "value")) s[k] = nu;
1988
+ if (Array.isArray(s.inputs)) s.inputs = s.inputs.map((v) => (v === old ? nu : v));
1989
+ else if (s.op === "group" && s.inputs && typeof s.inputs === "object") for (const k of Object.keys(s.inputs)) if (s.inputs[k] === old) s.inputs[k] = nu;
1990
+ if (s.vars) for (const k of Object.keys(s.vars)) if (s.vars[k] === old) s.vars[k] = nu;
1991
+ if (s.op === "result" && s.part === old) s.part = nu;
1992
+ if (s.op === "expr" && typeof s.expr === "string" && /^[A-Za-z_]\w*$/.test(nu)) s.expr = s.expr.replace(new RegExp(`\\b${old.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}\\b`, "g"), nu);
1993
+ }
1994
+ /** Add copies of nodes ({name: spec}): new names where they clash, references among them kept among them. */
1995
+ function pasteNodes(frag, at) {
1996
+ const names = Object.keys(frag);
1997
+ if (!names.length) return [];
1998
+ const taken = new Set(Object.keys(S.doc.nodes)), map = {};
1999
+ for (const n of names) {
2000
+ const base = n.replace(/-?copy\d*$|\d+$/, "") || "node";
2001
+ let nu = n, i = 1;
2002
+ while (taken.has(nu) || ["x", "y", "z", "pi", "e", ...Object.keys(FUNCS)].includes(nu)) nu = `${base}${/^[A-Za-z_]\w*$/.test(base) ? "" : "-"}${++i}`;
2003
+ taken.add(nu); map[n] = nu;
2004
+ }
2005
+ const specs = names.map((n) => clone(frag[n]));
2006
+ // through placeholders, so a chain of renames (a -> a2 while a2 is also pasted) cannot collide
2007
+ names.forEach((n, i) => specs.forEach((sp) => renameRefs(sp, n, `__lp${i}__`)));
2008
+ names.forEach((n, i) => specs.forEach((sp) => renameRefs(sp, `__lp${i}__`, map[n])));
2009
+ const xs = names.map((n) => S.layout[n]).filter(Boolean);
2010
+ const ox = xs.length ? Math.min(...xs.map((p) => p[0])) : 0, oy = xs.length ? Math.min(...xs.map((p) => p[1])) : 0;
2011
+ names.forEach((n, i) => {
2012
+ S.doc.nodes[map[n]] = specs[i];
2013
+ const p = S.layout[n];
2014
+ S.layout[map[n]] = at ? [at[0] + (p ? p[0] - ox : 30 * i), at[1] + (p ? p[1] - oy : 30 * i)] : p ? [p[0] + 40, p[1] + 60] : [40, 50 + 40 * i];
2015
+ });
2016
+ S.sel = map[names[names.length - 1]];
2017
+ structural();
2018
+ status(`Added ${names.map((n) => map[n]).join(", ")}.`, "ok");
2019
+ return names.map((n) => map[n]);
2020
+ }
2021
+ function duplicateNode(name) { return name in S.doc.nodes ? pasteNodes({ [name]: S.doc.nodes[name] }) : []; }
2022
+ function copyNode(name) {
2023
+ if (!(name in S.doc.nodes)) return;
2024
+ S.clip = { nodes: { [name]: clone(S.doc.nodes[name]) } };
2025
+ try { navigator.clipboard && navigator.clipboard.writeText(JSON.stringify(S.clip, null, 2)).catch(() => {}); } catch (e) { /* no clipboard: the page's own copy still pastes */ }
2026
+ status(`Copied ${name}.`);
2027
+ }
2028
+ function pasteText(text) {
2029
+ let frag = null;
2030
+ try {
2031
+ const j = JSON.parse(text);
2032
+ frag = j && typeof j === "object" ? (j.nodes && typeof j.nodes === "object" ? j.nodes : typeof j.op === "string" ? { [j.op]: j } : null) : null;
2033
+ } catch (e) { frag = null; }
2034
+ if (!frag || Object.values(frag).some((v) => !v || typeof v !== "object" || typeof v.op !== "string")) return false;
2035
+ pasteNodes(frag);
2036
+ return true;
2037
+ }
2038
+ function renameNode(old, nu) {
2039
+ nu = nu.trim();
2040
+ if (!nu || nu === old) return false;
2041
+ if (nu in S.doc.nodes) { status(`There is already a node called ${nu}.`, "warn"); return false; }
2042
+ if (["x", "y", "z", "pi", "e", ...Object.keys(FUNCS)].includes(nu)) { status(`${nu} is reserved.`, "warn"); return false; }
2043
+ const nodes = {};
2044
+ for (const [k, v] of Object.entries(S.doc.nodes)) nodes[k === old ? nu : k] = v;
2045
+ for (const s of Object.values(nodes)) renameRefs(s, old, nu);
2046
+ S.doc.nodes = nodes;
2047
+ if (S.doc.output === old) S.doc.output = nu;
2048
+ const dr = S.doc.drains;
2049
+ if (dr && typeof dr === "object") { if (dr.stress === old) dr.stress = nu; if (Array.isArray(dr.keepClear)) dr.keepClear = dr.keepClear.map((v) => (v === old ? nu : v)); }
2050
+ if (S.layout[old]) { S.layout[nu] = S.layout[old]; delete S.layout[old]; }
2051
+ if (S.bakes[old]) { S.bakes[nu] = S.bakes[old]; delete S.bakes[old]; }
2052
+ for (const k of ["sel", "target", "color"]) if (S[k] === old) S[k] = nu;
2053
+ return true;
2054
+ }
2055
+ function deleteNode(name) {
2056
+ const users = Object.keys(S.doc.nodes).filter((n) => n !== name && depsOf(n).includes(name));
2057
+ if (users.length) { status(`${name} is used by ${users.join(", ")}: rewire those first.`, "warn"); return; }
2058
+ delete S.doc.nodes[name];
2059
+ if (S.doc.output === name) S.doc.output = Object.keys(S.doc.nodes).pop();
2060
+ for (const k of ["sel", "target", "color"]) if (S[k] === name) S[k] = k === "target" ? S.doc.output : null;
2061
+ structural(); partPanel();
2062
+ }
2063
+ function addNode(op) {
2064
+ const c = S.box ? [0, 1, 2].map((i) => 0.5 * (S.box[0][i] + S.box[1][i])) : [0, 0, 0];
2065
+ let base = op, i = 1, name = op;
2066
+ while (name in S.doc.nodes) name = `${base}${++i}`;
2067
+ S.doc.nodes[name] = { op, ...OPS[op].make(c) };
2068
+ const sel = S.sel && S.layout[S.sel];
2069
+ S.layout[name] = sel ? [sel[0] + 210, sel[1] + 30] : [40 - G2.tx / G2.s, 50 - G2.ty / G2.s];
2070
+ S.sel = name;
2071
+ structural();
2072
+ }
2073
+ /** Wire `source` into `target`'s port `key` ("input", "inputs.2", "inputs.+", "vars.k", "part"). */
2074
+ function connect(target, key, source) {
2075
+ if (target === source) return;
2076
+ const spec = S.doc.nodes[target];
2077
+ const reaches = (from, to, seen = new Set()) => { if (from === to) return true; if (seen.has(from)) return false; seen.add(from); return depsOf(from).some((d) => reaches(d, to, seen)); };
2078
+ if (reaches(source, target)) { status(`${source} already depends on ${target}: that wire would make a loop.`, "warn"); return; }
2079
+ const [k, sub] = key.split(".");
2080
+ if (k === "inputs") { spec.inputs = spec.inputs || []; if (sub === "+") spec.inputs.push(source); else spec.inputs[+sub] = source; }
2081
+ else if (k === "vars") { spec.vars = spec.vars || {}; spec.vars[sub] = source; }
2082
+ else if (k === "gin") { spec.inputs = spec.inputs && !Array.isArray(spec.inputs) ? spec.inputs : {}; spec.inputs[sub] = source; }
2083
+ else if (k === "expr") return;
2084
+ else spec[k] = source;
2085
+ S.sel = target;
2086
+ structural();
2087
+ }
2088
+
2089
+ // ---- bakes: what the GPU cannot compute (meshes, results, grids, a graded lattice's warp) ---------------
2090
+
2091
+ const wantsWarp = (s) => s && s.op === "lattice" && typeof s.cell === "string" && (s.grading || "warp") === "warp" && !s.frame;
2092
+ /** Every bake the graph needs: a key into S.bakes, its node, and the kind the server bakes. */
2093
+ function bakeItems() {
2094
+ const out = [];
2095
+ for (const [name, s] of Object.entries(XN())) {
2096
+ if (BAKED.has(s.op)) out.push({ key: name, node: name, kind: "node" });
2097
+ else if (wantsWarp(s)) out.push({ key: `${name}#warp`, node: name, kind: "warp" });
2098
+ }
2099
+ return out;
2100
+ }
2101
+ function subtree(out, todo) {
2102
+ const nodes = XN();
2103
+ while (todo.length) {
2104
+ const n = todo.pop();
2105
+ if (n in out || !(n in nodes)) continue;
2106
+ out[n] = nodes[n];
2107
+ for (const r of refsOf(nodes[n], nodes)) todo.push(r.target);
2108
+ }
2109
+ return out;
2110
+ }
2111
+ /** What a bake depends on (editor.py's _closure and _warp_closure): compared with what it was baked from. */
2112
+ function closure(item) {
2113
+ const nodes = XN(), spec = nodes[item.node];
2114
+ if (item.kind === "warp") {
2115
+ const lat = {};
2116
+ for (const k of ["cell", "origin", "bounds", "grading"]) if (k in spec) lat[k] = spec[k];
2117
+ return subtree({ "#lattice": lat }, typeof spec.cell === "string" ? [spec.cell] : []);
2118
+ }
2119
+ const out = { [item.node]: spec };
2120
+ return spec.op === "result" && typeof spec.part === "string" && spec.part in nodes ? subtree(out, [spec.part]) : out;
2121
+ }
2122
+ const itemFor = (name) => bakeItems().find((i) => i.node === name);
2123
+ function bakeState(item) {
2124
+ if (typeof item === "string") item = itemFor(item);
2125
+ if (!item) return "none";
2126
+ const b = S.bakes[item.key];
2127
+ if (S.baking === item.key) return "baking";
2128
+ if (!b) return "missing";
2129
+ return stable(b.spec) === stable(closure(item)) ? "ok" : "stale";
2130
+ }
2131
+ const isAnalysis = (name) => S.doc.nodes[name] && S.doc.nodes[name].op === "result" && !!S.doc.nodes[name].study;
2132
+ const failed = {};
2133
+ function requestBakes(force) {
2134
+ if (!DATA.server || S.baking) return;
2135
+ for (const item of bakeItems()) {
2136
+ const st = bakeState(item);
2137
+ if (st === "ok") continue;
2138
+ const now = stable(closure(item));
2139
+ if (failed[item.key] === now && force !== item.key) continue;
2140
+ // an analysis is minutes: run it on load and when asked, not on every edit upstream
2141
+ if (st === "stale" && item.kind === "node" && isAnalysis(item.node) && force !== item.key) continue;
2142
+ bake(item);
2143
+ return;
2144
+ }
2145
+ }
2146
+ const bakesSoon = debounce(() => { requestBakes(); showNotes(); drawGraph(); }, 200);
2147
+ async function bake(item) {
2148
+ if (typeof item === "string") item = itemFor(item);
2149
+ S.baking = item.key;
2150
+ const what = item.kind === "warp" ? `${item.node}'s cell warp` : item.node;
2151
+ status(item.kind === "node" && isAnalysis(item.node) ? `Analysing ${item.node} (the study runs on the server; about a minute)…` : `Baking ${what}…`);
2152
+ drawGraph(); inspector();
2153
+ const sent = stable(closure(item));
2154
+ try {
2155
+ const r = await fetch("api/bake", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc: S.doc, node: item.node, kind: item.kind }) });
2156
+ const b = await r.json();
2157
+ if (!r.ok) throw new Error(b.error || r.statusText);
2158
+ S.bakes[item.key] = b;
2159
+ delete failed[item.key];
2160
+ status(`Baked ${what} in ${b.seconds} s.`, "ok");
2161
+ } catch (e) {
2162
+ status(`Could not bake ${what}: ${e.message}`, "warn");
2163
+ failed[item.key] = sent;
2164
+ }
2165
+ S.baking = null;
2166
+ recompile(); inspector();
2167
+ }
2168
+
2169
+ // ---- graph view -------------------------------------------------------------------------------------
2170
+
2171
+ const G2 = { tx: 20, ty: 46, s: 1 };
2172
+ const NW = 172, HEAD = 26, ROW = 17;
2173
+ function ports(name) {
2174
+ const s = S.doc.nodes[name], out = [];
2175
+ const spec = OPS[s.op];
2176
+ if (!spec) return out;
2177
+ const add = (key, label, v) => out.push({ key, label, target: typeof v === "string" && v in S.doc.nodes ? v : null, bad: typeof v === "string" && !(v in S.doc.nodes), num: typeof v === "number" ? v : null });
2178
+ for (const p of spec.params) {
2179
+ if (p.t === "field") { if (p.opt && s[p.k] == null) continue; if (p.alt && s[p.k] == null && s[p.alt] != null) continue; add(p.k, p.k, s[p.k]); }
2180
+ if (p.t === "list") { (s.inputs || []).forEach((v, i) => add(`inputs.${i}`, `in ${i + 1}`, v)); add("inputs.+", "+ input", null); }
2181
+ if (p.t === "vars") for (const [k, v] of Object.entries(s.vars || {})) add(`vars.${k}`, k, v);
2182
+ if (p.t === "distance" && s.input != null) add("input", "input", s.input);
2183
+ if (p.t === "part" && typeof s.part === "string" && s.part in S.doc.nodes) add("part", "part", s.part);
2184
+ }
2185
+ if (s.op === "group") {
2186
+ const g = (S.doc.groups || {})[s.group] || {};
2187
+ for (const k of g.inputs || []) add(`gin.${k}`, k, (s.inputs || {})[k]);
2188
+ }
2189
+ if (s.op === "expr") for (const r of refsOf(s, S.doc.nodes)) if (r.key === "expr") out.push({ key: "expr", label: `· ${r.target}`, target: r.target in S.doc.nodes ? r.target : null, bad: !(r.target in S.doc.nodes), fixed: true });
2190
+ return out;
2191
+ }
2192
+ function autoLayout(keep = false) {
2193
+ const names = Object.keys(S.doc.nodes);
2194
+ // columns as late as they can be: each node one left of its first user, so a leaf sits by what reads it
2195
+ const users = {};
2196
+ for (const n of names) for (const x of depsOf(n)) (users[x] = users[x] || []).push(n);
2197
+ const height = {};
2198
+ const hgt = (n, seen = new Set()) => {
2199
+ if (n in height) return height[n];
2200
+ if (seen.has(n)) return 0;
2201
+ seen.add(n);
2202
+ const us = users[n] || [];
2203
+ return (height[n] = us.length ? 1 + Math.max(...us.map((x) => hgt(x, seen))) : 0);
2204
+ };
2205
+ names.forEach((n) => hgt(n));
2206
+ const top = Math.max(0, ...Object.values(height));
2207
+ const depth = Object.fromEntries(names.map((n) => [n, top - height[n]]));
2208
+ const cols = {};
2209
+ for (const n of names) (cols[depth[n]] = cols[depth[n]] || []).push(n);
2210
+ // order each column by the mean row of what feeds it, so wires cross less
2211
+ const row = {};
2212
+ Object.keys(cols).map(Number).sort((a, b) => a - b).forEach((c) => {
2213
+ const col = cols[c];
2214
+ if (c > 0) col.sort((a, b) => {
2215
+ const m = (n) => { const ds = depsOf(n).filter((x) => x in row); return ds.length ? ds.reduce((s, x) => s + row[x], 0) / ds.length : 0; };
2216
+ return m(a) - m(b);
2217
+ });
2218
+ let y = 0;
2219
+ col.forEach((n) => { row[n] = y; if (!(keep && S.layout[n])) S.layout[n] = [c * 214, y]; y += HEAD + 12 + Math.max(1, ports(n).length) * ROW + 18; });
2220
+ });
2221
+ }
2222
+ function drawGraph() {
2223
+ const gN = $("#nodes"), gW = $("#wires");
2224
+ gN.replaceChildren(); gW.replaceChildren();
2225
+ const bad = new Set(S.issues.map((i) => i.node));
2226
+ const portPos = {};
2227
+ for (const name of Object.keys(S.doc.nodes)) {
2228
+ if (!S.layout[name]) autoLayout(true);
2229
+ const [x, y] = S.layout[name];
2230
+ const ps = ports(name), kind = kindOf(name), op = S.doc.nodes[name].op;
2231
+ const h = HEAD + 6 + Math.max(1, ps.length) * ROW + 4;
2232
+ const g = svg("g", { class: `gn${S.sel === name ? " sel" : ""}${bad.has(name) ? " bad" : ""}`, transform: `translate(${x},${y})`, "data-node": name });
2233
+ g.append(svg("rect", { class: "body", width: NW, height: h, rx: 7 }));
2234
+ g.append(svg("rect", { class: "stripe", width: 4, height: h - 10, x: 0, y: 5, rx: 2, fill: `var(--${kind})` }));
2235
+ const head = svg("rect", { class: "head", width: NW, height: HEAD, fill: "transparent" });
2236
+ g.append(head);
2237
+ const t = svg("text", { class: "name", x: 12, y: 17 }); t.textContent = name.length > 17 ? name.slice(0, 16) + "…" : name; g.append(t);
2238
+ const o = svg("text", { x: NW - 14, y: 17, "text-anchor": "end" }); o.textContent = op; g.append(o);
2239
+ let bx = 12;
2240
+ const badge = (txt, color) => { const b = svg("text", { class: "badge", x: bx, y: h + 12, fill: color }); b.textContent = txt; g.append(b); bx += txt.length * 6.6 + 8; };
2241
+ if (S.doc.output === name) badge("OUTPUT", "var(--accent)");
2242
+ if (S.target === name && S.doc.output !== name) badge("SHOWN", "var(--accent)");
2243
+ if (S.color === name) badge("COLOUR", "var(--scalar)");
2244
+ if (BAKED.has(op) || wantsWarp(S.doc.nodes[name])) {
2245
+ const st = bakeState(name), w = op === "lattice" ? "WARP " : "";
2246
+ badge(w + (st === "ok" ? (w ? "OK" : "BAKED") : st.toUpperCase()), st === "ok" ? "var(--muted)" : "var(--warn)");
2247
+ }
2248
+ ps.forEach((p, i) => {
2249
+ const py = HEAD + 6 + i * ROW + ROW / 2;
2250
+ const c = svg("circle", { class: `port${p.target ? " on" : ""}`, cx: 0, cy: py, r: 4.2, "data-node": name, "data-key": p.key });
2251
+ if (p.fixed) c.setAttribute("data-fixed", "1");
2252
+ g.append(c);
2253
+ const lt = svg("text", { x: 12, y: py + 4 });
2254
+ lt.textContent = p.label + (p.num != null ? ` ${fmt(p.num)}` : p.target ? ` ← ${p.target.length > 10 ? p.target.slice(0, 9) + "…" : p.target}` : p.bad ? " ?" : "");
2255
+ g.append(lt);
2256
+ portPos[`${name}|${p.key}|${i}`] = { x, y: y + py, target: p.target, node: name };
2257
+ });
2258
+ const outp = svg("circle", { class: "outp", cx: NW, cy: HEAD / 2, r: 5, fill: `var(--${kind})`, stroke: "var(--panel)", "data-out": name });
2259
+ g.append(outp);
2260
+ gN.append(g);
2261
+ }
2262
+ for (const pp of Object.values(portPos)) {
2263
+ if (!pp.target || !S.layout[pp.target]) continue;
2264
+ const [sx, sy] = S.layout[pp.target];
2265
+ const x0 = sx + NW, y0 = sy + HEAD / 2, x1 = pp.x, y1 = pp.y, dx = Math.max(40, Math.abs(x1 - x0) * 0.45);
2266
+ const hl = S.sel && (S.sel === pp.node || S.sel === pp.target);
2267
+ gW.append(svg("path", { class: `wire ${kindOf(pp.target)}${hl ? " hl" : ""}`, d: `M${x0},${y0} C${x0 + dx},${y0} ${x1 - dx},${y1} ${x1},${y1}` }));
2268
+ }
2269
+ $("#world").setAttribute("transform", `translate(${G2.tx},${G2.ty}) scale(${G2.s})`);
2270
+ }
2271
+ function graphFit() {
2272
+ const r = $("#graph").getBoundingClientRect();
2273
+ const xs = Object.values(S.layout);
2274
+ if (!xs.length || !r.width) return;
2275
+ const lo = [Math.min(...xs.map((p) => p[0])), Math.min(...xs.map((p) => p[1]))];
2276
+ const hi = [Math.max(...xs.map((p) => p[0])) + NW, Math.max(...xs.map((p) => p[1])) + 120];
2277
+ G2.s = Math.min(1.2, (r.width - 40) / (hi[0] - lo[0]), (r.height - 70) / (hi[1] - lo[1]));
2278
+ G2.s = Math.max(G2.s, 0.3);
2279
+ G2.tx = 20 - lo[0] * G2.s; G2.ty = 48 - lo[1] * G2.s;
2280
+ drawGraph();
2281
+ }
2282
+ (function graphInput() {
2283
+ const el2 = $("#graph");
2284
+ let drag = null;
2285
+ const world = (e) => { const r = el2.getBoundingClientRect(); return [(e.clientX - r.left - G2.tx) / G2.s, (e.clientY - r.top - G2.ty) / G2.s]; };
2286
+ el2.addEventListener("pointerdown", (e) => {
2287
+ const out = e.target.closest("[data-out]"), node = e.target.closest(".gn");
2288
+ el2.setPointerCapture(e.pointerId);
2289
+ if (out) { drag = { kind: "wire", from: out.dataset.out }; return; }
2290
+ if (node && !e.target.classList.contains("port")) {
2291
+ const name = node.dataset.node, p = world(e);
2292
+ drag = { kind: "node", name, off: [p[0] - S.layout[name][0], p[1] - S.layout[name][1]], moved: false };
2293
+ if (S.sel !== name) { S.sel = name; if (S.follow && kindOf(name) === "solid") S.target = name; afterSelect(); }
2294
+ return;
2295
+ }
2296
+ if (node && e.target.classList.contains("port")) { S.sel = node.dataset.node; afterSelect(); return; }
2297
+ drag = { kind: "pan", x: e.clientX - G2.tx, y: e.clientY - G2.ty };
2298
+ el2.classList.add("panning");
2299
+ });
2300
+ el2.addEventListener("pointermove", (e) => {
2301
+ if (!drag) return;
2302
+ if (drag.kind === "pan") { G2.tx = e.clientX - drag.x; G2.ty = e.clientY - drag.y; $("#world").setAttribute("transform", `translate(${G2.tx},${G2.ty}) scale(${G2.s})`); }
2303
+ else if (drag.kind === "node") { const p = world(e); S.layout[drag.name] = [p[0] - drag.off[0], p[1] - drag.off[1]]; drag.moved = true; drawGraph(); }
2304
+ else if (drag.kind === "wire") {
2305
+ const [sx, sy] = S.layout[drag.from], x0 = sx + NW, y0 = sy + HEAD / 2, [x1, y1] = world(e), dx = Math.max(40, Math.abs(x1 - x0) * 0.45);
2306
+ const w = $("#dragwire"); w.hidden = false; w.setAttribute("class", `wire ${kindOf(drag.from)} hl`);
2307
+ w.setAttribute("d", `M${x0},${y0} C${x0 + dx},${y0} ${x1 - dx},${y1} ${x1},${y1}`);
2308
+ document.querySelectorAll(".port.hot").forEach((p) => p.classList.remove("hot"));
2309
+ const under = document.elementFromPoint(e.clientX, e.clientY);
2310
+ if (under && under.classList.contains("port") && !under.dataset.fixed) under.classList.add("hot");
2311
+ }
2312
+ });
2313
+ const end = (e) => {
2314
+ if (drag && drag.kind === "wire") {
2315
+ $("#dragwire").hidden = true;
2316
+ const under = document.elementFromPoint(e.clientX, e.clientY);
2317
+ if (under && under.classList.contains("port") && !under.dataset.fixed) connect(under.dataset.node, under.dataset.key, drag.from);
2318
+ else drawGraph();
2319
+ }
2320
+ if (drag && drag.kind === "node" && drag.moved) commitSoon();
2321
+ drag = null; el2.classList.remove("panning");
2322
+ };
2323
+ el2.addEventListener("pointerup", end);
2324
+ el2.addEventListener("pointercancel", end);
2325
+ el2.addEventListener("wheel", (e) => {
2326
+ e.preventDefault();
2327
+ const r = el2.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
2328
+ const f = Math.exp(-e.deltaY * 0.0015), s = Math.min(2.5, Math.max(0.25, G2.s * f));
2329
+ G2.tx = mx - (mx - G2.tx) * (s / G2.s); G2.ty = my - (my - G2.ty) * (s / G2.s); G2.s = s;
2330
+ $("#world").setAttribute("transform", `translate(${G2.tx},${G2.ty}) scale(${G2.s})`);
2331
+ }, { passive: false });
2332
+ })();
2333
+ function afterSelect() { drawGraph(); inspector(); fillSelects(); fillFitParams(); recompileSoon(); }
2334
+
2335
+ // ---- inspector ----------------------------------------------------------------------------------------
2336
+
2337
+ function niceStep(span) { const m = span / 200, p = Math.pow(10, Math.floor(Math.log10(m))); return [1, 2, 5, 10].map((k) => k * p).find((s) => s >= m) || p; }
2338
+ function rangeFor(p, v) {
2339
+ if (p.range) return [Math.min(p.range[0], v), Math.max(p.range[1], v)];
2340
+ const a = Math.abs(v);
2341
+ if (v > 0) return [0, Math.max(v * 3, 1)];
2342
+ if (v < 0) return [v * 3, -v * 3];
2343
+ return [0, 10];
2344
+ }
2345
+ function slider(path, v, p = {}) {
2346
+ const [lo, hi] = rangeFor(p, v);
2347
+ const step = niceStep(hi - lo);
2348
+ const r = el("input", { type: "range", min: lo, max: hi, step, value: v, "aria-label": path.join(".") });
2349
+ const n = el("input", { type: "number", step, value: +(+v).toPrecision(6), "aria-label": path.join(".") });
2350
+ r.addEventListener("input", () => { n.value = r.value; setAt(S.doc, path, +r.value); paramChanged(path); });
2351
+ n.addEventListener("input", () => { if (n.value === "" || !isFinite(+n.value)) return; const x = +n.value; if (x < +r.min) r.min = x; if (x > +r.max) r.max = x; r.value = x; setAt(S.doc, path, x); paramChanged(path); });
2352
+ return el("div", { class: "row" }, r, n);
2353
+ }
2354
+ function scrubNum(path, v, label) {
2355
+ const n = el("input", { type: "number", value: +(+v).toPrecision(6), step: "any", "aria-label": `${path.join(".")}` });
2356
+ n.addEventListener("input", () => { if (n.value === "" || !isFinite(+n.value)) return; setAt(S.doc, path, +n.value); paramChanged(path); });
2357
+ const lab = el("label", { title: "drag to change" }, label);
2358
+ lab.addEventListener("pointerdown", (e) => {
2359
+ lab.setPointerCapture(e.pointerId);
2360
+ const x0 = e.clientX, v0 = +n.value || 0;
2361
+ const size = S.box ? Math.max(...[0, 1, 2].map((i) => S.box[1][i] - S.box[0][i])) : 100;
2362
+ const step = niceStep(size * 2);
2363
+ const mv = (ev) => { const x = +(v0 + Math.round((ev.clientX - x0) / 2) * step).toPrecision(8); n.value = x; setAt(S.doc, path, x); paramChanged(path); };
2364
+ const up = () => { lab.removeEventListener("pointermove", mv); lab.removeEventListener("pointerup", up); };
2365
+ lab.addEventListener("pointermove", mv); lab.addEventListener("pointerup", up);
2366
+ });
2367
+ return el("label", {}, lab, n);
2368
+ }
2369
+ function vec3(path, v) { return el("div", { class: "vec" }, ...["x", "y", "z"].map((a, i) => scrubNum([...path, i], v[i], a))); }
2370
+ function nodeOptions(sel, filter) {
2371
+ return Object.keys(S.doc.nodes).filter((n) => !filter || filter(n)).map((n) => el("option", { value: n, selected: n === sel }, `${n} (${S.doc.nodes[n].op})`));
2372
+ }
2373
+ function param(label, ...kids) { return el("div", { class: "param" }, el("div", { class: "lab" }, ...(Array.isArray(label) ? label : [el("span", {}, label)])), ...kids); }
2374
+ function removeBtn(path, title = "remove") { return el("button", { class: "btn ghost small", title, onclick: () => { setAt(S.doc, path, undefined); structural(); } }, "×"); }
2375
+
2376
+ /** A field parameter: a number with a slider, or a wire from another node. */
2377
+ function fieldParam(name, p, path, opt) {
2378
+ const v = getAt(S.doc, path);
2379
+ if (v == null && typeof p.def === "number") return fieldParam0(name, p, path, opt, p.def);
2380
+ return fieldParam0(name, p, path, opt, v);
2381
+ }
2382
+ function fieldParam0(name, p, path, opt, v) {
2383
+ const isRef = typeof v === "string";
2384
+ const sel = el("select", { "aria-label": `${p.k} source` },
2385
+ el("option", { value: "#num", selected: !isRef }, "number"),
2386
+ ...nodeOptions(isRef ? v : null, (n) => n !== name && (!p.solid || kindOf(n) === "solid")));
2387
+ sel.addEventListener("change", () => {
2388
+ if (sel.value === "#num") { setAt(S.doc, path, typeof p.def === "number" ? p.def : 1); structural(); }
2389
+ else connect(name, path.slice(2).join("."), sel.value);
2390
+ });
2391
+ const head = [el("b", {}, path.slice(2).join(".")), el("span", { class: "row" }, sel, opt ? removeBtn(path) : null)];
2392
+ return param(head, isRef ? el("p", { class: "note" }, `driven by ${v} (${kindOf(v)})`) : typeof v === "number" ? slider(path, v, p) : null);
2393
+ }
2394
+ function enumParam(path, opts, v) {
2395
+ const s = el("select", {}, ...opts.map((o) => el("option", { value: o, selected: o === v }, o)));
2396
+ s.addEventListener("change", () => { setAt(S.doc, path, s.value); structural(); });
2397
+ return s;
2398
+ }
2399
+ function axisParam(path, v) {
2400
+ const isVec = Array.isArray(v);
2401
+ const s = el("select", {}, ...["x", "y", "z", "vector"].map((o) => el("option", { value: o, selected: isVec ? o === "vector" : o === (v || "z") }, o)));
2402
+ s.addEventListener("change", () => { setAt(S.doc, path, s.value === "vector" ? [0, 0, 1] : s.value); structural(); });
2403
+ return el("div", {}, s, isVec ? vec3(path, v) : null);
2404
+ }
2405
+ function shapeEditor(path, s, depth = 0) {
2406
+ const box = el("div", { class: "sub" });
2407
+ const type = el("select", {}, ...["box", "cylinder", "sphere", "union"].map((t) => el("option", { value: t, selected: t === (s.type === "tube" ? "cylinder" : s.type) }, t)));
2408
+ type.addEventListener("change", () => {
2409
+ const b = shapeBounds(s) || [[-10, -10, -10], [10, 10, 10]];
2410
+ const c = [0, 1, 2].map((i) => +(0.5 * (b[0][i] + b[1][i])).toFixed(3)), e = Math.max(...[0, 1, 2].map((i) => b[1][i] - b[0][i])) / 2;
2411
+ const t = type.value;
2412
+ setAt(S.doc, path, t === "box" ? { type: "box", min: b[0], max: b[1] } : t === "cylinder" ? { type: "cylinder", center: c, axis: "z", length: 2 * e, r: e / 2 }
2413
+ : t === "sphere" ? { type: "sphere", center: c, r: e } : { type: "union", shapes: [clone(s)] });
2414
+ structural();
2415
+ });
2416
+ box.append(type);
2417
+ if (s.type === "box") {
2418
+ if ("min" in s) { box.append(param("min", vec3([...path, "min"], s.min)), param("max", vec3([...path, "max"], s.max))); }
2419
+ else box.append(param("center", vec3([...path, "center"], s.center)), param("size", vec3([...path, "size"], s.size)));
2420
+ } else if (s.type === "cylinder" || s.type === "tube") {
2421
+ if ("p0" in s) box.append(param("p0", vec3([...path, "p0"], s.p0)), param("p1", vec3([...path, "p1"], s.p1)));
2422
+ else box.append(param("center", vec3([...path, "center"], s.center)), param("axis", axisParam([...path, "axis"], s.axis)), param("length", slider([...path, "length"], s.length, { range: [0.5, Math.max(s.length * 3, 50)] })));
2423
+ box.append(param("r", slider([...path, "r"], s.r, { range: [0.1, Math.max(s.r * 3, 20)] })));
2424
+ if (s.rInner != null) box.append(param([el("span", {}, "rInner"), removeBtn([...path, "rInner"])], slider([...path, "rInner"], s.rInner, { range: [0, s.r] })));
2425
+ else box.append(el("button", { class: "btn small", onclick: () => { setAt(S.doc, [...path, "rInner"], +(s.r / 2).toFixed(2)); structural(); } }, "+ bore (rInner)"));
2426
+ } else if (s.type === "sphere") {
2427
+ box.append(param("center", vec3([...path, "center"], s.center)), param("r", slider([...path, "r"], s.r, { range: [0.1, Math.max(s.r * 3, 20)] })));
2428
+ } else if (s.type === "union") {
2429
+ (s.shapes || []).forEach((x, i) => box.append(param([el("span", {}, `shape ${i + 1}`), removeBtn([...path, "shapes", i])], shapeEditor([...path, "shapes", i], x, depth + 1))));
2430
+ box.append(el("button", { class: "btn small", onclick: () => { s.shapes = s.shapes || []; s.shapes.push(clone(s.shapes[s.shapes.length - 1] || { type: "sphere", center: [0, 0, 0], r: 5 })); structural(); } }, "+ shape"));
2431
+ }
2432
+ return box;
2433
+ }
2434
+
2435
+ function inspector() {
2436
+ const body = $("#node-body"), act = $("#node-actions");
2437
+ body.replaceChildren(); act.replaceChildren();
2438
+ const name = S.sel;
2439
+ if (!name || !(name in S.doc.nodes)) { body.append(el("p", { class: "note" }, "Pick a node in the graph, or add one.")); return; }
2440
+ const s = S.doc.nodes[name], spec = OPS[s.op];
2441
+ const nm = el("input", { type: "text", value: name, "aria-label": "Node name", spellcheck: "false" });
2442
+ nm.addEventListener("change", () => { if (renameNode(name, nm.value)) structural(); else nm.value = name; });
2443
+ const ops = el("select", { "aria-label": "Operation" }, ...Object.keys(OPS).map((o) => el("option", { value: o, selected: o === s.op }, o)));
2444
+ ops.addEventListener("change", () => {
2445
+ const keep = {};
2446
+ for (const k of ["input", "inputs", "description"]) if (k in s) keep[k] = s[k];
2447
+ S.doc.nodes[name] = { op: ops.value, ...OPS[ops.value].make([0, 0, 0]), ...keep };
2448
+ structural();
2449
+ });
2450
+ act.append(el("button", { class: "btn ghost small", title: "Make this the part that is meshed", onclick: () => { S.doc.output = name; S.target = name; structural(); partPanel(); } }, S.doc.output === name ? "output ✓" : "set output"),
2451
+ el("button", { class: "btn ghost small", title: "Duplicate node (Ctrl+D; Ctrl+C / Ctrl+V copy between graphs)", onclick: () => duplicateNode(name) }, "duplicate"),
2452
+ ...(kindOf(name) === "solid" && S.doc.output !== name ? [el("button", { class: "btn ghost small", title: "Mesh just this node at full resolution (STL, report), not the graph's output", onclick: () => run(name) }, "mesh")] : []),
2453
+ s.op === "group" ? el("button", { class: "btn ghost small", title: "Put the group's nodes back into the graph here", onclick: () => ungroup(name) }, "ungroup")
2454
+ : el("button", { class: "btn ghost small", title: "Make this node and what only it uses a reusable group (its constants become the group's knobs)", onclick: () => makeGroup(name) }, "make group"),
2455
+ el("button", { class: "btn ghost small", title: "Delete node", onclick: () => deleteNode(name) }, "delete"));
2456
+ body.append(el("div", { class: "row" }, nm, el("span", { class: `chip ${kindOf(name)}` }, kindOf(name))));
2457
+ body.append(ops);
2458
+ body.append(el("p", { class: "note" }, s.description || (spec ? spec.doc : "")));
2459
+ if (!spec) return;
2460
+ const N = (...k) => ["nodes", name, ...k];
2461
+ const optional = [];
2462
+ for (const p of spec.params) {
2463
+ const v = s[p.k];
2464
+ if (p.opt && v == null && !["frame", "rotate"].includes(p.t)) { if (!(p.alt && s[p.alt] == null && p.k === "thickness")) optional.push(p); continue; }
2465
+ if (p.alt && v == null) { optional.push(p); continue; }
2466
+ switch (p.t) {
2467
+ case "field": body.append(fieldParam(name, p, N(p.k), p.opt || (p.alt && s[p.alt] != null))); break;
2468
+ case "num": body.append(param([el("b", {}, p.k), p.opt ? removeBtn(N(p.k)) : null], slider(N(p.k), v ?? p.def, p))); break;
2469
+ case "enum": body.append(param(p.k, enumParam(N(p.k), p.opts, v ?? p.def))); break;
2470
+ case "bool": {
2471
+ const c = el("input", { type: "checkbox", checked: v ?? p.def });
2472
+ c.addEventListener("change", () => { setAt(S.doc, N(p.k), c.checked); structural(); });
2473
+ body.append(el("label", { class: "row note" }, c, p.k)); break;
2474
+ }
2475
+ case "text": {
2476
+ const t = el("input", { type: "text", value: v ?? "", spellcheck: "false" });
2477
+ t.addEventListener("change", () => { setAt(S.doc, N(p.k), t.value); structural(); });
2478
+ body.append(param([el("b", {}, p.k), p.opt ? removeBtn(N(p.k)) : null], t)); break;
2479
+ }
2480
+ case "vec3": body.append(param([el("b", {}, p.k), p.opt ? removeBtn(N(p.k)) : null], vec3(N(p.k), v ?? p.def))); break;
2481
+ case "axis": body.append(param(p.k, axisParam(N(p.k), v ?? p.def))); break;
2482
+ case "pair": {
2483
+ const arr = v || p.def;
2484
+ if (arr.length > 2) {
2485
+ // a remap table: the whole list, edited as text
2486
+ const t = el("input", { type: "text", value: arr.join(", "), spellcheck: "false", "aria-label": p.k });
2487
+ t.addEventListener("change", () => { const xs = t.value.split(/[ ,]+/).filter(Boolean).map(Number); if (xs.length >= 2 && xs.every(isFinite)) { setAt(S.doc, N(p.k), xs); structural(); } else t.value = arr.join(", "); });
2488
+ body.append(param(`${p.k} (table)`, t)); break;
2489
+ }
2490
+ body.append(param(p.k, el("div", { class: "vec", style: "grid-template-columns:1fr 1fr" }, scrubNum(N(p.k, 0), arr[0], "a"), scrubNum(N(p.k, 1), arr[1], "b")))); break;
2491
+ }
2492
+ case "curve": body.append(param("curve", curveEditor(N("curve"), s))); break;
2493
+ case "group": body.append(groupEditor(name, s)); break;
2494
+ case "shape": body.append(param("shape", shapeEditor(N("shape"), s.shape || {}))); break;
2495
+ case "list": {
2496
+ const list = el("div", { class: "sub" });
2497
+ (s.inputs || []).forEach((x, i) => list.append(fieldParam(name, { k: `in ${i + 1}`, solid: true, def: 0 }, N("inputs", i), true)));
2498
+ const add = el("select", { "aria-label": "Add an input" }, el("option", { value: "" }, "+ input…"), ...nodeOptions(null, (n) => n !== name && kindOf(n) === "solid"));
2499
+ add.addEventListener("change", () => { if (add.value) connect(name, "inputs.+", add.value); });
2500
+ list.append(add);
2501
+ body.append(param("inputs", list)); break;
2502
+ }
2503
+ case "code": {
2504
+ const t = el("textarea", { rows: 3, spellcheck: "false" }); t.value = v ?? "";
2505
+ const msg = el("p", { class: "note" });
2506
+ const check = () => { try { parseExpr(t.value); msg.textContent = ""; return true; } catch (e) { msg.textContent = e.message; msg.className = "note warn"; return false; } };
2507
+ check();
2508
+ t.addEventListener("input", debounce(() => { setAt(S.doc, N("expr"), t.value); if (check()) structural(); else { S.issues = validate(); showIssues(); } }, 250));
2509
+ body.append(param("expr", t, msg)); break;
2510
+ }
2511
+ case "vars": {
2512
+ const list = el("div", { class: "sub" });
2513
+ for (const k of Object.keys(s.vars || {})) list.append(fieldParam(name, { k, def: 1 }, N("vars", k), true));
2514
+ const nk = el("input", { type: "text", placeholder: "new var name", spellcheck: "false" });
2515
+ nk.addEventListener("change", () => { const k = nk.value.trim(); if (/^[A-Za-z_]\w*$/.test(k)) { setAt(S.doc, N("vars", k), 1); structural(); } });
2516
+ list.append(nk);
2517
+ body.append(param("vars", list)); break;
2518
+ }
2519
+ case "distance": {
2520
+ const mode = s.input != null ? "input" : s.points ? "points" : s.segment ? "segment" : s.plane ? "plane" : s.shape ? "shape" : "input";
2521
+ const m = el("select", {}, ...["input", "points", "segment", "plane", "shape"].map((o) => el("option", { value: o, selected: o === mode }, `to ${o === "input" ? "a solid node" : o}`)));
2522
+ m.addEventListener("change", () => {
2523
+ for (const k of ["input", "points", "segment", "plane", "shape", "signed"]) delete s[k];
2524
+ const c = S.box ? [0, 1, 2].map((i) => +(0.5 * (S.box[0][i] + S.box[1][i])).toFixed(2)) : [0, 0, 0];
2525
+ Object.assign(s, { input: { input: Object.keys(S.doc.nodes).find((n) => n !== name && kindOf(n) === "solid") || 0 }, points: { points: [c] },
2526
+ segment: { segment: [c, c.map((v, i) => v + (i === 0 ? 20 : 0))] }, plane: { plane: { point: c, normal: [0, 0, 1] } },
2527
+ shape: { shape: { type: "sphere", center: c, r: 10 } } }[m.value]);
2528
+ structural();
2529
+ });
2530
+ body.append(m);
2531
+ if (mode === "input") {
2532
+ body.append(fieldParam(name, { k: "input", solid: true, def: 0 }, N("input")));
2533
+ const c = el("input", { type: "checkbox", checked: !!s.signed });
2534
+ c.addEventListener("change", () => { s.signed = c.checked || undefined; if (!c.checked) delete s.signed; structural(); });
2535
+ body.append(el("label", { class: "row note" }, c, "signed (negative inside)"));
2536
+ } else if (mode === "points") {
2537
+ s.points.forEach((pt, i) => body.append(param([el("span", {}, `point ${i + 1}`), s.points.length > 1 ? removeBtn(N("points", i)) : null], vec3(N("points", i), pt))));
2538
+ body.append(el("button", { class: "btn small", onclick: () => { s.points.push(s.points[s.points.length - 1].slice()); structural(); } }, "+ point"));
2539
+ } else if (mode === "segment") body.append(param("from", vec3(N("segment", 0), s.segment[0])), param("to", vec3(N("segment", 1), s.segment[1])));
2540
+ else if (mode === "plane") body.append(param("point", vec3(N("plane", "point"), s.plane.point)), param("normal", vec3(N("plane", "normal"), s.plane.normal)));
2541
+ else body.append(param("shape", shapeEditor(N("shape"), s.shape)));
2542
+ break;
2543
+ }
2544
+ case "frame": {
2545
+ const fr = s.frame || { type: "cartesian" };
2546
+ const t = enumParam(N("frame", "type"), ["cartesian", "cylindrical"], fr.type);
2547
+ t.addEventListener("change", () => { if (t.value === "cartesian") delete s.frame; else s.frame = { type: "cylindrical", axis: "z", center: [0, 0, 0], radius: 20 }; structural(); }, { capture: true });
2548
+ const f = el("div", { class: "sub" }, t);
2549
+ if (fr.type === "cylindrical") f.append(param("axis", axisParam(N("frame", "axis"), fr.axis || "z")), param("center", vec3(N("frame", "center"), fr.center || [0, 0, 0])), param("radius", slider(N("frame", "radius"), fr.radius || 20, { range: [1, 200] })));
2550
+ body.append(param("frame", f)); break;
2551
+ }
2552
+ case "rotate": {
2553
+ if (!s.rotate) { optional.push(p); break; }
2554
+ body.append(param([el("b", {}, "rotate"), removeBtn(N("rotate"))], el("div", { class: "sub" },
2555
+ param("axis", axisParam(N("rotate", "axis"), s.rotate.axis || "z")), param("degrees", slider(N("rotate", "degrees"), s.rotate.degrees ?? 0, { range: [-180, 180] })),
2556
+ param("center", vec3(N("rotate", "center"), s.rotate.center || [0, 0, 0]))))); break;
2557
+ }
2558
+ case "scale": {
2559
+ const isPeak = v === "peak";
2560
+ const m = el("select", {}, ...["peak", "number"].map((o) => el("option", { value: o, selected: (o === "peak") === isPeak }, o === "peak" ? "fraction of its peak" : "divide by a number")));
2561
+ m.addEventListener("change", () => { setAt(S.doc, N("scale"), m.value === "peak" ? "peak" : 1); structural(); });
2562
+ body.append(param([el("b", {}, "scale"), removeBtn(N("scale"))], m, isPeak ? null : slider(N("scale"), +v || 1, { range: [0.01, 100] }))); break;
2563
+ }
2564
+ case "part": {
2565
+ const isNode = typeof v === "string" && v in S.doc.nodes;
2566
+ const sel = el("select", {}, el("option", { value: "#path", selected: !isNode }, "a mesh file…"), ...nodeOptions(isNode ? v : null, (n) => n !== name && kindOf(n) === "solid"));
2567
+ sel.addEventListener("change", () => { setAt(S.doc, N("part"), sel.value === "#path" ? "part.stl" : sel.value); structural(); });
2568
+ const path = isNode ? null : el("input", { type: "text", value: v || "", spellcheck: "false" });
2569
+ if (path) path.addEventListener("change", () => { setAt(S.doc, N("part"), path.value); structural(); });
2570
+ body.append(param([el("b", {}, "part"), removeBtn(N("part"))], sel, path)); break;
2571
+ }
2572
+ }
2573
+ }
2574
+ const item = itemFor(name);
2575
+ if (item) {
2576
+ const st = bakeState(item), b = S.bakes[item.key];
2577
+ const msg = { ok: `Baked on a ${b ? b.dims.join(" × ") : ""} grid${b && b.seconds ? ` in ${b.seconds} s` : ""}.`, stale: "Baked from an earlier version of this node (or what it analyzes).",
2578
+ missing: DATA.server ? "Not baked yet." : "Not baked: open the graph with `loadpath edit` to bake it.", baking: "Baking…" }[st];
2579
+ body.append(el("p", { class: `note ${st === "ok" ? "" : "warn"}` }, msg));
2580
+ if (b && b.analysis) body.append(el("p", { class: "note" }, Object.entries(b.analysis).slice(0, 4).map(([k, x]) => `${k} ${typeof x === "number" ? fmt(x) : x}`).join(" · ")));
2581
+ if (item.kind === "warp") body.append(el("p", { class: "note" }, "The preview draws the graded cells through the same warp the mesher solves (on a coarser grid)."));
2582
+ if (DATA.server && st !== "baking") body.append(el("button", { class: "btn small", disabled: !!S.baking, onclick: () => bake(item) }, isAnalysis(name) ? (st === "ok" ? "Re-run analysis" : "Run analysis") : "Re-bake"));
2583
+ }
2584
+ if (optional.length) {
2585
+ const add = el("select", { "aria-label": "Add a parameter" }, el("option", { value: "" }, "+ parameter…"), ...optional.map((p) => el("option", { value: p.k }, p.k)));
2586
+ add.addEventListener("change", () => {
2587
+ const p = optional.find((x) => x.k === add.value);
2588
+ if (!p) return;
2589
+ if (p.alt) delete s[p.alt];
2590
+ s[p.k] = p.t === "rotate" ? { axis: "z", degrees: 0 } : p.t === "scale" ? "peak" : p.t === "part" ? (Object.keys(S.doc.nodes).find((n) => n !== name && kindOf(n) === "solid") || "part.stl") : clone(p.def ?? 0);
2591
+ structural();
2592
+ });
2593
+ body.append(add);
2594
+ }
2595
+ const d = el("input", { type: "text", value: s.description || "", placeholder: "description", spellcheck: "false" });
2596
+ d.addEventListener("change", () => { if (d.value) s.description = d.value; else delete s.description; commit(); });
2597
+ body.append(d);
2598
+ }
2599
+
2600
+ // ---- panels -------------------------------------------------------------------------------------------
2601
+
2602
+ function fillSelects() {
2603
+ const names = Object.keys(S.doc.nodes);
2604
+ const solids = names.filter((n) => kindOf(n) === "solid"), scalars = names.filter((n) => kindOf(n) === "scalar");
2605
+ $("#target").replaceChildren(el("option", { value: "#follow", selected: S.follow }, `follow selection${S.follow && S.target ? ` (${S.target})` : ""}`),
2606
+ ...solids.map((n) => el("option", { value: n, selected: !S.follow && n === S.target }, n + (n === S.doc.output ? " · output" : ""))));
2607
+ $("#colorby").replaceChildren(el("option", { value: "", selected: !S.color && !S.walls && !S.over && !S.stress }, "plain"),
2608
+ el("option", { value: "#walls", selected: !!S.walls }, "wall thickness"), el("option", { value: "#over", selected: !!S.over }, "overhangs"),
2609
+ el("option", { value: "#stress", selected: !!S.stress, disabled: !DATA.server }, DATA.server ? "stress (live preview)" : "stress (needs loadpath edit)"), ...scalars.map((n) => el("option", { value: n, selected: n === S.color }, n)));
2610
+ $("#output").replaceChildren(...solids.map((n) => el("option", { value: n, selected: n === S.doc.output }, n)));
2611
+ }
2612
+ function partPanel() {
2613
+ const d = S.doc;
2614
+ $("#name").value = d.name || "field";
2615
+ $("#desc").textContent = d.description ? (d.description.length > 220 ? d.description.slice(0, 219) + "…" : d.description) : "";
2616
+ const pitch = (d.mesh && d.mesh.pitch) || 0.5;
2617
+ $("#pitch").value = pitch; $("#pitch-n").value = pitch;
2618
+ $("#material").value = typeof d.material === "string" ? d.material : d.material ? JSON.stringify(d.material) : "";
2619
+ materialPanel();
2620
+ $("#process").value = typeof d.process === "string" ? d.process : "";
2621
+ tunePanel();
2622
+ }
2623
+ function showIssues() {
2624
+ const list = $("#issues");
2625
+ const all = [...S.issues.map((i) => i.message), ...S.serverIssues.filter((i) => i.level === "error").map((i) => `${i.path}: ${i.message}`)];
2626
+ list.replaceChildren(...[...new Set(all)].slice(0, 8).map((m) => el("li", {}, m)));
2627
+ list.hidden = !all.length;
2628
+ }
2629
+ function showNotes() {
2630
+ const notes = [...(G.comp ? G.comp.notes : [])];
2631
+ const missing = G.comp ? G.comp.missing : [];
2632
+ for (const m of missing) notes.push(`${m}: not baked yet, drawn as ${S.doc.nodes[m] && S.doc.nodes[m].op === "mesh" ? "empty" : "zero"}`);
2633
+ for (const it of bakeItems()) if (bakeState(it) === "stale")
2634
+ notes.push(`${it.kind === "warp" ? `${it.node}'s warp` : it.node}: baked from an earlier graph${isAnalysis(it.node) ? " (re-run the analysis to update)" : DATA.server ? " (re-baking)" : ""}`);
2635
+ if (S.unbounded) notes.push("the shown solid has no bounds the editor can work out (a bare lattice or an expr): intersect it with a solid or give it bounds");
2636
+ const box = $("#notes");
2637
+ box.replaceChildren(...notes.slice(0, 5).map((n) => el("span", {}, n)));
2638
+ box.hidden = !notes.length || S.mode === "mesh";
2639
+ }
2640
+ /** The process's smallest wall: the graph's minWall, else spec.PROCESSES minFeature (FDM's when it names no process). */
2641
+ function minWall() {
2642
+ const kind = typeof S.doc.process === "string" ? S.doc.process : S.doc.process && S.doc.process.kind || "fdm";
2643
+ const mm = S.doc.minWall || (S.doc.process && typeof S.doc.process === "object" && S.doc.process.minFeature) || (DATA.minWalls || {})[kind] || 0.8;
2644
+ return { kind, mm };
2645
+ }
2646
+ /** The overhang check's build direction (the graph's `build`, else +z), its limit and where the plate is. */
2647
+ function overhang() {
2648
+ let b = Array.isArray(S.doc.build) ? S.doc.build : [0, 0, 1];
2649
+ const n = Math.hypot(...b) || 1; b = b.map((x) => x / n);
2650
+ const box = S.solidBox || S.box;
2651
+ let bed = -1e30;
2652
+ if (box) {
2653
+ bed = Infinity;
2654
+ for (const i of [0, 1]) for (const j of [0, 1]) for (const k of [0, 1]) bed = Math.min(bed, box[i][0] * b[0] + box[j][1] * b[1] + box[k][2] * b[2]);
2655
+ bed += 0.3;
2656
+ }
2657
+ const kind = minWall().kind;
2658
+ return { build: b, deg: 45, bed, needs: kind === "fdm" || kind === "lpbf", kind };
2659
+ }
2660
+ /** The live stress preview: the server solves the part on a coarse grid (preview.py); one request at a
2661
+ * time, and the newest graph goes next, so a dragged slider is followed without a queue. */
2662
+ async function requestStress() {
2663
+ if (!S.stress || !DATA.server) return;
2664
+ if (S.sp.busy) { S.sp.again = true; return; }
2665
+ S.sp.busy = true; S.sp.again = false; legend(); kick(false);
2666
+ const t0 = performance.now();
2667
+ try {
2668
+ const r = await fetch("api/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc: exportDoc() }) });
2669
+ const j = await r.json();
2670
+ if (!r.ok) throw new Error(j.error || r.statusText);
2671
+ stressTexture(j);
2672
+ S.sp.result = j; S.sp.error = null; S.sp.ms = performance.now() - t0;
2673
+ } catch (e) {
2674
+ S.sp.error = String(e.message || e); S.sp.result = null; G.stress = null;
2675
+ }
2676
+ S.sp.busy = false;
2677
+ if (S.sp.again && S.stress) return requestStress();
2678
+ legend(); kick(false);
2679
+ }
2680
+ const stressSoon = debounce(() => { if (S.stress) requestStress(); }, 90);
2681
+ function stressTexture(j) {
2682
+ if (!G.ok) return;
2683
+ const tex = (G.stress && G.stress.tex) || gl.createTexture();
2684
+ gl.activeTexture(gl.TEXTURE15);
2685
+ gl.bindTexture(gl.TEXTURE_3D, tex);
2686
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
2687
+ gl.texImage3D(gl.TEXTURE_3D, 0, gl.R16F, j.dims[0], j.dims[1], j.dims[2], 0, gl.RED, gl.HALF_FLOAT, b64ToU16(j.data));
2688
+ for (const [k, v] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE]])
2689
+ gl.texParameteri(gl.TEXTURE_3D, k, v);
2690
+ // node i sits at lo + i * pitch, the centre of texel i
2691
+ const a = j.dims.map((n) => 1 / (j.pitch * n)), b = j.dims.map((n, i) => (0.5 - j.lo[i] / j.pitch) / n);
2692
+ const goal = j.goal && j.goal.safetyFactor || 1;
2693
+ G.stress = { tex, a, b, max: Math.max(j.peak, 1e-6), goal: 1 / goal };
2694
+ }
2695
+
2696
+ function legend() {
2697
+ $("#lg-note").hidden = true;
2698
+ if (S.stress && S.mode === "preview") {
2699
+ $("#legend").hidden = false;
2700
+ const r = S.sp.result, goal = r && r.goal && r.goal.safetyFactor || 1;
2701
+ $("#lg-bar").hidden = !r;
2702
+ $("#lg-title").textContent = "Stress preview (coarse)";
2703
+ $("#lg-note").hidden = false;
2704
+ if (!r) {
2705
+ $("#lg-note").textContent = S.sp.error || "solving…";
2706
+ $("#lg-lo").textContent = $("#lg-mid").textContent = $("#lg-hi").textContent = "";
2707
+ return;
2708
+ }
2709
+ $("#lg-lo").textContent = "0 MPa"; $("#lg-mid").textContent = ""; $("#lg-hi").textContent = `${fmt(r.peak * r.allowed)} MPa peak`;
2710
+ const d = r.goal && r.goal.maxDeflection;
2711
+ $("#lg-note").textContent = `SF ≈ ${fmt(r.safetyFactor)}${goal > 1 ? ` (goal ${fmt(goal)}, darker red past it)` : ""} · deflection ≈ ${fmt(r.deflection)} mm${d ? ` (limit ${fmt(d)})` : ""} · ` +
2712
+ `${(r.cells / 1000).toFixed(1)}k cells in ${((S.sp.ms != null ? S.sp.ms : 1000 * r.seconds) / 1000).toFixed(1)} s` +
2713
+ `${S.sp.busy ? " · updating…" : ""}. A coarse preview, its SF corrected to read under the full check (5-28% under on the calibration parts): Run checks the part.`;
2714
+ return;
2715
+ }
2716
+ if (S.over && S.mode === "preview") {
2717
+ const o = overhang();
2718
+ $("#legend").hidden = false; $("#lg-bar").hidden = true;
2719
+ $("#lg-title").textContent = `overhangs past ${o.deg}° from vertical` + (o.needs ? "" : ` (${o.kind.toUpperCase()} needs no supports)`);
2720
+ $("#lg-lo").textContent = "red: needs support · amber: within 10°"; $("#lg-mid").textContent = ""; $("#lg-hi").textContent = "";
2721
+ return;
2722
+ }
2723
+ if (S.walls && S.mode === "preview") {
2724
+ const w = minWall();
2725
+ $("#legend").hidden = false; $("#lg-bar").hidden = true;
2726
+ $("#lg-title").textContent = `wall, ${w.kind.toUpperCase()} minimum ${w.mm} mm`;
2727
+ $("#lg-lo").textContent = `red: under ${w.mm} · amber: under ${fmt(2 * w.mm)}`; $("#lg-mid").textContent = ""; $("#lg-hi").textContent = "";
2728
+ return;
2729
+ }
2730
+ $("#lg-bar").hidden = false;
2731
+ const on = !!S.color && !!S.range && S.mode === "preview";
2732
+ $("#legend").hidden = !on;
2733
+ if (!on) return;
2734
+ $("#lg-title").textContent = S.color;
2735
+ $("#lg-lo").textContent = fmt(S.range[0]); $("#lg-mid").textContent = fmt(0.5 * (S.range[0] + S.range[1])); $("#lg-hi").textContent = fmt(S.range[1]);
2736
+ }
2737
+ (function legendBar() {
2738
+ const c = $("#lg-bar"), x = c.getContext("2d"), img = x.createImageData(256, 1);
2739
+ const turbo = (t) => {
2740
+ t = Math.min(1, Math.max(0, 0.1 + 0.85 * t));
2741
+ const t2 = t * t, t3 = t2 * t, t4 = t3 * t, t5 = t4 * t;
2742
+ return [0.13572138 + 4.6153926 * t - 42.66032258 * t2 + 132.13108234 * t3 - 152.94239396 * t4 + 59.28637943 * t5,
2743
+ 0.09140261 + 2.19418839 * t + 4.84296658 * t2 - 14.18503333 * t3 + 4.27729857 * t4 + 2.82956604 * t5,
2744
+ 0.1066733 + 12.64194608 * t - 60.58204836 * t2 + 110.36276771 * t3 - 89.90310912 * t4 + 27.34824973 * t5].map((v) => Math.min(1, Math.max(0, v)));
2745
+ };
2746
+ for (let i = 0; i < 256; i++) { const t = turbo(i / 255); img.data.set([t[0] * 255, t[1] * 255, t[2] * 255, 255], i * 4); }
2747
+ x.putImageData(img, 0, 0);
2748
+ })();
2749
+ function status(msg, cls = "") { const s = $("#status"); s.textContent = msg; s.className = `note ${cls}`; }
2750
+
2751
+ const lintSoon = debounce(async () => {
2752
+ if (!DATA.server) return;
2753
+ try {
2754
+ const r = await fetch("api/lint", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc: exportDoc() }) });
2755
+ S.serverIssues = (await r.json()).issues || [];
2756
+ showIssues();
2757
+ } catch (e) { /* offline */ }
2758
+ }, 900);
2759
+
2760
+ // run, save, export
2761
+ function download(name, text) {
2762
+ const a = el("a", { href: URL.createObjectURL(new Blob([text], { type: "application/json" })), download: name });
2763
+ document.body.append(a); a.click(); a.remove();
2764
+ }
2765
+ const fileName = () => DATA.graphPath || `${(S.doc.name || "field").replace(/[^\w.-]+/g, "-")}.json`;
2766
+ async function save() {
2767
+ commit();
2768
+ if (!DATA.server) { download(fileName(), JSON.stringify(exportDoc(), null, 2) + "\n"); status(`Downloaded ${fileName()}.`, "ok"); return; }
2769
+ const r = await fetch("api/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc: exportDoc() }) });
2770
+ const b = await r.json();
2771
+ if (!r.ok) { S.serverIssues = b.issues || []; showIssues(); status(b.error || "Not saved.", "warn"); return; }
2772
+ status(`Saved ${b.saved}.`, "ok");
2773
+ }
2774
+ async function run(node = null) {
2775
+ commit();
2776
+ if (!DATA.server) {
2777
+ const cmd = `loadpath field ${fileName()} --out out/${node ? ` --node ${node}` : ""}`;
2778
+ download(fileName(), JSON.stringify(exportDoc(), null, 2) + "\n");
2779
+ status(`Downloaded ${fileName()}. Mesh it with: ${cmd} (or open it with loadpath edit to run it here)`);
2780
+ try { await navigator.clipboard.writeText(cmd); } catch (e) { /* no clipboard */ }
2781
+ return;
2782
+ }
2783
+ const r = await fetch("api/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(node ? { doc: exportDoc(), node } : { doc: exportDoc() }) });
2784
+ const b = await r.json();
2785
+ if (!r.ok) { S.serverIssues = b.issues || []; showIssues(); status(b.error || "Could not start.", "warn"); return; }
2786
+ S.job = b.id; let since = 0;
2787
+ $("#run").disabled = true; $("#log").hidden = false; $("#log").textContent = ""; $("#report").hidden = true; $("#links").hidden = true;
2788
+ const what = node ? `Meshing ${node}` : "Meshing";
2789
+ status(`${what} at full resolution…`);
2790
+ const t0 = performance.now();
2791
+ const poll = async () => {
2792
+ const j = await (await fetch(`api/job?id=${b.id}&since=${since}`)).json();
2793
+ since = j.eventCount;
2794
+ for (const ev of j.events) $("#log").textContent += Object.entries(ev).map(([k, v]) => `${k} ${typeof v === "number" ? fmt(v) : v}`).join(" ") + "\n";
2795
+ $("#log").scrollTop = 1e9;
2796
+ if (j.state === "running") { status(`${what} at full resolution… ${((performance.now() - t0) / 1000).toFixed(0)} s`); setTimeout(poll, 800); return; }
2797
+ $("#run").disabled = false;
2798
+ if (j.state === "failed") { status(`Run failed: ${j.error}`, "warn"); return; }
2799
+ report(j);
2800
+ };
2801
+ poll();
2802
+ }
2803
+ // ---- tune: the graph's knobs against an FE check of the part ------------------------------------------
2804
+ function defaultStudy() {
2805
+ const r = Object.values(S.doc.nodes).find((n) => n && n.op === "result" && typeof n.study === "string");
2806
+ return r ? r.study : "";
2807
+ }
2808
+ function tuneSpec(make = false) {
2809
+ if (!S.doc.tune && make) S.doc.tune = { study: defaultStudy(), params: [] };
2810
+ return S.doc.tune;
2811
+ }
2812
+ function tunePanel() {
2813
+ const t = S.doc.tune || {};
2814
+ $("#tune-study").value = t.study || ""; $("#tune-study").placeholder = defaultStudy() || "example:arm-link or study.json";
2815
+ $("#tune-sf").value = t.safetyFactor ?? ""; $("#tune-defl").value = t.maxDeflection ?? ""; $("#tune-n").value = t.evaluations ?? 20;
2816
+ const lis = (t.params || []).map((p, i) => {
2817
+ const num = (k) => { const x = el("input", { type: "number", step: "any", value: p[k], "aria-label": `${p.path} ${k}` }); x.addEventListener("change", () => { p[k] = Number(x.value); commit(); }); return x; };
2818
+ const rm = el("button", { class: "btn ghost small", title: "Stop tuning this", onclick: () => { t.params.splice(i, 1); if (!t.params.length && !t.study) delete S.doc.tune; commit(); tunePanel(); } }, "×");
2819
+ return el("li", { title: p.also && p.also.length ? `moves with it: ${p.also.join(", ")}` : p.path }, el("span", {}, p.path.replace(/^nodes\//, "").replace(/\//g, " · ") + (p.also && p.also.length ? ` +${p.also.length}` : "")), num("min"), num("max"), rm);
2820
+ });
2821
+ $("#tune-params").replaceChildren(...lis);
2822
+ const sw = t.sweep && Object.entries(t.sweep)[0];
2823
+ $("#tune-sweep").value = sw ? `${sw[0] === "maxDeflection" ? "defl" : "SF"} ${sw[1].join(" ")}` : "";
2824
+ $("#tune").disabled = !(t.params && t.params.length);
2825
+ }
2826
+ function tuneField(k, v) {
2827
+ const t = tuneSpec(true);
2828
+ if (v === "" || v == null) delete t[k]; else t[k] = k === "study" ? v : Number(v);
2829
+ commit(); tunePanel();
2830
+ }
2831
+ /** "defl 0.4 0.6" or "SF 2 4" -> tune.sweep; empty clears it. */
2832
+ function tuneSweep(text) {
2833
+ const t = tuneSpec(true), w = text.trim().split(/[\s,]+/).filter(Boolean);
2834
+ if (!w.length) { delete t.sweep; commit(); tunePanel(); return; }
2835
+ const key = /^(defl|deflection|maxdeflection|d)$/i.test(w[0]) ? "maxDeflection" : /^(sf|safety|safetyfactor)$/i.test(w[0]) ? "safetyFactor" : null;
2836
+ const vals = w.slice(key ? 1 : 0).map(Number).filter((v) => v > 0);
2837
+ if (!vals.length) { status("Trade-off: a limit and its values, e.g. defl 0.4 0.6 0.8", "warn"); return; }
2838
+ t.sweep = { [key || (t.maxDeflection != null || !t.safetyFactor ? "maxDeflection" : "safetyFactor")]: vals };
2839
+ commit(); tunePanel();
2840
+ }
2841
+ function applyAssign(assign) {
2842
+ for (const [key, v] of assign) { const path = key.split("/").map((x) => /^\d+$/.test(x) ? +x : x); setUniform(path, +v.toPrecision(5)); paramChanged(path); }
2843
+ inspector(); updateMass();
2844
+ }
2845
+ /** The trade-off: every candidate no other beats on mass, deflection and SF, mass against deflection;
2846
+ * the sweep's picks ringed with their limit; click one to put its values in the graph. */
2847
+ function drawFront(j) {
2848
+ const box = $("#tune-chart"), pts = j.front || [];
2849
+ if (pts.length < 2) return;
2850
+ const W = 288, H = 132, L = 50, R = 8, T = 12, B = 24;
2851
+ const xs = pts.map((p) => p.deflection), ys = pts.map((p) => p.mass);
2852
+ const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
2853
+ const X = (x) => L + (W - L - R) * (x - x0) / ((x1 - x0) || 1), Y = (y) => T + (H - T - B) * (1 - (y - y0) / ((y1 - y0) || 1));
2854
+ const ns = "http://www.w3.org/2000/svg", mk = (t, a, txt) => { const e = document.createElementNS(ns, t); for (const [k, v] of Object.entries(a)) e.setAttribute(k, v); if (txt != null) e.textContent = txt; return e; };
2855
+ const svg = mk("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "lightest part for each deflection: the trade-off" });
2856
+ svg.append(mk("line", { x1: L, y1: H - B, x2: W - R, y2: H - B, stroke: "var(--line)" }), mk("line", { x1: L, y1: T, x2: L, y2: H - B, stroke: "var(--line)" }));
2857
+ svg.append(mk("text", { x: L - 4, y: T + 4, "text-anchor": "end" }, `${fmt(y1)} g`), mk("text", { x: L - 4, y: H - B, "text-anchor": "end" }, fmt(y0)),
2858
+ mk("text", { x: L, y: H - 8 }, `${fmt(x0)} mm`), mk("text", { x: W - R, y: H - 8, "text-anchor": "end" }, `${fmt(x1)} mm deflection`));
2859
+ svg.append(mk("polyline", { points: [...pts].sort((a, b) => a.deflection - b.deflection).map((p) => `${X(p.deflection)},${Y(p.mass)}`).join(" "), fill: "none", stroke: "var(--muted)", "stroke-width": 1, "stroke-dasharray": "3 3" }));
2860
+ const picks = (j.sweep || []).filter((s) => s.best);
2861
+ for (const p of pts) {
2862
+ const g = mk("g", { class: "pt" });
2863
+ g.append(mk("circle", { cx: X(p.deflection), cy: Y(p.mass), r: 3.2, fill: "var(--accent)" }));
2864
+ g.append(mk("title", {}, `${p.mass.toFixed(1)} g · ${fmt(p.deflection)} mm · SF ${fmt(p.safetyFactor)} · click to use`));
2865
+ g.addEventListener("click", () => { applyAssign(p.assign); status(`Using the ${p.mass.toFixed(1)} g candidate (${fmt(p.deflection)} mm, SF ${fmt(p.safetyFactor)}). Undo puts the last values back.`, "ok"); });
2866
+ svg.append(g);
2867
+ }
2868
+ for (const s of picks) {
2869
+ const k = Object.keys(s).find((x) => x !== "best"), b = s.best;
2870
+ const g = mk("g", { class: "pt" });
2871
+ g.append(mk("circle", { cx: X(b.deflection), cy: Y(b.mass), r: 6.5, fill: "none", stroke: b.unconfirmed ? "var(--warn)" : "var(--ink)", "stroke-width": 1.2 }));
2872
+ g.append(mk("text", { x: X(b.deflection) + 8, y: Y(b.mass) - 6 }, `${k === "maxDeflection" ? "≤" : "SF ≥"} ${fmt(s[k])}`));
2873
+ g.append(mk("title", {}, `lightest at ${k === "maxDeflection" ? "deflection ≤" : "SF ≥"} ${fmt(s[k])}: ${b.mass.toFixed(1)} g${b.unconfirmed ? " (not confirmed on the fine grid)" : ""} · click to use`));
2874
+ g.addEventListener("click", () => { applyAssign(b.assign); status(`Using the lightest part at ${k === "maxDeflection" ? "deflection ≤" : "SF ≥"} ${fmt(s[k])}: ${b.mass.toFixed(1)} g.`, "ok"); });
2875
+ svg.append(g);
2876
+ }
2877
+ box.replaceChildren(svg); box.hidden = false;
2878
+ }
2879
+ function addTuneKnob(key) {
2880
+ if (!key) { status("Pick the parameter under 'by changing' first.", "warn"); return; }
2881
+ const t = tuneSpec(true);
2882
+ if (!t.study) t.study = defaultStudy();
2883
+ if (t.params.some((p) => p.path === key)) return;
2884
+ const [lo, hi] = paramRange(key);
2885
+ t.params.push({ path: key, min: +lo.toPrecision(4), max: +hi.toPrecision(4) });
2886
+ commit(); tunePanel();
2887
+ }
2888
+ async function tune() {
2889
+ commit();
2890
+ const t = S.doc.tune;
2891
+ if (!t || !t.params || !t.params.length) { status("Add a knob to tune first.", "warn"); return; }
2892
+ if (!t.study) { status("Name the study (loads, supports, limits) to tune against.", "warn"); return; }
2893
+ if (!DATA.server) {
2894
+ const cmd = `loadpath tune ${fileName()} --out tune/`;
2895
+ download(fileName(), JSON.stringify(exportDoc(), null, 2) + "\n");
2896
+ status(`Downloaded ${fileName()}. Tune it with: ${cmd} (or open it with loadpath edit to tune it here)`);
2897
+ try { await navigator.clipboard.writeText(cmd); } catch (e) { /* no clipboard */ }
2898
+ return;
2899
+ }
2900
+ const r = await fetch("api/tune", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc: exportDoc() }) });
2901
+ const b = await r.json();
2902
+ if (!r.ok) { S.serverIssues = b.issues || []; showIssues(); status(b.error || "Could not start.", "warn"); return; }
2903
+ $("#tune").disabled = true; $("#log").hidden = false; $("#log").textContent = "";
2904
+ const pts = [], t0 = performance.now(), n = t.evaluations || 20;
2905
+ let since = 0;
2906
+ status("Tuning: meshing and analyzing the first candidate…");
2907
+ const poll = async () => {
2908
+ const j = await (await fetch(`api/job?id=${b.id}&since=${since}`)).json();
2909
+ since = j.eventCount;
2910
+ for (const ev of j.events) {
2911
+ if (ev.event === "tune") pts.push(ev);
2912
+ $("#log").textContent += Object.entries(ev).map(([k, v]) => `${k} ${typeof v === "number" ? fmt(v) : v}`).join(" ") + "\n";
2913
+ }
2914
+ $("#log").scrollTop = 1e9;
2915
+ drawTune(pts, j.limits);
2916
+ if (j.state === "running") {
2917
+ const last = pts[pts.length - 1];
2918
+ status(`Tuning: ${pts.length} of up to ${n} analyses${last && last.mass != null ? `, last ${last.mass.toFixed(1)} g, SF ${fmt(last.safetyFactor)}` : ""}… ${((performance.now() - t0) / 1000).toFixed(0)} s`);
2919
+ setTimeout(poll, 1000); return;
2920
+ }
2921
+ $("#tune").disabled = false;
2922
+ if (j.state === "failed") { status(`Tune failed: ${j.error}`, "warn"); return; }
2923
+ drawTune(j.history, j.limits, j.best);
2924
+ if (!j.best) { drawFront(j); status(`No candidate met the limits in ${j.history.length} analyses: widen the knobs' ranges or ease the limits.`, "warn"); return; }
2925
+ applyAssign(j.assign);
2926
+ drawFront(j);
2927
+ const at = j.best.check && j.best.check.ok ? j.best.check : j.best; // the fine grid's reading when it has one
2928
+ setCheck(at.safetyFactor, at.deflection);
2929
+ const lim = [at.safetyFactor != null ? `SF ${fmt(at.safetyFactor)}` : "", at.deflection != null ? `deflection ${fmt(at.deflection)} mm` : ""].filter(Boolean).join(", ");
2930
+ const how = j.best.unconfirmed ? " It passed once but not on a finer grid: it sits on the limit, so check it." : " Checked again on a finer grid.";
2931
+ status(`Tuned in ${j.history.length} analyses: ${j.start.mass != null ? `${j.start.mass.toFixed(1)} → ` : ""}${j.best.mass.toFixed(1)} g (${lim}).${how} The values are in the graph; Undo puts them back.`, j.best.unconfirmed ? "warn" : "ok");
2932
+ };
2933
+ poll();
2934
+ }
2935
+ /** Each analysis: mass against the evaluation number, filled where it met the limits; the best ringed. */
2936
+ function drawTune(pts, limits, best) {
2937
+ const box = $("#tune-chart"), ok = pts.filter((p) => p.mass != null);
2938
+ if (ok.length < 1) { box.hidden = true; return; }
2939
+ const W = 288, H = 120, L = 50, R = 8, T = 12, B = 20;
2940
+ const ys = ok.map((p) => p.mass), y0 = Math.min(...ys), y1 = Math.max(...ys), n = Math.max(pts.length, 2);
2941
+ const X = (i) => L + (W - L - R) * i / (n - 1), Y = (y) => T + (H - T - B) * (1 - (y - y0) / ((y1 - y0) || 1));
2942
+ const ns = "http://www.w3.org/2000/svg", mk = (t, a, txt) => { const e = document.createElementNS(ns, t); for (const [k, v] of Object.entries(a)) e.setAttribute(k, v); if (txt != null) e.textContent = txt; return e; };
2943
+ const svg = mk("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "mass of each analyzed candidate" });
2944
+ svg.append(mk("line", { x1: L, y1: H - B, x2: W - R, y2: H - B, stroke: "var(--line)" }), mk("line", { x1: L, y1: T, x2: L, y2: H - B, stroke: "var(--line)" }));
2945
+ svg.append(mk("text", { x: L - 4, y: T + 4, "text-anchor": "end" }, `${fmt(y1)} g`), mk("text", { x: L - 4, y: H - B, "text-anchor": "end" }, fmt(y0)),
2946
+ mk("text", { x: W - R, y: H - 6, "text-anchor": "end" }, `analysis ${pts.length}`), mk("text", { x: L, y: H - 6 }, "filled: meets the limits"));
2947
+ pts.forEach((p, i) => {
2948
+ if (p.mass == null) return;
2949
+ const c = mk("circle", { cx: X(i), cy: Y(p.mass), r: 3.2, fill: p.ok ? "var(--ok)" : "none", stroke: p.ok ? "var(--ok)" : "var(--warn)", "stroke-width": 1.3 });
2950
+ c.append(mk("title", {}, `${p.mass.toFixed(1)} g · SF ${fmt(p.safetyFactor)} · deflection ${fmt(p.deflection)} mm · ${Array.isArray(p.values) ? p.values.map(fmt).join(", ") : p.values}`));
2951
+ svg.append(c);
2952
+ if (best && p.mass === best.mass && p.ok) svg.append(mk("circle", { cx: X(i), cy: Y(p.mass), r: 6.5, fill: "none", stroke: "var(--ink)", "stroke-width": 1.2 }));
2953
+ });
2954
+ box.replaceChildren(svg); box.hidden = false;
2955
+ }
2956
+ function report(j) {
2957
+ const r = j.report, dl = $("#report");
2958
+ const rows = [["mass", r.mass != null ? `${r.mass.toFixed(1)} g` : null], ["volume", `${(r.volume / 1000).toFixed(2)} cm³`], ["faces", r.faces.toLocaleString()],
2959
+ ["watertight", r.watertight ? "yes" : "no", r.watertight], ["bodies", r.bodies, r.bodies === 1], ["sealed voids", r.enclosedVoids, r.enclosedVoids === 0],
2960
+ ["drains", r.drains ? (r.drains.ok ? `${r.drains.holes ? r.drains.holes.length : 0} holes, clear` : "not clear") : null, r.drains ? r.drains.ok : null],
2961
+ ["printable", r.printability && "ok" in r.printability ? (r.printability.ok ? "yes" : "issues") : null, r.printability ? r.printability.ok : null], ["seconds", j.seconds]];
2962
+ dl.replaceChildren(...rows.filter((x) => x[1] != null).flatMap(([k, v, good]) => [el("dt", {}, k), el("dd", { class: good === true ? "good" : good === false ? "bad" : "" }, String(v))]));
2963
+ dl.hidden = false;
2964
+ const L = $("#links");
2965
+ L.replaceChildren(el("a", { class: "btn small", href: j.files.viewer, target: "_blank" }, "Open in viewer"), el("a", { class: "btn small", href: j.files.stl, download: "" }, "STL"), el("a", { class: "btn small", href: j.files.report, target: "_blank" }, "report.json"));
2966
+ L.hidden = false;
2967
+ const an = Object.values(r.analyses || {}).filter((a) => a && a.safetyFactor != null);
2968
+ if (an.length) setCheck(Math.min(...an.map((a) => a.safetyFactor)), Math.max(...an.map((a) => a.deflection ?? 0)) || null);
2969
+ status(`Meshed${j.node ? ` ${j.node}` : ""}: ${r.faces.toLocaleString()} faces${r.mass != null ? `, ${r.mass.toFixed(1)} g` : ""}.`, "ok");
2970
+ showMesh(j.mesh);
2971
+ }
2972
+
2973
+ // ---- wiring ---------------------------------------------------------------------------------------------
2974
+
2975
+ function seg(id, on) {
2976
+ for (const b of document.querySelectorAll(`#${id} button`)) b.addEventListener("click", () => {
2977
+ for (const x of document.querySelectorAll(`#${id} button`)) x.setAttribute("aria-pressed", String(x === b));
2978
+ on(b.dataset.v);
2979
+ });
2980
+ }
2981
+ seg("cut", (v) => { S.cut = v; $("#cut-pos").disabled = $("#cut-flip").disabled = v === "off"; kick(false); });
2982
+ seg("quality", (v) => { S.quality = v; kick(false); });
2983
+ $("#cut-pos").addEventListener("input", (e) => { S.cutPos = +e.target.value; kick(true); });
2984
+ $("#cut-flip").addEventListener("click", () => { S.cutFlip = !S.cutFlip; kick(false); });
2985
+ $("#target").addEventListener("change", (e) => {
2986
+ S.follow = e.target.value === "#follow";
2987
+ if (!S.follow) S.target = e.target.value;
2988
+ else S.target = S.sel && kindOf(S.sel) === "solid" ? S.sel : S.doc.output;
2989
+ fillSelects(); drawGraph(); recompile();
2990
+ });
2991
+ $("#colorby").addEventListener("change", (e) => {
2992
+ S.walls = e.target.value === "#walls"; S.over = e.target.value === "#over"; S.stress = e.target.value === "#stress";
2993
+ S.color = S.walls || S.over || S.stress ? null : e.target.value || null;
2994
+ drawGraph(); recompile(); legend();
2995
+ if (S.stress) requestStress();
2996
+ });
2997
+ $("#output").addEventListener("change", (e) => { S.doc.output = e.target.value; structural(); });
2998
+ $("#name").addEventListener("change", (e) => { S.doc.name = e.target.value || "field"; commit(); });
2999
+ const setPitch = (v) => { if (!(v > 0)) return; S.doc.mesh = { ...(S.doc.mesh || {}), pitch: v }; $("#pitch").value = v; $("#pitch-n").value = v; commitSoon(); };
3000
+ $("#pitch").addEventListener("input", (e) => setPitch(+e.target.value));
3001
+ $("#pitch-n").addEventListener("change", (e) => setPitch(+e.target.value));
3002
+ $("#material").addEventListener("change", (e) => {
3003
+ const v = e.target.value.trim();
3004
+ let m = v;
3005
+ if (v.startsWith("{")) { try { m = JSON.parse(v); } catch (err) { status("That custom material isn't valid JSON.", "warn"); return; } }
3006
+ if (v) S.doc.material = m; else delete S.doc.material;
3007
+ commit(); materialPanel(); updateMass(); stressSoon();
3008
+ });
3009
+ $("#material-pick").addEventListener("change", (e) => {
3010
+ const v = e.target.value;
3011
+ if (v === "custom") { $("#material-custom").hidden = false; $("#material").focus(); return; }
3012
+ if (v) S.doc.material = v; else delete S.doc.material;
3013
+ $("#material").value = v;
3014
+ commit(); materialPanel(); updateMass(); stressSoon();
3015
+ });
3016
+ $("#materials").replaceChildren(...Object.keys(DATA.densities || {}).map((k) => el("option", { value: k })));
3017
+ $("#sweep").addEventListener("click", async () => {
3018
+ const key = $("#fit-param").value;
3019
+ if (!key) return;
3020
+ $("#sweep").disabled = true;
3021
+ status(`Sweeping ${key.slice(6).replace(/\//g, " · ")}…`);
3022
+ try { await sweepMass(key); status(""); } finally { $("#sweep").disabled = false; }
3023
+ });
3024
+ $("#fit-param").addEventListener("change", () => { $("#sweep-chart").hidden = true; });
3025
+ $("#fit").addEventListener("click", async () => {
3026
+ const g = Number($("#fit-target").value), key = $("#fit-param").value;
3027
+ if (!(g > 0) || !key) { status("Give a target mass and pick the parameter to change.", "warn"); return; }
3028
+ $("#fit").disabled = true;
3029
+ try { await fitMass(key, g); } finally { $("#fit").disabled = false; }
3030
+ });
3031
+ $("#process").addEventListener("change", (e) => { const v = e.target.value; if (v) S.doc.process = v; else delete S.doc.process; commit(); legend(); kick(false); });
3032
+ $("#run").addEventListener("click", () => run());
3033
+ $("#tune").addEventListener("click", tune);
3034
+ $("#tune-add").addEventListener("click", () => addTuneKnob($("#fit-param").value));
3035
+ $("#tune-study").addEventListener("change", (e) => tuneField("study", e.target.value.trim()));
3036
+ $("#tune-sf").addEventListener("change", (e) => tuneField("safetyFactor", e.target.value));
3037
+ $("#tune-defl").addEventListener("change", (e) => tuneField("maxDeflection", e.target.value));
3038
+ $("#tune-sweep").addEventListener("change", (e) => tuneSweep(e.target.value));
3039
+ $("#tune-n").addEventListener("change", (e) => tuneField("evaluations", Math.max(2, Math.round(+e.target.value || 20))));
3040
+ $("#save").addEventListener("click", save);
3041
+ $("#export").addEventListener("click", () => { commit(); download(fileName(), JSON.stringify(exportDoc(), null, 2) + "\n"); });
3042
+ $("#import").addEventListener("change", async (e) => {
3043
+ const f = e.target.files[0]; if (!f) return;
3044
+ try { loadDoc(JSON.parse(await f.text()), true); status(`Loaded ${f.name}.`, "ok"); } catch (err) { status(`${f.name}: ${err.message}`, "warn"); }
3045
+ e.target.value = "";
3046
+ });
3047
+ $("#fit").addEventListener("click", () => fitView("iso"));
3048
+ for (const b of document.querySelectorAll("[data-view]")) b.addEventListener("click", () => fitView(b.dataset.view));
3049
+ for (const b of document.querySelectorAll("#modebar button")) b.addEventListener("click", () => { setMode(b.dataset.mode); legend(); showNotes(); });
3050
+ $("#layout").addEventListener("click", () => { autoLayout(); graphFit(); commit(); });
3051
+ $("#gfit").addEventListener("click", graphFit);
3052
+ $("#undo").addEventListener("click", undo);
3053
+ $("#redo").addEventListener("click", redo);
3054
+ const addSel = $("#add");
3055
+ addSel.append(...Object.keys(OPS).map((o) => el("option", { value: o }, o)));
3056
+ addSel.addEventListener("change", () => { if (addSel.value) addNode(addSel.value); addSel.value = ""; });
3057
+ $("#jsonbtn").addEventListener("click", () => {
3058
+ const p = $("#jsonpane"), open = p.hidden;
3059
+ p.hidden = !open; $("#jsonbtn").setAttribute("aria-pressed", String(open));
3060
+ if (open) { commit(); $("#json").value = JSON.stringify(exportDoc(), null, 2); $("#json-note").textContent = ""; }
3061
+ });
3062
+ $("#json-apply").addEventListener("click", () => {
3063
+ try { loadDoc(JSON.parse($("#json").value), false); $("#json-note").textContent = "Applied."; $("#json-note").className = "note ok"; }
3064
+ catch (e) { $("#json-note").textContent = e.message; $("#json-note").className = "note warn"; }
3065
+ });
3066
+ document.addEventListener("keydown", (e) => {
3067
+ if (e.target.closest("input, textarea, select")) return;
3068
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
3069
+ if ((e.key === "Delete" || e.key === "Backspace") && S.sel) deleteNode(S.sel);
3070
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d" && S.sel) { e.preventDefault(); duplicateNode(S.sel); }
3071
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c" && S.sel && !String(window.getSelection())) { e.preventDefault(); copyNode(S.sel); }
3072
+ });
3073
+ // paste: a node or {nodes: {...}} from the clipboard (another graph, a doc, the page's own copy)
3074
+ document.addEventListener("paste", (e) => {
3075
+ if (e.target.closest("input, textarea, select")) return;
3076
+ const text = e.clipboardData && e.clipboardData.getData("text");
3077
+ if (text && pasteText(text)) { e.preventDefault(); return; }
3078
+ if (S.clip) { e.preventDefault(); pasteNodes(S.clip.nodes); }
3079
+ });
3080
+ (function divider() {
3081
+ const d = $("#divider"), right = $("#right");
3082
+ d.addEventListener("pointerdown", (e) => {
3083
+ d.setPointerCapture(e.pointerId);
3084
+ const mv = (ev) => { const r = right.getBoundingClientRect(); right.style.setProperty("--split", `${Math.min(85, Math.max(20, ((ev.clientY - r.top) / r.height) * 100))}%`); };
3085
+ const up = () => { d.removeEventListener("pointermove", mv); d.removeEventListener("pointerup", up); graphFit(); };
3086
+ d.addEventListener("pointermove", mv); d.addEventListener("pointerup", up);
3087
+ });
3088
+ })();
3089
+
3090
+ function loadDoc(doc, fresh) {
3091
+ if (!doc || typeof doc !== "object" || !doc.nodes || typeof doc.nodes !== "object") throw new Error("a field graph needs a 'nodes' object");
3092
+ if (!fresh) commit();
3093
+ S.doc = hoist(doc);
3094
+ if (!S.doc.output || !(S.doc.output in S.doc.nodes)) S.doc.output = Object.keys(S.doc.nodes).pop();
3095
+ S.layout = { ...((doc.editor && doc.editor.layout) || {}) };
3096
+ for (const k of Object.keys(S.layout)) if (!(k in S.doc.nodes)) delete S.layout[k];
3097
+ if (Object.keys(S.layout).length < Object.keys(S.doc.nodes).length) autoLayout(true);
3098
+ if (fresh) { S.sel = S.doc.output; S.target = S.doc.output; S.color = null; S.undo = []; S.redo = []; lastCommitted = null; S.box = null; }
3099
+ if (S.target && !(S.target in S.doc.nodes)) S.target = S.doc.output;
3100
+ if (S.color && !(S.color in S.doc.nodes)) S.color = null;
3101
+ if (fresh && !S.color) S.color = Object.keys(S.doc.nodes).find((n) => S.doc.nodes[n].op === "result") || null;
3102
+ commit(); partPanel(); fillSelects(); inspector(); recompile(); graphFit();
3103
+ }
3104
+
3105
+ // test hooks (and for agents driving the page)
3106
+ window.lpEditor = {
3107
+ probe: (name, pts) => Array.from(probe(name, Float32Array.from(pts.flat ? pts.flat() : pts)) || []),
3108
+ doc: () => exportDoc(), state: S, set: (path, v) => { setAt(S.doc, path, v); paramChanged(path); },
3109
+ THREE, cam: () => { camera.updateMatrixWorld(); return camera; },
3110
+ makeGroup: (n, g) => makeGroup(n, g), run: (n) => run(n), stress: () => S.sp, requestStress: () => requestStress(), setCheck: (sf, d) => setCheck(sf, d), tune: () => tune(), addTuneKnob: (k) => addTuneKnob(k), tuneSweep: (t) => tuneSweep(t), drawFront: (j) => drawFront(j), ungroup: (n) => ungroup(n), expanded: () => clone(X().doc),
3111
+ paste: (text) => pasteText(text), sweep: (key, n) => sweepMass(key, n), duplicate: (n) => duplicateNode(n),
3112
+ pick: (world) => { camera.updateMatrixWorld(); const v = new THREE.Vector3(...world).project(camera); return pickNdc([v.x, v.y]); },
3113
+ mass: (budget, name) => volumeOf(name || S.doc.output, budget || 600000), fitMass: (key, grams) => fitMass(key, grams),
3114
+ select: (n) => { S.sel = n; if (S.follow && kindOf(n) === "solid") S.target = n; afterSelect(); },
3115
+ view: (o) => {
3116
+ Object.assign(S, o);
3117
+ for (const [id, k] of [["cut", "cut"], ["quality", "quality"]]) if (o[k] !== undefined) for (const b of document.querySelectorAll(`#${id} button`)) b.setAttribute("aria-pressed", String(b.dataset.v === o[k]));
3118
+ if (o.cut !== undefined) $("#cut-pos").disabled = $("#cut-flip").disabled = o.cut === "off";
3119
+ if (o.cutPos !== undefined) $("#cut-pos").value = o.cutPos;
3120
+ fillSelects(); drawGraph(); recompile();
3121
+ },
3122
+ camera: (pos, target) => { camera.position.set(...pos); controls.target.set(...target); controls.update(); kick(false); },
3123
+ fit: fitView, glsl: () => G.comp && G.comp.head, recompile, load: (d) => loadDoc(d, true),
3124
+ };
3125
+
3126
+ S.bakes = DATA.bakes || {};
3127
+ $("#foot").textContent = DATA.server ? `Serving ${DATA.graphPath}. Run meshes it with loadpath's field mesher; Save writes the file.`
3128
+ : `Standalone page, made ${DATA.created}. Run downloads the JSON and gives the command; \`loadpath edit\` serves it with Run and Save.`;
3129
+ if (!DATA.server) { $("#run").textContent = "Run…"; $("#save").textContent = "Download"; }
3130
+ for (const e of DATA.errors || []) status(e, "warn");
3131
+ S.follow = true;
3132
+ resize();
3133
+ try { loadDoc(DATA.doc, true); } catch (e) { status(e.message, "warn"); }
3134
+ requestAnimationFrame(loop);
3135
+ </script>