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/reconstruct.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"""
|
|
2
|
+
From a density field to a part you can print.
|
|
3
|
+
|
|
4
|
+
1. Smooth the projected density on a 2x finer grid and take the 0.5
|
|
5
|
+
isosurface (marching cubes). Upsampling first is what turns voxel steps
|
|
6
|
+
into the organic surface; smoothing the field (not the mesh) keeps it
|
|
7
|
+
watertight. Before the blur, along members (rods) the surface is
|
|
8
|
+
averaged along the member's own axis (_axial_smooth), which takes out
|
|
9
|
+
the grid's staircase on members tilted a little off the grid.
|
|
10
|
+
2. Drop specks: components too small to matter are optimizer noise.
|
|
11
|
+
3. Exact booleans with manifold3d: union the preserved primitives, then
|
|
12
|
+
subtract the keep-outs. The isosurface can only approximate a bore to
|
|
13
|
+
within a cell; after the subtract, a 22mm bearing seat is 22mm.
|
|
14
|
+
4. Keep the bodies connected to the preserved geometry (and the largest
|
|
15
|
+
body), decimate to a triangle budget, and return a closed manifold mesh.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from scipy import ndimage
|
|
21
|
+
|
|
22
|
+
from .domain import Domain
|
|
23
|
+
from .shapes import manifold_from_trimesh, trimesh_from_manifold
|
|
24
|
+
from .spec import Study
|
|
25
|
+
|
|
26
|
+
__all__ = ["reconstruct", "ReconstructOptions"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReconstructOptions:
|
|
30
|
+
def __init__(self, upsample=3, smooth_sigma=0.9, level=0.5, max_triangles=250_000,
|
|
31
|
+
min_body_fraction=0.002, taubin_iterations=25, blend=None, blend_k=0.8, fillet=True, seat=True,
|
|
32
|
+
close=None, axial=True, axial_debias=None):
|
|
33
|
+
self.upsample = upsample # field refinement before meshing
|
|
34
|
+
self.smooth_sigma = smooth_sigma # in fine cells
|
|
35
|
+
self.level = level
|
|
36
|
+
self.max_triangles = max_triangles
|
|
37
|
+
self.min_body_fraction = min_body_fraction
|
|
38
|
+
# Volume-preserving Taubin smoothing of the organic surface, before
|
|
39
|
+
# the exact booleans (which then restore every crisp face and bore).
|
|
40
|
+
self.taubin_iterations = taubin_iterations
|
|
41
|
+
# Fillet between the organic body and preserved geometry: its radius
|
|
42
|
+
# in mm (None: 1.5 x minMember, at least 3), and the smooth-max
|
|
43
|
+
# strength of the older density blend (fillet=False). At 3 mm the
|
|
44
|
+
# motor mount's pads, which stand 1 mm proud of the plate round them,
|
|
45
|
+
# kept 108 mm of crease; at 4.5 (its minMember is 3) about 4.
|
|
46
|
+
self.blend = blend
|
|
47
|
+
self.blend_k = blend_k
|
|
48
|
+
# Fillet in distance space (True) or the older density smooth max.
|
|
49
|
+
self.fillet = fillet
|
|
50
|
+
# Move material at flush faces onto the face (_seat_on_flush_faces).
|
|
51
|
+
self.seat = seat
|
|
52
|
+
# Round every concave edge of the organic body itself, not only where
|
|
53
|
+
# it meets preserved geometry: a morphological closing in distance
|
|
54
|
+
# space by this radius in mm (None: half the minMember, at least 1;
|
|
55
|
+
# 0 turns it off). It only ever adds material, into corners and into
|
|
56
|
+
# slots narrower than twice the radius (below the minimum member,
|
|
57
|
+
# so unprintable anyway).
|
|
58
|
+
self.close = close
|
|
59
|
+
# Average the field along each member's own axis before smoothing
|
|
60
|
+
# (_axial_smooth): a member tilted a few degrees off the grid
|
|
61
|
+
# otherwise keeps the grid's staircase as a row of bumps and necks.
|
|
62
|
+
self.axial = axial
|
|
63
|
+
# Debias that average (see _axial_smooth) so thin members keep their
|
|
64
|
+
# volume; None follows resolution.keepThinMembers (off by default).
|
|
65
|
+
self.axial_debias = axial_debias
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _smooth_max(a, b, k):
|
|
69
|
+
"""Polynomial smooth maximum: max(a, b) plus a bump of up to k/4 where the
|
|
70
|
+
two are close. On density fields this is a fillet where they meet."""
|
|
71
|
+
h = np.maximum(k - np.abs(a - b), 0.0) / k
|
|
72
|
+
return np.maximum(a, b) + h * h * k * 0.25
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _smooth_min(a, b, k):
|
|
76
|
+
h = np.maximum(k - np.abs(a - b), 0.0) / k
|
|
77
|
+
return np.minimum(a, b) - h * h * k * 0.25
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _smooth_union(a, b, k):
|
|
81
|
+
"""Polynomial smooth minimum of two signed distances: their union with a
|
|
82
|
+
fillet where the surfaces meet. The fillet's sag from the sharp corner is
|
|
83
|
+
k/4; at a right angle it reaches about k (1 - 1/sqrt(2)) out along each
|
|
84
|
+
surface, roughly a round of radius k / (2 (sqrt(2) - 1)) = 1.2 k."""
|
|
85
|
+
h = np.maximum(k - np.abs(a - b), 0.0) / k
|
|
86
|
+
return np.minimum(a, b) - h * h * k * 0.25
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _field_distance(fine, step, level=0.5):
|
|
90
|
+
"""Signed distance (mm, negative inside) to the `level` surface of a
|
|
91
|
+
smooth density field: exact distance transforms away from the surface,
|
|
92
|
+
and the field's own gradient within a couple of cells of it, where a
|
|
93
|
+
distance transform would only resolve whole cells."""
|
|
94
|
+
inside = fine >= level
|
|
95
|
+
d = (ndimage.distance_transform_edt(~inside) - ndimage.distance_transform_edt(inside)) * step
|
|
96
|
+
d += np.where(inside, 0.5 * step, -0.5 * step)
|
|
97
|
+
g = np.linalg.norm(np.stack(np.gradient(fine, step)), axis=0)
|
|
98
|
+
near = (np.abs(fine - level) < 0.45) & (g > 1e-6)
|
|
99
|
+
lin = (level - fine) / np.maximum(g, 1e-6)
|
|
100
|
+
ok = near & (np.abs(lin) < 2.0 * step)
|
|
101
|
+
d[ok] = lin[ok]
|
|
102
|
+
return d
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _GridSDF:
|
|
106
|
+
"""The primitives' signed distances on one fine grid, each group (the
|
|
107
|
+
flush faces, preserved shapes, design space, keep-outs) evaluated once
|
|
108
|
+
and kept: _isosurface asked for the same ones up to three times, and
|
|
109
|
+
each was a min over a stacked (shapes, cells) array. A running minimum
|
|
110
|
+
gives the same values."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, shape, origin, step):
|
|
113
|
+
self.shape, self.origin, self.step = tuple(shape), origin, step
|
|
114
|
+
self._pts = None
|
|
115
|
+
self._min = {}
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def pts(self):
|
|
119
|
+
if self._pts is None:
|
|
120
|
+
idx = np.stack(np.meshgrid(*[np.arange(n) for n in self.shape], indexing="ij"), -1).reshape(-1, 3)
|
|
121
|
+
self._pts = self.origin + idx * self.step
|
|
122
|
+
return self._pts
|
|
123
|
+
|
|
124
|
+
def min(self, shapes):
|
|
125
|
+
key = tuple(id(s) for s in shapes)
|
|
126
|
+
if key not in self._min:
|
|
127
|
+
self._min[key] = _min_sdf(shapes, self.pts)
|
|
128
|
+
return self._min[key]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _min_sdf(shapes, pts):
|
|
132
|
+
"""min over `shapes` of their signed distance at `pts`."""
|
|
133
|
+
out = None
|
|
134
|
+
for sh in shapes:
|
|
135
|
+
d = sh.sdf(pts)
|
|
136
|
+
out = np.array(d, float) if out is None else np.minimum(out, d, out=out)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _seat_on_flush_faces(fine, domain: Domain, upsample: int, pad: int, flush, design, grid=None):
|
|
141
|
+
"""Move material at a flush face (a wall the part is held against) onto
|
|
142
|
+
the face itself.
|
|
143
|
+
|
|
144
|
+
The grid is centered on the design space, for exact symmetry, so along an
|
|
145
|
+
axis whose length is not a whole number of cells the outer cell layer
|
|
146
|
+
hangs past each end face by up to half a cell. A plate the optimizer
|
|
147
|
+
builds against a wall occupies that layer: it was analyzed as a full cell
|
|
148
|
+
thick, but the part is trimmed at the real face, so it was delivered
|
|
149
|
+
thinner by the overhang (the printed hook's wall plate, 1.46 mm p5 against
|
|
150
|
+
a 1.95 mm cell; the wall bracket's seat). Near a box face that a flush
|
|
151
|
+
region touches, the field is translated inward by that face's overhang,
|
|
152
|
+
so the plate keeps its analyzed thickness from the face in, easing back
|
|
153
|
+
to the field as it was over the next cells."""
|
|
154
|
+
from .shapes import Box
|
|
155
|
+
|
|
156
|
+
step = domain.pitch / upsample
|
|
157
|
+
origin = domain.origin - pad * domain.pitch + 0.5 * step
|
|
158
|
+
shape = fine.shape
|
|
159
|
+
grid = grid if grid is not None else _GridSDF(shape, origin, step)
|
|
160
|
+
pts = grid.pts
|
|
161
|
+
near = None
|
|
162
|
+
out = fine
|
|
163
|
+
grid_lo = domain.origin
|
|
164
|
+
grid_hi = domain.origin + np.asarray(domain.dims) * domain.pitch
|
|
165
|
+
for b in design:
|
|
166
|
+
if not isinstance(b, Box):
|
|
167
|
+
continue
|
|
168
|
+
for a in range(3):
|
|
169
|
+
for sign, face, hang in ((1, b.lo[a], b.lo[a] - grid_lo[a]), (-1, b.hi[a], grid_hi[a] - b.hi[a])):
|
|
170
|
+
if hang < 0.5 * step:
|
|
171
|
+
continue
|
|
172
|
+
if near is None:
|
|
173
|
+
near = grid.min(flush) < 1.5 * domain.pitch
|
|
174
|
+
inward = sign * (pts[:, a] - face) # mm inside the face
|
|
175
|
+
w = np.clip((2.5 * domain.pitch - inward) / (1.5 * domain.pitch), 0.0, 1.0) * near
|
|
176
|
+
if not w.any():
|
|
177
|
+
continue
|
|
178
|
+
shift = np.zeros(3)
|
|
179
|
+
shift[a] = sign * hang / step
|
|
180
|
+
moved = ndimage.shift(out, shift, order=1, mode="nearest")
|
|
181
|
+
w = w.reshape(shape)
|
|
182
|
+
out = w * moved + (1.0 - w) * out
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
FINE_CELLS = 8e6
|
|
187
|
+
|
|
188
|
+
# _axial_smooth: the Hessian's scale and the axis tensor's spread (Gaussian
|
|
189
|
+
# sigmas in grid cells), the smoothing length along the axis (sigma, cells),
|
|
190
|
+
# the coherence range over which it fades in, and the bilateral range (cells):
|
|
191
|
+
# samples whose surface lies further than that from the point's own are
|
|
192
|
+
# another feature (a branch, a member's end), not a step of the staircase.
|
|
193
|
+
AXIAL_SCALE = 1.5
|
|
194
|
+
AXIAL_SPREAD = 1.5
|
|
195
|
+
AXIAL_LENGTH = 3.0
|
|
196
|
+
AXIAL_COHERENCE = (0.5, 0.8)
|
|
197
|
+
AXIAL_RANGE = 1.5
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _rod_axes(g, scale=AXIAL_SCALE, spread=AXIAL_SPREAD):
|
|
201
|
+
"""Where the density `g` has rods, and along which axis: the tensor
|
|
202
|
+
sum of s e e^T spread over `spread` cells, with e the Hessian's flat
|
|
203
|
+
direction at `scale` and s its rod strength (the smaller of the two
|
|
204
|
+
strong curvatures, both negative: a ridge line, not a plate or a blob)."""
|
|
205
|
+
H = np.empty(g.shape + (3, 3))
|
|
206
|
+
for i in range(3):
|
|
207
|
+
for j in range(i, 3):
|
|
208
|
+
order = [0, 0, 0]
|
|
209
|
+
order[i] += 1
|
|
210
|
+
order[j] += 1
|
|
211
|
+
H[..., i, j] = H[..., j, i] = ndimage.gaussian_filter(g, scale, order=order)
|
|
212
|
+
lam, vec = np.linalg.eigh(H)
|
|
213
|
+
del H
|
|
214
|
+
o = np.argsort(np.abs(lam), axis=-1)
|
|
215
|
+
lam = np.take_along_axis(lam, o, -1)
|
|
216
|
+
e = np.take_along_axis(vec, o[..., None, :], -1)[..., :, 0]
|
|
217
|
+
del vec
|
|
218
|
+
l1, l2, l3 = lam[..., 0], lam[..., 1], lam[..., 2]
|
|
219
|
+
s = np.where((l2 < 0) & (l3 < 0), np.clip(-l2 - np.abs(l1), 0.0, None), 0.0)
|
|
220
|
+
del lam
|
|
221
|
+
T = {}
|
|
222
|
+
for i in range(3):
|
|
223
|
+
for j in range(i, 3):
|
|
224
|
+
T[i, j] = ndimage.gaussian_filter(s * e[..., i] * e[..., j], spread)
|
|
225
|
+
return T
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _axial_smooth(fine, coarse, upsample, length=AXIAL_LENGTH, coherence=AXIAL_COHERENCE, rng=AXIAL_RANGE,
|
|
229
|
+
reach=2.5, samples=21, debias=False):
|
|
230
|
+
"""Average the surface's position along the local member axis.
|
|
231
|
+
|
|
232
|
+
A member tilted slightly off the grid is voxelized as a staircase: its
|
|
233
|
+
top and bottom step a cell at a time, a cell / tan(tilt) apart along it,
|
|
234
|
+
so its section swings by about a cell, out of phase top and bottom. The
|
|
235
|
+
isotropic upsample and blur below only round each step off, and the part
|
|
236
|
+
came out with a row of bumps and necks along every long stem (GRIP's
|
|
237
|
+
upper arm: +-0.6 mm on an 11 mm stem every 19 mm, about 9% less bending
|
|
238
|
+
stiffness than the same mass at constant section, and the stress
|
|
239
|
+
concentrated at every neck). The steps carry no design intent: the
|
|
240
|
+
optimizer's filter blurs over more than a cell, and its sensitivities saw
|
|
241
|
+
the staircase only as the grid's error.
|
|
242
|
+
|
|
243
|
+
Where the material round a point is a rod with one clear axis
|
|
244
|
+
(_rod_axes: coherent, and strong against the part's own rods), the
|
|
245
|
+
field's signed distance there is replaced by its Gaussian average along
|
|
246
|
+
that axis over `length` cells, and the field's 0.5 level moved to the
|
|
247
|
+
averaged surface (averaging the density instead shrank thin members,
|
|
248
|
+
whose upsampled density never reaches 1). The average is bilateral:
|
|
249
|
+
samples whose distance differs from the point's by much more than a
|
|
250
|
+
step (`rng` cells) count less, so a branch leaving the member, or the
|
|
251
|
+
member's own end, is not smeared along it. Plates, blobs and junctions
|
|
252
|
+
of several directions keep the field as it was; only voxels within a
|
|
253
|
+
cell and a half of the surface change.
|
|
254
|
+
|
|
255
|
+
Averaging a round member's distance along a line that is not exactly
|
|
256
|
+
its axis is biased outward: the distance to a convex surface is convex,
|
|
257
|
+
so every sample off the axis reads further out than the point (about
|
|
258
|
+
offset^2 / 2r, so the thinner the member the worse), and the member
|
|
259
|
+
shrank. Thin members lost the most: a graded rod two cells across lost
|
|
260
|
+
15% of its volume, 1.5 cells 38%, and the MJF wrist link (sub-cell
|
|
261
|
+
struts on 2.44 mm cells) meshed 2-4% light at every density. The bias
|
|
262
|
+
is a steady offset where the staircase is a ripple, so the average is
|
|
263
|
+
taken twice and extrapolated back (2 A d - A A d, as Taubin's inflate
|
|
264
|
+
step undoes Laplacian shrinkage): the bias cancels to first order and
|
|
265
|
+
the ripple, already nearly gone after one pass, stays small. The rods
|
|
266
|
+
keep their volume to 0.5-2%.
|
|
267
|
+
|
|
268
|
+
It is opt-in (debias, on with resolution.keepThinMembers), like the
|
|
269
|
+
volume-keeping surface smoothing, and for the same reason: the shrinking
|
|
270
|
+
one-pass average reads better on the evals. Debiased on everywhere, the
|
|
271
|
+
full tier went mbb-beam 13.8 -> 15.0 g at target, motor-mount 19.8 ->
|
|
272
|
+
22.1, hook 23.9 -> 24.8, wrist yoke 16.7 -> 17.2: those parts verify
|
|
273
|
+
lighter with their thin members slightly shrunk. The wrist link went
|
|
274
|
+
34.2 -> 33.2 (31.4 with the pass off), but its verified SF swings 2-3x
|
|
275
|
+
between near-identical meshes of the same density (its peak sits on
|
|
276
|
+
sub-cell struts), so that case's 31 vs 34 g is mostly search noise."""
|
|
277
|
+
g = np.asarray(coarse, float)
|
|
278
|
+
T = _rod_axes(g)
|
|
279
|
+
size = 2 * int(np.ceil(1.5 * upsample)) + 1
|
|
280
|
+
band = (ndimage.maximum_filter(fine, size=size) > 0.02) & (ndimage.minimum_filter(fine, size=size) < 0.98)
|
|
281
|
+
fi = np.argwhere(band)
|
|
282
|
+
del band
|
|
283
|
+
if not len(fi):
|
|
284
|
+
return fine
|
|
285
|
+
# fine index i sits at coarse index (i + 0.5) / upsample - 0.5 (see _isosurface)
|
|
286
|
+
cc = ((fi + 0.5) / upsample - 0.5).T
|
|
287
|
+
t = np.empty((len(fi), 3, 3))
|
|
288
|
+
for (i, j), a in T.items():
|
|
289
|
+
t[:, i, j] = t[:, j, i] = ndimage.map_coordinates(a, cc, order=1, mode="nearest")
|
|
290
|
+
del cc
|
|
291
|
+
mu, vec = np.linalg.eigh(t)
|
|
292
|
+
del t
|
|
293
|
+
top = np.maximum(mu[:, 2], 1e-30)
|
|
294
|
+
coh = (mu[:, 2] - mu[:, 1]) / top
|
|
295
|
+
# strength against the part's own rods, so a design's scale does not matter
|
|
296
|
+
ref = np.percentile(top, 90)
|
|
297
|
+
lo, hi = coherence
|
|
298
|
+
w = np.clip((coh - lo) / (hi - lo), 0.0, 1.0) * np.clip(2.0 * top / max(ref, 1e-30), 0.0, 1.0)
|
|
299
|
+
keep = w > 0.0
|
|
300
|
+
fi, axis, w = fi[keep], vec[keep, :, 2].T, w[keep]
|
|
301
|
+
if not len(fi):
|
|
302
|
+
return fine
|
|
303
|
+
z = np.linspace(-reach, reach, samples)
|
|
304
|
+
gz = np.exp(-0.5 * z * z)
|
|
305
|
+
base = fi.T.astype(float)
|
|
306
|
+
dist = _field_distance(fine, 1.0, 0.5) # in fine cells
|
|
307
|
+
i, j, k = fi.T
|
|
308
|
+
d0 = dist[i, j, k]
|
|
309
|
+
r2 = (rng * upsample) ** 2
|
|
310
|
+
|
|
311
|
+
def along(field, ref):
|
|
312
|
+
num = np.zeros(len(fi))
|
|
313
|
+
den = np.zeros(len(fi))
|
|
314
|
+
for zk, gk in zip(z, gz):
|
|
315
|
+
dk = ndimage.map_coordinates(field, base + (w * (zk * length * upsample)) * axis, order=1, mode="nearest")
|
|
316
|
+
wk = gk * np.exp(-0.5 * (dk - ref) ** 2 / r2)
|
|
317
|
+
num += wk * dk
|
|
318
|
+
den += wk
|
|
319
|
+
return num / den
|
|
320
|
+
|
|
321
|
+
avg = along(dist, d0)
|
|
322
|
+
if debias:
|
|
323
|
+
A = dist.copy()
|
|
324
|
+
A[i, j, k] = avg
|
|
325
|
+
avg2 = along(A, avg)
|
|
326
|
+
del A
|
|
327
|
+
avg = 2.0 * avg - avg2
|
|
328
|
+
# move the field's 0.5 level by the change in distance, keeping its ramp
|
|
329
|
+
grad = np.linalg.norm(np.stack(np.gradient(fine)), axis=0)[i, j, k]
|
|
330
|
+
out = fine.copy()
|
|
331
|
+
out[i, j, k] = np.clip(fine[i, j, k] + (d0 - avg) * np.maximum(grad, 0.25), 0.0, 1.0)
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _isosurface(domain: Domain, xphys: np.ndarray, o: ReconstructOptions, keep_out=(), min_member=None,
|
|
336
|
+
flush=(), preserve=(), design=()):
|
|
337
|
+
import trimesh
|
|
338
|
+
from skimage.measure import marching_cubes
|
|
339
|
+
|
|
340
|
+
grid = domain.full_grid(xphys)
|
|
341
|
+
pad = 3
|
|
342
|
+
# Pad with void, so material that reaches the design space's boundary
|
|
343
|
+
# rounds off just inside it: the exact intersection with the design
|
|
344
|
+
# space then has nothing to cut, and no flat sawn-off faces appear where
|
|
345
|
+
# the box happened to end (GRIP's wrist yoke had several). Only where a
|
|
346
|
+
# part must sit flush against something (`flush`: its supports, a wall)
|
|
347
|
+
# is the edge replicated, so the material runs on through the boundary
|
|
348
|
+
# and the intersection cuts it off flat there, on purpose (the printed
|
|
349
|
+
# hook's wall face). Two cells of that, then one of void so marching
|
|
350
|
+
# cubes still closes the surface.
|
|
351
|
+
padded = np.pad(grid, pad)
|
|
352
|
+
if flush:
|
|
353
|
+
edge = np.pad(np.pad(grid, pad - 1, mode="edge"), 1)
|
|
354
|
+
idx = np.stack(np.meshgrid(*[np.arange(n) for n in padded.shape], indexing="ij"), -1).reshape(-1, 3)
|
|
355
|
+
centers = domain.origin + (idx - pad + 0.5) * domain.pitch
|
|
356
|
+
near = (_min_sdf(flush, centers) < 1.5 * domain.pitch).reshape(padded.shape)
|
|
357
|
+
padded = np.where(near, edge, padded)
|
|
358
|
+
grid = padded
|
|
359
|
+
# Cubic upsampling: linear interpolation of a voxel field leaves the
|
|
360
|
+
# staircase in the isosurface as faceted terraces.
|
|
361
|
+
fine = np.clip(ndimage.zoom(grid, o.upsample, order=3, mode="nearest", grid_mode=True), 0.0, 1.0)
|
|
362
|
+
# the fine grid's cell centers (see `origin` below), and the shapes'
|
|
363
|
+
# distances on them, computed once for all the steps that follow
|
|
364
|
+
sdfs = _GridSDF(fine.shape, domain.origin - pad * domain.pitch + 0.5 * (domain.pitch / o.upsample),
|
|
365
|
+
domain.pitch / o.upsample)
|
|
366
|
+
if flush and design and o.seat:
|
|
367
|
+
fine = _seat_on_flush_faces(fine, domain, o.upsample, pad, flush, design, grid=sdfs)
|
|
368
|
+
if o.axial:
|
|
369
|
+
fine = _axial_smooth(fine, grid, o.upsample, debias=bool(o.axial_debias))
|
|
370
|
+
if o.smooth_sigma > 0:
|
|
371
|
+
fine = ndimage.gaussian_filter(fine, o.smooth_sigma)
|
|
372
|
+
if min_member:
|
|
373
|
+
# Morphological opening with a ball of the minimum member's diameter:
|
|
374
|
+
# anything thinner than that cannot fit the ball and goes. Smoothing a
|
|
375
|
+
# member that ends inside the design space tapers it to a knife edge,
|
|
376
|
+
# and that sliver is both unprintable and a stress raiser that grew
|
|
377
|
+
# with every refinement of the verification grid (14 -> 58 MPa on the
|
|
378
|
+
# arm link at a flange tip).
|
|
379
|
+
step_ = domain.pitch / o.upsample
|
|
380
|
+
r = 0.5 * float(min_member) / step_
|
|
381
|
+
if r >= 1.0:
|
|
382
|
+
k = int(np.ceil(r))
|
|
383
|
+
zz, yy, xx = np.mgrid[-k:k + 1, -k:k + 1, -k:k + 1]
|
|
384
|
+
ball = (xx * xx + yy * yy + zz * zz) <= r * r
|
|
385
|
+
fine = ndimage.grey_opening(fine, footprint=ball)
|
|
386
|
+
step = domain.pitch / o.upsample
|
|
387
|
+
# zoom(grid_mode=True) maps fine cell centers to coarse cell centers, so
|
|
388
|
+
# fine index i sits at coarse-cell coordinate (i + 0.5) / upsample - 0.5.
|
|
389
|
+
origin = domain.origin - pad * domain.pitch + 0.5 * step
|
|
390
|
+
if preserve and o.blend > 0 and not o.fillet:
|
|
391
|
+
# Fillet the organic body into the preserved geometry: the preserved
|
|
392
|
+
# shapes as a density field that crosses 0.5 at their surface and
|
|
393
|
+
# ramps over `blend` mm, smooth-max'd into the optimized field. Where
|
|
394
|
+
# a member meets a boss the bump fills the crease; elsewhere the
|
|
395
|
+
# preserve field stays under 0.5 and changes nothing. (The exact union
|
|
396
|
+
# in reconstruct() still restores the crisp faces and bores.)
|
|
397
|
+
sd = sdfs.min(preserve).reshape(fine.shape)
|
|
398
|
+
pf = np.clip(0.5 - sd / (2.0 * o.blend), 0.0, 1.0)
|
|
399
|
+
fine = _smooth_max(fine, pf, o.blend_k)
|
|
400
|
+
if design:
|
|
401
|
+
# Round material off where it meets the design space's boundary: a
|
|
402
|
+
# smooth minimum with the envelope's own density field, which is 0.5
|
|
403
|
+
# on the boundary and 1 a cell inside it. Material that runs into the
|
|
404
|
+
# boundary ends there with rounded edges instead of a sawn-off face
|
|
405
|
+
# with sharp ones; material lying along it keeps its full thickness.
|
|
406
|
+
# (A multiplicative fade, tried first, thinned every member lying on
|
|
407
|
+
# a boundary face by half a cell: the arm link's flange plates went
|
|
408
|
+
# from 3.4 to 1.5 mm and failed the member check.)
|
|
409
|
+
sd = sdfs.min(design)
|
|
410
|
+
env = np.clip(0.5 - sd / (2.0 * domain.pitch), 0.0, 1.0)
|
|
411
|
+
if flush:
|
|
412
|
+
near = sdfs.min(flush) < 1.5 * domain.pitch
|
|
413
|
+
env[near] = 1.0
|
|
414
|
+
if preserve and o.fillet and o.blend > 0:
|
|
415
|
+
# Not round a preserved shape either: where a boss runs the full
|
|
416
|
+
# height of the design space, material beside it rounded off
|
|
417
|
+
# just under the boss's crisp end face and left a lip all round
|
|
418
|
+
# its rim (the bell crank's bosses, 40 mm of crease). Within
|
|
419
|
+
# `blend` of preserved geometry the material runs on to the
|
|
420
|
+
# boundary and ends flush with the boss's face, easing back to
|
|
421
|
+
# the rounding further out.
|
|
422
|
+
dp = sdfs.min(preserve)
|
|
423
|
+
env = np.maximum(env, np.clip(2.0 - dp / o.blend, 0.0, 1.0))
|
|
424
|
+
fine = _smooth_min(fine, env.reshape(fine.shape), 0.5)
|
|
425
|
+
if preserve and o.blend > 0 and o.fillet:
|
|
426
|
+
# (after the boundary rounding: a fillet made before it was rounded
|
|
427
|
+
# away again where a member's top met a full-height boss, bell crank)
|
|
428
|
+
# Fillet the organic body into the preserved geometry in distance
|
|
429
|
+
# space: the organic surface and the preserved shapes as signed
|
|
430
|
+
# distances, joined by a smooth union of radius about `blend` mm, then
|
|
431
|
+
# back to a density with its 0.5 level on the blended surface. A
|
|
432
|
+
# density-space smooth max (fillet=False, above) only bumps the
|
|
433
|
+
# field by k/4 within a cell or two of the junction, so members still
|
|
434
|
+
# met bosses in a hard crease (GRIP's wrist yoke, QA 10/28). The
|
|
435
|
+
# exact union in reconstruct() then adds the preserved shapes, which
|
|
436
|
+
# the fillet meets tangentially, so it leaves no crease of its own.
|
|
437
|
+
sd = sdfs.min(preserve).reshape(fine.shape)
|
|
438
|
+
k = o.blend / 1.2
|
|
439
|
+
du = _smooth_union(_field_distance(fine, step, o.level), sd, k)
|
|
440
|
+
# back to density, over the same width the smoothing gave the field
|
|
441
|
+
width = 2.0 * step
|
|
442
|
+
filleted = np.clip(o.level - du / (2.0 * width), 0.0, 1.0)
|
|
443
|
+
if design:
|
|
444
|
+
# after the boundary rounding, and never past the boundary itself
|
|
445
|
+
filleted = np.minimum(filleted, env.reshape(fine.shape))
|
|
446
|
+
fine = np.maximum(fine, filleted)
|
|
447
|
+
if o.fillet and o.close and o.close > 0:
|
|
448
|
+
# Closing: grow the surface out by `close`, re-distance, and shrink it
|
|
449
|
+
# back. Where the body is convex or flat that returns it unchanged;
|
|
450
|
+
# into a concave edge between two members it leaves a fillet of that
|
|
451
|
+
# radius. Struts meeting at a sharp angle otherwise keep a crease the
|
|
452
|
+
# preserve fillet never sees (GRIP's upper arm, 51 mm of it once its
|
|
453
|
+
# thrust disk was no longer pinned solid).
|
|
454
|
+
grown = _field_distance(fine, step, o.level) - o.close < 0
|
|
455
|
+
d2 = (ndimage.distance_transform_edt(~grown) - ndimage.distance_transform_edt(grown)) * step
|
|
456
|
+
d2 += np.where(grown, 0.5 * step, -0.5 * step)
|
|
457
|
+
# the re-distance is exact only to the grid's cells: smooth it over a
|
|
458
|
+
# cell, or its staircase lands just outside the body as ridges
|
|
459
|
+
closed = ndimage.gaussian_filter(np.clip(o.level - (d2 + o.close) / (4.0 * step), 0.0, 1.0), 1.0)
|
|
460
|
+
if design:
|
|
461
|
+
closed = np.minimum(closed, env.reshape(fine.shape))
|
|
462
|
+
fine = np.maximum(fine, closed)
|
|
463
|
+
if keep_out:
|
|
464
|
+
# Fade the field to zero at every keep-out surface. Smoothing and
|
|
465
|
+
# upsampling otherwise carry material across a keep-out (a cable
|
|
466
|
+
# channel through the middle of a link), and the exact subtract
|
|
467
|
+
# afterwards leaves a skin a fraction of a millimetre thick wrapped
|
|
468
|
+
# round it: unprintable, and found by the eval suite's member check.
|
|
469
|
+
clearance = 0.5 * domain.pitch
|
|
470
|
+
d = sdfs.min(keep_out)
|
|
471
|
+
fade = np.clip(d / clearance, 0.0, 1.0)
|
|
472
|
+
if preserve and o.fillet and o.blend > 0:
|
|
473
|
+
# ...except beside preserved geometry: a pad that bears on a
|
|
474
|
+
# keep-out (a motor face) ends exactly at it, and the faded
|
|
475
|
+
# material round it ended a clearance short, leaving the pad's
|
|
476
|
+
# end standing proud in a step all round (the motor mount's
|
|
477
|
+
# pads and pilot ring). There the exact subtract does the cut.
|
|
478
|
+
dp = sdfs.min(preserve)
|
|
479
|
+
fade = np.maximum(fade, np.clip(2.0 - dp / o.blend, 0.0, 1.0))
|
|
480
|
+
fine = fine * fade.reshape(fine.shape)
|
|
481
|
+
if fine.max() < o.level:
|
|
482
|
+
raise ValueError("the optimized density never reaches solid; nothing to mesh")
|
|
483
|
+
# One more layer of void all round: marching cubes leaves the surface open
|
|
484
|
+
# wherever the field is solid on the array's edge, and smoothing, the
|
|
485
|
+
# fillet and the closing above can carry it there through the single
|
|
486
|
+
# void layer of padding. Unseen below ~100k cells; at 845k (0.53 mm
|
|
487
|
+
# cells) the arm link's flush faces came out with 1700 open edges and
|
|
488
|
+
# manifold3d refused the mesh. Where the edge was void this changes
|
|
489
|
+
# nothing.
|
|
490
|
+
fine = np.pad(fine, 1)
|
|
491
|
+
origin = origin - step
|
|
492
|
+
verts, faces, _, _ = marching_cubes(fine, o.level, spacing=(step, step, step))
|
|
493
|
+
verts = verts + origin
|
|
494
|
+
mesh = trimesh.Trimesh(verts, faces[:, ::-1], process=True)
|
|
495
|
+
if mesh.volume < 0:
|
|
496
|
+
mesh.invert()
|
|
497
|
+
return mesh
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _flat_faces(shape):
|
|
501
|
+
"""The flat faces of a preserved shape, as (point, outward normal,
|
|
502
|
+
lateral) with lateral(p) the distance from p's projection to the face
|
|
503
|
+
(0 on it). Box faces and cylinder end caps; anything else has none."""
|
|
504
|
+
from .shapes import Box, Cylinder
|
|
505
|
+
|
|
506
|
+
out = []
|
|
507
|
+
if isinstance(shape, Cylinder):
|
|
508
|
+
def lateral(p, sh=shape):
|
|
509
|
+
d = p - sh.p0
|
|
510
|
+
radial = np.linalg.norm(d - (d @ sh.axis)[:, None] * sh.axis, axis=1)
|
|
511
|
+
return np.maximum(np.maximum(radial - sh.r, sh.r_inner - radial), 0.0)
|
|
512
|
+
out += [(shape.p0, -shape.axis, lateral), (shape.p1, shape.axis, lateral)]
|
|
513
|
+
elif isinstance(shape, Box):
|
|
514
|
+
for a in range(3):
|
|
515
|
+
others = [i for i in range(3) if i != a]
|
|
516
|
+
def lateral(p, sh=shape, o=others):
|
|
517
|
+
q = p[:, o]
|
|
518
|
+
return np.linalg.norm(np.maximum(np.maximum(sh.lo[o] - q, q - sh.hi[o]), 0.0), axis=1)
|
|
519
|
+
for sign, at in ((-1.0, shape.lo), (1.0, shape.hi)):
|
|
520
|
+
n = np.zeros(3)
|
|
521
|
+
n[a] = sign
|
|
522
|
+
out.append((at, n, lateral))
|
|
523
|
+
return out
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _snap_to_flat_faces(mesh, preserve, reach: float, spread: float):
|
|
527
|
+
"""Move the organic surface onto a preserved shape's flat face where it
|
|
528
|
+
runs alongside it within `reach` (mm), facing the same way.
|
|
529
|
+
|
|
530
|
+
The density field resolves a millimetre at best, so where the body runs
|
|
531
|
+
on flush with a boss's end face its surface lands a fraction of a
|
|
532
|
+
millimetre off the face, and the exact union leaves the boss's rim
|
|
533
|
+
standing in a hairline step all round (GRIP's turret: 70 mm of crease
|
|
534
|
+
round its J1 hub's mating face, and the body half a millimetre proud of
|
|
535
|
+
that face where the fillet had bulged past it). On the face's own
|
|
536
|
+
footprint the surface goes onto the face's plane; past its edge the move
|
|
537
|
+
eases off over `spread` mm, so the body rises from the face's rim in a
|
|
538
|
+
slope instead of a step."""
|
|
539
|
+
v = np.asarray(mesh.vertices, float).copy()
|
|
540
|
+
vn = np.asarray(mesh.vertex_normals, float)
|
|
541
|
+
moved = False
|
|
542
|
+
for sh in preserve:
|
|
543
|
+
for at, n, lateral in _flat_faces(sh):
|
|
544
|
+
s = (v - at) @ n
|
|
545
|
+
# only ever out onto the plane, adding material: moving a surface
|
|
546
|
+
# in onto it thins whatever stands on the face (the motor mount's
|
|
547
|
+
# web over a bolt boss: SF 3.35 -> 2.73)
|
|
548
|
+
cand = (s < 0) & (s > -reach) & (vn @ n > np.cos(np.radians(35.0)))
|
|
549
|
+
if not cand.any():
|
|
550
|
+
continue
|
|
551
|
+
i = np.nonzero(cand)[0]
|
|
552
|
+
# (fully onto the plane for a cell past the edge too: a vertex
|
|
553
|
+
# left a hundredth of a millimetre off it is still a step)
|
|
554
|
+
w = np.clip(1.0 - (lateral(v[i]) - reach) / spread, 0.0, 1.0)
|
|
555
|
+
w *= np.clip(2.0 * (reach - np.abs(s[i])) / reach, 0.0, 1.0)
|
|
556
|
+
v[i] -= (w * s[i])[:, None] * n
|
|
557
|
+
moved = moved or bool((w > 0).any())
|
|
558
|
+
if moved:
|
|
559
|
+
mesh.vertices = v
|
|
560
|
+
return mesh
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _attach_plan(mesh, study, organic):
|
|
564
|
+
"""Remember how the part was built, exact shapes and all, for STEP
|
|
565
|
+
export (step.py): ((organic & envelope) | preserved) - keep-outs."""
|
|
566
|
+
from .shapes import Union
|
|
567
|
+
from .sharp import Exact, Op, Organic
|
|
568
|
+
from .step import attach_plan
|
|
569
|
+
|
|
570
|
+
def one(shapes):
|
|
571
|
+
return shapes[0] if len(shapes) == 1 else Union(shapes)
|
|
572
|
+
|
|
573
|
+
org = Organic(None, ["organic"], mesh=trimesh_from_manifold(organic)) # a trimesh: parts cross processes
|
|
574
|
+
env = list(study.design_space) + [r.shape for r in study.preserve]
|
|
575
|
+
p = Op("intersect", [org, Exact(one(env), "envelope")])
|
|
576
|
+
if study.preserve:
|
|
577
|
+
pres = one([r.shape for r in study.preserve])
|
|
578
|
+
p = Op("union", [p, Exact(pres, "preserve")])
|
|
579
|
+
p.keep_with = pres
|
|
580
|
+
if study.keep_out:
|
|
581
|
+
keep = getattr(p, "keep_with", None)
|
|
582
|
+
p = Op("subtract", [p, Exact(one([r.shape for r in study.keep_out]), "keep-out")])
|
|
583
|
+
if keep is not None:
|
|
584
|
+
p.keep_with = keep
|
|
585
|
+
attach_plan(mesh, p)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _drop_slivers(mesh):
|
|
589
|
+
"""Drop zero-volume shells (a pair of coincident triangles facing apart)
|
|
590
|
+
that a boolean can leave on a flat face. manifold3d keeps them on vertices
|
|
591
|
+
of their own, so the mesh in memory is closed, but any STL reader welds
|
|
592
|
+
them into the part, where they read as non-manifold edges (the wrist link
|
|
593
|
+
in docs/tutorials/agent-mcp was not watertight once saved). Returns the
|
|
594
|
+
welded mesh without them, or `mesh` itself when there are none."""
|
|
595
|
+
import trimesh
|
|
596
|
+
|
|
597
|
+
m = mesh.copy()
|
|
598
|
+
m.merge_vertices()
|
|
599
|
+
if not len(m.faces):
|
|
600
|
+
return mesh
|
|
601
|
+
lab = trimesh.graph.connected_component_labels(m.face_adjacency, node_count=len(m.faces))
|
|
602
|
+
tri = m.vertices[m.faces]
|
|
603
|
+
vol = np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])) / 6.0
|
|
604
|
+
per = np.bincount(lab, weights=vol)
|
|
605
|
+
sliver = np.abs(per) < 1e-6 * max(abs(per.sum()), 1e-9)
|
|
606
|
+
if not sliver.any():
|
|
607
|
+
return mesh
|
|
608
|
+
# the welded copy: in manifold3d's own topology the part's faces either
|
|
609
|
+
# side of a sliver are joined through it, so dropping it unwelded opens holes
|
|
610
|
+
m.update_faces(~sliver[lab])
|
|
611
|
+
m.remove_unreferenced_vertices()
|
|
612
|
+
return m
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _smooth_surface(iso, domain: Domain, xphys: np.ndarray, iterations: int, keep_thin=False, lamb=0.5,
|
|
616
|
+
nu=0.53):
|
|
617
|
+
"""Smooth the isosurface: alternating steps v += f (L v - v), the first
|
|
618
|
+
shrinking (lamb), the second shrinking on thick members and inflating
|
|
619
|
+
(Taubin's) on members about two cells thick or less.
|
|
620
|
+
|
|
621
|
+
This code long passed nu = -0.53 to trimesh.smoothing.filter_taubin,
|
|
622
|
+
which subtracts its nu, so both steps shrank: plain Laplacian smoothing.
|
|
623
|
+
On thick members that rounds the staircase off in a way the evals are
|
|
624
|
+
tuned to: true Taubin everywhere read worse (mass at target SF +3% to
|
|
625
|
+
+10% on six full-tier cases, the wrist yoke failing on creases). But it
|
|
626
|
+
is curvature flow, and members one or two cells thick shrink until they
|
|
627
|
+
nearly part: the GRIP wrist yoke's stress-constrained design at 15k
|
|
628
|
+
cells lost 20% of its mesh volume (verified peak 33 -> 176 MPa) and the
|
|
629
|
+
camera boom's two-cell walls came out one cell thick (16x softer).
|
|
630
|
+
Capping each vertex's shift instead (0.1 / 0.25 cell) only halved the
|
|
631
|
+
damage. So it is opt-in (resolution.keepThinMembers): the member's
|
|
632
|
+
half-thickness at a vertex is the largest distance to void within two
|
|
633
|
+
cells; Taubin at 1.5 cells or less, as before at 2.5 or more, blended
|
|
634
|
+
between. That keeps the yoke at SF 1.22 and the motor mount's 15k
|
|
635
|
+
design at 1.42 (0.95 before); a narrower window (1.0-1.5) kept only
|
|
636
|
+
0.43 on the yoke. Off, every vertex smooths as before."""
|
|
637
|
+
import trimesh
|
|
638
|
+
|
|
639
|
+
w = 1.0
|
|
640
|
+
if keep_thin:
|
|
641
|
+
grid = domain.full_grid(np.asarray(xphys, float)) > 0.5
|
|
642
|
+
half = ndimage.maximum_filter(ndimage.distance_transform_edt(grid), size=5)
|
|
643
|
+
ijk = np.floor((np.asarray(iso.vertices) - domain.origin) / domain.pitch).astype(int)
|
|
644
|
+
ijk = np.clip(ijk, 0, np.array(grid.shape) - 1)
|
|
645
|
+
w = np.clip((half[ijk[:, 0], ijk[:, 1], ijk[:, 2]] - THIN[0]) / (THIN[1] - THIN[0]), 0.0, 1.0)[:, None]
|
|
646
|
+
second = nu * (2.0 * w - 1.0) # -nu thin (Taubin), +nu thick (as before)
|
|
647
|
+
lap = trimesh.smoothing.laplacian_calculation(iso)
|
|
648
|
+
v = np.asarray(iso.vertices, float).copy()
|
|
649
|
+
for k in range(iterations):
|
|
650
|
+
v = v + (lamb if k % 2 == 0 else second) * (lap @ v - v)
|
|
651
|
+
iso.vertices = v
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
THIN = (1.5, 2.5) # member half-thickness (cells): Taubin below the first, shrinking above the second
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _bodies(man):
|
|
658
|
+
return sorted(man.decompose(), key=lambda b: -b.volume())
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def reconstruct(study: Study, domain: Domain, xphys: np.ndarray, opts: ReconstructOptions | None = None):
|
|
662
|
+
"""Returns (trimesh.Trimesh, info dict)."""
|
|
663
|
+
import manifold3d as m3d
|
|
664
|
+
|
|
665
|
+
o = opts or ReconstructOptions()
|
|
666
|
+
if o.blend is None:
|
|
667
|
+
import copy
|
|
668
|
+
o = copy.copy(o)
|
|
669
|
+
o.blend = max(3.0, 1.5 * float(study.min_member or 0.0))
|
|
670
|
+
if o.close is None:
|
|
671
|
+
import copy
|
|
672
|
+
o = copy.copy(o)
|
|
673
|
+
o.close = max(1.0, 0.5 * float(study.min_member or 0.0))
|
|
674
|
+
if o.axial_debias is None:
|
|
675
|
+
import copy
|
|
676
|
+
o = copy.copy(o)
|
|
677
|
+
o.axial_debias = bool((getattr(study, "raw", None) or {}).get("resolution", {}).get("keepThinMembers", False))
|
|
678
|
+
if o.axial and getattr(study, "access", None) is not None:
|
|
679
|
+
# A cast or milled part's field is already shaped for its draw or
|
|
680
|
+
# cutter directions, and averaging it along a member's tilted axis
|
|
681
|
+
# leans those walls into undercuts: the cast arm link went from
|
|
682
|
+
# 141 to 630 mm^3 of undercut (limit 150) with it on.
|
|
683
|
+
import copy
|
|
684
|
+
o = copy.copy(o)
|
|
685
|
+
o.axial = False
|
|
686
|
+
# Upsampling refines a coarse grid's staircase; a fine grid has none to
|
|
687
|
+
# speak of, and 3x on 845k cells meant a 23M-cell field and 330 s of
|
|
688
|
+
# meshing for 2M triangles. Cap the refined field near FINE_CELLS (eval
|
|
689
|
+
# grids, all under ~150k box cells, keep their 3x).
|
|
690
|
+
box = float(np.prod(domain.dims))
|
|
691
|
+
cap = max(1, int((FINE_CELLS / box) ** (1.0 / 3.0)))
|
|
692
|
+
if o.upsample > cap:
|
|
693
|
+
import copy
|
|
694
|
+
o = copy.copy(o)
|
|
695
|
+
o.upsample = cap
|
|
696
|
+
# where the part must reach the boundary: its supports and its loads
|
|
697
|
+
flush = []
|
|
698
|
+
for sup in study.supports:
|
|
699
|
+
try:
|
|
700
|
+
flush.append(sup.shape if sup.shape is not None else study.region_shape(sup.region)[0])
|
|
701
|
+
except ValueError:
|
|
702
|
+
pass
|
|
703
|
+
for case in study.load_cases:
|
|
704
|
+
for ld in case.loads:
|
|
705
|
+
try:
|
|
706
|
+
flush.append(ld.shape if ld.shape is not None else study.region_shape(ld.region)[0])
|
|
707
|
+
except ValueError:
|
|
708
|
+
pass
|
|
709
|
+
if o.fillet and o.blend > 0:
|
|
710
|
+
# Material against a preserved shape runs on through the design
|
|
711
|
+
# space's boundary like a flush face, so the exact trim below cuts it
|
|
712
|
+
# level with the shape's own end face instead of leaving the end a
|
|
713
|
+
# fraction of a millimetre proud of it (a lip round every full-height
|
|
714
|
+
# boss: the bell crank's).
|
|
715
|
+
flush = flush + [r.shape for r in study.preserve]
|
|
716
|
+
iso = _isosurface(domain, xphys, o, [r.shape for r in study.keep_out], study.min_member,
|
|
717
|
+
flush=flush, preserve=[r.shape for r in study.preserve], design=list(study.design_space))
|
|
718
|
+
info = {"isoTriangles": int(len(iso.faces))}
|
|
719
|
+
if o.taubin_iterations:
|
|
720
|
+
keep_thin = bool((getattr(study, "raw", None) or {}).get("resolution", {}).get("keepThinMembers", False))
|
|
721
|
+
_smooth_surface(iso, domain, xphys, o.taubin_iterations, keep_thin)
|
|
722
|
+
if study.preserve and o.fillet and o.blend > 0:
|
|
723
|
+
_snap_to_flat_faces(iso, [r.shape for r in study.preserve], domain.pitch / o.upsample, o.blend)
|
|
724
|
+
if len(iso.faces) > o.max_triangles:
|
|
725
|
+
# Decimate the organic surface before the booleans, never after: a
|
|
726
|
+
# decimated part shaves the exact preserved faces (a bore wall moved
|
|
727
|
+
# a few hundredths of a millimetre reads as preserved geometry lost).
|
|
728
|
+
try:
|
|
729
|
+
simplified = iso.simplify_quadric_decimation(face_count=o.max_triangles)
|
|
730
|
+
if simplified.is_watertight:
|
|
731
|
+
iso = simplified
|
|
732
|
+
except Exception: # noqa: BLE001 - decimation is an optimization, never fatal
|
|
733
|
+
pass
|
|
734
|
+
try:
|
|
735
|
+
organic = manifold_from_trimesh(iso)
|
|
736
|
+
except ValueError:
|
|
737
|
+
# Marching cubes is closed by construction, but a degenerate sliver can
|
|
738
|
+
# still trip manifold3d; trimesh's repair is enough for those.
|
|
739
|
+
import trimesh
|
|
740
|
+
trimesh.repair.fix_winding(iso)
|
|
741
|
+
trimesh.repair.fill_holes(iso)
|
|
742
|
+
organic = manifold_from_trimesh(iso)
|
|
743
|
+
|
|
744
|
+
total = organic.volume()
|
|
745
|
+
keep = [b for b in organic.decompose() if b.volume() >= o.min_body_fraction * total]
|
|
746
|
+
organic = m3d.Manifold.batch_boolean(keep, m3d.OpType.Add) if len(keep) > 1 else keep[0]
|
|
747
|
+
|
|
748
|
+
# Trim to the design space (and preserved geometry) exactly: flat,
|
|
749
|
+
# flush faces wherever the design reaches its boundary. Circles fine
|
|
750
|
+
# enough to be 0.002 mm true (sharp.exact_manifold): at a fixed 96
|
|
751
|
+
# segments a 48 mm sleeve's facets were 0.026 mm off its cylinder.
|
|
752
|
+
from .sharp import exact_manifold
|
|
753
|
+
|
|
754
|
+
envelope = m3d.Manifold.batch_boolean(
|
|
755
|
+
[exact_manifold(sh) for sh in study.design_space] + [exact_manifold(r.shape) for r in study.preserve],
|
|
756
|
+
m3d.OpType.Add)
|
|
757
|
+
organic_raw = organic
|
|
758
|
+
organic = organic ^ envelope
|
|
759
|
+
part = organic
|
|
760
|
+
if study.preserve:
|
|
761
|
+
pres = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.preserve], m3d.OpType.Add)
|
|
762
|
+
part = part + pres
|
|
763
|
+
if study.keep_out:
|
|
764
|
+
ko = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.keep_out], m3d.OpType.Add)
|
|
765
|
+
part = part - ko
|
|
766
|
+
|
|
767
|
+
# decompose() lists an enclosed cavity as a body of negative volume: it
|
|
768
|
+
# is the part's (a sealed plenum's), not a fragment
|
|
769
|
+
bodies = [b for b in _bodies(part) if b.volume() > 0]
|
|
770
|
+
if not bodies:
|
|
771
|
+
raise ValueError("reconstruction produced an empty part")
|
|
772
|
+
# A body is part of the result if it is the main one or it carries
|
|
773
|
+
# preserved geometry; anything else is a fragment the subtract cut loose.
|
|
774
|
+
kept = [bodies[0]]
|
|
775
|
+
loose = []
|
|
776
|
+
dropped = 0.0
|
|
777
|
+
if study.preserve:
|
|
778
|
+
pres_man = m3d.Manifold.batch_boolean([exact_manifold(r.shape) for r in study.preserve], m3d.OpType.Add)
|
|
779
|
+
for b in bodies[1:]:
|
|
780
|
+
if study.preserve and (b ^ pres_man).volume() > 1e-6:
|
|
781
|
+
kept.append(b)
|
|
782
|
+
else:
|
|
783
|
+
loose.append(b)
|
|
784
|
+
dropped += b.volume()
|
|
785
|
+
if loose:
|
|
786
|
+
# cut the fragments away rather than rebuilding from the kept
|
|
787
|
+
# bodies, which would fill every enclosed cavity
|
|
788
|
+
part = part - (m3d.Manifold.batch_boolean(loose, m3d.OpType.Add) if len(loose) > 1 else loose[0])
|
|
789
|
+
info["bodies"] = len(kept)
|
|
790
|
+
info["droppedFragmentVolume"] = float(dropped)
|
|
791
|
+
|
|
792
|
+
mesh = _drop_slivers(trimesh_from_manifold(part))
|
|
793
|
+
_attach_plan(mesh, study, organic_raw)
|
|
794
|
+
info["triangles"] = int(len(mesh.faces))
|
|
795
|
+
info["volume"] = float(mesh.volume)
|
|
796
|
+
info["watertight"] = bool(mesh.is_watertight)
|
|
797
|
+
return mesh, info
|