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/viewer.html ADDED
@@ -0,0 +1,694 @@
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: the part fills the window; an instrument panel on the left (a bottom sheet on phones); the optimization timeline docks under the part. */
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
+ --clay: #c3c7ce;
19
+ --cut: #6d7684;
20
+ --display: "IBM Plex Sans Condensed", "Arial Narrow", system-ui, sans-serif;
21
+ --body: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
22
+ --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root:not([data-theme="light"]) {
26
+ --ground: #10151c; --panel: #171e27; --ink: #e3e8ef; --muted: #8d99aa; --line: #283242;
27
+ --chip: #1f2833; --accent: #86a2f7; --accent-ink: #0d1424; --warn: #fb923c; --ok: #4ade80;
28
+ --clay: #a9aeb7; --cut: #4b5361; color-scheme: dark;
29
+ }
30
+ }
31
+ :root[data-theme="dark"] {
32
+ --ground: #10151c; --panel: #171e27; --ink: #e3e8ef; --muted: #8d99aa; --line: #283242;
33
+ --chip: #1f2833; --accent: #86a2f7; --accent-ink: #0d1424; --warn: #fb923c; --ok: #4ade80;
34
+ --clay: #a9aeb7; --cut: #4b5361; color-scheme: dark;
35
+ }
36
+ [hidden] { display: none !important; }
37
+ html, body { height: 100%; }
38
+ body { margin: 0; background: var(--ground); color: var(--ink); font: 13px/1.45 var(--body); overflow: hidden; }
39
+ #app { position: relative; height: 100%; display: grid; grid-template-columns: 300px 1fr; }
40
+ #panel {
41
+ background: var(--panel); border-right: 1px solid var(--line); overflow-y: auto; min-width: 0;
42
+ padding-block: 18px 24px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: 16px;
43
+ display: flex; flex-direction: column; gap: 18px;
44
+ }
45
+ #stage { position: relative; min-width: 0; overflow: hidden; }
46
+ #stage > canvas { display: block; width: 100%; height: 100%; touch-action: none; }
47
+ header h1 { font: 600 20px/1.15 var(--display); margin: 0; letter-spacing: 0.01em; text-wrap: balance; }
48
+ header p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
49
+ section { display: flex; flex-direction: column; gap: 8px; }
50
+ section > h2 { margin: 0; font: 500 10.5px/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
51
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
52
+ select, button { font: inherit; color: inherit; }
53
+ select {
54
+ width: 100%; background: var(--chip); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px;
55
+ }
56
+ .seg { display: flex; background: var(--chip); border: 1px solid var(--line); border-radius: 7px; padding: 2px; gap: 2px; }
57
+ .seg button {
58
+ flex: 1 1 0; border: 0; background: transparent; padding: 6px 4px; border-radius: 5px; cursor: pointer;
59
+ font-size: 12px; white-space: nowrap; color: var(--muted);
60
+ }
61
+ .seg button[aria-pressed="true"] { background: var(--panel); color: var(--ink); box-shadow: 0 0 0 1px var(--line); font-weight: 500; }
62
+ .seg button:disabled { opacity: 0.4; cursor: default; }
63
+ button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
64
+ input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); }
65
+ .val { font: 12px var(--mono); font-variant-numeric: tabular-nums; color: var(--muted); min-width: 64px; text-align: right; }
66
+ .btn {
67
+ border: 1px solid var(--line); background: var(--chip); border-radius: 6px; padding: 6px 10px; cursor: pointer;
68
+ }
69
+ .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 500; }
70
+ .layers label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
71
+ .swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
72
+ dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; }
73
+ dt { color: var(--muted); }
74
+ dd { margin: 0; font: 12px var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
75
+ dd.good { color: var(--ok); } dd.bad { color: var(--warn); }
76
+ .cmp-head { font: 500 11px var(--mono); color: var(--muted); grid-column: 1 / -1; padding-top: 6px; border-top: 1px solid var(--line); }
77
+ .note { color: var(--muted); font-size: 12px; margin: 0; }
78
+
79
+ #legend {
80
+ position: absolute; z-index: 2; right: max(14px, env(safe-area-inset-right, 0px)); bottom: 14px; background: var(--panel);
81
+ border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; width: 220px; max-width: calc(100% - 28px);
82
+ }
83
+ #legend .t { font-size: 12px; font-weight: 500; margin-bottom: 6px; }
84
+ #legend canvas { width: 100%; height: 10px; border-radius: 2px; display: block; }
85
+ #legend .ticks { display: flex; justify-content: space-between; font: 11px var(--mono); color: var(--muted); margin-top: 4px; font-variant-numeric: tabular-nums; }
86
+ #legend .sub { font-size: 11px; color: var(--muted); margin-top: 4px; }
87
+ #views { position: absolute; z-index: 2; top: 12px; right: max(12px, env(safe-area-inset-right, 0px)); display: flex; gap: 4px; }
88
+ #views button { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer; font-size: 12px; }
89
+ .tag {
90
+ position: absolute; z-index: 1; pointer-events: none; transform: translate(-50%, -100%); font: 500 11px var(--mono);
91
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; white-space: nowrap;
92
+ }
93
+ .tag.load { color: var(--accent); }
94
+ #probe { position: absolute; pointer-events: none; font: 11.5px var(--mono); background: var(--ink); color: var(--panel);
95
+ border-radius: 5px; padding: 4px 7px; white-space: nowrap; transform: translate(12px, -50%); font-variant-numeric: tabular-nums; }
96
+
97
+ #timeline {
98
+ position: absolute; z-index: 2; left: 14px; right: 250px; bottom: 14px; background: var(--panel); border: 1px solid var(--line);
99
+ border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px;
100
+ }
101
+ #timeline .row { flex-wrap: nowrap; }
102
+ #spark { width: 100%; height: 34px; display: block; }
103
+ #tl-label { font: 12px var(--mono); font-variant-numeric: tabular-nums; color: var(--muted); }
104
+ #empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); pointer-events: none; }
105
+ #err { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; color: var(--warn); }
106
+
107
+ @media (max-width: 760px) {
108
+ #app { grid-template-columns: 1fr; grid-template-rows: 1fr minmax(0, 44%); }
109
+ #stage { grid-row: 1; }
110
+ #panel { grid-row: 2; border-right: 0; border-top: 1px solid var(--line); padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); }
111
+ #timeline { right: 14px; bottom: 76px; }
112
+ #legend { width: auto; left: 14px; right: 14px; max-width: none; }
113
+ }
114
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
115
+ </style>
116
+ <!--/head-->
117
+ <div id="app">
118
+ <aside id="panel">
119
+ <header><h1 id="title"></h1><p id="subtitle"></p></header>
120
+
121
+ <section id="sec-part">
122
+ <h2>Part</h2>
123
+ <select id="part" aria-label="Part"></select>
124
+ <div class="row" id="cmp-row"><select id="compare" aria-label="Compare with"></select></div>
125
+ </section>
126
+
127
+ <section id="sec-field">
128
+ <h2>Result</h2>
129
+ <div class="seg" id="field" role="group" aria-label="Result shown">
130
+ <button data-v="shape" aria-pressed="true">Shape</button>
131
+ <button data-v="stress">Stress</button>
132
+ <button data-v="sf">Safety</button>
133
+ <button data-v="disp">Displ.</button>
134
+ </div>
135
+ <select id="case" aria-label="Load case"></select>
136
+ <label class="row" id="fit-row"><input type="checkbox" id="fit"> Scale colours to this part's peak</label>
137
+ </section>
138
+
139
+ <section id="sec-deform">
140
+ <h2>Deformation</h2>
141
+ <div class="row"><input type="range" id="deform" min="0" max="1" step="0.001" value="0" aria-label="Deformation">
142
+ <span class="val" id="deform-val">off</span></div>
143
+ <div class="row"><button class="btn" id="flex">Animate</button><span class="note" id="deform-note"></span></div>
144
+ </section>
145
+
146
+ <section id="sec-cut">
147
+ <h2>Section</h2>
148
+ <div class="seg" id="cut" role="group" aria-label="Section plane">
149
+ <button data-v="off" aria-pressed="true">Off</button><button data-v="0">X</button><button data-v="1">Y</button><button data-v="2">Z</button>
150
+ </div>
151
+ <div class="row"><input type="range" id="cut-pos" min="0" max="1" step="0.001" value="0.5" aria-label="Section position" disabled>
152
+ <button class="btn" id="cut-flip" disabled title="Keep the other side">Flip</button></div>
153
+ </section>
154
+
155
+ <section id="sec-history" hidden>
156
+ <h2>Optimization</h2>
157
+ <p class="note" id="hist-note"></p>
158
+ <button class="btn primary" id="hist-toggle">Play the optimization</button>
159
+ </section>
160
+
161
+ <section id="sec-layers">
162
+ <h2>Layers</h2>
163
+ <div class="layers" id="layers"></div>
164
+ </section>
165
+
166
+ <section id="sec-stats">
167
+ <h2>Numbers</h2>
168
+ <dl id="stats"></dl>
169
+ </section>
170
+ <p class="note" id="foot"></p>
171
+ </aside>
172
+
173
+ <main id="stage">
174
+ <div id="views">
175
+ <button data-view="iso">Iso</button><button data-view="front">Front</button><button data-view="side">Side</button><button data-view="top">Top</button>
176
+ </div>
177
+ <div id="legend" hidden>
178
+ <div class="t" id="lg-title"></div>
179
+ <canvas id="lg-bar" width="256" height="1"></canvas>
180
+ <div class="ticks"><span id="lg-lo"></span><span id="lg-mid"></span><span id="lg-hi"></span></div>
181
+ <div class="sub" id="lg-sub"></div>
182
+ </div>
183
+ <div id="timeline" hidden>
184
+ <canvas id="spark"></canvas>
185
+ <div class="row">
186
+ <button class="btn" id="tl-play" aria-label="Play">Play</button>
187
+ <input type="range" id="tl" min="0" max="0" step="1" value="0" aria-label="Optimization frame">
188
+ </div>
189
+ <div id="tl-label"></div>
190
+ </div>
191
+ <div id="probe" hidden></div>
192
+ <div id="empty" hidden>No solid above half density yet</div>
193
+ </main>
194
+ </div>
195
+
196
+ <script type="application/json" id="loadpath-data">/*__LOADPATH_DATA__*/null</script>
197
+ <script type="importmap">
198
+ { "imports": {
199
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
200
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
201
+ } }
202
+ </script>
203
+ <script type="module">
204
+ import * as THREE from "three";
205
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
206
+ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
207
+
208
+ const DATA = JSON.parse(document.getElementById("loadpath-data").textContent);
209
+ const $ = (id) => document.getElementById(id);
210
+ const stage = $("stage");
211
+
212
+ // ---- decoding -------------------------------------------------------------
213
+ function bytes(b64) {
214
+ const s = atob(b64), out = new Uint8Array(s.length);
215
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
216
+ return out.buffer;
217
+ }
218
+ function dec(o) {
219
+ const buf = bytes(o.d);
220
+ if (o.t === "f32") return new Float32Array(buf);
221
+ if (o.t === "u16") return new Uint16Array(buf);
222
+ if (o.t === "u32") return new Uint32Array(buf);
223
+ const q = new Uint16Array(buf), n = o.n, out = new Float32Array(q.length);
224
+ for (let i = 0; i < q.length; i++) { const c = i % n; out[i] = o.lo[c] + (q[i] / 65535) * (o.hi[c] - o.lo[c]); }
225
+ return out;
226
+ }
227
+
228
+ // ---- colour ---------------------------------------------------------------
229
+ function turbo(x) { // Mikhailov's polynomial fit of Google Turbo, sRGB
230
+ x = Math.min(1, Math.max(0, x));
231
+ const x2 = x * x, x3 = x2 * x, x4 = x3 * x, x5 = x4 * x;
232
+ return [
233
+ 0.13572138 + 4.6153926 * x - 42.66032258 * x2 + 132.13108234 * x3 - 152.94239396 * x4 + 59.28637943 * x5,
234
+ 0.09140261 + 2.19418839 * x + 4.84296658 * x2 - 14.18503333 * x3 + 4.27729857 * x4 + 2.82956604 * x5,
235
+ 0.1066733 + 12.64194608 * x - 60.58204836 * x2 + 110.36276771 * x3 - 89.90310912 * x4 + 27.34824973 * x5,
236
+ ].map((v) => Math.min(1, Math.max(0, v)));
237
+ }
238
+ const toLin = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
239
+ const LUT = new Float32Array(256 * 3);
240
+ // the ends of Turbo are near black; the ramp runs from its blue to its red
241
+ const ramp = (t) => turbo(0.1 + 0.85 * t);
242
+ for (let i = 0; i < 256; i++) { const c = ramp(i / 255); for (let k = 0; k < 3; k++) LUT[i * 3 + k] = toLin(c[k]); }
243
+ function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
244
+
245
+ // ---- scene ----------------------------------------------------------------
246
+ let renderer;
247
+ try { renderer = new THREE.WebGLRenderer({ antialias: true }); }
248
+ catch (e) { stage.innerHTML = '<div id="err">This viewer needs WebGL, which this browser has turned off.</div>'; throw e; }
249
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
250
+ renderer.localClippingEnabled = true;
251
+ stage.prepend(renderer.domElement);
252
+ const scene = new THREE.Scene();
253
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 1e5);
254
+ camera.up.set(0, 0, 1);
255
+ const controls = new OrbitControls(camera, renderer.domElement);
256
+ controls.enableDamping = true;
257
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x6b7280, 1.2));
258
+ const key = new THREE.DirectionalLight(0xffffff, 1.6); scene.add(key); scene.add(key.target);
259
+ const rim = new THREE.DirectionalLight(0xdfe7ff, 0.6); scene.add(rim);
260
+ const clip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0);
261
+
262
+ // bounds over the parts (and history) for the camera and the deformation scale
263
+ const box = new THREE.Box3();
264
+ function grow(arr) { for (let i = 0; i < arr.length; i += 3) box.expandByPoint(new THREE.Vector3(arr[i], arr[i + 1], arr[i + 2])); }
265
+
266
+ // ---- parts ----------------------------------------------------------------
267
+ const parts = DATA.parts.map((p, pi) => {
268
+ const pos = dec(p.mesh.p), idx = dec(p.mesh.i);
269
+ grow(pos);
270
+ const cases = p.cases.map((c) => ({ ...c, vm: dec(c.vonMises), d: dec(c.displacement) }));
271
+ if (cases.length > 1) { // the envelope: worst stress anywhere, deformed as the case that moves most
272
+ const n = pos.length / 3, vm = new Float32Array(n);
273
+ for (const c of cases) for (let i = 0; i < n; i++) vm[i] = Math.max(vm[i], c.vm[i]);
274
+ const big = cases.reduce((a, b) => (b.maxDisplacement > a.maxDisplacement ? b : a));
275
+ cases.unshift({ name: "worst case", vm, d: big.d, peak: Math.max(...cases.map((c) => c.peak)),
276
+ maxDisplacement: big.maxDisplacement, envelope: big.name });
277
+ }
278
+ const g = new THREE.BufferGeometry();
279
+ g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
280
+ g.setIndex(new THREE.BufferAttribute(idx, 1));
281
+ const geo = toCreasedNormals(g, Math.PI / 5); // sharp at bores and cut faces, smooth on fillets
282
+ const corners = geo.attributes.position.count;
283
+ geo.setAttribute("color", new THREE.BufferAttribute(new Float32Array(corners * 3), 3));
284
+ const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0.04,
285
+ clippingPlanes: [], side: THREE.FrontSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
286
+ const mesh = new THREE.Mesh(geo, mat);
287
+ const capMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide, clippingPlanes: [] });
288
+ const cap = new THREE.Mesh(geo, capMat);
289
+ const group = new THREE.Group(); group.add(mesh); group.add(cap);
290
+ group.visible = false; scene.add(group);
291
+ return { ...p, pi, pos, idx, cases, geo, mesh, cap, group, label: tag(p.name) };
292
+ });
293
+
294
+ function tag(text, cls = "") { const el = document.createElement("div"); el.className = "tag " + cls; el.textContent = text; el.hidden = true; stage.append(el); return el; }
295
+
296
+ // ---- overlays and loads ---------------------------------------------------
297
+ const overlayGroups = new Map();
298
+ for (const o of DATA.overlays) {
299
+ const g = new THREE.BufferGeometry();
300
+ g.setAttribute("position", new THREE.BufferAttribute(dec(o.mesh.p), 3));
301
+ g.setIndex(new THREE.BufferAttribute(dec(o.mesh.i), 1));
302
+ g.computeVertexNormals();
303
+ const opaque = o.opacity >= 0.8;
304
+ const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: o.color, transparent: !opaque, opacity: o.opacity,
305
+ depthWrite: opaque, roughness: 0.7, side: opaque ? THREE.FrontSide : THREE.DoubleSide, clippingPlanes: [] }));
306
+ m.userData.section = o.section;
307
+ m.renderOrder = opaque ? 0 : 2;
308
+ if (!overlayGroups.has(o.group)) overlayGroups.set(o.group, { color: o.color, items: [], visible: o.visible });
309
+ const grp = overlayGroups.get(o.group);
310
+ grp.visible = grp.visible || o.visible;
311
+ grp.items.push(m); scene.add(m);
312
+ if (DATA.parts.length === 0) grow(g.attributes.position.array);
313
+ }
314
+ if (box.isEmpty()) for (const grp of overlayGroups.values()) for (const m of grp.items) grow(m.geometry.attributes.position.array);
315
+ const size = box.getSize(new THREE.Vector3()), span = Math.max(size.x, size.y, size.z) || 1;
316
+ const center = box.getCenter(new THREE.Vector3());
317
+
318
+ const arrows = DATA.arrows.map((a) => {
319
+ const dir = new THREE.Vector3(...a.dir), at = new THREE.Vector3(...a.at), len = 0.24 * span;
320
+ const h = new THREE.ArrowHelper(dir, at.clone().addScaledVector(dir, -len), len, new THREE.Color(a.color), 0.28 * len, 0.14 * len);
321
+ h.line.material.depthTest = false; h.cone.material.depthTest = false; h.renderOrder = 5;
322
+ scene.add(h);
323
+ return { ...a, h, tip: at.clone().addScaledVector(dir, -len), label: tag(a.label, "load") };
324
+ });
325
+ let showLoads = true;
326
+
327
+ // ---- history --------------------------------------------------------------
328
+ const H = DATA.history && DATA.history.frames && DATA.history.frames.length ? DATA.history : null;
329
+ const histMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.62, metalness: 0.04, clippingPlanes: [] });
330
+ const histMesh = new THREE.Mesh(new THREE.BufferGeometry(), histMat); histMesh.visible = false; scene.add(histMesh);
331
+ const histCache = new Map();
332
+ function frameGeo(i) {
333
+ if (histCache.has(i)) return histCache.get(i);
334
+ const f = H.frames[i];
335
+ let g = null;
336
+ if (f.mesh) {
337
+ g = new THREE.BufferGeometry();
338
+ g.setAttribute("position", new THREE.BufferAttribute(dec(f.mesh.p), 3));
339
+ g.setIndex(new THREE.BufferAttribute(dec(f.mesh.i), 1));
340
+ g.computeVertexNormals();
341
+ }
342
+ histCache.set(i, g);
343
+ return g;
344
+ }
345
+
346
+ // ---- state ----------------------------------------------------------------
347
+ const S = { part: 0, compare: -1, field: "shape", caseIdx: 0, deform: 0, cut: "off", cutPos: 0.5, flip: false,
348
+ fit: false, flex: false, history: false, frame: 0, playing: false };
349
+
350
+ function activeParts() { return [S.part, S.compare].filter((i) => i >= 0).map((i) => parts[i]); }
351
+ function curCase(p) { return p.cases.length ? p.cases[Math.min(S.caseIdx, p.cases.length - 1)] : null; }
352
+ function allowable(p) { return p.strength ? p.strength / ((p.allowance || 1) * (p.targetSF || 1)) : null; }
353
+
354
+ function scaleFor(p) {
355
+ // the field range: the allowable stress (red = at the limit), or this part's own peak
356
+ const c = curCase(p);
357
+ if (!c) return null;
358
+ if (S.field === "stress") return { lo: 0, hi: S.fit || !allowable(p) ? c.peak : allowable(p) };
359
+ if (S.field === "disp") return { lo: 0, hi: maxDispAll() };
360
+ if (S.field === "sf") { const t = p.targetSF || 1; return { lo: 0, hi: Math.max(3, 2 * t) }; }
361
+ return null;
362
+ }
363
+ function maxDispAll() { return Math.max(1e-12, ...activeParts().map((p) => (curCase(p) ? curCase(p).maxDisplacement : 0))); }
364
+
365
+ function value(p, c, v) {
366
+ if (S.field === "stress") return c.vm[v];
367
+ if (S.field === "sf") return c.vm[v] > 0 ? p.strength / (c.vm[v] * (p.allowance || 1)) : Infinity;
368
+ if (S.field === "disp") return Math.hypot(c.d[3 * v], c.d[3 * v + 1], c.d[3 * v + 2]);
369
+ return 0;
370
+ }
371
+
372
+ function paint(p) {
373
+ const col = p.geo.attributes.color.array, n = col.length / 3, c = curCase(p), sc = scaleFor(p);
374
+ const clay = new THREE.Color(p.kind === "baseline" ? css("--cut") : css("--clay"));
375
+ if (S.field === "shape" || !c || !sc) {
376
+ for (let i = 0; i < n; i++) { col[3 * i] = clay.r; col[3 * i + 1] = clay.g; col[3 * i + 2] = clay.b; }
377
+ } else {
378
+ // per original vertex, then fan out to the creased corners
379
+ const nv = p.pos.length / 3, t = new Float32Array(nv), rev = S.field === "sf";
380
+ for (let v = 0; v < nv; v++) { let x = (value(p, c, v) - sc.lo) / (sc.hi - sc.lo || 1); x = Math.min(1, Math.max(0, x)); t[v] = rev ? 1 - x : x; }
381
+ for (let i = 0; i < n; i++) { const k = Math.round(t[p.idx[i]] * 255) * 3; col[3 * i] = LUT[k]; col[3 * i + 1] = LUT[k + 1]; col[3 * i + 2] = LUT[k + 2]; }
382
+ }
383
+ p.geo.attributes.color.needsUpdate = true;
384
+ }
385
+
386
+ let deformK = 0;
387
+ function shape(p, s) {
388
+ const pos = p.geo.attributes.position.array, c = curCase(p), n = pos.length / 3;
389
+ for (let i = 0; i < n; i++) {
390
+ const v = p.idx[i];
391
+ pos[3 * i] = p.pos[3 * v]; pos[3 * i + 1] = p.pos[3 * v + 1]; pos[3 * i + 2] = p.pos[3 * v + 2];
392
+ if (c && s) { pos[3 * i] += s * c.d[3 * v]; pos[3 * i + 1] += s * c.d[3 * v + 1]; pos[3 * i + 2] += s * c.d[3 * v + 2]; }
393
+ }
394
+ p.geo.attributes.position.needsUpdate = true;
395
+ p.geo.computeBoundingSphere();
396
+ }
397
+ function applyShape(frac) {
398
+ deformK = (0.06 * span) / maxDispAll();
399
+ for (const p of activeParts()) shape(p, frac * deformK);
400
+ }
401
+
402
+ // side by side: the second part steps over along the shorter horizontal axis
403
+ function layout() {
404
+ const offAxis = size.x <= size.y ? 0 : 1, step = (offAxis === 0 ? size.x : size.y) * 1.3 + 0.1 * span;
405
+ for (const p of parts) { p.group.visible = false; p.group.position.set(0, 0, 0); }
406
+ if (S.history) return;
407
+ parts[S.part].group.visible = true;
408
+ if (S.compare >= 0) {
409
+ const q = parts[S.compare]; q.group.visible = true;
410
+ q.group.position.setComponent(offAxis, step);
411
+ }
412
+ }
413
+
414
+ function updateClip() {
415
+ const on = S.cut !== "off";
416
+ const planes = on ? [clip] : [];
417
+ if (on) {
418
+ const ax = +S.cut, n = new THREE.Vector3(); n.setComponent(ax, S.flip ? 1 : -1);
419
+ const lo = box.min.getComponent(ax), hi = box.max.getComponent(ax), x = lo + S.cutPos * (hi - lo);
420
+ clip.normal.copy(n); clip.constant = S.flip ? -x : x;
421
+ }
422
+ for (const p of parts) { p.mesh.material.clippingPlanes = planes; p.cap.material.clippingPlanes = planes; p.cap.visible = on; }
423
+ histMat.clippingPlanes = planes;
424
+ for (const grp of overlayGroups.values()) for (const m of grp.items) m.material.clippingPlanes = m.userData.section ? planes : [];
425
+ $("cut-pos").disabled = !on; $("cut-flip").disabled = !on;
426
+ }
427
+
428
+ function refreshColors() {
429
+ const cut = new THREE.Color(css("--cut"));
430
+ for (const p of parts) p.cap.material.color.copy(cut);
431
+ histMat.color.set(css("--clay"));
432
+ for (const p of activeParts()) paint(p);
433
+ scene.background = new THREE.Color(css("--ground"));
434
+ }
435
+
436
+ // ---- legend and numbers ---------------------------------------------------
437
+ const FIELD_NAME = { stress: "von Mises stress", sf: "Safety factor", disp: "Displacement" };
438
+ const fmt = (x, d = 2) => (x === Infinity ? "∞" : Math.abs(x) >= 100 ? x.toFixed(0) : Math.abs(x) >= 10 ? x.toFixed(1) : x.toFixed(d));
439
+ function legend() {
440
+ const p = parts[S.part], sc = p && scaleFor(p);
441
+ $("legend").hidden = S.history || S.field === "shape" || !sc;
442
+ if ($("legend").hidden) return;
443
+ const unit = S.field === "stress" ? " MPa" : S.field === "disp" ? " mm" : "";
444
+ const c = curCase(p);
445
+ $("lg-title").textContent = FIELD_NAME[S.field] + " · " + c.name;
446
+ const bar = $("lg-bar").getContext("2d"), img = bar.createImageData(256, 1);
447
+ for (let i = 0; i < 256; i++) { const t = ramp(S.field === "sf" ? 1 - i / 255 : i / 255); img.data.set([t[0] * 255, t[1] * 255, t[2] * 255, 255], i * 4); }
448
+ bar.putImageData(img, 0, 0);
449
+ $("lg-lo").textContent = fmt(sc.lo) + unit; $("lg-mid").textContent = fmt((sc.lo + sc.hi) / 2) + unit;
450
+ $("lg-hi").textContent = (S.field === "sf" ? "≥ " : "") + fmt(sc.hi) + unit;
451
+ let sub = "";
452
+ if (S.field === "stress") sub = S.fit || !allowable(p) ? `Scaled to this case's peak, ${fmt(c.peak)} MPa.` : (p.targetSF ? `Red is the allowable: strength ÷ ${fmt(p.allowance)} stress allowance ÷ safety factor goal ${p.targetSF}.` : `Red is the material's strength ÷ the ${fmt(p.allowance)} stress allowance.`) + ` Peak here: ${fmt(c.peak)} MPa.`;
453
+ if (S.field === "sf") sub = `Red is failure (1.0)${p.targetSF ? `; the goal is ${p.targetSF}` : ""}. Includes the ${fmt(p.allowance)}× stress allowance.`;
454
+ if (S.field === "disp") sub = `Largest: ${fmt(c.maxDisplacement, 3)} mm.`;
455
+ if (c.envelope) sub += ` Worst of all cases; shape from "${c.envelope}".`;
456
+ $("lg-sub").textContent = sub;
457
+ }
458
+
459
+ function stats() {
460
+ const dl = $("stats"); dl.innerHTML = "";
461
+ const add = (k, v, cls) => { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = k; dd.textContent = v; if (cls) dd.className = cls; dl.append(dt, dd); };
462
+ const ps = activeParts();
463
+ ps.forEach((p, j) => {
464
+ if (ps.length > 1) { const h = document.createElement("div"); h.className = "cmp-head"; h.textContent = (j ? "right · " : "left · ") + p.name; dl.append(h); }
465
+ for (const [k, v] of Object.entries(p.stats || {})) add(k, typeof v === "number" ? fmt(v) : v, k === "meets the goal" ? (v === "yes" ? "good" : "bad") : "");
466
+ const c = curCase(p);
467
+ if (c && S.field !== "shape") {
468
+ add("peak stress · " + c.name, fmt(c.peak) + " MPa");
469
+ add("largest displacement", fmt(c.maxDisplacement, 3) + " mm");
470
+ }
471
+ });
472
+ if (!ps.length) $("sec-stats").hidden = true;
473
+ }
474
+
475
+ // ---- controls -------------------------------------------------------------
476
+ $("title").textContent = DATA.title || "loadpath";
477
+ $("subtitle").textContent = [DATA.subtitle, DATA.created].filter(Boolean).join(" · ");
478
+ $("foot").textContent = (DATA.notes || []).join(" ") || "Drag to orbit, right-drag or two fingers to pan, scroll or pinch to zoom. Hover the part to read the value under the pointer.";
479
+
480
+ // open on the study's pick (starred) when there is one
481
+ S.part = Math.max(0, parts.findIndex((p) => p.name.startsWith("★")));
482
+ const partSel = $("part"), cmpSel = $("compare");
483
+ parts.forEach((p, i) => partSel.add(new Option(p.name, i)));
484
+ function fillCompare() {
485
+ cmpSel.innerHTML = ""; cmpSel.add(new Option("Compare with… (none)", -1));
486
+ parts.forEach((p, i) => { if (i !== S.part) cmpSel.add(new Option("Beside it: " + p.name, i)); });
487
+ cmpSel.value = S.compare;
488
+ }
489
+ partSel.value = S.part;
490
+ fillCompare();
491
+ $("cmp-row").hidden = parts.length < 2;
492
+ $("sec-part").hidden = parts.length === 0;
493
+ function fillCases() {
494
+ const p = parts[S.part], cs = $("case"); cs.innerHTML = "";
495
+ (p ? p.cases : []).forEach((c, i) => cs.add(new Option(c.name === "worst case" ? "Worst case (envelope)" : "Load case: " + c.name, i)));
496
+ S.caseIdx = Math.min(S.caseIdx, Math.max(0, cs.options.length - 1)); cs.value = S.caseIdx;
497
+ const has = p && p.cases.length > 0;
498
+ cs.hidden = !has || p.cases.length < 2;
499
+ for (const b of $("field").querySelectorAll("button")) b.disabled = b.dataset.v !== "shape" && !has;
500
+ $("sec-deform").hidden = !has;
501
+ if (!has) S.field = "shape";
502
+ }
503
+ fillCases();
504
+ if (!parts.some((p) => p.cases.length)) $("sec-field").hidden = true;
505
+
506
+ function seg(id, key, fn) {
507
+ const el = $(id);
508
+ el.addEventListener("click", (e) => {
509
+ const b = e.target.closest("button"); if (!b || b.disabled) return;
510
+ for (const x of el.querySelectorAll("button")) x.setAttribute("aria-pressed", x === b);
511
+ S[key] = b.dataset.v; fn();
512
+ });
513
+ }
514
+ function setSeg(id, v) { for (const x of $(id).querySelectorAll("button")) x.setAttribute("aria-pressed", x.dataset.v === String(v)); }
515
+
516
+ function update() {
517
+ layout(); applyShape(S.deform); refreshColors(); legend(); stats(); updateClip(); arrowsVis();
518
+ $("fit-row").hidden = S.field !== "stress";
519
+ $("deform-val").textContent = S.deform > 0 ? "×" + fmt(S.deform * deformK, 0) : "off";
520
+ $("deform-note").textContent = S.deform > 0 ? "Displacement drawn " + fmt(S.deform * deformK, 0) + " times its real size." : "";
521
+ }
522
+ partSel.onchange = () => { S.part = +partSel.value; if (S.compare === S.part) S.compare = -1; fillCompare(); fillCases(); setSeg("field", S.field); update(); };
523
+ cmpSel.onchange = () => { S.compare = +cmpSel.value; update(); fitView(); };
524
+ $("case").onchange = () => { S.caseIdx = +$("case").value; update(); };
525
+ $("fit").onchange = () => { S.fit = $("fit").checked; update(); };
526
+ seg("field", "field", update);
527
+ seg("cut", "cut", update);
528
+ $("deform").oninput = () => { S.deform = +$("deform").value; S.flex = false; $("flex").textContent = "Animate"; update(); };
529
+ $("cut-pos").oninput = () => { S.cutPos = +$("cut-pos").value; updateClip(); };
530
+ $("cut-flip").onclick = () => { S.flip = !S.flip; updateClip(); };
531
+ $("flex").onclick = () => { S.flex = !S.flex; $("flex").textContent = S.flex ? "Stop" : "Animate"; if (!S.flex) { applyShape(S.deform); } };
532
+
533
+ // layers
534
+ const layers = $("layers");
535
+ function layerRow(name, color, checked, fn) {
536
+ const l = document.createElement("label"), cb = document.createElement("input"), sw = document.createElement("span"), t = document.createElement("span");
537
+ cb.type = "checkbox"; cb.checked = checked; cb.onchange = () => fn(cb.checked);
538
+ sw.className = "swatch"; sw.style.background = color; t.textContent = name;
539
+ l.append(cb, sw, t); layers.append(l);
540
+ }
541
+ for (const [name, grp] of overlayGroups) {
542
+ for (const m of grp.items) m.visible = grp.visible;
543
+ layerRow(name, grp.color, grp.visible, (v) => { for (const m of grp.items) m.visible = v; });
544
+ }
545
+ if (arrows.length) layerRow("loads", arrows[0].color, true, (v) => { showLoads = v; arrowsVis(); });
546
+ if (parts.length && overlayGroups.size) layerRow("see through the part", css("--clay"), false, (v) => {
547
+ for (const p of parts) { const m = p.mesh.material; m.transparent = v; m.opacity = v ? 0.28 : 1; m.depthWrite = !v; m.needsUpdate = true; p.cap.visible = !v && S.cut !== "off"; }
548
+ });
549
+ $("sec-layers").hidden = !layers.children.length;
550
+ function arrowsVis() {
551
+ const c = parts[S.part] && curCase(parts[S.part]);
552
+ for (const a of arrows) a.h.visible = showLoads && (!c || c.name === "worst case" || !a.case || a.case === c.name || S.field === "shape");
553
+ }
554
+
555
+ // ---- history UI -----------------------------------------------------------
556
+ if (H) {
557
+ $("sec-history").hidden = false;
558
+ const runs = H.runs, last = H.frames[H.frames.length - 1];
559
+ $("hist-note").textContent = `${H.frames.length} frames from ${runs} optimizer run${runs > 1 ? "s" : ""}` +
560
+ (runs > 1 ? ", one per volume fraction the search tried." : ".") + " The surface is the density field cut at one half.";
561
+ $("tl").max = H.frames.length - 1;
562
+ $("hist-toggle").onclick = () => setHistory(!S.history);
563
+ }
564
+ function setHistory(on) {
565
+ S.history = on; S.playing = false;
566
+ $("hist-toggle").textContent = on ? "Back to the result" : "Play the optimization";
567
+ $("timeline").hidden = !on; histMesh.visible = on;
568
+ for (const id of ["sec-field", "sec-deform", "sec-part"]) $(id).style.opacity = on ? 0.4 : 1;
569
+ for (const id of ["sec-field", "sec-deform", "sec-part"]) $(id).inert = on;
570
+ if (on) { S.frame = S.frame || 0; showFrame(S.frame); playPause(true); } else { $("empty").hidden = true; }
571
+ update();
572
+ }
573
+ function showFrame(i) {
574
+ S.frame = i; $("tl").value = i;
575
+ const f = H.frames[i], g = frameGeo(i);
576
+ histMesh.geometry = g || new THREE.BufferGeometry();
577
+ $("empty").hidden = !!g;
578
+ const runs = H.runs;
579
+ $("tl-label").textContent = (runs > 1 ? `run ${f.run + 1}/${runs} · ` : "") + (f.volumeFraction != null ? `target vf ${f.volumeFraction.toFixed(3)} · ` : "") +
580
+ `iteration ${f.iteration} · volume ${f.volume != null ? f.volume.toFixed(3) : "–"} · compliance ${f.compliance != null ? f.compliance.toPrecision(3) : "–"} N·mm · β ${f.beta != null ? fmt(f.beta, 1) : "–"}`;
581
+ spark();
582
+ }
583
+ function spark() {
584
+ const cv = $("spark"), w = cv.clientWidth, h = cv.clientHeight; if (!w) return;
585
+ const dpr = Math.min(2, window.devicePixelRatio); cv.width = w * dpr; cv.height = h * dpr;
586
+ const g = cv.getContext("2d"); g.scale(dpr, dpr);
587
+ const ys = H.frames.map((f) => (f.compliance != null && f.compliance > 0 ? Math.log(f.compliance) : null));
588
+ const ok = ys.filter((y) => y != null); if (!ok.length) return;
589
+ const lo = Math.min(...ok), hi = Math.max(...ok), n = H.frames.length;
590
+ const X = (i) => (n > 1 ? (i / (n - 1)) * (w - 4) + 2 : w / 2), Y = (y) => h - 3 - ((y - lo) / (hi - lo || 1)) * (h - 8);
591
+ // run boundaries
592
+ g.strokeStyle = css("--line"); g.lineWidth = 1;
593
+ for (let i = 1; i < n; i++) if (H.frames[i].run !== H.frames[i - 1].run) { g.beginPath(); g.moveTo(X(i) - 0.5, 0); g.lineTo(X(i) - 0.5, h); g.stroke(); }
594
+ g.beginPath(); let started = false;
595
+ ys.forEach((y, i) => { if (y == null) return; if (!started) { g.moveTo(X(i), Y(y)); started = true; } else g.lineTo(X(i), Y(y)); });
596
+ g.strokeStyle = css("--muted"); g.lineWidth = 1.25; g.stroke();
597
+ const y = ys[S.frame];
598
+ g.fillStyle = css("--accent"); g.beginPath(); g.arc(X(S.frame), y != null ? Y(y) : h / 2, 3.5, 0, 7); g.fill();
599
+ g.fillStyle = css("--muted"); g.font = "10px " + css("--mono"); g.fillText("compliance (log)", 4, 10);
600
+ }
601
+ let lastStep = 0;
602
+ function playPause(force) { S.playing = force === undefined ? !S.playing : force; $("tl-play").textContent = S.playing ? "Pause" : "Play"; if (S.playing && S.frame >= H.frames.length - 1) showFrame(0); }
603
+ $("tl-play").onclick = () => playPause();
604
+ $("tl").oninput = () => { playPause(false); showFrame(+$("tl").value); };
605
+
606
+ // ---- camera ---------------------------------------------------------------
607
+ function fitView(dir) {
608
+ const b = new THREE.Box3();
609
+ for (const p of activeParts()) if (p.group.visible) b.expandByObject(p.group);
610
+ if (b.isEmpty()) b.copy(box);
611
+ const c = b.getCenter(new THREE.Vector3()), r = b.getSize(new THREE.Vector3()).length() / 2;
612
+ const d = dir || camera.position.clone().sub(controls.target).normalize();
613
+ const dist = 0.85 * r / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * (camera.aspect < 1 ? 1.25 / camera.aspect : 1.0);
614
+ controls.target.copy(c); camera.position.copy(c).addScaledVector(d, dist);
615
+ camera.near = dist / 100; camera.far = dist * 100; camera.updateProjectionMatrix();
616
+ }
617
+ const VIEWS = { iso: [1, -1.25, 0.85], front: [0, -1, 0.0001], side: [1, 0, 0.0001], top: [0.0001, -0.0001, 1] };
618
+ $("views").addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) fitView(new THREE.Vector3(...VIEWS[b.dataset.view]).normalize()); });
619
+
620
+ // ---- probe ----------------------------------------------------------------
621
+ const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
622
+ let probeAt = null;
623
+ renderer.domElement.addEventListener("pointermove", (e) => { const r = renderer.domElement.getBoundingClientRect(); probeAt = [e.clientX - r.left, e.clientY - r.top, r.width, r.height]; });
624
+ renderer.domElement.addEventListener("pointerleave", () => { probeAt = null; $("probe").hidden = true; });
625
+ function probe() {
626
+ const el = $("probe");
627
+ if (!probeAt || S.history || S.field === "shape") { el.hidden = true; return; }
628
+ ptr.set((probeAt[0] / probeAt[2]) * 2 - 1, -(probeAt[1] / probeAt[3]) * 2 + 1);
629
+ ray.setFromCamera(ptr, camera);
630
+ const ps = activeParts().filter((p) => p.cases.length);
631
+ const hits = ray.intersectObjects(ps.map((p) => p.mesh), false).filter((h) => S.cut === "off" || clip.distanceToPoint(h.point) >= 0);
632
+ if (!hits.length) { el.hidden = true; return; }
633
+ const h = hits[0], p = ps.find((q) => q.mesh === h.object), c = curCase(p);
634
+ const v = p.idx[h.face.a];
635
+ const vm = c.vm[v], sf = vm > 0 ? p.strength / (vm * (p.allowance || 1)) : Infinity, d = Math.hypot(c.d[3 * v], c.d[3 * v + 1], c.d[3 * v + 2]);
636
+ el.textContent = `${fmt(vm)} MPa · SF ${fmt(sf)} · ${fmt(d, 3)} mm`;
637
+ el.style.left = probeAt[0] + "px"; el.style.top = probeAt[1] + "px"; el.hidden = false;
638
+ }
639
+
640
+ // ---- labels ---------------------------------------------------------------
641
+ const tmp = new THREE.Vector3();
642
+ function place(el, v, show) {
643
+ if (!show) { el.hidden = true; return; }
644
+ tmp.copy(v).project(camera);
645
+ if (tmp.z > 1) { el.hidden = true; return; }
646
+ el.hidden = false;
647
+ el.style.left = ((tmp.x + 1) / 2) * stage.clientWidth + "px";
648
+ el.style.top = ((1 - tmp.y) / 2) * stage.clientHeight - 6 + "px";
649
+ }
650
+ function labels() {
651
+ const cmp = S.compare >= 0 && !S.history;
652
+ for (const p of parts) {
653
+ const b = new THREE.Box3().setFromObject(p.group), top = b.getCenter(new THREE.Vector3()); top.z = b.max.z;
654
+ place(p.label, top, cmp && p.group.visible);
655
+ }
656
+ // one label per force and case: a bolt pattern repeats the same load at every hole
657
+ const seen = new Set();
658
+ for (const a of arrows) { const k = a.label + "|" + a.case, first = !seen.has(k); seen.add(k); place(a.label, a.tip, a.h.visible && first); }
659
+ }
660
+
661
+ // ---- loop -----------------------------------------------------------------
662
+ function resize() {
663
+ const w = stage.clientWidth, h = stage.clientHeight;
664
+ renderer.setSize(w, h, false); camera.aspect = w / Math.max(1, h); camera.updateProjectionMatrix();
665
+ if (H && S.history) spark();
666
+ }
667
+ new ResizeObserver(resize).observe(stage);
668
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
669
+ mq.addEventListener?.("change", refreshColors);
670
+ new MutationObserver(refreshColors).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
671
+
672
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
673
+ const clock = new THREE.Clock();
674
+ function frame() {
675
+ requestAnimationFrame(frame);
676
+ const t = clock.getElapsedTime();
677
+ if (S.flex && !S.history) applyShape((S.deform || 1) * 0.5 * (1 - Math.cos(t * 2.4)));
678
+ if (S.history && S.playing && t - lastStep > (reduce ? 0.5 : 0.12)) {
679
+ lastStep = t;
680
+ if (S.frame < H.frames.length - 1) showFrame(S.frame + 1); else playPause(false);
681
+ }
682
+ key.position.copy(camera.position).add(new THREE.Vector3(0.3 * span, -0.2 * span, 0.8 * span));
683
+ key.target.position.copy(controls.target);
684
+ rim.position.copy(controls.target).sub(camera.position).add(controls.target);
685
+ controls.update();
686
+ renderer.render(scene, camera);
687
+ labels(); probe();
688
+ }
689
+ resize();
690
+ update();
691
+ fitView(new THREE.Vector3(...VIEWS.iso).normalize());
692
+ if (!parts.length && H) setHistory(true);
693
+ frame();
694
+ </script>