From b96417da9b474f3b0ee4d92950dc0932380db39d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Feb 2025 03:20:26 -0500 Subject: [PATCH 01/18] move viewport rect logic from subplot and docks to Figure --- fastplotlib/layouts/_figure.py | 334 ++++++++++++++++++++++----- fastplotlib/layouts/_imgui_figure.py | 17 +- fastplotlib/layouts/_plot_area.py | 27 --- fastplotlib/layouts/_subplot.py | 205 +--------------- fastplotlib/ui/_subplot_toolbar.py | 4 +- 5 files changed, 290 insertions(+), 297 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 70a4d41be..5e2d7042b 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -20,10 +20,14 @@ from .. import ImageGraphic +# number of pixels taken by the imgui toolbar when present +IMGUI_TOOLBAR_HEIGHT = 39 + + class Figure: def __init__( self, - shape: tuple[int, int] = (1, 1), + shape: list[tuple[int, int, int, int]] | tuple[int, int] = (1, 1), cameras: ( Literal["2d", "3d"] | Iterable[Iterable[Literal["2d", "3d"]]] @@ -97,15 +101,36 @@ def __init__( subplot names """ + if isinstance(shape, list): + if not all(isinstance(v, (tuple, list)) for v in shape): + raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + for item in shape: + if not all(isinstance(v, (int, np.integer)) for v in item): + raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + self._mode: str = "rect" + + elif isinstance(shape, tuple): + if not all(isinstance(v, (int, np.integer)) for v in shape): + raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + self._mode: str = "grid" + + # shape is [n_subplots, row_col_index] + self._subplot_grid_positions: dict[Subplot, tuple[int, int]] = dict() + + else: + raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + self._shape = shape + self.spacing = 10 + if names is not None: if len(list(chain(*names))) != len(self): raise ValueError( "must provide same number of subplot `names` as specified by Figure `shape`" ) - subplot_names = np.asarray(names).reshape(self.shape) + subplot_names = np.asarray(names) else: subplot_names = None @@ -115,18 +140,18 @@ def __init__( if isinstance(cameras, str): # create the array representing the views for each subplot in the grid - cameras = np.array([cameras] * len(self)).reshape(self.shape) + cameras = np.array([cameras] * len(self)) - # list -> array if necessary - cameras = np.asarray(cameras).reshape(self.shape) + # list/tuple -> array if necessary + cameras = np.asarray(cameras).flatten() - if cameras.shape != self.shape: - raise ValueError("Number of cameras does not match the number of subplots") + if cameras.size != len(self): + raise ValueError(f"Number of cameras: {cameras.size} does not match the number of subplots: {len(self)}") # create the cameras - subplot_cameras = np.empty(self.shape, dtype=object) - for i, j in product(range(self.shape[0]), range(self.shape[1])): - subplot_cameras[i, j] = create_camera(camera_type=cameras[i, j]) + subplot_cameras = np.empty(len(self), dtype=object) + for index in range(len(self)): + subplot_cameras[index] = create_camera(camera_type=cameras[index]) # if controller instances have been specified for each subplot if controllers is not None: @@ -134,8 +159,6 @@ def __init__( # one controller for all subplots if isinstance(controllers, pygfx.Controller): controllers = [controllers] * len(self) - # subplot_controllers[:] = controllers - # # subplot_controllers = np.asarray([controllers] * len(self), dtype=object) # individual controller instance specified for each subplot else: @@ -153,31 +176,31 @@ def __init__( ) try: - controllers = np.asarray(controllers).reshape(shape) + controllers = np.asarray(controllers).flatten() except ValueError: raise ValueError( f"number of controllers passed must be the same as the number of subplots specified " - f"by shape: {self.shape}. You have passed: <{controllers.size}> controllers" + f"by shape: {len(self)}. You have passed: {controllers.size} controllers" ) from None subplot_controllers: np.ndarray[pygfx.Controller] = np.empty( - self.shape, dtype=object + len(self), dtype=object ) - for i, j in product(range(self.shape[0]), range(self.shape[1])): - subplot_controllers[i, j] = controllers[i, j] - subplot_controllers[i, j].add_camera(subplot_cameras[i, j]) + for index in range(len(self)): + subplot_controllers[index] = controllers[index] + subplot_controllers[index].add_camera(subplot_cameras[index]) # parse controller_ids and controller_types to make desired controller for each supblot else: if controller_ids is None: # individual controller for each subplot - controller_ids = np.arange(len(self)).reshape(self.shape) + controller_ids = np.arange(len(self)) elif isinstance(controller_ids, str): if controller_ids == "sync": - # this will eventually make one controller for all subplots - controller_ids = np.zeros(self.shape, dtype=int) + # this will end up creating one controller to control the camera of every subplot + controller_ids = np.zeros(len(self), dtype=int) else: raise ValueError( f"`controller_ids` must be one of 'sync', an array/list of subplot names, or an array/list of " @@ -207,20 +230,20 @@ def __init__( ) # initialize controller_ids array - ids_init = np.arange(len(self)).reshape(self.shape) + ids_init = np.arange(len(self)) # set id based on subplot position for each synced sublist - for i, sublist in enumerate(controller_ids): + for row_ix, sublist in enumerate(controller_ids): for name in sublist: ids_init[subplot_names == name] = -( - i + 1 + row_ix + 1 ) # use negative numbers because why not controller_ids = ids_init # integer ids elif all([isinstance(item, (int, np.integer)) for item in ids_flat]): - controller_ids = np.asarray(controller_ids).reshape(self.shape) + controller_ids = np.asarray(controller_ids).flatten() else: raise TypeError( @@ -228,20 +251,20 @@ def __init__( f"you have passed: {controller_ids}" ) - if controller_ids.shape != self.shape: + if controller_ids.size != len(self): raise ValueError( "Number of controller_ids does not match the number of subplots" ) if controller_types is None: # `create_controller()` will auto-determine controller for each subplot based on defaults - controller_types = np.array(["default"] * len(self)).reshape(self.shape) + controller_types = np.array(["default"] * len(self)) # valid controller types if isinstance(controller_types, str): controller_types = [[controller_types]] - types_flat = list(chain(*controller_types)) + types_flat = np.asarray(controller_types).flatten() # str controller_type or pygfx instances valid_str = list(valid_controller_types.keys()) + ["default"] @@ -258,10 +281,10 @@ def __init__( controller_types: np.ndarray[pygfx.Controller] = np.asarray( controller_types - ).reshape(self.shape) + ) # make the real controllers for each subplot - subplot_controllers = np.empty(shape=self.shape, dtype=object) + subplot_controllers = np.empty(shape=len(self), dtype=object) for cid in np.unique(controller_ids): cont_type = controller_types[controller_ids == cid] if np.unique(cont_type).size > 1: @@ -292,32 +315,34 @@ def __init__( self._canvas = canvas self._renderer = renderer - nrows, ncols = self.shape + if self.mode == "grid": + nrows, ncols = self.shape - self._subplots: np.ndarray[Subplot] = np.ndarray( - shape=(nrows, ncols), dtype=object - ) + self._subplots: np.ndarray[Subplot] = np.ndarray( + shape=(nrows, ncols), dtype=object + ) - for i, j in self._get_iterator(): - position = (i, j) - camera = subplot_cameras[i, j] - controller = subplot_controllers[i, j] + for i, (row_ix, col_ix) in enumerate(product(range(nrows), range(ncols))): + camera = subplot_cameras[i] + controller = subplot_controllers[i] - if subplot_names is not None: - name = subplot_names[i, j] - else: - name = None - - self._subplots[i, j] = Subplot( - parent=self, - position=position, - parent_dims=(nrows, ncols), - camera=camera, - controller=controller, - canvas=canvas, - renderer=renderer, - name=name, - ) + if subplot_names is not None: + name = subplot_names[i] + else: + name = None + + subplot = Subplot( + parent=self, + camera=camera, + controller=controller, + canvas=canvas, + renderer=renderer, + name=name, + ) + + self._subplots[row_ix, col_ix] = subplot + + self._subplot_grid_positions[subplot] = (row_ix, col_ix) self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() @@ -328,11 +353,17 @@ def __init__( self._output = None + self.renderer.add_event_handler(self._set_viewport_rects, "resize") + @property - def shape(self) -> tuple[int, int]: + def shape(self) -> list[tuple[int, int, int, int]] | tuple[int, int]: """[n_rows, n_cols]""" return self._shape + @property + def mode(self) -> str: + return self._mode + @property def canvas(self) -> BaseRenderCanvas: """The canvas this Figure is drawn onto""" @@ -472,7 +503,7 @@ def show( elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas": # for test and docs gallery screenshots for subplot in self: - subplot.set_viewport_rect() + self._fpl_set_subplot_viewport_rect(subplot) subplot.axes.update_using_camera() # render call is blocking only on github actions for some reason, @@ -642,6 +673,190 @@ def export(self, uri: str | Path | bytes, **kwargs): def open_popup(self, *args, **kwargs): warn("popups only supported by ImguiFigure") + def _fpl_set_subplot_viewport_rect(self, subplot: Subplot): + """ + Sets the viewport rect for the given subplot + """ + + if self.mode == "grid": + row_ix, col_ix = self._subplot_grid_positions[subplot] + + nrows, ncols = self.shape + + x_start_render, y_start_render, width_canvas_render, height_canvas_render = ( + self.get_pygfx_render_area() + ) + + x_pos = ( + ( + (width_canvas_render / ncols) + + ((col_ix - 1) * (width_canvas_render / ncols)) + ) + + self.spacing + + x_start_render + ) + y_pos = ( + ( + (height_canvas_render / nrows) + + ((row_ix - 1) * (height_canvas_render / nrows)) + ) + + self.spacing + + y_start_render + ) + width_subplot = (width_canvas_render / ncols) - self.spacing + height_subplot = (height_canvas_render / nrows) - self.spacing + + if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: + # leave space for imgui toolbar + height_subplot -= IMGUI_TOOLBAR_HEIGHT + + # clip so that min values are always 1, otherwise JupyterRenderCanvas causes issues because it + # initializes with a width of (0, 0) + rect = np.array([x_pos, y_pos, width_subplot, height_subplot]).clip(1) + + for dock in subplot.docks.values(): + if dock.position == "right": + adjust = np.array( + [ + 0, # parent subplot x-position is same + 0, + -dock.size, # width of parent subplot is `self.size` smaller + 0, + ] + ) + + elif dock.position == "left": + adjust = np.array( + [ + dock.size, # `self.size` added to parent subplot x-position + 0, + -dock.size, # width of parent subplot is `self.size` smaller + 0, + ] + ) + + elif dock.position == "top": + adjust = np.array( + [ + 0, + dock.size, # `self.size` added to parent subplot y-position + 0, + -dock.size, # height of parent subplot is `self.size` smaller + ] + ) + + elif dock.position == "bottom": + adjust = np.array( + [ + 0, + 0, # parent subplot y-position is same, + 0, + -dock.size, # height of parent subplot is `self.size` smaller + ] + ) + + rect = rect + adjust + + subplot.viewport.rect = rect + + def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): + """ + Sets the viewport rect for the given subplot dock + """ + + if self.mode == "grid": + row_ix, col_ix = self._subplot_grid_positions[subplot] + + nrows, ncols = self.shape + + dock = subplot.docks[position] + + if dock.size == 0: + dock.viewport.rect = None + return + + x_start_render, y_start_render, width_render_canvas, height_render_canvas = ( + self.get_pygfx_render_area() + ) + + spacing = 2 # spacing in pixels + + if position == "right": + x_pos = ( + (width_render_canvas / ncols) + + ((col_ix - 1) * (width_render_canvas / ncols)) + + (width_render_canvas / ncols) + - dock.size + ) + y_pos = ( + (height_render_canvas / nrows) + + ((row_ix - 1) * (height_render_canvas / nrows)) + ) + spacing + width_viewport = dock.size + height_viewport = (height_render_canvas / nrows) - spacing + + elif position == "left": + x_pos = (width_render_canvas / ncols) + ( + (col_ix - 1) * (width_render_canvas / ncols) + ) + y_pos = ( + (height_render_canvas / nrows) + + ((row_ix - 1) * (height_render_canvas / nrows)) + ) + spacing + width_viewport = dock.size + height_viewport = (height_render_canvas / nrows) - spacing + + elif position == "top": + x_pos = ( + (width_render_canvas / ncols) + + ((col_ix - 1) * (width_render_canvas / ncols)) + + spacing + ) + y_pos = ( + (height_render_canvas / nrows) + + ((row_ix - 1) * (height_render_canvas / nrows)) + ) + spacing + width_viewport = (width_render_canvas / ncols) - spacing + height_viewport = dock.size + + elif position == "bottom": + x_pos = ( + (width_render_canvas / ncols) + + ((col_ix - 1) * (width_render_canvas / ncols)) + + spacing + ) + y_pos = ( + ( + (height_render_canvas / nrows) + + ((row_ix - 1) * (height_render_canvas / nrows)) + ) + + (height_render_canvas / nrows) + - dock.size + ) + width_viewport = (width_render_canvas / ncols) - spacing + height_viewport = dock.size + else: + raise ValueError("invalid position") + + if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: + # leave space for imgui toolbar + height_viewport -= IMGUI_TOOLBAR_HEIGHT + + rect = [ + x_pos + x_start_render, + y_pos + y_start_render, + width_viewport, + height_viewport, + ] + + dock.viewport.rect = rect + + def _set_viewport_rects(self): + for subplot in self: + self._fpl_set_subplot_viewport_rect(subplot) + for dock_pos in subplot.docks.keys(): + self._fpl_set_subplot_dock_viewport_rect(subplot, dock_pos) + def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ Fet rect for the portion of the canvas that the pygfx renderer draws to, @@ -659,7 +874,7 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: return 0, 0, width, height def _get_iterator(self): - return product(range(self.shape[0]), range(self.shape[1])) + return range(len(self)) def __iter__(self): self._current_iter = self._get_iterator() @@ -667,11 +882,14 @@ def __iter__(self): def __next__(self) -> Subplot: pos = self._current_iter.__next__() - return self._subplots[pos] + return self._subplots.ravel()[pos] def __len__(self): """number of subplots""" - return self.shape[0] * self.shape[1] + if isinstance(self._shape, tuple): + return self.shape[0] * self.shape[1] + if isinstance(self._shape, list): + return len(self._shape) def __str__(self): return f"{self.__class__.__name__} @ {hex(id(self))}" diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 8621f4464..a652683f5 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -80,12 +80,12 @@ def __init__( self.imgui_renderer.set_gui(self._draw_imgui) self._subplot_toolbars: np.ndarray[SubplotToolbar] = np.empty( - shape=self._subplots.shape, dtype=object + shape=self._subplots.size, dtype=object ) - for subplot in self._subplots.ravel(): + for i, subplot in enumerate(self._subplots.ravel()): toolbar = SubplotToolbar(subplot=subplot, fa_icons=self._fa_icons) - self._subplot_toolbars[subplot.position] = toolbar + self._subplot_toolbars[i] = toolbar self._right_click_menu = StandardRightClickMenu( figure=self, fa_icons=self._fa_icons @@ -164,7 +164,7 @@ def add_gui(self, gui: EdgeWindow): self.guis[location] = gui - self._reset_viewports() + self._set_viewport_rects() def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ @@ -200,15 +200,6 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: return xpos, ypos, max(1, width), max(1, height) - def _reset_viewports(self): - # TODO: think about moving this to Figure later, - # maybe also refactor Subplot and PlotArea so that - # the resize event is handled at the Figure level instead - for subplot in self: - subplot.set_viewport_rect() - for dock in subplot.docks.values(): - dock.set_viewport_rect() - def register_popup(self, popup: Popup.__class__): """ Register a popup class. Note that this takes the class, not an instance diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index e096a7f21..045aec8c1 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -28,7 +28,6 @@ class PlotArea: def __init__( self, parent: Union["PlotArea", "Figure"], - position: tuple[int, int] | str, camera: pygfx.PerspectiveCamera, controller: pygfx.Controller, scene: pygfx.Scene, @@ -70,7 +69,6 @@ def __init__( """ self._parent = parent - self._position = position self._scene = scene self._canvas = canvas @@ -88,8 +86,6 @@ def __init__( self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() - self.renderer.add_event_handler(self.set_viewport_rect, "resize") - # list of hex id strings for all graphics managed by this PlotArea # the real Graphic instances are managed by REFERENCES self._graphics: list[Graphic] = list() @@ -120,8 +116,6 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) - self.set_viewport_rect() - def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: @@ -141,11 +135,6 @@ def parent(self): """A parent if relevant""" return self._parent - @property - def position(self) -> tuple[int, int] | str: - """Position of this plot area within a larger layout (such as a Figure) if relevant""" - return self._position - @property def scene(self) -> pygfx.Scene: """The Scene where Graphics lie in this plot area""" @@ -284,19 +273,6 @@ def background_color(self, colors: str | tuple[float]): """1, 2, or 4 colors, each color must be acceptable by pygfx.Color""" self._background_material.set_colors(*colors) - def get_rect(self) -> tuple[float, float, float, float]: - """ - Returns the viewport rect to define the rectangle - occupied by the viewport w.r.t. the Canvas. - - If this is a subplot within a Figure, it returns the rectangle - for only this subplot w.r.t. the parent canvas. - - Must return: [x_pos, y_pos, width_viewport, height_viewport] - - """ - raise NotImplementedError("Must be implemented in subclass") - def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent ) -> np.ndarray: @@ -333,9 +309,6 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) - def set_viewport_rect(self, *args): - self.viewport.rect = self.get_rect() - def render(self): self._call_animate_functions(self._animate_funcs_pre) diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 7d52ebab2..3fe7e954e 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -21,8 +21,6 @@ class Subplot(PlotArea, GraphicMethodsMixin): def __init__( self, parent: Union["Figure"], - position: tuple[int, int], - parent_dims: tuple[int, int], camera: Literal["2d", "3d"] | pygfx.PerspectiveCamera, controller: pygfx.Controller, canvas: BaseRenderCanvas | pygfx.Texture, @@ -44,9 +42,6 @@ def __init__( position: (int, int), optional corresponds to the [row, column] position of the subplot within a ``Figure`` - parent_dims: (int, int), optional - dimensions of the parent ``Figure`` - camera: str or pygfx.PerspectiveCamera, default '2d' indicates the FOV for the camera, '2d' sets ``fov = 0``, '3d' sets ``fov = 50``. ``fov`` can be changed at any time. @@ -69,29 +64,18 @@ def __init__( super(GraphicMethodsMixin, self).__init__() - if position is None: - position = (0, 0) - - if parent_dims is None: - parent_dims = (1, 1) - - self.nrows, self.ncols = parent_dims - camera = create_camera(camera) controller = create_controller(controller_type=controller, camera=camera) self._docks = dict() - self.spacing = 2 - self._title_graphic: TextGraphic = None self._toolbar = True super(Subplot, self).__init__( parent=parent, - position=position, camera=camera, controller=controller, scene=pygfx.Scene(), @@ -148,7 +132,7 @@ def toolbar(self) -> bool: @toolbar.setter def toolbar(self, visible: bool): self._toolbar = bool(visible) - self.set_viewport_rect() + self.get_figure()._fpl_set_subplot_viewport_rect(self) def render(self): self.axes.update_using_camera() @@ -180,54 +164,6 @@ def center_title(self): self.docks["top"].center_graphic(self._title_graphic, zoom=1.5) self._title_graphic.world_object.position_y = -3.5 - def get_rect(self) -> np.ndarray: - """ - Returns the bounding box that defines the Subplot within the canvas. - - Returns - ------- - np.ndarray - x_position, y_position, width, height - - """ - row_ix, col_ix = self.position - - x_start_render, y_start_render, width_canvas_render, height_canvas_render = ( - self.parent.get_pygfx_render_area() - ) - - x_pos = ( - ( - (width_canvas_render / self.ncols) - + ((col_ix - 1) * (width_canvas_render / self.ncols)) - ) - + self.spacing - + x_start_render - ) - y_pos = ( - ( - (height_canvas_render / self.nrows) - + ((row_ix - 1) * (height_canvas_render / self.nrows)) - ) - + self.spacing - + y_start_render - ) - width_subplot = (width_canvas_render / self.ncols) - self.spacing - height_subplot = (height_canvas_render / self.nrows) - self.spacing - - if self.parent.__class__.__name__ == "ImguiFigure" and self.toolbar: - # leave space for imgui toolbar - height_subplot -= IMGUI_TOOLBAR_HEIGHT - - # clip so that min values are always 1, otherwise JupyterRenderCanvas causes issues because it - # initializes with a width of (0, 0) - rect = np.array([x_pos, y_pos, width_subplot, height_subplot]).clip(1) - - for dv in self.docks.values(): - rect = rect + dv.get_parent_rect_adjust() - - return rect - class Dock(PlotArea): _valid_positions = ["right", "left", "top", "bottom"] @@ -244,10 +180,10 @@ def __init__( ) self._size = size + self._position = position super().__init__( parent=parent, - position=position, camera=pygfx.OrthographicCamera(), controller=pygfx.PanZoomController(), scene=pygfx.Scene(), @@ -255,6 +191,10 @@ def __init__( renderer=parent.renderer, ) + @property + def position(self) -> str: + return self._position + @property def size(self) -> int: """Get or set the size of this dock""" @@ -263,138 +203,9 @@ def size(self) -> int: @size.setter def size(self, s: int): self._size = s - self.parent.set_viewport_rect() - self.set_viewport_rect() - def get_rect(self, *args): - """ - Returns the bounding box that defines this dock area within the canvas. - - Returns - ------- - np.ndarray - x_position, y_position, width, height - """ - if self.size == 0: - self.viewport.rect = None - return - - row_ix_parent, col_ix_parent = self.parent.position - - x_start_render, y_start_render, width_render_canvas, height_render_canvas = ( - self.parent.parent.get_pygfx_render_area() - ) - - spacing = 2 # spacing in pixels - - if self.position == "right": - x_pos = ( - (width_render_canvas / self.parent.ncols) - + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols)) - + (width_render_canvas / self.parent.ncols) - - self.size - ) - y_pos = ( - (height_render_canvas / self.parent.nrows) - + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows)) - ) + spacing - width_viewport = self.size - height_viewport = (height_render_canvas / self.parent.nrows) - spacing - - elif self.position == "left": - x_pos = (width_render_canvas / self.parent.ncols) + ( - (col_ix_parent - 1) * (width_render_canvas / self.parent.ncols) - ) - y_pos = ( - (height_render_canvas / self.parent.nrows) - + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows)) - ) + spacing - width_viewport = self.size - height_viewport = (height_render_canvas / self.parent.nrows) - spacing - - elif self.position == "top": - x_pos = ( - (width_render_canvas / self.parent.ncols) - + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols)) - + spacing - ) - y_pos = ( - (height_render_canvas / self.parent.nrows) - + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows)) - ) + spacing - width_viewport = (width_render_canvas / self.parent.ncols) - spacing - height_viewport = self.size - - elif self.position == "bottom": - x_pos = ( - (width_render_canvas / self.parent.ncols) - + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols)) - + spacing - ) - y_pos = ( - ( - (height_render_canvas / self.parent.nrows) - + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows)) - ) - + (height_render_canvas / self.parent.nrows) - - self.size - ) - width_viewport = (width_render_canvas / self.parent.ncols) - spacing - height_viewport = self.size - else: - raise ValueError("invalid position") - - if self.parent.__class__.__name__ == "ImguiFigure" and self.parent.toolbar: - # leave space for imgui toolbar - height_viewport -= IMGUI_TOOLBAR_HEIGHT - - return [ - x_pos + x_start_render, - y_pos + y_start_render, - width_viewport, - height_viewport, - ] - - def get_parent_rect_adjust(self): - if self.position == "right": - return np.array( - [ - 0, # parent subplot x-position is same - 0, - -self.size, # width of parent subplot is `self.size` smaller - 0, - ] - ) - - elif self.position == "left": - return np.array( - [ - self.size, # `self.size` added to parent subplot x-position - 0, - -self.size, # width of parent subplot is `self.size` smaller - 0, - ] - ) - - elif self.position == "top": - return np.array( - [ - 0, - self.size, # `self.size` added to parent subplot y-position - 0, - -self.size, # height of parent subplot is `self.size` smaller - ] - ) - - elif self.position == "bottom": - return np.array( - [ - 0, - 0, # parent subplot y-position is same, - 0, - -self.size, # height of parent subplot is `self.size` smaller - ] - ) + self.get_figure(self.parent)._fpl_set_subplot_viewport_rect(self.parent) + self.get_figure(self.parent)._fpl_set_subplot_dock_viewport_rect(self.parent, self._position, self.size) def render(self): if self.size == 0: diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index 6c1a81f73..abcd71102 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -25,14 +25,14 @@ def update(self): imgui.set_next_window_pos(pos) flags = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar - imgui.begin(f"Toolbar-{self._subplot.position}", p_open=None, flags=flags) + imgui.begin(f"Toolbar-{self._subplot.name}", p_open=None, flags=flags) # icons for buttons imgui.push_font(self._fa_icons) # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) - with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.position}"): + with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.name}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() From 6c53af67678197224fb40c2d5a3973b7826b78c7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 10 Feb 2025 03:42:13 -0500 Subject: [PATCH 02/18] progress --- fastplotlib/graphics/_axes.py | 2 +- fastplotlib/layouts/_figure.py | 38 +++++++++++++----------------- fastplotlib/ui/_subplot_toolbar.py | 2 +- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py index 9541dceeb..4938b1a97 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/graphics/_axes.py @@ -516,7 +516,7 @@ def update_using_camera(self): return if self._plot_area.camera.fov == 0: - xpos, ypos, width, height = self._plot_area.get_rect() + xpos, ypos, width, height = self._plot_area.viewport.rect # orthographic projection, get ranges using inverse # get range of screen space by getting the corners diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 5e2d7042b..ae1169154 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -55,8 +55,8 @@ def __init__( Parameters ---------- - shape: (int, int), default (1, 1) - (n_rows, n_cols) + shape: list[tuple[int, int, int, int]] | tuple[int, int], default (1, 1) + list of bounding boxes: [x, y, width, height], or a grid of shape [n_rows, n_cols] cameras: "2d", "3", list of "2d" | "3d", Iterable of camera instances, or Iterable of "2d" | "3d", optional | if str, one of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots @@ -122,7 +122,7 @@ def __init__( self._shape = shape - self.spacing = 10 + self.spacing = 0 if names is not None: if len(list(chain(*names))) != len(self): @@ -703,8 +703,8 @@ def _fpl_set_subplot_viewport_rect(self, subplot: Subplot): + self.spacing + y_start_render ) - width_subplot = (width_canvas_render / ncols) - self.spacing - height_subplot = (height_canvas_render / nrows) - self.spacing + width_subplot = (width_canvas_render / ncols) - (self.spacing * 2) + height_subplot = (height_canvas_render / nrows) - (self.spacing * 2) if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: # leave space for imgui toolbar @@ -779,8 +779,6 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): self.get_pygfx_render_area() ) - spacing = 2 # spacing in pixels - if position == "right": x_pos = ( (width_render_canvas / ncols) @@ -791,9 +789,9 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): y_pos = ( (height_render_canvas / nrows) + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + spacing + ) + self.spacing width_viewport = dock.size - height_viewport = (height_render_canvas / nrows) - spacing + height_viewport = (height_render_canvas / nrows) - self.spacing elif position == "left": x_pos = (width_render_canvas / ncols) + ( @@ -802,28 +800,28 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): y_pos = ( (height_render_canvas / nrows) + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + spacing + ) + self.spacing width_viewport = dock.size - height_viewport = (height_render_canvas / nrows) - spacing + height_viewport = (height_render_canvas / nrows) - self.spacing elif position == "top": x_pos = ( (width_render_canvas / ncols) + ((col_ix - 1) * (width_render_canvas / ncols)) - + spacing + + self.spacing ) y_pos = ( (height_render_canvas / nrows) + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + spacing - width_viewport = (width_render_canvas / ncols) - spacing + ) + self.spacing + width_viewport = (width_render_canvas / ncols) - self.spacing height_viewport = dock.size elif position == "bottom": x_pos = ( (width_render_canvas / ncols) + ((col_ix - 1) * (width_render_canvas / ncols)) - + spacing + + self.spacing ) y_pos = ( ( @@ -833,7 +831,7 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): + (height_render_canvas / nrows) - dock.size ) - width_viewport = (width_render_canvas / ncols) - spacing + width_viewport = (width_render_canvas / ncols) - self.spacing height_viewport = dock.size else: raise ValueError("invalid position") @@ -851,7 +849,8 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): dock.viewport.rect = rect - def _set_viewport_rects(self): + def _set_viewport_rects(self, *ev): + """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" for subplot in self: self._fpl_set_subplot_viewport_rect(subplot) for dock_pos in subplot.docks.keys(): @@ -873,11 +872,8 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: return 0, 0, width, height - def _get_iterator(self): - return range(len(self)) - def __iter__(self): - self._current_iter = self._get_iterator() + self._current_iter = iter(range(len(self))) return self def __next__(self) -> Subplot: diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index abcd71102..1a3058b8e 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -16,7 +16,7 @@ def __init__(self, subplot: Subplot, fa_icons: imgui.ImFont): def update(self): # get subplot rect - x, y, width, height = self._subplot.get_rect() + x, y, width, height = self._subplot.viewport.rect # place the toolbar window below the subplot pos = (x, y + height) From d620d5696bb9ba14119251bb81df06e26ca26ec8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Feb 2025 02:56:00 -0500 Subject: [PATCH 03/18] refactored rect code works well --- fastplotlib/layouts/_figure.py | 276 ++++++++++++--------------- fastplotlib/layouts/_imgui_figure.py | 4 +- fastplotlib/layouts/_plot_area.py | 4 +- fastplotlib/layouts/_subplot.py | 30 +-- fastplotlib/ui/_subplot_toolbar.py | 5 +- 5 files changed, 144 insertions(+), 175 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index ae1169154..ed8da572d 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -56,7 +56,7 @@ def __init__( Parameters ---------- shape: list[tuple[int, int, int, int]] | tuple[int, int], default (1, 1) - list of bounding boxes: [x, y, width, height], or a grid of shape [n_rows, n_cols] + grid of shape [n_rows, n_cols] or list of bounding boxes: [x, y, width, height] (NOT YET IMPLEMENTED) cameras: "2d", "3", list of "2d" | "3d", Iterable of camera instances, or Iterable of "2d" | "3d", optional | if str, one of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots @@ -102,6 +102,7 @@ def __init__( """ if isinstance(shape, list): + raise NotImplementedError("bounding boxes for shape not yet implemented") if not all(isinstance(v, (tuple, list)) for v in shape): raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") for item in shape: @@ -122,15 +123,14 @@ def __init__( self._shape = shape - self.spacing = 0 + self._spacing = 2 if names is not None: - if len(list(chain(*names))) != len(self): + subplot_names = np.asarray(names) + if subplot_names.size != len(self): raise ValueError( "must provide same number of subplot `names` as specified by Figure `shape`" ) - - subplot_names = np.asarray(names) else: subplot_names = None @@ -138,6 +138,8 @@ def __init__( canvas, renderer, canvas_kwargs={"size": size} ) + renderer.add_event_handler(self._set_viewport_rects, "resize") + if isinstance(cameras, str): # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * len(self)) @@ -353,7 +355,7 @@ def __init__( self._output = None - self.renderer.add_event_handler(self._set_viewport_rects, "resize") + self._pause_render = False @property def shape(self) -> list[tuple[int, int, int, int]] | tuple[int, int]: @@ -362,8 +364,21 @@ def shape(self) -> list[tuple[int, int, int, int]] | tuple[int, int]: @property def mode(self) -> str: + """one of 'grid' or 'rect'""" return self._mode + @property + def spacing(self) -> int: + return self._spacing + + @spacing.setter + def spacing(self, value: int): + if not isinstance(value, (int, np.integer)): + raise TypeError("spacing must be of type ") + + self._spacing = value + self._set_viewport_rects() + @property def canvas(self) -> BaseRenderCanvas: """The canvas this Figure is drawn onto""" @@ -399,21 +414,27 @@ def names(self) -> np.ndarray[str]: names.flags.writeable = False return names - def __getitem__(self, index: tuple[int, int] | str) -> Subplot: + def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot: if isinstance(index, str): for subplot in self._subplots.ravel(): if subplot.name == index: return subplot raise IndexError(f"no subplot with given name: {index}") - else: + + if self.mode == "grid": return self._subplots[index[0], index[1]] - def render(self, draw=True): + return self._subplots[index] + + def _render(self, draw=True): + if self._pause_render: + return + # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) - + self._set_viewport_rects() for subplot in self: - subplot.render() + subplot._render() self.renderer.flush() if draw: @@ -422,9 +443,9 @@ def render(self, draw=True): # call post-render animate functions self._call_animate_functions(self._animate_funcs_post) - def start_render(self): + def _start_render(self): """start render cycle""" - self.canvas.request_draw(self.render) + self.canvas.request_draw(self._render) def show( self, @@ -462,7 +483,7 @@ def show( if self._output: return self._output - self.start_render() + self._start_render() if sidecar_kwargs is None: sidecar_kwargs = dict() @@ -502,8 +523,8 @@ def show( elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas": # for test and docs gallery screenshots + self._set_viewport_rects() for subplot in self: - self._fpl_set_subplot_viewport_rect(subplot) subplot.axes.update_using_camera() # render call is blocking only on github actions for some reason, @@ -512,7 +533,7 @@ def show( # but it is necessary for the gallery images too so that's why this check is here if "RTD_BUILD" in os.environ.keys(): if os.environ["RTD_BUILD"] == "1": - self.render() + self._render() else: # assume GLFW self._output = self.canvas @@ -679,182 +700,123 @@ def _fpl_set_subplot_viewport_rect(self, subplot: Subplot): """ if self.mode == "grid": + # row, col position of this subplot within the grid row_ix, col_ix = self._subplot_grid_positions[subplot] + # number of rows, cols in the grid nrows, ncols = self.shape - x_start_render, y_start_render, width_canvas_render, height_canvas_render = ( + # get starting positions and dimensions for the pygfx portion of the canvas + # anything outside the pygfx portion of the canvas is for imgui + x0_canvas, y0_canvas, width_canvas, height_canvas = ( self.get_pygfx_render_area() ) - x_pos = ( - ( - (width_canvas_render / ncols) - + ((col_ix - 1) * (width_canvas_render / ncols)) - ) - + self.spacing - + x_start_render - ) - y_pos = ( - ( - (height_canvas_render / nrows) - + ((row_ix - 1) * (height_canvas_render / nrows)) - ) - + self.spacing - + y_start_render - ) - width_subplot = (width_canvas_render / ncols) - (self.spacing * 2) - height_subplot = (height_canvas_render / nrows) - (self.spacing * 2) + # width of an individual subplot + width_subplot = width_canvas / ncols + # height of an individual subplot + height_subplot = height_canvas / nrows + + # x position of this subplot + x_pos = ((col_ix - 1) * width_subplot) + width_subplot + x0_canvas + self.spacing + # y position of this subplot + y_pos = ((row_ix - 1) * height_subplot) + height_subplot + y0_canvas + self.spacing if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: - # leave space for imgui toolbar + # leave space for imgui toolbar height_subplot -= IMGUI_TOOLBAR_HEIGHT - # clip so that min values are always 1, otherwise JupyterRenderCanvas causes issues because it - # initializes with a width of (0, 0) - rect = np.array([x_pos, y_pos, width_subplot, height_subplot]).clip(1) - - for dock in subplot.docks.values(): - if dock.position == "right": - adjust = np.array( - [ - 0, # parent subplot x-position is same - 0, - -dock.size, # width of parent subplot is `self.size` smaller - 0, - ] - ) - - elif dock.position == "left": - adjust = np.array( - [ - dock.size, # `self.size` added to parent subplot x-position - 0, - -dock.size, # width of parent subplot is `self.size` smaller - 0, - ] - ) - - elif dock.position == "top": - adjust = np.array( - [ - 0, - dock.size, # `self.size` added to parent subplot y-position - 0, - -dock.size, # height of parent subplot is `self.size` smaller - ] - ) - - elif dock.position == "bottom": - adjust = np.array( - [ - 0, - 0, # parent subplot y-position is same, - 0, - -dock.size, # height of parent subplot is `self.size` smaller - ] - ) - - rect = rect + adjust - - subplot.viewport.rect = rect + # clip so that min (w, h) is always 1, otherwise JupyterRenderCanvas causes issues because it + # initializes with a width, height of (0, 0) + rect = np.array([ + x_pos, y_pos, width_subplot - self.spacing, height_subplot - self.spacing + ]).clip(min=[0, 0, 1, 1]) + + # adjust if a subplot dock is present + adjust = np.array([ + # add left dock size to x_pos + subplot.docks["left"].size, + # add top dock size to y_pos + subplot.docks["top"].size, + # remove left and right dock sizes from width + -subplot.docks["right"].size - subplot.docks["left"].size, + # remove top and bottom dock sizes from height + -subplot.docks["top"].size - subplot.docks["bottom"].size, + ]) + + subplot.viewport.rect = rect + adjust def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): """ Sets the viewport rect for the given subplot dock """ + dock = subplot.docks[position] + + if dock.size == 0: + dock.viewport.rect = None + return + if self.mode == "grid": + # row, col position of this subplot within the grid row_ix, col_ix = self._subplot_grid_positions[subplot] + # number of rows, cols in the grid nrows, ncols = self.shape - dock = subplot.docks[position] - - if dock.size == 0: - dock.viewport.rect = None - return - - x_start_render, y_start_render, width_render_canvas, height_render_canvas = ( + x0_canvas, y0_canvas, width_canvas, height_canvas = ( self.get_pygfx_render_area() ) - if position == "right": - x_pos = ( - (width_render_canvas / ncols) - + ((col_ix - 1) * (width_render_canvas / ncols)) - + (width_render_canvas / ncols) - - dock.size - ) - y_pos = ( - (height_render_canvas / nrows) - + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + self.spacing - width_viewport = dock.size - height_viewport = (height_render_canvas / nrows) - self.spacing - - elif position == "left": - x_pos = (width_render_canvas / ncols) + ( - (col_ix - 1) * (width_render_canvas / ncols) - ) - y_pos = ( - (height_render_canvas / nrows) - + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + self.spacing - width_viewport = dock.size - height_viewport = (height_render_canvas / nrows) - self.spacing - - elif position == "top": - x_pos = ( - (width_render_canvas / ncols) - + ((col_ix - 1) * (width_render_canvas / ncols)) - + self.spacing - ) - y_pos = ( - (height_render_canvas / nrows) - + ((row_ix - 1) * (height_render_canvas / nrows)) - ) + self.spacing - width_viewport = (width_render_canvas / ncols) - self.spacing - height_viewport = dock.size - - elif position == "bottom": - x_pos = ( - (width_render_canvas / ncols) - + ((col_ix - 1) * (width_render_canvas / ncols)) - + self.spacing - ) - y_pos = ( - ( - (height_render_canvas / nrows) - + ((row_ix - 1) * (height_render_canvas / nrows)) - ) - + (height_render_canvas / nrows) - - dock.size - ) - width_viewport = (width_render_canvas / ncols) - self.spacing - height_viewport = dock.size - else: - raise ValueError("invalid position") - - if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: - # leave space for imgui toolbar - height_viewport -= IMGUI_TOOLBAR_HEIGHT - - rect = [ - x_pos + x_start_render, - y_pos + y_start_render, + # width of an individual subplot + width_subplot = (width_canvas / ncols) + # height of an individual subplot + height_subplot = (height_canvas / nrows) + + # calculate the rect based on the dock position + match position: + case "right": + x_pos = ((col_ix - 1) * width_subplot) + (width_subplot * 2) - dock.size + y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + width_viewport = dock.size + height_viewport = height_subplot - self.spacing + + case "left": + x_pos = ((col_ix - 1) * width_subplot) + width_subplot + y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + width_viewport = dock.size + height_viewport = height_subplot - self.spacing + + case "top": + x_pos = ((col_ix - 1) * width_subplot) + width_subplot + self.spacing + y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + width_viewport = width_subplot - self.spacing + height_viewport = dock.size + + case "bottom": + x_pos = ((col_ix - 1) * width_subplot) + width_subplot + self.spacing + y_pos = ((row_ix - 1) * height_subplot) + (height_subplot * 2) - dock.size + width_viewport = width_subplot - self.spacing + height_viewport = dock.size + + case _: + raise ValueError("invalid position") + + dock.viewport.rect = [ + x_pos + x0_canvas, + y_pos + y0_canvas, width_viewport, height_viewport, ] - dock.viewport.rect = rect - def _set_viewport_rects(self, *ev): """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" + self._pause_render = True for subplot in self: self._fpl_set_subplot_viewport_rect(subplot) for dock_pos in subplot.docks.keys(): self._fpl_set_subplot_dock_viewport_rect(subplot, dock_pos) + self._pause_render = False def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index a652683f5..4b46c2a06 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -105,8 +105,8 @@ def imgui_renderer(self) -> ImguiRenderer: """imgui renderer""" return self._imgui_renderer - def render(self, draw=False): - super().render(draw) + def _render(self, draw=False): + super()._render(draw) self.imgui_renderer.render() self.canvas.request_draw() diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 045aec8c1..46ee59b1f 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -309,14 +309,14 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) - def render(self): + def _render(self): self._call_animate_functions(self._animate_funcs_pre) # does not flush, flush must be implemented in user-facing Plot objects self.viewport.render(self.scene, self.camera) for child in self.children: - child.render() + child._render() self._call_animate_functions(self._animate_funcs_post) diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 3fe7e954e..990d969e4 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -1,7 +1,5 @@ from typing import Literal, Union -import numpy as np - import pygfx from rendercanvas import BaseRenderCanvas @@ -13,10 +11,6 @@ from ..graphics._axes import Axes -# number of pixels taken by the imgui toolbar when present -IMGUI_TOOLBAR_HEIGHT = 39 - - class Subplot(PlotArea, GraphicMethodsMixin): def __init__( self, @@ -106,8 +100,17 @@ def name(self) -> str: @name.setter def name(self, name: str): + if name is None: + self._name = None + return + + for subplot in self.get_figure(self): + if (subplot is self) or (subplot is None): + continue + if subplot.name == name: + raise ValueError("subplot names must be unique") + self._name = name - self.set_title(name) @property def docks(self) -> dict: @@ -134,9 +137,9 @@ def toolbar(self, visible: bool): self._toolbar = bool(visible) self.get_figure()._fpl_set_subplot_viewport_rect(self) - def render(self): + def _render(self): self.axes.update_using_camera() - super().render() + super()._render() def set_title(self, text: str): """Sets the plot title, stored as a ``TextGraphic`` in the "top" dock area""" @@ -203,12 +206,15 @@ def size(self) -> int: @size.setter def size(self, s: int): self._size = s + if self.position == "top": + # TODO: treat title dock separately, do not allow user to change viewport stuff + return self.get_figure(self.parent)._fpl_set_subplot_viewport_rect(self.parent) - self.get_figure(self.parent)._fpl_set_subplot_dock_viewport_rect(self.parent, self._position, self.size) + self.get_figure(self.parent)._fpl_set_subplot_dock_viewport_rect(self.parent, self._position) - def render(self): + def _render(self): if self.size == 0: return - super().render() + super()._render() diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py index 1a3058b8e..7d183bf6d 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -17,6 +17,7 @@ def __init__(self, subplot: Subplot, fa_icons: imgui.ImFont): def update(self): # get subplot rect x, y, width, height = self._subplot.viewport.rect + y += self._subplot.docks["bottom"].size # place the toolbar window below the subplot pos = (x, y + height) @@ -25,14 +26,14 @@ def update(self): imgui.set_next_window_pos(pos) flags = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar - imgui.begin(f"Toolbar-{self._subplot.name}", p_open=None, flags=flags) + imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags) # icons for buttons imgui.push_font(self._fa_icons) # push ID to prevent conflict between multiple figs with same UI imgui.push_id(self._id_counter) - with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.name}"): + with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() From 372ca7c212d2648305434b989a7287d41a3dfe80 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Feb 2025 02:56:27 -0500 Subject: [PATCH 04/18] add tests for viewport rects --- examples/gridplot/gridplot_viewports_test.py | 37 +++++++++++++++++++ .../image_widget_viewports_test.py | 35 ++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 examples/gridplot/gridplot_viewports_test.py create mode 100644 examples/image_widget/image_widget_viewports_test.py diff --git a/examples/gridplot/gridplot_viewports_test.py b/examples/gridplot/gridplot_viewports_test.py new file mode 100644 index 000000000..99584b411 --- /dev/null +++ b/examples/gridplot/gridplot_viewports_test.py @@ -0,0 +1,37 @@ +""" +GridPlot test viewport rects +============================ + +Test figure to test that viewport rects are positioned correctly +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure( + shape=(2, 3), + size=(700, 560), + names=list(map(str, range(6))) +) + +np.random.seed(0) +a = np.random.rand(6, 10, 10) + +for data, subplot in zip(a, figure): + subplot.add_image(data) + subplot.docks["left"].size = 20 + subplot.docks["right"].size = 30 + subplot.docks["bottom"].size = 40 + +figure.show() + + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_widget/image_widget_viewports_test.py b/examples/image_widget/image_widget_viewports_test.py new file mode 100644 index 000000000..486112c5c --- /dev/null +++ b/examples/image_widget/image_widget_viewports_test.py @@ -0,0 +1,35 @@ +""" +ImageWidget test viewport rects +=============================== + +Test figure to test that viewport rects are positioned correctly in an image widget +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'hidden' + +import fastplotlib as fpl +import numpy as np + +np.random.seed(0) +a = np.random.rand(6, 15, 10, 10) + +iw = fpl.ImageWidget( + data=[img for img in a], + names=list(map(str, range(6))), + figure_kwargs={"size": (700, 560)}, +) + +for subplot in iw.figure: + subplot.docks["left"].size = 10 + subplot.docks["bottom"].size = 40 + +iw.show() + +figure = iw.figure + +# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively +# please see our docs for using fastplotlib interactively in ipython and jupyter +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() From 8aaa06a39c71ff9e3ad5e8a66cc96323dad0eaf1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 00:48:24 -0500 Subject: [PATCH 05/18] figure refactor works, tested backend and passes --- fastplotlib/layouts/_figure.py | 59 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index ed8da572d..78c30431d 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -73,7 +73,6 @@ def __init__( controller_ids: str, list of int, np.ndarray of int, or list with sublists of subplot str names, optional | If `None` a unique controller is created for each subplot | If "sync" all the subplots use the same controller - | If array/list it must be reshapeable to ``grid_shape``. This allows custom assignment of controllers @@ -126,7 +125,7 @@ def __init__( self._spacing = 2 if names is not None: - subplot_names = np.asarray(names) + subplot_names = np.asarray(names).flatten() if subplot_names.size != len(self): raise ValueError( "must provide same number of subplot `names` as specified by Figure `shape`" @@ -138,7 +137,7 @@ def __init__( canvas, renderer, canvas_kwargs={"size": size} ) - renderer.add_event_handler(self._set_viewport_rects, "resize") + canvas.add_event_handler(self._set_viewport_rects, "resize") if isinstance(cameras, str): # create the array representing the views for each subplot in the grid @@ -157,7 +156,6 @@ def __init__( # if controller instances have been specified for each subplot if controllers is not None: - # one controller for all subplots if isinstance(controllers, pygfx.Controller): controllers = [controllers] * len(self) @@ -177,23 +175,17 @@ def __init__( "pygfx.Controller instances" ) - try: - controllers = np.asarray(controllers).flatten() - except ValueError: + subplot_controllers: np.ndarray[pygfx.Controller] = np.asarray(controllers).flatten() + if not subplot_controllers.size == len(self): raise ValueError( f"number of controllers passed must be the same as the number of subplots specified " - f"by shape: {len(self)}. You have passed: {controllers.size} controllers" + f"by shape: {len(self)}. You have passed: {subplot_controllers.size} controllers" ) from None - subplot_controllers: np.ndarray[pygfx.Controller] = np.empty( - len(self), dtype=object - ) - for index in range(len(self)): - subplot_controllers[index] = controllers[index] subplot_controllers[index].add_camera(subplot_cameras[index]) - # parse controller_ids and controller_types to make desired controller for each supblot + # parse controller_ids and controller_types to make desired controller for each subplot else: if controller_ids is None: # individual controller for each subplot @@ -239,13 +231,17 @@ def __init__( for name in sublist: ids_init[subplot_names == name] = -( row_ix + 1 - ) # use negative numbers because why not + ) # use negative numbers to avoid collision with positive numbers from np.arange controller_ids = ids_init # integer ids elif all([isinstance(item, (int, np.integer)) for item in ids_flat]): controller_ids = np.asarray(controller_ids).flatten() + if controller_ids.max() < 0: + raise ValueError( + "if passing an integer array of `controller_ids`, all the integers must be positive." + ) else: raise TypeError( @@ -264,14 +260,14 @@ def __init__( # valid controller types if isinstance(controller_types, str): - controller_types = [[controller_types]] + controller_types = np.array([controller_types] * len(self)) - types_flat = np.asarray(controller_types).flatten() + controller_types: np.ndarray[pygfx.Controller] = np.asarray(controller_types).flatten() # str controller_type or pygfx instances valid_str = list(valid_controller_types.keys()) + ["default"] # make sure each controller type is valid - for controller_type in types_flat: + for controller_type in controller_types: if controller_type is None: continue @@ -281,10 +277,6 @@ def __init__( f"Valid `controller_types` arguments are:\n {valid_str}" ) - controller_types: np.ndarray[pygfx.Controller] = np.asarray( - controller_types - ) - # make the real controllers for each subplot subplot_controllers = np.empty(shape=len(self), dtype=object) for cid in np.unique(controller_ids): @@ -394,7 +386,11 @@ def controllers(self) -> np.ndarray[pygfx.Controller]: """controllers, read-only array, access individual subplots to change a controller""" controllers = np.asarray( [subplot.controller for subplot in self], dtype=object - ).reshape(self.shape) + ) + + if self.mode == "grid": + controllers = controllers.reshape(self.shape) + controllers.flags.writeable = False return controllers @@ -403,14 +399,22 @@ def cameras(self) -> np.ndarray[pygfx.Camera]: """cameras, read-only array, access individual subplots to change a camera""" cameras = np.asarray( [subplot.camera for subplot in self], dtype=object - ).reshape(self.shape) + ) + + if self.mode == "grid": + cameras = cameras.reshape(self.shape) + cameras.flags.writeable = False return cameras @property def names(self) -> np.ndarray[str]: """subplot names, read-only array, access individual subplots to change a name""" - names = np.asarray([subplot.name for subplot in self]).reshape(self.shape) + names = np.asarray([subplot.name for subplot in self]) + + if self.mode == "grid": + names = names.reshape(self.shape) + names.flags.writeable = False return names @@ -432,13 +436,10 @@ def _render(self, draw=True): # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) - self._set_viewport_rects() for subplot in self: subplot._render() self.renderer.flush() - if draw: - self.canvas.request_draw() # call post-render animate functions self._call_animate_functions(self._animate_funcs_post) @@ -811,12 +812,10 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): def _set_viewport_rects(self, *ev): """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event""" - self._pause_render = True for subplot in self: self._fpl_set_subplot_viewport_rect(subplot) for dock_pos in subplot.docks.keys(): self._fpl_set_subplot_dock_viewport_rect(subplot, dock_pos) - self._pause_render = False def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ From cf0c470d296dddf5382daa4e64a818a664ede58e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 00:49:04 -0500 Subject: [PATCH 06/18] cleanup iw --- fastplotlib/widgets/image_widget/_widget.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 31a8176e5..0fbc02be3 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -347,8 +347,6 @@ def __init__( """ self._initialized = False - self._names = None - if figure_kwargs is None: figure_kwargs = dict() @@ -425,7 +423,6 @@ def __init__( raise ValueError( "number of `names` for subplots must be same as the number of data arrays" ) - self._names = names else: raise TypeError( @@ -496,7 +493,7 @@ def __init__( self._dims_max_bounds[_dim], array.shape[i] ) - figure_kwargs_default = {"controller_ids": "sync"} + figure_kwargs_default = {"controller_ids": "sync", "names": names} # update the default kwargs with any user-specified kwargs # user specified kwargs will overwrite the defaults @@ -518,10 +515,6 @@ def __init__( self._histogram_widget = histogram_widget for data_ix, (d, subplot) in enumerate(zip(self.data, self.figure)): - if self._names is not None: - name = self._names[data_ix] - else: - name = None frame = self._process_indices(d, slice_indices=self._current_index) frame = self._process_frame_apply(frame, data_ix) @@ -554,8 +547,6 @@ def __init__( **graphic_kwargs, ) subplot.add_graphic(ig) - subplot.name = name - subplot.set_title(name) if self._histogram_widget: hlut = HistogramLUTTool(data=d, image_graphic=ig, name="histogram_lut") From 54b0a7d7b35e2dac9e337d461641454212f52489 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:09:05 -0500 Subject: [PATCH 07/18] update tests --- ...dplot_viewports_test.py => gridplot_viewports_check.py} | 0 ...t_viewports_test.py => image_widget_viewports_check.py} | 2 +- examples/tests/test_examples.py | 7 +++---- 3 files changed, 4 insertions(+), 5 deletions(-) rename examples/gridplot/{gridplot_viewports_test.py => gridplot_viewports_check.py} (100%) rename examples/image_widget/{image_widget_viewports_test.py => image_widget_viewports_check.py} (92%) diff --git a/examples/gridplot/gridplot_viewports_test.py b/examples/gridplot/gridplot_viewports_check.py similarity index 100% rename from examples/gridplot/gridplot_viewports_test.py rename to examples/gridplot/gridplot_viewports_check.py diff --git a/examples/image_widget/image_widget_viewports_test.py b/examples/image_widget/image_widget_viewports_check.py similarity index 92% rename from examples/image_widget/image_widget_viewports_test.py rename to examples/image_widget/image_widget_viewports_check.py index 486112c5c..057134341 100644 --- a/examples/image_widget/image_widget_viewports_test.py +++ b/examples/image_widget/image_widget_viewports_check.py @@ -2,7 +2,7 @@ ImageWidget test viewport rects =============================== -Test figure to test that viewport rects are positioned correctly in an image widget +Test Figure to test that viewport rects are positioned correctly in an image widget """ # test_example = true diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py index 67519187b..d5f3e8ab9 100644 --- a/examples/tests/test_examples.py +++ b/examples/tests/test_examples.py @@ -58,11 +58,11 @@ def test_examples_run(module, force_offscreen): @pytest.fixture def force_offscreen(): """Force the offscreen canvas to be selected by the auto gui module.""" - os.environ["WGPU_FORCE_OFFSCREEN"] = "true" + os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true" try: yield finally: - del os.environ["WGPU_FORCE_OFFSCREEN"] + del os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] def test_that_we_are_on_lavapipe(): @@ -103,11 +103,10 @@ def test_example_screenshots(module, force_offscreen): # hacky but it works for now example.figure.imgui_renderer.render() + example.figure._set_viewport_rects() # render each subplot for subplot in example.figure: subplot.viewport.render(subplot.scene, subplot.camera) - for dock in subplot.docks.values(): - dock.set_viewport_rect() # flush pygfx renderer example.figure.renderer.flush() From 269ae8663d4667631080184d8bf360313e05b523 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:15:44 -0500 Subject: [PATCH 08/18] update right click menu --- fastplotlib/ui/right_click_menus/_standard_menu.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py index 9a584043c..772baa170 100644 --- a/fastplotlib/ui/right_click_menus/_standard_menu.py +++ b/fastplotlib/ui/right_click_menus/_standard_menu.py @@ -74,12 +74,11 @@ def update(self): return name = self.get_subplot().name - if name is None: - name = self.get_subplot().position - # text label at the top of the menu - imgui.text(f"subplot: {name}") - imgui.separator() + if name is not None: + # text label at the top of the menu + imgui.text(f"subplot: {name}") + imgui.separator() # autoscale, center, maintain aspect if imgui.menu_item(f"Autoscale", "", False)[0]: From 243215658ec3989b9a6535d7532fb689f79e6faf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:16:56 -0500 Subject: [PATCH 09/18] update imgui figure --- fastplotlib/layouts/_imgui_figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 4b46c2a06..2e77f350d 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -20,7 +20,7 @@ class ImguiFigure(Figure): def __init__( self, - shape: tuple[int, int] = (1, 1), + shape: list[tuple[int, int, int, int]] | tuple[int, int] = (1, 1), cameras: ( Literal["2d", "3d"] | Iterable[Iterable[Literal["2d", "3d"]]] From 7b8f5c406730f6c4262aede475df0cc8e7ce448c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:18:20 -0500 Subject: [PATCH 10/18] add gridplot viewport rect verification screenshots --- examples/screenshots/gridplot_viewports_check.png | 3 +++ examples/screenshots/image_widget_viewports_check.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 examples/screenshots/gridplot_viewports_check.png create mode 100644 examples/screenshots/image_widget_viewports_check.png diff --git a/examples/screenshots/gridplot_viewports_check.png b/examples/screenshots/gridplot_viewports_check.png new file mode 100644 index 000000000..050067e22 --- /dev/null +++ b/examples/screenshots/gridplot_viewports_check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:250959179b0f998e1c586951864e9cbce3ac63bf6d2e12a680a47b9b1be061a1 +size 46456 diff --git a/examples/screenshots/image_widget_viewports_check.png b/examples/screenshots/image_widget_viewports_check.png new file mode 100644 index 000000000..6bfbc0153 --- /dev/null +++ b/examples/screenshots/image_widget_viewports_check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27e8aaab0085d15965649f0a4b367e313bab382c13b39de0354d321398565a46 +size 99567 From b5157beaf048299623d22802f57efbe67dc973a1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:22:11 -0500 Subject: [PATCH 11/18] black complains --- fastplotlib/layouts/_figure.py | 117 ++++++++++++++++++++++---------- fastplotlib/layouts/_subplot.py | 4 +- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 78c30431d..cd900ddb1 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -103,22 +103,30 @@ def __init__( if isinstance(shape, list): raise NotImplementedError("bounding boxes for shape not yet implemented") if not all(isinstance(v, (tuple, list)) for v in shape): - raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + raise TypeError( + "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" + ) for item in shape: if not all(isinstance(v, (int, np.integer)) for v in item): - raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + raise TypeError( + "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" + ) self._mode: str = "rect" elif isinstance(shape, tuple): if not all(isinstance(v, (int, np.integer)) for v in shape): - raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + raise TypeError( + "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" + ) self._mode: str = "grid" # shape is [n_subplots, row_col_index] self._subplot_grid_positions: dict[Subplot, tuple[int, int]] = dict() else: - raise TypeError("shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]") + raise TypeError( + "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" + ) self._shape = shape @@ -147,7 +155,9 @@ def __init__( cameras = np.asarray(cameras).flatten() if cameras.size != len(self): - raise ValueError(f"Number of cameras: {cameras.size} does not match the number of subplots: {len(self)}") + raise ValueError( + f"Number of cameras: {cameras.size} does not match the number of subplots: {len(self)}" + ) # create the cameras subplot_cameras = np.empty(len(self), dtype=object) @@ -175,7 +185,9 @@ def __init__( "pygfx.Controller instances" ) - subplot_controllers: np.ndarray[pygfx.Controller] = np.asarray(controllers).flatten() + subplot_controllers: np.ndarray[pygfx.Controller] = np.asarray( + controllers + ).flatten() if not subplot_controllers.size == len(self): raise ValueError( f"number of controllers passed must be the same as the number of subplots specified " @@ -262,7 +274,9 @@ def __init__( if isinstance(controller_types, str): controller_types = np.array([controller_types] * len(self)) - controller_types: np.ndarray[pygfx.Controller] = np.asarray(controller_types).flatten() + controller_types: np.ndarray[pygfx.Controller] = np.asarray( + controller_types + ).flatten() # str controller_type or pygfx instances valid_str = list(valid_controller_types.keys()) + ["default"] @@ -384,9 +398,7 @@ def renderer(self) -> pygfx.WgpuRenderer: @property def controllers(self) -> np.ndarray[pygfx.Controller]: """controllers, read-only array, access individual subplots to change a controller""" - controllers = np.asarray( - [subplot.controller for subplot in self], dtype=object - ) + controllers = np.asarray([subplot.controller for subplot in self], dtype=object) if self.mode == "grid": controllers = controllers.reshape(self.shape) @@ -397,9 +409,7 @@ def controllers(self) -> np.ndarray[pygfx.Controller]: @property def cameras(self) -> np.ndarray[pygfx.Camera]: """cameras, read-only array, access individual subplots to change a camera""" - cameras = np.asarray( - [subplot.camera for subplot in self], dtype=object - ) + cameras = np.asarray([subplot.camera for subplot in self], dtype=object) if self.mode == "grid": cameras = cameras.reshape(self.shape) @@ -719,9 +729,19 @@ def _fpl_set_subplot_viewport_rect(self, subplot: Subplot): height_subplot = height_canvas / nrows # x position of this subplot - x_pos = ((col_ix - 1) * width_subplot) + width_subplot + x0_canvas + self.spacing + x_pos = ( + ((col_ix - 1) * width_subplot) + + width_subplot + + x0_canvas + + self.spacing + ) # y position of this subplot - y_pos = ((row_ix - 1) * height_subplot) + height_subplot + y0_canvas + self.spacing + y_pos = ( + ((row_ix - 1) * height_subplot) + + height_subplot + + y0_canvas + + self.spacing + ) if self.__class__.__name__ == "ImguiFigure" and subplot.toolbar: # leave space for imgui toolbar @@ -729,21 +749,28 @@ def _fpl_set_subplot_viewport_rect(self, subplot: Subplot): # clip so that min (w, h) is always 1, otherwise JupyterRenderCanvas causes issues because it # initializes with a width, height of (0, 0) - rect = np.array([ - x_pos, y_pos, width_subplot - self.spacing, height_subplot - self.spacing - ]).clip(min=[0, 0, 1, 1]) + rect = np.array( + [ + x_pos, + y_pos, + width_subplot - self.spacing, + height_subplot - self.spacing, + ] + ).clip(min=[0, 0, 1, 1]) # adjust if a subplot dock is present - adjust = np.array([ - # add left dock size to x_pos - subplot.docks["left"].size, - # add top dock size to y_pos - subplot.docks["top"].size, - # remove left and right dock sizes from width - -subplot.docks["right"].size - subplot.docks["left"].size, - # remove top and bottom dock sizes from height - -subplot.docks["top"].size - subplot.docks["bottom"].size, - ]) + adjust = np.array( + [ + # add left dock size to x_pos + subplot.docks["left"].size, + # add top dock size to y_pos + subplot.docks["top"].size, + # remove left and right dock sizes from width + -subplot.docks["right"].size - subplot.docks["left"].size, + # remove top and bottom dock sizes from height + -subplot.docks["top"].size - subplot.docks["bottom"].size, + ] + ) subplot.viewport.rect = rect + adjust @@ -770,33 +797,49 @@ def _fpl_set_subplot_dock_viewport_rect(self, subplot, position): ) # width of an individual subplot - width_subplot = (width_canvas / ncols) + width_subplot = width_canvas / ncols # height of an individual subplot - height_subplot = (height_canvas / nrows) + height_subplot = height_canvas / nrows # calculate the rect based on the dock position match position: case "right": - x_pos = ((col_ix - 1) * width_subplot) + (width_subplot * 2) - dock.size - y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + x_pos = ( + ((col_ix - 1) * width_subplot) + (width_subplot * 2) - dock.size + ) + y_pos = ( + ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + ) width_viewport = dock.size height_viewport = height_subplot - self.spacing case "left": x_pos = ((col_ix - 1) * width_subplot) + width_subplot - y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + y_pos = ( + ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + ) width_viewport = dock.size height_viewport = height_subplot - self.spacing case "top": - x_pos = ((col_ix - 1) * width_subplot) + width_subplot + self.spacing - y_pos = ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + x_pos = ( + ((col_ix - 1) * width_subplot) + width_subplot + self.spacing + ) + y_pos = ( + ((row_ix - 1) * height_subplot) + height_subplot + self.spacing + ) width_viewport = width_subplot - self.spacing height_viewport = dock.size case "bottom": - x_pos = ((col_ix - 1) * width_subplot) + width_subplot + self.spacing - y_pos = ((row_ix - 1) * height_subplot) + (height_subplot * 2) - dock.size + x_pos = ( + ((col_ix - 1) * width_subplot) + width_subplot + self.spacing + ) + y_pos = ( + ((row_ix - 1) * height_subplot) + + (height_subplot * 2) + - dock.size + ) width_viewport = width_subplot - self.spacing height_viewport = dock.size diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 990d969e4..a97e89b0d 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -211,7 +211,9 @@ def size(self, s: int): return self.get_figure(self.parent)._fpl_set_subplot_viewport_rect(self.parent) - self.get_figure(self.parent)._fpl_set_subplot_dock_viewport_rect(self.parent, self._position) + self.get_figure(self.parent)._fpl_set_subplot_dock_viewport_rect( + self.parent, self._position + ) def _render(self): if self.size == 0: From e5a1fac5aa781911def992d79f8107df9c561283 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:38:54 -0500 Subject: [PATCH 12/18] remove cell --- examples/notebooks/quickstart.ipynb | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb index 09317110d..737aee3e7 100644 --- a/examples/notebooks/quickstart.ipynb +++ b/examples/notebooks/quickstart.ipynb @@ -1695,22 +1695,6 @@ "figure_grid[\"top-right-plot\"]" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "cb7566a5", - "metadata": { - "collapsed": false, - "jupyter": { - "outputs_hidden": false - } - }, - "outputs": [], - "source": [ - "# view its position\n", - "figure_grid[\"top-right-plot\"].position" - ] - }, { "cell_type": "code", "execution_count": null, From e71ddcf085e50001b598cbe98f57a79266259fad Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:42:07 -0500 Subject: [PATCH 13/18] update docs --- docs/source/api/layouts/figure.rst | 5 +++-- docs/source/api/layouts/imgui_figure.rst | 5 +++-- docs/source/api/layouts/subplot.rst | 4 ---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index 17ee965b6..3d6c745e9 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -23,9 +23,11 @@ Properties Figure.cameras Figure.canvas Figure.controllers + Figure.mode Figure.names Figure.renderer Figure.shape + Figure.spacing Methods ~~~~~~~ @@ -36,10 +38,9 @@ Methods Figure.clear Figure.close Figure.export + Figure.export_numpy Figure.get_pygfx_render_area Figure.open_popup Figure.remove_animation - Figure.render Figure.show - Figure.start_render diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 38a546ae9..6d6bb2dd4 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -25,9 +25,11 @@ Properties ImguiFigure.controllers ImguiFigure.guis ImguiFigure.imgui_renderer + ImguiFigure.mode ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape + ImguiFigure.spacing Methods ~~~~~~~ @@ -39,11 +41,10 @@ Methods ImguiFigure.clear ImguiFigure.close ImguiFigure.export + ImguiFigure.export_numpy ImguiFigure.get_pygfx_render_area ImguiFigure.open_popup ImguiFigure.register_popup ImguiFigure.remove_animation - ImguiFigure.render ImguiFigure.show - ImguiFigure.start_render diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 3de44222d..1cf9be31c 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -31,7 +31,6 @@ Properties Subplot.name Subplot.objects Subplot.parent - Subplot.position Subplot.renderer Subplot.scene Subplot.selectors @@ -58,12 +57,9 @@ Methods Subplot.clear Subplot.delete_graphic Subplot.get_figure - Subplot.get_rect Subplot.insert_graphic Subplot.map_screen_to_world Subplot.remove_animation Subplot.remove_graphic - Subplot.render Subplot.set_title - Subplot.set_viewport_rect From e3ffa653f7ae8a8314dbbc21b79ac2077849eb2f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 13 Feb 2025 01:50:25 -0500 Subject: [PATCH 14/18] include OS in screenshot diff artifacts filename --- .github/workflows/ci-pygfx-release.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-pygfx-release.yml b/.github/workflows/ci-pygfx-release.yml index e93f82fd5..c8f51d05b 100644 --- a/.github/workflows/ci-pygfx-release.yml +++ b/.github/workflows/ci-pygfx-release.yml @@ -82,7 +82,7 @@ jobs: - uses: actions/upload-artifact@v4 if: ${{ failure() }} with: - name: screenshot-diffs-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }} + name: screenshot-diffs-${{ matrix.os }}-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }} path: | examples/diffs examples/notebooks/diffs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f50b9623..d29b54e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: - uses: actions/upload-artifact@v4 if: ${{ failure() }} with: - name: screenshot-diffs-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }} + name: screenshot-diffs-${{ matrix.os }}-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }} path: | examples/diffs examples/notebooks/diffs From 346543bf4a54a97907c2c523665e949f9d23289a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Feb 2025 00:32:37 -0500 Subject: [PATCH 15/18] comments --- fastplotlib/layouts/_figure.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index cd900ddb1..3ec6a34e6 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -111,6 +111,7 @@ def __init__( raise TypeError( "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" ) + # constant that sets the Figure to be in "rect" mode self._mode: str = "rect" elif isinstance(shape, tuple): @@ -118,6 +119,7 @@ def __init__( raise TypeError( "shape argument must be a list of bounding boxes or a tuple[n_rows, n_cols]" ) + # constant that sets the Figure to be in "grid" mode self._mode: str = "grid" # shape is [n_subplots, row_col_index] @@ -130,6 +132,7 @@ def __init__( self._shape = shape + # default spacing of 2 pixels between subplots self._spacing = 2 if names is not None: @@ -370,15 +373,22 @@ def shape(self) -> list[tuple[int, int, int, int]] | tuple[int, int]: @property def mode(self) -> str: - """one of 'grid' or 'rect'""" + """ + one of 'grid' or 'rect' + + Used by Figure to determine certain aspects, such as how to calculate + rects and shapes of properties for cameras, controllers, and subplots arrays + """ return self._mode @property def spacing(self) -> int: + """spacing between subplots, in pixels""" return self._spacing @spacing.setter def spacing(self, value: int): + """set the spacing between subplots, in pixels""" if not isinstance(value, (int, np.integer)): raise TypeError("spacing must be of type ") From 465ea744a139b641f52feb11d98aef3f147f774e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Feb 2025 01:54:26 -0500 Subject: [PATCH 16/18] modify nb test, should work again now --- examples/notebooks/nb_test_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py index e1c32e0a0..dc55107ba 100644 --- a/examples/notebooks/nb_test_utils.py +++ b/examples/notebooks/nb_test_utils.py @@ -94,6 +94,9 @@ def plot_test(name, fig: fpl.Figure): if not TESTING: return + # otherwise the first render is wrong + fig._set_viewport_rects() + snapshot = fig.canvas.snapshot() rgb_img = rgba_to_rgb(snapshot.data) From fe794a4b4da9866c38d74ec2b473189d5cceb0a1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Feb 2025 02:37:23 -0500 Subject: [PATCH 17/18] try to make first render look right on github actions --- examples/notebooks/nb_test_utils.py | 17 +++++++++++++++++ fastplotlib/layouts/_figure.py | 3 --- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py index dc55107ba..f1505f98a 100644 --- a/examples/notebooks/nb_test_utils.py +++ b/examples/notebooks/nb_test_utils.py @@ -95,7 +95,24 @@ def plot_test(name, fig: fpl.Figure): return # otherwise the first render is wrong + if fpl.IMGUI: + # there doesn't seem to be a resize event for the manual offscreen canvas + fig.imgui_renderer._backend.io.display_size = fig.canvas.get_logical_size() + # run this once so any edge widgets set their sizes and therefore the subplots get the correct rect + # hacky but it works for now + fig.imgui_renderer.render() + fig._set_viewport_rects() + # render each subplot + for subplot in fig: + subplot.viewport.render(subplot.scene, subplot.camera) + + # flush pygfx renderer + fig.renderer.flush() + + if fpl.IMGUI: + # render imgui + fig.imgui_renderer.render() snapshot = fig.canvas.snapshot() rgb_img = rgba_to_rgb(snapshot.data) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 3ec6a34e6..5f253b82f 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -451,9 +451,6 @@ def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot: return self._subplots[index] def _render(self, draw=True): - if self._pause_render: - return - # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) for subplot in self: From d6f72d1a42ee35817b327e9ae33191d217106ab5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 20 Feb 2025 02:58:48 -0500 Subject: [PATCH 18/18] update last groundtruth screenshot --- examples/screenshots/no-imgui-gridplot_viewports_check.png | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/screenshots/no-imgui-gridplot_viewports_check.png diff --git a/examples/screenshots/no-imgui-gridplot_viewports_check.png b/examples/screenshots/no-imgui-gridplot_viewports_check.png new file mode 100644 index 000000000..8dea071d0 --- /dev/null +++ b/examples/screenshots/no-imgui-gridplot_viewports_check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cda256658e84b14b48bf5151990c828092ff461f394fb9e54341ab601918aa1 +size 45113 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy