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/struts.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strut (beam) lattices as distance fields: the periodic truss cells nTop and
|
|
3
|
+
every AM lattice library ship (simple cubic, BCC, BCCz, FCC, FCCz, octet,
|
|
4
|
+
Kelvin, diamond), and stochastic Voronoi foams.
|
|
5
|
+
|
|
6
|
+
A strut lattice is a set of segments; the solid is every point within
|
|
7
|
+
`thickness / 2` of one, so its field is the exact distance to the nearest
|
|
8
|
+
segment less the radius (Lipschitz 1, which the narrow-band mesher and the
|
|
9
|
+
Newton projection both like). The radius can vary per point, so a strut
|
|
10
|
+
thickens where a field says, and `blend` rounds every node with a smooth
|
|
11
|
+
minimum (nTop's node fillets): thin struts break at their nodes first.
|
|
12
|
+
|
|
13
|
+
Periodic cells are evaluated in cell units (period 1). A point is folded
|
|
14
|
+
into the cell's fundamental region first, by the mirrors and axis swaps the
|
|
15
|
+
cell is invariant under (found numerically, so a new cell needs no hand
|
|
16
|
+
work), and only the few segments that can be nearest to that region are
|
|
17
|
+
tested. For the cubic cells that is 1/48 of the cell and a handful of
|
|
18
|
+
segments; BCCz and FCCz keep z special (no swaps with z), diamond has no
|
|
19
|
+
mirrors through its nodes, so it tests more.
|
|
20
|
+
|
|
21
|
+
Voronoi foams are not periodic: seeds are scattered over a box at a mean
|
|
22
|
+
spacing that may vary (a cell field: denser seeds where the cell is small),
|
|
23
|
+
relaxed a few steps so the cells are even, and the Voronoi edges are the
|
|
24
|
+
struts. A periodic Voronoi unit cell (seeds tiled 3x3x3) stands in for the
|
|
25
|
+
foam in homogenization.
|
|
26
|
+
|
|
27
|
+
Strut directions matter for FDM: a strut is self-supporting when it rises
|
|
28
|
+
at least `angle` (45 degrees) from the bed; flatter ones are bridges.
|
|
29
|
+
`overhang_report` says how much strut length bridges, for a cell in any
|
|
30
|
+
orientation, or through any coordinate map (graded or conformal frames).
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import itertools
|
|
35
|
+
from functools import lru_cache
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
__all__ = ["STRUT_KINDS", "is_strut", "cell_segments", "strut_distance", "strut_density_table",
|
|
40
|
+
"strut_relative_density", "strut_diameter_for_density", "VoronoiStruts", "overhang_report",
|
|
41
|
+
"unit_cell_distance", "best_orientation", "lattice_printability"]
|
|
42
|
+
|
|
43
|
+
_CHUNK = 65_536
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fcc():
|
|
47
|
+
return [(0, 0, 0), (0.5, 0.5, 0), (0.5, 0, 0.5), (0, 0.5, 0.5)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _nodes_pairs(nodes, length, tol=1e-6):
|
|
51
|
+
"""Every pair of the periodic node set (`nodes` in [0,1)^3, tiled) at
|
|
52
|
+
`length` apart, as segments with the first end in the home cell."""
|
|
53
|
+
base = np.asarray(nodes, float)
|
|
54
|
+
offs = np.array(list(itertools.product((-1, 0, 1), repeat=3)), float)
|
|
55
|
+
allp = (base[None] + offs[:, None]).reshape(-1, 3)
|
|
56
|
+
seg = []
|
|
57
|
+
for a in base:
|
|
58
|
+
d = np.linalg.norm(allp - a, axis=1)
|
|
59
|
+
for b in allp[np.abs(d - length) < tol]:
|
|
60
|
+
seg.append((a, b))
|
|
61
|
+
return seg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _axis_edges(axis):
|
|
65
|
+
"""Cube edges along one axis (period 1: one per cell)."""
|
|
66
|
+
e = np.zeros(3)
|
|
67
|
+
e["xyz".index(axis)] = 1.0
|
|
68
|
+
return [(np.zeros(3), e)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _kelvin():
|
|
72
|
+
# bitruncated cubic honeycomb: truncated octahedra on a BCC lattice,
|
|
73
|
+
# vertices the permutations of (0, +-1/4, +-1/2) round each centre
|
|
74
|
+
v = set()
|
|
75
|
+
for p in set(itertools.permutations((0, 1, 2))):
|
|
76
|
+
for s1 in (-1, 1):
|
|
77
|
+
for s2 in (-1, 1):
|
|
78
|
+
q = np.zeros(3)
|
|
79
|
+
q[p[1]] = 0.25 * s1
|
|
80
|
+
q[p[2]] = 0.5 * s2
|
|
81
|
+
for c in ((0, 0, 0), (0.5, 0.5, 0.5)):
|
|
82
|
+
w = tuple(np.round((q + c) % 1.0, 6))
|
|
83
|
+
v.add(w)
|
|
84
|
+
return _nodes_pairs(sorted(v), np.sqrt(2) / 4)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _diamond():
|
|
88
|
+
nodes = _fcc() + [tuple(np.add(p, 0.25)) for p in _fcc()]
|
|
89
|
+
return _nodes_pairs(nodes, np.sqrt(3) / 4)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# segments of each unit cell, period 1
|
|
93
|
+
_CELLS = {
|
|
94
|
+
"cubic": lambda: _axis_edges("x") + _axis_edges("y") + _axis_edges("z"),
|
|
95
|
+
"bcc": lambda: _nodes_pairs([(0, 0, 0), (0.5, 0.5, 0.5)], np.sqrt(3) / 2),
|
|
96
|
+
"bccz": lambda: _nodes_pairs([(0, 0, 0), (0.5, 0.5, 0.5)], np.sqrt(3) / 2) + _axis_edges("z"),
|
|
97
|
+
"fcc": lambda: [s for s in _nodes_pairs(_fcc(), np.sqrt(2) / 2)
|
|
98
|
+
if np.count_nonzero(np.abs(np.asarray(s[0]) % 1.0) < 1e-9) >= 2
|
|
99
|
+
or np.count_nonzero(np.abs(np.asarray(s[1]) % 1.0) < 1e-9) >= 2],
|
|
100
|
+
"fccz": lambda: [s for s in _nodes_pairs(_fcc(), np.sqrt(2) / 2)
|
|
101
|
+
if np.count_nonzero(np.abs(np.asarray(s[0]) % 1.0) < 1e-9) >= 2
|
|
102
|
+
or np.count_nonzero(np.abs(np.asarray(s[1]) % 1.0) < 1e-9) >= 2] + _axis_edges("z"),
|
|
103
|
+
"octet": lambda: _nodes_pairs(_fcc(), np.sqrt(2) / 2),
|
|
104
|
+
"kelvin": _kelvin,
|
|
105
|
+
"diamond-strut": _diamond,
|
|
106
|
+
}
|
|
107
|
+
STRUT_KINDS = tuple(_CELLS) + ("voronoi",)
|
|
108
|
+
_ALIASES = {"simple-cubic": "cubic", "bcc-z": "bccz", "fcc-z": "fccz", "octet-truss": "octet",
|
|
109
|
+
"tetrakaidecahedron": "kelvin", "diamond-beam": "diamond-strut", "strut-diamond": "diamond-strut"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def canonical(kind):
|
|
113
|
+
k = str(kind).lower()
|
|
114
|
+
return _ALIASES.get(k, k)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_strut(kind) -> bool:
|
|
118
|
+
return canonical(kind) in STRUT_KINDS
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _dedupe(segs):
|
|
122
|
+
"""Unique segments (either direction)."""
|
|
123
|
+
out, seen = [], set()
|
|
124
|
+
for a, b in segs:
|
|
125
|
+
a, b = np.asarray(a, float), np.asarray(b, float)
|
|
126
|
+
key = tuple(np.round(np.concatenate([a, b]), 6)), tuple(np.round(np.concatenate([b, a]), 6))
|
|
127
|
+
if key[0] in seen or key[1] in seen:
|
|
128
|
+
continue
|
|
129
|
+
seen.add(key[0])
|
|
130
|
+
out.append((a, b))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@lru_cache(maxsize=None)
|
|
135
|
+
def cell_segments(kind) -> np.ndarray:
|
|
136
|
+
"""(S, 2, 3) the unit cell's struts, each once (period 1), so one cell's
|
|
137
|
+
worth of strut length; translate by whole cells for the rest."""
|
|
138
|
+
kind = canonical(kind)
|
|
139
|
+
if kind == "voronoi":
|
|
140
|
+
return _periodic_voronoi(8, 0)
|
|
141
|
+
segs = _CELLS[kind]()
|
|
142
|
+
# one representative per periodic class: shift each so its midpoint is in [0,1)
|
|
143
|
+
reps = []
|
|
144
|
+
for a, b in segs:
|
|
145
|
+
m = 0.5 * (np.asarray(a) + np.asarray(b))
|
|
146
|
+
sh = np.floor(m + 1e-9)
|
|
147
|
+
reps.append((np.asarray(a) - sh, np.asarray(b) - sh))
|
|
148
|
+
return np.asarray(_dedupe(reps), float)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _seg_dist(P, A, B):
|
|
152
|
+
"""(N, S) distance from points to segments."""
|
|
153
|
+
AB = B - A
|
|
154
|
+
L2 = np.maximum(np.einsum("ij,ij->i", AB, AB), 1e-18)
|
|
155
|
+
AP = P[:, None, :] - A[None]
|
|
156
|
+
t = np.clip(np.einsum("nsj,sj->ns", AP, AB) / L2, 0.0, 1.0)
|
|
157
|
+
D = AP - t[..., None] * AB[None]
|
|
158
|
+
return np.sqrt(np.einsum("nsj,nsj->ns", D, D))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _all_translates(segs, reach=1):
|
|
162
|
+
offs = np.array(list(itertools.product(range(-reach, reach + 1), repeat=3)), float)
|
|
163
|
+
return (segs[None, :, :, :] + offs[:, None, None, :]).reshape(-1, 2, 3)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@lru_cache(maxsize=None)
|
|
167
|
+
def _symmetry(kind):
|
|
168
|
+
"""(mirror, swaps) the cell is invariant under, about its corner node:
|
|
169
|
+
mirror x -> -x on every axis (and through the cell's 1/2 planes, which
|
|
170
|
+
follows with period 1), and which axis swaps (all, xy only, none)."""
|
|
171
|
+
segs = _all_translates(cell_segments(kind), 2)
|
|
172
|
+
rng = np.random.default_rng(1)
|
|
173
|
+
P = rng.uniform(-0.5, 0.5, (600, 3))
|
|
174
|
+
|
|
175
|
+
def field(Q):
|
|
176
|
+
return _seg_dist(Q, segs[:, 0], segs[:, 1]).min(axis=1)
|
|
177
|
+
|
|
178
|
+
f0 = field(P)
|
|
179
|
+
mirror = all(np.allclose(field(P * np.where(np.arange(3) == a, -1, 1)), f0, atol=1e-9) for a in range(3))
|
|
180
|
+
swap_xy = np.allclose(field(P[:, [1, 0, 2]]), f0, atol=1e-9)
|
|
181
|
+
swap_xz = np.allclose(field(P[:, [2, 1, 0]]), f0, atol=1e-9)
|
|
182
|
+
return mirror, ("all" if swap_xy and swap_xz else "xy" if swap_xy else "none")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@lru_cache(maxsize=None)
|
|
186
|
+
def _candidates(kind, reach=0.32, aspect=None):
|
|
187
|
+
"""Segments that can be nearest to a point of the folded region (within
|
|
188
|
+
`reach` of it, cell units: a strut up to 0.6 cell across is right).
|
|
189
|
+
With a stretched cell (`aspect`, per axis) distances are measured in
|
|
190
|
+
the stretched space and only the mirrors fold (no axis swaps)."""
|
|
191
|
+
mirror, swaps = _symmetry(kind)
|
|
192
|
+
if aspect is not None:
|
|
193
|
+
swaps = "none"
|
|
194
|
+
segs = _all_translates(cell_segments(kind), 2)
|
|
195
|
+
n = 18
|
|
196
|
+
c = (np.arange(n) + 0.5) / n
|
|
197
|
+
if mirror:
|
|
198
|
+
g = np.stack(np.meshgrid(*[0.5 * c] * 3, indexing="ij"), -1).reshape(-1, 3)
|
|
199
|
+
if swaps == "all":
|
|
200
|
+
g = g[(g[:, 0] <= g[:, 1] + 1e-9) & (g[:, 1] <= g[:, 2] + 1e-9)]
|
|
201
|
+
elif swaps == "xy":
|
|
202
|
+
g = g[g[:, 0] <= g[:, 1] + 1e-9]
|
|
203
|
+
h = 0.5 / n
|
|
204
|
+
else:
|
|
205
|
+
g = np.stack(np.meshgrid(*[c - 0.5] * 3, indexing="ij"), -1).reshape(-1, 3)
|
|
206
|
+
h = 1.0 / n
|
|
207
|
+
near = np.zeros(len(segs), bool)
|
|
208
|
+
best = np.full(len(g), np.inf)
|
|
209
|
+
if aspect is not None:
|
|
210
|
+
a = np.asarray(aspect, float)
|
|
211
|
+
segs, g, h = segs * a, g * a, h * float(a.max())
|
|
212
|
+
for i in range(0, len(segs), 64):
|
|
213
|
+
d = _seg_dist(g, segs[i:i + 64, 0], segs[i:i + 64, 1])
|
|
214
|
+
best = np.minimum(best, d.min(axis=1))
|
|
215
|
+
for i in range(0, len(segs), 64):
|
|
216
|
+
d = _seg_dist(g, segs[i:i + 64, 0], segs[i:i + 64, 1])
|
|
217
|
+
# nearest to some sample by less than the cell's reach (plus the
|
|
218
|
+
# sample spacing), or within reach of the strut surface at all
|
|
219
|
+
near[i:i + 64] = (d <= np.minimum(best[:, None] + 2 * h * np.sqrt(3), reach + h * np.sqrt(3))).any(axis=0)
|
|
220
|
+
if aspect is not None:
|
|
221
|
+
segs = segs / np.asarray(aspect, float)
|
|
222
|
+
return segs[near], mirror, swaps
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _fold(U, mirror, swaps):
|
|
226
|
+
f = U - np.round(U)
|
|
227
|
+
if not mirror:
|
|
228
|
+
return f
|
|
229
|
+
f = np.abs(f)
|
|
230
|
+
if swaps == "all":
|
|
231
|
+
f = np.sort(f, axis=1)
|
|
232
|
+
elif swaps == "xy":
|
|
233
|
+
f[:, :2] = np.sort(f[:, :2], axis=1)
|
|
234
|
+
return f
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _smin_many(D, k):
|
|
238
|
+
"""Smooth minimum over the columns of D, polynomial, radius k (scalar
|
|
239
|
+
or per row): sharp mins where k is 0."""
|
|
240
|
+
D = np.sort(D, axis=1)
|
|
241
|
+
out = D[:, 0].copy()
|
|
242
|
+
k = np.broadcast_to(np.asarray(k, float), (len(D),))
|
|
243
|
+
if not np.any(k > 0):
|
|
244
|
+
return out
|
|
245
|
+
kk = np.maximum(k, 1e-12)
|
|
246
|
+
for j in range(1, min(D.shape[1], 6)):
|
|
247
|
+
b = D[:, j]
|
|
248
|
+
h = np.clip(0.5 + 0.5 * (b - out) / kk, 0.0, 1.0)
|
|
249
|
+
s = b * (1 - h) + out * h - kk * h * (1 - h)
|
|
250
|
+
out = np.where(k > 0, s, out)
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
import numba as _nb
|
|
256
|
+
except ImportError: # pragma: no cover - numba is a dependency; numpy without it
|
|
257
|
+
_nb = None
|
|
258
|
+
|
|
259
|
+
if _nb is not None:
|
|
260
|
+
|
|
261
|
+
@_nb.njit(cache=True, nogil=True)
|
|
262
|
+
def _strut_kernel(f, A, B, blend, out):
|
|
263
|
+
"""_seg_dist + (_smin_many or min) per point, without the (N, S)
|
|
264
|
+
intermediates: ~10x the numpy path. nogil, so peval's threads run
|
|
265
|
+
it in parallel."""
|
|
266
|
+
S = A.shape[0]
|
|
267
|
+
m = min(S, 6)
|
|
268
|
+
best = np.empty(6)
|
|
269
|
+
AB = B - A
|
|
270
|
+
L2 = np.empty(S)
|
|
271
|
+
for s in range(S):
|
|
272
|
+
L2[s] = max(AB[s, 0] * AB[s, 0] + AB[s, 1] * AB[s, 1] + AB[s, 2] * AB[s, 2], 1e-18)
|
|
273
|
+
for i in range(f.shape[0]):
|
|
274
|
+
for j in range(6):
|
|
275
|
+
best[j] = np.inf
|
|
276
|
+
for s in range(S):
|
|
277
|
+
p0 = f[i, 0] - A[s, 0]
|
|
278
|
+
p1 = f[i, 1] - A[s, 1]
|
|
279
|
+
p2 = f[i, 2] - A[s, 2]
|
|
280
|
+
t = (p0 * AB[s, 0] + p1 * AB[s, 1] + p2 * AB[s, 2]) / L2[s]
|
|
281
|
+
t = min(max(t, 0.0), 1.0)
|
|
282
|
+
d0 = p0 - t * AB[s, 0]
|
|
283
|
+
d1 = p1 - t * AB[s, 1]
|
|
284
|
+
d2 = p2 - t * AB[s, 2]
|
|
285
|
+
d = d0 * d0 + d1 * d1 + d2 * d2 # squared: sqrt only the kept ones
|
|
286
|
+
if d < best[m - 1]:
|
|
287
|
+
j = m - 1
|
|
288
|
+
while j > 0 and best[j - 1] > d:
|
|
289
|
+
best[j] = best[j - 1]
|
|
290
|
+
j -= 1
|
|
291
|
+
best[j] = d
|
|
292
|
+
for j in range(m):
|
|
293
|
+
best[j] = np.sqrt(best[j])
|
|
294
|
+
o = best[0]
|
|
295
|
+
k = blend[i]
|
|
296
|
+
if k > 0:
|
|
297
|
+
for j in range(1, m):
|
|
298
|
+
b = best[j]
|
|
299
|
+
h = min(max(0.5 + 0.5 * (b - o) / k, 0.0), 1.0)
|
|
300
|
+
o = b * (1 - h) + o * h - k * h * (1 - h)
|
|
301
|
+
out[i] = o
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def unit_cell_distance(kind, U, blend=0.0, aspect=None):
|
|
305
|
+
"""Distance (cell units) from lattice coordinates U (period 1) to the
|
|
306
|
+
nearest strut centreline of a periodic cell; `blend` (cell units, per
|
|
307
|
+
point or scalar) smooth-unions the struts at the nodes. `aspect` (per
|
|
308
|
+
axis) stretches the cell: U is in stretched cells, the distance in
|
|
309
|
+
units of the unstretched cell."""
|
|
310
|
+
kind = canonical(kind)
|
|
311
|
+
a = None
|
|
312
|
+
if aspect is not None and not np.allclose(aspect, 1.0):
|
|
313
|
+
a = tuple(float(x) for x in aspect)
|
|
314
|
+
segs, mirror, swaps = _candidates(kind, 0.32 * (max(a) / min(a) if a else 1.0), a)
|
|
315
|
+
U = np.asarray(U, float)
|
|
316
|
+
out = np.empty(len(U))
|
|
317
|
+
blend = np.broadcast_to(np.asarray(blend, float), (len(U),))
|
|
318
|
+
sc = np.asarray(a if a else (1.0, 1.0, 1.0))
|
|
319
|
+
A, B = segs[:, 0] * sc, segs[:, 1] * sc
|
|
320
|
+
if _nb is not None and len(U):
|
|
321
|
+
f = np.ascontiguousarray(_fold(U, mirror, swaps) * sc)
|
|
322
|
+
_strut_kernel(f, np.ascontiguousarray(A), np.ascontiguousarray(B),
|
|
323
|
+
np.ascontiguousarray(blend, np.float64), out)
|
|
324
|
+
return out
|
|
325
|
+
step = max(1, _CHUNK * 8 // max(len(segs), 1))
|
|
326
|
+
for i in range(0, len(U), step):
|
|
327
|
+
f = _fold(U[i:i + step], mirror, swaps) * sc
|
|
328
|
+
D = _seg_dist(f, A, B)
|
|
329
|
+
bl = blend[i:i + step]
|
|
330
|
+
out[i:i + step] = _smin_many(D, bl) if np.any(bl > 0) else D.min(axis=1)
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def strut_distance(kind, U, diameter_over_cell, blend=0.0):
|
|
335
|
+
"""Signed distance (cell units) of the strut solid: centreline distance
|
|
336
|
+
less the radius."""
|
|
337
|
+
return unit_cell_distance(kind, U, blend) - 0.5 * np.asarray(diameter_over_cell, float)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---- density ------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
@lru_cache(maxsize=None)
|
|
343
|
+
def strut_density_table(kind, blend=0.0):
|
|
344
|
+
"""(diameter/cell, relative density) on one cell, sampled at 2^20
|
|
345
|
+
scrambled Sobol points (a regular grid aliases with axis-aligned struts:
|
|
346
|
+
12% light on thin cubic rods)."""
|
|
347
|
+
from scipy.stats import qmc
|
|
348
|
+
|
|
349
|
+
g = qmc.Sobol(3, scramble=True, seed=7).random_base2(20)
|
|
350
|
+
d = np.sort(unit_cell_distance(kind, g, blend))
|
|
351
|
+
t = np.linspace(0.0, 0.8, 161)
|
|
352
|
+
rho = np.searchsorted(d, 0.5 * t) / d.size
|
|
353
|
+
return t, rho
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _blend_key(blend):
|
|
357
|
+
return round(float(blend), 2)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# the periodic box of a cell, in cells: a Voronoi foam's `cell` is its seed
|
|
361
|
+
# spacing, and its periodic stand-in holds 8 seeds a box, 2 spacings across
|
|
362
|
+
_BOX = {"voronoi": 2.0}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def strut_relative_density(kind, diameter, cell, blend=0.0):
|
|
366
|
+
kind = canonical(kind)
|
|
367
|
+
box = _BOX.get(kind, 1.0)
|
|
368
|
+
t, rho = strut_density_table(kind, _blend_key(blend / box))
|
|
369
|
+
return np.interp(np.asarray(diameter, float) / (box * np.asarray(cell, float)), t, rho)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def strut_diameter_for_density(kind, rho, cell, blend=0.0):
|
|
373
|
+
kind = canonical(kind)
|
|
374
|
+
box = _BOX.get(kind, 1.0)
|
|
375
|
+
t, r = strut_density_table(kind, _blend_key(blend / box))
|
|
376
|
+
# the table is non-decreasing; interp wants strictly increasing x
|
|
377
|
+
r, idx = np.unique(r, return_index=True)
|
|
378
|
+
return np.interp(np.asarray(rho, float), r, t[idx]) * box * np.asarray(cell, float)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---- Voronoi foams ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def _voronoi_edges(seeds, keep_lo, keep_hi):
|
|
384
|
+
from scipy.spatial import Voronoi
|
|
385
|
+
|
|
386
|
+
vor = Voronoi(seeds)
|
|
387
|
+
V = vor.vertices
|
|
388
|
+
edges = set()
|
|
389
|
+
for ridge in vor.ridge_vertices:
|
|
390
|
+
if -1 in ridge or len(ridge) < 2:
|
|
391
|
+
continue
|
|
392
|
+
for a, b in zip(ridge, ridge[1:] + ridge[:1]):
|
|
393
|
+
edges.add((min(a, b), max(a, b)))
|
|
394
|
+
E = np.array(sorted(edges), int).reshape(-1, 2)
|
|
395
|
+
if not len(E):
|
|
396
|
+
return np.zeros((0, 2, 3))
|
|
397
|
+
S = V[E]
|
|
398
|
+
m = S.mean(axis=1)
|
|
399
|
+
inside = np.all((m >= keep_lo) & (m <= keep_hi), axis=1)
|
|
400
|
+
return S[inside]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@lru_cache(maxsize=None)
|
|
404
|
+
def _periodic_voronoi(n_seeds, seed):
|
|
405
|
+
"""A periodic Voronoi cell: `n_seeds` relaxed seeds in the unit box,
|
|
406
|
+
tiled 3x3x3; the edges whose midpoints are in the home box."""
|
|
407
|
+
rng = np.random.default_rng(seed)
|
|
408
|
+
s = rng.uniform(0, 1, (n_seeds, 3))
|
|
409
|
+
offs = np.array(list(itertools.product((-1, 0, 1), repeat=3)), float)
|
|
410
|
+
for _ in range(30): # periodic repulsion: even cells
|
|
411
|
+
allp = (s[None] + offs[:, None]).reshape(-1, 3)
|
|
412
|
+
d = s[:, None, :] - allp[None]
|
|
413
|
+
r = np.linalg.norm(d, axis=2)
|
|
414
|
+
r[r < 1e-9] = np.inf
|
|
415
|
+
h = n_seeds ** (-1 / 3)
|
|
416
|
+
w = np.clip(1.0 - r / h, 0, None) ** 2
|
|
417
|
+
s = (s + 0.1 * h * (w[..., None] * d / r[..., None]).sum(axis=1)) % 1.0
|
|
418
|
+
allp = (s[None] + offs[:, None]).reshape(-1, 3)
|
|
419
|
+
return _voronoi_edges(allp, np.zeros(3), np.ones(3) - 1e-12)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class VoronoiStruts:
|
|
423
|
+
"""A stochastic Voronoi strut foam over a box: seeds `cell` mm apart on
|
|
424
|
+
average (a callable gives a per-point spacing), relaxed so cells come out
|
|
425
|
+
even, Voronoi edges as struts. `distance(P)` is the exact distance to the
|
|
426
|
+
nearest edge (edges are split so a k-nearest query on piece midpoints
|
|
427
|
+
finds it)."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, lo, hi, cell, seed=0, relax=8):
|
|
430
|
+
from scipy.spatial import cKDTree
|
|
431
|
+
|
|
432
|
+
lo, hi = np.asarray(lo, float), np.asarray(hi, float)
|
|
433
|
+
cell_fn = cell if callable(cell) else (lambda P, c=float(cell): np.full(len(P), c))
|
|
434
|
+
rng = np.random.default_rng(seed)
|
|
435
|
+
pad = 2.0 * float(np.max(cell_fn(np.array([0.5 * (lo + hi)]))))
|
|
436
|
+
# rejection sampling at the finest spacing, density ~ 1 / cell^3
|
|
437
|
+
probe = rng.uniform(lo - pad, hi + pad, (20_000, 3))
|
|
438
|
+
cmin = float(np.min(cell_fn(probe)))
|
|
439
|
+
vol = float(np.prod(hi - lo + 2 * pad))
|
|
440
|
+
n_try = int(min(4e6, vol / cmin ** 3 * 1.0))
|
|
441
|
+
C = rng.uniform(lo - pad, hi + pad, (n_try, 3))
|
|
442
|
+
c = np.asarray(cell_fn(C), float)
|
|
443
|
+
S = C[rng.uniform(0, 1, n_try) < (cmin / c) ** 3]
|
|
444
|
+
if len(S) < 8:
|
|
445
|
+
raise ValueError("voronoi lattice: the box holds fewer than 8 cells; make the cell smaller")
|
|
446
|
+
for _ in range(relax): # push neighbours apart by the local spacing
|
|
447
|
+
tree = cKDTree(S)
|
|
448
|
+
h = np.asarray(cell_fn(S), float)
|
|
449
|
+
d, j = tree.query(S, k=min(9, len(S)))
|
|
450
|
+
d, j = d[:, 1:], j[:, 1:]
|
|
451
|
+
vec = S[:, None, :] - S[j]
|
|
452
|
+
w = np.clip(1.0 - d / h[:, None], 0, None) ** 2
|
|
453
|
+
S = S + 0.15 * h[:, None] * (w[..., None] * vec / np.maximum(d, 1e-9)[..., None]).sum(axis=1)
|
|
454
|
+
self.seeds = S
|
|
455
|
+
segs = _voronoi_edges(S, lo - 0.5 * pad, hi + 0.5 * pad)
|
|
456
|
+
# split long edges so each piece is short against the spacing
|
|
457
|
+
L = np.linalg.norm(segs[:, 1] - segs[:, 0], axis=1)
|
|
458
|
+
piece = 0.2 * cmin
|
|
459
|
+
k = np.maximum(1, np.ceil(L / piece).astype(int))
|
|
460
|
+
A, B, E = [], [], []
|
|
461
|
+
for n in np.unique(k):
|
|
462
|
+
ids = np.flatnonzero(k == n)
|
|
463
|
+
sel = segs[ids]
|
|
464
|
+
t = np.linspace(0, 1, n + 1)
|
|
465
|
+
pa = sel[:, None, 0] + (sel[:, None, 1] - sel[:, None, 0]) * t[None, :-1, None]
|
|
466
|
+
pb = sel[:, None, 0] + (sel[:, None, 1] - sel[:, None, 0]) * t[None, 1:, None]
|
|
467
|
+
A.append(pa.reshape(-1, 3))
|
|
468
|
+
B.append(pb.reshape(-1, 3))
|
|
469
|
+
E.append(np.repeat(ids, n))
|
|
470
|
+
self.edges = segs
|
|
471
|
+
self.A, self.B = np.concatenate(A), np.concatenate(B)
|
|
472
|
+
self.eid = np.concatenate(E) # each piece's edge
|
|
473
|
+
self.tree = cKDTree(0.5 * (self.A + self.B))
|
|
474
|
+
self.piece = piece
|
|
475
|
+
self.cell_min = cmin
|
|
476
|
+
|
|
477
|
+
def distance(self, P, blend=0.0, k=None):
|
|
478
|
+
"""Distance to the nearest strut centreline; with `blend`, the
|
|
479
|
+
smooth minimum over struts, each strut once (its pieces' nearest:
|
|
480
|
+
smoothing over pieces beaded every strut where its pieces join)."""
|
|
481
|
+
P = np.asarray(P, float)
|
|
482
|
+
out = np.empty(len(P))
|
|
483
|
+
blend = np.broadcast_to(np.asarray(blend, float), (len(P),))
|
|
484
|
+
if k is None:
|
|
485
|
+
# a blend reads every strut within it: 32 nearest pieces keep it
|
|
486
|
+
# continuous (16 left 0.01 mm steps where struts crowd a node)
|
|
487
|
+
k = 32 if np.any(blend > 0) else 12
|
|
488
|
+
for i in range(0, len(P), _CHUNK):
|
|
489
|
+
Q = P[i:i + _CHUNK]
|
|
490
|
+
_, j = self.tree.query(Q, k=k)
|
|
491
|
+
A, B = self.A[j], self.B[j]
|
|
492
|
+
AB = B - A
|
|
493
|
+
AP = Q[:, None] - A
|
|
494
|
+
t = np.clip(np.einsum("nkj,nkj->nk", AP, AB) / np.maximum(np.einsum("nkj,nkj->nk", AB, AB), 1e-18),
|
|
495
|
+
0, 1)
|
|
496
|
+
D = np.linalg.norm(AP - t[..., None] * AB, axis=2)
|
|
497
|
+
bl = blend[i:i + _CHUNK]
|
|
498
|
+
if np.any(bl > 0):
|
|
499
|
+
o = np.argsort(D, axis=1)
|
|
500
|
+
D = np.take_along_axis(D, o, 1)
|
|
501
|
+
e = np.take_along_axis(self.eid[j], o, 1)
|
|
502
|
+
dup = np.zeros(D.shape, bool)
|
|
503
|
+
for c in range(1, D.shape[1]):
|
|
504
|
+
dup[:, c] = (e[:, :c] == e[:, c:c + 1]).any(axis=1)
|
|
505
|
+
out[i:i + _CHUNK] = _smin_many(np.where(dup, 1e9, D), bl)
|
|
506
|
+
else:
|
|
507
|
+
out[i:i + _CHUNK] = D.min(axis=1)
|
|
508
|
+
return out
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---- printability -------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
def _rotation(build):
|
|
514
|
+
b = np.asarray(build, float)
|
|
515
|
+
return b / np.linalg.norm(b)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def overhang_report(kind, cell, diameter=None, build=(0, 0, 1), angle=45.0, jacobian=None,
|
|
519
|
+
max_bridge=None) -> dict:
|
|
520
|
+
"""How a strut cell prints on FDM: each strut's rise from the bed
|
|
521
|
+
(degrees), the strut length below `angle` (bridging, unsupported), and
|
|
522
|
+
the longest such bridge in mm. `jacobian` (M, 3, 3) are dx/du of the
|
|
523
|
+
lattice's coordinate map sampled through the part (graded and conformal
|
|
524
|
+
frames bend struts; None is the plain cell, du = dx / cell): every
|
|
525
|
+
strut is mapped through each sample, so the fractions are over the part,
|
|
526
|
+
not the cell. `max_bridge` (mm) is what the printer bridges cleanly;
|
|
527
|
+
flat struts longer than that are flagged."""
|
|
528
|
+
kind = canonical(kind)
|
|
529
|
+
segs = cell_segments(kind)
|
|
530
|
+
du = segs[:, 1] - segs[:, 0]
|
|
531
|
+
up = _rotation(build)
|
|
532
|
+
if jacobian is None:
|
|
533
|
+
J = np.eye(3)[None] * float(np.mean(cell))
|
|
534
|
+
else:
|
|
535
|
+
J = np.asarray(jacobian, float)
|
|
536
|
+
dx = np.einsum("mij,sj->msi", J, du) # (M, S, 3) mm
|
|
537
|
+
L = np.linalg.norm(dx, axis=2)
|
|
538
|
+
rise = np.degrees(np.arcsin(np.clip(np.abs(dx @ up) / np.maximum(L, 1e-12), 0, 1)))
|
|
539
|
+
flat = rise < angle - 1e-6
|
|
540
|
+
total = float(L.sum())
|
|
541
|
+
rep = {"kind": kind, "angle": float(angle), "build": up.tolist(),
|
|
542
|
+
"strutsPerCell": int(len(segs)),
|
|
543
|
+
"minRise": float(rise.min()), "unsupportedFraction": float(L[flat].sum() / max(total, 1e-12)),
|
|
544
|
+
"horizontalFraction": float(L[rise < 5.0].sum() / max(total, 1e-12)),
|
|
545
|
+
"longestBridge": float(L[flat].max()) if flat.any() else 0.0,
|
|
546
|
+
"selfSupporting": bool(not flat.any())}
|
|
547
|
+
if diameter is not None:
|
|
548
|
+
rep["diameter"] = [float(np.min(diameter)), float(np.max(diameter))]
|
|
549
|
+
if max_bridge is not None:
|
|
550
|
+
rep["maxBridge"] = float(max_bridge)
|
|
551
|
+
rep["bridgesTooLong"] = bool(rep["longestBridge"] > max_bridge)
|
|
552
|
+
return rep
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def best_orientation(kind, angle=45.0, n=600, seed=0):
|
|
556
|
+
"""The cell rotation (3x3, rows the cell axes in part coordinates) that
|
|
557
|
+
leaves the least strut length below `angle` with build +z: random
|
|
558
|
+
rotations plus the cube's special axes. Returns (R, report)."""
|
|
559
|
+
from scipy.spatial.transform import Rotation
|
|
560
|
+
|
|
561
|
+
rots = list(Rotation.random(n, random_state=seed).as_matrix())
|
|
562
|
+
# z along [001], [011], [111] and their spins
|
|
563
|
+
for ax in ((0, 0, 1), (0, 1, 1), (1, 1, 1)):
|
|
564
|
+
a = np.asarray(ax, float) / np.linalg.norm(ax)
|
|
565
|
+
r0 = Rotation.align_vectors([[0, 0, 1]], [a])[0]
|
|
566
|
+
for spin in np.linspace(0, np.pi / 2, 7):
|
|
567
|
+
rots.append((Rotation.from_rotvec([0, 0, spin]) * r0).as_matrix())
|
|
568
|
+
best = None
|
|
569
|
+
for R in rots:
|
|
570
|
+
rep = overhang_report(kind, 1.0, jacobian=R[None], angle=angle)
|
|
571
|
+
key = (rep["unsupportedFraction"], -rep["minRise"])
|
|
572
|
+
if best is None or key < best[0]:
|
|
573
|
+
best = (key, R, rep)
|
|
574
|
+
return best[1], best[2]
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def lattice_printability(graph, lattice, solid=None, build=(0, 0, 1), angle=45.0, samples=300, seed=0,
|
|
578
|
+
max_bridge=None) -> dict:
|
|
579
|
+
"""overhang_report for a FieldGraph's strut lattice node as it sits in
|
|
580
|
+
the part: the coordinate map's Jacobian is sampled at points inside
|
|
581
|
+
`solid` (default the graph's output), so graded, stretched and
|
|
582
|
+
conformal cells are judged as they print. Voronoi foams are judged on
|
|
583
|
+
their actual edges inside the solid."""
|
|
584
|
+
nd = graph.nodes[lattice]
|
|
585
|
+
kind = canonical(nd.spec.get("type", "gyroid"))
|
|
586
|
+
solid = solid or graph.output
|
|
587
|
+
b = graph.bounds(solid)
|
|
588
|
+
if b is None:
|
|
589
|
+
raise ValueError(f"{solid!r} is unbounded")
|
|
590
|
+
lo, hi = b
|
|
591
|
+
rng = np.random.default_rng(seed)
|
|
592
|
+
P = rng.uniform(lo, hi, (samples * 20, 3))
|
|
593
|
+
P = P[graph.evaluate(P, solid) < 0][:samples]
|
|
594
|
+
if not len(P):
|
|
595
|
+
raise ValueError(f"no points inside {solid!r} to sample the lattice at")
|
|
596
|
+
up = _rotation(build)
|
|
597
|
+
if kind == "voronoi":
|
|
598
|
+
vor = graph._resource(nd, lambda: graph._voronoi(nd))
|
|
599
|
+
E = vor.edges
|
|
600
|
+
off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
|
|
601
|
+
mid = E.mean(axis=1) + off
|
|
602
|
+
E = E[graph.evaluate(mid, solid) < 0]
|
|
603
|
+
dx = E[:, 1] - E[:, 0]
|
|
604
|
+
L = np.linalg.norm(dx, axis=1)
|
|
605
|
+
rise = np.degrees(np.arcsin(np.clip(np.abs(dx @ up) / np.maximum(L, 1e-12), 0, 1)))
|
|
606
|
+
flat = rise < angle - 1e-6
|
|
607
|
+
rep = {"kind": kind, "angle": float(angle), "build": up.tolist(), "struts": int(len(E)),
|
|
608
|
+
"minRise": float(rise.min()) if len(E) else 90.0,
|
|
609
|
+
"unsupportedFraction": float(L[flat].sum() / max(L.sum(), 1e-12)),
|
|
610
|
+
"horizontalFraction": float(L[rise < 5.0].sum() / max(L.sum(), 1e-12)),
|
|
611
|
+
"longestBridge": float(L[flat].max()) if flat.any() else 0.0, "selfSupporting": bool(not flat.any())}
|
|
612
|
+
else:
|
|
613
|
+
eps = 1e-3
|
|
614
|
+
U0, cell = graph.lattice_coords(nd, P)
|
|
615
|
+
Jinv = np.empty((len(P), 3, 3))
|
|
616
|
+
for a in range(3):
|
|
617
|
+
dP = np.zeros(3)
|
|
618
|
+
dP[a] = eps
|
|
619
|
+
U1, _ = graph.lattice_coords(nd, P + dP)
|
|
620
|
+
du = U1 - U0
|
|
621
|
+
du -= np.round(du) # phases wrap
|
|
622
|
+
Jinv[:, :, a] = du / eps # du/dx
|
|
623
|
+
ok = np.abs(np.linalg.det(Jinv)) > 1e-9
|
|
624
|
+
J = np.zeros_like(Jinv)
|
|
625
|
+
J[ok] = np.linalg.inv(Jinv[ok]) # dx/du
|
|
626
|
+
# a conformal map's phase defects (and the medial axis, where the
|
|
627
|
+
# layer coordinate folds) have no cell to speak of: leave them out
|
|
628
|
+
span = np.linalg.norm(J, axis=1) / cell[:, None]
|
|
629
|
+
ok &= np.all((span > 0.3) & (span < 3.0), axis=1)
|
|
630
|
+
if not ok.any():
|
|
631
|
+
raise ValueError(f"lattice {lattice!r}: no regular cell among the samples")
|
|
632
|
+
rep = overhang_report(kind, float(np.mean(cell)), jacobian=J[ok], build=build, angle=angle)
|
|
633
|
+
rep["irregularSamples"] = int((~ok).sum())
|
|
634
|
+
rep["cell"] = [float(cell.min()), float(cell.max())]
|
|
635
|
+
if max_bridge is not None:
|
|
636
|
+
rep["maxBridge"] = float(max_bridge)
|
|
637
|
+
rep["bridgesTooLong"] = bool(rep["longestBridge"] > max_bridge)
|
|
638
|
+
rep["samples"] = int(len(P))
|
|
639
|
+
return rep
|