yapCAD 0.5.0__py2.py3-none-any.whl → 0.5.1__py2.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.
@@ -0,0 +1,155 @@
1
+ """Trimesh-backed boolean engine for yapCAD solids.
2
+
3
+ This engine is optional. It converts yapCAD solids to ``trimesh.Trimesh``
4
+ instances, dispatches boolean operations via :mod:`trimesh.boolean`, and
5
+ converts the resulting mesh back into a yapCAD solid.
6
+
7
+ Availability depends on both the ``trimesh`` package and at least one
8
+ boolean backend supported by ``trimesh`` (e.g. Blender, OpenSCAD, Cork).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Iterable
14
+
15
+ import os
16
+ import numpy as np
17
+
18
+ try:
19
+ import trimesh
20
+ except ImportError: # pragma: no cover - optional dependency
21
+ trimesh = None # type: ignore[assignment]
22
+
23
+ from . import native as _native
24
+
25
+ ENGINE_NAME = "trimesh"
26
+
27
+
28
+ def engines_available() -> set[str]:
29
+ """Return the set of trimesh boolean backends that are operational."""
30
+
31
+ if trimesh is None: # pragma: no cover - optional dependency
32
+ return set()
33
+ return set(trimesh.boolean.engines_available)
34
+
35
+
36
+ def is_available(backend: str | None = None) -> bool:
37
+ """Check whether the engine can run (trimesh + backend present)."""
38
+
39
+ available = engines_available()
40
+ if not available:
41
+ return False
42
+ if backend is None:
43
+ return True
44
+ return backend in available
45
+
46
+
47
+ def _solid_to_mesh(sld) -> "trimesh.Trimesh":
48
+ if trimesh is None: # pragma: no cover - optional dependency
49
+ raise RuntimeError("trimesh is not installed")
50
+
51
+ triangles = list(_native._iter_triangles_from_solid(sld))
52
+ if not triangles:
53
+ return trimesh.Trimesh(vertices=np.zeros((0, 3)), faces=np.zeros((0, 3), dtype=np.int64), process=False)
54
+
55
+ vertex_map: dict[tuple[float, float, float], int] = {}
56
+ verts = []
57
+ faces = []
58
+ for tri in triangles:
59
+ face_inds = []
60
+ for pt in tri:
61
+ key = (round(pt[0], 9), round(pt[1], 9), round(pt[2], 9))
62
+ idx = vertex_map.get(key)
63
+ if idx is None:
64
+ idx = len(verts)
65
+ vertex_map[key] = idx
66
+ verts.append([pt[0], pt[1], pt[2]])
67
+ face_inds.append(idx)
68
+ faces.append(face_inds)
69
+
70
+ verts_np = np.asarray(verts, dtype=float)
71
+ faces_np = np.asarray(faces, dtype=np.int64)
72
+ mesh = trimesh.Trimesh(vertices=verts_np, faces=faces_np, process=False)
73
+ mesh.remove_unreferenced_vertices()
74
+ if not mesh.is_watertight:
75
+ mesh.merge_vertices()
76
+ mesh.remove_duplicate_faces()
77
+ mesh.remove_degenerate_faces()
78
+ mesh.process(validate=True)
79
+ try:
80
+ mesh.fix_normals()
81
+ except Exception:
82
+ pass
83
+ return mesh
84
+
85
+
86
+ def _mesh_to_solid(mesh: "trimesh.Trimesh", operation: str) -> list:
87
+ triangles = np.asarray(mesh.triangles)
88
+ if triangles.size == 0:
89
+ return _native._geom3d().solid([], [], ['boolean', f'{ENGINE_NAME}:{operation}'])
90
+
91
+ tri_points = []
92
+ for tri in triangles:
93
+ tri_points.append([
94
+ _native.point(tri[0][0], tri[0][1], tri[0][2]),
95
+ _native.point(tri[1][0], tri[1][1], tri[1][2]),
96
+ _native.point(tri[2][0], tri[2][1], tri[2][2]),
97
+ ])
98
+
99
+ surface = _native._surface_from_triangles(tri_points)
100
+ if surface is None:
101
+ return _native._geom3d().solid([], [], ['boolean', f'{ENGINE_NAME}:{operation}'])
102
+ return _native._geom3d().solid([surface], [], ['boolean', f'{ENGINE_NAME}:{operation}'])
103
+
104
+
105
+ def solid_boolean(a, b, operation: str, tol=_native._DEFAULT_RAY_TOL, *, stitch: bool = False,
106
+ backend: str | None = None):
107
+ """Perform a boolean between ``a`` and ``b`` using trimesh."""
108
+
109
+ if trimesh is None: # pragma: no cover - optional dependency
110
+ raise RuntimeError("trimesh is not installed; install trimesh to enable this engine")
111
+
112
+ available = engines_available()
113
+ if backend is not None and backend not in available:
114
+ raise RuntimeError(
115
+ f"trimesh backend '{backend}' is not available; install the appropriate binary (available: {available})"
116
+ )
117
+ if backend is None and not available:
118
+ raise RuntimeError(
119
+ "no trimesh boolean backends are available; install Blender, OpenSCAD, Cork, or another supported engine"
120
+ )
121
+
122
+ mesh_a = _solid_to_mesh(a)
123
+ mesh_b = _solid_to_mesh(b)
124
+
125
+ backup_cache = None
126
+ if backend == 'blender':
127
+ backup_cache = os.environ.get('ARCH_CACHE_LINE_SIZE')
128
+ os.environ['ARCH_CACHE_LINE_SIZE'] = '64'
129
+
130
+ op = operation.lower()
131
+ try:
132
+ if op == 'union':
133
+ result = trimesh.boolean.union([mesh_a, mesh_b], engine=backend, check_volume=False)
134
+ elif op == 'intersection':
135
+ result = trimesh.boolean.intersection([mesh_a, mesh_b], engine=backend, check_volume=False)
136
+ elif op == 'difference':
137
+ result = trimesh.boolean.difference([mesh_a, mesh_b], engine=backend, check_volume=False)
138
+ else:
139
+ raise RuntimeError(f"unsupported boolean operation '{operation}' for trimesh engine")
140
+ except Exception as exc: # pragma: no cover - depends on external binaries
141
+ raise RuntimeError(f"trimesh boolean operation failed: {exc}") from exc
142
+ finally:
143
+ if backend == 'blender':
144
+ if backup_cache is None:
145
+ os.environ.pop('ARCH_CACHE_LINE_SIZE', None)
146
+ else:
147
+ os.environ['ARCH_CACHE_LINE_SIZE'] = backup_cache
148
+
149
+ if result is None or result.faces.size == 0:
150
+ return _native._geom3d().solid([], [], ['boolean', f'{ENGINE_NAME}:{operation}'])
151
+
152
+ return _mesh_to_solid(result, operation)
153
+
154
+
155
+ __all__ = ['ENGINE_NAME', 'is_available', 'solid_boolean', 'engines_available']
yapcad/combine.py CHANGED
@@ -1,4 +1,5 @@
1
- ## yapCAD boolen operation support
1
+ ## yapCAD boolen operation support for 2D closed curves. For three dimensional
2
+ ## boolean operations, see geom3d.py
2
3
 
3
4
  from yapcad.geom import *
4
5
  from yapcad.geom_util import *
@@ -12,7 +13,7 @@ class Boolean(Geometry):
12
13
  def __repr__(self):
13
14
  return f"Boolean({self.type},{self.elem})"
14
15
 
15
- def __init__(self,type='union',polys=[]):
16
+ def __init__(self,type='union',polys=[], *, minang=5.0, minlen=0.25):
16
17
  super().__init__()
17
18
  self._setClosed(True)
18
19
  self._setSampleable(True)
@@ -26,14 +27,49 @@ class Boolean(Geometry):
26
27
  self.__type=type
27
28
  self._setUpdate(True)
28
29
  self.__outline=[]
30
+ self.__minang = float(minang)
31
+ self.__minlen = float(minlen)
29
32
 
30
33
  @property
31
34
  def type(self):
32
35
  return self.__type
33
36
 
37
+ def _poly_to_segments(self, poly):
38
+ segments = []
39
+ if not poly:
40
+ return segments
41
+ for i in range(1, len(poly)):
42
+ segments.append([poly[i - 1], poly[i]])
43
+ return segments
44
+
45
+ def _prepare_geom(self, geom_obj):
46
+ if isinstance(geom_obj, Geometry):
47
+ gl = geom_obj.geom
48
+ else:
49
+ gl = geom_obj
50
+
51
+ # If gl is a single arc/line/poly, wrap it in a list for geomlist2poly_with_holes
52
+ if isarc(gl) or isline(gl) or ispoly(gl):
53
+ gl = [gl]
54
+
55
+ try:
56
+ outer, holes = geomlist2poly_with_holes(gl, self.__minang, self.__minlen, checkcont=False)
57
+ except Exception:
58
+ if isgeomlist(gl):
59
+ return gl
60
+ return []
61
+
62
+ if not outer:
63
+ return []
64
+
65
+ segments = self._poly_to_segments(outer)
66
+ for hole in holes:
67
+ segments.extend(self._poly_to_segments(hole))
68
+ return segments
69
+
34
70
  def _combine_geom(self,g1,g2):
35
- gl1 = g1.geom
36
- gl2 = g2.geom
71
+ gl1 = self._prepare_geom(g1)
72
+ gl2 = self._prepare_geom(g2)
37
73
 
38
74
  return combineglist(gl1,gl2,self.type)
39
75
 
@@ -83,16 +119,18 @@ class Boolean(Geometry):
83
119
  @property
84
120
  def geom(self):
85
121
  if self.update:
86
- if len(self.elem)==2:
87
- self.__outline = self._combine_geom(self.elem[0],self.elem[1])
88
- self.__outline = cullZeroLength(self.__outline)
89
- self._setUpdate(False)
90
- self._setBbox(bbox(self.__outline))
91
- self._setLength(length(self.__outline))
92
- self._setCenter(self._calcCenter())
93
- else:
94
- raise NotImplementedError(
95
- f"don't know how to do {self.type} yet for {len(self.elem)} polygons")
122
+ if len(self.elem) < 2:
123
+ raise ValueError('Boolean requires at least two operands')
124
+
125
+ result = self.elem[0]
126
+ for operand in self.elem[1:]:
127
+ result = self._combine_geom(result, operand)
128
+
129
+ self.__outline = cullZeroLength(result)
130
+ self._setUpdate(False)
131
+ self._setBbox(bbox(self.__outline))
132
+ self._setLength(length(self.__outline))
133
+ self._setCenter(self._calcCenter())
96
134
  return deepcopy(self.__outline)
97
135
 
98
136