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/latticestep.py
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lattice parts as smooth, exact CAD (STEP), lattice and all.
|
|
3
|
+
|
|
4
|
+
Two routes (export_lattice_step picks one):
|
|
5
|
+
|
|
6
|
+
cavities - a TPMS sheet lattice sealed under a skin splits the part's inside
|
|
7
|
+
into cavities, the channels on each side of the lattice's walls. So
|
|
8
|
+
|
|
9
|
+
part = envelope - cavities
|
|
10
|
+
|
|
11
|
+
where the envelope is the part with the lattice solid (step.export_step:
|
|
12
|
+
exact planes and cylinders, smooth patches elsewhere) and each cavity is
|
|
13
|
+
bounded by one smooth level set: the part's field with the lattice replaced
|
|
14
|
+
by one side of its wall, capped `inset` inside the envelope. That level set
|
|
15
|
+
is the wall's side, the skin's inner face and the fillets between them as
|
|
16
|
+
one surface with no crease (the sheet's two sides never meet), so it goes
|
|
17
|
+
straight to bicubic patches on the true field (ibrep.py), every patch whole:
|
|
18
|
+
no boolean and no trimmed face, the cavities are the solid's inner shells.
|
|
19
|
+
Drain bores are cut afterwards (one OpenCascade cut of plain cylinders).
|
|
20
|
+
|
|
21
|
+
void - anything else (strut cells, Voronoi foams, networks, several
|
|
22
|
+
lattices, lattices open at the outside): part = envelope - void, the void
|
|
23
|
+
one smooth level set of the part's own field carried on past the envelope
|
|
24
|
+
(void_family), meshed with patches half the thinnest strut. Sealed void is
|
|
25
|
+
inner shells; void that reaches the outside is cut by splitting the faces
|
|
26
|
+
where they cross and keeping pieces by the fields (_split_cut), not by
|
|
27
|
+
OpenCascade's cut, which crawled or failed on lattice blocks.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
# ---- skinned lattices: the envelope less its cavities
|
|
37
|
+
|
|
38
|
+
def cavity_families(doc, g, name, base_dir=None, sources=None, meshes=None, inset=0.4, h=None, extra=None):
|
|
39
|
+
"""The part's cavities on each side of lattice `name`'s walls as smooth
|
|
40
|
+
implicit families (negative = material), and the envelope graph (the
|
|
41
|
+
part with the lattice solid).
|
|
42
|
+
|
|
43
|
+
With the lattice replaced by one side of its wall (d - t/2, or -d - t/2),
|
|
44
|
+
the part's field is the part with the other side's channels filled: its
|
|
45
|
+
zero set is the outside, the bores and this side's cavities, wall,
|
|
46
|
+
skin and the fillets between them one smooth surface (the sheet's two
|
|
47
|
+
sides never meet, so no crease). Everything within `inset` of the
|
|
48
|
+
envelope's surface is made material, which leaves the cavities alone."""
|
|
49
|
+
from .fields import _lattice_variant_graph, expand_groups
|
|
50
|
+
from .ibrep import Family
|
|
51
|
+
from .struts import is_strut
|
|
52
|
+
|
|
53
|
+
full = expand_groups(doc)
|
|
54
|
+
spec = dict(full["nodes"][name])
|
|
55
|
+
if is_strut(spec.get("type", "gyroid")) or spec.get("form", "sheet") != "sheet":
|
|
56
|
+
raise ValueError(f"lattice {name!r}: cavity STEP is for TPMS sheet lattices")
|
|
57
|
+
cell = spec.get("cell", 8.0)
|
|
58
|
+
cmin = float(cell) if isinstance(cell, (int, float)) else 4.0
|
|
59
|
+
others = [k for k, nd in g.nodes.items() if nd.op == "lattice" and k != name]
|
|
60
|
+
env = _lattice_variant_graph(doc, g, (name,), others, base_dir, sources, meshes, extra=extra)
|
|
61
|
+
ins = float(inset)
|
|
62
|
+
fams = []
|
|
63
|
+
for side in ("_plus", "_minus"):
|
|
64
|
+
gs = _lattice_variant_graph(doc, g, (), others, base_dir, sources, meshes,
|
|
65
|
+
extra={**(extra or {}), name: {**spec, "form": side}})
|
|
66
|
+
|
|
67
|
+
def f(P, gs=gs):
|
|
68
|
+
return np.minimum(gs.evaluate(P), -(env.evaluate(P) + ins))
|
|
69
|
+
|
|
70
|
+
fams.append(Family(f"{name}{'+' if side == '_plus' else '-'}", f, float(h or cmin / 6.0)))
|
|
71
|
+
return fams, env
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _components(Q):
|
|
75
|
+
"""Connected components of a quad mesh (quads sharing an edge)."""
|
|
76
|
+
import scipy.sparse as sp
|
|
77
|
+
from scipy.sparse.csgraph import connected_components
|
|
78
|
+
|
|
79
|
+
E = np.concatenate([np.sort(Q[:, [k, (k + 1) % 4]], axis=1) for k in range(4)])
|
|
80
|
+
owner = np.tile(np.arange(len(Q)), 4)
|
|
81
|
+
key = E[:, 0] * (int(Q.max()) + 1) + E[:, 1]
|
|
82
|
+
o = np.argsort(key, kind="stable")
|
|
83
|
+
k_, ow = key[o], owner[o]
|
|
84
|
+
same = k_[1:] == k_[:-1]
|
|
85
|
+
G = sp.coo_matrix((np.ones(same.sum()), (ow[1:][same], ow[:-1][same])), shape=(len(Q), len(Q)))
|
|
86
|
+
return connected_components(G, directed=False)[1]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _closed(Q):
|
|
90
|
+
"""True when every edge of quad mesh Q has two quads."""
|
|
91
|
+
E = np.sort(np.concatenate([Q[:, [k, (k + 1) % 4]] for k in range(4)]), axis=1)
|
|
92
|
+
_, cnt = np.unique(E, axis=0, return_counts=True)
|
|
93
|
+
return bool((cnt == 2).all())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pieces(fam, lo, hi, levels=2, small=12):
|
|
97
|
+
"""The family's cavities as (Sheet, quads, h) pieces, one per closed
|
|
98
|
+
component. A component small enough (under `small` h across) whose
|
|
99
|
+
patches fold (the surface turns within a patch: a sliver cavity, a
|
|
100
|
+
tight corner) is meshed again on its own box at half the spacing, up to
|
|
101
|
+
`levels` times; a component wholly inside such a box is replaced by
|
|
102
|
+
what the finer mesh finds there."""
|
|
103
|
+
from .ibrep import Family, Sheet, manifold_nets
|
|
104
|
+
|
|
105
|
+
def build(f, a, b):
|
|
106
|
+
V, Q, nm = manifold_nets(f, a, b)
|
|
107
|
+
if not len(Q):
|
|
108
|
+
return None, [], nm
|
|
109
|
+
sh = Sheet(f, V, Q)
|
|
110
|
+
fo = sh.folds()
|
|
111
|
+
comp = _components(Q)
|
|
112
|
+
out = []
|
|
113
|
+
for c in np.unique(comp):
|
|
114
|
+
qs = np.flatnonzero(comp == c)
|
|
115
|
+
P = V[np.unique(Q[qs])]
|
|
116
|
+
out.append({"sheet": sh, "quads": qs, "h": f.h, "lo": P.min(0), "hi": P.max(0),
|
|
117
|
+
"folds": int(fo[qs].sum()), "closed": _closed(Q[qs])})
|
|
118
|
+
return sh, out, nm
|
|
119
|
+
|
|
120
|
+
sh0, pieces, nonman = build(fam, lo - 2 * fam.h, hi + 2 * fam.h)
|
|
121
|
+
sheets = [sh0]
|
|
122
|
+
refined = 0
|
|
123
|
+
for lev in range(levels):
|
|
124
|
+
todo = [p for p in pieces if p["folds"] and p["closed"] and p["h"] == fam.h / 2 ** lev
|
|
125
|
+
and (p["hi"] - p["lo"]).max() < small * fam.h]
|
|
126
|
+
if not todo:
|
|
127
|
+
break
|
|
128
|
+
boxes = [[p["lo"] - 2 * p["h"], p["hi"] + 2 * p["h"]] for p in todo]
|
|
129
|
+
merged = True
|
|
130
|
+
while merged: # overlapping boxes are one
|
|
131
|
+
merged = False
|
|
132
|
+
for i in range(len(boxes)):
|
|
133
|
+
for j in range(i + 1, len(boxes)):
|
|
134
|
+
if (boxes[i][0] <= boxes[j][1]).all() and (boxes[j][0] <= boxes[i][1]).all():
|
|
135
|
+
boxes[i] = [np.minimum(boxes[i][0], boxes[j][0]), np.maximum(boxes[i][1], boxes[j][1])]
|
|
136
|
+
del boxes[j]
|
|
137
|
+
merged = True
|
|
138
|
+
break
|
|
139
|
+
if merged:
|
|
140
|
+
break
|
|
141
|
+
h2 = fam.h / 2 ** (lev + 1)
|
|
142
|
+
f2 = Family(fam.name, fam.f, h2)
|
|
143
|
+
for a, b in boxes:
|
|
144
|
+
inside = [p for p in pieces if (p["lo"] >= a).all() and (p["hi"] <= b).all()]
|
|
145
|
+
if not inside:
|
|
146
|
+
continue
|
|
147
|
+
sh2, new, nm = build(f2, a, b)
|
|
148
|
+
new = [p for p in new if p["closed"] and (p["lo"] > a + h2).all() and (p["hi"] < b - h2).all()]
|
|
149
|
+
if not new:
|
|
150
|
+
continue # the finer mesh is no better: keep the coarse one
|
|
151
|
+
pieces = [p for p in pieces if not any(p is x for x in inside)] + new
|
|
152
|
+
if sh2 is not None:
|
|
153
|
+
sheets.append(sh2)
|
|
154
|
+
nonman += nm
|
|
155
|
+
refined += 1
|
|
156
|
+
return pieces, sheets, nonman, refined
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cavity_shells(fam, lo, hi, progress=None):
|
|
160
|
+
"""Closed shells of smooth patches, one per cavity of family `fam`
|
|
161
|
+
(surface nets + ibrep patches; every patch whole, edges shared, so they
|
|
162
|
+
sew exactly; folded small cavities meshed again finer). Returns
|
|
163
|
+
(shells, report)."""
|
|
164
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Sewing
|
|
165
|
+
from OCP.Geom import Geom_BezierSurface
|
|
166
|
+
from OCP.OCP.collections import Array2_gp_Pnt
|
|
167
|
+
from OCP.TopAbs import TopAbs_SHELL
|
|
168
|
+
from OCP.TopExp import TopExp_Explorer
|
|
169
|
+
from OCP.TopoDS import TopoDS
|
|
170
|
+
|
|
171
|
+
from .ibrep import DEG
|
|
172
|
+
from .step import _pnt
|
|
173
|
+
|
|
174
|
+
t0 = time.time()
|
|
175
|
+
pieces, sheets, nonman, refined = _pieces(fam, np.asarray(lo, float), np.asarray(hi, float))
|
|
176
|
+
n = DEG + 1
|
|
177
|
+
shells, open_, dev = [], 0, []
|
|
178
|
+
for p in pieces:
|
|
179
|
+
sh, qs = p["sheet"], p["quads"]
|
|
180
|
+
if not p["closed"]:
|
|
181
|
+
open_ += 1
|
|
182
|
+
continue
|
|
183
|
+
dev.append(sh.deviation_of(qs))
|
|
184
|
+
sew = BRepBuilderAPI_Sewing(1e-4)
|
|
185
|
+
for q in qs:
|
|
186
|
+
arr = Array2_gp_Pnt(1, n, 1, n)
|
|
187
|
+
for a in range(n):
|
|
188
|
+
for b in range(n):
|
|
189
|
+
arr.SetValue(a + 1, b + 1, _pnt(sh.C[q, a, b]))
|
|
190
|
+
sew.Add(BRepBuilderAPI_MakeFace(Geom_BezierSurface(arr), 1e-7).Face())
|
|
191
|
+
sew.Perform()
|
|
192
|
+
if sew.NbFreeEdges():
|
|
193
|
+
open_ += 1
|
|
194
|
+
continue
|
|
195
|
+
ex = TopExp_Explorer(sew.SewedShape(), TopAbs_SHELL)
|
|
196
|
+
while ex.More():
|
|
197
|
+
shells.append(TopoDS.Shell(ex.Current()))
|
|
198
|
+
ex.Next()
|
|
199
|
+
dev = np.concatenate(dev) if dev else np.zeros(1)
|
|
200
|
+
rep = {"family": fam.name, "h": fam.h, "patches": int(sum(len(p["quads"]) for p in pieces)),
|
|
201
|
+
"cavities": len(pieces), "openCavities": open_, "refinedBoxes": refined,
|
|
202
|
+
"foldedPatches": int(sum(p["folds"] for p in pieces)), "nonManifoldEdges": int(nonman),
|
|
203
|
+
"chordEdges": int(sum(s.wild_edges for s in sheets)), "coonsPatches": int(sum(s.wild_patches for s in sheets)),
|
|
204
|
+
"patchDeviation": {"p99": round(float(np.percentile(dev, 99)), 4), "max": round(float(dev.max()), 4)},
|
|
205
|
+
"seconds": round(time.time() - t0, 1)}
|
|
206
|
+
return shells, rep
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _drain_bores(g):
|
|
210
|
+
"""The graph's drain bores (drains.py) as (p0, p1, r), and the graph
|
|
211
|
+
edit that leaves them out (the `drained` node without its bores)."""
|
|
212
|
+
bores = [k for k in g.nodes if k.startswith("drain-bore-")]
|
|
213
|
+
nd = g.nodes.get("drained")
|
|
214
|
+
if not bores or nd is None:
|
|
215
|
+
return [], None
|
|
216
|
+
cyl = [(np.asarray(g.nodes[k].spec["shape"]["p0"], float), np.asarray(g.nodes[k].spec["shape"]["p1"], float),
|
|
217
|
+
float(g.nodes[k].spec["shape"]["r"])) for k in bores]
|
|
218
|
+
keep = [x for x in nd.spec["inputs"] if x not in bores]
|
|
219
|
+
return cyl, {"drained": {"op": "union", "inputs": keep}}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cut_bores(shape, bores):
|
|
223
|
+
"""shape less the bore cylinders (one OpenCascade cut)."""
|
|
224
|
+
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
|
225
|
+
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
|
|
226
|
+
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
|
227
|
+
from OCP.OCP.collections import List_TopoDS_Shape
|
|
228
|
+
|
|
229
|
+
args, tools = List_TopoDS_Shape(), List_TopoDS_Shape()
|
|
230
|
+
args.Append(shape)
|
|
231
|
+
for p0, p1, r in bores:
|
|
232
|
+
ax = p1 - p0
|
|
233
|
+
L = float(np.linalg.norm(ax))
|
|
234
|
+
tools.Append(BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(*map(float, p0)), gp_Dir(*map(float, ax / L))),
|
|
235
|
+
r, L).Shape())
|
|
236
|
+
cut = BRepAlgoAPI_Cut()
|
|
237
|
+
cut.SetArguments(args)
|
|
238
|
+
cut.SetTools(tools)
|
|
239
|
+
cut.SetRunParallel(True)
|
|
240
|
+
cut.Build()
|
|
241
|
+
if not cut.IsDone():
|
|
242
|
+
raise RuntimeError("drain bore cut failed")
|
|
243
|
+
return cut.Shape()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def export_cavity_step(doc, g, out_dir, name, base_dir=None, sources=None, meshes=None, inset=0.4, h=None,
|
|
247
|
+
surface="smooth", part_mesh=None, progress=None, faces=None, patches=None):
|
|
248
|
+
"""<name>.step for a part whose TPMS sheet lattice is covered by a skin:
|
|
249
|
+
the part with the lattice solid (step.export_step: exact faces, smooth
|
|
250
|
+
patches) less the cavities on both sides of the lattice's walls, each a
|
|
251
|
+
closed shell of smooth patches on the true field (cavity_shells), then
|
|
252
|
+
less the drain bores. Returns (report, shape)."""
|
|
253
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
|
|
254
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
255
|
+
from OCP.TopAbs import TopAbs_SHELL
|
|
256
|
+
from OCP.TopExp import TopExp_Explorer
|
|
257
|
+
from OCP.TopoDS import TopoDS
|
|
258
|
+
|
|
259
|
+
from .step import (
|
|
260
|
+
DEFAULT_FACES,
|
|
261
|
+
DEFAULT_PATCHES,
|
|
262
|
+
_volume,
|
|
263
|
+
export_step,
|
|
264
|
+
read_step,
|
|
265
|
+
write_step,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
t0 = time.time()
|
|
269
|
+
lattices = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
|
|
270
|
+
if len(lattices) != 1:
|
|
271
|
+
raise ValueError("cavity STEP takes a part with one lattice node")
|
|
272
|
+
k = lattices[0]
|
|
273
|
+
bores, extra = _drain_bores(g)
|
|
274
|
+
fams, env = cavity_families(doc, g, k, base_dir, sources, meshes, inset, h, extra)
|
|
275
|
+
em = env.mesh(progress=progress)
|
|
276
|
+
st = export_step(em, os.path.join(out_dir, f"{name}-envelope.step"), faces=int(faces or DEFAULT_FACES),
|
|
277
|
+
name=name, surface=surface, patches=int(patches or DEFAULT_PATCHES))
|
|
278
|
+
outer = st.pop("shape")
|
|
279
|
+
lo, hi = np.asarray(em.bounds[0], float), np.asarray(em.bounds[1], float)
|
|
280
|
+
shells, reps = [], []
|
|
281
|
+
for f in fams:
|
|
282
|
+
s, r = cavity_shells(f, lo, hi, progress)
|
|
283
|
+
shells += s
|
|
284
|
+
reps.append(r)
|
|
285
|
+
ex = TopExp_Explorer(outer, TopAbs_SHELL)
|
|
286
|
+
outs = []
|
|
287
|
+
while ex.More():
|
|
288
|
+
outs.append(TopoDS.Shell(ex.Current()))
|
|
289
|
+
ex.Next()
|
|
290
|
+
mk = BRepBuilderAPI_MakeSolid(outs[0])
|
|
291
|
+
for s in outs[1:]:
|
|
292
|
+
mk.Add(s)
|
|
293
|
+
islands, dropped, voids = [], [], []
|
|
294
|
+
for s in shells:
|
|
295
|
+
so = BRepBuilderAPI_MakeSolid(s).Solid()
|
|
296
|
+
v = _volume(so)
|
|
297
|
+
if abs(v) < 0.01:
|
|
298
|
+
continue # a speck where the surface grazes a grid node
|
|
299
|
+
if v < 0:
|
|
300
|
+
voids.append((s, v)) # a cavity: faces point into it
|
|
301
|
+
elif BRepCheck_Analyzer(so).IsValid():
|
|
302
|
+
islands.append(so) # material loose inside a cavity (a cut-off wall)
|
|
303
|
+
else:
|
|
304
|
+
dropped.append(round(v, 2))
|
|
305
|
+
for s, _ in voids:
|
|
306
|
+
mk.Add(s)
|
|
307
|
+
solid = mk.Solid()
|
|
308
|
+
filled = []
|
|
309
|
+
if not BRepCheck_Analyzer(solid, False).IsValid():
|
|
310
|
+
# a cavity OpenCascade cannot place (its classifier miscounts on a
|
|
311
|
+
# shell of a few strongly curved patches) is left solid
|
|
312
|
+
keep = []
|
|
313
|
+
for s, v in voids:
|
|
314
|
+
m1 = BRepBuilderAPI_MakeSolid(outs[0])
|
|
315
|
+
m1.Add(s)
|
|
316
|
+
(keep if BRepCheck_Analyzer(m1.Solid(), False).IsValid() else filled).append((s, v))
|
|
317
|
+
mk = BRepBuilderAPI_MakeSolid(outs[0])
|
|
318
|
+
for s_ in outs[1:]:
|
|
319
|
+
mk.Add(s_)
|
|
320
|
+
for s, _ in keep:
|
|
321
|
+
mk.Add(s)
|
|
322
|
+
solid = mk.Solid()
|
|
323
|
+
voids = keep
|
|
324
|
+
filled = [round(-v, 2) for _, v in filled]
|
|
325
|
+
n_cav = len(voids)
|
|
326
|
+
if islands:
|
|
327
|
+
from OCP.BRep import BRep_Builder
|
|
328
|
+
from OCP.TopoDS import TopoDS_Compound
|
|
329
|
+
|
|
330
|
+
bb = BRep_Builder()
|
|
331
|
+
comp = TopoDS_Compound()
|
|
332
|
+
bb.MakeCompound(comp)
|
|
333
|
+
for x in [solid] + islands:
|
|
334
|
+
bb.Add(comp, x)
|
|
335
|
+
solid = comp
|
|
336
|
+
if bores:
|
|
337
|
+
solid = _cut_bores(solid, bores)
|
|
338
|
+
valid = bool(BRepCheck_Analyzer(solid).IsValid())
|
|
339
|
+
rep = {"export": "envelope less cavities", "lattice": k, "inset": inset, "envelope": st,
|
|
340
|
+
"cavities": reps, "cavityShells": n_cav, "looseBodies": len(islands),
|
|
341
|
+
"droppedBodies": dropped, "filledCavities": filled, "drainBores": len(bores), "valid": valid, "volume": round(abs(_volume(solid)), 2),
|
|
342
|
+
"envelopeVolume": round(abs(_volume(outer)), 2)}
|
|
343
|
+
if part_mesh is not None:
|
|
344
|
+
rep["meshVolume"] = round(float(abs(part_mesh.volume)), 2)
|
|
345
|
+
rep.update(write_step(solid, os.path.join(out_dir, f"{name}.step"), name=name))
|
|
346
|
+
t1 = time.time()
|
|
347
|
+
back = read_step(rep["path"])
|
|
348
|
+
rep["readBack"] = {"seconds": round(time.time() - t1, 1), "volume": round(abs(_volume(back)), 2),
|
|
349
|
+
"valid": bool(BRepCheck_Analyzer(back).IsValid())}
|
|
350
|
+
rep["readBack"]["ok"] = rep["readBack"]["valid"] and abs(rep["readBack"]["volume"] - rep["volume"]) < 1e-3 * rep["volume"]
|
|
351
|
+
rep["seconds"] = round(time.time() - t0, 1)
|
|
352
|
+
return rep, solid
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---- any lattice: the envelope less one void solid ------------------------
|
|
356
|
+
|
|
357
|
+
def _feature(g, names, P):
|
|
358
|
+
"""The finest lattice feature (mm) over points P: the smallest strut
|
|
359
|
+
diameter or wall (5th percentile of the thickness node there) and the
|
|
360
|
+
smallest cell."""
|
|
361
|
+
from .struts import canonical, is_strut, strut_diameter_for_density
|
|
362
|
+
|
|
363
|
+
feat, cells = [], []
|
|
364
|
+
memo = {}
|
|
365
|
+
for k in names:
|
|
366
|
+
nd = g.nodes[k]
|
|
367
|
+
s, r = nd.spec, nd.refs
|
|
368
|
+
cell = np.broadcast_to(np.asarray(g._val(r["cell"], P, memo) if "cell" in r else s.get("cell", 8.0),
|
|
369
|
+
float), (len(P),))
|
|
370
|
+
if "thickness" in r:
|
|
371
|
+
t = np.broadcast_to(np.asarray(g._val(r["thickness"], P, memo), float), (len(P),))
|
|
372
|
+
elif "thickness" in s:
|
|
373
|
+
t = np.full(len(P), float(s["thickness"]))
|
|
374
|
+
elif "density" in r and is_strut(s.get("type", "gyroid")):
|
|
375
|
+
rho = np.clip(np.broadcast_to(np.asarray(g._val(r["density"], P, memo), float), (len(P),)), 0.02, 0.9)
|
|
376
|
+
t = strut_diameter_for_density(canonical(s["type"]), rho, cell, float(s.get("blend", 0.0) or 0.0))
|
|
377
|
+
else:
|
|
378
|
+
t = 0.12 * cell
|
|
379
|
+
feat.append(float(np.percentile(t, 5)))
|
|
380
|
+
cells.append(float(np.percentile(cell, 5)))
|
|
381
|
+
return min(feat), min(cells)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def void_family(P_graph, E_graph, B_graph, lattices, h, cap=None, eta=None):
|
|
385
|
+
"""The part's void as a smooth implicit family (negative = material).
|
|
386
|
+
Inside the envelope E (the part with its lattices solid) it is the
|
|
387
|
+
part's own field. Outside, it carries on what the envelope cut: the
|
|
388
|
+
raw lattice (struts and walls run on past E and cross its surface
|
|
389
|
+
square instead of ending on it), except over the body (B: the part
|
|
390
|
+
with its lattices gone; its value at the nearest point just inside E,
|
|
391
|
+
Newton onto E's surface then eta in), which stays material so no void
|
|
392
|
+
face lies on a skin. Capped `cap` outside E, the cap's crease rounded
|
|
393
|
+
(all of that is outside, where the envelope cut takes it off). The
|
|
394
|
+
void solid {f > 0} is what the envelope loses: sealed cavities inside
|
|
395
|
+
it, open pores that reach through its surface."""
|
|
396
|
+
from .fields import _smin
|
|
397
|
+
from .ibrep import Family
|
|
398
|
+
|
|
399
|
+
cap = float(cap if cap is not None else 2.0 * h)
|
|
400
|
+
eta = float(eta if eta is not None else 0.25 * h)
|
|
401
|
+
k = 0.5 * cap
|
|
402
|
+
eps = 1e-4
|
|
403
|
+
|
|
404
|
+
def vgE(Q):
|
|
405
|
+
n = len(Q)
|
|
406
|
+
X = np.concatenate([Q] + [Q + d for d in np.eye(3) * eps] + [Q - d for d in np.eye(3) * eps])
|
|
407
|
+
v = E_graph.evaluate(X)
|
|
408
|
+
gr = np.stack([(v[(1 + a) * n:(2 + a) * n] - v[(4 + a) * n:(5 + a) * n]) / (2 * eps) for a in range(3)], 1)
|
|
409
|
+
return v[:n], gr
|
|
410
|
+
|
|
411
|
+
def f(X):
|
|
412
|
+
X = np.asarray(X, float)
|
|
413
|
+
e = E_graph.evaluate(X)
|
|
414
|
+
out = cap - e
|
|
415
|
+
near = e < cap + k
|
|
416
|
+
if not near.any():
|
|
417
|
+
return out
|
|
418
|
+
idx = np.flatnonzero(near)
|
|
419
|
+
ei = e[idx]
|
|
420
|
+
p = np.empty(len(idx))
|
|
421
|
+
i_in = ei <= 0
|
|
422
|
+
if i_in.any():
|
|
423
|
+
p[i_in] = P_graph.evaluate(X[idx[i_in]])
|
|
424
|
+
o = ~i_in
|
|
425
|
+
if o.any():
|
|
426
|
+
x = X[idx[o]]
|
|
427
|
+
lat = np.min([P_graph.evaluate(x, name=n_) for n_ in lattices], axis=0)
|
|
428
|
+
p[o] = lat
|
|
429
|
+
if B_graph is not None:
|
|
430
|
+
# over the body only where the lattice is open (or nearly):
|
|
431
|
+
# elsewhere the strut already makes it material
|
|
432
|
+
w = np.flatnonzero(lat > -h)
|
|
433
|
+
q = x[w]
|
|
434
|
+
live = np.arange(len(q))
|
|
435
|
+
for _ in range(6):
|
|
436
|
+
v, gr = vgE(q[live])
|
|
437
|
+
q[live] -= ((v + eta) / np.maximum((gr * gr).sum(1), 1e-12))[:, None] * gr
|
|
438
|
+
live = live[np.abs(v + eta) > 1e-3 * h]
|
|
439
|
+
if not len(live):
|
|
440
|
+
break
|
|
441
|
+
p[np.flatnonzero(o)[w]] = np.minimum(B_graph.evaluate(q), lat[w])
|
|
442
|
+
out[idx] = _smin(p, cap - ei, k)
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
return Family("void", f, float(h)), cap
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _shell_solid(sh, reverse=False):
|
|
449
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
|
|
450
|
+
from OCP.TopoDS import TopoDS
|
|
451
|
+
|
|
452
|
+
s = TopoDS.Shell(sh.Reversed()) if reverse else sh
|
|
453
|
+
return BRepBuilderAPI_MakeSolid(s).Solid()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _valid(shape, geom=True):
|
|
457
|
+
from .step import shape_valid
|
|
458
|
+
|
|
459
|
+
return shape_valid(shape, geom)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _bop(kind, args, tools, fuzzy=None):
|
|
463
|
+
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
|
464
|
+
from OCP.OCP.collections import List_TopoDS_Shape
|
|
465
|
+
|
|
466
|
+
op = {"cut": BRepAlgoAPI_Cut, "common": BRepAlgoAPI_Common, "fuse": BRepAlgoAPI_Fuse}[kind]()
|
|
467
|
+
a, t = List_TopoDS_Shape(), List_TopoDS_Shape()
|
|
468
|
+
for x in args:
|
|
469
|
+
a.Append(x)
|
|
470
|
+
for x in tools:
|
|
471
|
+
t.Append(x)
|
|
472
|
+
op.SetArguments(a)
|
|
473
|
+
op.SetTools(t)
|
|
474
|
+
op.SetRunParallel(True)
|
|
475
|
+
op.SetUseOBB(True)
|
|
476
|
+
if fuzzy:
|
|
477
|
+
op.SetFuzzyValue(fuzzy)
|
|
478
|
+
op.Build()
|
|
479
|
+
if not op.IsDone():
|
|
480
|
+
raise RuntimeError(f"OpenCascade {kind} failed")
|
|
481
|
+
return op.Shape()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _mesh_point(face):
|
|
485
|
+
"""(u, v) of a point well inside a face: the centroid of the biggest
|
|
486
|
+
triangle of its own mesh (where PointInFace finds none: a sliver), or
|
|
487
|
+
None."""
|
|
488
|
+
from OCP.BRep import BRep_Tool
|
|
489
|
+
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
490
|
+
from OCP.TopLoc import TopLoc_Location
|
|
491
|
+
|
|
492
|
+
BRepMesh_IncrementalMesh(face, 0.01, False, 0.2, False)
|
|
493
|
+
tri = BRep_Tool.Triangulation_s(face, TopLoc_Location())
|
|
494
|
+
if tri is None or tri.NbTriangles() == 0 or not tri.HasUVNodes():
|
|
495
|
+
return None
|
|
496
|
+
best, out = -1.0, None
|
|
497
|
+
for i in range(1, tri.NbTriangles() + 1):
|
|
498
|
+
a, b, c = tri.Triangle(i).Get()
|
|
499
|
+
pa, pb, pc = (tri.UVNode(k) for k in (a, b, c))
|
|
500
|
+
area = abs((pb.X() - pa.X()) * (pc.Y() - pa.Y()) - (pc.X() - pa.X()) * (pb.Y() - pa.Y()))
|
|
501
|
+
if area > best:
|
|
502
|
+
best, out = area, ((pa.X() + pb.X() + pc.X()) / 3, (pa.Y() + pb.Y() + pc.Y()) / 3)
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _volume_of(shell):
|
|
507
|
+
from .step import _volume
|
|
508
|
+
|
|
509
|
+
return _volume(_shell_solid(shell))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _solid_of(shell):
|
|
513
|
+
"""A shell as a solid facing out (whatever way it was sewn)."""
|
|
514
|
+
from .step import _volume
|
|
515
|
+
|
|
516
|
+
so = _shell_solid(shell)
|
|
517
|
+
return so if _volume(so) > 0 else _shell_solid(shell, reverse=True)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _outward(shell, inward=False):
|
|
521
|
+
"""The shell turned so it bounds positive volume (negative: `inward`,
|
|
522
|
+
as an inner shell of a solid)."""
|
|
523
|
+
from OCP.TopoDS import TopoDS
|
|
524
|
+
|
|
525
|
+
from .step import _volume
|
|
526
|
+
|
|
527
|
+
v = _volume(_shell_solid(shell))
|
|
528
|
+
return TopoDS.Shell(shell.Reversed()) if (v < 0) != inward else shell
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _shell_vertex(shell):
|
|
532
|
+
"""(3,) one vertex of the shell."""
|
|
533
|
+
from OCP.BRep import BRep_Tool
|
|
534
|
+
from OCP.TopAbs import TopAbs_VERTEX
|
|
535
|
+
from OCP.TopExp import TopExp_Explorer
|
|
536
|
+
from OCP.TopoDS import TopoDS
|
|
537
|
+
|
|
538
|
+
p = BRep_Tool.Pnt_s(TopoDS.Vertex(TopExp_Explorer(shell, TopAbs_VERTEX).Current()))
|
|
539
|
+
return np.array([p.X(), p.Y(), p.Z()])
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _inside_any(solids, P, tol=1e-6):
|
|
543
|
+
"""(n,) bool: P inside any of the solids (OpenCascade's classifier)."""
|
|
544
|
+
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
|
|
545
|
+
from OCP.gp import gp_Pnt
|
|
546
|
+
from OCP.TopAbs import TopAbs_IN
|
|
547
|
+
|
|
548
|
+
out = np.zeros(len(P), bool)
|
|
549
|
+
for so in solids:
|
|
550
|
+
cl = BRepClass3d_SolidClassifier(so)
|
|
551
|
+
for i, p in enumerate(P):
|
|
552
|
+
if not out[i]:
|
|
553
|
+
cl.Perform(gp_Pnt(*map(float, p)), tol)
|
|
554
|
+
out[i] = cl.State() == TopAbs_IN
|
|
555
|
+
return out
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _faces_list(shape):
|
|
559
|
+
from OCP.TopAbs import TopAbs_FACE
|
|
560
|
+
from OCP.TopExp import TopExp_Explorer
|
|
561
|
+
from OCP.TopoDS import TopoDS
|
|
562
|
+
|
|
563
|
+
out, ex = [], TopExp_Explorer(shape, TopAbs_FACE)
|
|
564
|
+
while ex.More():
|
|
565
|
+
out.append(TopoDS.Face(ex.Current()))
|
|
566
|
+
ex.Next()
|
|
567
|
+
return out
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _flap_faces(sew, max_area):
|
|
571
|
+
"""Faces of a sewing with a free edge, under max_area (mm^2)."""
|
|
572
|
+
from OCP.BRepGProp import BRepGProp
|
|
573
|
+
from OCP.GProp import GProp_GProps
|
|
574
|
+
from OCP.OCP.collections import (
|
|
575
|
+
IndexedDataMap_TopoDS_Shape_List_TopoDS_Shape_TopTools_ShapeMapHasher as Map,
|
|
576
|
+
)
|
|
577
|
+
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
|
578
|
+
from OCP.TopExp import TopExp
|
|
579
|
+
from OCP.TopoDS import TopoDS
|
|
580
|
+
|
|
581
|
+
m = Map()
|
|
582
|
+
TopExp.MapShapesAndAncestors_s(sew.SewedShape(), TopAbs_EDGE, TopAbs_FACE, m)
|
|
583
|
+
out = []
|
|
584
|
+
for i in range(1, sew.NbFreeEdges() + 1):
|
|
585
|
+
e = sew.FreeEdge(i)
|
|
586
|
+
if not m.Contains(e):
|
|
587
|
+
continue
|
|
588
|
+
for f in m.FindFromKey(e):
|
|
589
|
+
f = TopoDS.Face(f)
|
|
590
|
+
pr = GProp_GProps()
|
|
591
|
+
BRepGProp.SurfaceProperties_s(f, pr)
|
|
592
|
+
if pr.Mass() < max_area and not any(f.IsSame(x) for x in out):
|
|
593
|
+
out.append(f)
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _split_cut(outer, voids, bore_solids, bores, env, fam, h):
|
|
598
|
+
"""The envelope (its shells `outer`) less the open void (`voids`: the
|
|
599
|
+
void family's shells that reach out through it, as meshed: faces
|
|
600
|
+
pointing away from the material) and the drain bores, without
|
|
601
|
+
OpenCascade's cut: its general fuse splits every face where the shells
|
|
602
|
+
cross (seconds), and each piece is kept or dropped from the fields
|
|
603
|
+
instead of by classifying it against the solids (the cut crawled for
|
|
604
|
+
many minutes on a lattice block's 9k patches doing that). A piece of
|
|
605
|
+
the envelope stays where the part is solid just inside it and out of
|
|
606
|
+
the bores; a piece of the void stays inside the envelope and out of
|
|
607
|
+
the bores; a piece of a bore stays inside the envelope where the part
|
|
608
|
+
round it is solid. The pieces share their edges, so they sew exactly.
|
|
609
|
+
Returns (shells, report)."""
|
|
610
|
+
from OCP.BOPTools import BOPTools_AlgoTools3D
|
|
611
|
+
from OCP.BRepAlgoAPI import BRepAlgoAPI_BuilderAlgo
|
|
612
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
|
|
613
|
+
from OCP.BRepGProp import BRepGProp_Face
|
|
614
|
+
from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Vec
|
|
615
|
+
from OCP.IntTools import IntTools_Context
|
|
616
|
+
from OCP.OCP.collections import List_TopoDS_Shape
|
|
617
|
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED, TopAbs_SHELL
|
|
618
|
+
from OCP.TopExp import TopExp_Explorer
|
|
619
|
+
from OCP.TopoDS import TopoDS
|
|
620
|
+
|
|
621
|
+
def faces_of(shape):
|
|
622
|
+
out, ex = [], TopExp_Explorer(shape, TopAbs_FACE)
|
|
623
|
+
while ex.More():
|
|
624
|
+
out.append(TopoDS.Face(ex.Current()))
|
|
625
|
+
ex.Next()
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
t0 = time.time()
|
|
629
|
+
groups = {"envelope": [], "void": [], "bore": []}
|
|
630
|
+
args = List_TopoDS_Shape()
|
|
631
|
+
for kind, shapes in (("envelope", outer), ("void", voids), ("bore", bore_solids)):
|
|
632
|
+
for x in shapes:
|
|
633
|
+
args.Append(x)
|
|
634
|
+
groups[kind] += faces_of(x)
|
|
635
|
+
gf = BRepAlgoAPI_BuilderAlgo()
|
|
636
|
+
gf.SetArguments(args)
|
|
637
|
+
gf.SetRunParallel(True)
|
|
638
|
+
gf.Build()
|
|
639
|
+
if not gf.IsDone():
|
|
640
|
+
raise RuntimeError("OpenCascade could not split the envelope by the void")
|
|
641
|
+
t_split = time.time() - t0
|
|
642
|
+
ctx = IntTools_Context()
|
|
643
|
+
pieces, pts, nrm, kinds = [], [], [], []
|
|
644
|
+
nopoint = 0
|
|
645
|
+
for kind, fs in groups.items():
|
|
646
|
+
for f in fs:
|
|
647
|
+
mod = gf.Modified(f)
|
|
648
|
+
got = [TopoDS.Face(x) for x in mod] if mod.Size() else ([] if gf.IsDeleted(f) else [f])
|
|
649
|
+
for p in got:
|
|
650
|
+
P, uv = gp_Pnt(), gp_Pnt2d()
|
|
651
|
+
if BOPTools_AlgoTools3D.PointInFace_s(p, P, uv, ctx) != 0:
|
|
652
|
+
got_uv = _mesh_point(p) # its own triangulation's biggest triangle
|
|
653
|
+
if got_uv is None:
|
|
654
|
+
nopoint += 1
|
|
655
|
+
continue
|
|
656
|
+
uv = gp_Pnt2d(*got_uv)
|
|
657
|
+
n, q = gp_Vec(), gp_Pnt()
|
|
658
|
+
BRepGProp_Face(p).Normal(uv.X(), uv.Y(), q, n)
|
|
659
|
+
if p.Orientation() == TopAbs_REVERSED:
|
|
660
|
+
n.Reverse()
|
|
661
|
+
L = n.Magnitude()
|
|
662
|
+
if L < 1e-12:
|
|
663
|
+
nopoint += 1
|
|
664
|
+
continue
|
|
665
|
+
pieces.append(p)
|
|
666
|
+
pts.append((q.X(), q.Y(), q.Z()))
|
|
667
|
+
nrm.append((n.X() / L, n.Y() / L, n.Z() / L))
|
|
668
|
+
kinds.append(kind)
|
|
669
|
+
pts, nrm, kinds = np.asarray(pts, float).reshape(-1, 3), np.asarray(nrm, float).reshape(-1, 3), np.asarray(kinds)
|
|
670
|
+
|
|
671
|
+
def in_bore(X):
|
|
672
|
+
out = np.zeros(len(X), bool)
|
|
673
|
+
for p0, p1, r in bores:
|
|
674
|
+
ax = (p1 - p0) / np.linalg.norm(p1 - p0)
|
|
675
|
+
t = (X - p0) @ ax
|
|
676
|
+
d = np.linalg.norm(X - p0 - np.outer(t, ax), axis=1)
|
|
677
|
+
out |= (t > 0) & (t < np.linalg.norm(p1 - p0)) & (d < r)
|
|
678
|
+
return out
|
|
679
|
+
|
|
680
|
+
eps = 0.25 * h
|
|
681
|
+
near = 0.1 * h # within this of the other surface, the B-rep decides, not the field
|
|
682
|
+
keep = np.zeros(len(pieces), bool)
|
|
683
|
+
flip = np.zeros(len(pieces), bool)
|
|
684
|
+
e = np.flatnonzero(kinds == "envelope")
|
|
685
|
+
if len(e):
|
|
686
|
+
# solid just inside: the field says, unless the piece is a sliver
|
|
687
|
+
# by a strut that grazes the face (the patches and the field part
|
|
688
|
+
# by their deviation there): then the void solids themselves
|
|
689
|
+
X = pts[e] - 1e-3 * h * nrm[e]
|
|
690
|
+
fv = fam.f(X)
|
|
691
|
+
keep[e] = (fv < 0) & ~in_bore(X)
|
|
692
|
+
unsure = e[np.abs(fv) < near]
|
|
693
|
+
if len(unsure):
|
|
694
|
+
inside = _inside_any([_shell_solid(x, reverse=True) for x in voids if _volume_of(x) < 0],
|
|
695
|
+
pts[unsure] - 1e-4 * nrm[unsure])
|
|
696
|
+
keep[unsure] = ~inside & ~in_bore(pts[unsure])
|
|
697
|
+
v = np.flatnonzero(kinds == "void")
|
|
698
|
+
if len(v):
|
|
699
|
+
ev = env.evaluate(pts[v])
|
|
700
|
+
keep[v] = (ev < 0) & ~in_bore(pts[v])
|
|
701
|
+
unsure = v[np.abs(ev) < near]
|
|
702
|
+
if len(unsure): # by the envelope's own faces (patches off its field)
|
|
703
|
+
keep[unsure] = _inside_any([_solid_of(x) for x in outer], pts[unsure]) & ~in_bore(pts[unsure])
|
|
704
|
+
b = kinds == "bore"
|
|
705
|
+
if b.any(): # a bore's faces point out of its cylinder, into the part
|
|
706
|
+
keep[b] = (env.evaluate(pts[b]) < 0) & (fam.f(pts[b] + eps * nrm[b]) < 0)
|
|
707
|
+
flip[b] = True
|
|
708
|
+
kept = [p.Reversed() if fl else p for p, k, fl in zip(pieces, keep, flip) if k]
|
|
709
|
+
for tol in (1e-4, 2e-3 * h, 1e-2 * h):
|
|
710
|
+
# a sliver kept that should not be (a flap: its free edges are its
|
|
711
|
+
# own) is dropped if it is small; a sliver the classifiers dropped
|
|
712
|
+
# that should be kept (a strut grazing a face makes pieces microns
|
|
713
|
+
# across) leaves a hole that wide, so sewing a little looser closes
|
|
714
|
+
# it. Flaps first: a loose tolerance can fold a face's wire on itself
|
|
715
|
+
sew = BRepBuilderAPI_Sewing(tol)
|
|
716
|
+
for f in kept:
|
|
717
|
+
sew.Add(f)
|
|
718
|
+
sew.Perform()
|
|
719
|
+
flaps = 0
|
|
720
|
+
for _ in range(3):
|
|
721
|
+
if not sew.NbFreeEdges():
|
|
722
|
+
break
|
|
723
|
+
cut_faces = _flap_faces(sew, 0.25 * h * h)
|
|
724
|
+
if not cut_faces:
|
|
725
|
+
break
|
|
726
|
+
flaps += len(cut_faces)
|
|
727
|
+
keep_faces = [f for f in _faces_list(sew.SewedShape()) if not any(f.IsSame(c) for c in cut_faces)]
|
|
728
|
+
sew = BRepBuilderAPI_Sewing(tol)
|
|
729
|
+
for f in keep_faces:
|
|
730
|
+
sew.Add(f)
|
|
731
|
+
sew.Perform()
|
|
732
|
+
if not sew.NbFreeEdges():
|
|
733
|
+
break
|
|
734
|
+
shells = []
|
|
735
|
+
ex = TopExp_Explorer(sew.SewedShape(), TopAbs_SHELL)
|
|
736
|
+
while ex.More():
|
|
737
|
+
shells.append(TopoDS.Shell(ex.Current()))
|
|
738
|
+
ex.Next()
|
|
739
|
+
return shells, {"pieces": len(pieces), "kept": int(keep.sum()), "noInteriorPoint": nopoint,
|
|
740
|
+
"freeEdges": int(sew.NbFreeEdges()), "sewTolerance": tol, "flapsDropped": flaps,
|
|
741
|
+
"splitSeconds": round(t_split, 1), "seconds": round(time.time() - t0, 1)}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _translated(shells, v):
|
|
745
|
+
"""Copies of the shells moved by v (mm)."""
|
|
746
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
747
|
+
from OCP.gp import gp_Trsf, gp_Vec
|
|
748
|
+
from OCP.TopoDS import TopoDS
|
|
749
|
+
|
|
750
|
+
tr = gp_Trsf()
|
|
751
|
+
tr.SetTranslation(gp_Vec(*map(float, v)))
|
|
752
|
+
return [TopoDS.Shell(BRepBuilderAPI_Transform(x, tr, True).Shape()) for x in shells]
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _bore_tools(bores):
|
|
756
|
+
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
|
|
757
|
+
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
|
|
758
|
+
|
|
759
|
+
out = []
|
|
760
|
+
for p0, p1, r in bores:
|
|
761
|
+
ax = p1 - p0
|
|
762
|
+
L = float(np.linalg.norm(ax))
|
|
763
|
+
out.append(BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(*map(float, p0)), gp_Dir(*map(float, ax / L))),
|
|
764
|
+
r, L).Shape())
|
|
765
|
+
return out
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _void_shells(fam, lo, hi, E_graph):
|
|
769
|
+
"""Closed shells of the void family's patches, each with its signed
|
|
770
|
+
volume and whether it reaches outside the envelope."""
|
|
771
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Sewing
|
|
772
|
+
from OCP.Geom import Geom_BezierSurface
|
|
773
|
+
from OCP.OCP.collections import Array2_gp_Pnt
|
|
774
|
+
from OCP.TopAbs import TopAbs_SHELL
|
|
775
|
+
from OCP.TopExp import TopExp_Explorer
|
|
776
|
+
from OCP.TopoDS import TopoDS
|
|
777
|
+
|
|
778
|
+
from .ibrep import DEG
|
|
779
|
+
from .step import _pnt, _volume
|
|
780
|
+
|
|
781
|
+
t0 = time.time()
|
|
782
|
+
pieces, sheets, nonman, refined = _pieces(fam, np.asarray(lo, float), np.asarray(hi, float))
|
|
783
|
+
n = DEG + 1
|
|
784
|
+
out, open_, dev, outside = [], 0, [], 0
|
|
785
|
+
for p in pieces:
|
|
786
|
+
sh, qs = p["sheet"], p["quads"]
|
|
787
|
+
if not p["closed"]:
|
|
788
|
+
open_ += 1
|
|
789
|
+
continue
|
|
790
|
+
cen = sh.V[sh.Q[qs]].mean(axis=1)
|
|
791
|
+
dev.append(sh.deviation_of(qs[E_graph.evaluate(cen) <= 0])) # outside E is cut away
|
|
792
|
+
sew = BRepBuilderAPI_Sewing(1e-4)
|
|
793
|
+
for q in qs:
|
|
794
|
+
arr = Array2_gp_Pnt(1, n, 1, n)
|
|
795
|
+
for a in range(n):
|
|
796
|
+
for b in range(n):
|
|
797
|
+
arr.SetValue(a + 1, b + 1, _pnt(sh.C[q, a, b]))
|
|
798
|
+
sew.Add(BRepBuilderAPI_MakeFace(Geom_BezierSurface(arr), 1e-7).Face())
|
|
799
|
+
sew.Perform()
|
|
800
|
+
if sew.NbFreeEdges():
|
|
801
|
+
open_ += 1
|
|
802
|
+
continue
|
|
803
|
+
V = sh.V[np.unique(sh.Q[qs])]
|
|
804
|
+
eV = E_graph.evaluate(V)
|
|
805
|
+
if (eV > 0.1 * fam.h).all():
|
|
806
|
+
# wholly outside the envelope (the cap band where E is a loose
|
|
807
|
+
# bound on the distance, as in a bore's end): the cut takes it all
|
|
808
|
+
outside += 1
|
|
809
|
+
continue
|
|
810
|
+
crosses = bool((eV > 0).any())
|
|
811
|
+
# the quads' own winding says which side is void (sewing orients a
|
|
812
|
+
# shell after whichever face it starts from, and a few patches on a
|
|
813
|
+
# rough field can face the wrong way): their signed volume decides
|
|
814
|
+
T = np.concatenate([sh.Q[qs][:, [0, 1, 2]], sh.Q[qs][:, [0, 2, 3]]])
|
|
815
|
+
X = sh.V
|
|
816
|
+
qvol = float(np.einsum("ij,ij->i", X[T[:, 0]], np.cross(X[T[:, 1]], X[T[:, 2]])).sum() / 6.0)
|
|
817
|
+
ex = TopExp_Explorer(sew.SewedShape(), TopAbs_SHELL)
|
|
818
|
+
while ex.More():
|
|
819
|
+
s = TopoDS.Shell(ex.Current())
|
|
820
|
+
v = _volume(_shell_solid(s))
|
|
821
|
+
if v * qvol < 0:
|
|
822
|
+
s = TopoDS.Shell(s.Reversed())
|
|
823
|
+
v = -v
|
|
824
|
+
if abs(v) >= 0.01: # a speck where the surface grazes a grid node
|
|
825
|
+
out.append({"shell": s, "volume": v, "crosses": crosses})
|
|
826
|
+
ex.Next()
|
|
827
|
+
dev = np.concatenate(dev) if dev else np.zeros(1)
|
|
828
|
+
rep = {"h": fam.h, "patches": int(sum(len(p["quads"]) for p in pieces)),
|
|
829
|
+
"components": len(pieces), "openComponents": open_, "outsideComponents": outside,
|
|
830
|
+
"refinedBoxes": refined,
|
|
831
|
+
"foldedPatches": int(sum(p["folds"] for p in pieces)), "nonManifoldEdges": int(nonman),
|
|
832
|
+
"chordEdges": int(sum(s.wild_edges for s in sheets)), "coonsPatches": int(sum(s.wild_patches for s in sheets)),
|
|
833
|
+
"patchDeviation": {"p99": round(float(np.percentile(dev, 99)), 4), "max": round(float(dev.max()), 4)},
|
|
834
|
+
"seconds": round(time.time() - t0, 1)}
|
|
835
|
+
return out, rep
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _log(progress, msg):
|
|
839
|
+
if progress:
|
|
840
|
+
progress({"stage": "lattice-step", "message": msg})
|
|
841
|
+
if os.environ.get("LOADPATH_VERBOSE"):
|
|
842
|
+
print(f"[latticestep] {msg}", flush=True)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
VOLUME_SLACK = 0.015 # solid this much heavier than the STL: void lost (pockets under the grid)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _grid_shift(lo, hi, h):
|
|
849
|
+
"""Where the patch grid starts (a shift under one patch, per axis): no
|
|
850
|
+
grid plane within a quarter patch of the part's bounding planes, where
|
|
851
|
+
its flat faces usually are. A row of patch corners a few microns off a
|
|
852
|
+
face leaves sliver pieces when the void is cut there (and OpenCascade's
|
|
853
|
+
own cut crawled on them)."""
|
|
854
|
+
out = np.zeros(3)
|
|
855
|
+
for a in range(3):
|
|
856
|
+
best = None
|
|
857
|
+
for t in np.linspace(0.05, 0.95, 37):
|
|
858
|
+
# grid planes sit at lo - t h + k h: their offsets from lo and hi
|
|
859
|
+
d0 = t % 1.0
|
|
860
|
+
d1 = ((hi[a] - lo[a]) / h + t) % 1.0
|
|
861
|
+
m = min(d0, 1 - d0, d1, 1 - d1)
|
|
862
|
+
if best is None or m > best[0] + 1e-9:
|
|
863
|
+
best = (m, t)
|
|
864
|
+
out[a] = best[1] * h
|
|
865
|
+
return out
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def export_void_step(doc, g, out_dir, name, base_dir=None, sources=None, meshes=None, h=None,
|
|
869
|
+
surface="smooth", part_mesh=None, progress=None, faces=None, patches=None):
|
|
870
|
+
"""<name>.step for a part with lattices of any kind (strut cells, Voronoi
|
|
871
|
+
foams, TPMS sheets or networks; graded, conformal; under a skin or open
|
|
872
|
+
at the outside): the envelope (the part with its lattices solid; exact
|
|
873
|
+
faces, smooth patches) less the void (void_family: one smooth level set
|
|
874
|
+
of the part's own field, node blends and fillets included, as bicubic
|
|
875
|
+
patches on the true surface). Void that is sealed inside the part goes
|
|
876
|
+
in as inner shells; void that reaches the outside (an open lattice) is
|
|
877
|
+
cut away in one OpenCascade boolean, along with any drain bores.
|
|
878
|
+
Returns (report, shape)."""
|
|
879
|
+
from OCP.BRep import BRep_Builder
|
|
880
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
|
|
881
|
+
from OCP.TopAbs import TopAbs_SHELL
|
|
882
|
+
from OCP.TopExp import TopExp_Explorer
|
|
883
|
+
from OCP.TopoDS import TopoDS, TopoDS_Compound
|
|
884
|
+
|
|
885
|
+
from .step import (
|
|
886
|
+
DEFAULT_FACES,
|
|
887
|
+
DEFAULT_PATCHES,
|
|
888
|
+
_volume,
|
|
889
|
+
export_step,
|
|
890
|
+
read_step,
|
|
891
|
+
write_step,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
t0 = time.time()
|
|
895
|
+
lattices = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
|
|
896
|
+
if not lattices:
|
|
897
|
+
raise ValueError("void STEP takes a part with lattice nodes")
|
|
898
|
+
bores, extra = _drain_bores(g)
|
|
899
|
+
env = _lattice_variant_graph_(doc, g, lattices, (), base_dir, sources, meshes, extra)
|
|
900
|
+
part = _lattice_variant_graph_(doc, g, (), (), base_dir, sources, meshes, extra)
|
|
901
|
+
body = _lattice_variant_graph_(doc, g, (), lattices, base_dir, sources, meshes, extra)
|
|
902
|
+
em = env.mesh(progress=progress)
|
|
903
|
+
st = export_step(em, os.path.join(out_dir, f"{name}-envelope.step"), faces=int(faces or DEFAULT_FACES),
|
|
904
|
+
name=name, surface=surface, patches=int(patches or DEFAULT_PATCHES))
|
|
905
|
+
outer = st.pop("shape")
|
|
906
|
+
lo, hi = np.asarray(em.bounds[0], float), np.asarray(em.bounds[1], float)
|
|
907
|
+
feat, cell = _feature(g, lattices, em.sample(4000) if len(em.faces) else lo[None])
|
|
908
|
+
h_given = h is not None
|
|
909
|
+
if h is None:
|
|
910
|
+
h = min(cell / 6.0, feat / 2.0)
|
|
911
|
+
h = float(h)
|
|
912
|
+
S = np.random.default_rng(0).uniform(lo, hi, (2000, 3))
|
|
913
|
+
bv = body.evaluate(S)
|
|
914
|
+
if (bv >= 100).all():
|
|
915
|
+
body = None # lattices alone: nothing to keep material outside
|
|
916
|
+
fam, cap = void_family(part, env, body, lattices, h)
|
|
917
|
+
# the grid off the part's own round numbers: a row of patch corners on a
|
|
918
|
+
# face of the envelope (a box face through the cell nodes) makes the cut
|
|
919
|
+
# meet patch edges lying in its plane, which OpenCascade crawls through
|
|
920
|
+
ex = TopExp_Explorer(outer, TopAbs_SHELL)
|
|
921
|
+
outs = []
|
|
922
|
+
while ex.More():
|
|
923
|
+
outs.append(TopoDS.Shell(ex.Current()))
|
|
924
|
+
ex.Next()
|
|
925
|
+
|
|
926
|
+
def envelope_with(voids):
|
|
927
|
+
mk = BRepBuilderAPI_MakeSolid(outs[0])
|
|
928
|
+
for s in outs[1:]:
|
|
929
|
+
mk.Add(s)
|
|
930
|
+
for s in voids:
|
|
931
|
+
mk.Add(s)
|
|
932
|
+
return mk.Solid()
|
|
933
|
+
|
|
934
|
+
def assemble(shells):
|
|
935
|
+
sealed = [x for x in shells if not x["crosses"] and x["volume"] < 0]
|
|
936
|
+
loose = [x for x in shells if not x["crosses"] and x["volume"] > 0]
|
|
937
|
+
pores = [x for x in shells if x["crosses"] and x["volume"] < 0]
|
|
938
|
+
plugs = [x for x in shells if x["crosses"] and x["volume"] > 0]
|
|
939
|
+
filled = []
|
|
940
|
+
solid = envelope_with([x["shell"] for x in sealed])
|
|
941
|
+
_log(progress, f"envelope with {len(sealed)} sealed voids; checking")
|
|
942
|
+
if sealed and not _valid(solid, False):
|
|
943
|
+
keep, bad = [], []
|
|
944
|
+
for x in sealed:
|
|
945
|
+
(keep if _valid(envelope_with([x["shell"]]), False) else bad).append(x)
|
|
946
|
+
solid = envelope_with([x["shell"] for x in keep])
|
|
947
|
+
filled += [round(-x["volume"], 2) for x in bad]
|
|
948
|
+
sealed = keep
|
|
949
|
+
# open pores (void reaching out through the envelope) and any plugs (a
|
|
950
|
+
# strut stub the envelope cut off, wrapped by a pore): the envelope split
|
|
951
|
+
# by them and the pieces picked by the fields (_split_cut)
|
|
952
|
+
split = None
|
|
953
|
+
if pores or plugs:
|
|
954
|
+
t1 = time.time()
|
|
955
|
+
pieces, split = _split_cut(outs, [x["shell"] for x in pores + plugs], _bore_tools(bores), bores, env,
|
|
956
|
+
fam, h)
|
|
957
|
+
if split["freeEdges"]:
|
|
958
|
+
# the void crossing the envelope's faces microns from its
|
|
959
|
+
# edges or from another crossing (a lattice whose nodes lie on
|
|
960
|
+
# the faces: a conformal frame lays a layer on every face):
|
|
961
|
+
# the general fuse leaves wedge pieces there that the fields
|
|
962
|
+
# cannot classify, so the sewing keeps holes (39 free edges
|
|
963
|
+
# on an open 12 mm conformal octet block). The envelope moved
|
|
964
|
+
# a fiftieth of a patch, in no symmetric direction, parts
|
|
965
|
+
# those coincidences; the part moves less than its patches'
|
|
966
|
+
# own deviation from the field.
|
|
967
|
+
nudge = h * np.array([0.026, 0.022, 0.014])
|
|
968
|
+
outs_n = _translated(outs, nudge)
|
|
969
|
+
p2, s2 = _split_cut(outs_n, [x["shell"] for x in pores + plugs], _bore_tools(bores), bores, env,
|
|
970
|
+
fam, h)
|
|
971
|
+
if s2["freeEdges"] < split["freeEdges"]:
|
|
972
|
+
s2["envelopeNudge"] = [round(float(x), 4) for x in nudge]
|
|
973
|
+
s2["freeEdgesBeforeNudge"] = split["freeEdges"]
|
|
974
|
+
pieces, split = p2, s2
|
|
975
|
+
# the biggest shell is the part's outside; another one inside it
|
|
976
|
+
# (OpenCascade's classifier, a point on it) is a cavity, an inner
|
|
977
|
+
# shell: one of the envelope's the void did not touch (a cable
|
|
978
|
+
# channel) or one the split left whole. Outside it: a body of
|
|
979
|
+
# its own (a strut stub cut loose).
|
|
980
|
+
sized = sorted(((abs(_volume(_shell_solid(x))), x) for x in pieces), key=lambda t: -t[0])
|
|
981
|
+
main = _outward(sized[0][1])
|
|
982
|
+
main_solid = _shell_solid(main)
|
|
983
|
+
bodies, holes = [(sized[0][0], main)], []
|
|
984
|
+
for v, x in sized[1:]:
|
|
985
|
+
if _inside_any([main_solid], _shell_vertex(x)[None]).all():
|
|
986
|
+
holes.append((v, _outward(x, inward=True)))
|
|
987
|
+
else:
|
|
988
|
+
bodies.append((v, _outward(x)))
|
|
989
|
+
def with_inner(shells):
|
|
990
|
+
mk = BRepBuilderAPI_MakeSolid(bodies[0][1])
|
|
991
|
+
for x in shells:
|
|
992
|
+
mk.Add(x)
|
|
993
|
+
return mk.Solid()
|
|
994
|
+
|
|
995
|
+
inner = [x["shell"] for x in sealed] + [x for _, x in holes]
|
|
996
|
+
solid = with_inner(inner)
|
|
997
|
+
if sealed and not _valid(solid, False):
|
|
998
|
+
# a sealed void that passed against the whole envelope can
|
|
999
|
+
# fail against the cut one (a pocket microns off a pore's
|
|
1000
|
+
# face): checked one by one again, a failing one left solid
|
|
1001
|
+
keep, bad = [], []
|
|
1002
|
+
for x in sealed:
|
|
1003
|
+
(keep if _valid(with_inner([x["shell"]]), False) else bad).append(x)
|
|
1004
|
+
solid = with_inner([x["shell"] for x in keep] + [x for _, x in holes])
|
|
1005
|
+
if keep and not _valid(solid, False):
|
|
1006
|
+
# each passes alone, some pair does not: OpenCascade's
|
|
1007
|
+
# classifier calls one pocket inside another (a ray
|
|
1008
|
+
# grazing a patch edge of a small pocket); taken one at
|
|
1009
|
+
# a time, a pocket that breaks the solid is left solid
|
|
1010
|
+
base = [x for _, x in holes]
|
|
1011
|
+
took = []
|
|
1012
|
+
for x in sorted(keep, key=lambda y: y["volume"]): # biggest first
|
|
1013
|
+
if _valid(with_inner(base + [y["shell"] for y in took] + [x["shell"]]), False):
|
|
1014
|
+
took.append(x)
|
|
1015
|
+
else:
|
|
1016
|
+
bad.append(x)
|
|
1017
|
+
keep = took
|
|
1018
|
+
solid = with_inner(base + [x["shell"] for x in keep])
|
|
1019
|
+
filled += [round(-x["volume"], 2) for x in bad]
|
|
1020
|
+
sealed = keep
|
|
1021
|
+
split["cavities"] = len(holes)
|
|
1022
|
+
if len(bodies) > 1:
|
|
1023
|
+
bb = BRep_Builder()
|
|
1024
|
+
comp = TopoDS_Compound()
|
|
1025
|
+
bb.MakeCompound(comp)
|
|
1026
|
+
bb.Add(comp, solid)
|
|
1027
|
+
for _, x in bodies[1:]:
|
|
1028
|
+
bb.Add(comp, _shell_solid(x))
|
|
1029
|
+
solid = comp
|
|
1030
|
+
split["bodies"] = len(bodies)
|
|
1031
|
+
_log(progress, f"split {split['pieces']} face pieces, kept {split['kept']}, {len(bodies)} bodies: "
|
|
1032
|
+
f"{time.time() - t1:.0f} s")
|
|
1033
|
+
elif bores:
|
|
1034
|
+
solid = _bop("cut", [solid], _bore_tools(bores))
|
|
1035
|
+
if loose:
|
|
1036
|
+
bb = BRep_Builder()
|
|
1037
|
+
comp = TopoDS_Compound()
|
|
1038
|
+
bb.MakeCompound(comp)
|
|
1039
|
+
for x in [solid] + [_shell_solid(x["shell"]) for x in loose]:
|
|
1040
|
+
bb.Add(comp, x)
|
|
1041
|
+
solid = comp
|
|
1042
|
+
_log(progress, "checking the solid")
|
|
1043
|
+
valid = _valid(solid)
|
|
1044
|
+
return solid, sealed, filled, split, pores, plugs, loose, valid
|
|
1045
|
+
|
|
1046
|
+
tries = []
|
|
1047
|
+
nudge = 0
|
|
1048
|
+
best = None
|
|
1049
|
+
for attempt in range(3):
|
|
1050
|
+
_log(progress, f"void patches at h {h:.3f} mm")
|
|
1051
|
+
j = (_grid_shift(lo, hi, h) + nudge * 0.11 * h * np.array([1.0, 0.618, 0.382])) % h
|
|
1052
|
+
shells, vrep = _void_shells(fam, lo - cap - j, hi + cap, env)
|
|
1053
|
+
_log(progress, f"{vrep['patches']} patches, {len(shells)} shells, {vrep['openComponents']} open, "
|
|
1054
|
+
f"{vrep['seconds']} s")
|
|
1055
|
+
last = h_given or attempt == 2
|
|
1056
|
+
if vrep["openComponents"] and not last:
|
|
1057
|
+
# a void surface that does not close (the grid could not tell two
|
|
1058
|
+
# sheets apart somewhere: a gap or a fold under the patch size) is
|
|
1059
|
+
# meshed again finer
|
|
1060
|
+
tries.append({"grid": round(h, 4), "openComponents": vrep["openComponents"]})
|
|
1061
|
+
h *= 0.7
|
|
1062
|
+
fam, cap = void_family(part, env, body, lattices, h)
|
|
1063
|
+
continue
|
|
1064
|
+
# (a face the split leaves invalid, two void sheets crossing the
|
|
1065
|
+
# envelope's face microns apart so its wire folds, is not retried: on
|
|
1066
|
+
# the open Voronoi block two finer grids failed the same way)
|
|
1067
|
+
got = assemble(shells)
|
|
1068
|
+
filled_now = sum(got[2])
|
|
1069
|
+
if best is None or (got[7], -filled_now) > (best[0][7], -sum(best[0][2])):
|
|
1070
|
+
best = (got, shells, vrep)
|
|
1071
|
+
if filled_now > 0.01 * abs(_volume(outer)) and not last:
|
|
1072
|
+
# a big sealed cavity OpenCascade rejects (its patches fold where
|
|
1073
|
+
# struts meet a skin at a crease, which grid placement makes or
|
|
1074
|
+
# breaks: a 12 mm skinned conformal block's main cavity, 639 of
|
|
1075
|
+
# its 1728 mm^3, failed on one placement and passed on two
|
|
1076
|
+
# others) is meshed again on a grid moved a ninth of a patch
|
|
1077
|
+
# (small pockets left solid are not worth another meshing)
|
|
1078
|
+
tries.append({"grid": round(h, 4), "filledCavities": round(float(filled_now), 2)})
|
|
1079
|
+
nudge += 1
|
|
1080
|
+
continue
|
|
1081
|
+
(solid, sealed, filled, split, pores, plugs, loose, valid), shells, vrep = best
|
|
1082
|
+
# (nor are pockets thinner than the patch size, a wedge where a strut
|
|
1083
|
+
# meets a skin at a shallow angle, that fall between grid nodes: the
|
|
1084
|
+
# solid reads heavier than the STL and the report says so. Meshing
|
|
1085
|
+
# them finer made OpenCascade's solid check crash on the BCC test)
|
|
1086
|
+
break
|
|
1087
|
+
vrep["retries"] = tries
|
|
1088
|
+
_log(progress, f"valid {valid}; writing")
|
|
1089
|
+
rep = {"export": "envelope less void", "lattices": lattices, "grid": round(h, 4), "cap": round(cap, 4),
|
|
1090
|
+
"envelope": st, "void": vrep, "split": split, "sealedCavities": len(sealed), "openPores": len(pores),
|
|
1091
|
+
"plugs": len(plugs), "looseBodies": len(loose), "filledCavities": filled,
|
|
1092
|
+
"drainBores": len(bores), "valid": valid, "volume": round(abs(_volume(solid)), 2),
|
|
1093
|
+
"envelopeVolume": round(abs(_volume(outer)), 2)}
|
|
1094
|
+
if part_mesh is not None:
|
|
1095
|
+
rep["meshVolume"] = round(float(abs(part_mesh.volume)), 2)
|
|
1096
|
+
# the envelope's own file needed its faces' 2-D curves to read back
|
|
1097
|
+
# valid (a sliver patch): so does the part, which has its faces
|
|
1098
|
+
pcurves = bool(st.get("pcurves"))
|
|
1099
|
+
rep.update(write_step(solid, os.path.join(out_dir, f"{name}.step"), name=name, pcurves=pcurves))
|
|
1100
|
+
t1 = time.time()
|
|
1101
|
+
back = read_step(rep["path"])
|
|
1102
|
+
rep["readBack"] = {"seconds": round(time.time() - t1, 1), "volume": round(abs(_volume(back)), 2),
|
|
1103
|
+
"valid": _valid(back)}
|
|
1104
|
+
if valid and not rep["readBack"]["valid"] and not pcurves:
|
|
1105
|
+
_log(progress, "read back invalid without 2-D curves; writing them")
|
|
1106
|
+
pcurves = True
|
|
1107
|
+
rep.update(write_step(solid, rep["path"], name=name, pcurves=True))
|
|
1108
|
+
t1 = time.time()
|
|
1109
|
+
back = read_step(rep["path"])
|
|
1110
|
+
rep["readBack"] = {"seconds": round(time.time() - t1, 1), "volume": round(abs(_volume(back)), 2),
|
|
1111
|
+
"valid": _valid(back)}
|
|
1112
|
+
rep["pcurves"] = pcurves
|
|
1113
|
+
del back # a full-size part's re-read is gigabytes
|
|
1114
|
+
rep["readBack"]["ok"] = rep["readBack"]["valid"] and abs(rep["readBack"]["volume"] - rep["volume"]) < 1e-3 * rep["volume"]
|
|
1115
|
+
_log(progress, "read back; distance to the STL")
|
|
1116
|
+
if part_mesh is not None:
|
|
1117
|
+
rep["stlDistance"] = stl_distance(solid, part_mesh)
|
|
1118
|
+
# complete: every void went in (none left open, filled or uncuttable)
|
|
1119
|
+
# and the solid no heavier than the STL by more than its facets explain
|
|
1120
|
+
heavy = part_mesh is not None and rep["volume"] > (1 + VOLUME_SLACK) * rep["meshVolume"]
|
|
1121
|
+
rep["complete"] = bool(not vrep["openComponents"] and not filled
|
|
1122
|
+
and (split is None or not split["freeEdges"]) and not heavy)
|
|
1123
|
+
if not rep["complete"]:
|
|
1124
|
+
rep["warning"] = (f"the STEP is missing lattice void: {vrep['openComponents']} void surface(s) did not "
|
|
1125
|
+
f"close, {len(filled)} sealed cavit(ies) left solid, "
|
|
1126
|
+
f"{(split or {}).get('freeEdges', 0)} free edges where the open void was cut"
|
|
1127
|
+
+ (f", {100 * (rep['volume'] / rep['meshVolume'] - 1):.1f}% heavier than the STL "
|
|
1128
|
+
"(pockets thinner than the grid)" if heavy else "")
|
|
1129
|
+
+ "; try a finer latticeGrid")
|
|
1130
|
+
if not valid:
|
|
1131
|
+
rep["warning"] = (rep.get("warning", "") + "; " if rep.get("warning") else "") + (
|
|
1132
|
+
"OpenCascade's check rejects the solid (an envelope face where two lattice surfaces cross it "
|
|
1133
|
+
"microns apart, typically); a CAD tool may need to heal it")
|
|
1134
|
+
rep["ok"] = bool(valid and rep["readBack"]["ok"] and rep["complete"])
|
|
1135
|
+
rep["seconds"] = round(time.time() - t0, 1)
|
|
1136
|
+
return rep, solid
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def stl_distance(shape, mesh, n=40000, deflection=None):
|
|
1140
|
+
"""How far a B-rep and the printed mesh are apart (mm), both ways:
|
|
1141
|
+
points on the B-rep's faces to the mesh, points on the mesh to the
|
|
1142
|
+
B-rep's tessellation (`deflection` mm). Mean, p99, max of each.
|
|
1143
|
+
The default deflection is 0.01 mm up to 20k faces, coarser past that
|
|
1144
|
+
(to 0.03), and the angular limit loose so the chord sets it: 0.01 mm
|
|
1145
|
+
and 0.1 rad on a full-size arm link's 46k patches was 34M triangles and
|
|
1146
|
+
took over 13 GB."""
|
|
1147
|
+
from .sharp import mesh_distance
|
|
1148
|
+
from .step import shape_to_trimesh
|
|
1149
|
+
|
|
1150
|
+
if deflection is None:
|
|
1151
|
+
from OCP.TopAbs import TopAbs_FACE
|
|
1152
|
+
from OCP.TopExp import TopExp_Explorer
|
|
1153
|
+
|
|
1154
|
+
nf, ex = 0, TopExp_Explorer(shape, TopAbs_FACE)
|
|
1155
|
+
while ex.More():
|
|
1156
|
+
nf += 1
|
|
1157
|
+
ex.Next()
|
|
1158
|
+
deflection = round(min(0.03, 0.01 * max(1.0, nf / 20000)), 4)
|
|
1159
|
+
|
|
1160
|
+
t = shape_to_trimesh(shape, deflection=deflection, angle=0.5)
|
|
1161
|
+
a = mesh_distance(mesh, t.sample(n), cap=2.0)
|
|
1162
|
+
b = mesh_distance(t, mesh.sample(n), cap=2.0)
|
|
1163
|
+
|
|
1164
|
+
def st(d):
|
|
1165
|
+
return {"mean": round(float(d.mean()), 4), "p99": round(float(np.percentile(d, 99)), 4),
|
|
1166
|
+
"max": round(float(d.max()), 4)}
|
|
1167
|
+
return {"stepToStl": st(a), "stlToStep": st(b), "tessellationDeflection": deflection}
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _lattice_variant_graph_(doc, g, solid, empty, base_dir, sources, meshes, extra):
|
|
1171
|
+
from .fields import _lattice_variant_graph
|
|
1172
|
+
|
|
1173
|
+
return _lattice_variant_graph(doc, g, solid, empty, base_dir, sources, meshes, extra=extra)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def export_lattice_step(doc, g, out_dir, name, base_dir=None, sources=None, meshes=None, h=None,
|
|
1177
|
+
surface="smooth", part_mesh=None, progress=None, faces=None, patches=None):
|
|
1178
|
+
"""step lattice "exact": the cavity route (export_cavity_step: each side
|
|
1179
|
+
of the walls its own smooth level set, patches a sixth of a cell) for one
|
|
1180
|
+
TPMS sheet lattice sealed under a skin, since its patches need not
|
|
1181
|
+
resolve the wall; the void route (export_void_step: patches half the
|
|
1182
|
+
thinnest strut or wall) for everything else: strut cells, foams,
|
|
1183
|
+
networks, several lattices, lattices open at the outside."""
|
|
1184
|
+
from .struts import is_strut
|
|
1185
|
+
|
|
1186
|
+
lattices = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
|
|
1187
|
+
kw = {"base_dir": base_dir, "sources": sources, "meshes": meshes, "h": h, "surface": surface,
|
|
1188
|
+
"part_mesh": part_mesh, "progress": progress, "faces": faces, "patches": patches}
|
|
1189
|
+
route = "void"
|
|
1190
|
+
if len(lattices) == 1:
|
|
1191
|
+
s = g.nodes[lattices[0]].spec
|
|
1192
|
+
if not is_strut(s.get("type", "gyroid")) and s.get("form", "sheet") == "sheet":
|
|
1193
|
+
_, extra = _drain_bores(g)
|
|
1194
|
+
env = _lattice_variant_graph_(doc, g, lattices, (), base_dir, sources, meshes, extra)
|
|
1195
|
+
part = _lattice_variant_graph_(doc, g, (), (), base_dir, sources, meshes, extra)
|
|
1196
|
+
P = env.mesh(progress=progress).sample(20000)
|
|
1197
|
+
# sealed: the part's own surface is the envelope's everywhere
|
|
1198
|
+
# (no lattice opening onto it)
|
|
1199
|
+
if (part.evaluate(P) < 0.05).mean() > 0.999:
|
|
1200
|
+
route = "cavities"
|
|
1201
|
+
if route == "cavities":
|
|
1202
|
+
rep, shape = export_cavity_step(doc, g, out_dir, name, **kw)
|
|
1203
|
+
if part_mesh is not None:
|
|
1204
|
+
rep["stlDistance"] = stl_distance(shape, part_mesh)
|
|
1205
|
+
rep["complete"] = bool(not any(c["openCavities"] for c in rep["cavities"]) and not rep["filledCavities"])
|
|
1206
|
+
rep["ok"] = bool(rep["valid"] and rep["readBack"]["ok"] and rep["complete"])
|
|
1207
|
+
else:
|
|
1208
|
+
rep, shape = export_void_step(doc, g, out_dir, name, **kw)
|
|
1209
|
+
rep["route"] = route
|
|
1210
|
+
return rep, shape
|