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
loadpath/step.py ADDED
@@ -0,0 +1,806 @@
1
+ """
2
+ STEP export: the part as a B-rep a CAD package can work with.
3
+
4
+ The part's exact pieces stay exact: every box face is a plane, every bore,
5
+ boss and bearing seat a true cylinder, every sphere a sphere, so a CAD user
6
+ can dimension, mate and drill against them. Those pieces come from the CSG
7
+ plan a part was built with (sharp.py for field graphs, reconstruct.py for
8
+ studies): the same exact shapes manifold3d used for the STL, rebuilt in
9
+ OpenCascade, and the organic leaves as faceted solids (one planar face per
10
+ triangle, decimated to a face budget first). OpenCascade's booleans then cut
11
+ the exact shapes from the facets, so the seam where a bore meets the organic
12
+ body is an exact intersection curve on the cylinder, and coplanar facets
13
+ merge into single faces.
14
+
15
+ A mesh with no plan (any STL) exports fully faceted.
16
+
17
+ Needs OpenCascade's Python bindings (`pip install "loadpath[step]"`, which is
18
+ cadquery-ocp).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+
24
+ import numpy as np
25
+
26
+ __all__ = ["attach_plan", "available", "export_step", "plan_of", "read_step", "shape_to_trimesh"]
27
+
28
+ DEFAULT_FACES = 20_000 # organic facets in the B-rep: ~14 MB of STEP, ~20 s
29
+ FUZZY = 1e-4 # boolean fuzz (mm): facets snapped onto exact faces
30
+ POINT_DECIMALS = 5 # mm places for written points (10 nm)
31
+ DEFAULT_PATCHES = 2000 # surface="smooth": organic triangles to start the patch layout from (refined where it misses)
32
+
33
+
34
+ def available():
35
+ try:
36
+ import OCP # noqa: F401
37
+ return True
38
+ except ImportError:
39
+ return False
40
+
41
+
42
+ def _need():
43
+ if not available():
44
+ raise RuntimeError('STEP export needs OpenCascade: pip install "loadpath[step]" (cadquery-ocp)')
45
+
46
+
47
+ def attach_plan(mesh, plan):
48
+ """Remember the CSG plan a mesh was built from (an attribute, not
49
+ metadata: metadata is copied and serialized)."""
50
+ mesh._loadpath_csg = plan
51
+ return mesh
52
+
53
+
54
+ def plan_of(mesh):
55
+ return getattr(mesh, "_loadpath_csg", None)
56
+
57
+
58
+ # ---- OpenCascade shapes ------------------------------------------------------------
59
+
60
+ def _pnt(v):
61
+ from OCP.gp import gp_Pnt
62
+ return gp_Pnt(*(float(x) for x in v))
63
+
64
+
65
+ def _faceted(mesh):
66
+ """A closed trimesh as an OpenCascade solid, one planar face per
67
+ triangle, sharing edges (BRepBuilderAPI_MakeShapeOnMesh)."""
68
+ from OCP.BRep import BRep_Builder
69
+ from OCP.BRepBuilderAPI import (
70
+ BRepBuilderAPI_MakeShapeOnMesh,
71
+ BRepBuilderAPI_MakeSolid,
72
+ )
73
+ from OCP.Poly import Poly_Triangle, Poly_Triangulation
74
+ from OCP.TopAbs import TopAbs_FACE
75
+ from OCP.TopExp import TopExp_Explorer
76
+ from OCP.TopoDS import TopoDS_Shell
77
+
78
+ V = np.asarray(mesh.vertices, float)
79
+ F = np.asarray(mesh.faces, np.int64)
80
+ tri = Poly_Triangulation(len(V), len(F), False)
81
+ for i, v in enumerate(V):
82
+ tri.SetNode(i + 1, _pnt(v))
83
+ for i, f in enumerate(F):
84
+ tri.SetTriangle(i + 1, Poly_Triangle(int(f[0]) + 1, int(f[1]) + 1, int(f[2]) + 1))
85
+ mk = BRepBuilderAPI_MakeShapeOnMesh(tri)
86
+ mk.Build()
87
+ b = BRep_Builder()
88
+ shell = TopoDS_Shell()
89
+ b.MakeShell(shell)
90
+ ex = TopExp_Explorer(mk.Shape(), TopAbs_FACE)
91
+ while ex.More():
92
+ b.Add(shell, ex.Current())
93
+ ex.Next()
94
+ shell.Closed(True)
95
+ return BRepBuilderAPI_MakeSolid(shell).Solid()
96
+
97
+
98
+ def _exact(shape, faces):
99
+ """An exact shapes.Shape as an OpenCascade solid."""
100
+ from OCP.BRepPrimAPI import (
101
+ BRepPrimAPI_MakeBox,
102
+ BRepPrimAPI_MakeCylinder,
103
+ BRepPrimAPI_MakeSphere,
104
+ )
105
+ from OCP.gp import gp_Ax2, gp_Dir
106
+
107
+ from .shapes import (
108
+ Box,
109
+ Cylinder,
110
+ MeshShape,
111
+ Shell,
112
+ Sphere,
113
+ Union,
114
+ trimesh_from_manifold,
115
+ )
116
+
117
+ if isinstance(shape, Box):
118
+ return BRepPrimAPI_MakeBox(_pnt(shape.lo), _pnt(shape.hi)).Shape()
119
+ if isinstance(shape, Cylinder):
120
+ ax = gp_Ax2(_pnt(shape.p0), gp_Dir(*(float(x) for x in shape.axis)))
121
+ outer = BRepPrimAPI_MakeCylinder(ax, shape.r, shape.length).Shape()
122
+ if shape.r_inner > 0:
123
+ a = shape.axis
124
+ ax_i = gp_Ax2(_pnt(shape.p0 - a), gp_Dir(*(float(x) for x in a)))
125
+ inner = BRepPrimAPI_MakeCylinder(ax_i, shape.r_inner, shape.length + 2.0).Shape()
126
+ outer = _bool("subtract", [outer, inner])
127
+ return outer
128
+ if isinstance(shape, Sphere):
129
+ return BRepPrimAPI_MakeSphere(_pnt(shape.center), shape.r).Shape()
130
+ if isinstance(shape, Union):
131
+ return _bool("union", [_exact(s, faces) for s in shape.shapes])
132
+ if isinstance(shape, Shell):
133
+ man = _bool("subtract", [_exact(shape.outer, faces), _exact(shape.inner, faces)])
134
+ return man if shape.clip is None else _bool("intersect", [man, _exact(shape.clip, faces)])
135
+ if isinstance(shape, MeshShape):
136
+ return _faceted(_decimate(shape.mesh, faces))
137
+ return _faceted(_decimate(trimesh_from_manifold(shape.to_manifold()), faces))
138
+
139
+
140
+ def _bool(op, shapes):
141
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
142
+
143
+ if len(shapes) == 1:
144
+ return shapes[0]
145
+ cls = {"union": BRepAlgoAPI_Fuse, "intersect": BRepAlgoAPI_Common, "subtract": BRepAlgoAPI_Cut}[op]
146
+ if op == "intersect":
147
+ out = shapes[0]
148
+ for s in shapes[1:]:
149
+ out = _bool1(cls, [out], [s])
150
+ return out
151
+ return _bool1(cls, shapes[:1], shapes[1:])
152
+
153
+
154
+ def _bool1(cls, args, tools, fuzzy=FUZZY):
155
+ from OCP.OCP.collections import List_TopoDS_Shape
156
+
157
+ a, t = List_TopoDS_Shape(), List_TopoDS_Shape()
158
+ for s in args:
159
+ a.Append(s)
160
+ for s in tools:
161
+ t.Append(s)
162
+ op = cls()
163
+ op.SetArguments(a)
164
+ op.SetTools(t)
165
+ op.SetFuzzyValue(fuzzy)
166
+ op.SetRunParallel(True)
167
+ op.SetNonDestructive(True)
168
+ op.Build()
169
+ if not op.IsDone():
170
+ raise RuntimeError(f"OpenCascade {cls.__name__} failed")
171
+ return op.Shape()
172
+
173
+
174
+ def _closest(mesh, P, chunk=20_000):
175
+ import trimesh
176
+
177
+ out = np.empty_like(P)
178
+ for i in range(0, len(P), chunk):
179
+ out[i:i + chunk] = trimesh.proximity.closest_point(mesh, P[i:i + chunk])[0]
180
+ return out
181
+
182
+
183
+ def _sharpness(V, F):
184
+ """Per vertex: 1 - the least cosine between its faces' normals and its
185
+ own normal (0 on a flat patch, ~1 on a right-angle crease)."""
186
+ n = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]])
187
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
188
+ vn = np.zeros_like(V)
189
+ for c in range(3):
190
+ np.add.at(vn, F[:, c], n)
191
+ vn /= np.maximum(np.linalg.norm(vn, axis=1, keepdims=True), 1e-300)
192
+ k = np.zeros(len(V))
193
+ for c in range(3):
194
+ np.maximum.at(k, F[:, c], 1 - np.einsum("ij,ij->i", n, vn[F[:, c]]))
195
+ return k
196
+
197
+
198
+ def _collapse(V, F, target, locked=None, turn=0.9):
199
+ """A closed mesh (V, F) coarsened to about `target` faces, shortest
200
+ edges first, keeping its topology: decimate.py's passes (the link
201
+ condition, no face turning by more than acos(`turn`)) with no field to
202
+ hold to. Each collapse keeps the sharper end where it is, so creases
203
+ and seams stay put and every vertex is one of the original ones.
204
+ Vertices flagged in `locked` never move. Returns the vertices, faces and
205
+ lock flags of the result."""
206
+ from .decimate import decimate
207
+
208
+ V = np.asarray(V, float)
209
+ F = np.asarray(F, np.int64)
210
+ k = _sharpness(V, F)
211
+
212
+ def place(W, a, b):
213
+ pick = k[a] >= k[b]
214
+ k[a] = np.maximum(k[a], k[b])
215
+ return np.where(pick[:, None], W[a], W[b])
216
+
217
+ lock = np.zeros(len(V), bool) if locked is None else np.asarray(locked, bool)
218
+ V2, F2, _, kept = decimate(V, F, None, 0.0, 1.0, locked=lock, max_passes=300, min_gain=0.002,
219
+ target=int(target), place=place, turn=turn)
220
+ return V2, F2, lock[kept]
221
+
222
+
223
+ def _fast(mesh, faces):
224
+ """fast_simplification's quadric decimation, without the zero-area
225
+ faces (a repeated vertex) it leaves behind, which make an otherwise
226
+ closed result read as torn."""
227
+ import trimesh
228
+
229
+ c = mesh.simplify_quadric_decimation(face_count=int(faces))
230
+ F = np.asarray(c.faces)
231
+ ok = (F[:, 0] != F[:, 1]) & (F[:, 1] != F[:, 2]) & (F[:, 0] != F[:, 2])
232
+ if ok.all():
233
+ return c
234
+ out = trimesh.Trimesh(c.vertices, F[ok], process=False)
235
+ out.remove_unreferenced_vertices()
236
+ return out
237
+
238
+
239
+ def _decimate(mesh, faces):
240
+ """`mesh` cut down to about `faces` triangles, still closed. A lattice
241
+ does not decimate (its walls collapse first): past 4x the budget that is
242
+ an error, not a STEP file of millions of faces."""
243
+ if len(mesh.faces) <= faces:
244
+ return mesh
245
+ try:
246
+ out = _fast(mesh, faces)
247
+ if out.is_watertight and abs(out.volume) > 0.9 * abs(mesh.volume):
248
+ return out
249
+ except Exception: # noqa: BLE001 - decimation is best-effort
250
+ pass
251
+ import trimesh
252
+
253
+ if len(mesh.faces) <= 20 * faces and mesh.is_watertight and mesh.is_winding_consistent: # tore: coarsen carefully
254
+ V, F, _ = _collapse(mesh.vertices, mesh.faces, int(faces))
255
+ out = trimesh.Trimesh(V, F, process=False)
256
+ if len(out.faces) <= 1.5 * faces and out.is_watertight:
257
+ return out
258
+ if len(mesh.faces) > 4 * faces:
259
+ raise ValueError(f"an organic region of {len(mesh.faces)} triangles does not decimate to {faces} and stay "
260
+ "closed (a lattice?): raise the STEP face budget, or hand over the STL for it")
261
+ return mesh
262
+
263
+
264
+ def _organic_budget(plan, faces):
265
+ from .sharp import Organic, _leaves
266
+
267
+ org = [lf for lf in _leaves(plan) if isinstance(lf, Organic) and lf.mesh is not None]
268
+ for lf in org:
269
+ if not hasattr(lf.mesh, "faces"): # a manifold (reconstruct.py)
270
+ from .shapes import trimesh_from_manifold
271
+
272
+ lf.mesh = trimesh_from_manifold(lf.mesh)
273
+ total = sum(len(lf.mesh.faces) for lf in org) or 1
274
+ return {id(lf): max(500, int(faces * len(lf.mesh.faces) / total)) for lf in org}
275
+
276
+
277
+ def _pieces(shape, faces):
278
+ """An exact shape as separate OpenCascade solids (a union's members):
279
+ booleans take them as separate tools, which survives members that share
280
+ faces (a motor box against its driver's box) where a pre-fused union
281
+ can come out invalid."""
282
+ from .shapes import Union
283
+
284
+ if isinstance(shape, Union):
285
+ return [p for s in shape.shapes for p in _pieces(s, faces)]
286
+ return [_exact(shape, faces)]
287
+
288
+
289
+ def _on_float32(mesh):
290
+ """The mesh with its vertices on float32's grid. manifold3d's results
291
+ come back in double precision now (shapes.trimesh_from_manifold), and a
292
+ study part's organic leaf, decimated from those, leaves OpenCascade's
293
+ booleans against the exact planes with no clean result (the lug's
294
+ STEP came out with no solid); rounded as manifold3d's float32 output
295
+ used to round them, they fuse. Moves a vertex ~1e-5 mm at most."""
296
+ import trimesh
297
+
298
+ V = np.asarray(mesh.vertices, np.float32).astype(float)
299
+ if np.array_equal(V, mesh.vertices):
300
+ return mesh
301
+ return trimesh.Trimesh(V, np.asarray(mesh.faces), process=False)
302
+
303
+
304
+ def _build(plan, budget, faces, notes):
305
+ """(OpenCascade solids, the same thing as a manifold3d Manifold). The
306
+ manifold is the referee: each OpenCascade boolean is tried a few ways
307
+ (fuzz, tools separate or fused) until it is valid and its volume agrees
308
+ with manifold3d's."""
309
+ import manifold3d as m3d
310
+
311
+ from .shapes import manifold_from_trimesh
312
+ from .sharp import Exact, Organic, exact_manifold
313
+
314
+ if isinstance(plan, Exact):
315
+ return _pieces(plan.shape, faces), exact_manifold(plan.shape)
316
+ if isinstance(plan, Organic):
317
+ m = _on_float32(plan.mesh)
318
+ d = _decimate(m, budget.get(id(plan), faces))
319
+ if len(d.faces) < len(m.faces):
320
+ notes.append({"leaf": ",".join(plan.names) or "organic", "faces": len(m.faces),
321
+ "stepFaces": len(d.faces), "deviation": _deviation(m, d)})
322
+ return [_faceted(d)], manifold_from_trimesh(d)
323
+ kids = [_build(k, budget, faces, notes) for k in plan.kids]
324
+ kind = {"union": m3d.OpType.Add, "intersect": m3d.OpType.Intersect, "subtract": m3d.OpType.Subtract}[plan.op]
325
+ want = m3d.Manifold.batch_boolean([k[1] for k in kids], kind)
326
+ if plan.op == "union":
327
+ first, tools = kids[0][0][:1], kids[0][0][1:] + [s for k in kids[1:] for s in k[0]]
328
+ else:
329
+ first = [_fuse_all(kids[0][0])]
330
+ tools = [s for k in kids[1:] for s in k[0]]
331
+ if plan.op == "intersect":
332
+ tools = [_fuse_all(tools)]
333
+ shape = _referee(plan.op, first, tools, want.volume(), notes)
334
+ return [shape], want
335
+
336
+
337
+ def _fuse_all(shapes):
338
+ return shapes[0] if len(shapes) == 1 else _bool1(_cls("union"), shapes[:1], shapes[1:])
339
+
340
+
341
+ def _cls(op):
342
+ from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
343
+
344
+ return {"union": BRepAlgoAPI_Fuse, "intersect": BRepAlgoAPI_Common, "subtract": BRepAlgoAPI_Cut}[op]
345
+
346
+
347
+ def _referee(op, first, tools, want, notes):
348
+ from OCP.BRepCheck import BRepCheck_Analyzer
349
+
350
+ tries = [(FUZZY, False), (1e-6, False), (FUZZY, True), (0.0, False), (1e-3, False)]
351
+ best = None
352
+ for fuzzy, fused in tries:
353
+ t = [_fuse_all(tools)] if fused and len(tools) > 1 else tools
354
+ try:
355
+ r = _bool1(_cls(op), first, t, fuzzy)
356
+ except RuntimeError:
357
+ continue
358
+ ok = bool(BRepCheck_Analyzer(r).IsValid())
359
+ err = abs(_volume(r) - want) / max(abs(want), 1e-9)
360
+ if ok and err < 2e-3:
361
+ return r
362
+ score = (not ok, err)
363
+ if best is None or score < best[0]:
364
+ best = (score, r)
365
+ if best is None:
366
+ raise RuntimeError(f"OpenCascade could not {op} the part")
367
+ notes.append({"warning": f"{op}: no clean OpenCascade result; kept the closest "
368
+ f"(valid {not best[0][0]}, volume off {best[0][1]:.2%})"})
369
+ return best[1]
370
+
371
+
372
+ def _deviation(full, coarse, n=4000):
373
+ """Distance (mm) from a sample of the full mesh's surface to the
374
+ decimated one: what the faceting costs."""
375
+ import trimesh
376
+
377
+ P, _ = trimesh.sample.sample_surface(full, n, seed=2)
378
+ _, d, _ = trimesh.proximity.closest_point(coarse, P)
379
+ return {"mean": round(float(d.mean()), 4), "p99": round(float(np.percentile(d, 99)), 4)}
380
+
381
+
382
+ def _keep_bodies(shape, keep_with=None):
383
+ """The largest solid and any solid touching `keep_with` (preserved
384
+ geometry): what reconstruct keeps, fragments dropped."""
385
+ from OCP.BRep import BRep_Builder
386
+ from OCP.TopAbs import TopAbs_SOLID
387
+ from OCP.TopExp import TopExp_Explorer
388
+ from OCP.TopoDS import TopoDS_Compound
389
+
390
+ solids = []
391
+ ex = TopExp_Explorer(shape, TopAbs_SOLID)
392
+ while ex.More():
393
+ solids.append(ex.Current())
394
+ ex.Next()
395
+ if len(solids) <= 1:
396
+ return shape, 0
397
+ vols = [_volume(s) for s in solids]
398
+ order = np.argsort(vols)[::-1]
399
+ kept = [solids[order[0]]]
400
+ for i in order[1:]:
401
+ if keep_with is not None and _volume(_bool("intersect", [solids[i], keep_with])) > 1e-6:
402
+ kept.append(solids[i])
403
+ b = BRep_Builder()
404
+ comp = TopoDS_Compound()
405
+ b.MakeCompound(comp)
406
+ for s in kept:
407
+ b.Add(comp, s)
408
+ return comp, len(solids) - len(kept)
409
+
410
+
411
+ def _volume(shape):
412
+ from OCP.BRepGProp import BRepGProp
413
+ from OCP.GProp import GProp_GProps
414
+
415
+ p = GProp_GProps()
416
+ BRepGProp.VolumeProperties_s(shape, p)
417
+ return float(p.Mass())
418
+
419
+
420
+ def _faces_report(shape, exact_planes):
421
+ """Faces of the B-rep by surface type, and the share of its area on
422
+ exact geometry (cylinders, spheres, and planes of the exact shapes)."""
423
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
424
+ from OCP.BRepGProp import BRepGProp
425
+ from OCP.GeomAbs import (
426
+ GeomAbs_BezierSurface,
427
+ GeomAbs_BSplineSurface,
428
+ GeomAbs_Cylinder,
429
+ GeomAbs_Plane,
430
+ GeomAbs_Sphere,
431
+ )
432
+ from OCP.GProp import GProp_GProps
433
+ from OCP.TopAbs import TopAbs_FACE
434
+ from OCP.TopExp import TopExp_Explorer
435
+ from OCP.TopoDS import TopoDS
436
+
437
+ kinds = {}
438
+ area = {"exact": 0.0, "faceted": 0.0, "smooth": 0.0}
439
+ ex = TopExp_Explorer(shape, TopAbs_FACE)
440
+ while ex.More():
441
+ f = TopoDS.Face(ex.Current())
442
+ s = BRepAdaptor_Surface(f)
443
+ t = s.GetType()
444
+ p = GProp_GProps()
445
+ BRepGProp.SurfaceProperties_s(f, p)
446
+ a = float(p.Mass())
447
+ if t == GeomAbs_Plane:
448
+ pl = s.Plane()
449
+ n = pl.Axis().Direction()
450
+ o = pl.Location()
451
+ nv = np.array([n.X(), n.Y(), n.Z()])
452
+ ov = np.array([o.X(), o.Y(), o.Z()])
453
+ exact = any(abs(abs(nv @ en) - 1) < 1e-7 and abs((ov - ep) @ en) < 1e-4 for ep, en in exact_planes)
454
+ key = "plane" if exact else "facet"
455
+ elif t == GeomAbs_Cylinder:
456
+ key, exact = "cylinder", True
457
+ elif t == GeomAbs_Sphere:
458
+ key, exact = "sphere", True
459
+ elif t in (GeomAbs_BSplineSurface, GeomAbs_BezierSurface):
460
+ key, exact = "bspline", None
461
+ else:
462
+ key, exact = "other", True
463
+ kinds[key] = kinds.get(key, 0) + 1
464
+ area["smooth" if exact is None else "exact" if exact else "faceted"] += a
465
+ ex.Next()
466
+ total = sum(area.values()) or 1.0
467
+ return {"faces": kinds, "exactArea": round(area["exact"], 1), "facetedArea": round(area["faceted"], 1),
468
+ "smoothArea": round(area["smooth"], 1), "exactAreaFraction": round(area["exact"] / total, 4)}
469
+
470
+
471
+ def _planes(shape, out):
472
+ from .shapes import Box, Cylinder, Shell, Union
473
+
474
+ if isinstance(shape, Box):
475
+ for a in range(3):
476
+ n = np.zeros(3)
477
+ n[a] = 1.0
478
+ out += [(shape.lo.copy(), n), (shape.hi.copy(), n.copy())]
479
+ elif isinstance(shape, Cylinder):
480
+ out += [(shape.p0.copy(), shape.axis.copy()), (shape.p1.copy(), shape.axis.copy())]
481
+ elif isinstance(shape, Union):
482
+ for s in shape.shapes:
483
+ _planes(s, out)
484
+ elif isinstance(shape, Shell):
485
+ for s in (shape.outer, shape.inner, shape.clip):
486
+ if s is not None:
487
+ _planes(s, out)
488
+ return out
489
+
490
+
491
+ def _bezier_faces(shape):
492
+ """A compound of the shape's Bezier and B-spline faces."""
493
+ from OCP.BRep import BRep_Builder
494
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
495
+ from OCP.GeomAbs import GeomAbs_BezierSurface, GeomAbs_BSplineSurface
496
+ from OCP.TopAbs import TopAbs_FACE
497
+ from OCP.TopExp import TopExp_Explorer
498
+ from OCP.TopoDS import TopoDS, TopoDS_Compound
499
+
500
+ bb = BRep_Builder()
501
+ out = TopoDS_Compound()
502
+ bb.MakeCompound(out)
503
+ ex = TopExp_Explorer(shape, TopAbs_FACE)
504
+ while ex.More():
505
+ if BRepAdaptor_Surface(TopoDS.Face(ex.Current())).GetType() in (GeomAbs_BezierSurface, GeomAbs_BSplineSurface):
506
+ bb.Add(out, ex.Current())
507
+ ex.Next()
508
+ return out
509
+
510
+
511
+ def _smooth_shape(part, plan, patches, notes, degree=None):
512
+ """surface="smooth": the finished mesh as a B-rep of smooth patches with
513
+ its exact faces exact (brep.py), or None (and a note) when that does not
514
+ come out a valid solid within 0.2% of the mesh's volume."""
515
+ if not hasattr(part, "faces"):
516
+ notes.append({"warning": "smooth surfaces need the part's mesh, not only its plan; faceted instead"})
517
+ return None
518
+ from OCP.BRepCheck import BRepCheck_Analyzer
519
+
520
+ from .brep import smooth_brep
521
+ from .sharp import mesh_distance
522
+
523
+ try:
524
+ solid, rep = smooth_brep(part, plan, patches, **({"order": int(degree)} if degree else {}))
525
+ vol = _volume(solid)
526
+ if not BRepCheck_Analyzer(solid).IsValid() or abs(vol - abs(part.volume)) > 2e-3 * abs(part.volume):
527
+ raise RuntimeError(f"invalid or {abs(vol / abs(part.volume) - 1):.2%} off in volume")
528
+ except Exception as e: # noqa: BLE001 - facets always work
529
+ notes.append({"warning": f"smooth surfaces failed ({e}); faceted instead"})
530
+ return None
531
+ # the patches against the mesh; the exact faces are the truth there (a
532
+ # mesh's facets sag off a big bore by more than the patches miss)
533
+ back = shape_to_trimesh(_bezier_faces(solid), deflection=0.005, angle=0.05)
534
+ if len(back.faces): # a part of exact faces only has no patches to measure
535
+ d = mesh_distance(part, back.sample(20000), cap=1.0)
536
+ rep["deviation"] = {"mean": round(float(d.mean()), 4), "p99": round(float(np.percentile(d, 99)), 4),
537
+ "max": round(float(d.max()), 4)}
538
+ notes.append({"leaf": "smooth", **rep})
539
+ return solid
540
+
541
+
542
+ def export_step(part, path, faces=DEFAULT_FACES, name=None, unify=True, check=True, surface="facets",
543
+ patches=DEFAULT_PATCHES, degree=None):
544
+ """Write `part` (a trimesh from a field graph or a study, or a CSG plan)
545
+ to a STEP file at `path`. `faces` is the budget of organic facets; the
546
+ exact shapes cost a handful of faces each. Returns a report: faces by
547
+ type, the share of the area that is exact geometry, solids, volume (and
548
+ the STL's, to compare), what decimating the organic surface cost, and
549
+ the file's size."""
550
+ _need()
551
+ import time
552
+
553
+ from OCP.BRepCheck import BRepCheck_Analyzer
554
+
555
+ from .sharp import Exact, Op, Organic, _leaves
556
+
557
+ t0 = time.time()
558
+ plan = part if isinstance(part, (Op, Exact, Organic)) else plan_of(part)
559
+ mesh_volume = None if isinstance(part, (Op, Exact, Organic)) else float(abs(part.volume))
560
+ notes = []
561
+ keep_with = None
562
+ mesh_plan = plan
563
+ if plan is None:
564
+ plan = Organic(None, ["mesh"], mesh=part)
565
+ keep = getattr(plan, "keep_with", None)
566
+ budget = _organic_budget(plan, faces)
567
+ shape = None
568
+ if surface == "smooth":
569
+ shape = _smooth_shape(part, mesh_plan if mesh_volume is not None else None, patches, notes, degree)
570
+ if shape is None:
571
+ shapes, _ = _build(plan, budget, faces, notes)
572
+ shape = _fuse_all(shapes) if len(shapes) > 1 else shapes[0]
573
+ dropped = 0
574
+ if keep is not None:
575
+ keep_with = _exact(keep, faces)
576
+ shape, dropped = _keep_bodies(shape, keep_with)
577
+ if unify:
578
+ from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
579
+
580
+ u = ShapeUpgrade_UnifySameDomain(shape, True, True, False)
581
+ u.SetLinearTolerance(1e-5)
582
+ u.Build()
583
+ shape = u.Shape()
584
+ else:
585
+ dropped = 0
586
+ valid = bool(BRepCheck_Analyzer(shape).IsValid()) if check else None
587
+ if check and not valid:
588
+ from OCP.ShapeFix import ShapeFix_Shape
589
+
590
+ fx = ShapeFix_Shape(shape)
591
+ fx.Perform()
592
+ shape = fx.Shape()
593
+ valid = bool(BRepCheck_Analyzer(shape).IsValid())
594
+ planes = []
595
+ for lf in _leaves(plan):
596
+ if isinstance(lf, Exact):
597
+ _planes(lf.shape, planes)
598
+ rep = _faces_report(shape, planes)
599
+ wrote = write_step(shape, path, name=name, verify=bool(check and valid))
600
+ from OCP.TopAbs import TopAbs_SOLID
601
+ from OCP.TopExp import TopExp_Explorer
602
+
603
+ n = 0
604
+ ex = TopExp_Explorer(shape, TopAbs_SOLID)
605
+ while ex.More():
606
+ n += 1
607
+ ex.Next()
608
+ rep.update({"path": str(path), "bytes": os.path.getsize(path), "solids": n, "valid": valid,
609
+ "volume": round(_volume(shape), 2), "seconds": round(time.time() - t0, 1)})
610
+ if "pcurves" in wrote:
611
+ rep["pcurves"] = wrote["pcurves"]
612
+ rep["readBackValid"] = wrote["readBackValid"]
613
+ rep["valid"] = bool(valid and wrote["readBackValid"])
614
+ if mesh_volume is not None:
615
+ rep["meshVolume"] = round(mesh_volume, 2)
616
+ if dropped:
617
+ rep["droppedFragments"] = dropped
618
+ if notes:
619
+ rep["decimated"] = notes
620
+ rep["shape"] = shape
621
+ return rep
622
+
623
+
624
+ def write_step(shape, path, name=None, pcurves=False, verify=False):
625
+ """Write an OpenCascade shape to STEP (mm, AP214, points compacted),
626
+ without the faces' 2-D curves unless `pcurves` (readers rebuild them,
627
+ and they are half the file). `verify` reads the file back and, if the
628
+ shape no longer passes OpenCascade's check (a reader projecting an edge
629
+ onto a sliver patch can fail where the writer's own 2-D curve was fine),
630
+ writes it again with the 2-D curves. Returns {path, bytes, writeSeconds}
631
+ (and with verify: pcurves, readBackValid)."""
632
+ import time
633
+
634
+ from OCP.Interface import Interface_Static
635
+ from OCP.STEPControl import (
636
+ STEPControl_AsIs,
637
+ STEPControl_Controller,
638
+ STEPControl_Writer,
639
+ )
640
+
641
+ t0 = time.time()
642
+ STEPControl_Controller.Init_s() # registers the write.* parameters before they are set
643
+ Interface_Static.SetCVal_s("write.step.unit", "MM")
644
+ Interface_Static.SetCVal_s("write.step.schema", "AP214IS")
645
+ Interface_Static.SetIVal_s("write.surfacecurve.mode", 1 if pcurves else 0)
646
+ if name:
647
+ Interface_Static.SetCVal_s("write.step.product.name", str(name))
648
+ w = STEPControl_Writer()
649
+ w.Transfer(shape, STEPControl_AsIs)
650
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
651
+ if int(w.Write(str(path))) != 1:
652
+ raise RuntimeError(f"OpenCascade could not write {path}")
653
+ _compact(path)
654
+ out = {"path": str(path), "bytes": os.path.getsize(path), "writeSeconds": round(time.time() - t0, 1)}
655
+ if verify:
656
+ ok = shape_valid(read_step(path))
657
+ if not ok and not pcurves:
658
+ out = write_step(shape, path, name=name, pcurves=True, verify=True)
659
+ out["writeSeconds"] = round(time.time() - t0, 1)
660
+ return out
661
+ out.update({"pcurves": bool(pcurves), "readBackValid": bool(ok)})
662
+ return out
663
+
664
+
665
+ BIG_SHELL = 8000 # faces: past this a whole-solid check is checked shell by shell
666
+
667
+
668
+ def shape_valid(shape, geom=True):
669
+ """OpenCascade's check (BRepCheck). A solid with an inner shell of more
670
+ than BIG_SHELL faces is checked shell by shell (each as a solid of its
671
+ own): the whole-solid check classifies every shell against every other
672
+ and went past 12 GB on an arm link's 38k-patch void."""
673
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
674
+ from OCP.BRepCheck import BRepCheck_Analyzer
675
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
676
+ from OCP.TopExp import TopExp_Explorer
677
+ from OCP.TopoDS import TopoDS
678
+
679
+ def nfaces(x):
680
+ ex, n = TopExp_Explorer(x, TopAbs_FACE), 0
681
+ while ex.More():
682
+ n += 1
683
+ ex.Next()
684
+ return n
685
+
686
+ shells = []
687
+ ex = TopExp_Explorer(shape, TopAbs_SHELL)
688
+ while ex.More():
689
+ shells.append(TopoDS.Shell(ex.Current()))
690
+ ex.Next()
691
+ if len(shells) < 2 or max(nfaces(x) for x in shells) <= BIG_SHELL:
692
+ return bool(BRepCheck_Analyzer(shape, geom).IsValid())
693
+ return all(BRepCheck_Analyzer(BRepBuilderAPI_MakeSolid(x).Solid(), geom).IsValid() for x in shells)
694
+
695
+
696
+ def _compact(path, decimals=POINT_DECIMALS):
697
+ """Rewrite a STEP file smaller: points to `decimals` places (mm; 10 nm
698
+ is a thousandth of the sewing tolerance) and each distinct point once.
699
+ Three quarters of a smooth STEP file is points, and a patch's edge poles
700
+ are the same points as its edge curve's and its neighbour's."""
701
+ import re
702
+
703
+ with open(path) as fh:
704
+ text = fh.read()
705
+ head, sep, rest = text.partition("DATA;")
706
+ data, sep2, tail = rest.partition("ENDSEC;")
707
+ if not sep or not sep2:
708
+ return
709
+ ents = re.split(r"\n(?=#\d+\s*=)", data)
710
+ num = re.compile(r"-?\d+\.\d*(?:E[-+]?\d+)?")
711
+ point = re.compile(r"#(\d+)\s*=\s*CARTESIAN_POINT\('([^']*)',\((.*)\)\)\s*;\s*$", re.DOTALL)
712
+
713
+ def short(x):
714
+ t = f"{float(x):.{decimals}f}".rstrip("0")
715
+ return "0." if t in ("-0.", "0.") else t
716
+
717
+ first, alias, out = {}, {}, []
718
+ for e in ents:
719
+ mo = point.match(e.strip())
720
+ if mo:
721
+ coords = ",".join(short(x) for x in num.findall(mo.group(3)))
722
+ key = (mo.group(2), coords)
723
+ if key in first:
724
+ alias[mo.group(1)] = first[key]
725
+ continue
726
+ first[key] = mo.group(1)
727
+ out.append(f"#{mo.group(1)} = CARTESIAN_POINT('{mo.group(2)}',({coords}));")
728
+ else:
729
+ out.append(e)
730
+ if alias:
731
+ ref = re.compile(r"#(\d+)")
732
+ body = []
733
+ for e in out:
734
+ lhs, eq, rhs = e.partition("=")
735
+ body.append(lhs + eq + ref.sub(lambda m: "#" + alias.get(m.group(1), m.group(1)), rhs) if eq else e)
736
+ out = body
737
+ with open(path, "w") as fh:
738
+ fh.write(head + sep + "\n".join(x.strip("\n") for x in out) + "\n" + sep2 + tail)
739
+
740
+
741
+ def read_step(path):
742
+ """The shape in a STEP file (OpenCascade TopoDS_Shape)."""
743
+ _need()
744
+ from OCP.IFSelect import IFSelect_RetDone
745
+ from OCP.STEPControl import STEPControl_Reader
746
+
747
+ r = STEPControl_Reader()
748
+ if r.ReadFile(str(path)) != IFSelect_RetDone:
749
+ raise ValueError(f"could not read STEP file {path}")
750
+ r.TransferRoots()
751
+ return r.OneShape()
752
+
753
+
754
+ def shape_to_trimesh(shape, deflection=0.002, angle=0.05, chunk=2000):
755
+ """Triangulate an OpenCascade shape finely (chord `deflection` mm) into a
756
+ trimesh: how to measure a B-rep with the mesh tools."""
757
+ import trimesh
758
+ from OCP.BRep import BRep_Builder, BRep_Tool
759
+ from OCP.BRepMesh import BRepMesh_IncrementalMesh
760
+ from OCP.BRepTools import BRepTools
761
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
762
+ from OCP.TopExp import TopExp_Explorer
763
+ from OCP.TopLoc import TopLoc_Location
764
+ from OCP.TopoDS import TopoDS, TopoDS_Compound
765
+
766
+ # in chunks of faces, each meshed (in parallel), read into numpy and its
767
+ # triangulation dropped: one mesh of a whole lattice part (46k patches,
768
+ # tens of millions of nodes) held at once went past 13 GB
769
+ faces = []
770
+ ex = TopExp_Explorer(shape, TopAbs_FACE)
771
+ while ex.More():
772
+ faces.append(TopoDS.Face(ex.Current()))
773
+ ex.Next()
774
+ V, F = [], []
775
+ off = 0
776
+ bb = BRep_Builder()
777
+ for c0 in range(0, len(faces), chunk):
778
+ comp = TopoDS_Compound()
779
+ bb.MakeCompound(comp)
780
+ for f in faces[c0:c0 + chunk]:
781
+ bb.Add(comp, f)
782
+ BRepMesh_IncrementalMesh(comp, deflection, False, angle, True)
783
+ for f in faces[c0:c0 + chunk]:
784
+ loc = TopLoc_Location()
785
+ tri = BRep_Tool.Triangulation_s(f, loc)
786
+ if tri is None:
787
+ continue
788
+ tr = loc.Transformation()
789
+ nn, nt = tri.NbNodes(), tri.NbTriangles()
790
+ v = np.empty((nn, 3))
791
+ for i in range(nn):
792
+ p = tri.Node(i + 1).Transformed(tr)
793
+ v[i] = p.X(), p.Y(), p.Z()
794
+ t = np.empty((nt, 3), np.int64)
795
+ for i in range(nt):
796
+ t[i] = tri.Triangle(i + 1).Get()
797
+ if f.Orientation() == TopAbs_REVERSED:
798
+ t = t[:, [0, 2, 1]]
799
+ V.append(v)
800
+ F.append(t - 1 + off)
801
+ off += nn
802
+ BRepTools.Clean_s(comp)
803
+ m = trimesh.Trimesh(np.concatenate(V) if V else np.zeros((0, 3)),
804
+ np.concatenate(F) if F else np.zeros((0, 3), np.int64), process=True)
805
+ m.merge_vertices(digits_vertex=6)
806
+ return m