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.py ADDED
@@ -0,0 +1,446 @@
1
+ """
2
+ Interactive viewers: one self-contained HTML file per study (or heat
3
+ exchanger) that opens in any browser. Orbit the part, colour it by von
4
+ Mises stress, safety factor or displacement for each load case, scrub an
5
+ exaggerated deformation, cut it open with a section plane, play the
6
+ optimization back iteration by iteration, and put an outcome beside another
7
+ (or beside the part it replaces) to compare.
8
+
9
+ from loadpath.viewer import HistoryRecorder, study_viewer
10
+ rec = HistoryRecorder()
11
+ result = run_study(study, on_progress=rec)
12
+ study_viewer(result, "bracket.html", history=rec)
13
+
14
+ The file carries its own data (meshes and fields as packed arrays) and
15
+ loads three.js from a CDN, so it needs a network connection the first time
16
+ it is opened and nothing else: no server, no Python. render.py is for
17
+ reports and vision models; this is for people.
18
+
19
+ Lower level: `Viewer` takes parts, fields, overlays and history frames
20
+ directly, for results that did not come out of run_study.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import os
27
+ import time
28
+
29
+ import numpy as np
30
+
31
+ __all__ = ["HistoryRecorder", "Viewer", "study_viewer", "heat_exchanger_viewer"]
32
+
33
+ _TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "viewer.html")
34
+
35
+ # overlay colours (sRGB hex); the page keeps its own palette for the UI
36
+ _KEEP_OUT = "#e0533d"
37
+ _PRESERVE = "#8a94a6"
38
+ _SUPPORT = "#2f9e6f"
39
+ _DESIGN = "#5b7fd6"
40
+ _LOAD = "#2f5bd3"
41
+ _FLUIDS = ["#2f6fd6", "#e8892b", "#3aa57a", "#b04fc7"]
42
+
43
+
44
+ # -- packing ---------------------------------------------------------------
45
+
46
+ def _b64(a) -> str:
47
+ return base64.b64encode(np.ascontiguousarray(a).tobytes()).decode("ascii")
48
+
49
+
50
+ def _f32(a) -> dict:
51
+ a = np.asarray(a, np.float32)
52
+ return {"t": "f32", "n": int(a.shape[-1]) if a.ndim > 1 else 1, "d": _b64(a.ravel())}
53
+
54
+
55
+ def _q16(a) -> dict:
56
+ """Quantize to 16 bits per component against the array's own range:
57
+ a tenth of a micron on a 100 mm part, a sixty-thousandth of a stress
58
+ range. Half the bytes of float32, for history frames and fields."""
59
+ a = np.asarray(a, np.float64)
60
+ n = int(a.shape[-1]) if a.ndim > 1 else 1
61
+ a2 = a.reshape(-1, n)
62
+ lo, hi = a2.min(axis=0) if len(a2) else np.zeros(n), a2.max(axis=0) if len(a2) else np.ones(n)
63
+ span = np.where(hi > lo, hi - lo, 1.0)
64
+ q = np.round((a2 - lo) / span * 65535.0).astype(np.uint16)
65
+ return {"t": "q16", "n": n, "lo": lo.tolist(), "hi": (lo + span).tolist(), "d": _b64(q.ravel())}
66
+
67
+
68
+ def _idx(faces) -> dict:
69
+ f = np.asarray(faces)
70
+ if f.size and f.max() < 65536:
71
+ return {"t": "u16", "n": 3, "d": _b64(f.astype(np.uint16).ravel())}
72
+ return {"t": "u32", "n": 3, "d": _b64(f.astype(np.uint32).ravel())}
73
+
74
+
75
+ def _decimate(mesh, faces):
76
+ """Quadric decimation (trimesh, with the fast_simplification package),
77
+ else vertex clustering on a grid sized to land near `faces`: coarser
78
+ looking, but it never ships a 20 MB page because a package is missing."""
79
+ if faces is None or len(mesh.faces) <= faces:
80
+ return mesh
81
+ try:
82
+ return mesh.simplify_quadric_decimation(face_count=int(faces))
83
+ except Exception: # noqa: BLE001 - a display proxy only
84
+ pass
85
+ import trimesh
86
+ cell = float(np.sqrt(mesh.area / (0.5 * faces)))
87
+ for _ in range(6):
88
+ key = np.floor(mesh.vertices / cell).astype(np.int64)
89
+ _, inv = np.unique(key, axis=0, return_inverse=True)
90
+ inv = inv.ravel()
91
+ f = inv[mesh.faces]
92
+ f = f[(f[:, 0] != f[:, 1]) & (f[:, 1] != f[:, 2]) & (f[:, 0] != f[:, 2])]
93
+ if len(f) <= 1.3 * faces:
94
+ break
95
+ cell *= 1.25
96
+ v = np.zeros((inv.max() + 1, 3))
97
+ np.add.at(v, inv, mesh.vertices)
98
+ v /= np.bincount(inv)[:, None]
99
+ return trimesh.Trimesh(v, f, process=True)
100
+
101
+
102
+ def _mesh(mesh, max_faces=None, quantize=False) -> dict:
103
+ m = _decimate(mesh, max_faces)
104
+ return {"p": (_q16 if quantize else _f32)(np.asarray(m.vertices)), "i": _idx(m.faces)}, m
105
+
106
+
107
+ def _manifold_mesh(shape):
108
+ import trimesh
109
+ mf = shape.to_manifold().to_mesh()
110
+ return trimesh.Trimesh(np.asarray(mf.vert_properties)[:, :3], np.asarray(mf.tri_verts), process=False)
111
+
112
+
113
+ def _clean(o):
114
+ if isinstance(o, dict):
115
+ return {k: _clean(v) for k, v in o.items()}
116
+ if isinstance(o, (list, tuple)):
117
+ return [_clean(v) for v in o]
118
+ if isinstance(o, np.ndarray):
119
+ return _clean(o.tolist())
120
+ if isinstance(o, (np.floating, float)):
121
+ f = float(o)
122
+ return None if f != f or f in (float("inf"), float("-inf")) else f
123
+ if isinstance(o, np.integer):
124
+ return int(o)
125
+ if isinstance(o, np.bool_):
126
+ return bool(o)
127
+ return o
128
+
129
+
130
+ # -- history ---------------------------------------------------------------
131
+
132
+ class HistoryRecorder:
133
+ """Pass as run_study's on_progress (or call it from yours) to keep the
134
+ density field every `every` iterations, as float16 (120k cells is 240 kB
135
+ a frame). A search runs the optimizer several times, at different
136
+ volume fractions; each run is kept, in order. `chain` is called with
137
+ every event too, so this can sit in front of a progress printer."""
138
+
139
+ def __init__(self, every: int = 2, chain=None):
140
+ self.every = max(1, int(every))
141
+ self.chain = chain
142
+ self.runs: list[dict] = []
143
+
144
+ def __call__(self, event, xp=None):
145
+ if self.chain is not None:
146
+ self.chain(event, xp)
147
+ if event.get("stage") != "optimize":
148
+ return
149
+ if "iteration" not in event:
150
+ self.runs.append({"volumeFraction": event.get("volumeFraction"), "frames": []})
151
+ return
152
+ if xp is None:
153
+ return
154
+ if not self.runs:
155
+ self.runs.append({"volumeFraction": event.get("volumeFraction"), "frames": []})
156
+ run = self.runs[-1]
157
+ frame = {"iteration": int(event["iteration"]), "compliance": event.get("compliance"),
158
+ "volume": event.get("volume"), "beta": event.get("beta"),
159
+ "x": np.asarray(xp, np.float16).copy()}
160
+ if frame["iteration"] % self.every == 0 or not run["frames"]:
161
+ run["frames"].append(frame)
162
+ run["last"] = None
163
+ else:
164
+ run["last"] = frame # so the end of a run is kept even off the stride
165
+
166
+ def _frames(self):
167
+ for r in self.runs:
168
+ yield r["frames"] + ([r["last"]] if r.get("last") is not None else [])
169
+
170
+ @property
171
+ def frame_count(self) -> int:
172
+ return sum(len(f) for f in self._frames())
173
+
174
+
175
+ def _iso(domain, x, level=0.5, max_faces=None):
176
+ """The solid (density above `level`) as a surface in mm."""
177
+ import trimesh
178
+ from skimage.measure import marching_cubes
179
+
180
+ g = np.pad(domain.full_grid(np.asarray(x, np.float32)), 1)
181
+ if g.max() <= level:
182
+ return None
183
+ v, f, _, _ = marching_cubes(g, level, spacing=(domain.pitch,) * 3)
184
+ v = v + np.asarray(domain.origin) - 0.5 * domain.pitch
185
+ m = trimesh.Trimesh(v, f[:, ::-1], process=True)
186
+ return _decimate(m, max_faces)
187
+
188
+
189
+ def _history_payload(study, recorder, max_frames=90, max_faces=6000):
190
+ from .domain import build_domain
191
+
192
+ runs = list(recorder._frames())
193
+ flat = [(ri, f) for ri, fr in enumerate(runs) for f in fr]
194
+ if not flat:
195
+ return None
196
+ domain = build_domain(study)
197
+ if any(len(f["x"]) != domain.n_elem for _, f in flat):
198
+ return None # recorded on another grid (a different resolution): nothing to place it on
199
+ if len(flat) > max_frames:
200
+ # spread evenly, and always keep each run's last frame (its converged design)
201
+ keep = set(np.linspace(0, len(flat) - 1, max(2, max_frames - len(runs))).round().astype(int).tolist())
202
+ ends = np.cumsum([len(fr) for fr in runs]) - 1
203
+ keep |= set(int(e) for e in ends if e >= 0)
204
+ flat = [flat[i] for i in sorted(keep)]
205
+ frames = []
206
+ for ri, f in flat:
207
+ m = _iso(domain, f["x"], max_faces=max_faces)
208
+ frames.append(_clean({
209
+ "run": ri, "volumeFraction": recorder.runs[ri]["volumeFraction"], "iteration": f["iteration"],
210
+ "compliance": f["compliance"], "volume": f["volume"], "beta": f["beta"],
211
+ "mesh": None if m is None else _mesh(m, quantize=True)[0]}))
212
+ return {"runs": len(runs), "frames": frames}
213
+
214
+
215
+ # -- the scene -------------------------------------------------------------
216
+
217
+ class Viewer:
218
+ """A scene for the HTML viewer. Units are mm, N and MPa, z up."""
219
+
220
+ def __init__(self, title: str = "loadpath", subtitle: str | None = None):
221
+ self.title = title
222
+ self.subtitle = subtitle
223
+ self.parts: list[dict] = []
224
+ self.overlays: list[dict] = []
225
+ self.arrows: list[dict] = []
226
+ self.history = None
227
+ self.notes: list[str] = []
228
+
229
+ def add_part(self, name, mesh, fields=None, strength=None, allowance=1.0, target_sf=None,
230
+ stats=None, max_faces=80_000, color=None, kind="part"):
231
+ """A selectable body. `fields` is verify_part(..., fields=True)["fields"]
232
+ (analysis nodes; per case von Mises and displacement), mapped onto
233
+ the drawn surface by nearest node. `stats` is shown beside it: a dict
234
+ of label -> value (already formatted or a number)."""
235
+ payload, m = _mesh(mesh, max_faces)
236
+ part = {"name": name, "kind": kind, "mesh": payload, "stats": _clean(stats or {}),
237
+ "color": color, "strength": strength, "allowance": allowance, "targetSF": target_sf,
238
+ "cases": []}
239
+ if fields is not None and fields.get("cases"):
240
+ from scipy.spatial import cKDTree
241
+ # inverse-distance over the nearest few analysis nodes: one node
242
+ # each speckles the surface where it cuts across voxel corners
243
+ dist, idx = cKDTree(fields["nodes"]).query(np.asarray(m.vertices), k=4)
244
+ w = 1.0 / np.maximum(dist, 1e-6) ** 2
245
+ w /= w.sum(axis=1, keepdims=True)
246
+ for c in fields["cases"]:
247
+ vm = (np.asarray(c["vonMises"])[idx] * w).sum(axis=1)
248
+ d = (np.asarray(c["displacement"])[idx] * w[..., None]).sum(axis=1)
249
+ part["cases"].append({"name": c["name"], "vonMises": _q16(vm), "displacement": _q16(d),
250
+ "peak": float(vm.max()),
251
+ "maxDisplacement": float(np.linalg.norm(d, axis=1).max())})
252
+ self.parts.append(part)
253
+ return part
254
+
255
+ def add_overlay(self, name, mesh, color="#8a94a6", opacity=0.25, group="context", visible=True,
256
+ max_faces=40_000, section=True):
257
+ """A context body drawn translucent (keep-outs, the design space, a
258
+ fluid space). `section` says whether the section plane cuts it."""
259
+ payload, _ = _mesh(mesh, max_faces)
260
+ self.overlays.append({"name": name, "group": group, "color": color, "opacity": float(opacity),
261
+ "visible": bool(visible), "section": bool(section), "mesh": payload})
262
+
263
+ def add_arrow(self, at, direction, label="", case=None, color=_LOAD):
264
+ d = np.asarray(direction, float)
265
+ n = float(np.linalg.norm(d))
266
+ if n == 0:
267
+ return
268
+ self.arrows.append({"at": np.asarray(at, float).tolist(), "dir": (d / n).tolist(),
269
+ "label": label, "case": case, "color": color})
270
+
271
+ def add_study_context(self, study, design_space=True):
272
+ """Keep-outs, preserved regions, supports, load arrows and the
273
+ design space envelope, as toggleable layers."""
274
+ for r in study.keep_out:
275
+ try:
276
+ self.add_overlay(f"keep-out · {r.id}", _manifold_mesh(r.shape), _KEEP_OUT, 0.18, "keep-outs")
277
+ except Exception: # noqa: BLE001 - context only
278
+ pass
279
+ for r in study.preserve:
280
+ try:
281
+ self.add_overlay(f"preserve · {r.id}", _manifold_mesh(r.shape), _PRESERVE, 0.22, "preserved",
282
+ visible=False)
283
+ except Exception: # noqa: BLE001
284
+ pass
285
+ for i, s in enumerate(study.supports):
286
+ try:
287
+ shape = s.shape if s.shape is not None else study.region_shape(s.region)[0]
288
+ label = s.region or f"support {i + 1}"
289
+ self.add_overlay(f"support · {label}", _manifold_mesh(shape), _SUPPORT, 0.35, "supports")
290
+ except Exception: # noqa: BLE001
291
+ pass
292
+ if design_space:
293
+ for i, s in enumerate(study.design_space):
294
+ try:
295
+ self.add_overlay(f"design space {i + 1}" if len(study.design_space) > 1 else "design space",
296
+ _manifold_mesh(s), _DESIGN, 0.07, "design space", visible=False)
297
+ except Exception: # noqa: BLE001
298
+ pass
299
+ for case in study.load_cases:
300
+ for ld in case.loads:
301
+ try:
302
+ shape = ld.shape if ld.shape is not None else study.region_shape(ld.region)[0]
303
+ except ValueError:
304
+ continue
305
+ lo, hi = shape.bounds()
306
+ f = np.asarray(ld.force, float)
307
+ mag = float(np.linalg.norm(f))
308
+ if mag > 0:
309
+ self.add_arrow(0.5 * (np.asarray(lo) + np.asarray(hi)), f, f"{mag:.0f} N", case.name)
310
+ mom = float(np.linalg.norm(ld.moment))
311
+ if mom > 0:
312
+ self.add_arrow(0.5 * (np.asarray(lo) + np.asarray(hi)), ld.moment,
313
+ f"{mom / 1000:.2f} N·m", case.name, color="#8b5cf6")
314
+
315
+ def to_dict(self) -> dict:
316
+ return _clean({"version": 1, "title": self.title, "subtitle": self.subtitle, "parts": self.parts,
317
+ "overlays": self.overlays, "arrows": self.arrows, "history": self.history,
318
+ "notes": self.notes, "created": time.strftime("%Y-%m-%d %H:%M")})
319
+
320
+ def html(self, standalone: bool = True) -> str:
321
+ """The page. `standalone=False` leaves out the doctype, html, head and
322
+ body tags, for hosts that wrap a page in their own skeleton."""
323
+ with open(_TEMPLATE) as fh:
324
+ page = fh.read()
325
+ data = json.dumps(self.to_dict(), separators=(",", ":")).replace("</", "<\\/")
326
+ page = page.replace("/*__LOADPATH_DATA__*/null", data, 1)
327
+ page = page.replace("__LOADPATH_TITLE__", _esc(self.title), 1)
328
+ if standalone:
329
+ page = ("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
330
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n"
331
+ + page.replace("<!--/head-->", "</head>\n<body>", 1) + "\n</body>\n</html>\n")
332
+ else:
333
+ page = page.replace("<!--/head-->", "", 1)
334
+ return page
335
+
336
+ def save(self, path, standalone: bool = True) -> str:
337
+ with open(path, "w") as fh:
338
+ fh.write(self.html(standalone))
339
+ return path
340
+
341
+
342
+ def _esc(s: str) -> str:
343
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
344
+
345
+
346
+ def _stats(o, study) -> dict:
347
+ r = o.report
348
+ out = {"mass": f"{r['mass']:.1f} g"}
349
+ if r.get("massReduction") is not None:
350
+ out["lighter than the design space"] = f"{100 * r['massReduction']:.0f}%"
351
+ out["volume fraction"] = f"{o.volume_fraction:.3f}"
352
+ sf = r.get("safetyFactor")
353
+ if sf is not None and np.isfinite(sf):
354
+ out["safety factor"] = f"{sf:.2f}" + (f" (goal {study.goal.safety_factor:g})" if study.goal.safety_factor else "")
355
+ cases = r.get("cases") or []
356
+ if cases:
357
+ d = max(c["deflection"] for c in cases)
358
+ out["deflection at loads"] = f"{d:.3f} mm" + (f" (limit {study.goal.max_deflection:g})"
359
+ if study.goal.max_deflection else "")
360
+ if r.get("p5Member") is not None:
361
+ out["thin members (p5)"] = f"{r['p5Member']:.2f} mm"
362
+ if o.passes is not None:
363
+ out["meets the goal"] = "yes" if o.passes else "no"
364
+ return out
365
+
366
+
367
+ def study_viewer(result, path=None, history: HistoryRecorder | None = None, outcomes="best",
368
+ baseline=None, baseline_name="original", fields=True, title=None, max_faces=80_000,
369
+ standalone=True):
370
+ """A viewer for a StudyResult. `outcomes` is "best", "all" or a list of
371
+ indices; each becomes a selectable part. `fields` verifies each again
372
+ with stress fields (a few seconds to a minute a part: it is the same
373
+ analysis the study ran). `baseline` (a trimesh or a mesh file) is the
374
+ part this replaces, verified under the same loads, to compare against.
375
+ `history` is a HistoryRecorder the study ran with. Returns the HTML, and
376
+ writes it to `path` when given."""
377
+ import trimesh
378
+
379
+ from .verify import STRESS_ALLOWANCE, verify_part
380
+
381
+ study = result.study
382
+ pitch = (result.domain or {}).get("pitch")
383
+ vkw = {"min_pitch": 0.5 * pitch, "contact_band": pitch} if pitch else {}
384
+ if outcomes == "best":
385
+ idx = [result.best if result.best is not None else (len(result.outcomes) - 1)] if result.outcomes else []
386
+ elif outcomes == "all":
387
+ idx = list(range(len(result.outcomes)))
388
+ else:
389
+ idx = list(outcomes)
390
+ v = Viewer(title or study.name, f"{study.material.name} · {len(study.load_cases)} load case"
391
+ + ("s" if len(study.load_cases) != 1 else ""))
392
+ mat = study.material
393
+ # keep the page light enough to open on a phone: ~160k faces across the parts
394
+ n_parts = len(idx) + (baseline is not None)
395
+ max_faces = max(20_000, min(max_faces, 160_000 // max(1, n_parts)))
396
+ for i in idx:
397
+ o = result.outcomes[i]
398
+ rep = verify_part(study, o.mesh, fields=True, **vkw) if fields else None
399
+ name = f"vf {o.volume_fraction:.2f} · {o.report['mass']:.1f} g"
400
+ if i == result.best:
401
+ name = "★ " + name
402
+ v.add_part(name, o.mesh, rep["fields"] if rep else None, mat.strength, STRESS_ALLOWANCE,
403
+ study.goal.safety_factor, _stats(o, study), max_faces=max_faces)
404
+ if baseline is not None:
405
+ bm = trimesh.load(baseline, force="mesh") if isinstance(baseline, (str, os.PathLike)) else baseline
406
+ rep = verify_part(study, bm, fields=True, **vkw) if fields else None
407
+ st = {"mass": f"{rep['mass']:.1f} g"} if rep else {}
408
+ if rep:
409
+ st["safety factor"] = f"{rep['safetyFactor']:.2f}"
410
+ st["deflection at loads"] = f"{max(c['deflection'] for c in rep['cases']):.3f} mm"
411
+ v.add_part(baseline_name, bm, rep["fields"] if rep else None, mat.strength, STRESS_ALLOWANCE,
412
+ study.goal.safety_factor, st, max_faces=max_faces, kind="baseline")
413
+ v.add_study_context(study)
414
+ if history is not None:
415
+ v.history = _history_payload(study, history)
416
+ page = v.html(standalone)
417
+ if path:
418
+ with open(path, "w") as fh:
419
+ fh.write(page)
420
+ return page
421
+
422
+
423
+ def heat_exchanger_viewer(mesh, spaces: dict, report: dict | None = None, path=None, title="heat exchanger",
424
+ max_faces=120_000, standalone=True):
425
+ """A viewer for design_heat_exchanger(..., fluid_meshes=True): the part,
426
+ and each fluid's space as its own layer, cut open with the section
427
+ plane to see the two networks interleave."""
428
+ v = Viewer(title, "dual-fluid heat exchanger")
429
+ st = {}
430
+ if report:
431
+ units = (("solidVolume", "solid", 1e-3, "cm³"), ("coreVolume", "core", 1e-3, "cm³"),
432
+ ("cell", "gyroid cell", 1, "mm"), ("wall", "wall", 1, "mm"),
433
+ ("minWallBetweenFluids", "thinnest wall between fluids", 1, "mm"),
434
+ ("minOuterWall", "thinnest outer wall", 1, "mm"), ("overhangArea", "overhang", 1, "mm²"))
435
+ for k, lab, f, u in units:
436
+ if isinstance(report.get(k), (int, float)):
437
+ st[lab] = f"{report[k] * f:.3g} {u}"
438
+ if "isolated" in report:
439
+ st["fluids kept apart"] = "yes" if report["isolated"] else "no"
440
+ v.add_part(title, mesh, stats=st, max_faces=max_faces)
441
+ for i, (name, fm) in enumerate(spaces.items()):
442
+ v.add_overlay(f"fluid · {name}", fm, _FLUIDS[i % len(_FLUIDS)], 0.85, f"fluid {name}", visible=(i == 0),
443
+ max_faces=max_faces)
444
+ if path:
445
+ v.save(path, standalone)
446
+ return v.html(standalone)
@@ -0,0 +1,106 @@
1
+ """
2
+ Time and rate dependence of elastomer stiffness: creep under a steady load
3
+ and stiffening under fast loads, for TPU feet, pads and crush cores.
4
+
5
+ A TPU part is a viscoelastic solid. Its modulus depends on how long the
6
+ load has been on: a material card's E comes from a test lasting seconds; a
7
+ foot under a payload for a year is softer, a mount vibrating at 35 Hz or a
8
+ pad hit in 15 ms is stiffer. Over the times that matter here the creep
9
+ compliance of printed TPU grows with the logarithm of time, the form
10
+ isolator makers give for their polyurethanes (Getzner: "the increase in
11
+ deformation is proportional to the time logarithm"):
12
+
13
+ J(t) / J(t_ref) = 1 + m ln(t / t_ref)
14
+
15
+ and the effective modulus at a time scale t is E_ref / (1 + m ln(t / t_ref)),
16
+ below 1 for long holds and above 1 for fast events. A vibration at
17
+ frequency f samples the time scale 1 / (2 pi f); an impact that crushes
18
+ for duration T samples T.
19
+
20
+ Sourced value for printed TPU, m = 0.045 per e-fold (0.10 per decade),
21
+ t_ref = 10 s (the duration of a tensile test, where the card's E is from):
22
+
23
+ - Fit to a generalized Maxwell model of 100-minute compression creep on
24
+ extrusion-printed TPU cylinders (CMC 86(3), 2025, "creep" parameter set:
25
+ E0 25.9, E1 7.3 (tau 177 s), E2 18.8 MPa (tau 2.9 s)): E(10 s) / E(6000 s)
26
+ = 1.29 over ln(600) = 6.4, so m = 0.045.
27
+ - Check: the 35 Hz to 10 s stiffness ratio this gives is 1.53, inside the
28
+ 1.3-1.6 dynamic-to-static ratio isolator makers quote for polyurethane
29
+ (GERB NOVODAMP data sheet).
30
+ - Ten years under load: J grows 1.78x over the 10 s value. Getzner quote
31
+ less than 50% extra deflection after 10 years within the recommended load
32
+ range for their PU foams, so 1.78 is on the cautious side for a lightly
33
+ loaded foot.
34
+
35
+ Not covered: temperature (TPU softens quickly above ~40 C), large strain
36
+ (the law is linear viscoelastic), and the frequency dependence of the loss
37
+ factor (modal.LOSS_FACTOR stays a single number; the log law itself implies
38
+ tan delta ~ pi m / 2 = 0.07, about half the 0.12 we use, which is a 10 Hz DMA
39
+ value: printed TPU has damping the creep data alone do not show).
40
+
41
+ A calibration card can override m (card["creep"]["m"]), measured with the
42
+ calibration kit's creep coupon (see calibration.creep_coupons).
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import numpy as np
47
+
48
+ __all__ = ["CREEP", "creep_params", "modulus_factor", "creep_factor", "dynamic_factor", "event_factor",
49
+ "YEAR", "DAY"]
50
+
51
+ DAY = 86_400.0
52
+ YEAR = 365.25 * DAY
53
+
54
+ # m: compliance growth per e-fold of time; tRef: the time scale of the
55
+ # material card's E (s)
56
+ CREEP = {
57
+ "tpu": {"m": 0.045, "tRef": 10.0,
58
+ "source": "printed TPU compression creep, CMC 86(3) 2025 (GM fit); 35 Hz/static 1.53 vs PU 1.3-1.6"},
59
+ }
60
+
61
+
62
+ def creep_params(material):
63
+ """(m, t_ref) for a material, or None if it is not modelled as
64
+ viscoelastic. A saved calibration card's creep block wins."""
65
+ name = getattr(material, "name", str(material)).lower()
66
+ try:
67
+ from .calibration import load_card
68
+
69
+ card = load_card(name) or {}
70
+ except Exception: # noqa: BLE001 - a missing or broken card is no card
71
+ card = {}
72
+ base = card.get("basedOn", name)
73
+ p = CREEP.get(name) or CREEP.get(base)
74
+ if card.get("creep"):
75
+ p = {**(p or {"tRef": 10.0}), **card["creep"]}
76
+ if not p:
77
+ return None
78
+ return float(p["m"]), float(p.get("tRef", 10.0))
79
+
80
+
81
+ def modulus_factor(material, seconds) -> np.ndarray:
82
+ """E(t) / E_card at time scale `seconds` (1 for materials not modelled)."""
83
+ p = creep_params(material)
84
+ t = np.asarray(seconds, float)
85
+ if p is None:
86
+ return np.ones_like(t)
87
+ m, tr = p
88
+ # floor the log so very fast events cannot blow the factor up
89
+ return 1.0 / np.maximum(1.0 + m * np.log(np.maximum(t, 1e-6) / tr), 0.25)
90
+
91
+
92
+ def creep_factor(material, seconds) -> np.ndarray:
93
+ """Deflection under a steady load after `seconds`, over the deflection
94
+ the card's E predicts."""
95
+ return 1.0 / modulus_factor(material, seconds)
96
+
97
+
98
+ def dynamic_factor(material, frequency) -> np.ndarray:
99
+ """Dynamic stiffness at `frequency` (Hz) over the card's stiffness."""
100
+ return modulus_factor(material, 1.0 / (2 * np.pi * np.asarray(frequency, float)))
101
+
102
+
103
+ def event_factor(material, duration) -> np.ndarray:
104
+ """Stiffening for a load applied over `duration` seconds (an impact's
105
+ crush time)."""
106
+ return modulus_factor(material, duration)