11from __future__ import annotations
2- from typing import Optional
2+ from typing import Optional , Union
33from LoopStructural .utils .exceptions import LoopValueError
44from LoopStructural .utils import rng
55import numpy as np
66
7+ from LoopStructural .utils .logging import getLogger
8+
9+ logger = getLogger (__name__ )
10+
711
812class BoundingBox :
913 def __init__ (
1014 self ,
1115 origin : Optional [np .ndarray ] = None ,
1216 maximum : Optional [np .ndarray ] = None ,
17+ global_origin : Optional [np .ndarray ] = None ,
1318 nsteps : Optional [np .ndarray ] = None ,
1419 step_vector : Optional [np .ndarray ] = None ,
15- dimensions : Optional [int ] = None ,
20+ dimensions : Optional [int ] = 3 ,
1621 ):
1722 """A bounding box for a model, defined by the
1823 origin, maximum and number of steps in each direction
@@ -28,19 +33,30 @@ def __init__(
2833 nsteps : Optional[np.ndarray], optional
2934 _description_, by default None
3035 """
36+ # reproject relative to the global origin, if origin is not provided.
37+ # we want the local coordinates to start at 0
38+ # otherwise uses provided origin. This is useful for having multiple bounding boxes rela
39+ if global_origin is not None and origin is None :
40+ origin = np .zeros (global_origin .shape )
3141 if maximum is None and nsteps is not None and step_vector is not None :
3242 maximum = origin + nsteps * step_vector
43+ if origin is not None and global_origin is None :
44+ global_origin = origin
3345 self ._origin = np .array (origin )
3446 self ._maximum = np .array (maximum )
35- if dimensions is None :
36- if self .origin is None :
37- raise LoopValueError ("Origin is not set" )
38- self .dimensions = len (self .origin )
39- print (self .dimensions )
47+ self .dimensions = dimensions
48+ if self .origin .shape :
49+ if self .origin .shape [0 ] != self .dimensions :
50+ logger .warning (
51+ f"Origin has { self .origin .shape [0 ]} dimensions but bounding box has { self .dimensions } "
52+ )
53+
4054 else :
4155 self .dimensions = dimensions
42- if nsteps is None :
43- self .nsteps = np .array ([50 , 50 , 25 ])
56+ self ._global_origin = global_origin
57+ self .nsteps = np .array ([50 , 50 , 25 ])
58+ if nsteps is not None :
59+ self .nsteps = np .array (nsteps )
4460 self .name_map = {
4561 "xmin" : (0 , 0 ),
4662 "ymin" : (0 , 1 ),
@@ -58,6 +74,22 @@ def __init__(
5874 "maxz" : (1 , 2 ),
5975 }
6076
77+ @property
78+ def global_origin (self ):
79+ return self ._global_origin
80+
81+ @global_origin .setter
82+ def global_origin (self , global_origin ):
83+ if self .dimensions != len (global_origin ):
84+ logger .warning (
85+ f"Global origin has { len (global_origin )} dimensions but bounding box has { self .dimensions } "
86+ )
87+ self ._global_origin = global_origin
88+
89+ @property
90+ def global_maximum (self ):
91+ return self .maximum - self .origin + self ._global_origin
92+
6193 @property
6294 def valid (self ):
6395 return self ._origin is not None and self ._maximum is not None
@@ -70,6 +102,10 @@ def origin(self) -> np.ndarray:
70102
71103 @origin .setter
72104 def origin (self , origin : np .ndarray ):
105+ if self .dimensions != len (origin ):
106+ logger .warning (
107+ f"Origin has { len (origin )} dimensions but bounding box has { self .dimensions } "
108+ )
73109 self ._origin = origin
74110
75111 @property
@@ -95,7 +131,17 @@ def bb(self):
95131 return np .array ([self .origin , self .maximum ])
96132
97133 @nelements .setter
98- def nelements (self , nelements ):
134+ def nelements (self , nelements : Union [int , float ]):
135+ """Update the number of elements in the associated grid
136+ This is for visualisation, not for the interpolation
137+ When set it will update the nsteps/step vector for cubic
138+ elements
139+
140+ Parameters
141+ ----------
142+ nelements : int,float
143+ The new number of elements
144+ """
99145 box_vol = self .volume
100146 ele_vol = box_vol / nelements
101147 # calculate the step vector of a regular cube
@@ -106,15 +152,17 @@ def nelements(self, nelements):
106152 self .nsteps = nsteps
107153
108154 @property
109- def corners (self ):
110- """Returns the corners of the bounding box
155+ def corners (self ) -> np .ndarray :
156+ """Returns the corners of the bounding box in local coordinates
157+
111158
112159
113160 Returns
114161 -------
115- _type_
116- _description_
162+ np.ndarray
163+ array of corners in clockwise order
117164 """
165+
118166 return np .array (
119167 [
120168 self .origin .tolist (),
@@ -128,6 +176,29 @@ def corners(self):
128176 ]
129177 )
130178
179+ @property
180+ def corners_global (self ) -> np .ndarray :
181+ """Returns the corners of the bounding box
182+ in the original space
183+
184+ Returns
185+ -------
186+ np.ndarray
187+ corners of the bounding box
188+ """
189+ return np .array (
190+ [
191+ self .global_origin .tolist (),
192+ [self .global_maximum [0 ], self .global_origin [1 ], self .global_origin [2 ]],
193+ [self .global_maximum [0 ], self .global_maximum [1 ], self .global_origin [2 ]],
194+ [self .global_origin [0 ], self .global_maximum [1 ], self .global_origin [2 ]],
195+ [self .global_origin [0 ], self .global_origin [1 ], self .global_maximum [2 ]],
196+ [self .global_maximum [0 ], self .global_origin [1 ], self .global_maximum [2 ]],
197+ self .global_maximum .tolist (),
198+ [self .global_origin [0 ], self .global_maximum [1 ], self .global_maximum [2 ]],
199+ ]
200+ )
201+
131202 @property
132203 def step_vector (self ):
133204 return (self .maximum - self .origin ) / self .nsteps
@@ -136,21 +207,69 @@ def step_vector(self):
136207 def length (self ):
137208 return self .maximum - self .origin
138209
139- def fit (self , locations : np .ndarray ):
210+ def fit (self , locations : np .ndarray , local_coordinate : bool = False ) -> BoundingBox :
211+ """Initialise the bounding box from a set of points.
212+
213+ Parameters
214+ ----------
215+ locations : np.ndarray
216+ xyz locations of the points to fit the bbox
217+ local_coordinate : bool, optional
218+ whether to set the origin to [0,0,0], by default False
219+
220+ Returns
221+ -------
222+ BoundingBox
223+ A reference to the bounding box object, note this is not a new bounding box
224+ it updates the current one in place.
225+
226+ Raises
227+ ------
228+ LoopValueError
229+ _description_
230+ """
140231 if locations .shape [1 ] != self .dimensions :
141232 raise LoopValueError (
142233 f"locations array is { locations .shape [1 ]} D but bounding box is { self .dimensions } "
143234 )
144- self .origin = locations .min (axis = 0 )
145- self .maximum = locations .max (axis = 0 )
235+ origin = locations .min (axis = 0 )
236+ maximum = locations .max (axis = 0 )
237+ if local_coordinate :
238+ self .global_origin = origin
239+ self .origin = np .zeros (3 )
240+ self .maximum = maximum - origin
241+ else :
242+ self .origin = origin
243+ self .maximum = maximum
244+ self .global_origin = np .zeros (3 )
146245 return self
147246
148247 def with_buffer (self , buffer : float = 0.2 ) -> BoundingBox :
248+ """Create a new bounding box with a buffer around the existing bounding box
249+
250+ Parameters
251+ ----------
252+ buffer : float, optional
253+ percentage to expand the dimensions by, by default 0.2
254+
255+ Returns
256+ -------
257+ BoundingBox
258+ The new bounding box object.
259+
260+ Raises
261+ ------
262+ LoopValueError
263+ if the current bounding box is invalid
264+ """
149265 if self .origin is None or self .maximum is None :
150266 raise LoopValueError ("Cannot create bounding box with buffer, no origin or maximum" )
267+ # local coordinates, rescale into the original bounding boxes global coordinates
151268 origin = self .origin - buffer * (self .maximum - self .origin )
152269 maximum = self .maximum + buffer * (self .maximum - self .origin )
153- return BoundingBox (origin = origin , maximum = maximum )
270+ return BoundingBox (
271+ origin = origin , maximum = maximum , global_origin = self .global_origin + origin
272+ )
154273
155274 def get_value (self , name ):
156275 ix , iy = self .name_map .get (name , (- 1 , - 1 ))
@@ -185,17 +304,110 @@ def is_inside(self, xyz):
185304 inside = np .logical_and (inside , xyz [:, 2 ] < self .maximum [2 ])
186305 return inside
187306
188- def regular_grid (self , nsteps = None , shuffle = False , order = "C" ):
307+ def regular_grid (
308+ self ,
309+ nsteps : Optional [Union [list , np .ndarray ]] = None ,
310+ shuffle : bool = False ,
311+ order : str = "C" ,
312+ local : bool = True ,
313+ ) -> np .ndarray :
314+ """Get the grid of points from the bounding box
315+
316+ Parameters
317+ ----------
318+ nsteps : Optional[Union[list, np.ndarray]], optional
319+ number of steps, by default None uses self.nsteps
320+ shuffle : bool, optional
321+ Whether to return points in order or random, by default False
322+ order : str, optional
323+ when flattening using numpy "C" or "F", by default "C"
324+ local : bool, optional
325+ Whether to return the points in the local coordinate system of global
326+ , by default True
327+
328+ Returns
329+ -------
330+ np.ndarray
331+ numpy array N,3 of the points
332+ """
333+
189334 if nsteps is None :
190335 nsteps = self .nsteps
191336 coordinates = [
192337 np .linspace (self .origin [i ], self .maximum [i ], nsteps [i ]) for i in range (self .dimensions )
193338 ]
194339
340+ if not local :
341+ coordinates = [
342+ np .linspace (self .global_origin [i ], self .global_maximum [i ], nsteps [i ])
343+ for i in range (self .dimensions )
344+ ]
195345 coordinate_grid = np .meshgrid (* coordinates , indexing = "ij" )
346+ locs = np .array ([coord .flatten (order = order ) for coord in coordinate_grid ]).T
196347
197- locs = np .array ([c .flatten (order = order ) for c in coordinate_grid ]).T
198348 if shuffle :
199349 # logger.info("Shuffling points")
200350 rng .shuffle (locs )
201351 return locs
352+
353+ def cell_centers (self , order : str = "F" ) -> np .ndarray :
354+ """Get the cell centers of a regular grid
355+
356+ Parameters
357+ ----------
358+ order : str, optional
359+ order of the grid, by default "C"
360+
361+ Returns
362+ -------
363+ np.ndarray
364+ array of cell centers
365+ """
366+ locs = self .regular_grid (order = order , nsteps = self .nsteps - 1 )
367+
368+ return locs + 0.5 * self .step_vector
369+
370+ def to_dict (self ) -> dict :
371+ """Export the defining characteristics of the bounding
372+ box to a dictionary for json serialisation
373+
374+ Returns
375+ -------
376+ dict
377+ dictionary with origin, maximum and nsteps
378+ """
379+ return {
380+ "origin" : self .origin .tolist (),
381+ "maximum" : self .maximum .tolist (),
382+ "nsteps" : self .nsteps .tolist (),
383+ }
384+
385+ def vtk (self ):
386+ """Export the model as a pyvista RectilinearGrid
387+
388+ Returns
389+ -------
390+ pv.RectilinearGrid
391+ a pyvista grid object
392+
393+ Raises
394+ ------
395+ ImportError
396+ If pyvista is not installed raise import error
397+ """
398+ try :
399+ import pyvista as pv
400+ except ImportError :
401+ raise ImportError ("pyvista is required for vtk support" )
402+ x = np .linspace (self .global_origin [0 ], self .global_maximum [0 ], self .nsteps [0 ])
403+ y = np .linspace (self .global_origin [1 ], self .global_maximum [1 ], self .nsteps [1 ])
404+ z = np .linspace (self .global_origin [2 ], self .global_maximum [2 ], self .nsteps [2 ])
405+ return pv .RectilinearGrid (
406+ x ,
407+ y ,
408+ z ,
409+ )
410+
411+ @property
412+ def structured_grid (self ):
413+ pass
0 commit comments