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/lattice.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field-driven lattices: fill a part's interior with a triply periodic minimal
|
|
3
|
+
surface (TPMS) sheet whose wall thickness follows the stress, keeping a solid
|
|
4
|
+
skin and solid material wherever the part works hard.
|
|
5
|
+
|
|
6
|
+
Everything is an implicit field:
|
|
7
|
+
|
|
8
|
+
- the part as a signed distance (negative inside), from a voxelization of
|
|
9
|
+
its mesh and distance transforms;
|
|
10
|
+
- the TPMS as an approximate distance, the level-set function divided by
|
|
11
|
+
its gradient's magnitude, so a sheet's wall comes out in millimetres;
|
|
12
|
+
- solid zones (the skin, collars round preserved geometry, keep-outs,
|
|
13
|
+
supports and loads, and cells the sizing turned solid) as distances too,
|
|
14
|
+
joined to the lattice by a smooth union, so every sheet meets the skin
|
|
15
|
+
and every boss with a fillet instead of a crease.
|
|
16
|
+
|
|
17
|
+
Two ways to grade the wall:
|
|
18
|
+
|
|
19
|
+
- `target_sf` set (the sizing that means something): the core is a
|
|
20
|
+
homogenized material, a sheet of relative density rho having modulus
|
|
21
|
+
E * C rho^n and a peak wall stress A rho^-m times the stress it carries
|
|
22
|
+
on average (unit-cell FEA, `homogenize`; the fits are in SHEETS). A
|
|
23
|
+
voxel FEA of the part with the skin solid and every core cell at its
|
|
24
|
+
sheet's modulus gives each cell's stress; each cell's density is set,
|
|
25
|
+
fully-stressed-design style, to the least that keeps its walls at the
|
|
26
|
+
target safety factor, cells that would need more than `wall_max` turn
|
|
27
|
+
solid, and the analysis repeats as the load moves to wherever it is
|
|
28
|
+
stiffest. That is the lattice an engineer can sign off on: the voids are
|
|
29
|
+
where the stress is not.
|
|
30
|
+
- otherwise, from a verification of the solid part (`fields`): the wall runs
|
|
31
|
+
from `wall_min` where the part idles to `wall_max`, and material above
|
|
32
|
+
`solid_above` utilization stays solid. Quick, but the solid part's stress
|
|
33
|
+
is not the lattice part's.
|
|
34
|
+
|
|
35
|
+
Printability is part of the design, not an afterthought:
|
|
36
|
+
|
|
37
|
+
- every wall is at least the process's minimum feature;
|
|
38
|
+
- powder (SLS, MJF) and resin (SLA, volumetric) would stay trapped in a
|
|
39
|
+
lattice under a closed skin, so each enclosed void gets drain holes
|
|
40
|
+
(lowest and highest along the build, away from the stress), pockets too
|
|
41
|
+
small to drain are filled solid, and the report says what is still
|
|
42
|
+
trapped (it should be nothing);
|
|
43
|
+
- FDM prints the closed skin like infill; the report gives the lattice's
|
|
44
|
+
own down-facing area (gyroid and diamond sheets self-support at the
|
|
45
|
+
usual cell sizes; Schwarz P has flat ceilings).
|
|
46
|
+
|
|
47
|
+
Units are millimetres throughout.
|
|
48
|
+
"""
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from functools import lru_cache
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
from scipy import ndimage
|
|
56
|
+
|
|
57
|
+
from .spec import Study
|
|
58
|
+
|
|
59
|
+
__all__ = ["LatticeOptions", "tpms", "lattice_infill", "TPMS", "SHEETS", "STRUTS", "STRUT_TABLES", "lattice_fit", "homogenize",
|
|
60
|
+
"relative_density", "wall_for_density", "envelope_mesh"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _gyroid(x, y, z):
|
|
64
|
+
return np.sin(x) * np.cos(y) + np.sin(y) * np.cos(z) + np.sin(z) * np.cos(x)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _gyroid_grad(x, y, z):
|
|
68
|
+
return (np.cos(x) * np.cos(y) - np.sin(z) * np.sin(x),
|
|
69
|
+
np.cos(y) * np.cos(z) - np.sin(x) * np.sin(y),
|
|
70
|
+
np.cos(z) * np.cos(x) - np.sin(y) * np.sin(z))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _schwarz_p(x, y, z):
|
|
74
|
+
return np.cos(x) + np.cos(y) + np.cos(z)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _schwarz_p_grad(x, y, z):
|
|
78
|
+
return -np.sin(x), -np.sin(y), -np.sin(z)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _diamond(x, y, z):
|
|
82
|
+
return (np.sin(x) * np.sin(y) * np.sin(z) + np.sin(x) * np.cos(y) * np.cos(z)
|
|
83
|
+
+ np.cos(x) * np.sin(y) * np.cos(z) + np.cos(x) * np.cos(y) * np.sin(z))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _diamond_grad(x, y, z):
|
|
87
|
+
sx, cx, sy, cy, sz, cz = np.sin(x), np.cos(x), np.sin(y), np.cos(y), np.sin(z), np.cos(z)
|
|
88
|
+
return (cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz,
|
|
89
|
+
sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz,
|
|
90
|
+
sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
TPMS = {"gyroid": (_gyroid, _gyroid_grad),
|
|
94
|
+
"schwarz-p": (_schwarz_p, _schwarz_p_grad),
|
|
95
|
+
"diamond": (_diamond, _diamond_grad)}
|
|
96
|
+
|
|
97
|
+
# Homogenized sheets, from `homogenize` (periodic unit cell, 40 voxels per
|
|
98
|
+
# cell, nu = 0.35, relative density 0.14-0.6; the fits are within 5% of
|
|
99
|
+
# every point): modulus E*/E = C rho^n (uniaxial; the cells are cubic, so
|
|
100
|
+
# that is every axis), and the peak wall von Mises (99.5th percentile of
|
|
101
|
+
# nodal values) over the macro von Mises, = A rho^-m, the larger of a
|
|
102
|
+
# uniaxial and a shear macro stress (uniaxial for gyroid and Schwarz P,
|
|
103
|
+
# shear for thin diamond). Elastic first yield: the walls start yielding
|
|
104
|
+
# there, well before the cell collapses, which is the right reading for a
|
|
105
|
+
# linear check. Per gram, diamond is the stiffest and strongest of the
|
|
106
|
+
# three and Schwarz P the weakest.
|
|
107
|
+
SHEETS = {
|
|
108
|
+
"gyroid": {"C": 0.648, "n": 1.43, "A": 3.01, "m": 1.10},
|
|
109
|
+
"diamond": {"C": 0.671, "n": 1.35, "A": 2.62, "m": 1.22},
|
|
110
|
+
"schwarz-p": {"C": 0.669, "n": 1.68, "A": 3.82, "m": 1.04},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Homogenized strut cells (struts.py), from `homogenize` the same way
|
|
114
|
+
# (bench/strut_fits.py: periodic unit cell, 48 voxels a cell, relative
|
|
115
|
+
# density 0.06-0.34). Struts are cubic but anisotropic, so these are the
|
|
116
|
+
# conservative isotropic reading: the modulus is the lesser of the [100]
|
|
117
|
+
# and [111] moduli, the stress factor the largest of a [100] pull, a [111]
|
|
118
|
+
# pull and a shear. BCCz and FCCz are stiff along z and the octet along its
|
|
119
|
+
# struts; the fits do not count on it. The thickness is the strut diameter.
|
|
120
|
+
# sheet_modulus and sheet_stress_factor read STRUT_TABLES below instead,
|
|
121
|
+
# which run to rho 0.92; these power laws are the summary over 0.06-0.34.
|
|
122
|
+
STRUTS = {
|
|
123
|
+
"cubic": {"C": 0.739, "n": 2.38, "A": 3.09, "m": 1.84},
|
|
124
|
+
"bcc": {"C": 0.61, "n": 2.49, "A": 4.63, "m": 1.64},
|
|
125
|
+
"bccz": {"C": 0.488, "n": 2.5, "A": 4.12, "m": 2.3},
|
|
126
|
+
"fcc": {"C": 0.443, "n": 1.4, "A": 5.96, "m": 0.97},
|
|
127
|
+
"fccz": {"C": 0.435, "n": 1.44, "A": 5.26, "m": 1.19},
|
|
128
|
+
"octet": {"C": 0.363, "n": 1.36, "A": 7.22, "m": 0.91},
|
|
129
|
+
"kelvin": {"C": 0.937, "n": 2.07, "A": 3.28, "m": 1.62},
|
|
130
|
+
# one periodic draw of 8 relaxed seeds (struts._periodic_voronoi), the
|
|
131
|
+
# thickness over the seed spacing; a foam is near isotropic
|
|
132
|
+
"voronoi": {"C": 0.841, "n": 1.93, "A": 2.87, "m": 1.57},
|
|
133
|
+
"diamond-strut": {"C": 0.695, "n": 2.1, "A": 2.63, "m": 1.77},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# The power laws above were fitted over rho 0.06-0.34 and miss badly past
|
|
138
|
+
# it: at rho 0.8 the octet fit reads E*/E 0.27 against a solved 0.56 and a
|
|
139
|
+
# stress factor 8.8 against 4.3, so a dense strut core looked far weaker than
|
|
140
|
+
# it is. The same unit-cell solves from rho 0.06 to 0.92 (bench/strut_fits.py,
|
|
141
|
+
# n=48), tabulated as (rho, E*/E, stress factor), the conservative isotropic
|
|
142
|
+
# reading as above. Between rows the readings are interpolated log-log, up to
|
|
143
|
+
# the solid (1, 1, 1); under the first row the power law runs on with the
|
|
144
|
+
# slope of the first two rows.
|
|
145
|
+
STRUT_TABLES = {
|
|
146
|
+
"cubic": [
|
|
147
|
+
(0.061, 0.001, 525.18), (0.099, 0.003, 225.22), (0.159, 0.0092, 85.85), (0.237, 0.0237, 46.11), (0.343, 0.0605, 21.63),
|
|
148
|
+
(0.478, 0.1399, 10.45), (0.647, 0.3106, 5.63), (0.785, 0.5317, 3.59),
|
|
149
|
+
],
|
|
150
|
+
"bcc": [
|
|
151
|
+
(0.058, 0.0005, 472.41), (0.093, 0.0016, 229.56), (0.158, 0.0058, 104.25), (0.232, 0.0158, 56.67), (0.327, 0.0404, 25.59),
|
|
152
|
+
(0.49, 0.1298, 10.7), (0.658, 0.3127, 5.89), (0.799, 0.551, 3.85), (0.921, 0.8148, 2.26),
|
|
153
|
+
],
|
|
154
|
+
"bccz": [
|
|
155
|
+
(0.055, 0.0004, 3174.74), (0.104, 0.0017, 748.84), (0.158, 0.0046, 290.12), (0.235, 0.0125, 116.99), (0.348, 0.0373, 45.0),
|
|
156
|
+
(0.483, 0.0997, 18.63), (0.648, 0.258, 7.29), (0.799, 0.5184, 3.47), (0.921, 0.8014, 2.25),
|
|
157
|
+
],
|
|
158
|
+
"fcc": [
|
|
159
|
+
(0.056, 0.0083, 94.57), (0.095, 0.0155, 58.45), (0.163, 0.0317, 35.92), (0.246, 0.0613, 24.25), (0.334, 0.1026, 16.2),
|
|
160
|
+
(0.487, 0.2017, 9.34), (0.655, 0.3516, 5.35), (0.798, 0.5517, 3.6), (0.919, 0.8046, 2.16),
|
|
161
|
+
],
|
|
162
|
+
"fccz": [
|
|
163
|
+
(0.062, 0.0085, 134.39), (0.105, 0.0161, 79.55), (0.163, 0.0291, 47.25), (0.238, 0.0534, 30.62), (0.335, 0.0989, 17.6),
|
|
164
|
+
(0.481, 0.1808, 9.3), (0.644, 0.3172, 5.76), (0.805, 0.5462, 3.66), (0.92, 0.8037, 2.16),
|
|
165
|
+
],
|
|
166
|
+
"octet": [
|
|
167
|
+
(0.073, 0.0108, 74.07), (0.107, 0.0173, 57.65), (0.178, 0.033, 36.12), (0.243, 0.0515, 27.9), (0.322, 0.0827, 18.73),
|
|
168
|
+
(0.48, 0.1702, 11.09), (0.652, 0.3379, 6.48), (0.79, 0.5612, 4.27), (0.922, 0.8359, 2.2),
|
|
169
|
+
],
|
|
170
|
+
"kelvin": [
|
|
171
|
+
(0.056, 0.0023, 344.9), (0.094, 0.0071, 155.37), (0.16, 0.0213, 69.09), (0.239, 0.0482, 32.62), (0.331, 0.0945, 19.28),
|
|
172
|
+
(0.478, 0.207, 10.72), (0.646, 0.4007, 5.47), (0.803, 0.6402, 3.33), (0.922, 0.8481, 1.95),
|
|
173
|
+
],
|
|
174
|
+
"diamond-strut": [
|
|
175
|
+
(0.058, 0.0018, 378.68), (0.093, 0.0047, 192.93), (0.157, 0.0144, 70.05), (0.232, 0.0321, 33.78), (0.34, 0.0721, 17.59),
|
|
176
|
+
(0.489, 0.1596, 9.47), (0.657, 0.3211, 5.96), (0.797, 0.5353, 4.3), (0.92, 0.8046, 2.91),
|
|
177
|
+
],
|
|
178
|
+
"voronoi": [
|
|
179
|
+
(0.06, 0.0036, 236.04), (0.1, 0.0098, 106.78), (0.16, 0.0244, 52.1), (0.24, 0.0531, 26.99), (0.34, 0.104, 15.49),
|
|
180
|
+
(0.481, 0.2074, 8.75), (0.65, 0.3885, 5.01), (0.8, 0.6086, 3.21), (0.92, 0.8274, 2.16),
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _table_reading(kind: str, rho, col: int):
|
|
186
|
+
"""Log-log interpolation of a strut table column (1 = E*/E, 2 = stress
|
|
187
|
+
factor) at rho, None for a kind with no table."""
|
|
188
|
+
from .struts import canonical
|
|
189
|
+
|
|
190
|
+
k = kind if kind in SHEETS else canonical(kind)
|
|
191
|
+
rows = STRUT_TABLES.get(k)
|
|
192
|
+
if rows is None:
|
|
193
|
+
return None
|
|
194
|
+
t = np.array(rows + [(1.0, 1.0, 1.0)], float)
|
|
195
|
+
x, y = np.log(t[:, 0]), np.log(t[:, col])
|
|
196
|
+
lr = np.log(np.clip(np.asarray(rho, float), 1e-3, 1.0))
|
|
197
|
+
slope = (y[1] - y[0]) / (x[1] - x[0])
|
|
198
|
+
out = np.where(lr < x[0], y[0] + slope * (lr - x[0]), np.interp(lr, x, y))
|
|
199
|
+
return np.exp(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def lattice_fit(kind: str) -> dict:
|
|
203
|
+
"""The homogenized fit (C, n, A, m) of a sheet or strut cell."""
|
|
204
|
+
from .struts import canonical
|
|
205
|
+
|
|
206
|
+
if kind in SHEETS:
|
|
207
|
+
return SHEETS[kind]
|
|
208
|
+
k = canonical(kind)
|
|
209
|
+
if k in STRUTS:
|
|
210
|
+
return STRUTS[k]
|
|
211
|
+
raise ValueError(f"no homogenized fit for lattice {kind!r} (have {', '.join(list(SHEETS) + list(STRUTS))})")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_SINGLE = [0]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class single_precision:
|
|
218
|
+
"""Inside this block (from any thread) gyroid and diamond take their
|
|
219
|
+
trig in single precision, on a phase reduced to one period in double:
|
|
220
|
+
numpy's float32 sin/cos are SIMD and ~30x faster than float64's, and
|
|
221
|
+
the value is still good to ~1e-7 of a cell near the surface (1e-6 mm
|
|
222
|
+
on a 7 mm cell). For sampling a grid for marching cubes (which keeps
|
|
223
|
+
float32 samples anyway); not for finite differences on a 1e-4 pitch
|
|
224
|
+
step, as the crease pass takes, where that noise is a 3% gradient."""
|
|
225
|
+
|
|
226
|
+
def __enter__(self):
|
|
227
|
+
_SINGLE[0] += 1
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
def __exit__(self, *exc):
|
|
231
|
+
_SINGLE[0] -= 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def tpms(kind: str, pts: np.ndarray, cell: float) -> np.ndarray:
|
|
235
|
+
"""Approximate signed distance (mm) to the TPMS mid-surface, `cell` mm per
|
|
236
|
+
period: the level-set value over its gradient's magnitude. Exact on the
|
|
237
|
+
surface, and good to a few percent out to a quarter cell, which is as far
|
|
238
|
+
as a sheet wall reaches."""
|
|
239
|
+
if kind not in TPMS:
|
|
240
|
+
raise ValueError(f"lattice type must be one of {sorted(TPMS)}, got {kind!r}")
|
|
241
|
+
if not _SINGLE[0] and np.ndim(cell) == 0:
|
|
242
|
+
from . import fieldops
|
|
243
|
+
|
|
244
|
+
if fieldops.available():
|
|
245
|
+
# double precision, one compiled pass (bit for bit the code below)
|
|
246
|
+
return fieldops.tpms(kind, pts, cell)
|
|
247
|
+
k = 2.0 * np.pi / cell
|
|
248
|
+
if kind == "schwarz-p":
|
|
249
|
+
x, y, z = (k * pts[:, i] for i in range(3))
|
|
250
|
+
f, grad = TPMS[kind]
|
|
251
|
+
g = np.stack(grad(x, y, z), axis=-1) * k
|
|
252
|
+
return f(x, y, z) / np.maximum(np.linalg.norm(g, axis=-1), 0.3 * k)
|
|
253
|
+
# gyroid and diamond from one set of sines and cosines (the separate
|
|
254
|
+
# value and gradient functions above take each three times)
|
|
255
|
+
if _SINGLE[0]:
|
|
256
|
+
two_pi = np.float32(2.0 * np.pi)
|
|
257
|
+
ph = []
|
|
258
|
+
for i in range(3):
|
|
259
|
+
u = pts[:, i] * (1.0 / cell)
|
|
260
|
+
u -= np.floor(u)
|
|
261
|
+
ph.append(u.astype(np.float32) * two_pi)
|
|
262
|
+
x, y, z = ph
|
|
263
|
+
k = np.float32(k)
|
|
264
|
+
else:
|
|
265
|
+
x, y, z = (k * pts[:, i] for i in range(3))
|
|
266
|
+
sx, cx, sy, cy, sz, cz = np.sin(x), np.cos(x), np.sin(y), np.cos(y), np.sin(z), np.cos(z)
|
|
267
|
+
if kind == "gyroid":
|
|
268
|
+
f = sx * cy + sy * cz + sz * cx
|
|
269
|
+
g = (cx * cy - sz * sx, cy * cz - sx * sy, cz * cx - sy * sz)
|
|
270
|
+
else:
|
|
271
|
+
f = sx * sy * sz + sx * cy * cz + cx * sy * cz + cx * cy * sz
|
|
272
|
+
g = (cx * sy * sz + cx * cy * cz - sx * sy * cz - sx * cy * sz,
|
|
273
|
+
sx * cy * sz - sx * sy * cz + cx * cy * cz - cx * sy * sz,
|
|
274
|
+
sx * sy * cz - sx * cy * sz - cx * sy * sz + cx * cy * cz)
|
|
275
|
+
gn = np.sqrt(g[0] * g[0] + g[1] * g[1] + g[2] * g[2]) * k
|
|
276
|
+
return np.asarray(f / np.maximum(gn, 0.3 * k), np.float64)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@lru_cache(maxsize=None)
|
|
280
|
+
def _density_table(kind: str):
|
|
281
|
+
"""Relative density of a sheet |tpms| < wall/2 against wall/cell, sampled
|
|
282
|
+
on one cell (the distance approximation makes it a little sub-linear)."""
|
|
283
|
+
n = 96
|
|
284
|
+
c = (np.arange(n) + 0.5) / n
|
|
285
|
+
g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
|
|
286
|
+
d = np.sort(np.abs(tpms(kind, g, 1.0)))
|
|
287
|
+
t = np.linspace(0.0, 0.8, 161)
|
|
288
|
+
rho = np.searchsorted(d, 0.5 * t) / d.size
|
|
289
|
+
return t, rho
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def relative_density(kind: str, wall, cell: float, blend: float = 0.0):
|
|
293
|
+
"""Volume fraction of a `wall` mm sheet (or strut diameter) in `cell` mm
|
|
294
|
+
cells; `blend` (mm) is a strut cell's node fillet."""
|
|
295
|
+
from .struts import is_strut, strut_relative_density
|
|
296
|
+
|
|
297
|
+
if is_strut(kind):
|
|
298
|
+
return strut_relative_density(kind, wall, cell, blend=float(blend) / float(np.mean(cell)))
|
|
299
|
+
t, rho = _density_table(kind)
|
|
300
|
+
return np.interp(np.asarray(wall, float) / cell, t, rho)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def wall_for_density(kind: str, rho, cell: float):
|
|
304
|
+
"""The sheet wall (mm) that gives relative density `rho`."""
|
|
305
|
+
t, r = _density_table(kind)
|
|
306
|
+
return np.interp(np.asarray(rho, float), r, t) * cell
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def sheet_modulus(kind: str, rho):
|
|
310
|
+
"""E*/E of a sheet or strut cell at relative density rho."""
|
|
311
|
+
s = lattice_fit(kind)
|
|
312
|
+
rho = np.asarray(rho, float)
|
|
313
|
+
t = _table_reading(kind, rho, 1)
|
|
314
|
+
if t is not None:
|
|
315
|
+
return np.where(rho >= 1.0, 1.0, t)
|
|
316
|
+
return np.where(rho >= 1.0, 1.0, s["C"] * np.clip(rho, 1e-3, 1.0) ** s["n"])
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def sheet_stress_factor(kind: str, rho):
|
|
320
|
+
"""Peak wall stress over the macro stress the lattice carries."""
|
|
321
|
+
s = lattice_fit(kind)
|
|
322
|
+
rho = np.asarray(rho, float)
|
|
323
|
+
t = _table_reading(kind, rho, 2)
|
|
324
|
+
if t is not None:
|
|
325
|
+
return np.where(rho >= 1.0, 1.0, t)
|
|
326
|
+
return np.where(rho >= 1.0, 1.0, s["A"] * np.clip(rho, 1e-3, 1.0) ** -s["m"])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def homogenize(kind: str, wall_over_cell: float, n: int = 40, nu: float = 0.35, blend: float = 0.0) -> dict:
|
|
330
|
+
"""Effective properties of a TPMS sheet by periodic homogenization of one
|
|
331
|
+
unit cell on an n^3 voxel grid (only the solid voxels; their nodes wrap
|
|
332
|
+
across the cell faces). Returns relative density, uniaxial modulus and
|
|
333
|
+
Poisson ratio, shear modulus (all over the solid's E), the Zener
|
|
334
|
+
anisotropy ratio, and the peak wall stress factor under a uniaxial and a
|
|
335
|
+
shear macro stress (99.5th percentile and max of nodal von Mises over
|
|
336
|
+
the macro von Mises). Strut cells too (struts.py): `wall_over_cell` is
|
|
337
|
+
then the strut diameter over the cell and `blend` the node fillet over
|
|
338
|
+
the cell; they are cubic but far from isotropic, so the modulus along
|
|
339
|
+
the cube diagonal [111] and the wall stress under a [111] pull come
|
|
340
|
+
too (E111, stressFactor111). Slow (tens of seconds at n = 40): the fits
|
|
341
|
+
in SHEETS and STRUTS come from it."""
|
|
342
|
+
import pyamg
|
|
343
|
+
import scipy.sparse as sp
|
|
344
|
+
from scipy.sparse.linalg import cg
|
|
345
|
+
|
|
346
|
+
from .fem import CORNERS, _b_matrix, elastic_c, hex8_b, hex8_ke, von_mises
|
|
347
|
+
|
|
348
|
+
h = 1.0 / n
|
|
349
|
+
c = (np.arange(n) + 0.5) * h
|
|
350
|
+
g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
|
|
351
|
+
from .struts import is_strut, unit_cell_distance
|
|
352
|
+
|
|
353
|
+
if is_strut(kind):
|
|
354
|
+
solid = unit_cell_distance(kind, g, blend) < 0.5 * wall_over_cell
|
|
355
|
+
else:
|
|
356
|
+
solid = np.abs(tpms(kind, g, 1.0)) < 0.5 * wall_over_cell
|
|
357
|
+
eijk = np.argwhere(solid.reshape(n, n, n))
|
|
358
|
+
ne = len(eijk)
|
|
359
|
+
corner = (eijk[:, None, :] + CORNERS[None]) % n
|
|
360
|
+
used, inv = np.unique((corner[..., 0] * n + corner[..., 1]) * n + corner[..., 2], return_inverse=True)
|
|
361
|
+
enode = inv.reshape(ne, 8)
|
|
362
|
+
nn = len(used)
|
|
363
|
+
edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
|
|
364
|
+
ke = hex8_ke(nu) * h
|
|
365
|
+
k = sp.csr_matrix((np.tile(ke.ravel(), ne), (np.repeat(edof, 24, axis=1).ravel(),
|
|
366
|
+
np.tile(edof, (1, 24)).ravel())), shape=(3 * nn, 3 * nn))
|
|
367
|
+
free = np.ones(3 * nn, bool)
|
|
368
|
+
free[:3] = False # one node holds the cell still
|
|
369
|
+
kf = k[free][:, free]
|
|
370
|
+
ml = pyamg.smoothed_aggregation_solver(kf, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
|
|
371
|
+
xc = (eijk[:, None, :] + CORNERS[None]) * h
|
|
372
|
+
c0 = elastic_c(1.0, nu)
|
|
373
|
+
bc = _b_matrix(0, 0, 0) / h
|
|
374
|
+
|
|
375
|
+
def load(e):
|
|
376
|
+
# u = macro strain . x + periodic fluctuation
|
|
377
|
+
eps = np.array([[e[0], e[3] / 2, e[5] / 2], [e[3] / 2, e[1], e[4] / 2], [e[5] / 2, e[4] / 2, e[2]]])
|
|
378
|
+
u0 = (xc @ eps.T).reshape(ne, 24)
|
|
379
|
+
f = np.zeros(3 * nn)
|
|
380
|
+
np.add.at(f, edof, -(u0 @ ke.T))
|
|
381
|
+
uf, info = cg(kf, f[free], rtol=1e-8, maxiter=2000, M=ml.aspreconditioner())
|
|
382
|
+
if info != 0:
|
|
383
|
+
raise RuntimeError("unit cell solve did not converge")
|
|
384
|
+
u = np.zeros(3 * nn)
|
|
385
|
+
u[free] = uf
|
|
386
|
+
ue = u[edof] + u0
|
|
387
|
+
mean = ((ue @ bc.T) @ c0.T).sum(axis=0) / n ** 3
|
|
388
|
+
acc = np.zeros((nn, 6))
|
|
389
|
+
for kk, (a, b, cc) in enumerate(2.0 * CORNERS - 1.0):
|
|
390
|
+
np.add.at(acc, enode[:, kk], (ue @ (hex8_b(a, b, cc, nu) / h).T) @ c0.T)
|
|
391
|
+
vm = von_mises(acc / np.bincount(enode.ravel(), minlength=nn)[:, None])
|
|
392
|
+
return mean, vm
|
|
393
|
+
|
|
394
|
+
s1, _ = load([1, 0, 0, 0, 0, 0])
|
|
395
|
+
c11, c12 = s1[0], s1[1]
|
|
396
|
+
c44 = load([0, 0, 0, 1, 0, 0])[0][3]
|
|
397
|
+
e_uni = (c11 - c12) * (c11 + 2 * c12) / (c11 + c12)
|
|
398
|
+
nu_h = c12 / (c11 + c12)
|
|
399
|
+
_, vm_z = load([-nu_h / e_uni, -nu_h / e_uni, 1 / e_uni, 0, 0, 0])
|
|
400
|
+
_, vm_s = load([0, 0, 0, 1 / c44, 0, 0])
|
|
401
|
+
# [111]: macro stress n n^T (unit von Mises) through the cubic compliance
|
|
402
|
+
s11 = (c11 + c12) / ((c11 - c12) * (c11 + 2 * c12))
|
|
403
|
+
s12 = -c12 / ((c11 - c12) * (c11 + 2 * c12))
|
|
404
|
+
s44 = 1.0 / c44
|
|
405
|
+
e111 = 1.0 / (s11 - 2 * (s11 - s12 - 0.5 * s44) / 3.0)
|
|
406
|
+
sig = np.full(6, 1.0 / 3.0)
|
|
407
|
+
eps = np.array([s11 * sig[0] + s12 * (sig[1] + sig[2]), s11 * sig[1] + s12 * (sig[0] + sig[2]),
|
|
408
|
+
s11 * sig[2] + s12 * (sig[0] + sig[1]), s44 * sig[3], s44 * sig[4], s44 * sig[5]])
|
|
409
|
+
_, vm_d = load(list(eps))
|
|
410
|
+
return {"rho": float(solid.mean()), "E": float(e_uni), "nu": float(nu_h), "G": float(c44),
|
|
411
|
+
"E111": float(e111), "stressFactor111": float(np.percentile(vm_d, 99.5)),
|
|
412
|
+
"zener": float(2 * c44 / (c11 - c12)),
|
|
413
|
+
"stressFactor": float(np.percentile(vm_z, 99.5)), "stressFactorMax": float(vm_z.max()),
|
|
414
|
+
"shearStressFactor": float(np.percentile(vm_s, 99.5) / np.sqrt(3.0))}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass
|
|
418
|
+
class LatticeOptions:
|
|
419
|
+
kind: str = "gyroid"
|
|
420
|
+
cell: float = 8.0 # mm per period
|
|
421
|
+
skin: float = 1.2 # mm of solid shell; 0 leaves the lattice open
|
|
422
|
+
wall_min: float = 0.8 # mm, where the part idles (>= the process's min feature)
|
|
423
|
+
wall_max: float = 2.0 # mm; a cell that needs more turns solid
|
|
424
|
+
solid_above: float = 0.5 # (solid-part grading) utilization above which material stays solid
|
|
425
|
+
safety_factor: float = 2.0 # (solid-part grading) the allowable is strength / this
|
|
426
|
+
target_sf: float | None = None # homogenized sizing to this safety factor (every load case)
|
|
427
|
+
collar: float = 2.0 # mm of solid round preserved shapes, keep-outs, supports and loads
|
|
428
|
+
blend: float | None = None # mm, smooth-union radius between solid and lattice (default wall_max)
|
|
429
|
+
drains: str = "auto" # "auto" (powder and resin processes), "yes", "no"
|
|
430
|
+
drain_diameter: float | None = None # mm (default the process's escape hole: SLS 4, MJF 5, resin 3.5)
|
|
431
|
+
fill_pockets: float | None = None # mm^3: enclosed voids smaller than this are filled (default cell^3/8)
|
|
432
|
+
iterations: int = 12 # sizing passes
|
|
433
|
+
analysis_cells: int = 120_000 # homogenized FEA size
|
|
434
|
+
step: float | None = None # field sampling, mm (default: wall_min / 2.5)
|
|
435
|
+
max_points: int = 12_000_000
|
|
436
|
+
exact_surface: bool = True # mesh the TPMS itself (projected), not its grid samples
|
|
437
|
+
anisotropic: bool | None = None # (homogenized sizing) full cell tensors (lattice_tensors); None: on
|
|
438
|
+
# where tables exist and LOADPATH_LATTICE_ANISO is not 0
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def from_spec(cls, spec: dict, process=None, safety_factor: float | None = None) -> "LatticeOptions":
|
|
442
|
+
"""Options from the JSON spec (schema.LATTICE_SCHEMA). `safety_factor`
|
|
443
|
+
is the study goal's, used when the spec names none."""
|
|
444
|
+
spec = dict(spec or {})
|
|
445
|
+
o = cls()
|
|
446
|
+
for key, attr in (("type", "kind"), ("cell", "cell"), ("skin", "skin"), ("minWall", "wall_min"),
|
|
447
|
+
("maxWall", "wall_max"), ("solidAbove", "solid_above"), ("collar", "collar"),
|
|
448
|
+
("blend", "blend"), ("drains", "drains"), ("drainDiameter", "drain_diameter"),
|
|
449
|
+
("fillPockets", "fill_pockets"), ("step", "step")):
|
|
450
|
+
if key in spec:
|
|
451
|
+
setattr(o, attr, spec[key] if attr in ("kind", "drains") else float(spec[key]))
|
|
452
|
+
sizing = spec.get("sizing", "homogenized")
|
|
453
|
+
if sizing not in ("homogenized", "solid-stress"):
|
|
454
|
+
raise ValueError("lattice sizing must be 'homogenized' or 'solid-stress'")
|
|
455
|
+
sf = float(spec.get("safetyFactor") or safety_factor or 2.0)
|
|
456
|
+
if sizing == "homogenized":
|
|
457
|
+
o.target_sf = sf
|
|
458
|
+
else:
|
|
459
|
+
o.safety_factor = sf
|
|
460
|
+
if process is not None and o.wall_min < process.min_feature:
|
|
461
|
+
o.wall_min = process.min_feature
|
|
462
|
+
if o.wall_max < o.wall_min:
|
|
463
|
+
raise ValueError("lattice maxWall must be at least minWall")
|
|
464
|
+
if o.cell < 4.0 * o.wall_max:
|
|
465
|
+
raise ValueError(f"lattice cell {o.cell:g}mm is too small for {o.wall_max:g}mm walls "
|
|
466
|
+
"(at least 4 x maxWall, or the channels close)")
|
|
467
|
+
if o.drains not in ("auto", "yes", "no"):
|
|
468
|
+
raise ValueError("lattice drains must be 'auto', 'yes' or 'no'")
|
|
469
|
+
return o
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def envelope_mesh(study: Study):
|
|
473
|
+
"""The whole envelope as a solid: design space and preserved shapes, less
|
|
474
|
+
the keep-outs. A lattice part that keeps its outer shape starts here."""
|
|
475
|
+
import manifold3d as m3d
|
|
476
|
+
|
|
477
|
+
from .shapes import trimesh_from_manifold
|
|
478
|
+
|
|
479
|
+
add = m3d.Manifold.batch_boolean([s.to_manifold() for s in study.design_space]
|
|
480
|
+
+ [r.shape.to_manifold() for r in study.preserve], m3d.OpType.Add)
|
|
481
|
+
if study.keep_out:
|
|
482
|
+
add = add - m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.keep_out], m3d.OpType.Add)
|
|
483
|
+
return trimesh_from_manifold(add)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _part_distance(mesh, step, lo, dims):
|
|
487
|
+
"""Signed distance to the mesh surface on the grid (negative inside),
|
|
488
|
+
from an inside/outside voxelization and two distance transforms."""
|
|
489
|
+
from .verify import voxelize
|
|
490
|
+
|
|
491
|
+
hi = lo + np.asarray(dims) * step
|
|
492
|
+
solid, origin = voxelize(mesh, step, box=(lo, hi))
|
|
493
|
+
if solid.shape != tuple(dims): # rounding at the last cell
|
|
494
|
+
out = np.zeros(dims, bool)
|
|
495
|
+
s = tuple(slice(0, min(a, b)) for a, b in zip(solid.shape, dims))
|
|
496
|
+
out[s] = solid[s]
|
|
497
|
+
solid = out
|
|
498
|
+
return _signed_distance(solid, step)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _signed_distance(solid, step):
|
|
502
|
+
d = (ndimage.distance_transform_edt(~solid) - ndimage.distance_transform_edt(solid)) * step
|
|
503
|
+
return d + np.where(solid, 0.5 * step, -0.5 * step)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _collar_distance(study: Study, pts, collar):
|
|
507
|
+
"""Distance (negative inside) to the solid zones round everything the
|
|
508
|
+
user drew: preserved shapes, keep-outs (a bore's wall), and where loads
|
|
509
|
+
and supports attach. Nothing there should be lattice."""
|
|
510
|
+
shapes = [r.shape for r in study.preserve] + [r.shape for r in study.keep_out]
|
|
511
|
+
for s in study.supports:
|
|
512
|
+
shapes.append(s.shape if s.shape is not None else study.region_shape(s.region)[0])
|
|
513
|
+
for case in study.load_cases:
|
|
514
|
+
for ld in case.loads:
|
|
515
|
+
shapes.append(ld.shape if ld.shape is not None else study.region_shape(ld.region)[0])
|
|
516
|
+
d = np.full(len(pts), np.inf)
|
|
517
|
+
seen = set()
|
|
518
|
+
for s in shapes:
|
|
519
|
+
if id(s) in seen:
|
|
520
|
+
continue
|
|
521
|
+
seen.add(id(s))
|
|
522
|
+
d = np.minimum(d, s.sdf(pts) - collar)
|
|
523
|
+
return d
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _block_mean(a, r):
|
|
527
|
+
nx, ny, nz = (n // r for n in a.shape)
|
|
528
|
+
return a[:nx * r, :ny * r, :nz * r].reshape(nx, r, ny, r, nz, r).mean(axis=(1, 3, 5))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _size(study: Study, o: LatticeOptions, lo, step, inside, solid_zone):
|
|
532
|
+
"""Homogenized fully-stressed sizing on a grid r fine cells to the side.
|
|
533
|
+
Returns (rho per analysis cell, 1 = solid; the strain-equivalent stress
|
|
534
|
+
at full modulus, MPa; r; info)."""
|
|
535
|
+
from scipy.spatial import cKDTree
|
|
536
|
+
|
|
537
|
+
from .domain import boundary_conditions, gravity_force
|
|
538
|
+
from .fem import HexMesh, Solver, Stiffness, von_mises
|
|
539
|
+
|
|
540
|
+
mat = study.material
|
|
541
|
+
kind = o.kind
|
|
542
|
+
vol = inside.sum() * step ** 3
|
|
543
|
+
pitch = max(o.cell / 5.0, (vol / o.analysis_cells) ** (1.0 / 3.0))
|
|
544
|
+
r = max(1, int(round(pitch / step)))
|
|
545
|
+
pitch = r * step
|
|
546
|
+
f_in = _block_mean(inside.astype(np.float32), r)
|
|
547
|
+
f_solid = _block_mean((inside & solid_zone).astype(np.float32), r)
|
|
548
|
+
active = f_in >= 0.5
|
|
549
|
+
lbl, _ = ndimage.label(active)
|
|
550
|
+
ijk = np.argwhere(active)
|
|
551
|
+
hm = HexMesh(ijk, pitch, lo)
|
|
552
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
|
|
553
|
+
sup = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
|
|
554
|
+
keep = np.isin(lbl[tuple(ijk.T)], sup)
|
|
555
|
+
if not keep.all():
|
|
556
|
+
ijk = ijk[keep]
|
|
557
|
+
hm = HexMesh(ijk, pitch, lo)
|
|
558
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
|
|
559
|
+
fs = (f_solid[tuple(ijk.T)] / f_in[tuple(ijk.T)]).astype(float)
|
|
560
|
+
fc = 1.0 - fs
|
|
561
|
+
lattice_cell = fc > 0.05
|
|
562
|
+
rho_min = float(relative_density(kind, o.wall_min, o.cell))
|
|
563
|
+
rho_max = float(relative_density(kind, o.wall_max, o.cell))
|
|
564
|
+
rho = np.full(hm.n_elem, rho_min)
|
|
565
|
+
rho[~lattice_cell] = 1.0
|
|
566
|
+
stiff = Stiffness(hm, mat.nu, bc.fixed)
|
|
567
|
+
solver = Solver(hm, rtol=1e-6, maxiter=3000)
|
|
568
|
+
aniso = _use_tensors(o)
|
|
569
|
+
if aniso:
|
|
570
|
+
from .fem import elastic_c
|
|
571
|
+
from .homogenized import _center_strain, solve_anisotropic
|
|
572
|
+
from .lattice_tensors import stress_surface, tensors_at
|
|
573
|
+
|
|
574
|
+
c_solid = elastic_c(1.0, mat.nu)
|
|
575
|
+
# candidate densities a cell can be sized to, and their tensors
|
|
576
|
+
cand = np.geomspace(rho_min, rho_max, 28)
|
|
577
|
+
cand_t = tensors_at(kind, cand)
|
|
578
|
+
target = o.target_sf * 1.15 # verify's discretization allowance
|
|
579
|
+
allow = mat.strength / target
|
|
580
|
+
contact = bc.support_nodes.copy()
|
|
581
|
+
for ln in bc.case_load_nodes:
|
|
582
|
+
contact |= ln
|
|
583
|
+
band = 1.5 * pitch + o.collar
|
|
584
|
+
near = cKDTree(hm.node_xyz[contact]).query(hm.elem_centers(), k=1, distance_upper_bound=band)[0] < np.inf
|
|
585
|
+
# a stress concentration spreads over a cell before the lattice can
|
|
586
|
+
# follow it: grow each requirement over a quarter cell
|
|
587
|
+
grow = max(1, int(round(0.25 * o.cell / pitch)))
|
|
588
|
+
grid = np.zeros(active.shape)
|
|
589
|
+
history = []
|
|
590
|
+
analyzed = rho
|
|
591
|
+
for it in range(o.iterations):
|
|
592
|
+
e_cell = mat.E * (fs + fc * sheet_modulus(kind, rho))
|
|
593
|
+
fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else 0.0
|
|
594
|
+
rhs = [bc.loads[ci] + fg for ci in range(len(study.load_cases))]
|
|
595
|
+
if aniso:
|
|
596
|
+
c_lat = tensors_at(kind, rho)[0]
|
|
597
|
+
c_cell = mat.E * (fs[:, None, None] * c_solid + fc[:, None, None] * c_lat)
|
|
598
|
+
us = solve_anisotropic(hm, mat.nu, c_cell, bc, mat.E, rhs, proxy=e_cell)
|
|
599
|
+
need, wall_u, se = _tensor_need(kind, us, hm, mat, rho, rho_max, cand, cand_t, allow,
|
|
600
|
+
_center_strain, stress_surface, c_solid)
|
|
601
|
+
need = np.where(lattice_cell, need, 0.0)
|
|
602
|
+
grid[:] = 0.0
|
|
603
|
+
grid[tuple(ijk.T)] = need
|
|
604
|
+
need = ndimage.maximum_filter(grid, size=2 * grow + 1)[tuple(ijk.T)]
|
|
605
|
+
new = np.clip(np.maximum(need, 0.5 * np.minimum(rho, rho_max)), rho_min, None)
|
|
606
|
+
stay = (rho >= 1.0) & (need > 0.8 * rho_max)
|
|
607
|
+
new = np.where((new > rho_max) | stay, 1.0, np.minimum(new, rho_max))
|
|
608
|
+
new[~lattice_cell] = 1.0
|
|
609
|
+
wall_u = np.where(lattice_cell & (rho < 1.0), wall_u, 0.0)
|
|
610
|
+
solid_u = np.where(~lattice_cell | (rho >= 1.0), se, 0.0)
|
|
611
|
+
peak = float(np.max(np.where(near, 0.0, np.maximum(wall_u, solid_u))))
|
|
612
|
+
frac = float((fs + fc * rho).mean())
|
|
613
|
+
history.append({"iteration": it, "predictedSF": mat.strength / (1.15 * peak) if peak > 0 else np.inf,
|
|
614
|
+
"solidCells": float((rho >= 1).mean()), "fill": frac,
|
|
615
|
+
"compliance": float(sum(f @ u for f, u in zip(rhs, us)))})
|
|
616
|
+
short = history[-1]["predictedSF"] / o.target_sf
|
|
617
|
+
if it >= 2:
|
|
618
|
+
allow *= float(np.clip(short, 0.85, 1.15)) ** 0.5
|
|
619
|
+
if np.allclose(new, rho, atol=0.01) and abs(short - 1.0) < 0.05:
|
|
620
|
+
break
|
|
621
|
+
analyzed, rho = rho, new
|
|
622
|
+
continue
|
|
623
|
+
k = stiff.assemble(e_cell)
|
|
624
|
+
if bc.springs is not None:
|
|
625
|
+
k = (k + mat.E * bc.springs).tocsr()
|
|
626
|
+
us = solver.solve_many(k, rhs)
|
|
627
|
+
# stress at the solid's modulus for each cell's strain: what the
|
|
628
|
+
# skin carries, and E*/E of it is the lattice's macro stress
|
|
629
|
+
se = np.max([von_mises(stiff.stress(u, mat.E)) for u in us], axis=0)
|
|
630
|
+
sm = se * sheet_modulus(kind, rho)
|
|
631
|
+
# a solid cell is judged as the densest lattice under the same
|
|
632
|
+
# strain: whether it could go back to lattice (with some hysteresis,
|
|
633
|
+
# or cells flicker), not what it needs as solid, which would spread
|
|
634
|
+
# solid outwards a ring per pass
|
|
635
|
+
s = SHEETS[kind]
|
|
636
|
+
sm_eval = se * sheet_modulus(kind, np.minimum(rho, rho_max))
|
|
637
|
+
need = np.where(lattice_cell, (s["A"] * sm_eval / allow) ** (1.0 / s["m"]), 0.0)
|
|
638
|
+
grid[:] = 0.0
|
|
639
|
+
grid[tuple(ijk.T)] = need
|
|
640
|
+
need = ndimage.maximum_filter(grid, size=2 * grow + 1)[tuple(ijk.T)]
|
|
641
|
+
new = np.clip(np.maximum(need, 0.5 * np.minimum(rho, rho_max)), rho_min, None) # move limit
|
|
642
|
+
stay = (rho >= 1.0) & (need > 0.8 * rho_max)
|
|
643
|
+
new = np.where((new > rho_max) | stay, 1.0, np.minimum(new, rho_max))
|
|
644
|
+
new[~lattice_cell] = 1.0
|
|
645
|
+
wall_u = np.where(lattice_cell & (rho < 1.0), sheet_stress_factor(kind, rho) * sm, 0.0)
|
|
646
|
+
solid_u = np.where(~lattice_cell | (rho >= 1.0), se, 0.0)
|
|
647
|
+
peak = float(np.max(np.where(near, 0.0, np.maximum(wall_u, solid_u))))
|
|
648
|
+
frac = float((fs + fc * rho).mean())
|
|
649
|
+
history.append({"iteration": it, "predictedSF": mat.strength / (1.15 * peak) if peak > 0 else np.inf,
|
|
650
|
+
"solidCells": float((rho >= 1).mean()), "fill": frac,
|
|
651
|
+
"compliance": float(sum((bc.loads[ci] + fg) @ u for ci, u in enumerate(us)))})
|
|
652
|
+
# the sizing assumes each cell's macro stress stays put as it
|
|
653
|
+
# changes; when the analysis keeps reading under the target (the
|
|
654
|
+
# load moved onto what got stiffer), tighten the allowable to match
|
|
655
|
+
short = history[-1]["predictedSF"] / o.target_sf
|
|
656
|
+
if it >= 2:
|
|
657
|
+
allow *= float(np.clip(short, 0.85, 1.15)) ** 0.5
|
|
658
|
+
if np.allclose(new, rho, atol=0.01) and abs(short - 1.0) < 0.05:
|
|
659
|
+
break
|
|
660
|
+
analyzed, rho = rho, new
|
|
661
|
+
else:
|
|
662
|
+
rho = analyzed # report the field that was analyzed
|
|
663
|
+
rho_grid = np.zeros(active.shape)
|
|
664
|
+
rho_grid[tuple(ijk.T)] = rho
|
|
665
|
+
se_grid = np.zeros(active.shape)
|
|
666
|
+
se_grid[tuple(ijk.T)] = se
|
|
667
|
+
mask = np.zeros(active.shape, bool)
|
|
668
|
+
mask[tuple(ijk.T)] = True
|
|
669
|
+
info = {"analysisPitch": pitch, "analysisCells": int(hm.n_elem), "history": history,
|
|
670
|
+
"predictedSF": history[-1]["predictedSF"],
|
|
671
|
+
"solidCellFraction": float((rho[lattice_cell] >= 1).mean()) if lattice_cell.any() else 0.0}
|
|
672
|
+
return rho_grid, se_grid, mask, r, info
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _use_tensors(o) -> bool:
|
|
676
|
+
import os
|
|
677
|
+
|
|
678
|
+
from .lattice_tensors import has_tensors
|
|
679
|
+
|
|
680
|
+
on = o.anisotropic
|
|
681
|
+
if on is None:
|
|
682
|
+
on = os.environ.get("LOADPATH_LATTICE_ANISO", "1").strip() not in ("0", "false", "off")
|
|
683
|
+
return bool(on) and has_tensors(o.kind)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _tensor_need(kind, us, hm, mat, rho, rho_max, cand, cand_t, allow, center_strain, stress_surface, c_solid):
|
|
687
|
+
"""Fully-stressed sizing on the cell tensors: each cell's macro stress
|
|
688
|
+
(its lattice tensor at the density it was analyzed at, a solid cell as
|
|
689
|
+
the densest lattice) held while the density changes, the least
|
|
690
|
+
candidate density whose wall stress surface stays under `allow` in every
|
|
691
|
+
load case (log-interpolated between candidates; above rho_max where none
|
|
692
|
+
does). Also the walls' stress at the analyzed density and the solid's
|
|
693
|
+
von Mises, both worst over the cases."""
|
|
694
|
+
from .fem import von_mises
|
|
695
|
+
from .lattice_tensors import tensors_at
|
|
696
|
+
|
|
697
|
+
r_eval = np.minimum(rho, rho_max)
|
|
698
|
+
c_now, p_now, q_now, f_now = tensors_at(kind, r_eval)
|
|
699
|
+
Ck, Pk, Qk, Fk = cand_t
|
|
700
|
+
worst = np.zeros((len(rho), len(cand)))
|
|
701
|
+
wall_now = np.zeros(len(rho))
|
|
702
|
+
se = np.zeros(len(rho))
|
|
703
|
+
for u in us:
|
|
704
|
+
eps = center_strain(hm, u)
|
|
705
|
+
se = np.maximum(se, von_mises(mat.E * eps @ c_solid.T))
|
|
706
|
+
sig = mat.E * np.einsum("eab,eb->ea", c_now, eps)
|
|
707
|
+
wall_now = np.maximum(wall_now, stress_surface(sig, p_now, q_now, f_now))
|
|
708
|
+
for j in range(len(cand)):
|
|
709
|
+
worst[:, j] = np.maximum(worst[:, j], stress_surface(sig, Pk[j], Qk[j], Fk[j]))
|
|
710
|
+
ok = worst <= allow
|
|
711
|
+
first = np.where(ok.any(axis=1), ok.argmax(axis=1), len(cand))
|
|
712
|
+
need = np.full(len(rho), 1.5 * rho_max)
|
|
713
|
+
at = first < len(cand)
|
|
714
|
+
need[at] = cand[first[at]]
|
|
715
|
+
mid = at & (first > 0)
|
|
716
|
+
j = first[mid]
|
|
717
|
+
lw0, lw1 = np.log(worst[mid, j - 1]), np.log(worst[mid, j])
|
|
718
|
+
t = np.clip((lw0 - np.log(allow)) / np.maximum(lw0 - lw1, 1e-12), 0.0, 1.0)
|
|
719
|
+
need[mid] = np.exp(np.log(cand[j - 1]) + t * (np.log(cand[j]) - np.log(cand[j - 1])))
|
|
720
|
+
return need, wall_now, se
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _to_fine(coarse, mask, r, dims):
|
|
724
|
+
"""Trilinear upsampling of a per-analysis-cell field to the fine grid,
|
|
725
|
+
the cells outside the analysis filled from their nearest neighbour."""
|
|
726
|
+
idx = ndimage.distance_transform_edt(~mask, return_distances=False, return_indices=True)
|
|
727
|
+
filled = coarse[tuple(idx)]
|
|
728
|
+
coords = [(np.arange(n) + 0.5) / r - 0.5 for n in dims]
|
|
729
|
+
out = np.empty(dims, np.float32)
|
|
730
|
+
gy, gz = np.meshgrid(coords[1], coords[2], indexing="ij")
|
|
731
|
+
for i, x in enumerate(coords[0]): # a slab at a time keeps the coordinates small
|
|
732
|
+
out[i] = ndimage.map_coordinates(filled, [np.full_like(gy, x), gy, gz], order=1, mode="nearest")
|
|
733
|
+
return out
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _capsule(pts, a, b, radius):
|
|
737
|
+
ab = b - a
|
|
738
|
+
t = np.clip(((pts - a) @ ab) / (ab @ ab), 0.0, 1.0)
|
|
739
|
+
return np.linalg.norm(pts - (a + t[:, None] * ab), axis=1) - radius
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _local(lo, step, shape, a, b, reach):
|
|
743
|
+
"""The grid block round segment a-b out to `reach`, and its points."""
|
|
744
|
+
box_lo = np.floor((np.minimum(a, b) - reach - lo) / step - 1).astype(int).clip(0)
|
|
745
|
+
box_hi = np.ceil((np.maximum(a, b) + reach - lo) / step + 1).astype(int).clip(None, np.array(shape))
|
|
746
|
+
bs = tuple(slice(x0, x1) for x0, x1 in zip(box_lo, box_hi))
|
|
747
|
+
ii = np.stack(np.meshgrid(*[np.arange(x0, x1) for x0, x1 in zip(box_lo, box_hi)], indexing="ij"), -1)
|
|
748
|
+
return bs, lo + (ii.reshape(-1, 3) + 0.5) * step, ii.shape[:3]
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _drains(o, d, dp, d_collar, stress, lo, step, process):
|
|
752
|
+
"""Pockets too small to drain (a mask, to fill solid), and drain holes
|
|
753
|
+
from every other enclosed void to the outside: (a, b, radius, report)
|
|
754
|
+
per hole, a running from inside the void out through the skin.
|
|
755
|
+
|
|
756
|
+
Holes go where the part is calmest, just under the skin on a flat face
|
|
757
|
+
clear of everything the user drew, two per void well apart so powder
|
|
758
|
+
can be blown and resin rinsed through. Each gets a solid boss."""
|
|
759
|
+
material = d < 0.0
|
|
760
|
+
lbl, n = ndimage.label(~material)
|
|
761
|
+
open_ids = set(np.unique(lbl[dp > 0.0]).tolist())
|
|
762
|
+
vox = step ** 3
|
|
763
|
+
sizes = ndimage.sum_labels(np.ones_like(lbl), lbl, index=np.arange(1, n + 1))
|
|
764
|
+
fill = o.fill_pockets if o.fill_pockets is not None else o.cell ** 3 / 8.0
|
|
765
|
+
trapped = [i for i in range(1, n + 1) if i not in open_ids]
|
|
766
|
+
small = [i for i in trapped if sizes[i - 1] * vox < fill]
|
|
767
|
+
pockets = np.isin(lbl, small) if small else None
|
|
768
|
+
big = [i for i in trapped if i not in small]
|
|
769
|
+
holes = []
|
|
770
|
+
if not big or o.drains == "no" or (o.drains == "auto" and (process is None or process.kind == "fdm")):
|
|
771
|
+
return pockets, holes
|
|
772
|
+
if o.drain_diameter is not None:
|
|
773
|
+
dia = o.drain_diameter
|
|
774
|
+
else:
|
|
775
|
+
from .processcheck import RULES
|
|
776
|
+
|
|
777
|
+
dia = getattr(process, "escape_hole", None) or \
|
|
778
|
+
(RULES[process.kind]["escapeHole"] if process.kind in RULES else 3.0 if process.kind == "volumetric" else 4.0)
|
|
779
|
+
rad = 0.5 * dia
|
|
780
|
+
axis, sign = process.build if process is not None and process.build is not None else (2, 1)
|
|
781
|
+
objs = ndimage.find_objects(lbl)
|
|
782
|
+
for i in big:
|
|
783
|
+
sl = objs[i - 1]
|
|
784
|
+
comp = lbl[sl] == i
|
|
785
|
+
depth = -dp[sl]
|
|
786
|
+
cand = comp & (depth < o.skin + 3.0 * step) & (d_collar[sl] > rad + o.collar + 1.0)
|
|
787
|
+
if not cand.any():
|
|
788
|
+
cand = comp & (depth <= depth[comp].min() + step)
|
|
789
|
+
idx = np.argwhere(cand)
|
|
790
|
+
sv = stress[sl][cand]
|
|
791
|
+
off = np.asarray([s_.start for s_ in sl])
|
|
792
|
+
# a hole is a stress raiser (about 3x in a plate): the calmest flat
|
|
793
|
+
# site first, then the calmest one well away from it, so air or
|
|
794
|
+
# rinse can flow through; on a tie, lower along the build drains
|
|
795
|
+
order = np.lexsort((idx[:, axis] * sign, np.round(sv, 1)))
|
|
796
|
+
reach = rad + max(o.collar, o.wall_max)
|
|
797
|
+
extent = float(np.linalg.norm((idx.max(axis=0) - idx.min(axis=0)) * step))
|
|
798
|
+
picks = []
|
|
799
|
+
for j in order[:2000]:
|
|
800
|
+
pt0 = lo + (off + idx[j] + 0.5) * step
|
|
801
|
+
if picks and np.linalg.norm(pt0 - picks[0][0]) < max(4.0 * dia, 0.5 * extent):
|
|
802
|
+
continue
|
|
803
|
+
site = _drain_site(dp, tuple(off + idx[j]), lo, step, reach)
|
|
804
|
+
if site is not None:
|
|
805
|
+
picks.append(site)
|
|
806
|
+
if len(picks) == 2:
|
|
807
|
+
break
|
|
808
|
+
for pt, nrm, g in picks:
|
|
809
|
+
b = pt + nrm * (-dp[g] + 1.5)
|
|
810
|
+
holes.append((pt, b, rad, {"at": (pt + nrm * -dp[g]).tolist(), "normal": nrm.tolist(),
|
|
811
|
+
"diameter": dia, "stress": float(stress[g])}))
|
|
812
|
+
return pockets, holes
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _drain_site(dp, g, lo, step, reach):
|
|
816
|
+
"""The site's point and outward normal, if the surface over it is flat
|
|
817
|
+
out to `reach`: a hole on an edge or a corner loses half its boss to
|
|
818
|
+
the edge and is where the stress concentrates."""
|
|
819
|
+
nb = tuple(slice(max(c - 2, 0), c + 3) for c in g)
|
|
820
|
+
gr = np.array([np.gradient(dp[nb], step, axis=k).mean() for k in range(3)])
|
|
821
|
+
nrm = gr / max(np.linalg.norm(gr), 1e-9)
|
|
822
|
+
pt = lo + (np.asarray(g) + 0.5) * step
|
|
823
|
+
surf = pt + nrm * -dp[g]
|
|
824
|
+
u = np.cross(nrm, [1.0, 0.0, 0.0] if abs(nrm[0]) < 0.9 else [0.0, 1.0, 0.0])
|
|
825
|
+
u /= np.linalg.norm(u)
|
|
826
|
+
v = np.cross(nrm, u)
|
|
827
|
+
ang = np.linspace(0.0, 2.0 * np.pi, 12, endpoint=False)
|
|
828
|
+
ring = surf + reach * (np.cos(ang)[:, None] * u + np.sin(ang)[:, None] * v)
|
|
829
|
+
ijk = np.floor((ring - lo) / step).astype(int)
|
|
830
|
+
if (ijk < 0).any() or (ijk >= np.array(dp.shape)).any():
|
|
831
|
+
return None
|
|
832
|
+
if np.abs(dp[tuple(ijk.T)]).max() > 1.5 * step:
|
|
833
|
+
return None
|
|
834
|
+
return pt, nrm, g
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _trapped(d, dp, step):
|
|
838
|
+
lbl, n = ndimage.label(d >= 0.0)
|
|
839
|
+
open_ids = np.unique(lbl[dp > 0.0])
|
|
840
|
+
tr = (lbl > 0) & ~np.isin(lbl, open_ids)
|
|
841
|
+
return float(tr.sum() * step ** 3), int(len(np.setdiff1d(np.arange(1, n + 1), open_ids)))
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def lattice_infill(study: Study, mesh, fields: dict | None = None, opts: LatticeOptions | None = None):
|
|
845
|
+
"""Returns (trimesh.Trimesh, info). With `opts.target_sf` the wall is
|
|
846
|
+
sized by the homogenized analysis (see the module docstring); otherwise
|
|
847
|
+
`fields` is verify_part(..., fields=True)["fields"] for the solid part,
|
|
848
|
+
and without it the lattice is uniform at wall_min. `mesh` is the solid
|
|
849
|
+
to fill: a topology-optimized part, or `envelope_mesh(study)` to keep
|
|
850
|
+
the envelope's outer shape."""
|
|
851
|
+
import manifold3d as m3d
|
|
852
|
+
import trimesh
|
|
853
|
+
from skimage.measure import marching_cubes
|
|
854
|
+
|
|
855
|
+
from .reconstruct import _smooth_union
|
|
856
|
+
from .shapes import manifold_from_trimesh, trimesh_from_manifold
|
|
857
|
+
|
|
858
|
+
o = opts or LatticeOptions()
|
|
859
|
+
process = study.process
|
|
860
|
+
lo, hi = mesh.bounds
|
|
861
|
+
lo = lo - 2.0 * o.wall_max
|
|
862
|
+
hi = hi + 2.0 * o.wall_max
|
|
863
|
+
step = o.step or o.wall_min / 2.5
|
|
864
|
+
step = max(step, (float(np.prod(hi - lo)) / o.max_points) ** (1.0 / 3.0))
|
|
865
|
+
dims = tuple(np.maximum(np.ceil((hi - lo) / step).astype(int), 2))
|
|
866
|
+
d_part = _part_distance(mesh, step, lo, dims)
|
|
867
|
+
inside = d_part < 0
|
|
868
|
+
|
|
869
|
+
idx = np.stack(np.meshgrid(*[np.arange(n) for n in dims], indexing="ij"), -1).reshape(-1, 3)
|
|
870
|
+
pts = lo + (idx + 0.5) * step
|
|
871
|
+
del idx
|
|
872
|
+
flat_in = inside.ravel()
|
|
873
|
+
d_collar = np.full(len(pts), np.inf)
|
|
874
|
+
if o.collar > 0:
|
|
875
|
+
d_collar[flat_in] = _collar_distance(study, pts[flat_in], o.collar)
|
|
876
|
+
d_collar = d_collar.reshape(dims)
|
|
877
|
+
sizing = None
|
|
878
|
+
stress = np.zeros(dims, np.float32)
|
|
879
|
+
if o.target_sf:
|
|
880
|
+
zone = (d_part > -o.skin) | (d_collar < 0)
|
|
881
|
+
rho_c, se_c, mask, r, sizing = _size(study, o, lo, step, inside, zone)
|
|
882
|
+
rho = _to_fine(rho_c, mask, r, dims)
|
|
883
|
+
stress = _to_fine(se_c, mask, r, dims)
|
|
884
|
+
rho_max = float(relative_density(o.kind, o.wall_max, o.cell))
|
|
885
|
+
wall = np.clip(wall_for_density(o.kind, np.minimum(rho, rho_max), o.cell), o.wall_min, o.wall_max)
|
|
886
|
+
d_cells = _signed_distance(rho > 0.5 * (1.0 + rho_max), step)
|
|
887
|
+
util = None
|
|
888
|
+
else:
|
|
889
|
+
util = np.zeros(len(pts))
|
|
890
|
+
if fields is not None:
|
|
891
|
+
from scipy.spatial import cKDTree
|
|
892
|
+
|
|
893
|
+
vm = np.max([c["vonMises"] for c in fields["cases"]], axis=0)
|
|
894
|
+
allow = study.material.strength / o.safety_factor
|
|
895
|
+
tree = cKDTree(fields["nodes"])
|
|
896
|
+
dist, near = tree.query(pts[flat_in], k=4)
|
|
897
|
+
w = 1.0 / np.maximum(dist, 1e-6) ** 2
|
|
898
|
+
util[flat_in] = (w * vm[near]).sum(axis=1) / w.sum(axis=1) / allow
|
|
899
|
+
# wall from utilization, square-root graded so moderate stress already
|
|
900
|
+
# thickens it; solid above solid_above
|
|
901
|
+
u = np.clip(util / o.solid_above, 0.0, 1.0)
|
|
902
|
+
wall = (o.wall_min + (o.wall_max - o.wall_min) * np.sqrt(u)).reshape(dims)
|
|
903
|
+
d_cells = ((o.solid_above - util) * o.cell).reshape(dims)
|
|
904
|
+
stress = (util * study.material.strength / o.safety_factor).reshape(dims)
|
|
905
|
+
# marching cubes reads a 2.5-sample wall about 5% thin: a hair more
|
|
906
|
+
# keeps the thinnest wall at the process minimum
|
|
907
|
+
d_lat = (np.abs(tpms(o.kind, pts, o.cell)) - 0.5 * wall.ravel() - 0.15 * step).reshape(dims)
|
|
908
|
+
del pts
|
|
909
|
+
d_solid = np.minimum(d_cells, d_collar)
|
|
910
|
+
if o.skin > 0:
|
|
911
|
+
d_solid = np.minimum(d_solid, -d_part - o.skin)
|
|
912
|
+
blend = o.blend or o.wall_max
|
|
913
|
+
d = np.maximum(d_part, _smooth_union(d_lat, d_solid, blend))
|
|
914
|
+
# how much of d follows the lattice term (1 in the open lattice, fading
|
|
915
|
+
# through the blend, 0 in solid, skin, pockets and holes): the exact
|
|
916
|
+
# surface below corrects d by this much of the TPMS's own sampling error
|
|
917
|
+
sens = _lattice_share(d_lat, d_solid, d_part, blend) if o.exact_surface else None
|
|
918
|
+
del d_cells
|
|
919
|
+
|
|
920
|
+
# a boss can wall off a scrap of channel next to it: a second round
|
|
921
|
+
# fills or drains whatever the first left
|
|
922
|
+
filled = 0.0
|
|
923
|
+
all_holes = []
|
|
924
|
+
boss = max(o.collar, o.wall_max)
|
|
925
|
+
for _ in range(3):
|
|
926
|
+
pockets, holes = _drains(o, d, d_part, d_collar, stress, lo, step, process)
|
|
927
|
+
if pockets is not None:
|
|
928
|
+
d = np.where(pockets, -0.5 * step, d)
|
|
929
|
+
if sens is not None:
|
|
930
|
+
sens[pockets] = 0.0
|
|
931
|
+
filled += float(pockets.sum() * step ** 3)
|
|
932
|
+
if not holes:
|
|
933
|
+
break
|
|
934
|
+
for a, b, rad, _ in holes:
|
|
935
|
+
bs, hp, shp = _local(lo, step, d.shape, a, b, rad + boss + blend)
|
|
936
|
+
cap = _capsule(hp, a, b, rad).reshape(shp)
|
|
937
|
+
ds = np.minimum(d_solid[bs], cap - boss)
|
|
938
|
+
new = np.maximum(d_part[bs], _smooth_union(d_lat[bs], ds, blend))
|
|
939
|
+
if sens is not None:
|
|
940
|
+
sens[bs] = np.where(new < d[bs], _lattice_share(d_lat[bs], ds, d_part[bs], blend), sens[bs])
|
|
941
|
+
d[bs] = np.minimum(d[bs], new)
|
|
942
|
+
d_solid[bs] = ds
|
|
943
|
+
for a, b, rad, _ in all_holes + holes:
|
|
944
|
+
# through the boss's rounded end and on into the void
|
|
945
|
+
a = a - (b - a) / np.linalg.norm(b - a) * (boss + rad + blend)
|
|
946
|
+
bs, hp, shp = _local(lo, step, d.shape, a, b, rad)
|
|
947
|
+
cut = -_capsule(hp, a, b, rad).reshape(shp)
|
|
948
|
+
if sens is not None:
|
|
949
|
+
sens[bs] = np.where(cut > d[bs], 0.0, sens[bs])
|
|
950
|
+
d[bs] = np.maximum(d[bs], cut)
|
|
951
|
+
all_holes += holes
|
|
952
|
+
holes = all_holes
|
|
953
|
+
holes = [h[3] for h in holes]
|
|
954
|
+
del d_solid
|
|
955
|
+
trapped_vol, trapped_n = _trapped(d, d_part, step)
|
|
956
|
+
|
|
957
|
+
if o.exact_surface:
|
|
958
|
+
lat = _exact_mesh(o, d, d_lat, sens, wall, lo, step)
|
|
959
|
+
else:
|
|
960
|
+
verts, faces, _, _ = marching_cubes(d, 0.0, spacing=(step, step, step))
|
|
961
|
+
verts = verts + lo + 0.5 * step
|
|
962
|
+
lat = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
|
|
963
|
+
del d_lat, sens
|
|
964
|
+
if lat.volume < 0:
|
|
965
|
+
lat.invert()
|
|
966
|
+
part = manifold_from_trimesh(lat)
|
|
967
|
+
solid_vol = float(manifold_from_trimesh(mesh).volume())
|
|
968
|
+
# the user's geometry stays exact: preserved shapes solid, keep-outs clear
|
|
969
|
+
if study.preserve:
|
|
970
|
+
part = part + m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.preserve],
|
|
971
|
+
m3d.OpType.Add)
|
|
972
|
+
part = part ^ manifold_from_trimesh(mesh)
|
|
973
|
+
if study.keep_out:
|
|
974
|
+
part = part - m3d.Manifold.batch_boolean([r.shape.to_manifold() for r in study.keep_out],
|
|
975
|
+
m3d.OpType.Add)
|
|
976
|
+
# Drop lattice fragments the trim cut loose. (decompose() also returns
|
|
977
|
+
# each enclosed cavity, as a negative volume: those stay.)
|
|
978
|
+
pieces = part.decompose()
|
|
979
|
+
big = max(b.volume() for b in pieces)
|
|
980
|
+
loose = [b for b in pieces if 0 < b.volume() < 0.01 * big]
|
|
981
|
+
for b in loose:
|
|
982
|
+
part = part - b
|
|
983
|
+
out = trimesh_from_manifold(part)
|
|
984
|
+
vol = float(part.volume())
|
|
985
|
+
info = {"kind": o.kind, "cell": o.cell, "skin": o.skin, "wallMin": o.wall_min, "wallMax": o.wall_max,
|
|
986
|
+
"step": step, "volume": vol, "solidVolume": solid_vol, "volumeRatio": vol / solid_vol,
|
|
987
|
+
"mass": vol * study.material.density / 1000.0, "graded": fields is not None or bool(o.target_sf),
|
|
988
|
+
"sizing": sizing, "droppedBodies": len(loose),
|
|
989
|
+
"solidFraction": float((util[flat_in] >= o.solid_above).mean()) if util is not None and flat_in.any()
|
|
990
|
+
else (sizing or {}).get("solidCellFraction", 0.0)}
|
|
991
|
+
info["printability"] = _printability(out, d, d_part, lo, step, o, process, holes, filled, trapped_vol, trapped_n)
|
|
992
|
+
# what stays walled in, where powder or resin would be trapped
|
|
993
|
+
info["enclosedVoid"] = trapped_vol
|
|
994
|
+
return out, info
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _lattice_share(d_lat, d_solid, d_part, blend, eps=1e-3):
|
|
998
|
+
"""d(d) / d(d_lat) for d = max(d_part, smooth_union(d_lat, d_solid))."""
|
|
999
|
+
from .reconstruct import _smooth_union
|
|
1000
|
+
|
|
1001
|
+
su = _smooth_union(d_lat, d_solid, blend)
|
|
1002
|
+
share = (_smooth_union(d_lat + eps, d_solid, blend) - su) / eps
|
|
1003
|
+
return np.where(su >= d_part, share, 0.0).astype(np.float32)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _exact_mesh(o, d, d_lat, sens, wall, lo, step):
|
|
1007
|
+
"""The lattice part meshed from its field with the TPMS evaluated
|
|
1008
|
+
exactly: the sampled field d, trilinear between samples, plus `sens`
|
|
1009
|
+
times the TPMS term's own error there (exact sheet minus its trilinear
|
|
1010
|
+
samples, which also drops the samples' 0.15 step allowance for
|
|
1011
|
+
marching cubes reading walls thin). The slow parts (part distance,
|
|
1012
|
+
collars, sizing, drains) stay on the grid; the sheet, which is what a
|
|
1013
|
+
grid gets wrong (facets, walls ~0.03 mm off), comes out on its exact
|
|
1014
|
+
surface, projected by fields.field_mesh."""
|
|
1015
|
+
from .fields import field_mesh
|
|
1016
|
+
|
|
1017
|
+
wall = np.broadcast_to(np.asarray(wall, np.float32), d.shape)
|
|
1018
|
+
grids = [np.asarray(g, np.float32) for g in (d, d_lat, sens, wall)]
|
|
1019
|
+
lo = np.asarray(lo, float)
|
|
1020
|
+
|
|
1021
|
+
def f(P):
|
|
1022
|
+
c = ((P - lo) / step - 0.5).T
|
|
1023
|
+
di, li, si, wi = (ndimage.map_coordinates(g, c, order=1, mode="nearest") for g in grids)
|
|
1024
|
+
le = np.abs(tpms(o.kind, P, o.cell)) - 0.5 * wi
|
|
1025
|
+
return di + si * (le - li)
|
|
1026
|
+
|
|
1027
|
+
hi = lo + (np.array(d.shape) - 0.5) * step
|
|
1028
|
+
return field_mesh(f, lo + 0.5 * step, hi, pitch=step)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _wall_thickness(d, step):
|
|
1032
|
+
"""(p1, p5) wall thickness, mm, from the field itself: twice the depth of
|
|
1033
|
+
the medial ridge, interpolated by the field's own distance there. No ray
|
|
1034
|
+
casts: on a lattice mesh of millions of faces, trimesh's ray fallback
|
|
1035
|
+
(without embree) runs out of memory."""
|
|
1036
|
+
depth = np.maximum(-d, 0.0)
|
|
1037
|
+
ridge = (depth > 0) & (depth >= ndimage.maximum_filter(depth, size=3))
|
|
1038
|
+
vals = 2.0 * depth[ridge] + 0.5 * step
|
|
1039
|
+
if vals.size == 0:
|
|
1040
|
+
return None, None
|
|
1041
|
+
return float(np.percentile(vals, 1)), float(np.percentile(vals, 5))
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _printability(mesh, d, dp, lo, step, o, process, holes, filled, trapped_vol, trapped_n):
|
|
1045
|
+
p1, p5 = _wall_thickness(d, step)
|
|
1046
|
+
min_feature = process.min_feature if process is not None else 0.8
|
|
1047
|
+
rep = {"process": process.kind if process is not None else None, "minFeature": min_feature,
|
|
1048
|
+
"wallP1": p1, "wallP5": p5, "wallOk": p1 is not None and p1 >= 0.9 * min_feature,
|
|
1049
|
+
"drainHoles": holes, "filledPocketVolume": filled,
|
|
1050
|
+
"trappedVolume": trapped_vol, "trappedVoids": trapped_n}
|
|
1051
|
+
if process is None or process.kind != "fdm":
|
|
1052
|
+
rep["drainOk"] = trapped_vol < 1.0
|
|
1053
|
+
# the lattice's own ceilings and the skin's over it (inside the part): FDM
|
|
1054
|
+
# bridges these, so report how much faces down past the 45 degree limit
|
|
1055
|
+
if process is not None and process.build is not None:
|
|
1056
|
+
axis, sign = process.build
|
|
1057
|
+
up = np.zeros(3)
|
|
1058
|
+
up[axis] = sign
|
|
1059
|
+
c = mesh.triangles_center
|
|
1060
|
+
ijk = np.clip(((c - lo) / step).astype(int), 0, np.array(dp.shape) - 1)
|
|
1061
|
+
interior = dp[tuple(ijk.T)] < -(0.5 * o.skin + step)
|
|
1062
|
+
down = -(mesh.face_normals @ up) > np.cos(np.radians(40.0))
|
|
1063
|
+
a = mesh.area_faces
|
|
1064
|
+
rep["internalOverhangFraction"] = float(a[interior & down].sum() / max(a[interior].sum(), 1e-9))
|
|
1065
|
+
return rep
|