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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
@@ -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