forked from ladybug-tools/ladybug-geometry
-
Notifications
You must be signed in to change notification settings - Fork 0
/
face.py
2748 lines (2418 loc) 路 126 KB
/
face.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# coding=utf-8
"""Planar Face in 3D Space"""
from __future__ import division
import math
import sys
if (sys.version_info > (3, 0)): # python 3
xrange = range
from .pointvector import Point3D, Vector3D
from .ray import Ray3D
from .line import LineSegment3D
from .polyline import Polyline3D
from .plane import Plane
from .mesh import Mesh3D
from ._2d import Base2DIn3D
from ..intersection3d import closest_point3d_on_line3d
from ..geometry2d.pointvector import Point2D, Vector2D
from ..geometry2d.ray import Ray2D
from ..geometry2d.polygon import Polygon2D
from ..geometry2d.mesh import Mesh2D
import ladybug_geometry.boolean as pb
class Face3D(Base2DIn3D):
"""Planar Face in 3D space.
Args:
boundary: A list or tuple of Point3D objects representing the outer
boundary vertices of the face.
plane: A Plane object indicating the plane in which the face exists.
If None, the Plane normal will automatically be calculated by
analyzing the input vertices and the origin of the plane will be
the first vertex of the input vertices. Default: None.
holes: Optional list of lists with one list for each hole in the face.
Each hole should be a list of at least 3 Point3D objects.
If None, it will be assumed that there are no holes in the face.
The boundary and holes are stored as separate lists of Point3Ds on the
`boundary` and `holes` properties of this object. However, the
`vertices` property will always contain all vertices across the shape.
For a Face3D that has holes, it will trace out a single shape that
turns inwards from the boundary to cut out the holes.
enforce_right_hand: Boolean to note whether a check should be run to
ensure that input vertices are counterclockwise within the input plane,
thereby enforcing the right-hand rule. By default, this is True
and ensures that all Face3D objects adhere to the right-hand rule.
It is recommended that this only be set to False in cases where you
are certain that the input vertices are counter-clockwise
within the input plane and you would like to avoid the extra
unnecessary check.
Properties:
* vertices
* plane
* boundary
* holes
* polygon2d
* boundary_polygon2d
* hole_polygon2d
* triangulated_mesh2d
* triangulated_mesh3d
* boundary_segments
* hole_segments
* normal
* min
* max
* center
* perimeter
* area
* centroid
* altitude
* azimuth
* is_clockwise
* is_convex
* is_self_intersecting
* self_intersection_points
* is_valid
* has_holes
* upper_left_corner
* lower_left_corner
* upper_right_corner
* lower_right_corner
* upper_left_counter_clockwise_vertices
* lower_left_counter_clockwise_vertices
* lower_right_counter_clockwise_vertices
* upper_right_counter_clockwise_vertices
"""
__slots__ = ('_plane', '_polygon2d', '_mesh2d', '_mesh3d',
'_boundary', '_holes', '_boundary_segments', '_hole_segments',
'_boundary_polygon2d', '_hole_polygon2d',
'_perimeter', '_area', '_centroid',
'_is_convex', '_is_self_intersecting')
HOLE_VERTEX_THRESHOLD = 400 # threshold at which faster hole merging method is used
def __init__(self, boundary, plane=None, holes=None, enforce_right_hand=True):
"""Initialize Face3D."""
# process the boundary and plane inputs
self._boundary = self._check_vertices_input(boundary)
if plane is not None:
assert isinstance(plane, Plane), 'Expected Plane for Face3D.' \
' Got {}.'.format(type(plane))
else:
plane = self._plane_from_vertices(boundary)
self._plane = plane
# process boundary and holes input
if holes:
assert isinstance(holes, (tuple, list)), \
'holes should be a tuple or list. Got {}'.format(type(holes))
self._holes = tuple(
self._check_vertices_input(hole, 'hole') for hole in holes)
# create a Polygon2D from the vertices
_boundary2d = [self._plane.xyz_to_xy(_v) for _v in boundary]
_holes2d = [[self._plane.xyz_to_xy(_v) for _v in hole] for hole in holes]
v_count = len(_boundary2d) # count the vertices for hole merging method
for h in _holes2d:
v_count += len(h)
_polygon2d = Polygon2D.from_shape_with_holes_fast(_boundary2d, _holes2d) \
if v_count > self.HOLE_VERTEX_THRESHOLD else \
Polygon2D.from_shape_with_holes(_boundary2d, _holes2d)
# convert Polygon2D vertices to 3D to become the vertices of the face.
self._vertices = tuple(self._plane.xy_to_xyz(_v)
for _v in _polygon2d.vertices)
self._polygon2d = _polygon2d
else:
self._holes = None
self._vertices = self._boundary
self._polygon2d = None
# perform a check of vertex orientation and enforce counter clockwise vertices
if enforce_right_hand is True:
if self.is_clockwise is True:
self._boundary = tuple(reversed(self._boundary))
self._vertices = tuple(reversed(self._vertices))
if self._polygon2d is not None:
self._polygon2d = self._polygon2d.reverse()
# set other properties to None for now
self._mesh2d = None
self._mesh3d = None
self._boundary_polygon2d = None
self._hole_polygon2d = None
self._boundary_segments = None
self._hole_segments = None
self._min = None
self._max = None
self._center = None
self._perimeter = None
self._area = None
self._centroid = None
self._is_convex = None
self._is_self_intersecting = None
@classmethod
def from_dict(cls, data):
"""Create a Face3D from a dictionary.
Args:
data: A python dictionary in the following format
.. code-block:: python
{
"type": "Face3D",
"boundary": [(0, 0, 0), (10, 0, 0), (0, 10, 0)],
"plane": {"n": (0, 0, 1), "o": (0, 0, 0), "x": (1, 0, 0)},
"holes": [[(2, 2, 0), (5, 2, 0), (2, 5, 0)]]
}
"""
holes = None
if 'holes' in data and data['holes'] is not None:
holes = tuple(tuple(
Point3D.from_array(pt) for pt in hole) for hole in data['holes'])
plane = None
if 'plane' in data and data['plane'] is not None:
plane = Plane.from_dict(data['plane'])
return cls(tuple(Point3D.from_array(pt) for pt in data['boundary']),
plane, holes)
@classmethod
def from_extrusion(cls, line_segment, extrusion_vector):
"""Initialize Face3D by extruding a line segment.
Initializing a face this way has the added benefit of having its
properties quickly computed.
Args:
line_segment: A LineSegment3D to be extruded.
extrusion_vector: A vector denoting the direction and distance to
extrude the line segment.
"""
assert isinstance(line_segment, LineSegment3D), \
'line_segment must be LineSegment3D. Got {}.'.format(type(line_segment))
assert isinstance(extrusion_vector, Vector3D), \
'extrusion_vector must be Vector3D. Got {}.'.format(type(extrusion_vector))
_p1 = line_segment.p1
_p2 = line_segment.p2
_verts = (_p1, _p2, _p2 + extrusion_vector, _p1 + extrusion_vector)
_plane = Plane(line_segment.v.cross(extrusion_vector), _p1)
face = cls(_verts, _plane, enforce_right_hand=False)
_base = line_segment.length
_dist = extrusion_vector.magnitude
_height = _dist * math.sin(extrusion_vector.angle(line_segment.v))
face._perimeter = _base * 2 + _dist * 2
face._area = _base * _height
face._centroid = _p1 + (line_segment.v * 0.5) + (extrusion_vector * 0.5)
face._is_convex = True
face._is_self_intersecting = False
return face
@classmethod
def from_rectangle(cls, base, height, base_plane=None):
"""Initialize Face3D from rectangle parameters (base + height) and a base plane.
Initializing a face this way has the added benefit of having its
properties quickly computed.
Args:
base: A number indicating the length of the base of the rectangle.
height: A number indicating the length of the height of the rectangle.
base_plane: A Plane object in which the rectangle will be created.
The origin of this plane will be the lower left corner of the
rectangle and the X and Y axes will form the sides.
Default is the world XY plane.
"""
assert isinstance(base, (float, int)), 'Rectangle base must be a number.'
assert isinstance(height, (float, int)), 'Rectangle height must be a number.'
if base_plane is not None:
assert isinstance(base_plane, Plane), \
'base_plane must be Plane. Got {}.'.format(type(base_plane))
else:
base_plane = Plane(Vector3D(0, 0, 1), Point3D(0, 0, 0))
_o = base_plane.o
_b_vec = base_plane.x * base
_h_vec = base_plane.y * height
_verts = (_o, _o + _b_vec, _o + _h_vec + _b_vec, _o + _h_vec)
face = cls(_verts, base_plane, enforce_right_hand=False)
face._perimeter = base * 2 + height * 2
face._area = base * height
face._centroid = _o + (_b_vec * 0.5) + (_h_vec * 0.5)
face._is_convex = True
face._is_self_intersecting = False
return face
@classmethod
def from_regular_polygon(cls, side_count, radius=1, base_plane=None):
"""Initialize Face3D from regular polygon parameters and a base_plane.
Args:
side_count: An integer for the number of sides on the regular
polygon. This number must be greater than 2.
radius: A number indicating the distance from the polygon's center
where the vertices of the polygon will lie.
The default is set to 1.
base_plane: A Plane object for the plane in which the face exists.
The origin of this plane will be used as the center of the polygon.
If None, the default will be the WorldXY plane.
"""
# set the default base_plane
if base_plane is not None:
assert isinstance(base_plane, Plane), 'Expected Plane. Got {}'.format(
type(base_plane))
else:
base_plane = Plane(Vector3D(0, 0, 1), Point3D(0, 0, 0))
# create the regular polygon face
_polygon2d = Polygon2D.from_regular_polygon(side_count, radius)
_vert3d = tuple(base_plane.xy_to_xyz(_v) for _v in _polygon2d.vertices)
_face = cls(_vert3d, base_plane, enforce_right_hand=False)
# assign extra properties that we know to the face
_face._polygon2d = _polygon2d
_face._center = base_plane.o
_face._centroid = base_plane.o
_face._is_convex = True
_face._is_self_intersecting = False
return _face
@classmethod
def from_punched_geometry(cls, base_face, sub_faces):
"""Create a face with holes punched in it from sub-faces.
Args:
base_face: A Face3D that acts as a parent to the sub_faces, completely
encircling them.
sub_faces: A list of Face3D objects that will be punched into the
base_face. These faces must lie completely within the base_face
for the result to be valid. The is_sub_face() method can be
used to check sub_faces before they are input here.
"""
assert isinstance(base_face, Face3D), \
'base_face should be a Face3D. Got {}'.format(type(base_face))
for hole in sub_faces:
assert isinstance(hole, Face3D), \
'sub_face should be a list. Got {}'.format(type(hole))
hole_verts = [list(sf.boundary) for sf in sub_faces]
if base_face.has_holes:
hole_verts.extend([list(h) for h in base_face.holes])
return cls(base_face.boundary, base_face.plane, hole_verts,
enforce_right_hand=False)
@property
def vertices(self):
"""Tuple of all vertices in this face.
Note that, in the case of a face with holes, some vertices will be repeated
since this property effectively traces out a single boundary around the
whole shape, winding inward to cut out the holes.
"""
return self._vertices
@property
def plane(self):
"""Tuple of all vertices in this face."""
return self._plane
@property
def polygon2d(self):
"""A Polygon2D of this face in the 2D space of the face's plane.
Note that this is a single polygon object even when there are holes in the
face since such a polygon can be made by drawing a line from the holes to
the outer boundary.
"""
if self._polygon2d is None:
_vert2d = tuple(self._plane.xyz_to_xy(_v) for _v in self.vertices)
self._polygon2d = Polygon2D(_vert2d)
return self._polygon2d
@property
def triangulated_mesh2d(self):
"""A triangulated Mesh2D in the 2D space of the face's plane."""
if self._mesh2d is None:
self._mesh2d = Mesh2D.from_polygon_triangulated(
self.boundary_polygon2d, self.hole_polygon2d)
return self._mesh2d
@property
def triangulated_mesh3d(self):
"""A triangulated Mesh3D of this face."""
if self._mesh3d is None:
_vert3d = tuple(self._plane.xy_to_xyz(_v) for _v in
self.triangulated_mesh2d.vertices)
self._mesh3d = Mesh3D(_vert3d, self.triangulated_mesh2d.faces)
return self._mesh3d
@property
def boundary(self):
"""Tuple of vertices on the boundary of this face.
For most Face3D objects, this will be identical to the vertices property.
However, when the Face3D has holes within it, this property stores
the outer boundary of the shape.
"""
return self._boundary
@property
def holes(self):
"""Tuple with one tuple of vertices for each hole within this face.
This property will be None when the face has no holes in it.
"""
return self._holes
@property
def boundary_segments(self):
"""Tuple of all line segments bordering the face.
Note that this does not include segments for any holes in the face.
Just the outer boundary.
"""
if self._boundary_segments is None:
_segs = []
for i, vert in enumerate(self.boundary):
_seg = LineSegment3D.from_end_points(self.boundary[i - 1], vert)
_segs.append(_seg)
_segs.append(_segs.pop(0)) # segments will start from the first vertex
self._boundary_segments = tuple(_segs)
return self._boundary_segments
@property
def hole_segments(self):
"""Tuple with a tuple of line segments for each hole in the face.
This will be None if there are no holes in the face.
"""
if self._holes is not None and self._hole_segments is None:
_all_segs = []
for hole in self.holes:
_segs = []
for i, vert in enumerate(hole):
_seg = LineSegment3D.from_end_points(hole[i - 1], vert)
_segs.append(_seg)
_segs.append(_segs.pop(0)) # segments will start from the first vertex
_all_segs.append(_segs)
self._hole_segments = tuple(tuple(_s) for _s in _all_segs)
return self._hole_segments
@property
def boundary_polygon2d(self):
"""A Polygon2D of the face boundary in the 2D space of the face's plane.
Note that this does not include any holes in the face. Just the outer boundary.
"""
if self._boundary_polygon2d is None:
_vert2d = tuple(self._plane.xyz_to_xy(_v) for _v in self.boundary)
self._boundary_polygon2d = Polygon2D(_vert2d)
return self._boundary_polygon2d
@property
def hole_polygon2d(self):
"""A list of Polygon2D for the face holes in the 2D space of the face's plane.
"""
if self._holes is not None and self._hole_polygon2d is None:
self._hole_polygon2d = []
for hole in self.holes:
_vert2d = tuple(self._plane.xyz_to_xy(_v) for _v in hole)
self._hole_polygon2d.append(Polygon2D(_vert2d))
return self._hole_polygon2d
@property
def normal(self):
"""Normal vector for the plane in which the face exists."""
return self._plane.n
@property
def perimeter(self):
"""The perimeter of the face. This includes the length of holes in the face."""
if self._perimeter is None:
self._perimeter = sum([seg.length for seg in self.boundary_segments])
if self._holes is not None:
for hole in self.hole_segments:
self._perimeter += sum([seg.length for seg in hole])
return self._perimeter
@property
def area(self):
"""The area of the face."""
if self._area is None:
self._area = self.polygon2d.area
return self._area
@property
def centroid(self):
"""The centroid of the face as a Point3D (aka. center of mass).
Note that the centroid is more time consuming to compute than the center
(or the middle point of the face bounding box). So the center might be
preferred over the centroid if you just need a rough point for the middle
of the face.
"""
if self._centroid is None:
_cent2d = self.triangulated_mesh2d.centroid
self._centroid = self._plane.xy_to_xyz(_cent2d)
return self._centroid
@property
def azimuth(self):
"""Get the azimuth of the Face3D (between 0 and 2 * Pi).
This will be zero if the Face3D is perfectly horizontal.
"""
return self.plane.azimuth
@property
def altitude(self):
"""Get the altitude of the Face3D (between Pi/2 and -Pi/2)."""
return self.plane.altitude
@property
def is_clockwise(self):
"""Boolean for whether the face vertices and boundary are in clockwise order.
Note that all Face3D objects should have counterclockwise vertices (meaning
that this property should always be False). This property exists largely
for testing / debugging purposes.
"""
return self.polygon2d.is_clockwise
@property
def is_convex(self):
"""Boolean noting whether the face is convex (True) or non-convex (False).
Note that any face with holes will be automatically considered non-convex
since the underlying polygon_2d is always non-convex in this case.
"""
if self._is_convex is None:
self._is_convex = self.polygon2d.is_convex
return self._is_convex
@property
def is_self_intersecting(self):
"""Boolean noting whether the face has self-intersecting edges.
Note that this property is relatively computationally intense to obtain compared
to properties like area and is_convex. Also, most CAD programs forbid geometry
with self-intersecting edges. So it is recommended that this property only
be used in quality control scripts where the origin of the geometry is unknown.
"""
if self._is_self_intersecting is None:
self._is_self_intersecting = False
if self.boundary_polygon2d.is_self_intersecting:
self._is_self_intersecting = True
if self.has_holes:
for hp in self.hole_polygon2d:
if hp.is_self_intersecting:
self._is_self_intersecting = True
break
return self._is_self_intersecting
@property
def self_intersection_points(self):
"""A tuple of Point3Ds for the locations where the Face3D intersects itself.
This will be an empty tuple if the Face3D is not self-intersecting and it
is generally recommended that the Face3D.is_self_intersecting property
be checked before using this property.
"""
if self.is_self_intersecting:
int_pts = []
for pt2 in self.boundary_polygon2d.self_intersection_points:
int_pts.append(self.plane.xy_to_xyz(pt2))
if self.has_holes:
for hp in self.hole_polygon2d:
for pt2 in hp.self_intersection_points:
int_pts.append(self.plane.xy_to_xyz(pt2))
return tuple(int_pts)
return ()
@property
def is_valid(self):
"""Boolean noting whether the face is valid (having a non-zero area).
Note that faces are still considered valid if they have out-of-plane vertices,
self-intersecting edges, or duplicate/colinear vertices. The check_planar
method can be used to detect if there are out-of-plane vertices. The
is_self_intersecting property identifies self-intersecting edges, and the
remove_colinear_vertices method will remove duplicate/colinear vertices."""
return not self.area == 0
@property
def has_holes(self):
"""Boolean noting whether the face has holes within it."""
return self._holes is not None
@property
def upper_left_corner(self):
"""Get the vertex in the upper-left corner of the face's bounding box."""
return self._corner_point('min', 'max')
@property
def lower_left_corner(self):
"""Get the vertex in the lower-left corner of the face's bounding box."""
return self._corner_point('min', 'min')
@property
def upper_right_corner(self):
"""Get the vertex in the upper-right corner of the face's bounding box."""
return self._corner_point('max', 'max')
@property
def lower_right_corner(self):
"""Get the vertex in the lower-right corner of the face's bounding box."""
return self._corner_point('max', 'min')
@property
def upper_left_counter_clockwise_vertices(self):
"""Get face vertices starting from the upper left and moving counterclockwise.
Horizontal faces will treat the positive Y axis as up. All other faces
treat the positive Z axis as up.
"""
corner_pt, polygon = self._corner_point_and_polygon(self._vertices, 'min', 'max')
verts3d, verts2d = self._counter_clockwise_verts(polygon)
return self._corner_pt_verts(corner_pt, verts3d, verts2d)
@property
def lower_left_counter_clockwise_vertices(self):
"""Get face vertices starting from the lower left and moving counterclockwise.
Horizontal faces will treat the positive Y axis as up. All other faces
treat the positive Z axis as up.
"""
corner_pt, polygon = self._corner_point_and_polygon(self._vertices, 'min', 'min')
verts3d, verts2d = self._counter_clockwise_verts(polygon)
return self._corner_pt_verts(corner_pt, verts3d, verts2d)
@property
def lower_right_counter_clockwise_vertices(self):
"""Get face vertices starting from the lower left and moving counterclockwise.
Horizontal faces will treat the positive Y axis as up. All other faces
treat the positive Z axis as up.
"""
corner_pt, polygon = self._corner_point_and_polygon(self._vertices, 'max', 'min')
verts3d, verts2d = self._counter_clockwise_verts(polygon)
return self._corner_pt_verts(corner_pt, verts3d, verts2d)
@property
def upper_right_counter_clockwise_vertices(self):
"""Get face vertices starting from the lower left and moving counterclockwise.
Horizontal faces will treat the positive Y axis as up. All other faces
treat the positive Z axis as up.
"""
corner_pt, polygon = self._corner_point_and_polygon(self._vertices, 'max', 'max')
verts3d, verts2d = self._counter_clockwise_verts(polygon)
return self._corner_pt_verts(corner_pt, verts3d, verts2d)
def pole_of_inaccessibility(self, tolerance):
"""Get the pole of inaccessibility for the Face3D.
The pole of inaccessibility is the most distant internal point from the
Face3D outline. It is not to be confused with the centroid, which
represents the "center of mass" of the shape and may be outside of
the Face3D if the shape is concave. The poly of inaccessibility is
useful for optimal placement of a text label on the Face3D.
Args:
tolerance: The precision to which the pole of inaccessibility
will be computed.
"""
return self.plane.xy_to_xyz(self.polygon2d.pole_of_inaccessibility(tolerance))
def is_horizontal(self, tolerance):
"""Check whether a this face is horizontal within a given tolerance.
Args:
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered equivalent.
Returns:
True if the face is horizontal. False if it is not.
"""
return self.max.z - self.min.z <= tolerance
def is_geometrically_equivalent(self, face, tolerance):
"""Check whether a given face is geometrically equivalent to this Face.
Geometrical equivalence is defined as being coplanar with this face,
having the same number of vertices, and having each vertex map-able between
the faces. Clockwise relationships do not have to match nor does the normal
direction of the face. However, all other properties must be matching to
within the input tolerance.
This is useful for identifying matching surfaces when solving for adjacency
and you need to ensure that two faces match perfectly in their area and vertices.
Note that you may also want to use the remove_colinear_vertices() method
on input faces before using this method in order to count faces with the
same non-colinear vertices as geometrically equivalent.
Args:
face: Another face for which geometric equivalency will be tested.
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered geometrically equivalent.
Returns:
True if geometrically equivalent. False if not geometrically equivalent.
"""
# rule out surfaces if they don't fit key criteria
if not self.center.is_equivalent(face.center, tolerance):
return False
if len(self.vertices) != len(face.vertices):
return False
# see if we can find a matching vertex
match_i = None
for i, pt in enumerate(self.vertices):
if pt.is_equivalent(face[0], tolerance):
match_i = i
break
# check equivalency of each vertex
if match_i is None:
return False
elif self[match_i - 1].is_equivalent(face[1], tolerance):
for i in xrange(len(self.vertices)):
if self[match_i - i].is_equivalent(face[i], tolerance) is False:
return False
elif self[match_i + 1].is_equivalent(face[1], tolerance):
for i in xrange(0, -len(self.vertices), -1):
if self[match_i + i].is_equivalent(face[i], tolerance) is False:
return False
else:
return False
return True
def is_centered_adjacent(self, face, tolerance):
"""Check whether a given face is centered adjacent with this Face.
Centered adjacency is defined as sharing the same center point as this face
and being next to one another to within the tolerance.
This is useful for identifying matching faces when you want to quickly
solve for adjacency and you are not concerned about false positives in cases
where one face does not perfectly match the other in terms of vertex ordering.
Args:
face: Another face for which centered adjacency will be tested.
tolerance: The minimum difference between the coordinate values of two
centers at which they can be considered centered adjacent.
Returns:
True if centered adjacent. False if not centered adjacent.
"""
if not self.center.is_equivalent(face.center, tolerance): # center check
return False
# construct a ray using this face's normal and a point just behind this face
point_on_face = self._point_on_face(tolerance)
point_on_face = point_on_face - (self.normal * tolerance) # move below
test_ray = Ray3D(point_on_face, self.normal)
# shoot ray from this face to the other to verify adjacency
if face.intersect_line_ray(test_ray):
return True
return False
def is_sub_face(self, face, tolerance, angle_tolerance):
"""Check whether a given face is a sub-face of this face.
Sub-faces will lie in the same plane as this one and have all of their
vertices completely within the boundary of this face.
This is useful for identifying whether a given sub-face (ie. a window or door)
can be assigned as a child to this face.
Args:
face: Another face for which sub-face equivalency will be tested.
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered equivalent.
angle_tolerance: The max angle in radians that the plane normals can
differ from one another in order for them to be considered coplanar.
Returns:
True if it can be a valid sub-face. False if it is not a valid sub-face.
"""
# test whether the surface is coplanar
if not self.plane.is_coplanar_tolerance(face.plane, tolerance, angle_tolerance):
return False
# if it is, convert sub-face to a polygon in this face's plane
return self._is_sub_face(face)
def polygon_in_face(self, sub_face, origin=None, flip=False):
"""Get a Polygon2D for a sub_face within the plane of this Face3D.
Note that there is no check within this method to determine whether the
the sub_face is coplanar with this Face3D or is fully bounded by it.
So the is_sub_face method should be used to evaluate this before using
this method.
Args:
sub_face: A Face3D for which a Polygon2D in the plane of this
Face3D will be returned.
origin: An optional Point3D to set the origin of the plane in which
the sub_face will be evaluated. Plugging in values like the
Face's lower_left_corner can standardize the geometry rules
for the resulting polygon. If None, this face's own
plane will be used. (Default: None).
flip: Boolean to note whether the x-axis of the plane should be flipped
when translating this the sub_face vertices.
"""
# set the process the origin into a plane
if origin is None:
plane = self.plane if not flip else self.plane.flip()
else:
if self._plane.n.z in (1, -1):
plane = Plane(self._plane.n, origin, Vector3D(1, 0, 0)) if not flip \
else Plane(self._plane.n, origin, Vector3D(-1, 0, 0))
else:
proj_y = Vector3D(0, 0, 1).project(self._plane.n)
proj_x = proj_y.rotate(self._plane.n, math.pi / -2)
plane = Plane(self._plane.n, origin, proj_x)
pts_2d = tuple(plane.xyz_to_xy(pt) for pt in sub_face.boundary)
return Polygon2D(pts_2d)
def is_point_on_face(self, point, tolerance):
"""Check whether a given point is on this face.
This includes both a check to be sure that the point is in the plane of this
face and a check to ensure that point lies in the boundary of the face.
Args:
point: A Point3D to evaluate whether it lies on the face.
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered equivalent.
Returns:
True if the point is on the face. False if it is not.
"""
# test whether the point is in the plane of the face
if self.plane.distance_to_point(point) > tolerance:
return False
# if it is, convert the point into this face's plane
vert2d = self.plane.xyz_to_xy(point)
return self.polygon2d.is_point_inside(vert2d)
def check_planar(self, tolerance, raise_exception=True):
"""Check that all of the face's vertices lie within the face's plane.
This check is not done by default when creating the face since
it is assumed that there is likely a check for planarity before the face
is created (ie. in CAD software where the face likely originates from).
This method is intended for quality control checking when the origin of
face geometry is unknown or is known to come from a place where no
planarity check was performed.
Args:
tolerance: The minimum distance between a given vertex and a the
face's plane at which the vertex is said to lie in the plane.
raise_exception: Boolean to note whether an exception should be raised
if a vertex does not lie within the face's plane. If True, an
exception message will be given in such cases, which notes the non-planar
vertex and its distance from the plane. If False, this method will
simply return a False boolean if a vertex is found that is out
of plane. Default is True to raise an exception.
Returns:
True if planar within the tolerance. False if not planar.
"""
for _v in self.vertices:
if self._plane.distance_to_point(_v) >= tolerance:
if raise_exception is True:
raise ValueError(
'Vertex {} is out of plane with its parent face.\nDistance '
'to plane is {}'.format(_v, self._plane.distance_to_point(_v)))
else:
return False
return True
def non_planar_vertices(self, tolerance):
"""Get a tuple of Point3D for any vertices that lie outside the face's plane.
This will be an empty tuple when the Face3D is planar and it is recommended
that the Face3D.check_planar method be used before calling this one.
Args:
tolerance: The minimum distance between a given vertex and a the
face's plane at which the vertex is said to lie in the plane.
"""
np_verts = []
for _v in self.vertices:
if self._plane.distance_to_point(_v) >= tolerance:
np_verts.append(_v)
return tuple(np_verts)
def remove_duplicate_vertices(self, tolerance):
"""Get a version of this face without duplicate vertices.
Args:
tolerance: The minimum distance between a two vertices at which
they are considered co-located or duplicated.
"""
if not self.has_holes: # we only need to evaluate one list of vertices
new_vertices = tuple(
pt for i, pt in enumerate(self._vertices)
if not pt.is_equivalent(self._vertices[i - 1], tolerance))
_new_face = Face3D(new_vertices, self.plane, enforce_right_hand=False)
return _new_face
# the face has holes
_boundary = tuple(
pt for i, pt in enumerate(self._boundary)
if not pt.is_equivalent(self._boundary[i - 1], tolerance))
_holes = tuple(
tuple(p for i, p in enumerate(h) if not p.is_equivalent(h[i - 1], tolerance))
for j, h in enumerate(self._holes))
_new_face = Face3D(_boundary, self.plane, _holes, enforce_right_hand=False)
return _new_face
def remove_colinear_vertices(self, tolerance):
"""Get a version of this face without colinear or duplicate vertices.
Args:
tolerance: The minimum distance between a vertex and the boundary segments
at which point the vertex is considered colinear.
"""
if not self.has_holes: # we only need to evaluate one list of vertices
new_vertices = self._remove_colinear(
self._vertices, self.polygon2d, tolerance)
_new_face = Face3D(new_vertices, self.plane, enforce_right_hand=False)
return _new_face
# the face has holes
_boundary = self._remove_colinear(
self._boundary, self.boundary_polygon2d, tolerance)
_holes = tuple(self._remove_colinear(hole, self.hole_polygon2d[i], tolerance)
for i, hole in enumerate(self._holes))
_new_face = Face3D(_boundary, self.plane, _holes, enforce_right_hand=False)
return _new_face
def flip(self):
"""Get a face with a flipped direction from this one."""
_new_face = Face3D(reversed(self.vertices), self.plane.flip(),
enforce_right_hand=False)
self._transfer_properties(_new_face)
if self._holes is not None:
_new_face._boundary = tuple(reversed(self._boundary))
_new_face._holes = self._holes
return _new_face
def move(self, moving_vec):
"""Get a face that has been moved along a vector.
Args:
moving_vec: A Vector3D with the direction and distance to move the face.
"""
_verts = self._move(self.vertices, moving_vec)
_new_face = self._face_transform(_verts, self.plane.move(moving_vec))
if self._holes is not None:
_new_face._boundary = self._move(self._boundary, moving_vec)
_new_face._holes = tuple(self._move(hole, moving_vec)
for hole in self._holes)
return _new_face
def rotate(self, axis, angle, origin):
"""Rotate a face by a certain angle around an axis and origin.
Right hand rule applies:
If axis has a positive orientation, rotation will be clockwise.
If axis has a negative orientation, rotation will be counterclockwise.
Args:
axis: A Vector3D axis representing the axis of rotation.
angle: An angle for rotation in radians.
origin: A Point3D for the origin around which the object will be rotated.
"""
_verts = self._rotate(self.vertices, axis, angle, origin)
_new_face = self._face_transform(_verts, self.plane.rotate(axis, angle, origin))
if self._holes is not None:
_new_face._boundary = self._rotate(self._boundary, axis, angle, origin)
_new_face._holes = tuple(self._rotate(hole, axis, angle, origin)
for hole in self._holes)
return _new_face
def rotate_xy(self, angle, origin):
"""Get a face rotated counterclockwise in the world XY plane by a certain angle.
Args:
angle: An angle in radians.
origin: A Point3D for the origin around which the object will be rotated.
"""
_verts = self._rotate_xy(self.vertices, angle, origin)
_new_face = self._face_transform(_verts, self.plane.rotate_xy(angle, origin))
if self._holes is not None:
_new_face._boundary = self._rotate_xy(self._boundary, angle, origin)
_new_face._holes = tuple(self._rotate_xy(hole, angle, origin)
for hole in self._holes)
return _new_face
def reflect(self, normal, origin):
"""Get a face reflected across a plane with the input normal vector and origin.
Args:
normal: A Vector3D representing the normal vector for the plane across
which the face will be reflected. THIS VECTOR MUST BE NORMALIZED.
origin: A Point3D representing the origin from which to reflect.
"""
_verts = self._reflect(self.vertices, normal, origin)
_new_face = self._face_transform_reflect(
_verts, self.plane.reflect(normal, origin))
if self._holes is not None:
_new_face._boundary = self._reflect(self._boundary, normal, origin)
_new_face._holes = tuple(self._reflect(hole, normal, origin)
for hole in self._holes)
return _new_face
def scale(self, factor, origin=None):
"""Scale a face by a factor from an origin point.
Args:
factor: A number representing how much the face should be scaled.
origin: A Point3D representing the origin from which to scale.
If None, it will be scaled from the World origin (0, 0, 0).
"""
_verts = self._scale(self.vertices, factor, origin)
_new_face = self._face_transform_scale(_verts, None, factor)
if self._holes is not None:
_new_face._boundary = self._scale(self._boundary, factor, origin)
_new_face._holes = tuple(self._scale(hole, factor, origin)
for hole in self._holes)
return _new_face
def split_through_holes(self):
"""Get this Face3D split through its holes to get Face3D without holes.
This method attempts to return the minimum number of non-holed shapes that
are needed to represent the original Face3D. If this fails, the result
will be derived from a triangulated shape. If getting a minimum number
of constituent Face3D is not important, it is more efficient to just
use all of the triangles in Face3D.triangulated_mesh3d instead of the
result of this method.
Returns:
A list of Face3D without holes that together form a geometric
representation of this Face3D. If this Face3D has no holes a list
with a single Face3D is returned.
"""
def _shared_vertex_count(vert_set, verts):
"""Get the number of shared vertices."""
in_set = tuple(v for v in verts if v in vert_set)
return len(in_set)