mesh2cad 0.3.0__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.
- mesh2cad/__init__.py +1 -0
- mesh2cad/brep.py +552 -0
- mesh2cad/cli.py +81 -0
- mesh2cad/convert.py +55 -0
- mesh2cad/fit.py +305 -0
- mesh2cad/io.py +31 -0
- mesh2cad/mcp_server.py +60 -0
- mesh2cad/report.py +108 -0
- mesh2cad/segment.py +565 -0
- mesh2cad-0.3.0.dist-info/METADATA +117 -0
- mesh2cad-0.3.0.dist-info/RECORD +14 -0
- mesh2cad-0.3.0.dist-info/WHEEL +4 -0
- mesh2cad-0.3.0.dist-info/entry_points.txt +2 -0
- mesh2cad-0.3.0.dist-info/licenses/LICENSE +21 -0
mesh2cad/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
mesh2cad/brep.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Build a B-rep solid from segmented mesh and export STEP.
|
|
2
|
+
|
|
3
|
+
Strategy: snap every mesh vertex onto the fitted surfaces of all regions it
|
|
4
|
+
touches (corner -> plane intersection point, edge -> intersection curve),
|
|
5
|
+
rebuild triangles as OCC faces, sew, then merge co-surface faces with
|
|
6
|
+
ShapeUpgrade_UnifySameDomain so each fitted plane becomes one clean face.
|
|
7
|
+
Unfitted regions stay faceted (hybrid fallback).
|
|
8
|
+
|
|
9
|
+
Exact path (build_planar): plane polygons from triple-plane intersections,
|
|
10
|
+
circles from plane ∩ cylinder and plane ∩ sphere, cylindrical faces, and
|
|
11
|
+
spherical caps/zones bounded by those circles.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import trimesh
|
|
17
|
+
from OCP.BRep import BRep_Builder
|
|
18
|
+
from OCP.BRepBuilderAPI import (BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon,
|
|
19
|
+
BRepBuilderAPI_MakeSolid, BRepBuilderAPI_Sewing)
|
|
20
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
21
|
+
from OCP.gp import gp_Pnt
|
|
22
|
+
from OCP.IFSelect import IFSelect_RetDone
|
|
23
|
+
from OCP.ShapeFix import ShapeFix_Solid
|
|
24
|
+
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|
25
|
+
from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
|
|
26
|
+
from OCP.TopAbs import TopAbs_SHELL
|
|
27
|
+
from OCP.TopExp import TopExp_Explorer
|
|
28
|
+
from OCP.TopoDS import TopoDS, TopoDS_Shape
|
|
29
|
+
|
|
30
|
+
from .fit import Cone, Cylinder, Plane, Sphere
|
|
31
|
+
from .segment import Region
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _curved_normal(surface, pts):
|
|
35
|
+
"""Unit surface normal used to linearise a cylinder or sphere constraint."""
|
|
36
|
+
if isinstance(surface, Cone):
|
|
37
|
+
return surface.normal(pts)
|
|
38
|
+
if isinstance(surface, Cylinder):
|
|
39
|
+
rad = surface._radial(pts)
|
|
40
|
+
elif isinstance(surface, Sphere):
|
|
41
|
+
rad = pts - surface.center
|
|
42
|
+
else:
|
|
43
|
+
raise TypeError(type(surface))
|
|
44
|
+
n = np.linalg.norm(rad, axis=1, keepdims=True)
|
|
45
|
+
n = np.maximum(n, 1e-12)
|
|
46
|
+
return rad / n
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def snap_vertices(m: trimesh.Trimesh, regions: list[Region], tol: float, iters: int = 10):
|
|
50
|
+
"""Move vertices onto the fitted surfaces of every region they touch.
|
|
51
|
+
Plane-only vertices are solved in one batched least-squares step;
|
|
52
|
+
vertices touching a cylinder or sphere use a few linearised iterations."""
|
|
53
|
+
nv = len(m.vertices)
|
|
54
|
+
x0 = m.vertices.copy()
|
|
55
|
+
lam = 1e-6
|
|
56
|
+
rows_v, rows_r = [], []
|
|
57
|
+
for i, r in enumerate(regions):
|
|
58
|
+
if r.surface is None:
|
|
59
|
+
continue
|
|
60
|
+
vs = np.unique(m.faces[r.faces])
|
|
61
|
+
rows_v.append(vs)
|
|
62
|
+
rows_r.append(np.full(len(vs), i))
|
|
63
|
+
if not rows_v:
|
|
64
|
+
return x0
|
|
65
|
+
rv, rr = np.concatenate(rows_v), np.concatenate(rows_r)
|
|
66
|
+
is_plane = np.array([isinstance(r.surface, Plane) for r in regions])
|
|
67
|
+
curved = np.zeros(nv, bool)
|
|
68
|
+
curved[rv[~is_plane[rr]]] = True
|
|
69
|
+
touched = np.zeros(nv, bool)
|
|
70
|
+
touched[rv] = True
|
|
71
|
+
|
|
72
|
+
def solve(p, N, D, idx):
|
|
73
|
+
# accumulate N^T N and N^T D per vertex
|
|
74
|
+
A = np.zeros((nv, 3, 3))
|
|
75
|
+
b = np.zeros((nv, 3))
|
|
76
|
+
np.add.at(A, idx, N[:, :, None] * N[:, None, :])
|
|
77
|
+
np.add.at(b, idx, N * D[:, None])
|
|
78
|
+
A += lam * np.eye(3)
|
|
79
|
+
b += lam * p
|
|
80
|
+
return np.linalg.solve(A, b[..., None])[..., 0]
|
|
81
|
+
|
|
82
|
+
pl = is_plane[rr]
|
|
83
|
+
Np = np.array([regions[i].surface.normal if is_plane[i] else np.zeros(3) for i in range(len(regions))])
|
|
84
|
+
Dp = np.array([regions[i].surface.d if is_plane[i] else 0.0 for i in range(len(regions))])
|
|
85
|
+
x = x0.copy()
|
|
86
|
+
for _ in range(iters if curved.any() else 1):
|
|
87
|
+
N = np.zeros((len(rv), 3))
|
|
88
|
+
D = np.zeros(len(rv))
|
|
89
|
+
N[pl], D[pl] = Np[rr[pl]], Dp[rr[pl]]
|
|
90
|
+
for i in np.unique(rr[~pl]):
|
|
91
|
+
sel = (rr == i)
|
|
92
|
+
c = regions[i].surface
|
|
93
|
+
p = x[rv[sel]]
|
|
94
|
+
q = c.project(p)
|
|
95
|
+
n = _curved_normal(c, p)
|
|
96
|
+
N[sel], D[sel] = n, np.einsum("ij,ij->i", n, q)
|
|
97
|
+
xn = solve(x0, N, D, rv)
|
|
98
|
+
xn[~touched] = x0[~touched]
|
|
99
|
+
# guard against ill-conditioned (near-parallel) solves
|
|
100
|
+
bad = np.linalg.norm(xn - x0, axis=1) > 4 * tol + 1e-9
|
|
101
|
+
if bad.any():
|
|
102
|
+
first = {}
|
|
103
|
+
for v, i in zip(rv, rr):
|
|
104
|
+
first.setdefault(v, i)
|
|
105
|
+
for v in np.where(bad)[0]:
|
|
106
|
+
xn[v] = regions[first[v]].surface.project(x0[v][None])[0]
|
|
107
|
+
done = np.abs(xn - x).max() < 1e-10
|
|
108
|
+
x = xn
|
|
109
|
+
if done:
|
|
110
|
+
break
|
|
111
|
+
return x
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_solid(m: trimesh.Trimesh, verts: np.ndarray, sew_tol: float) -> TopoDS_Shape:
|
|
115
|
+
sew = BRepBuilderAPI_Sewing(sew_tol)
|
|
116
|
+
pts = [gp_Pnt(*map(float, v)) for v in verts]
|
|
117
|
+
for f in m.faces:
|
|
118
|
+
a, b, c = f
|
|
119
|
+
if len({a, b, c}) < 3:
|
|
120
|
+
continue
|
|
121
|
+
poly = BRepBuilderAPI_MakePolygon(pts[a], pts[b], pts[c], True)
|
|
122
|
+
if not poly.IsDone():
|
|
123
|
+
continue
|
|
124
|
+
mf = BRepBuilderAPI_MakeFace(poly.Wire(), True)
|
|
125
|
+
if mf.IsDone():
|
|
126
|
+
sew.Add(mf.Face())
|
|
127
|
+
sew.Perform()
|
|
128
|
+
shape = sew.SewedShape()
|
|
129
|
+
exp = TopExp_Explorer(shape, TopAbs_SHELL)
|
|
130
|
+
if exp.More():
|
|
131
|
+
ms = BRepBuilderAPI_MakeSolid()
|
|
132
|
+
while exp.More():
|
|
133
|
+
ms.Add(TopoDS.Shell_s(exp.Current()))
|
|
134
|
+
exp.Next()
|
|
135
|
+
fix = ShapeFix_Solid(ms.Solid())
|
|
136
|
+
fix.Perform()
|
|
137
|
+
shape = fix.Solid()
|
|
138
|
+
return shape
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def unify(shape: TopoDS_Shape, lin_tol: float, ang_tol: float = 1e-3) -> TopoDS_Shape:
|
|
142
|
+
u = ShapeUpgrade_UnifySameDomain(shape, True, True, True)
|
|
143
|
+
u.SetLinearTolerance(lin_tol)
|
|
144
|
+
u.SetAngularTolerance(ang_tol)
|
|
145
|
+
u.Build()
|
|
146
|
+
return u.Shape()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_valid(shape: TopoDS_Shape) -> bool:
|
|
150
|
+
return bool(BRepCheck_Analyzer(shape).IsValid())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_step(shape: TopoDS_Shape, path: str) -> None:
|
|
154
|
+
w = STEPControl_Writer()
|
|
155
|
+
w.Transfer(shape, STEPControl_AsIs)
|
|
156
|
+
if w.Write(str(path)) != IFSelect_RetDone:
|
|
157
|
+
raise RuntimeError(f"STEP write failed: {path}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- planar B-rep
|
|
161
|
+
def _plane_basis(n):
|
|
162
|
+
u = np.cross(n, [1.0, 0, 0])
|
|
163
|
+
if np.linalg.norm(u) < 0.1:
|
|
164
|
+
u = np.cross(n, [0, 1.0, 0])
|
|
165
|
+
u /= np.linalg.norm(u)
|
|
166
|
+
return u, np.cross(n, u)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _intersect3(planes):
|
|
170
|
+
N = np.array([p.normal for p in planes])
|
|
171
|
+
D = np.array([p.d for p in planes])
|
|
172
|
+
if abs(np.linalg.det(N)) < 1e-6:
|
|
173
|
+
return None
|
|
174
|
+
return np.linalg.solve(N, D)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _loops(m, label, rid):
|
|
178
|
+
"""Directed boundary edges of region rid chained into loops of (a, b, nbr)."""
|
|
179
|
+
faces = np.where(label == rid)[0]
|
|
180
|
+
adj = {}
|
|
181
|
+
for (fa, fb), (ea, eb) in zip(m.face_adjacency, m.face_adjacency_edges):
|
|
182
|
+
adj[(min(ea, eb), max(ea, eb))] = (fa, fb)
|
|
183
|
+
nxt = {}
|
|
184
|
+
for f in faces:
|
|
185
|
+
tri = m.faces[f]
|
|
186
|
+
for k in range(3):
|
|
187
|
+
a, b = int(tri[k]), int(tri[(k + 1) % 3])
|
|
188
|
+
fa, fb = adj.get((min(a, b), max(a, b)), (f, f))
|
|
189
|
+
other = fb if fa == f else fa
|
|
190
|
+
if label[other] != rid:
|
|
191
|
+
if a in nxt:
|
|
192
|
+
return None # non-manifold boundary vertex
|
|
193
|
+
nxt[a] = (b, int(label[other]))
|
|
194
|
+
loops = []
|
|
195
|
+
while nxt:
|
|
196
|
+
start = next(iter(nxt))
|
|
197
|
+
loop, a = [], start
|
|
198
|
+
while True:
|
|
199
|
+
if a not in nxt:
|
|
200
|
+
return None
|
|
201
|
+
b, s = nxt.pop(a)
|
|
202
|
+
loop.append((a, b, s))
|
|
203
|
+
a = b
|
|
204
|
+
if a == start:
|
|
205
|
+
break
|
|
206
|
+
loops.append(loop)
|
|
207
|
+
return loops
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _runs(loop, min_run):
|
|
211
|
+
labs = [s for _, _, s in loop]
|
|
212
|
+
# rotate so we start at a label change
|
|
213
|
+
for i in range(len(labs)):
|
|
214
|
+
if labs[i] != labs[i - 1]:
|
|
215
|
+
labs = labs[i:] + labs[:i]
|
|
216
|
+
break
|
|
217
|
+
runs = []
|
|
218
|
+
for s in labs:
|
|
219
|
+
if runs and runs[-1][0] == s:
|
|
220
|
+
runs[-1][1] += 1
|
|
221
|
+
else:
|
|
222
|
+
runs.append([s, 1])
|
|
223
|
+
# drop flicker runs
|
|
224
|
+
changed = True
|
|
225
|
+
while changed and len(runs) > 1:
|
|
226
|
+
changed = False
|
|
227
|
+
i = min(range(len(runs)), key=lambda k: runs[k][1])
|
|
228
|
+
if runs[i][1] < min_run:
|
|
229
|
+
runs[i - 1][1] += runs[i][1]
|
|
230
|
+
runs.pop(i)
|
|
231
|
+
# merge equal neighbours
|
|
232
|
+
j = 0
|
|
233
|
+
while j < len(runs) and len(runs) > 1:
|
|
234
|
+
k = (j + 1) % len(runs)
|
|
235
|
+
if runs[j][0] == runs[k][0] and j != k:
|
|
236
|
+
runs[j][1] += runs[k][1]
|
|
237
|
+
runs.pop(k)
|
|
238
|
+
j = 0
|
|
239
|
+
else:
|
|
240
|
+
j += 1
|
|
241
|
+
changed = True
|
|
242
|
+
if runs and runs[0][0] == runs[-1][0] and len(runs) > 1:
|
|
243
|
+
runs[0][1] += runs.pop()[1]
|
|
244
|
+
return [r[0] for r in runs]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _inside(poly, p):
|
|
248
|
+
"""Even-odd point-in-polygon test (2D)."""
|
|
249
|
+
x, y = poly[:, 0], poly[:, 1]
|
|
250
|
+
x2, y2 = np.roll(x, -1), np.roll(y, -1)
|
|
251
|
+
c = ((y > p[1]) != (y2 > p[1])) & (p[0] < (x2 - x) * (p[1] - y) / (y2 - y + 1e-300) + x)
|
|
252
|
+
return bool(c.sum() % 2)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _unit_plane(plane):
|
|
256
|
+
n = np.asarray(plane.normal, float)
|
|
257
|
+
ln = float(np.linalg.norm(n))
|
|
258
|
+
if ln < 1e-12:
|
|
259
|
+
return None, None
|
|
260
|
+
return n / ln, float(plane.d) / ln
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _plane_sphere_circle(plane, sphere):
|
|
264
|
+
"""plane ∩ sphere → (center, unit normal, radius), or None if they miss."""
|
|
265
|
+
n, d = _unit_plane(plane)
|
|
266
|
+
if n is None or sphere.radius <= 0:
|
|
267
|
+
return None
|
|
268
|
+
h = float(n @ sphere.center - d)
|
|
269
|
+
if not np.isfinite(h) or abs(h) >= sphere.radius * (1.0 - 1e-9):
|
|
270
|
+
return None
|
|
271
|
+
rho2 = sphere.radius ** 2 - h * h
|
|
272
|
+
if rho2 <= 0:
|
|
273
|
+
return None
|
|
274
|
+
rho = float(np.sqrt(rho2))
|
|
275
|
+
if rho <= 1e-8 * sphere.radius:
|
|
276
|
+
return None
|
|
277
|
+
return sphere.center - h * n, n, rho
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _make_sphere_face(sphere, planes, pts, normals):
|
|
281
|
+
"""Spherical cap (1 plane) or zone (2 parallel planes) bounded by circles."""
|
|
282
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
|
283
|
+
from OCP.Geom import Geom_SphericalSurface
|
|
284
|
+
from OCP.gp import gp_Ax3, gp_Dir
|
|
285
|
+
R = float(sphere.radius)
|
|
286
|
+
C = np.asarray(sphere.center, float)
|
|
287
|
+
try:
|
|
288
|
+
if len(planes) == 1:
|
|
289
|
+
n, d = _unit_plane(planes[0])
|
|
290
|
+
if n is None:
|
|
291
|
+
return None
|
|
292
|
+
h = float(n @ C - d)
|
|
293
|
+
if abs(h) >= R * (1.0 - 1e-9):
|
|
294
|
+
return None
|
|
295
|
+
side = 1.0 if float(np.median(pts @ n - d)) >= 0.0 else -1.0
|
|
296
|
+
axis = side * n
|
|
297
|
+
v0 = float(np.arcsin(np.clip(-h * side / R, -1.0, 1.0)))
|
|
298
|
+
if np.pi / 2 - v0 < 1e-5:
|
|
299
|
+
return None
|
|
300
|
+
ax = gp_Ax3(gp_Pnt(*map(float, C)), gp_Dir(*map(float, axis)))
|
|
301
|
+
surf = Geom_SphericalSurface(ax, R)
|
|
302
|
+
mf = BRepBuilderAPI_MakeFace(surf, 0.0, 2 * np.pi, v0, np.pi / 2, 1e-7)
|
|
303
|
+
elif len(planes) == 2:
|
|
304
|
+
n1, _d1 = _unit_plane(planes[0])
|
|
305
|
+
n2, _d2 = _unit_plane(planes[1])
|
|
306
|
+
if n1 is None or n2 is None or abs(float(n1 @ n2)) < 0.995:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def latitude(plane):
|
|
310
|
+
n, d = _unit_plane(plane)
|
|
311
|
+
h = float(n @ C - d)
|
|
312
|
+
if abs(h) >= R * (1.0 - 1e-9):
|
|
313
|
+
return None
|
|
314
|
+
return float(np.arcsin(np.clip(-h * float(n @ n1) / R, -1.0, 1.0)))
|
|
315
|
+
|
|
316
|
+
v1, v2 = latitude(planes[0]), latitude(planes[1])
|
|
317
|
+
if v1 is None or v2 is None or abs(v1 - v2) < 1e-5:
|
|
318
|
+
return None
|
|
319
|
+
ax = gp_Ax3(gp_Pnt(*map(float, C)), gp_Dir(*map(float, n1)))
|
|
320
|
+
surf = Geom_SphericalSurface(ax, R)
|
|
321
|
+
mf = BRepBuilderAPI_MakeFace(surf, 0.0, 2 * np.pi, min(v1, v2), max(v1, v2), 1e-7)
|
|
322
|
+
else:
|
|
323
|
+
return None
|
|
324
|
+
if not mf.IsDone():
|
|
325
|
+
return None
|
|
326
|
+
face = mf.Face()
|
|
327
|
+
except Exception:
|
|
328
|
+
return None
|
|
329
|
+
radial = pts - C
|
|
330
|
+
radial = radial / np.maximum(np.linalg.norm(radial, axis=1, keepdims=True), 1e-12)
|
|
331
|
+
if float(np.mean(np.sum(normals * radial, axis=1))) < 0.0:
|
|
332
|
+
face = face.Reversed()
|
|
333
|
+
return face
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _plane_cone_circle(plane, cone):
|
|
337
|
+
"""plane ∩ cone for a plane perpendicular to the axis → (center, normal, radius)."""
|
|
338
|
+
n, d = _unit_plane(plane)
|
|
339
|
+
if n is None or abs(float(n @ cone.axis)) < 0.995:
|
|
340
|
+
return None
|
|
341
|
+
h = (d - float(n @ cone.apex)) / float(n @ cone.axis)
|
|
342
|
+
if h <= 0:
|
|
343
|
+
return None
|
|
344
|
+
rho = float(cone.radius_at(h))
|
|
345
|
+
if rho <= 1e-9:
|
|
346
|
+
return None
|
|
347
|
+
return cone.apex + cone.axis * h, n, rho
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _cyl_cone_circle(cyl, cone):
|
|
351
|
+
"""Coaxial cylinder ∩ cone → (center, axis, radius)."""
|
|
352
|
+
if abs(float(cyl.axis @ cone.axis)) < 0.995:
|
|
353
|
+
return None
|
|
354
|
+
off = cone.apex - cyl.point
|
|
355
|
+
if np.linalg.norm(off - cyl.axis * (off @ cyl.axis)) > 0.05 * cyl.radius:
|
|
356
|
+
return None
|
|
357
|
+
h = float(cyl.radius / np.tan(cone.half_angle))
|
|
358
|
+
return cone.apex + cone.axis * h, cone.axis, float(cyl.radius)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _ring(surface, other):
|
|
362
|
+
"""Circle where a cone/cylinder region meets a neighbouring region."""
|
|
363
|
+
if isinstance(surface, Cone):
|
|
364
|
+
if isinstance(other, Plane):
|
|
365
|
+
return _plane_cone_circle(other, surface)
|
|
366
|
+
if isinstance(other, Cylinder):
|
|
367
|
+
return _cyl_cone_circle(other, surface)
|
|
368
|
+
if isinstance(surface, Cylinder) and isinstance(other, Cone):
|
|
369
|
+
return _cyl_cone_circle(surface, other)
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _make_cone_face(cone, hits):
|
|
374
|
+
"""Conical frustum face between two coaxial circles."""
|
|
375
|
+
from OCP.Geom import Geom_ConicalSurface
|
|
376
|
+
from OCP.gp import gp_Ax3, gp_Dir
|
|
377
|
+
if len(hits) != 2 or any(h is None for h in hits):
|
|
378
|
+
return None
|
|
379
|
+
(c1, _, r1), (c2, _, r2) = hits
|
|
380
|
+
z = c2 - c1
|
|
381
|
+
H = float(np.linalg.norm(z))
|
|
382
|
+
if H < 1e-9:
|
|
383
|
+
return None
|
|
384
|
+
z = z / H
|
|
385
|
+
semi = float(np.arctan2(r2 - r1, H))
|
|
386
|
+
try:
|
|
387
|
+
surf = Geom_ConicalSurface(gp_Ax3(gp_Pnt(*map(float, c1)), gp_Dir(*map(float, z))), semi, float(r1))
|
|
388
|
+
mf = BRepBuilderAPI_MakeFace(surf, 0.0, 2 * np.pi, 0.0, H / np.cos(semi), 1e-7)
|
|
389
|
+
return mf.Face() if mf.IsDone() else None
|
|
390
|
+
except Exception:
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def build_planar(m: trimesh.Trimesh, regions: list[Region], tol: float):
|
|
395
|
+
"""Exact B-rep: plane polygons, cylindrical walls, spherical caps/zones.
|
|
396
|
+
Returns None when the topology can't be resolved (caller falls back)."""
|
|
397
|
+
from OCP.Geom import Geom_Plane
|
|
398
|
+
from OCP.gp import gp_Dir, gp_Pln
|
|
399
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire
|
|
400
|
+
from OCP.Geom import Geom_CylindricalSurface
|
|
401
|
+
from OCP.gp import gp_Ax2, gp_Ax3, gp_Circ
|
|
402
|
+
if any(r.surface is None for r in regions):
|
|
403
|
+
return None
|
|
404
|
+
label = -np.ones(len(m.faces), int)
|
|
405
|
+
for i, r in enumerate(regions):
|
|
406
|
+
label[r.faces] = i
|
|
407
|
+
if (label < 0).any():
|
|
408
|
+
return None
|
|
409
|
+
sew = BRepBuilderAPI_Sewing(max(tol, 1e-6) * 2)
|
|
410
|
+
def circle_on(plane, cyl):
|
|
411
|
+
"""plane ∩ cylinder when the axis is parallel to the plane normal."""
|
|
412
|
+
if abs(plane.normal @ cyl.axis) < 0.995:
|
|
413
|
+
return None
|
|
414
|
+
n = plane.normal
|
|
415
|
+
c = cyl.point + cyl.axis * ((plane.d - n @ cyl.point) / (n @ cyl.axis))
|
|
416
|
+
return c, n
|
|
417
|
+
|
|
418
|
+
for rid, r in enumerate(regions):
|
|
419
|
+
loops = _loops(m, label, rid)
|
|
420
|
+
if not loops:
|
|
421
|
+
return None
|
|
422
|
+
if isinstance(r.surface, Cylinder):
|
|
423
|
+
# cylinder bounded by exactly two planar circular loops
|
|
424
|
+
if len(loops) != 2:
|
|
425
|
+
return None
|
|
426
|
+
hs = []
|
|
427
|
+
for loop in loops:
|
|
428
|
+
seq = _runs(loop, min_run=3)
|
|
429
|
+
if len(seq) != 1:
|
|
430
|
+
return None
|
|
431
|
+
o = regions[seq[0]].surface
|
|
432
|
+
cc = (circle_on(o, r.surface) if isinstance(o, Plane) else _ring(r.surface, o))
|
|
433
|
+
if cc is None:
|
|
434
|
+
return None
|
|
435
|
+
hs.append(cc[0])
|
|
436
|
+
a = hs[1] - hs[0]
|
|
437
|
+
h = np.linalg.norm(a)
|
|
438
|
+
a = a / h
|
|
439
|
+
ax = gp_Ax3(gp_Pnt(*map(float, hs[0])), gp_Dir(*map(float, a)))
|
|
440
|
+
surf = Geom_CylindricalSurface(ax, float(r.surface.radius))
|
|
441
|
+
mf = BRepBuilderAPI_MakeFace(surf, 0.0, 2 * np.pi, 0.0, float(h), 1e-7)
|
|
442
|
+
if not mf.IsDone():
|
|
443
|
+
return None
|
|
444
|
+
sew.Add(mf.Face())
|
|
445
|
+
continue
|
|
446
|
+
if isinstance(r.surface, Sphere):
|
|
447
|
+
# sphere bounded by planar circles (cap, or a zone between two planes)
|
|
448
|
+
planes = []
|
|
449
|
+
for loop in loops:
|
|
450
|
+
seq = _runs(loop, min_run=3)
|
|
451
|
+
if len(seq) != 1 or not isinstance(regions[seq[0]].surface, Plane):
|
|
452
|
+
return None
|
|
453
|
+
if _plane_sphere_circle(regions[seq[0]].surface, r.surface) is None:
|
|
454
|
+
return None
|
|
455
|
+
planes.append(regions[seq[0]].surface)
|
|
456
|
+
pts = m.triangles_center[r.faces]
|
|
457
|
+
face = _make_sphere_face(r.surface, planes, pts, m.face_normals[r.faces])
|
|
458
|
+
if face is None:
|
|
459
|
+
return None
|
|
460
|
+
sew.Add(face)
|
|
461
|
+
continue
|
|
462
|
+
if isinstance(r.surface, Cone):
|
|
463
|
+
hits = []
|
|
464
|
+
for loop in loops:
|
|
465
|
+
seq = _runs(loop, min_run=3)
|
|
466
|
+
if len(seq) != 1:
|
|
467
|
+
return None
|
|
468
|
+
hits.append(_ring(r.surface, regions[seq[0]].surface))
|
|
469
|
+
face = _make_cone_face(r.surface, hits)
|
|
470
|
+
if face is None:
|
|
471
|
+
return None
|
|
472
|
+
sew.Add(face)
|
|
473
|
+
continue
|
|
474
|
+
wires = []
|
|
475
|
+
u, v = _plane_basis(r.surface.normal)
|
|
476
|
+
for loop in loops:
|
|
477
|
+
lp = m.vertices[[a for a, _, _ in loop]]
|
|
478
|
+
q2 = np.c_[lp @ u, lp @ v]
|
|
479
|
+
# signed area of the mesh loop: >0 outer boundary, <0 hole (u,v,n right-handed)
|
|
480
|
+
sa = 0.5 * np.sum(q2[:, 0] * np.roll(q2[:, 1], -1) - np.roll(q2[:, 0], -1) * q2[:, 1])
|
|
481
|
+
seq = _runs(loop, min_run=3)
|
|
482
|
+
if len(seq) == 1 and isinstance(regions[seq[0]].surface, Cylinder):
|
|
483
|
+
cyl = regions[seq[0]].surface
|
|
484
|
+
cc = circle_on(r.surface, cyl)
|
|
485
|
+
if cc is None:
|
|
486
|
+
return None
|
|
487
|
+
circ = gp_Circ(gp_Ax2(gp_Pnt(*map(float, cc[0])), gp_Dir(*map(float, cc[1]))), float(cyl.radius))
|
|
488
|
+
w = BRepBuilderAPI_MakeWire(BRepBuilderAPI_MakeEdge(circ).Edge()).Wire()
|
|
489
|
+
wires.append((sa, q2, w))
|
|
490
|
+
continue
|
|
491
|
+
if len(seq) == 1 and isinstance(regions[seq[0]].surface, (Sphere, Cone)):
|
|
492
|
+
o = regions[seq[0]].surface
|
|
493
|
+
hit = (_plane_sphere_circle(r.surface, o) if isinstance(o, Sphere)
|
|
494
|
+
else _plane_cone_circle(r.surface, o))
|
|
495
|
+
if hit is None:
|
|
496
|
+
return None
|
|
497
|
+
cc, nrm, rho = hit
|
|
498
|
+
circ = gp_Circ(gp_Ax2(gp_Pnt(*map(float, cc)), gp_Dir(*map(float, nrm))), float(rho))
|
|
499
|
+
w = BRepBuilderAPI_MakeWire(BRepBuilderAPI_MakeEdge(circ).Edge()).Wire()
|
|
500
|
+
wires.append((sa, q2, w))
|
|
501
|
+
continue
|
|
502
|
+
if len(seq) < 3 or any(not isinstance(regions[k].surface, Plane) for k in seq):
|
|
503
|
+
return None
|
|
504
|
+
pts = []
|
|
505
|
+
for i in range(len(seq)):
|
|
506
|
+
c = _intersect3([r.surface, regions[seq[i - 1]].surface, regions[seq[i]].surface])
|
|
507
|
+
if c is None:
|
|
508
|
+
return None
|
|
509
|
+
pts.append(c)
|
|
510
|
+
poly = BRepBuilderAPI_MakePolygon()
|
|
511
|
+
for p in pts:
|
|
512
|
+
poly.Add(gp_Pnt(*map(float, p)))
|
|
513
|
+
poly.Close()
|
|
514
|
+
if not poly.IsDone():
|
|
515
|
+
return None
|
|
516
|
+
wires.append((sa, q2, poly.Wire()))
|
|
517
|
+
from OCP.ShapeFix import ShapeFix_Face
|
|
518
|
+
outers = [w for w in wires if w[0] > 0]
|
|
519
|
+
holes = [w for w in wires if w[0] <= 0]
|
|
520
|
+
if not outers:
|
|
521
|
+
return None
|
|
522
|
+
n = r.surface.normal
|
|
523
|
+
o = n * r.surface.d
|
|
524
|
+
pln = gp_Pln(gp_Pnt(*map(float, o)), gp_Dir(*map(float, n)))
|
|
525
|
+
groups = {id(w): [] for w in outers}
|
|
526
|
+
for h in holes:
|
|
527
|
+
host = [w for w in outers if _inside(w[1], h[1][0])]
|
|
528
|
+
if not host:
|
|
529
|
+
return None
|
|
530
|
+
groups[id(min(host, key=lambda w: w[0]))].append(h)
|
|
531
|
+
for w in outers:
|
|
532
|
+
mf = BRepBuilderAPI_MakeFace(pln, w[2], True)
|
|
533
|
+
for h in groups[id(w)]:
|
|
534
|
+
mf.Add(h[2])
|
|
535
|
+
if not mf.IsDone():
|
|
536
|
+
return None
|
|
537
|
+
fx = ShapeFix_Face(mf.Face())
|
|
538
|
+
fx.FixOrientation()
|
|
539
|
+
fx.Perform()
|
|
540
|
+
sew.Add(fx.Face())
|
|
541
|
+
sew.Perform()
|
|
542
|
+
shape = sew.SewedShape()
|
|
543
|
+
exp = TopExp_Explorer(shape, TopAbs_SHELL)
|
|
544
|
+
if not exp.More():
|
|
545
|
+
return None
|
|
546
|
+
ms = BRepBuilderAPI_MakeSolid()
|
|
547
|
+
while exp.More():
|
|
548
|
+
ms.Add(TopoDS.Shell_s(exp.Current()))
|
|
549
|
+
exp.Next()
|
|
550
|
+
fix = ShapeFix_Solid(ms.Solid())
|
|
551
|
+
fix.Perform()
|
|
552
|
+
return fix.Solid()
|
mesh2cad/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""mesh2cad command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _convert(a) -> int:
|
|
13
|
+
from .convert import convert_file
|
|
14
|
+
from .report import dumps, to_markdown
|
|
15
|
+
out = a.output or str(Path(a.input).with_suffix(".step"))
|
|
16
|
+
res = convert_file(a.input, out, tol=a.tol, cylinders=not a.no_cylinders,
|
|
17
|
+
spheres=not a.no_spheres, cones=not a.no_cones)
|
|
18
|
+
r = res.report
|
|
19
|
+
if a.report:
|
|
20
|
+
p = Path(a.report)
|
|
21
|
+
p.write_text(to_markdown(r) if p.suffix.lower() == ".md" else dumps(r))
|
|
22
|
+
if a.json:
|
|
23
|
+
print(dumps(r))
|
|
24
|
+
else:
|
|
25
|
+
d = r["deviation"]
|
|
26
|
+
print(f"wrote {out} [{r["method"]}] faces={r['output']['faces']} "
|
|
27
|
+
f"valid={r['output']['valid_solid']} hausdorff={d['hausdorff']:.4f} rms={d['rms']:.4f}")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _report(a) -> int:
|
|
32
|
+
from .convert import convert_mesh
|
|
33
|
+
from .io import load_mesh
|
|
34
|
+
from .report import dumps, to_markdown
|
|
35
|
+
r = convert_mesh(load_mesh(a.input), tol=a.tol, cylinders=not a.no_cylinders,
|
|
36
|
+
spheres=not a.no_spheres, cones=not a.no_cones).report
|
|
37
|
+
print(dumps(r) if a.json else to_markdown(r), end="")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mcp(a) -> int:
|
|
42
|
+
try:
|
|
43
|
+
from .mcp_server import run
|
|
44
|
+
except ImportError:
|
|
45
|
+
print("MCP support needs: pip install 'mesh2cad[mcp]'", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
run()
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv=None) -> int:
|
|
52
|
+
ap = argparse.ArgumentParser(prog="mesh2cad", description="Convert STL/OBJ/PLY meshes to STEP B-rep CAD")
|
|
53
|
+
ap.add_argument("--version", action="version", version=f"mesh2cad {__version__}")
|
|
54
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
55
|
+
c = sub.add_parser("convert", help="convert a mesh to STEP")
|
|
56
|
+
c.add_argument("input")
|
|
57
|
+
c.add_argument("-o", "--output")
|
|
58
|
+
c.add_argument("--tol", type=float, default=None,
|
|
59
|
+
help="fit tolerance in model units (default: 0.1%% of bbox diagonal); set to scan noise level")
|
|
60
|
+
c.add_argument("--report", help="write fidelity report (.json or .md)")
|
|
61
|
+
c.add_argument("--json", action="store_true", help="print full report as JSON")
|
|
62
|
+
c.add_argument("--no-cylinders", action="store_true")
|
|
63
|
+
c.add_argument("--no-spheres", action="store_true")
|
|
64
|
+
c.add_argument("--no-cones", action="store_true")
|
|
65
|
+
c.set_defaults(fn=_convert)
|
|
66
|
+
r = sub.add_parser("report", help="analyze a mesh and print a fidelity report without writing STEP")
|
|
67
|
+
r.add_argument("input")
|
|
68
|
+
r.add_argument("--tol", type=float, default=None)
|
|
69
|
+
r.add_argument("--json", action="store_true")
|
|
70
|
+
r.add_argument("--no-cylinders", action="store_true")
|
|
71
|
+
r.add_argument("--no-spheres", action="store_true")
|
|
72
|
+
r.add_argument("--no-cones", action="store_true")
|
|
73
|
+
r.set_defaults(fn=_report)
|
|
74
|
+
mm = sub.add_parser("mcp", help="run the MCP server (stdio) for AI agents")
|
|
75
|
+
mm.set_defaults(fn=_mcp)
|
|
76
|
+
a = ap.parse_args(argv)
|
|
77
|
+
return a.fn(a)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(main())
|
mesh2cad/convert.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""High-level pipeline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import trimesh
|
|
7
|
+
|
|
8
|
+
from . import brep, report as rep
|
|
9
|
+
from .io import clean, load_mesh
|
|
10
|
+
from .segment import segment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Result:
|
|
15
|
+
shape: object
|
|
16
|
+
regions: list
|
|
17
|
+
report: dict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def convert_mesh(m: trimesh.Trimesh, tol: float | None = None, cylinders: bool = True,
|
|
21
|
+
spheres: bool = True, cones: bool = True, angle_deg: float = 20.0, ref: trimesh.Trimesh | None = None,
|
|
22
|
+
with_report: bool = True) -> Result:
|
|
23
|
+
m = clean(m)
|
|
24
|
+
if tol is None:
|
|
25
|
+
tol = 1e-3 * m.scale
|
|
26
|
+
regions = segment(m, tol, angle_deg=angle_deg, cylinders=cylinders, spheres=spheres, cones=cones)
|
|
27
|
+
shape, method = None, "faceted-hybrid"
|
|
28
|
+
if all(r.surface is not None for r in regions):
|
|
29
|
+
shape = brep.build_planar(m, regions, tol)
|
|
30
|
+
if shape is not None:
|
|
31
|
+
from .report import volume
|
|
32
|
+
vm = abs(m.volume) if m.is_volume else None
|
|
33
|
+
if not brep.is_valid(shape) or (vm and abs(volume(shape) - vm) > 0.05 * vm):
|
|
34
|
+
shape = None
|
|
35
|
+
else:
|
|
36
|
+
method = "exact"
|
|
37
|
+
if shape is None:
|
|
38
|
+
verts = brep.snap_vertices(m, regions, tol)
|
|
39
|
+
shape = brep.build_solid(m, verts, sew_tol=max(tol, 1e-6))
|
|
40
|
+
shape = brep.unify(shape, lin_tol=max(tol * 0.05, 1e-6))
|
|
41
|
+
if not brep.is_valid(shape):
|
|
42
|
+
# snapping tangled triangles: export the raw mesh as a faithful faceted solid
|
|
43
|
+
shape = brep.unify(brep.build_solid(m, m.vertices, sew_tol=1e-6), lin_tol=1e-6)
|
|
44
|
+
method = "faceted"
|
|
45
|
+
valid = brep.is_valid(shape)
|
|
46
|
+
r = rep.build_report(m, shape, regions, valid, ref=ref) if with_report else {}
|
|
47
|
+
if r:
|
|
48
|
+
r["method"] = method
|
|
49
|
+
return Result(shape, regions, r)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def convert_file(src: str, dst: str, tol: float | None = None, **kw) -> Result:
|
|
53
|
+
res = convert_mesh(load_mesh(src), tol=tol, **kw)
|
|
54
|
+
brep.write_step(res.shape, dst)
|
|
55
|
+
return res
|