@@ -224,6 +224,10 @@ class BboxBase(TransformNode):
224224 is_bbox = True
225225 is_affine = True
226226
227+ @staticmethod
228+ def _empty_set_points ():
229+ return np .array ([[np .inf , np .inf ], [- np .inf , - np .inf ]])
230+
227231 if DEBUG :
228232 @staticmethod
229233 def _check (points ):
@@ -655,30 +659,60 @@ def rotated(self, radians):
655659 return bbox
656660
657661 @staticmethod
658- def union (bboxes ):
662+ def union (bboxes , null_as_empty = True ):
659663 """Return a `Bbox` that contains all of the given *bboxes*."""
660664 if not len (bboxes ):
661665 raise ValueError ("'bboxes' cannot be empty" )
662- # needed for 1.14.4 < numpy_version < 1.16
663- # can remove once we are at numpy >= 1.16
664- with np .errstate (invalid = 'ignore' ):
665- x0 = np .min ([bbox .xmin for bbox in bboxes ])
666- x1 = np .max ([bbox .xmax for bbox in bboxes ])
667- y0 = np .min ([bbox .ymin for bbox in bboxes ])
668- y1 = np .max ([bbox .ymax for bbox in bboxes ])
666+ if not null_as_empty :
667+ cbook .warn_deprecated (
668+ 3.4 , message = "Previously, Bboxs with negative width or "
669+ "height could cause surprising results when unioned. In order "
670+ "to fix this, any Bbox with negative width or height will be "
671+ "treated as Bbox.null() (the empty set) starting in the next "
672+ "point release. To upgrade to this behavior now, please pass "
673+ "null_as_empty=True." )
674+ # needed for 1.14.4 < numpy_version < 1.16
675+ # can remove once we are at numpy >= 1.16
676+ with np .errstate (invalid = 'ignore' ):
677+ x0 = np .min ([bbox .xmin for bbox in bboxes ])
678+ x1 = np .max ([bbox .xmax for bbox in bboxes ])
679+ y0 = np .min ([bbox .ymin for bbox in bboxes ])
680+ y1 = np .max ([bbox .ymax for bbox in bboxes ])
681+ else :
682+ # needed for 1.14.4 < numpy_version < 1.16
683+ # can remove once we are at numpy >= 1.16
684+ with np .errstate (invalid = 'ignore' ):
685+ x0 = np .min ([bbox .x0 for bbox in bboxes ])
686+ x1 = np .max ([bbox .x1 for bbox in bboxes ])
687+ y0 = np .min ([bbox .y0 for bbox in bboxes ])
688+ y1 = np .max ([bbox .y1 for bbox in bboxes ])
669689 return Bbox ([[x0 , y0 ], [x1 , y1 ]])
670690
671691 @staticmethod
672- def intersection (bbox1 , bbox2 ):
692+ def intersection (bbox1 , bbox2 , null_as_empty = True ):
673693 """
674694 Return the intersection of *bbox1* and *bbox2* if they intersect, or
675695 None if they don't.
676696 """
677- x0 = np .maximum (bbox1 .xmin , bbox2 .xmin )
678- x1 = np .minimum (bbox1 .xmax , bbox2 .xmax )
679- y0 = np .maximum (bbox1 .ymin , bbox2 .ymin )
680- y1 = np .minimum (bbox1 .ymax , bbox2 .ymax )
681- return Bbox ([[x0 , y0 ], [x1 , y1 ]]) if x0 <= x1 and y0 <= y1 else None
697+ if not null_as_empty :
698+ cbook .warn_deprecated (
699+ 3.4 , message = "Previously, Bboxs with negative width or "
700+ "height could cause surprising results under intersection. To "
701+ "fix this, any Bbox with negative width or height will be "
702+ "treated as Bbox.null() (the empty set) starting in the next "
703+ "point release. To upgrade to this behavior now, please pass "
704+ "null_as_empty=True." )
705+ #TODO: should probably be nanmax...
706+ x0 = np .maximum (bbox1 .xmin , bbox2 .xmin )
707+ x1 = np .minimum (bbox1 .xmax , bbox2 .xmax )
708+ y0 = np .maximum (bbox1 .ymin , bbox2 .ymin )
709+ y1 = np .minimum (bbox1 .ymax , bbox2 .ymax )
710+ else :
711+ x0 = np .maximum (bbox1 .x0 , bbox2 .x0 )
712+ x1 = np .minimum (bbox1 .x1 , bbox2 .x1 )
713+ y0 = np .maximum (bbox1 .y0 , bbox2 .y0 )
714+ y1 = np .minimum (bbox1 .y1 , bbox2 .y1 )
715+ return Bbox ([[x0 , y0 ], [x1 , y1 ]])
682716
683717
684718class Bbox (BboxBase ):
@@ -708,16 +742,16 @@ class Bbox(BboxBase):
708742
709743 **Create from collections of points**
710744
711- The "empty" object for accumulating Bboxs is the null bbox, which is a
712- stand-in for the empty set.
745+ The "empty" object for accumulating Bboxs is the null bbox, which
746+ represents the empty set.
713747
714748 >>> Bbox.null()
715749 Bbox([[inf, inf], [-inf, -inf]])
716750
717751 Adding points to the null bbox will give you the bbox of those points.
718752
719753 >>> box = Bbox.null()
720- >>> box.update_from_data_xy([[1, 1]])
754+ >>> box.update_from_data_xy([[1, 1]], ignore=False )
721755 >>> box
722756 Bbox([[1.0, 1.0], [1.0, 1.0]])
723757 >>> box.update_from_data_xy([[2, 3], [3, 2]], ignore=False)
@@ -736,33 +770,54 @@ class Bbox(BboxBase):
736770 default value of ``ignore`` can be changed at any time by code with
737771 access to your Bbox, for example using the method `~.Bbox.ignore`.
738772
739- **Properties of the ``null`` bbox **
773+ **Create from a set of constraints **
740774
741- .. note::
775+ The null object for accumulating Bboxs from constraints is the entire plane
776+
777+ >>> Bbox.unbounded()
778+ Bbox([[-inf, -inf], [inf, inf]])
779+
780+ By repeatedly intersecting Bboxs, we can refine the Bbox as needed
781+
782+ >>> constraints = Bbox.unbounded()
783+ >>> for box in [Bbox([[0, 0], [1, 1]]), Bbox([[-1, 1], [1, 1]])]:
784+ ... constraints = Bbox.intersection(box, constraints)
785+ >>> constraints
786+ Bbox([[0.0, 1.0], [1.0, 1.0]])
787+
788+ **Algebra of Bboxs**
742789
743- The current behavior of `Bbox.null()` may be surprising as it does
744- not have all of the properties of the "empty set", and as such does
745- not behave like a "zero" object in the mathematical sense. We may
746- change that in the future (with a deprecation period).
790+ The family of all BBoxs forms a ring of sets, once we include the empty set
791+ (`Bbox.null`) and the full space (`Bbox.unbounded`).
747792
748- The null bbox is the identity for intersections
793+ The unbounded bbox is the identity for intersections (the "multiplicative"
794+ identity)
749795
750- >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null ())
796+ >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.unbounded ())
751797 Bbox([[1.0, 1.0], [3.0, 7.0]])
752798
753- except with itself, where it returns the full space.
799+ and union with the unbounded Bbox always returns the unbounded Bbox
754800
755- >>> Bbox.intersection( Bbox.null( ), Bbox.null() )
801+ >>> Bbox.union([ Bbox([[0, 0], [0, 0]] ), Bbox.unbounded()] )
756802 Bbox([[-inf, -inf], [inf, inf]])
757803
758- A union containing null will always return the full space (not the other
759- set!)
804+ The null Bbox is the identity for unions (the "additive" identity)
760805
761- >>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.null()])
762- Bbox([[-inf, -inf], [inf, inf]])
806+ >>> Bbox.union([Bbox.null(), Bbox([[1, 1], [3, 7]])])
807+ Bbox([[1.0, 1.0], [3.0, 7.0]])
808+
809+ and intersection with the null Bbox always returns the null Bbox
810+
811+ >>> Bbox.intersection(Bbox.null(), Bbox.unbounded())
812+ Bbox([[inf, inf], [-inf, -inf]])
813+
814+ .. note::
815+
816+ In order to ensure that there is a unique "empty set", all empty Bboxs
817+ are automatically converted to ``Bbox([[inf, inf], [-inf, -inf]])``.
763818 """
764819
765- def __init__ (self , points , ** kwargs ):
820+ def __init__ (self , points , null_as_empty = True , ** kwargs ):
766821 """
767822 Parameters
768823 ----------
@@ -774,6 +829,18 @@ def __init__(self, points, **kwargs):
774829 if points .shape != (2 , 2 ):
775830 raise ValueError ('Bbox points must be of the form '
776831 '"[[x0, y0], [x1, y1]]".' )
832+ if np .any (np .diff (points , axis = 0 ) < 0 ):
833+ if null_as_empty :
834+ points = self ._empty_set_points ()
835+ else :
836+ cbook .warn_deprecated (
837+ 3.4 , message = "In order to guarantee that Bbox union and "
838+ "intersection can work correctly, Bboxs with negative "
839+ "width or height will always be converted to Bbox.null "
840+ "starting in the next point release. To silence this "
841+ "warning, explicitly pass explicitly set null_as_empty to "
842+ "True to enable this behavior now." )
843+ self ._null_as_empty = null_as_empty
777844 self ._points = points
778845 self ._minpos = np .array ([np .inf , np .inf ])
779846 self ._ignore = True
@@ -793,32 +860,48 @@ def invalidate(self):
793860 TransformNode .invalidate (self )
794861
795862 @staticmethod
796- def unit ():
863+ def unit (null_as_empty = True ):
797864 """Create a new unit `Bbox` from (0, 0) to (1, 1)."""
798- return Bbox ([[0 , 0 ], [1 , 1 ]])
865+ return Bbox ([[0 , 0 ], [1 , 1 ]], null_as_empty = null_as_empty )
799866
800867 @staticmethod
801- def null ():
868+ def null (null_as_empty = True ):
802869 """Create a new null `Bbox` from (inf, inf) to (-inf, -inf)."""
803- return Bbox ([[ np . inf , np . inf ], [ - np . inf , - np . inf ]] )
870+ return Bbox (Bbox . _empty_set_points (), null_as_empty = null_as_empty )
804871
805872 @staticmethod
806- def from_bounds (x0 , y0 , width , height ):
873+ def unbounded (null_as_empty = True ):
874+ """Create a new unbounded `Bbox` from (-inf, -inf) to (inf, inf)."""
875+ return Bbox ([[- np .inf , - np .inf ], [np .inf , np .inf ]],
876+ null_as_empty = null_as_empty )
877+
878+ @staticmethod
879+ def from_bounds (x0 , y0 , width , height , null_as_empty = True ):
807880 """
808881 Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
809882
810- *width* and *height* may be negative.
883+ If *width* or *height* are negative, `Bbox.null` is returned .
811884 """
812- return Bbox .from_extents (x0 , y0 , x0 + width , y0 + height )
885+ if width < 0 or height < 0 or np .isnan (width ) or np .isnan (height ):
886+ if null_as_empty :
887+ return Bbox .null ()
888+ else :
889+ cbook .warn_deprecated (
890+ 3.4 , message = "As of the next point release, "
891+ "Bbox.from_bounds will return the null Bbox if *width* or "
892+ "*height* are negative. To enable this behavior now, pass "
893+ "null_as_empty=True." )
894+ return Bbox .from_extents (x0 , y0 , x0 + width , y0 + height ,
895+ null_as_empty = null_as_empty )
813896
814897 @staticmethod
815- def from_extents (* args ):
898+ def from_extents (* args , null_as_empty = True ):
816899 """
817900 Create a new Bbox from *left*, *bottom*, *right* and *top*.
818901
819902 The *y*-axis increases upwards.
820903 """
821- return Bbox (np .reshape (args , (2 , 2 )))
904+ return Bbox (np .reshape (args , (2 , 2 )), null_as_empty = null_as_empty )
822905
823906 def __format__ (self , fmt ):
824907 return (
@@ -910,41 +993,57 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
910993 @BboxBase .x0 .setter
911994 def x0 (self , val ):
912995 self ._points [0 , 0 ] = val
996+ if self ._null_as_empty and self .x0 > self .x1 :
997+ self ._points = self ._empty_set_points ()
913998 self .invalidate ()
914999
9151000 @BboxBase .y0 .setter
9161001 def y0 (self , val ):
9171002 self ._points [0 , 1 ] = val
1003+ if self ._null_as_empty and self .y0 > self .y1 :
1004+ self ._points = self ._empty_set_points ()
9181005 self .invalidate ()
9191006
9201007 @BboxBase .x1 .setter
9211008 def x1 (self , val ):
9221009 self ._points [1 , 0 ] = val
1010+ if self ._null_as_empty and self .x0 > self .x1 :
1011+ self ._points = self ._empty_set_points ()
9231012 self .invalidate ()
9241013
9251014 @BboxBase .y1 .setter
9261015 def y1 (self , val ):
9271016 self ._points [1 , 1 ] = val
1017+ if self ._null_as_empty and self .y0 > self .y1 :
1018+ self ._points = self ._empty_set_points ()
9281019 self .invalidate ()
9291020
9301021 @BboxBase .p0 .setter
9311022 def p0 (self , val ):
9321023 self ._points [0 ] = val
1024+ if self ._null_as_empty and (self .y0 > self .y1 or self .x0 > self .x1 ):
1025+ self ._points = self ._empty_set_points ()
9331026 self .invalidate ()
9341027
9351028 @BboxBase .p1 .setter
9361029 def p1 (self , val ):
9371030 self ._points [1 ] = val
1031+ if self ._null_as_empty and (self .y0 > self .y1 or self .x0 > self .x1 ):
1032+ self ._points = self ._empty_set_points ()
9381033 self .invalidate ()
9391034
9401035 @BboxBase .intervalx .setter
9411036 def intervalx (self , interval ):
9421037 self ._points [:, 0 ] = interval
1038+ if self ._null_as_empty and self .x0 > self .x1 :
1039+ self ._points = self ._empty_set_points ()
9431040 self .invalidate ()
9441041
9451042 @BboxBase .intervaly .setter
9461043 def intervaly (self , interval ):
9471044 self ._points [:, 1 ] = interval
1045+ if self ._null_as_empty and self .y0 > self .y1 :
1046+ self ._points = self ._empty_set_points ()
9481047 self .invalidate ()
9491048
9501049 @BboxBase .bounds .setter
@@ -953,6 +1052,9 @@ def bounds(self, bounds):
9531052 points = np .array ([[l , b ], [l + w , b + h ]], float )
9541053 if np .any (self ._points != points ):
9551054 self ._points = points
1055+ if self ._null_as_empty and \
1056+ (self .y0 > self .y1 or self .x0 > self .x1 ):
1057+ self ._points = self ._empty_set_points ()
9561058 self .invalidate ()
9571059
9581060 @property
@@ -983,6 +1085,9 @@ def set_points(self, points):
9831085 """
9841086 if np .any (self ._points != points ):
9851087 self ._points = points
1088+ if self ._null_as_empty \
1089+ and (self .y0 > self .y1 or self .x0 > self .x1 ):
1090+ self ._points = self ._empty_set_points ()
9861091 self .invalidate ()
9871092
9881093 def set (self , other ):
@@ -991,6 +1096,9 @@ def set(self, other):
9911096 """
9921097 if np .any (self ._points != other .get_points ()):
9931098 self ._points = other .get_points ()
1099+ if self ._null_as_empty \
1100+ and (self .y0 > self .y1 or self .x0 > self .x1 ):
1101+ self ._points = self ._empty_set_points ()
9941102 self .invalidate ()
9951103
9961104 def mutated (self ):
0 commit comments