yapCAD 0.3.0__py2.py3-none-any.whl → 0.3.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.
yapcad/geometry.py CHANGED
@@ -1,81 +1,473 @@
1
1
  ## yapCAD geometry-generating superclass
2
2
  ## =====================================
3
3
 
4
- ## subclasses of Geometry implement the geom() method, which produce
5
- ## geometry lists or computational geometry primitives, as described
6
- ## by the functions in geom.py
7
-
8
- ## subclasses of SampleGeometry implelement geom() and sample()
9
- ## methods. The geom() method is guaranteed to produce a geometry
10
- ## list, and the sample() method allows for parametric sampling, which
11
- ## is guaranteed to "make sense" in the interval 0.0 <= u <= 1.0.
12
- ## Unless there is a good reason otherwise, subclasses of
13
- ## SampleGeometry should guarantee C0 continuity*
14
-
15
- ## * For fractal elements, or other complex forms where C0 continuity
16
- ## is less meaningful, SampleGeometry might still be approprite.
17
- ## However, if you have diconnected line and arc segments, with a
18
- ## classically-definalble gap (or the possibility thereof) then
19
- ## implement Geometry instead
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
- """ generalized computational geometry class """
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 'geometry base class wrapper for: {}'.format(vstr(self._elem))
107
+ return f"Geometry({self.__elem})"
30
108
 
31
109
  def __init__(self,a=False):
32
- self._update=True
33
- self._elem=[]
34
- if a:
35
- if ispoint(a) or isline (a) or isarc(a) or ispoly(a) \
36
- or isgeomlist(a) or isinstance(a,Geometry):
37
- self._elem=[ deepcopy(a) ]
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: {}'.format(a))
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
- if self._update:
338
+ """return yapcad.geom representation of figure"""
339
+ if self.update:
47
340
  self._updateInternals()
48
- return deepcopy(self._elem)
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
- class SampleGeometry(Geometry):
54
- """ generalized sampleable geometry class"""
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
- def __init__(self,a=False):
57
- super().__init__(a)
356
+ if len(gl) == 1:
357
+ return sample(gl[0],u)
358
+ else:
359
+ return sample(gl,u)
58
360
 
59
- def __repr__(self):
60
- return 'sampleable geometry base class wrapper for: {}'.format(vstr(self._elem))
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
- def sample(self,u):
63
- if self._update:
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
- class IntersectGeometry(SampleGeometry):
69
- """ generalized intersectable geometry class"""
373
+ gl = self.geom
70
374
 
71
- def __init__(self,a=False):
72
- super().__init__(a)
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
- if self._update:
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
- return intersectXY(g,self.geom(),inside,params)
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
+ ]