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 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 diff --git a/examples/gridplot/gridplot_viewports_check.py b/examples/gridplot/gridplot_viewports_check.py new file mode 100644 index 000000000..99584b411 --- /dev/null +++ b/examples/gridplot/gridplot_viewports_check.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_check.py b/examples/image_widget/image_widget_viewports_check.py new file mode 100644 index 000000000..057134341 --- /dev/null +++ b/examples/image_widget/image_widget_viewports_check.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() diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py index e1c32e0a0..f1505f98a 100644 --- a/examples/notebooks/nb_test_utils.py +++ b/examples/notebooks/nb_test_utils.py @@ -94,6 +94,26 @@ def plot_test(name, fig: fpl.Figure): if not TESTING: 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/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, 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 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 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() 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 70a4d41be..5f253b82f 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"]]] @@ -51,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) + 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 @@ -69,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 @@ -97,15 +100,47 @@ def __init__( subplot names """ + 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: + 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]" + ) + # constant that sets the Figure to be in "rect" mode + 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]" + ) + # constant that sets the Figure to be in "grid" mode + 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 + # default spacing of 2 pixels between subplots + self._spacing = 2 + if names is not None: - if len(list(chain(*names))) != len(self): + 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`" ) - - subplot_names = np.asarray(names).reshape(self.shape) else: subplot_names = None @@ -113,29 +148,30 @@ def __init__( canvas, renderer, canvas_kwargs={"size": size} ) + 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 - 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: - # 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: @@ -152,32 +188,28 @@ def __init__( "pygfx.Controller instances" ) - try: - controllers = np.asarray(controllers).reshape(shape) - 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: {self.shape}. 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( - self.shape, 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].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 - 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 +239,24 @@ 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 - ) # use negative numbers because why not + row_ix + 1 + ) # 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).reshape(self.shape) + 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( @@ -228,25 +264,27 @@ 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]] + controller_types = np.array([controller_types] * len(self)) - types_flat = list(chain(*controller_types)) + 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 @@ -256,12 +294,8 @@ def __init__( f"Valid `controller_types` arguments are:\n {valid_str}" ) - 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 +326,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 +364,37 @@ def __init__( self._output = None + self._pause_render = False + @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: + """ + 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 ") + + self._spacing = value + self._set_viewport_rects() + @property def canvas(self) -> BaseRenderCanvas: """The canvas this Figure is drawn onto""" @@ -346,54 +408,62 @@ 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 - ).reshape(self.shape) + controllers = np.asarray([subplot.controller for subplot in self], dtype=object) + + if self.mode == "grid": + controllers = controllers.reshape(self.shape) + controllers.flags.writeable = False return controllers @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 - ).reshape(self.shape) + cameras = np.asarray([subplot.camera for subplot in self], dtype=object) + + 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 - 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): # call the animation functions before render self._call_animate_functions(self._animate_funcs_pre) - for subplot in self: - subplot.render() + subplot._render() self.renderer.flush() - if draw: - self.canvas.request_draw() # 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, @@ -431,7 +501,7 @@ def show( if self._output: return self._output - self.start_render() + self._start_render() if sidecar_kwargs is None: sidecar_kwargs = dict() @@ -471,8 +541,8 @@ def show( elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas": # for test and docs gallery screenshots + self._set_viewport_rects() for subplot in self: - subplot.set_viewport_rect() subplot.axes.update_using_camera() # render call is blocking only on github actions for some reason, @@ -481,7 +551,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 @@ -642,6 +712,161 @@ 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, 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 + + # 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() + ) + + # 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 + height_subplot -= IMGUI_TOOLBAR_HEIGHT + + # 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 + + x0_canvas, y0_canvas, width_canvas, height_canvas = ( + self.get_pygfx_render_area() + ) + + # 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, + ] + + 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(): + 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, @@ -658,20 +883,20 @@ 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])) - def __iter__(self): - self._current_iter = self._get_iterator() + self._current_iter = iter(range(len(self))) return 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..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"]]] @@ -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 @@ -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() @@ -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..46ee59b1f 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,17 +309,14 @@ 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): + 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 7d52ebab2..a97e89b0d 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,16 +11,10 @@ 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, 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 +36,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 +58,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(), @@ -122,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: @@ -148,11 +135,11 @@ 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): + 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""" @@ -180,54 +167,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 +183,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 +194,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,141 +206,17 @@ 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 + if self.position == "top": + # TODO: treat title dock separately, do not allow user to change viewport stuff 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() + 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 ) - 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 - ] - ) - - 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 6c1a81f73..7d183bf6d 100644 --- a/fastplotlib/ui/_subplot_toolbar.py +++ b/fastplotlib/ui/_subplot_toolbar.py @@ -16,7 +16,8 @@ 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 + 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.position}", 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.position}"): + with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"): # autoscale button if imgui.button(fa.ICON_FA_MAXIMIZE): self._subplot.auto_scale() 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]: 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")