yapCAD 0.3.0__py2.py3-none-any.whl → 0.5.0__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.
- yapcad/combine.py +82 -376
- yapcad/drawable.py +44 -3
- yapcad/ezdxf_drawable.py +1 -1
- yapcad/geom.py +204 -264
- yapcad/geom3d.py +975 -55
- yapcad/geom3d_util.py +541 -0
- yapcad/geom_util.py +817 -0
- yapcad/geometry.py +441 -49
- yapcad/geometry_checks.py +112 -0
- yapcad/geometry_utils.py +115 -0
- yapcad/io/__init__.py +5 -0
- yapcad/io/stl.py +83 -0
- yapcad/mesh.py +46 -0
- yapcad/metadata.py +109 -0
- yapcad/octtree.py +627 -0
- yapcad/poly.py +153 -299
- yapcad/pyglet_drawable.py +597 -61
- yapcad/triangulator.py +103 -0
- yapcad/xform.py +0 -1
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/METADATA +94 -38
- yapcad-0.5.0.dist-info/RECORD +27 -0
- yapcad-0.3.0.dist-info/RECORD +0 -17
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/WHEEL +0 -0
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/licenses/LICENSE.txt +0 -0
- {yapcad-0.3.0.dist-info → yapcad-0.5.0.dist-info}/top_level.txt +0 -0
yapcad/geometry.py
CHANGED
@@ -1,81 +1,473 @@
|
|
1
1
|
## yapCAD geometry-generating superclass
|
2
2
|
## =====================================
|
3
3
|
|
4
|
-
##
|
5
|
-
##
|
6
|
-
##
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
4
|
+
## Copyright (c) 2020 Richard W. DeVaul
|
5
|
+
## Copyright (c) 2020 yapCAD contributors
|
6
|
+
## All rights reserved
|
7
|
+
|
8
|
+
# Permission is hereby granted, free of charge, to any person
|
9
|
+
# obtaining a copy of this software and associated documentation files
|
10
|
+
# (the "Software"), to deal in the Software without restriction,
|
11
|
+
# including without limitation the rights to use, copy, modify, merge,
|
12
|
+
# publish, distribute, sublicense, and/or sell copies of the Software,
|
13
|
+
# and to permit persons to whom the Software is furnished to do so,
|
14
|
+
# subject to the following conditions:
|
15
|
+
#
|
16
|
+
# The above copyright notice and this permission notice shall be
|
17
|
+
# included in all copies or substantial portions of the Software.
|
18
|
+
#
|
19
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
20
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
21
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
22
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
23
|
+
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
24
|
+
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
25
|
+
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
26
|
+
# SOFTWARE.
|
27
|
+
|
28
|
+
"""object-oriented computational geometry figure classes for **yapCAD**
|
29
|
+
|
30
|
+
===============
|
31
|
+
Overview
|
32
|
+
===============
|
33
|
+
|
34
|
+
The ``yapcad.geometry`` module provides the ``Geometry`` class. At its
|
35
|
+
most basic level, the ``Geometry`` class wrapps ``yapcad.geom``
|
36
|
+
figures and caches properties, such as the figure's bounding box,
|
37
|
+
length, center, etc., speeding certain computational geometry
|
38
|
+
operations.
|
39
|
+
|
40
|
+
Why would I use ``yapcad.geometry`` vs. ``yapcad.geom``
|
41
|
+
=======================================================
|
42
|
+
|
43
|
+
You might perfer the convenience and simplicity of the object-oriented
|
44
|
+
interface as a way to access the underlying power of the
|
45
|
+
``yapcad.geom`` and associated modules. In addition, in many cases it
|
46
|
+
is actually more efficient to use the ``Geometry`` class wrappers for
|
47
|
+
figures rather than the ``yapcad.geom`` figures themselves because of
|
48
|
+
the memoizing and caching feautres provided.
|
49
|
+
|
50
|
+
For examle, Geometry class provides wrappers around the
|
51
|
+
``yapcad.geom3d poly2surface()`` function for the triangulation of
|
52
|
+
figures into triangle mesh surfaces, which is cached. This, in turn,
|
53
|
+
provides the foundations for extrusions and the construction of 3D
|
54
|
+
triangluated objects.
|
55
|
+
|
56
|
+
Likewise, for compound figures above a certain complexity threshold,
|
57
|
+
the Geometry class implements an internal quadtree decomposition of
|
58
|
+
the figure to speed intersection testing. This is done in a lazy way,
|
59
|
+
which is to say that the quadtree is constructed the first time
|
60
|
+
intersection calculation is requested, and persists for as long as the
|
61
|
+
figure's geometry remains unchanged. *NOTE: Fixeme, quadtree-based
|
62
|
+
intersection not yet implemented*
|
63
|
+
|
64
|
+
object oriented vs. functional approch
|
65
|
+
--------------------------------------
|
66
|
+
|
67
|
+
The ``yapcad.geom`` module generally takes a functional programming
|
68
|
+
approach, minimizing side effects. This comes at the cost of some
|
69
|
+
redundant computation, as the representation of ``yapcad.geom``
|
70
|
+
figures are generally quite minimal, and properties such as centers,
|
71
|
+
bounding boxes, lengths, **etc.**, must be recomputed each time they
|
72
|
+
are requested, unless the user has made their own explicit cache.
|
73
|
+
|
74
|
+
By contrast, the ``Geometry`` class caches these properties, and can
|
75
|
+
provide higher efficiency for certain types of complex operations. In
|
76
|
+
addition, derived classes provide representations and functionality
|
77
|
+
absent from the underlying functional representations, such as
|
78
|
+
"growable" polygons, boolean operations, *etc.*.
|
79
|
+
|
80
|
+
yapcad.geometry conveneince functions
|
81
|
+
=====================================
|
82
|
+
|
83
|
+
Convenience functions, such as ``Line()``, ``Arc()``, *etc.*, operate
|
84
|
+
analogously to their uncapitalized counterparts in ``yapcad.geom``,
|
85
|
+
creating ``Geometry`` class instances wrapping the corresponding
|
86
|
+
figure.
|
87
|
+
|
88
|
+
"""
|
20
89
|
|
21
90
|
from copy import deepcopy
|
22
91
|
from yapcad.geom import *
|
23
|
-
|
92
|
+
from yapcad.geom_util import *
|
93
|
+
from yapcad.geom3d import *
|
24
94
|
|
25
95
|
class Geometry:
|
26
|
-
"""
|
96
|
+
"""generalized computational geometry base class, also acts as a
|
97
|
+
wrapper around yapcad.geom elements.
|
98
|
+
|
99
|
+
Using Geometry subclass qinstances can be more effiient than
|
100
|
+
workng with the corresponding yapcad.geom elements, as the
|
101
|
+
Geometry instance uses lazy evaluation and caching to speed
|
102
|
+
repeated evaluations of various properties.
|
103
|
+
|
104
|
+
"""
|
27
105
|
|
28
106
|
def __repr__(self):
|
29
|
-
return
|
107
|
+
return f"Geometry({self.__elem})"
|
30
108
|
|
31
109
|
def __init__(self,a=False):
|
32
|
-
self.
|
33
|
-
self.
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
110
|
+
self.__update=True # do we need to update geom?
|
111
|
+
self.__elem=[] # internal geometry
|
112
|
+
self.__length=0.0
|
113
|
+
self.__sampleable = False
|
114
|
+
self.__intersectable = False
|
115
|
+
self.__continuous = False
|
116
|
+
self.__closed = False
|
117
|
+
self.__center = None
|
118
|
+
self.__bbox = None
|
119
|
+
self.__surface = None # surface representation
|
120
|
+
self.__surface_ang = -1 # surface parameter
|
121
|
+
self.__surface_len = -1 # surface parameter
|
122
|
+
self.__derived = False # set to true for derived geometry subclasses
|
123
|
+
if a != False:
|
124
|
+
if ispoint(a):
|
125
|
+
self.__elem= deepcopy(a)
|
126
|
+
self.__sampleable=True
|
127
|
+
elif isline(a) or isarc(a):
|
128
|
+
self.__elem= deepcopy(a)
|
129
|
+
self.__sampleable=True
|
130
|
+
self.__intersectable=True
|
131
|
+
self.__continuous=True
|
132
|
+
if iscircle(a):
|
133
|
+
self.__closed = True
|
134
|
+
elif ispoly(a):
|
135
|
+
self.__elem=deepcopy(a)
|
136
|
+
self.__sampleable=True
|
137
|
+
self.__intersectable=True
|
138
|
+
self.__continuous=True
|
139
|
+
self.__closed=ispolygon(a)
|
140
|
+
elif isgeomlist(a):
|
141
|
+
self.__elem=deepcopy(a)
|
142
|
+
self.__sampleable=True
|
143
|
+
self.__intersectable=True
|
144
|
+
self.__continuous=iscontinuousgeomlist(a)
|
145
|
+
self.__closed=isclosedgeomlist(a)
|
146
|
+
|
147
|
+
elif isinstance(a,Geometry):
|
148
|
+
self.__elem = deepcopy(a.elem)
|
149
|
+
self.__sampleable = a.issampleable()
|
150
|
+
self.__intersectable= a.isintersectable()
|
151
|
+
self.__continuous= a.iscontinuous()
|
152
|
+
self.__closed = a.isclosed()
|
38
153
|
else:
|
39
|
-
raise ValueError('bad argument to Geometry class constructor: {}'
|
154
|
+
raise ValueError(f'bad argument to Geometry class constructor: {a}')
|
155
|
+
|
156
|
+
|
157
|
+
@property
|
158
|
+
def sampleable(self):
|
159
|
+
return self.__sampleable
|
160
|
+
|
161
|
+
def _setSampleable(self,bln):
|
162
|
+
self.__sampleable=bln
|
163
|
+
|
164
|
+
def issampleable(self):
|
165
|
+
"""is the figure sampleable?"""
|
166
|
+
return self.__sampleable
|
167
|
+
|
168
|
+
@property
|
169
|
+
def intersectable(self):
|
170
|
+
return self.__intersectable
|
171
|
+
|
172
|
+
def isintersectable(self):
|
173
|
+
"""is the figure intersectable?"""
|
174
|
+
return self.__intersectable
|
175
|
+
|
176
|
+
@property
|
177
|
+
def continuous(self):
|
178
|
+
return self.__continuous
|
179
|
+
|
180
|
+
def iscontinuous(self):
|
181
|
+
"""is the figure C0 continuous over the interval [0,1]"""
|
182
|
+
return self.__continuous
|
183
|
+
|
184
|
+
@property
|
185
|
+
def closed(self):
|
186
|
+
return self.__closed
|
187
|
+
|
188
|
+
def _setClosed(self,bln):
|
189
|
+
self.__closed=bln
|
190
|
+
|
191
|
+
def isclosed(self):
|
192
|
+
"""is the figure C0 continuous over the [0,1] interval and
|
193
|
+
is ``self.sample(0.0)`` within epsilon of ``self.sample(1.0)``
|
194
|
+
"""
|
195
|
+
return self.__closed
|
196
|
+
|
197
|
+
@property
|
198
|
+
def derived(self):
|
199
|
+
return self.__derived
|
200
|
+
|
201
|
+
def _setDerived(self,bln):
|
202
|
+
self.__derived = bln
|
203
|
+
|
204
|
+
def isderived(self):
|
205
|
+
"""is this an instance of a derived geometry subclass that
|
206
|
+
computes self.geom from self.elem? Set only in constructors
|
207
|
+
for derived geometry subclasses
|
208
|
+
"""
|
209
|
+
return self.__derived
|
210
|
+
|
211
|
+
@property
|
212
|
+
def length(self):
|
213
|
+
"""return length of figure"""
|
214
|
+
if self.update:
|
215
|
+
self._updateInternals()
|
216
|
+
return self.__length
|
217
|
+
|
218
|
+
def _setLength(self,l):
|
219
|
+
self.__length=l
|
220
|
+
|
221
|
+
@property
|
222
|
+
def center(self):
|
223
|
+
"""return center of figure"""
|
224
|
+
if self.update:
|
225
|
+
self._updateInternals()
|
226
|
+
return self.__center
|
227
|
+
|
228
|
+
def _setCenter(self,c):
|
229
|
+
self.__center=c
|
230
|
+
|
231
|
+
@property
|
232
|
+
def bbox(self):
|
233
|
+
"""return 3D bounding box of figure"""
|
234
|
+
if self.update:
|
235
|
+
self._updateInternals()
|
236
|
+
return self.__bbox
|
237
|
+
|
238
|
+
def _setBbox(self,bbx):
|
239
|
+
self.__bbox=bbx
|
240
|
+
|
241
|
+
def isinsideXY(self,p):
|
242
|
+
"""determine if a point is inside a figure. In the case of non-closed
|
243
|
+
figures, such as lines, determine if the point lies within
|
244
|
+
epsilon of one of the lines of the figure.
|
245
|
+
"""
|
246
|
+
if self.update:
|
247
|
+
self._updateInternals()
|
248
|
+
return isinsideXY(self.geom,p)
|
249
|
+
|
250
|
+
|
251
|
+
def translate(self,delta):
|
252
|
+
"""apply a translation to figure"""
|
253
|
+
self._setElem(translate(self.__elem,delta))
|
254
|
+
self._setUpdate(True)
|
255
|
+
|
256
|
+
def scale(self,sx=1.0,sy=False,sz=False,cent=point(0,0,0)):
|
257
|
+
"""apply a scaling to a figure"""
|
258
|
+
self._setElem(scale(self.__elem,sx,sy,sz,cent))
|
259
|
+
self._setUpdate(True)
|
260
|
+
|
261
|
+
def rotate(self,ang,cent=point(0,0,0),axis=point(0,0,1.0)):
|
262
|
+
"""apply a rotation to a figure"""
|
263
|
+
self._setElem(rotate(self.__elem,ang,cent,axis))
|
264
|
+
self._setUpdate(True)
|
265
|
+
|
266
|
+
def mirror(self,plane,keepSign=True):
|
267
|
+
"""apply a mirror operation to a figure. Currently, the following
|
268
|
+
values of "plane" are allowed: 'xz', 'yz', xy'. Generalized
|
269
|
+
arbitrary reflection plane specification will be added in the
|
270
|
+
future.
|
271
|
+
|
272
|
+
If ``keepSign == True`` (default) the sign of the area will be
|
273
|
+
maintained, meaning that if ``mirror`` is applied to a
|
274
|
+
right-handed closed figure (a figure with positive area) the
|
275
|
+
resulting mirrored figure will also have positive area. This
|
276
|
+
is probably what you want, unless you are specifically turning
|
277
|
+
a face into a hole, or vice-versa.
|
278
|
+
|
279
|
+
"""
|
280
|
+
nelm = mirror(self.__elem,plane)
|
281
|
+
if keepSign:
|
282
|
+
nelm = reverseGeomList(nelm)
|
283
|
+
self._setElem(nelm)
|
284
|
+
self._setUpdate(True)
|
285
|
+
|
286
|
+
def transform(self,m):
|
287
|
+
"""apply an arbitrary transformation to a figure, as specified by a
|
288
|
+
transformation matrix.
|
289
|
+
"""
|
290
|
+
self._setElem(transform(self.elem,m))
|
291
|
+
self._setUpdate(True)
|
40
292
|
|
41
293
|
|
294
|
+
# one underscore to make this easily overridable in subclasses
|
42
295
|
def _updateInternals(self):
|
296
|
+
"""update internals: set basic attributes based on geom"""
|
297
|
+
if self.update:
|
298
|
+
self._setUpdate(False)
|
299
|
+
if self.__elem == []:
|
300
|
+
self.__length = 0.0
|
301
|
+
self.__center = None
|
302
|
+
self.__bbox = None
|
303
|
+
else:
|
304
|
+
self.__length = length(self.__elem)
|
305
|
+
self.__center = center(self.__elem)
|
306
|
+
self.__bbox = bbox(self.__elem)
|
307
|
+
|
43
308
|
return
|
44
309
|
|
310
|
+
## more properties
|
311
|
+
|
312
|
+
@property
|
313
|
+
def update(self):
|
314
|
+
return self.__update
|
315
|
+
|
316
|
+
def _setUpdate(self,bln):
|
317
|
+
"""method to set status of __update, accessible from derived classes"""
|
318
|
+
self.__update = bln
|
319
|
+
|
320
|
+
@update.setter
|
321
|
+
def update(self,bln):
|
322
|
+
"""property to flag update. Ignores right-hand side of expression, always sets update flag to True"""
|
323
|
+
self._setUpdate(True)
|
324
|
+
|
325
|
+
@property
|
326
|
+
def elem(self):
|
327
|
+
return self.__elem
|
328
|
+
|
329
|
+
def _setElem(self,e):
|
330
|
+
self.__elem = e
|
331
|
+
|
332
|
+
@elem.setter
|
333
|
+
def elem(self,e):
|
334
|
+
self._setElem(e)
|
335
|
+
|
336
|
+
@property
|
45
337
|
def geom(self):
|
46
|
-
|
338
|
+
"""return yapcad.geom representation of figure"""
|
339
|
+
if self.update:
|
47
340
|
self._updateInternals()
|
48
|
-
return deepcopy(self.
|
341
|
+
return deepcopy(self.__elem)
|
49
342
|
|
50
|
-
|
343
|
+
def sample(self,u):
|
344
|
+
"""If the figure is sampleable, given a parameter u, return the point
|
345
|
+
on the figure corresponding to the specified sampling
|
346
|
+
parameter.
|
51
347
|
|
52
|
-
|
53
|
-
|
54
|
-
|
348
|
+
"""
|
349
|
+
if not self.__sampleable:
|
350
|
+
raise ValueError('figure is not sampleable')
|
351
|
+
if self.update:
|
352
|
+
self._updateInternals()
|
353
|
+
|
354
|
+
gl = self.geom
|
55
355
|
|
56
|
-
|
57
|
-
|
356
|
+
if len(gl) == 1:
|
357
|
+
return sample(gl[0],u)
|
358
|
+
else:
|
359
|
+
return sample(gl,u)
|
58
360
|
|
59
|
-
def
|
60
|
-
|
361
|
+
def unsample(self,p):
|
362
|
+
"""
|
363
|
+
Invert the sampling operation: return the parameter corresponding
|
364
|
+
to the closest point on the figure to p as long as the distance is
|
365
|
+
less than epsilon.
|
61
366
|
|
62
|
-
|
63
|
-
if self.
|
367
|
+
"""
|
368
|
+
if not self.__sampleable:
|
369
|
+
raise ValueError('figure is not sampleable, or unsampleable for that matter')
|
370
|
+
if self.update:
|
64
371
|
self._updateInternals()
|
65
|
-
return sample(self.geom(),u)
|
66
|
-
|
67
372
|
|
68
|
-
|
69
|
-
""" generalized intersectable geometry class"""
|
373
|
+
gl = self.geom
|
70
374
|
|
71
|
-
|
72
|
-
|
375
|
+
if len(gl) == 1:
|
376
|
+
return unsample(gl[0],p)
|
377
|
+
else:
|
378
|
+
return unsample(gl,p)
|
379
|
+
|
380
|
+
def segment(self,u1,u2,reverse=False):
|
381
|
+
gl = self.geom
|
382
|
+
if gl == []:
|
383
|
+
raise ValueError('empty Boolean, segment not defined')
|
384
|
+
return segmentgeomlist(gl,u1,u2,closed=self.closed,reverse=reverse)
|
73
385
|
|
74
|
-
def __repr__(self):
|
75
|
-
return 'intersectable geometry base class wrapper for: {}'.format(vstr(self._elem))
|
76
|
-
|
77
386
|
def intersectXY(self,g,inside=True,params=False):
|
78
|
-
|
387
|
+
"""given two XY-coplanar figures, this figure and ``g``,
|
388
|
+
calculate the intersection of these two figures, and return a
|
389
|
+
list of intersection points, or False if none.
|
390
|
+
|
391
|
+
``g`` must be an instance of ``IntersectGeometry``, or a
|
392
|
+
``yapcad.geom`` figure.
|
393
|
+
|
394
|
+
If ``inside == True``, only return intersections that are
|
395
|
+
within the ``0 <= u <= 1.0`` interval for both figures. If
|
396
|
+
``params == True``, instead of returning a list of points,
|
397
|
+
return two lists corresponding to the sampling parameter value
|
398
|
+
of the intersections corresponding to each figure.
|
399
|
+
|
400
|
+
"""
|
401
|
+
if not self.isintersectable():
|
402
|
+
raise ValueError(f'this instance {self} not intersectable')
|
403
|
+
if self.update:
|
79
404
|
self._updateInternals()
|
80
|
-
|
405
|
+
if isinstance(g,Geometry):
|
406
|
+
if g.isintersectable():
|
407
|
+
g = g.geom
|
408
|
+
else:
|
409
|
+
raise ValueError(f'Geometry object {g} not intersectable')
|
410
|
+
elif isgeomlist([g]):
|
411
|
+
pass
|
412
|
+
else:
|
413
|
+
raise ValueError(f'bad thing passed to intersectXY: {g}')
|
414
|
+
|
415
|
+
return intersectXY(self.geom,g,inside,params)
|
416
|
+
|
81
417
|
|
418
|
+
def surface(self,minang = 5.0, minlen = 0.5):
|
419
|
+
"""
|
420
|
+
triangulate a closed polyline or geometry list, return a surface.
|
421
|
+
the ``minang`` parameter specifies a minimum angular resolution
|
422
|
+
for sampling arcs, and ``minleng`` specifies a minimum distance
|
423
|
+
between sampled points.
|
424
|
+
``surface = ['surface',vertices,normals,faces]``, where:
|
425
|
+
``vertices`` is a list of ``yapcad.geom`` points,
|
426
|
+
``normals`` is a list of ``yapcad.geom`` points of the same length as ``vertices``,
|
427
|
+
and ``faces`` is the list of faces, which is to say lists of three indices that
|
428
|
+
refer to the vertices of the triangle that represents each face.
|
429
|
+
"""
|
430
|
+
if not self.isclosed():
|
431
|
+
raise ValueError("non-closed figure has no surface representation")
|
432
|
+
if self.update:
|
433
|
+
self._updateInternals()
|
434
|
+
if (self.__surface and
|
435
|
+
close(self.__surface_ang,minang) and
|
436
|
+
close(self.__surface_len,minlen)):
|
437
|
+
return self.__surface
|
438
|
+
self.__surface_ang = minang
|
439
|
+
self.__surface_len = minlen
|
440
|
+
geo = self.geom
|
441
|
+
if len(geo) == 0:
|
442
|
+
return []
|
443
|
+
if ispolygon(geo):
|
444
|
+
ply = geo
|
445
|
+
holes = []
|
446
|
+
else:
|
447
|
+
ply, holes = geomlist2poly_with_holes(geo, minang, minlen)
|
448
|
+
|
449
|
+
self.__surface,bnd = poly2surface(ply, holepolys=holes, checkclosed=False)
|
450
|
+
|
451
|
+
return self.__surface
|
452
|
+
|
453
|
+
## Utility functions
|
454
|
+
|
455
|
+
def Point(x=False,y=False,z=False,w=False):
|
456
|
+
return Geometry(point(x,y,z,w))
|
457
|
+
|
458
|
+
def Line(p1,p2=False):
|
459
|
+
return Geometry(line(p1,p2))
|
460
|
+
|
461
|
+
def Arc(c,rp=False,sn=False,e=False,n=False,samplereverse=False):
|
462
|
+
return Geometry(arc(c,rp,sn,e,n,samplereverse))
|
463
|
+
|
464
|
+
# def Poly(*args):
|
465
|
+
# ply = poly(*args)
|
466
|
+
# # print(f'ply: {ply}')
|
467
|
+
# return Geometry(ply)
|
468
|
+
|
469
|
+
def Figure(*args):
|
470
|
+
return Geometry(list(*args))
|
471
|
+
|
472
|
+
|
473
|
+
|
@@ -0,0 +1,112 @@
|
|
1
|
+
"""Validation helpers for yapCAD geometry."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from collections import Counter
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from typing import Iterable, List, Sequence
|
8
|
+
|
9
|
+
from yapcad.geom import dist, epsilon, point
|
10
|
+
from yapcad.geometry_utils import triangle_normal
|
11
|
+
|
12
|
+
|
13
|
+
def is_closed_polygon(points: Sequence[Sequence[float]], tol: float = epsilon) -> bool:
|
14
|
+
"""Return ``True`` if a polyline is closed within ``tol``."""
|
15
|
+
|
16
|
+
if not points:
|
17
|
+
return False
|
18
|
+
first = points[0]
|
19
|
+
last = points[-1]
|
20
|
+
if len(first) < 3 or len(last) < 3:
|
21
|
+
return False
|
22
|
+
return dist(first, last) <= tol
|
23
|
+
|
24
|
+
|
25
|
+
def faces_oriented(surface: Sequence) -> "CheckResult":
|
26
|
+
from yapcad.geom3d import issurface
|
27
|
+
|
28
|
+
if not issurface(surface):
|
29
|
+
raise ValueError('faces_oriented expects a surface')
|
30
|
+
|
31
|
+
verts = surface[1]
|
32
|
+
faces = surface[3]
|
33
|
+
|
34
|
+
reference = None
|
35
|
+
inconsistent = []
|
36
|
+
|
37
|
+
for idx, face in enumerate(faces):
|
38
|
+
if len(face) != 3:
|
39
|
+
continue
|
40
|
+
v0 = verts[face[0]]
|
41
|
+
v1 = verts[face[1]]
|
42
|
+
v2 = verts[face[2]]
|
43
|
+
normal = triangle_normal((v0[0], v0[1], v0[2]),
|
44
|
+
(v1[0], v1[1], v1[2]),
|
45
|
+
(v2[0], v2[1], v2[2]))
|
46
|
+
if normal is None:
|
47
|
+
continue
|
48
|
+
if reference is None:
|
49
|
+
reference = normal
|
50
|
+
continue
|
51
|
+
dot = reference[0] * normal[0] + reference[1] * normal[1] + reference[2] * normal[2]
|
52
|
+
if dot < -epsilon:
|
53
|
+
inconsistent.append(idx)
|
54
|
+
|
55
|
+
if reference is None:
|
56
|
+
return CheckResult(True, ['no non-degenerate faces found'])
|
57
|
+
if inconsistent:
|
58
|
+
return CheckResult(False, [f'inconsistent face orientation indices: {inconsistent}'])
|
59
|
+
return CheckResult(True, [])
|
60
|
+
|
61
|
+
|
62
|
+
def surface_watertight(surface: Sequence) -> "CheckResult":
|
63
|
+
from yapcad.geom3d import issurface
|
64
|
+
|
65
|
+
if not issurface(surface):
|
66
|
+
raise ValueError('surface_watertight expects a surface')
|
67
|
+
|
68
|
+
faces = surface[3]
|
69
|
+
edges = Counter()
|
70
|
+
|
71
|
+
for face in faces:
|
72
|
+
if len(face) != 3:
|
73
|
+
continue
|
74
|
+
a, b, c = face
|
75
|
+
edges[_edge_key(a, b)] += 1
|
76
|
+
edges[_edge_key(b, c)] += 1
|
77
|
+
edges[_edge_key(c, a)] += 1
|
78
|
+
|
79
|
+
boundary = [edge for edge, count in edges.items() if count == 1]
|
80
|
+
invalid = [edge for edge, count in edges.items() if count > 2]
|
81
|
+
|
82
|
+
warnings: List[str] = []
|
83
|
+
ok = True
|
84
|
+
if boundary:
|
85
|
+
ok = False
|
86
|
+
warnings.append(f'{len(boundary)} boundary edges detected')
|
87
|
+
if invalid:
|
88
|
+
ok = False
|
89
|
+
warnings.append(f'edges with multiplicity >2: {invalid}')
|
90
|
+
|
91
|
+
return CheckResult(ok, warnings)
|
92
|
+
|
93
|
+
|
94
|
+
def _edge_key(a: int, b: int) -> tuple[int, int]:
|
95
|
+
return (a, b) if a < b else (b, a)
|
96
|
+
|
97
|
+
|
98
|
+
@dataclass
|
99
|
+
class CheckResult:
|
100
|
+
ok: bool
|
101
|
+
warnings: List[str]
|
102
|
+
|
103
|
+
def __bool__(self) -> bool:
|
104
|
+
return self.ok
|
105
|
+
|
106
|
+
|
107
|
+
__all__ = [
|
108
|
+
'CheckResult',
|
109
|
+
'is_closed_polygon',
|
110
|
+
'faces_oriented',
|
111
|
+
'surface_watertight',
|
112
|
+
]
|