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