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 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