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/brep.py ADDED
@@ -0,0 +1,1470 @@
1
+ """
2
+ Smooth B-rep from a finished part: the organic body as large Bezier
3
+ patches, the bores, bosses and flat faces as true cylinders and planes.
4
+
5
+ step.py's default STEP keeps the exact shapes exact and facets the rest.
6
+ Facets are fine to print from and to mate against, but a CAD user cannot
7
+ fillet, offset or shell them. This module works on the finished mesh, after
8
+ the booleans, so no OpenCascade boolean ever has to cut a smooth surface:
9
+
10
+ 1. label: every triangle lying on one of the plan's exact surfaces (a box
11
+ face, a bore, a boss, a cap) gets that surface's label; the organic rest
12
+ splits into smooth regions at creases; slivers join their surroundings;
13
+ 2. coarsen: decimate.py's passes, no field, with a rule that keeps the
14
+ seams (a vertex only collapses into a neighbour carrying all its labels,
15
+ seam vertices only along their seam, corners never) and relaxation
16
+ rounds between them. Organic triangles stay well shaped and do not lean
17
+ far off the surface, so a patch never wraps a tight bend;
18
+ 3. edges: a coarse edge on a seam follows the mesh's seam path, one inside
19
+ the organic body is its chord draped evenly over the surface; both
20
+ become Bezier curves of the patch degree (5 by default);
21
+ 4. faces: organic triangles pair up into quads, one patch each (the odd
22
+ ones out split into three quads), its sides those curves and its inside
23
+ interpolating points pulled onto the mesh along the patch normal; each
24
+ connected exact region is one face on its surface, bounded by its seams;
25
+ 5. sew, make the solid.
26
+
27
+ The caller (step.export_step, surface="smooth") checks the result (valid,
28
+ volume against the mesh) and falls back to facets if it is not right.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+
34
+ __all__ = ["coarsen", "label", "smooth_brep", "surfaces"]
35
+
36
+ TOL = 0.005 # mm: a vertex this close to an exact surface lies on it
37
+ PATCHES = 2000 # organic triangles to start the layout from (two make one patch; refined where it misses)
38
+ RAY_MAX = 3.0 # mm: farthest a patch point is pulled along its normal onto the surface
39
+ CREASE = 40.0 # degrees: organic faces meeting sharper than this are separate smooth regions
40
+ SEW = 0.01 # mm: sewing tolerance (exact faces' seams sit within TOL of the patches')
41
+ ASPECT = 0.1 # least 2*area / longest edge^2 of a patch (equilateral 0.87)
42
+ LEAN = 0.5 # least cosine between a patch's plane and the surface normal at its corners
43
+ REFINE = 0.07 # mm: a patch or edge missing the mesh by more is split
44
+ REFINE_ROUNDS = 8
45
+ MIN_SPLIT = 0.3 # mm: shortest edge that splits
46
+ ISLAND = 1.0 # mm^2: label regions smaller than this, inside one other region, join it
47
+ TURN = 0.5 # least cosine between a coarsened face's normal and its old one
48
+
49
+
50
+ class Surf:
51
+ """An exact surface piece: plane (point, normal, in-bounds test),
52
+ cylinder (p0, axis, r, axial range) or sphere (center, r)."""
53
+
54
+ def __init__(self, kind, **kw):
55
+ self.kind = kind
56
+ self.__dict__.update(kw)
57
+
58
+ def key(self):
59
+ if self.kind == "plane":
60
+ n = self.n if self.n[np.argmax(np.abs(self.n))] > 0 else -self.n
61
+ return ("plane", *np.round(n, 6), round(float(self.p @ n), 5))
62
+ if self.kind == "cylinder":
63
+ a = self.axis if self.axis[np.argmax(np.abs(self.axis))] > 0 else -self.axis
64
+ q = self.p0 - (self.p0 @ a) * a
65
+ return ("cylinder", *np.round(a, 6), *np.round(q, 5), round(self.r, 5))
66
+ return ("sphere", *np.round(self.c, 5), round(self.r, 5))
67
+
68
+ def dist(self, P):
69
+ if self.kind == "plane":
70
+ return np.abs((P - self.p) @ self.n)
71
+ if self.kind == "cylinder":
72
+ d = P - self.p0
73
+ t = d @ self.axis
74
+ return np.abs(np.linalg.norm(d - t[:, None] * self.axis, axis=1) - self.r)
75
+ return np.abs(np.linalg.norm(P - self.c, axis=1) - self.r)
76
+
77
+ def normal(self, P):
78
+ if self.kind == "plane":
79
+ return np.broadcast_to(self.n, P.shape)
80
+ if self.kind == "cylinder":
81
+ d = P - self.p0
82
+ d = d - (d @ self.axis)[:, None] * self.axis
83
+ return d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12)
84
+ d = P - self.c
85
+ return d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12)
86
+
87
+ def inside(self, P, tol=0.02):
88
+ if self.kind == "plane" and self.box is not None:
89
+ lo, hi = self.box
90
+ return np.all((P >= lo - tol) & (P <= hi + tol), axis=1)
91
+ if self.kind == "plane" and self.disk is not None:
92
+ c, r0, r1 = self.disk
93
+ d = np.linalg.norm(P - c - ((P - c) @ self.n)[:, None] * self.n, axis=1)
94
+ return (d >= r0 - tol) & (d <= r1 + tol)
95
+ if self.kind == "cylinder":
96
+ t = (P - self.p0) @ self.axis
97
+ return (t >= -tol) & (t <= self.length + tol)
98
+ return np.ones(len(P), bool)
99
+
100
+ def geom(self):
101
+ from OCP.Geom import Geom_CylindricalSurface, Geom_Plane, Geom_SphericalSurface
102
+ from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
103
+
104
+ if self.kind == "plane":
105
+ return Geom_Plane(gp_Pnt(*map(float, self.p)), gp_Dir(*map(float, self.n)))
106
+ if self.kind == "cylinder":
107
+ return Geom_CylindricalSurface(gp_Ax3(gp_Pnt(*map(float, self.p0)), gp_Dir(*map(float, self.axis))),
108
+ float(self.r))
109
+ return Geom_SphericalSurface(gp_Ax3(gp_Pnt(*map(float, self.c)), gp_Dir(0, 0, 1)), float(self.r))
110
+
111
+
112
+
113
+ def _closest(mesh, P, edge=0.5, k=16):
114
+ """Closest points on a mesh to P and the normals there: trimesh's
115
+ closest_point gathers candidates by triangle bounding boxes, and an
116
+ exact boolean's slivers (a bore wall the length of the bore) make every
117
+ point a candidate of all of them, minutes on a part; here the mesh is
118
+ split to `edge` and each point tries the k nearest centroids."""
119
+ import trimesh
120
+ from scipy.spatial import cKDTree
121
+
122
+ from .fields import _closest_on_triangles
123
+
124
+ cache = getattr(mesh, "_loadpath_closest", None)
125
+ if cache is None or cache[0] != edge:
126
+ v, f = trimesh.remesh.subdivide_to_size(np.asarray(mesh.vertices, float),
127
+ np.asarray(mesh.faces, np.int64), max_edge=edge, max_iter=16)
128
+ tri = v[f]
129
+ nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
130
+ nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-300)
131
+ cache = (edge, tri, nrm, cKDTree(tri.mean(axis=1)))
132
+ mesh._loadpath_closest = cache
133
+ _, tri, nrm, tree = cache
134
+ P = np.asarray(P, float).reshape(-1, 3)
135
+ k = min(k, len(tri))
136
+ out, nout = np.empty_like(P), np.empty_like(P)
137
+ for j in range(0, len(P), 100_000):
138
+ q = P[j:j + 100_000]
139
+ cand = tree.query(q, k=k)[1].reshape(len(q), k)
140
+ T = tri[cand]
141
+ Q = np.repeat(q[:, None], k, axis=1).reshape(-1, 3)
142
+ C = _closest_on_triangles(Q, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
143
+ T[:, :, 2].reshape(-1, 3)).reshape(len(q), k, 3)
144
+ b = np.argmin(np.sum((C - q[:, None]) ** 2, axis=2), axis=1)
145
+ r = np.arange(len(q))
146
+ out[j:j + len(q)] = C[r, b]
147
+ nout[j:j + len(q)] = nrm[cand[r, b]]
148
+ return out, nout
149
+
150
+ def _shape_surfaces(shape, out):
151
+ from .shapes import Box, Cylinder, Shell, Sphere, Union
152
+
153
+ if isinstance(shape, Box):
154
+ for a in range(3):
155
+ n = np.zeros(3)
156
+ n[a] = 1.0
157
+ for at in (shape.lo, shape.hi):
158
+ lo, hi = shape.lo.copy(), shape.hi.copy()
159
+ lo[a] = hi[a] = at[a]
160
+ out.append(Surf("plane", p=at.copy(), n=n.copy(), box=(lo, hi), disk=None))
161
+ elif isinstance(shape, Cylinder):
162
+ out.append(Surf("cylinder", p0=shape.p0.copy(), axis=shape.axis.copy(), r=shape.r, length=shape.length))
163
+ if shape.r_inner > 0:
164
+ out.append(Surf("cylinder", p0=shape.p0.copy(), axis=shape.axis.copy(), r=shape.r_inner,
165
+ length=shape.length))
166
+ for c in (shape.p0, shape.p1):
167
+ out.append(Surf("plane", p=c.copy(), n=shape.axis.copy(), box=None, disk=(c.copy(), shape.r_inner, shape.r)))
168
+ elif isinstance(shape, Sphere):
169
+ out.append(Surf("sphere", c=np.asarray(shape.center, float), r=float(shape.r)))
170
+ elif isinstance(shape, Union):
171
+ for s in shape.shapes:
172
+ _shape_surfaces(s, out)
173
+ elif isinstance(shape, Shell):
174
+ for s in (shape.outer, shape.inner, shape.clip):
175
+ if s is not None:
176
+ _shape_surfaces(s, out)
177
+ return out
178
+
179
+
180
+ def surfaces(plan):
181
+ """The exact surfaces of a CSG plan's exact leaves, one per distinct
182
+ plane, cylinder or sphere (pieces of one plane share it)."""
183
+ from .sharp import Exact, _leaves
184
+
185
+ out = []
186
+ for lf in _leaves(plan):
187
+ if isinstance(lf, Exact):
188
+ _shape_surfaces(lf.shape, out)
189
+ return out
190
+
191
+
192
+ def label(mesh, surfs, tol=TOL):
193
+ """Per face: 0 for organic, k + 1 for a face lying on surfs[k] (all three
194
+ corners within `tol`, inside its bounds, normal along the surface's).
195
+ Surfaces with the same geometry share a label."""
196
+ V = np.asarray(mesh.vertices, float)
197
+ F = np.asarray(mesh.faces)
198
+ C = V[F].mean(axis=1)
199
+ fn = np.asarray(mesh.face_normals)
200
+ lab = np.zeros(len(F), np.int64)
201
+ best = np.full(len(F), np.inf)
202
+ keys = {}
203
+ for k, s in enumerate(surfs):
204
+ lk = keys.setdefault(s.key(), k + 1)
205
+ dv = s.dist(V)
206
+ ok = (dv < tol) & s.inside(V)
207
+ on = ok[F].all(axis=1)
208
+ if not on.any():
209
+ continue
210
+ i = np.flatnonzero(on)
211
+ para = np.abs(np.einsum("ij,ij->i", fn[i], s.normal(C[i]))) > 0.995
212
+ i = i[para]
213
+ d = dv[F[i]].sum(axis=1)
214
+ better = d < best[i]
215
+ lab[i[better]] = lk
216
+ best[i[better]] = d[better]
217
+ return lab
218
+
219
+
220
+ def creases(mesh, lab, O, angle=CREASE):
221
+ """Organic faces (label 0) split into smooth regions at creases (faces
222
+ meeting at more than `angle` degrees), labelled O, O + 1, ...: a patch
223
+ must not round over a sharp edge of the field."""
224
+ import scipy.sparse as sp
225
+ from scipy.sparse.csgraph import connected_components
226
+
227
+ org = lab == 0
228
+ A = np.asarray(mesh.face_adjacency)
229
+ keep = org[A[:, 0]] & org[A[:, 1]] & (np.asarray(mesh.face_adjacency_angles) < np.radians(angle))
230
+ n = len(lab)
231
+ G = sp.coo_matrix((np.ones(keep.sum()), (A[keep, 0], A[keep, 1])), shape=(n, n))
232
+ _, comp = connected_components(G, directed=False)
233
+ out = lab.copy()
234
+ _, ids = np.unique(comp[org], return_inverse=True)
235
+ out[org] = O + ids
236
+ return out
237
+
238
+
239
+ def peninsulas(mesh, lab, O, rounds=4):
240
+ """Exact faces poking into the organic body (two of their three
241
+ neighbours organic, of one region) turn organic: a seam that juts out
242
+ and back makes a face boundary cross itself."""
243
+ A = np.asarray(mesh.face_adjacency)
244
+ lab = lab.copy()
245
+ for _ in range(rounds):
246
+ nb = [[] for _ in range(len(lab))]
247
+ for x, y in A:
248
+ nb[x].append(y)
249
+ nb[y].append(x)
250
+ flip = []
251
+ for f in np.flatnonzero(lab < O):
252
+ other = [lab[g] for g in nb[f] if lab[g] != lab[f]]
253
+ if len(other) >= 2 and other[0] >= O and all(o == other[0] for o in other):
254
+ flip.append((f, other[0]))
255
+ if not flip:
256
+ break
257
+ for f, l in flip:
258
+ lab[f] = l
259
+ return lab
260
+
261
+
262
+ def islands(mesh, lab, area=ISLAND, surfs=None, tol=0.02):
263
+ """Small label regions (under `area` mm^2) wholly surrounded by one other
264
+ label take that label: a sliver of organic faces inside a flat face (its
265
+ normals too noisy for label()) would cut a slit into the plane."""
266
+ import scipy.sparse as sp
267
+ from scipy.sparse.csgraph import connected_components
268
+
269
+ A = np.asarray(mesh.face_adjacency)
270
+ fa = np.asarray(mesh.area_faces)
271
+ n = len(lab)
272
+ for _ in range(3):
273
+ same = lab[A[:, 0]] == lab[A[:, 1]]
274
+ G = sp.coo_matrix((np.ones(same.sum()), (A[same, 0], A[same, 1])), shape=(n, n))
275
+ nc, comp = connected_components(G, directed=False)
276
+ ca = np.bincount(comp, fa, minlength=nc)
277
+ small = ca < area
278
+ if not small.any():
279
+ break
280
+ d = ~same
281
+ a, b = A[d, 0], A[d, 1]
282
+ # neighbour labels of each small component
283
+ cs = np.r_[comp[a], comp[b]]
284
+ nb = np.r_[lab[b], lab[a]]
285
+ m = small[cs]
286
+ cs, nb = cs[m], nb[m]
287
+ if not len(cs):
288
+ break
289
+ K = int(lab.max()) + 1
290
+ u = np.unique(cs.astype(np.int64) * K + nb)
291
+ uc, ul = u // K, u % K
292
+ cnt = np.bincount(uc, minlength=nc)
293
+ one = np.full(nc, -1)
294
+ one[uc[cnt[uc] == 1]] = ul[cnt[uc] == 1]
295
+ flip = small & (one >= 0)
296
+ if surfs: # onto an exact surface only if the island lies on it
297
+ V = np.asarray(mesh.vertices, float)
298
+ F = np.asarray(mesh.faces)
299
+ for c in np.flatnonzero(flip):
300
+ t = int(one[c])
301
+ if t in surfs:
302
+ P = V[F[comp == c].ravel()]
303
+ if surfs[t].dist(P).max() > tol:
304
+ flip[c] = False
305
+ if not flip.any():
306
+ break
307
+ f = flip[comp]
308
+ lab = lab.copy()
309
+ lab[f] = one[comp[f]]
310
+ return lab
311
+
312
+
313
+ def _vertex_sets(F, lab, n):
314
+ K = int(lab.max()) + 1 if len(lab) else 1
315
+ pairs = np.unique(F.ravel().astype(np.int64) * K + np.repeat(lab, 3))
316
+ v, l = pairs // K, pairs % K
317
+ nl = np.bincount(v, minlength=n)
318
+ h = np.zeros(n)
319
+ rnd = np.random.default_rng(7).random(K) + 1.0
320
+ np.add.at(h, v, rnd[l])
321
+ one = np.full(n, -1)
322
+ one[v[nl[v] == 1]] = l[nl[v] == 1]
323
+ return K, pairs, nl, h, one
324
+
325
+
326
+ def _edge_faces(F):
327
+ """(edges a<b, face on each side): closed manifold, so each edge has two."""
328
+ a = np.r_[F[:, 0], F[:, 1], F[:, 2]]
329
+ b = np.r_[F[:, 1], F[:, 2], F[:, 0]]
330
+ f = np.tile(np.arange(len(F)), 3)
331
+ lo, hi = np.minimum(a, b), np.maximum(a, b)
332
+ key = lo.astype(np.int64) * (int(F.max()) + 1) + hi
333
+ o = np.argsort(key, kind="stable")
334
+ key, f = key[o], f[o]
335
+ first = np.r_[True, key[1:] != key[:-1]]
336
+ i = np.flatnonzero(first)
337
+ j = i + 1
338
+ j = np.minimum(j, len(key) - 1)
339
+ E = np.stack([lo[o][i], hi[o][i]], axis=1)
340
+ return E, key[i], f[i], f[j]
341
+
342
+
343
+ def coarsen(mesh, lab, patches=PATCHES, curved=None, surfs=None, O=None, progress=None):
344
+ """The labelled mesh coarsened until about `patches` organic triangles
345
+ remain and the exact regions are as coarse as their seams allow. Returns
346
+ (vertex indices into the mesh, faces, labels)."""
347
+ from .decimate import decimate
348
+
349
+ V = np.asarray(mesh.vertices, float)
350
+ F = np.asarray(mesh.faces, np.int64)
351
+ st = {}
352
+ rng = np.random.default_rng(0)
353
+ if O is None: # labels from O up are organic (0 alone, by default)
354
+ O = 0
355
+ if curved is None:
356
+ curved = np.zeros(int(lab.max()) + 1, bool)
357
+
358
+ def allow(E, Fc, lb):
359
+ K, pairs, nl, h, one = _vertex_sets(Fc, lb, int(Fc.max()) + 1)
360
+ a, b = E[:, 0], E[:, 1]
361
+ a_in_b = (nl[a] == 1) & np.isin(b * K + one[a], pairs)
362
+ b_in_a = (nl[b] == 1) & np.isin(a * K + one[b], pairs)
363
+ ok = a_in_b | b_in_a
364
+ # along a seam: both ends on the same two surfaces, the edge between them
365
+ both2 = (nl[a] == 2) & (nl[b] == 2) & (h[a] == h[b])
366
+ if both2.any():
367
+ _, key, f0, f1 = _edge_faces(Fc)
368
+ kq = np.minimum(a, b).astype(np.int64) * (int(Fc.max()) + 1) + np.maximum(a, b)
369
+ pos = np.clip(np.searchsorted(key, kq), 0, len(key) - 1)
370
+ seam = (key[pos] == kq) & (lb[f0[pos]] != lb[f1[pos]])
371
+ ok |= both2 & seam
372
+ # organic triangles: stop at the budget
373
+ org = (one[a] >= O) | (one[b] >= O)
374
+ n_org = int((lb >= O).sum())
375
+ room = max(0, (n_org - int(patches)) // 2)
376
+ oi = np.flatnonzero(ok & org)
377
+ if len(oi) > 4 * room: # near the budget: a spread-out few, not a cluster
378
+ ok[rng.choice(oi, len(oi) - 4 * room, replace=False)] = False
379
+ st.update(nl=nl, one=one, K=K, pairs=pairs)
380
+ return ok
381
+
382
+ def place(W, a, b):
383
+ nl, one, K, pairs = st["nl"], st["one"], st["K"], st["pairs"]
384
+ a_in_b = (nl[a] == 1) & np.isin(b * K + one[a], pairs)
385
+ return np.where(a_in_b[:, None], W[b], W[a])
386
+
387
+ from scipy.spatial import cKDTree
388
+
389
+ tree = cKDTree(V)
390
+ vn = np.asarray(mesh.vertex_normals)
391
+
392
+ def shape(T):
393
+ e = np.stack([T[:, 1] - T[:, 0], T[:, 2] - T[:, 1], T[:, 0] - T[:, 2]], axis=1)
394
+ n = np.cross(e[:, 0], -e[:, 2])
395
+ area2 = np.linalg.norm(n, axis=1)
396
+ q = area2 / np.maximum((e ** 2).sum(axis=2).max(axis=1), 1e-300)
397
+ n = n / np.maximum(area2[:, None], 1e-300)
398
+ _, i = tree.query(T.reshape(-1, 3))
399
+ lean = np.einsum("kj,kcj->kc", n, vn[i].reshape(-1, 3, 3)).min(axis=1)
400
+ return q, lean
401
+
402
+ def quality(Told, Tnew, lb):
403
+ """No slivers, and no patch leaning far off the surface at its
404
+ corners (a big patch over a tight bend fills badly), unless it was
405
+ already that bad. Exact faces merge back whole: any shape will do."""
406
+ q0, l0 = shape(Told)
407
+ q1, l1 = shape(Tnew)
408
+ organic = ((q1 > ASPECT) | (q1 >= q0)) & ((l1 > LEAN) | (l1 >= l0))
409
+ bend = (l1 > LEAN) | (l1 >= l0) # a bore's triangle: under ~60 degrees of arc
410
+ return np.where(lb >= O, organic, np.where(curved[np.minimum(lb, len(curved) - 1)], bend, True))
411
+
412
+ import trimesh
413
+
414
+ org = trimesh.Trimesh(V, F[lab >= O], process=False) if (lab >= O).any() else None
415
+ W, Fc, lb = V, F, np.asarray(lab)
416
+ for _ in range(60):
417
+ before = len(Fc)
418
+ W, Fc, stats, _ = decimate(W, Fc, None, 0.0, 1.0, max_passes=6, min_gain=0.0, progress=progress,
419
+ place=place, turn=TURN, labels=lb, allow=allow, max_reject=40, quality=quality)
420
+ lb = stats["labels"]
421
+ if (lb >= O).sum() <= patches or len(Fc) > 0.995 * before:
422
+ break
423
+ W = _relax(W, Fc, lb, org, surfs or {}, O)
424
+ # which coarse vertices still sit on one of the mesh's vertices (seams, corners)
425
+ d, vid = tree.query(W)
426
+ vid[d > 1e-9] = -1
427
+ return W, vid, Fc, lb
428
+
429
+
430
+ def _relax(W, F, lb, org, surfs, O, rounds=2, step=0.5):
431
+ """Even out the patch layout: each vertex inside one region moves halfway
432
+ to its neighbours' average, back onto its surface (the organic mesh, or
433
+ its exact surface). Seam and corner vertices stay put."""
434
+ import trimesh
435
+
436
+ n = len(W)
437
+ _, _, nl, _, one = _vertex_sets(F, lb, n)
438
+ free = (nl == 1)
439
+ E = np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]])
440
+ W = W.copy()
441
+ for _ in range(rounds):
442
+ S = np.zeros_like(W)
443
+ c = np.zeros(n)
444
+ np.add.at(S, E[:, 0], W[E[:, 1]])
445
+ np.add.at(c, E[:, 0], 1)
446
+ A = S / np.maximum(c, 1)[:, None]
447
+ T = W + step * (A - W)
448
+ o = free & (one >= O)
449
+ if org is not None and o.any():
450
+ cp, cn = _closest(org, T[o])
451
+ vn = trimesh.Trimesh(W, F, process=False).vertex_normals[o]
452
+ ok = np.einsum("ij,ij->i", cn, vn) > 0.5
453
+ idx = np.flatnonzero(o)
454
+ W[idx[ok]] = cp[ok]
455
+ for k, sf in surfs.items():
456
+ e = free & (one == k)
457
+ if e.any():
458
+ W[e] = _onto(sf, T[e])
459
+ return W
460
+
461
+
462
+ def _graph(mesh, mask_v=None, mask_e=None):
463
+ import scipy.sparse as sp
464
+
465
+ E = np.asarray(mesh.edges_unique)
466
+ w = np.linalg.norm(mesh.vertices[E[:, 0]] - mesh.vertices[E[:, 1]], axis=1)
467
+ keep = np.ones(len(E), bool)
468
+ if mask_v is not None:
469
+ keep &= mask_v[E[:, 0]] & mask_v[E[:, 1]]
470
+ if mask_e is not None:
471
+ keep &= mask_e
472
+ n = len(mesh.vertices)
473
+ return sp.coo_matrix((w[keep], (E[keep, 0], E[keep, 1])), shape=(n, n)).tocsr()
474
+
475
+
476
+ def _path(G, a, b, limit):
477
+ from scipy.sparse.csgraph import dijkstra
478
+
479
+ d, pred = dijkstra(G, directed=False, indices=a, return_predecessors=True, limit=limit)
480
+ if not np.isfinite(d[b]):
481
+ return None
482
+ p = [b]
483
+ while p[-1] != a:
484
+ p.append(pred[p[-1]])
485
+ if p[-1] < 0:
486
+ return None
487
+ return p[::-1]
488
+
489
+
490
+ def _fit(P, tol):
491
+ from OCP.GeomAbs import GeomAbs_C2
492
+ from OCP.GeomAPI import GeomAPI_PointsToBSpline
493
+ from OCP.OCP.collections import Array1_gp_Pnt
494
+
495
+ from .step import _pnt
496
+
497
+ if len(P) > 40: # a long path: keep the ends, thin the middle
498
+ i = np.unique(np.r_[np.linspace(0, len(P) - 1, 40).round().astype(int)])
499
+ P = P[i]
500
+ if len(P) == 2:
501
+ P = np.vstack([P[0], P.mean(axis=0), P[1]])
502
+ arr = Array1_gp_Pnt(1, len(P))
503
+ for j, p in enumerate(P):
504
+ arr.SetValue(j + 1, _pnt(p))
505
+ return GeomAPI_PointsToBSpline(arr, 2, 5, GeomAbs_C2, tol).Curve()
506
+
507
+
508
+ def smooth_brep(mesh, plan, patches=PATCHES, order=3, progress=None):
509
+ """The part as an OpenCascade solid: exact faces on the plan's planes,
510
+ cylinders and spheres, smooth B-spline patches elsewhere. Returns
511
+ (solid, report). Raises if the construction fails."""
512
+ import time
513
+
514
+ import trimesh
515
+ from OCP.BRepAdaptor import BRepAdaptor_Surface
516
+ from OCP.BRepBuilderAPI import (
517
+ BRepBuilderAPI_MakeEdge,
518
+ BRepBuilderAPI_MakeFace,
519
+ BRepBuilderAPI_MakeSolid,
520
+ BRepBuilderAPI_Sewing,
521
+ )
522
+ from OCP.BRepCheck import BRepCheck_Analyzer
523
+ from OCP.GeomAbs import GeomAbs_BezierSurface
524
+ from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Solid
525
+ from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
526
+ from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
527
+ from OCP.TopExp import TopExp_Explorer
528
+ from OCP.TopoDS import TopoDS
529
+
530
+ from .step import _pnt, _volume
531
+
532
+ t0 = time.time()
533
+ surfs = surfaces(plan) if plan is not None else []
534
+ lab = label(mesh, surfs)
535
+ keys = {}
536
+ geo = {}
537
+ for k, s in enumerate(surfs):
538
+ lk = keys.setdefault(s.key(), k + 1)
539
+ geo.setdefault(lk, s)
540
+ curved = np.zeros(int(lab.max()) + 1, bool)
541
+ for lk, sf in geo.items():
542
+ if lk < len(curved):
543
+ curved[lk] = sf.kind != "plane"
544
+ O = (max(geo) if geo else 0) + 1
545
+ lab = islands(mesh, peninsulas(mesh, creases(mesh, lab, O), O))
546
+ CV, vid, F, flab = coarsen(mesh, lab, patches, curved, geo, O)
547
+ # a coarse patch can end up an island inside another face (a sliver of the
548
+ # body on a flat face): it joins that face
549
+ flab = islands(trimesh.Trimesh(CV, F, process=False), flab, ISLAND, geo)
550
+ V = np.asarray(mesh.vertices, float)
551
+ t1 = time.time()
552
+
553
+ # fine-mesh graphs for the paths
554
+ fl = lab
555
+ Ef, _, f0, f1 = _edge_faces(np.asarray(mesh.faces, np.int64))
556
+ seam_e = fl[f0] != fl[f1]
557
+ import scipy.sparse as sp
558
+
559
+ CE, ckey, cf0, cf1 = _edge_faces(F)
560
+ N = int(order) + 1 # points per patch side
561
+ # the whole surface is the target: a patch near a seam lands on the true
562
+ # surface either side, and a demoted exact region (below) is patches too
563
+ org_mesh = trimesh.Trimesh(V, np.asarray(mesh.faces), process=False)
564
+
565
+ try:
566
+ from trimesh.ray.ray_pyembree import RayMeshIntersector # embreex: ~100x faster
567
+ except ImportError:
568
+ from trimesh.ray.ray_triangle import RayMeshIntersector
569
+
570
+ rays = RayMeshIntersector(org_mesh)
571
+
572
+ def pull(P, nrm, reach=None):
573
+ """Points onto the organic surface along the patch normal (the nearer
574
+ hit either way, where the surface faces the same way); else the
575
+ closest point, where its normal agrees; else left where they are."""
576
+ if not len(P):
577
+ return P
578
+ best = np.full(len(P), np.inf)
579
+ out = P.copy()
580
+ lim = np.full(len(P), RAY_MAX) if reach is None else np.minimum(reach, RAY_MAX)
581
+ for sgn in (1.0, -1.0):
582
+ loc, ri, ti = rays.intersects_location(P, sgn * nrm, multiple_hits=False)
583
+ if not len(ri):
584
+ continue
585
+ d = np.linalg.norm(loc - P[ri], axis=1)
586
+ ok = (np.einsum("ij,ij->i", org_mesh.face_normals[ti], nrm[ri]) > 0.3) & (d < lim[ri]) & (d < best[ri])
587
+ out[ri[ok]] = loc[ok]
588
+ best[ri[ok]] = d[ok]
589
+ miss = ~np.isfinite(best)
590
+ if miss.any():
591
+ cp, cn = _closest(org_mesh, P[miss])
592
+ idx = np.flatnonzero(miss)
593
+ # where it faces the same way; else (a layout triangle wrapped
594
+ # round a thin wall's edge, its normal side-on to the surface)
595
+ # wherever it is within reach: a point left off the surface
596
+ # drags its whole patch off
597
+ ok = (np.einsum("ij,ij->i", cn, nrm[miss]) > 0.3) | (np.linalg.norm(cp - P[miss], axis=1) < lim[miss])
598
+ out[idx[ok]] = cp[ok]
599
+ return out
600
+
601
+ def resample(P, n):
602
+ d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))]
603
+ if d[-1] <= 0:
604
+ return np.repeat(P[:1], n, axis=0)
605
+ t = np.linspace(0, d[-1], n)
606
+ return np.stack([np.interp(t, d, P[:, k]) for k in range(3)], axis=1)
607
+
608
+ def drape(P, nrm, rounds=4):
609
+ """Polylines (m, n, 3) between pairs of surface points onto the
610
+ surface, evenly spaced: pulling a chord along one direction bunches
611
+ its points up at a bend, and the gaps between them cut the corner."""
612
+ P = np.array(P, float)
613
+ m, n = P.shape[:2]
614
+ R = np.repeat(np.asarray(nrm, float)[:, None], n - 2, axis=1).reshape(-1, 3)
615
+ # the surface under a chord is never further from it than half its
616
+ # length: a hit beyond is another sheet (a thin wall's far side)
617
+ reach = np.repeat(0.5 * np.linalg.norm(P[:, -1] - P[:, 0], axis=1) + TOL, n - 2)
618
+ for k in range(rounds + 1):
619
+ P[:, 1:-1] = pull(P[:, 1:-1].reshape(-1, 3), R, reach).reshape(m, n - 2, 3)
620
+ if k < rounds:
621
+ P = np.stack([resample(x, n) for x in P])
622
+ return P
623
+
624
+ def build(attempt, refine=False):
625
+ """Edges and faces from the coarse layout: (sewing, counts), or
626
+ (None, regions to demote) when an exact region needs patches."""
627
+ # every coarse edge: a polyline over the surface
628
+ poly, split, fl_edge = {}, {}, {}
629
+ cfn = np.cross(CV[F[:, 1]] - CV[F[:, 0]], CV[F[:, 2]] - CV[F[:, 0]])
630
+ cfn /= np.maximum(np.linalg.norm(cfn, axis=1, keepdims=True), 1e-300)
631
+ # seam paths over the seam vertices alone (a small graph: fast Dijkstra)
632
+ sv = np.unique(Ef[seam_e])
633
+ loc = np.full(len(V), -1)
634
+ loc[sv] = np.arange(len(sv))
635
+ w = np.linalg.norm(V[Ef[seam_e, 0]] - V[Ef[seam_e, 1]], axis=1)
636
+ Gs = sp.coo_matrix((w, (loc[Ef[seam_e, 0]], loc[Ef[seam_e, 1]])), shape=(len(sv), len(sv))).tocsr()
637
+ inner, t17 = [], np.linspace(0, 1, 17)[:, None]
638
+ for (a, b), la, lb, f0e, f1e in zip(CE, flab[cf0], flab[cf1], cf0, cf1):
639
+ a, b = int(a), int(b)
640
+ pa, pb = CV[a], CV[b]
641
+ L = float(np.linalg.norm(pa - pb))
642
+ if la == lb and la < O:
643
+ t = np.linspace(0, 1, 9)[:, None]
644
+ P = _onto(geo[int(la)], pa * (1 - t) + pb * t)
645
+ P[0], P[-1] = pa, pb
646
+ elif la == lb:
647
+ # inside the organic body: the chord draped over the surface (the
648
+ # shortest path over the mesh's edges is a staircase, and two
649
+ # from one corner can share their first half); below, all at once
650
+ nn = cfn[f0e] + cfn[f1e]
651
+ inner.append(((a, b), pa * (1 - t17) + pb * t17, nn / max(np.linalg.norm(nn), 1e-300)))
652
+ P = None
653
+ else:
654
+ pth = _path(Gs, int(loc[vid[a]]), int(loc[vid[b]]), limit=4 * L + 1.0) if vid[a] >= 0 and vid[b] >= 0 else None
655
+ if pth is not None:
656
+ P = V[sv[pth]]
657
+ elif la >= O and lb >= O: # between two patched regions: drape it
658
+ nn = cfn[f0e] + cfn[f1e]
659
+ inner.append(((a, b), pa * (1 - t17) + pb * t17, nn / max(np.linalg.norm(nn), 1e-300)))
660
+ P = None
661
+ else:
662
+ raise RuntimeError(f"no surface path for a seam edge ({vid[a]}, {vid[b]}, labels {la}, {lb})")
663
+ poly[(a, b)] = P
664
+ split[(a, b)] = False # set below: next to a three-quad triangle, two half edges
665
+ fl_edge[(a, b)] = la >= O or lb >= O # organic on a side: a Bezier edge
666
+ if inner:
667
+ Pd = drape(np.stack([x[1] for x in inner]), np.stack([x[2] for x in inner]))
668
+ for (e, _, _), P in zip(inner, Pd):
669
+ poly[e] = P
670
+
671
+ D = N - 1 # patch and edge degree
672
+ Bm = _bernstein(D) # Bm[i, j]: j-th Bernstein at i / D
673
+ Bi = np.linalg.inv(Bm) # points at even parameters -> poles
674
+ Bint = np.linalg.inv(Bm[1:-1, 1:-1])
675
+
676
+ # organic triangles pair up into quads (one patch for two triangles);
677
+ # the rest split into three quads each
678
+ pair = _pairs(CV, F, flab, O)
679
+ single = np.ones(len(F), bool)
680
+ single[[i for i, j in pair]] = False
681
+ single[[j for i, j in pair]] = False
682
+ single &= flab >= O
683
+ for i in np.flatnonzero(single):
684
+ for k in range(3):
685
+ a, b = int(F[i, k]), int(F[i, (k + 1) % 3])
686
+ split[(min(a, b), max(a, b))] = True
687
+
688
+ poles = {}
689
+
690
+ def curve(a, b):
691
+ """Poles of coarse edge (a, b), from a to b."""
692
+ e = (min(a, b), max(a, b))
693
+ if e not in poles:
694
+ poles[e] = Bi @ resample(poly[e], N)
695
+ return poles[e] if a < b else poles[e][::-1]
696
+
697
+ def casteljau(C):
698
+ """Poles of the two halves (t <= 1/2, t >= 1/2) of a Bezier curve."""
699
+ L, R = [C[0]], [C[-1]]
700
+ W = C.copy()
701
+ for _ in range(D):
702
+ W = 0.5 * (W[:-1] + W[1:])
703
+ L.append(W[0])
704
+ R.append(W[-1])
705
+ return np.array(L), np.array(R[::-1])
706
+
707
+ def half(a, b):
708
+ """Poles from corner a to the midpoint of edge (a, b)."""
709
+ return casteljau(curve(a, b))[0]
710
+
711
+ def value(C, t):
712
+ from math import comb
713
+
714
+ t = np.atleast_1d(t)
715
+ B = np.array([[comb(D, j) * x ** j * (1 - x) ** (D - j) for j in range(D + 1)] for x in t])
716
+ return B @ C
717
+
718
+ def make_curve(C):
719
+ from OCP.Geom import Geom_BezierCurve
720
+ from OCP.OCP.collections import Array1_gp_Pnt
721
+
722
+ arr = Array1_gp_Pnt(1, len(C))
723
+ for j, c in enumerate(C):
724
+ arr.SetValue(j + 1, _pnt(c))
725
+ return Geom_BezierCurve(arr)
726
+
727
+ def coons(bottom, top, left, right):
728
+ """Points of the Coons blend of a patch's four sides (poles: bottom,
729
+ top along u; left, right along v; corners shared) at even parameters."""
730
+ sb, st_, sl, sr = (Bm @ x for x in (bottom, top, left, right))
731
+ U, W = np.meshgrid(u, u, indexing="ij")
732
+ X = ((1 - W)[..., None] * sb[:, None] + W[..., None] * st_[:, None]
733
+ + (1 - U)[..., None] * sl[None] + U[..., None] * sr[None]
734
+ - ((1 - U) * (1 - W))[..., None] * sb[0] - (U * (1 - W))[..., None] * sb[-1]
735
+ - ((1 - U) * W)[..., None] * st_[0] - (U * W)[..., None] * st_[-1])
736
+ return X
737
+
738
+ def poles_of(bottom, top, left, right, X):
739
+ """Poles of the Bezier patch with those sides through points X
740
+ (N, N, 3): its inside poles solved for, so it passes through them."""
741
+ C = np.zeros((N, N, 3))
742
+ C[:, 0], C[:, -1], C[0, :], C[-1, :] = bottom, top, left, right
743
+ Cb = C.copy()
744
+ Cb[1:-1, 1:-1] = 0.0
745
+ R = X[1:-1, 1:-1] - np.einsum("ia,abk,jb->ijk", Bm[1:-1], Cb, Bm[1:-1])
746
+ C[1:-1, 1:-1] = np.einsum("ia,abk,jb->ijk", Bint, R, Bint)
747
+ return C
748
+
749
+ def patch(C):
750
+ from OCP.Geom import Geom_BezierSurface
751
+ from OCP.OCP.collections import Array2_gp_Pnt
752
+
753
+ arr = Array2_gp_Pnt(1, N, 1, N)
754
+ for i in range(N):
755
+ for j in range(N):
756
+ arr.SetValue(i + 1, j + 1, _pnt(C[i, j]))
757
+ return BRepBuilderAPI_MakeFace(Geom_BezierSurface(arr), 1e-7).Face()
758
+
759
+ edge_cache = {}
760
+ surf_geom = {}
761
+
762
+ def edge(key, C=None, P=None):
763
+ if key not in edge_cache:
764
+ edge_cache[key] = BRepBuilderAPI_MakeEdge(make_curve(C) if C is not None else _fit(P, 1e-5)).Edge()
765
+ return edge_cache[key]
766
+
767
+ sew = BRepBuilderAPI_Sewing(SEW)
768
+ u = np.linspace(0, 1, N)
769
+ nq = 0
770
+ def side(a, b):
771
+ """[(edge, reversed?)] along coarse edge a -> b."""
772
+ e = (min(a, b), max(a, b))
773
+ if split[e]:
774
+ return [(edge(("h", a, e), half(a, b)), False), (edge(("h", b, e), half(b, a)), True)]
775
+ if fl_edge[e]:
776
+ return [(edge(("e", e), curve(e[0], e[1])), a > b)]
777
+ return [(edge(("p", e), P=poly[e]), a > b)]
778
+
779
+ # each connected exact region is one face, bounded by its seams
780
+ import scipy.sparse as sp2
781
+ from scipy.sparse.csgraph import connected_components as _cc
782
+
783
+ n_region = n_fallback = 0
784
+ why, demote = [], []
785
+ exact = flab < O
786
+ same = exact[cf0] & (flab[cf0] == flab[cf1])
787
+ G = sp2.coo_matrix((np.ones(same.sum()), (cf0[same], cf1[same])), shape=(len(F), len(F)))
788
+ _, comp = _cc(G, directed=False)
789
+ comp = np.where(exact, comp, -1)
790
+ for c in np.unique(comp[exact]):
791
+ tris = np.flatnonzero(comp == c)
792
+ lf = int(flab[tris[0]])
793
+ if lf not in surf_geom:
794
+ surf_geom[lf] = geo[lf].geom()
795
+ inside = {int(t) for t in tris}
796
+ out_edges = {}
797
+ for t in tris:
798
+ f = [int(x) for x in F[t]]
799
+ for k in range(3):
800
+ a, b = f[k], f[(k + 1) % 3]
801
+ e = (min(a, b), max(a, b))
802
+ pos = int(np.searchsorted(ckey, e[0] * (int(F.max()) + 1) + e[1]))
803
+ other = int(cf1[pos]) if int(cf0[pos]) == t else int(cf0[pos])
804
+ if other not in inside:
805
+ out_edges.setdefault(a, []).append(b)
806
+ # a seam path running through another corner of this boundary
807
+ # doubles back: those corners' triangles go to patches first
808
+ bv = sorted(set(out_edges) | {b for bs in out_edges.values() for b in bs})
809
+ spikes = set()
810
+ if attempt < 4:
811
+ from scipy.spatial import cKDTree as _KD
812
+
813
+ kd = _KD(CV[bv])
814
+ for a, bs in out_edges.items():
815
+ for b in bs:
816
+ Pp = poly[(min(a, b), max(a, b))]
817
+ if Pp is None or len(Pp) < 3:
818
+ continue
819
+ for hit in kd.query_ball_point(Pp[1:-1], 1e-6):
820
+ spikes.update(bv[h] for h in hit if bv[h] not in (a, b))
821
+ loops = []
822
+ while out_edges:
823
+ a0 = next(iter(out_edges))
824
+ loop, a = [], a0
825
+ while True:
826
+ b = out_edges[a].pop()
827
+ if not out_edges[a]:
828
+ del out_edges[a]
829
+ loop += side(a, b)
830
+ a = b
831
+ if a == a0 or a not in out_edges:
832
+ break
833
+ loops.append(loop)
834
+ T = CV[F[tris]]
835
+ tn = np.cross(T[:, 1] - T[:, 0], T[:, 2] - T[:, 0])
836
+ flip = float(np.einsum("ij,ij->", tn, geo[lf].normal(T.mean(axis=1)))) < 0
837
+ try:
838
+ sew.Add(_region_face(geo[lf], surf_geom[lf], loops, flip))
839
+ n_region += 1
840
+ continue
841
+ except Exception as e: # noqa: BLE001 - patches or one face per triangle still work
842
+ why.append(f"{geo[lf].kind} with {len(loops)} loops: {e}"[:200])
843
+ if attempt < 4:
844
+ near = np.isin(F[tris], list(spikes)).any(axis=1) if spikes else np.zeros(len(tris), bool)
845
+ demote.append(tris[near] if near.any() and attempt == 0 else tris)
846
+ continue
847
+ n_fallback += 1
848
+ for t in tris: # one face per triangle, merged later
849
+ f = [int(x) for x in F[t]]
850
+ loop = side(f[0], f[1]) + side(f[1], f[2]) + side(f[2], f[0])
851
+ sew.Add(_exact_face(geo[lf], surf_geom[lf], loop, CV[f].mean(axis=0)))
852
+ if demote:
853
+ # a region whose boundary will not make one valid face (a seam
854
+ # that doubles back on itself): smooth patches there instead,
855
+ # flat on its plane, and build again
856
+ return None, demote
857
+ todo, owner = [], [] # (sides, normal) of every patch, its triangles
858
+ # pairs: one patch over the quad a, c, b, d (shared edge a-b, c and d the far corners)
859
+ for i, j in pair:
860
+ fi, fj = [int(x) for x in F[i]], [int(x) for x in F[j]]
861
+ a, b = sorted(set(fi) & set(fj))
862
+ c = (set(fi) - {a, b}).pop()
863
+ d = (set(fj) - {a, b}).pop()
864
+ n = cfn[i] + cfn[j]
865
+ n /= max(np.linalg.norm(n), 1e-300)
866
+ # corners 00 = a, 10 = c, 01 = d, 11 = b
867
+ todo.append(((curve(a, c), curve(d, b), curve(a, d), curve(c, b)), n))
868
+ owner.append((i, j))
869
+ nq += 1
870
+ # odd triangles: three patches each, centroid to edge midpoints;
871
+ # the centroids and spokes of all of them pulled in one go
872
+ odd = []
873
+ for i in np.flatnonzero(single):
874
+ f = [int(x) for x in F[i]]
875
+ H = {(x, y): half(x, y) for x in f for y in f if x != y}
876
+ Mid = {(min(x, y), max(x, y)): H[(x, y)][-1] for (x, y) in H}
877
+ odd.append((i, f, H, Mid, np.mean([*CV[f], *Mid.values()], axis=0)))
878
+ if odd:
879
+ Gc = pull(np.stack([o[4] for o in odd]), cfn[[o[0] for o in odd]],
880
+ np.array([np.ptp(CV[o[1]], axis=0).max() for o in odd]) + TOL)
881
+ t = np.linspace(0, 1, 13)[:, None]
882
+ lines = np.stack([Gc[q] * (1 - t) + M * t for q, o in enumerate(odd) for M in o[3].values()])
883
+ lines = drape(lines, np.repeat(cfn[[o[0] for o in odd]], 3, axis=0))
884
+ for q, (i, f, H, Mid, _) in enumerate(odd):
885
+ n = cfn[i]
886
+ spoke = {e: Bi @ resample(lines[3 * q + r], N) for r, e in enumerate(Mid)}
887
+ for k in range(3):
888
+ c, j, i2 = f[k], f[(k + 1) % 3], f[(k + 2) % 3]
889
+ e1, e2 = (min(c, j), max(c, j)), (min(c, i2), max(c, i2))
890
+ # corners 00 = c, 10 = M(c, j), 01 = M(c, i), 11 = G
891
+ todo.append(((H[(c, j)], spoke[e2][::-1], H[(c, i2)], spoke[e1][::-1]), n))
892
+ owner.append((i,))
893
+ nq += 1
894
+ # every patch's inside onto the surface in one go
895
+ if todo:
896
+ X = np.stack([coons(*sd) for sd, _ in todo])
897
+ inner = X[:, 1:-1, 1:-1].reshape(-1, 3)
898
+ nrm = np.repeat(np.stack([n for _, n in todo]), (N - 2) ** 2, axis=0)
899
+ # a point of the inside is never further off the surface than
900
+ # its patch is across: a hit further than that is another sheet
901
+ size = np.linalg.norm(X[:, 0, 0] - X[:, -1, -1], axis=1) + np.linalg.norm(X[:, 0, -1] - X[:, -1, 0], axis=1)
902
+ reach = np.repeat(0.5 * size, (N - 2) ** 2)
903
+ X[:, 1:-1, 1:-1] = pull(inner, nrm, reach).reshape(len(todo), N - 2, N - 2, 3)
904
+ Cs = [poles_of(*sd, x) for (sd, _), x in zip(todo, X)]
905
+ if refine:
906
+ split = deviant(Cs, owner, edge_cache, poly)
907
+ if split:
908
+ return "split", (split, poly)
909
+ for C in Cs:
910
+ sew.Add(patch(C))
911
+ sew.Perform()
912
+ return sew, (nq, n_region, n_fallback, why)
913
+
914
+ def close(sew):
915
+ """The sewn faces as a solid; exact regions that fell back to one
916
+ face per triangle merge back (patches stay patches)."""
917
+ ex = TopExp_Explorer(sew.SewedShape(), TopAbs_SHELL)
918
+ shells = []
919
+ while ex.More():
920
+ shells.append(TopoDS.Shell(ex.Current()))
921
+ ex.Next()
922
+ if not shells or sew.NbFreeEdges():
923
+ raise RuntimeError(f"patches did not close ({len(shells)} shells, {sew.NbFreeEdges()} free edges)")
924
+ # more than one shell: sealed voids inside (a sandwich's skin round
925
+ # its lattice), the biggest is the outside
926
+ size = [abs(_volume(BRepBuilderAPI_MakeSolid(sh).Solid())) for sh in shells]
927
+ order = np.argsort(size)[::-1]
928
+ mk = BRepBuilderAPI_MakeSolid(shells[int(order[0])])
929
+ for k in order[1:]:
930
+ mk.Add(shells[int(k)])
931
+ fx = ShapeFix_Solid(mk.Solid())
932
+ fx.Perform()
933
+ solid = fx.Solid()
934
+ if _volume(solid) < 0:
935
+ solid.Reverse()
936
+ u = ShapeUpgrade_UnifySameDomain(solid, False, True, False)
937
+ fe = TopExp_Explorer(solid, TopAbs_FACE)
938
+ while fe.More():
939
+ if BRepAdaptor_Surface(TopoDS.Face(fe.Current())).GetType() == GeomAbs_BezierSurface:
940
+ ee = TopExp_Explorer(fe.Current(), TopAbs_EDGE)
941
+ while ee.More():
942
+ u.KeepShape(ee.Current())
943
+ ee.Next()
944
+ fe.Next()
945
+ u.Build()
946
+ merged = u.Shape()
947
+ if not BRepCheck_Analyzer(merged).IsValid():
948
+ sf = ShapeFix_Shape(merged)
949
+ sf.Perform()
950
+ merged = sf.Shape()
951
+ return merged if BRepCheck_Analyzer(merged).IsValid() or not BRepCheck_Analyzer(solid).IsValid() else solid
952
+
953
+ def bad_regions(solid):
954
+ """Coarse triangles of the exact regions behind the solid's invalid
955
+ faces (sewing can still tangle a boundary a lone face had right)."""
956
+ from scipy.spatial import cKDTree
957
+
958
+ ex_t = np.flatnonzero(flab < O)
959
+ if not len(ex_t):
960
+ return []
961
+ kd = cKDTree(CV[F[ex_t]].mean(axis=1))
962
+ out = []
963
+ fe = TopExp_Explorer(solid, TopAbs_FACE)
964
+ while fe.More():
965
+ f = TopoDS.Face(fe.Current())
966
+ if BRepAdaptor_Surface(f).GetType() != GeomAbs_BezierSurface and not BRepCheck_Analyzer(f).IsValid():
967
+ tm = shape_to_trimesh(f, deflection=0.05, angle=0.3)
968
+ if len(tm.faces):
969
+ _, i = kd.query(tm.triangles_center[np.argmax(tm.area_faces)])
970
+ t = ex_t[i]
971
+ out.append(np.flatnonzero(comp_of(t)))
972
+ fe.Next()
973
+ return out
974
+
975
+ def comp_of(t):
976
+ """Mask of the exact region (connected, one label) holding triangle t."""
977
+ import scipy.sparse as sp3
978
+ from scipy.sparse.csgraph import connected_components
979
+
980
+ same = (flab[cf0] == flab[cf1]) & (flab[cf0] == flab[t])
981
+ G = sp3.coo_matrix((np.ones(same.sum()), (cf0[same], cf1[same])), shape=(len(F), len(F)))
982
+ _, c = connected_components(G, directed=False)
983
+ return (c == c[t]) & (flab == flab[t])
984
+
985
+ from math import comb as _comb
986
+
987
+ from .sharp import mesh_distance
988
+ from .step import shape_to_trimesh
989
+
990
+ g9 = np.linspace(0, 1, 9)
991
+ B9 = np.array([[_comb(N - 1, j) * x ** j * (1 - x) ** (N - 1 - j) for j in range(N)] for x in g9])
992
+
993
+ vn = np.asarray(mesh.vertex_normals)
994
+
995
+ def bend(e):
996
+ """How much the surface turns along coarse edge e, times its length:
997
+ the edge to split across a tight bend (the longest one would cut along it)."""
998
+ _, i = vtree.query(CV[list(e)])
999
+ c = float(np.clip(vn[i[0]] @ vn[i[1]], -1, 1))
1000
+ return (np.arccos(c) + 0.05) * float(np.linalg.norm(CV[e[0]] - CV[e[1]]))
1001
+
1002
+ worst = [0.0]
1003
+
1004
+ def deviant(Cs, owner, edges, poly):
1005
+ """Coarse edges to split: those whose curve misses the mesh by more
1006
+ than REFINE, and the longest edge of each triangle under a patch that
1007
+ does. Edges shorter than MIN_SPLIT stay."""
1008
+ from OCP.BRep import BRep_Tool
1009
+
1010
+ S = np.einsum("ua,nabk,vb->nuvk", B9, np.stack(Cs), B9).reshape(len(Cs), -1, 3)
1011
+ dp = mesh_distance(mesh, S.reshape(-1, 3), cap=2.0).reshape(len(Cs), -1).max(axis=1)
1012
+ keys = list(edges)
1013
+ P = []
1014
+ for k in keys:
1015
+ c = BRep_Tool.Curve_s(edges[k], 0.0, 1.0)
1016
+ t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
1017
+ P.append([[q.X(), q.Y(), q.Z()] for q in (c.Value(x) for x in t)])
1018
+ de = mesh_distance(mesh, np.array(P, float).reshape(-1, 3), cap=2.0).reshape(len(keys), -1).max(axis=1) if keys else []
1019
+ out = {k[-1] for k, d in zip(keys, de) if d > REFINE}
1020
+ worst[0] = float(dp.max()) if len(dp) else 0.0
1021
+ # and every edge whose draped path is off the surface (a coarse
1022
+ # triangle bridging a groove: its chord found no surface to fall on)
1023
+ pe = [e for e, P in poly.items() if P is not None]
1024
+ if pe:
1025
+ dl = mesh_distance(mesh, np.concatenate([poly[e] for e in pe]), cap=2.0)
1026
+ cut = np.cumsum([len(poly[e]) for e in pe])[:-1]
1027
+ out |= {e for e, d in zip(pe, np.split(dl, cut)) if d.max() > REFINE}
1028
+ for C_owner, d in zip(owner, dp):
1029
+ if d > REFINE:
1030
+ for t in C_owner:
1031
+ f = [int(x) for x in F[t]]
1032
+ es = [(min(f[k], f[(k + 1) % 3]), max(f[k], f[(k + 1) % 3])) for k in range(3)]
1033
+ out.add(max(es, key=bend))
1034
+ return {e for e in out if np.linalg.norm(CV[e[0]] - CV[e[1]]) > MIN_SPLIT and e in poly and poly[e] is not None}
1035
+
1036
+ from scipy.spatial import cKDTree as _KDT
1037
+
1038
+ vtree = _KDT(V)
1039
+
1040
+ def split_edges(split, poly):
1041
+ """The coarse mesh with those edges cut at their middle on the
1042
+ surface (a seam's at its mesh vertex), triangles split to match."""
1043
+ nonlocal CV, vid, F, flab
1044
+ mid = {}
1045
+ kq = {int(k): (int(a), int(b)) for k, a, b in zip(ckey, cf0, cf1)}
1046
+ for e in split:
1047
+ P = poly[e]
1048
+ d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))]
1049
+ i = int(np.argmin(np.abs(d - 0.5 * d[-1])))
1050
+ g0, g1 = kq[e[0] * (int(F.max()) + 1) + e[1]]
1051
+ seam = flab[g0] != flab[g1]
1052
+ if seam and not 0 < i < len(P) - 1:
1053
+ continue # a seam splits only at one of its mesh vertices
1054
+ if 0 < i < len(P) - 1:
1055
+ q = P[i]
1056
+ else:
1057
+ w = 0.5 * d[-1]
1058
+ j = min(max(int(np.searchsorted(d, w)), 1), len(P) - 1)
1059
+ q = P[j - 1] + (P[j] - P[j - 1]) * (w - d[j - 1]) / max(d[j] - d[j - 1], 1e-300)
1060
+ dd, vi = vtree.query(q)
1061
+ mid[e] = (q, int(vi) if dd < 1e-9 else -1)
1062
+ idx = {}
1063
+ CVl, vidl = list(CV), list(vid)
1064
+ for e, (q, vi) in mid.items():
1065
+ idx[e] = len(CVl)
1066
+ CVl.append(q)
1067
+ vidl.append(vi)
1068
+ nF, nL = [], []
1069
+ for f, lf in zip(F, flab):
1070
+ v = [int(x) for x in f]
1071
+ m = [idx.get((min(v[k], v[(k + 1) % 3]), max(v[k], v[(k + 1) % 3]))) for k in range(3)]
1072
+ cnt = sum(x is not None for x in m)
1073
+ if cnt == 0:
1074
+ tri = [v]
1075
+ elif cnt == 3:
1076
+ tri = [[v[0], m[0], m[2]], [m[0], v[1], m[1]], [m[2], m[1], v[2]], [m[0], m[1], m[2]]]
1077
+ else:
1078
+ r = 0
1079
+ if cnt == 1:
1080
+ r = next(k for k in range(3) if m[k] is not None)
1081
+ else:
1082
+ r = (next(k for k in range(3) if m[k] is None) + 1) % 3
1083
+ v = v[r:] + v[:r]
1084
+ m = m[r:] + m[:r]
1085
+ if cnt == 1:
1086
+ tri = [[v[0], m[0], v[2]], [m[0], v[1], v[2]]]
1087
+ else: # m[0] on v0-v1, m[1] on v1-v2, v2-v0 whole
1088
+ tri = [[m[0], v[1], m[1]], [v[0], m[0], m[1]], [v[0], m[1], v[2]]]
1089
+ nF += tri
1090
+ nL += [int(lf)] * len(tri)
1091
+ CV, vid = np.array(CVl, float), np.array(vidl)
1092
+ F, flab = np.array(nF, np.int64), np.array(nL, np.int64)
1093
+
1094
+ def snap():
1095
+ """A vertex inside the organic body that is off the mesh (one of an
1096
+ exact region's, slid along its surface's extension past the region,
1097
+ until the region became patches): onto the mesh, or every patch
1098
+ round it misses by as much and no splitting can help."""
1099
+ nonlocal CV, vid
1100
+ _, _, nl, _, one = _vertex_sets(F, flab, len(CV))
1101
+ o = np.flatnonzero((nl == 1) & (one >= O))
1102
+ if len(o):
1103
+ cp, _ = _closest(mesh, CV[o])
1104
+ far = np.flatnonzero(np.linalg.norm(cp - CV[o], axis=1) > TOL)
1105
+ if len(far):
1106
+ CV, vid = CV.copy(), vid.copy()
1107
+ CV[o[far]] = cp[far]
1108
+ vid[o[far]] = -1
1109
+ unfold()
1110
+
1111
+ def unfold(passes=4):
1112
+ """Flip edges of organic coarse triangles that face against the
1113
+ surface under them (a collapse or a split folded the layout there):
1114
+ the pulls along a backwards normal miss, and the patch folds over
1115
+ its neighbours."""
1116
+ nonlocal F
1117
+ for _ in range(passes):
1118
+ org = np.flatnonzero(flab >= O)
1119
+ fn = lambda T: np.cross(T[..., 1, :] - T[..., 0, :], T[..., 2, :] - T[..., 0, :])
1120
+ T = CV[F[org]]
1121
+ n = fn(T)
1122
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
1123
+ _, sn = _closest(mesh, T.mean(axis=1))
1124
+ bad = org[np.einsum("ij,ij->i", n, sn) < 0.3]
1125
+ if not len(bad):
1126
+ return
1127
+ owner_e = {}
1128
+ for t, f in enumerate(F):
1129
+ for k in range(3):
1130
+ owner_e.setdefault((min(f[k], f[(k + 1) % 3]), max(f[k], f[(k + 1) % 3])), []).append(t)
1131
+ done, changed = set(), False
1132
+ for t in bad:
1133
+ if t in done:
1134
+ continue
1135
+ f = [int(x) for x in F[t]]
1136
+ best = None
1137
+ for k in range(3):
1138
+ a, b, c = f[k], f[(k + 1) % 3], f[(k + 2) % 3]
1139
+ ts = owner_e[(min(a, b), max(a, b))]
1140
+ if len(ts) != 2:
1141
+ continue
1142
+ u = ts[0] if ts[1] == t else ts[1]
1143
+ if u in done or flab[u] != flab[t]:
1144
+ continue
1145
+ d = next(int(x) for x in F[u] if x not in (a, b))
1146
+ if (min(c, d), max(c, d)) in owner_e:
1147
+ continue # the flipped edge exists already
1148
+ new = np.array([[c, a, d], [c, d, b]])
1149
+ Tn = CV[new]
1150
+ nn = fn(Tn)
1151
+ area = np.linalg.norm(nn, axis=1)
1152
+ if area.min() < 1e-9:
1153
+ continue
1154
+ _, s2 = _closest(mesh, Tn.mean(axis=1))
1155
+ q = np.einsum("ij,ij->i", nn / area[:, None], s2).min()
1156
+ if q > 0.5 and (best is None or q > best[0]):
1157
+ best = (q, u, new)
1158
+ if best is not None:
1159
+ _, u, new = best
1160
+ F = F.copy()
1161
+ F[t], F[u] = new[0], new[1]
1162
+ done |= {t, int(u)}
1163
+ changed = True
1164
+ if not changed:
1165
+ return
1166
+
1167
+ # refine while it helps: a split can also fold the layout somewhere
1168
+ # (a big triangle wrapped round a thin wall's edge), so the best layout
1169
+ # so far, by its worst patch, is kept and refining stops once two
1170
+ # rounds in a row are no better
1171
+ refined, best, worse = 0, None, 0
1172
+ for r in range(REFINE_ROUNDS + 1):
1173
+ snap()
1174
+ CE, ckey, cf0, cf1 = _edge_faces(F)
1175
+ sew, got = build(0, refine=True)
1176
+ if sew is None: # a region to patch first
1177
+ for tris in got:
1178
+ flab[tris] = int(flab.max()) + 1
1179
+ best, worse = None, 0
1180
+ continue
1181
+ if not isinstance(sew, str):
1182
+ best = None # nothing left to split: this one
1183
+ break
1184
+ split, poly_ = got
1185
+ if best is None or worst[0] < best[0]:
1186
+ best, worse = (worst[0], CV, vid, F, flab, refined), 0
1187
+ else:
1188
+ worse += 1
1189
+ if r == REFINE_ROUNDS or worse >= 2:
1190
+ break
1191
+ split_edges(split, poly_)
1192
+ refined += len(split)
1193
+ if best is not None:
1194
+ _, CV, vid, F, flab, refined = best
1195
+ CE, ckey, cf0, cf1 = _edge_faces(F)
1196
+
1197
+ demoted = 0
1198
+ for attempt in range(5):
1199
+ snap()
1200
+ CE, ckey, cf0, cf1 = _edge_faces(F)
1201
+ sew, got = build(attempt)
1202
+ if sew is None:
1203
+ for tris in got:
1204
+ flab[tris] = int(flab.max()) + 1
1205
+ demoted += len(got)
1206
+ continue
1207
+ solid = close(sew)
1208
+ bad = bad_regions(solid) if not BRepCheck_Analyzer(solid).IsValid() and attempt < 4 else []
1209
+ if not bad:
1210
+ break
1211
+ for tris in bad:
1212
+ flab[tris] = int(flab.max()) + 1
1213
+ demoted += len(bad)
1214
+ nq, n_region, n_fallback, why = got
1215
+ solid = _forward(solid)
1216
+ t3 = time.time()
1217
+ rep = {"patches": nq, "degree": N - 1, "exactFaces": n_region, "refinedEdges": refined, "exactDemoted": demoted, "exactFallbacks": n_fallback, "reasons": why[:5],
1218
+ "triangles": int((flab >= O).sum()), "exactTriangles": int((flab < O).sum()),
1219
+ "exactSurfaces": len({int(x) for x in np.unique(lab) if x < O}),
1220
+ "smoothRegions": len({int(x) for x in np.unique(lab) if x >= O}),
1221
+ "seconds": {"coarsen": round(t1 - t0, 1), "patches": round(t3 - t1, 1),
1222
+ "solid": round(time.time() - t3, 1)}}
1223
+ return solid, rep
1224
+
1225
+
1226
+ def _forward(solid, tries=20):
1227
+ """The solid rebuilt with every face carrying its own outward
1228
+ orientation: STEP keeps faces' orientations but not a reversed shell's
1229
+ or solid's, so an inside-out shell would read back inside out. Readers
1230
+ then check the orientation with OpenCascade's infinite-point test,
1231
+ which shoots from the first face and can misjudge a patch; the faces go
1232
+ in an order it gets right, or a reader would turn the part inside out."""
1233
+ from OCP.BRep import BRep_Builder
1234
+ from OCP.BRepClass3d import BRepClass3d_SolidClassifier
1235
+ from OCP.TopAbs import TopAbs_FACE, TopAbs_OUT, TopAbs_SHELL
1236
+ from OCP.TopExp import TopExp_Explorer
1237
+ from OCP.TopoDS import TopoDS_Shell, TopoDS_Solid
1238
+
1239
+ from .step import _volume
1240
+
1241
+ shells = [] # faces of each shell, outside first
1242
+ es = TopExp_Explorer(solid, TopAbs_SHELL)
1243
+ while es.More():
1244
+ fs, ex = [], TopExp_Explorer(es.Current(), TopAbs_FACE)
1245
+ while ex.More():
1246
+ fs.append(ex.Current())
1247
+ ex.Next()
1248
+ shells.append(fs)
1249
+ es.Next()
1250
+ if not shells:
1251
+ return solid
1252
+
1253
+ def shell_of(fs):
1254
+ bb = BRep_Builder()
1255
+ shell = TopoDS_Shell()
1256
+ bb.MakeShell(shell)
1257
+ for f in fs:
1258
+ bb.Add(shell, f)
1259
+ shell.Closed(True)
1260
+ return shell
1261
+
1262
+ def make(fs, rest):
1263
+ bb = BRep_Builder()
1264
+ out = TopoDS_Solid()
1265
+ bb.MakeSolid(out)
1266
+ bb.Add(out, shell_of(fs))
1267
+ for r in rest:
1268
+ bb.Add(out, shell_of(r))
1269
+ return out
1270
+
1271
+ if len(shells) > 1: # the outside first
1272
+ vols = [_volume(make(fs, [])) for fs in shells]
1273
+ k = int(np.argmax(vols))
1274
+ shells = [shells[k]] + shells[:k] + shells[k + 1:]
1275
+ faces, rest = shells[0], shells[1:]
1276
+ out = make(faces, rest)
1277
+ if _volume(out) <= 0:
1278
+ return solid
1279
+ for k in range(min(tries, len(faces))):
1280
+ cand = make(faces[k:] + faces[:k], rest) if k else out
1281
+ cl = BRepClass3d_SolidClassifier(cand)
1282
+ cl.PerformInfinitePoint(1e-6)
1283
+ if cl.State() == TopAbs_OUT:
1284
+ return cand
1285
+ return out
1286
+
1287
+
1288
+ def _pairs(V, F, lab, O, cos_min=0.5, angle_max=160.0):
1289
+ """Organic triangles matched in pairs across an edge inside one region,
1290
+ best-shaped quads first: [(i, j)]. A pair must be convex, with corner
1291
+ angles under `angle_max` degrees and the two normals within ~60 degrees."""
1292
+ E, _, f0, f1 = _edge_faces(F)
1293
+ ok = (lab[f0] == lab[f1]) & (lab[f0] >= O)
1294
+ E, f0, f1 = E[ok], f0[ok], f1[ok]
1295
+ n = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]])
1296
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
1297
+ a, b = E[:, 0], E[:, 1]
1298
+ c = F[f0].sum(axis=1) - a - b
1299
+ d = F[f1].sum(axis=1) - a - b
1300
+ Q = np.stack([V[a], V[c], V[b], V[d]], axis=1) # around the quad
1301
+ p = np.roll(Q, 1, axis=1) - Q
1302
+ q = np.roll(Q, -1, axis=1) - Q
1303
+ cosang = np.einsum("ikj,ikj->ik", p, q) / np.maximum(np.linalg.norm(p, axis=2) * np.linalg.norm(q, axis=2), 1e-300)
1304
+ ang = np.degrees(np.arccos(np.clip(cosang, -1, 1)))
1305
+ nn = n[f0] + n[f1]
1306
+ turn = np.einsum("ikj,ij->ik", np.cross(q, p), nn) # convex: every corner turns the same way
1307
+ good = ((np.einsum("ij,ij->i", n[f0], n[f1]) > cos_min) & (ang.max(axis=1) < angle_max)
1308
+ & ((turn > 0).all(axis=1) | (turn < 0).all(axis=1)))
1309
+ score = np.abs(ang - 90.0).max(axis=1)
1310
+ used = np.zeros(len(F), bool)
1311
+ out = []
1312
+ for k in np.flatnonzero(good)[np.argsort(score[good], kind="stable")]:
1313
+ i, j = int(f0[k]), int(f1[k])
1314
+ if not used[i] and not used[j]:
1315
+ used[i] = used[j] = True
1316
+ out.append((i, j))
1317
+ return out
1318
+
1319
+
1320
+ def _uv(s, P, ref=None):
1321
+ """(u, v) of points on exact surface `s` in OpenCascade's parameters for
1322
+ s.geom(): planes and cylinders (angle unwrapped around `ref`)."""
1323
+ if s.kind == "plane":
1324
+ X, Y = _frame(s.n)
1325
+ d = P - s.p
1326
+ return np.stack([d @ X, d @ Y], axis=1)
1327
+ X, Y = _frame(s.axis)
1328
+ d = P - s.p0
1329
+ u = np.arctan2(d @ Y, d @ X)
1330
+ if ref is not None:
1331
+ u = ref + (u - ref + np.pi) % (2 * np.pi) - np.pi
1332
+ return np.stack([u, d @ s.axis], axis=1)
1333
+
1334
+
1335
+ def _frame(n):
1336
+ """The X and Y directions OpenCascade gives gp_Ax3(origin, n)."""
1337
+ from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
1338
+
1339
+ ax = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(*map(float, n)))
1340
+ x, y = ax.XDirection(), ax.YDirection()
1341
+ return np.array([x.X(), x.Y(), x.Z()]), np.array([y.X(), y.Y(), y.Z()])
1342
+
1343
+
1344
+ def _exact_face(s, geom, loop, centre, tol=0.01):
1345
+ """A face on exact surface `s` (its OpenCascade surface `geom`) bounded by
1346
+ `loop`, [(edge, reversed)], with each edge's curve on the surface given
1347
+ explicitly (sampled, mapped to (u, v), fitted), so a bore's face covers
1348
+ the arc it should and not the rest of the cylinder."""
1349
+ from OCP.BRep import BRep_Builder, BRep_Tool
1350
+ from OCP.BRepLib import BRepLib
1351
+ from OCP.Geom2dAPI import Geom2dAPI_PointsToBSpline
1352
+ from OCP.GeomAbs import GeomAbs_C2
1353
+ from OCP.gp import gp_Pnt2d
1354
+ from OCP.OCP.collections import Array1_double, Array1_gp_Pnt2d
1355
+ from OCP.TopAbs import TopAbs_REVERSED
1356
+ from OCP.TopoDS import TopoDS_Face, TopoDS_Wire
1357
+
1358
+ if s.kind == "sphere":
1359
+ raise RuntimeError("spherical exact faces are not built yet")
1360
+ ref = None
1361
+ if s.kind == "cylinder":
1362
+ ref = float(_uv(s, centre[None])[0, 0])
1363
+ bb = BRep_Builder()
1364
+ face = TopoDS_Face()
1365
+ bb.MakeFace(face, geom, 1e-7)
1366
+ wire = TopoDS_Wire()
1367
+ bb.MakeWire(wire)
1368
+ ring = []
1369
+ for e, rev in loop:
1370
+ c = BRep_Tool.Curve_s(e, 0.0, 1.0)
1371
+ t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
1372
+ P = np.array([[p.X(), p.Y(), p.Z()] for p in (c.Value(x) for x in t)])
1373
+ UV = _uv(s, P, ref)
1374
+ pts, par = Array1_gp_Pnt2d(1, len(t)), Array1_double(1, len(t))
1375
+ for j, (q, x) in enumerate(zip(UV, t)):
1376
+ pts.SetValue(j + 1, gp_Pnt2d(float(q[0]), float(q[1])))
1377
+ par.SetValue(j + 1, float(x))
1378
+ pc = Geom2dAPI_PointsToBSpline(pts, par, 1, 3, GeomAbs_C2, 1e-7).Curve()
1379
+ bb.UpdateEdge(e, pc, face, tol)
1380
+ bb.Add(wire, e.Oriented(TopAbs_REVERSED) if rev else e)
1381
+ ring.append(UV[::-1] if rev else UV)
1382
+ R = np.vstack(ring)
1383
+ area = 0.5 * np.sum(R[:, 0] * np.roll(R[:, 1], -1) - np.roll(R[:, 0], -1) * R[:, 1])
1384
+ if area < 0:
1385
+ wire.Reverse()
1386
+ bb.Add(face, wire)
1387
+ BRepLib.SameParameter_s(face, tol)
1388
+ from OCP.ShapeFix import ShapeFix_Face
1389
+
1390
+ fx = ShapeFix_Face(face) # the wire's own checks (uv gaps at the corners)
1391
+ fx.Perform()
1392
+ return fx.Face()
1393
+
1394
+
1395
+ def _region_face(s, geom, loops, flip, tol=0.01):
1396
+ """One face on exact surface `s` bounded by `loops` ([(edge, reversed)]
1397
+ each, counterclockwise about the part's outward normal; `flip` when that
1398
+ is against the surface's own normal). Each edge gets its curve on the
1399
+ surface, the angle around a cylinder unwrapped along the loop, so a ring
1400
+ round a bore closes in 3D but not in (u, v): ShapeFix adds the seam."""
1401
+ from OCP.BRep import BRep_Builder, BRep_Tool
1402
+ from OCP.BRepCheck import BRepCheck_Analyzer
1403
+ from OCP.BRepLib import BRepLib
1404
+ from OCP.Geom2dAPI import Geom2dAPI_PointsToBSpline
1405
+ from OCP.GeomAbs import GeomAbs_C2
1406
+ from OCP.gp import gp_Pnt2d
1407
+ from OCP.OCP.collections import Array1_double, Array1_gp_Pnt2d
1408
+ from OCP.ShapeFix import ShapeFix_Face
1409
+ from OCP.TopAbs import TopAbs_REVERSED
1410
+ from OCP.TopoDS import TopoDS_Face, TopoDS_Wire
1411
+
1412
+ if s.kind == "sphere":
1413
+ raise RuntimeError("spherical exact faces are not built yet")
1414
+ bb = BRep_Builder()
1415
+ face = TopoDS_Face()
1416
+ bb.MakeFace(face, geom, 1e-7)
1417
+ for loop in loops:
1418
+ if flip:
1419
+ loop = [(e, not r) for e, r in reversed(loop)]
1420
+ parts = []
1421
+ for e, rev in loop:
1422
+ c = BRep_Tool.Curve_s(e, 0.0, 1.0)
1423
+ t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
1424
+ P = np.array([[p.X(), p.Y(), p.Z()] for p in (c.Value(x) for x in t)])
1425
+ parts.append((t, P[::-1] if rev else P))
1426
+ UV = _uv(s, np.vstack([P for _, P in parts]))
1427
+ if s.kind == "cylinder":
1428
+ UV[:, 0] = np.unwrap(UV[:, 0])
1429
+ wire = TopoDS_Wire()
1430
+ bb.MakeWire(wire)
1431
+ for k, ((e, rev), (t, _)) in enumerate(zip(loop, parts)):
1432
+ uv = UV[12 * k:12 * (k + 1)]
1433
+ if rev:
1434
+ uv = uv[::-1]
1435
+ pts, par = Array1_gp_Pnt2d(1, len(t)), Array1_double(1, len(t))
1436
+ for j, (q, x) in enumerate(zip(uv, t)):
1437
+ pts.SetValue(j + 1, gp_Pnt2d(float(q[0]), float(q[1])))
1438
+ par.SetValue(j + 1, float(x))
1439
+ pc = Geom2dAPI_PointsToBSpline(pts, par, 1, 3, GeomAbs_C2, 1e-7).Curve()
1440
+ bb.UpdateEdge(e, pc, face, tol)
1441
+ bb.Add(wire, e.Oriented(TopAbs_REVERSED) if rev else e)
1442
+ bb.Add(face, wire)
1443
+ BRepLib.SameParameter_s(face, tol)
1444
+ fx = ShapeFix_Face(face)
1445
+ fx.Perform()
1446
+ out = fx.Face()
1447
+ if not BRepCheck_Analyzer(out).IsValid():
1448
+ raise RuntimeError("exact region face is not valid")
1449
+ return out
1450
+
1451
+
1452
+ def _bernstein(d):
1453
+ """B[i, j] = j-th Bernstein polynomial of degree d at i / d."""
1454
+ from math import comb
1455
+
1456
+ t = np.linspace(0, 1, d + 1)
1457
+ return np.array([[comb(d, j) * x ** j * (1 - x) ** (d - j) for j in range(d + 1)] for x in t])
1458
+
1459
+
1460
+ def _onto(s, P):
1461
+ """Points pulled onto exact surface `s`."""
1462
+ if s.kind == "plane":
1463
+ return P - ((P - s.p) @ s.n)[:, None] * s.n
1464
+ if s.kind == "cylinder":
1465
+ d = P - s.p0
1466
+ t = d @ s.axis
1467
+ r = d - t[:, None] * s.axis
1468
+ return s.p0 + t[:, None] * s.axis + r / np.maximum(np.linalg.norm(r, axis=1, keepdims=True), 1e-12) * s.r
1469
+ d = P - s.c
1470
+ return s.c + d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12) * s.r