diff --git a/examples/reference_spaces/line_scales.py b/examples/reference_spaces/line_scales.py new file mode 100644 index 000000000..5d07e28cd --- /dev/null +++ b/examples/reference_spaces/line_scales.py @@ -0,0 +1,59 @@ +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import DebugWindow +import pygfx +from icecream import ic + +xs = np.linspace(0, 10 * np.pi, 1000) +ys = np.sin(xs) + +ys100 = ys * 1000 + +l1 = np.column_stack([xs, ys]) +l2 = np.column_stack([xs, ys100]) + +fig = fpl.Figure(size=(500, 400)) + +fig[0, 0].add_line(l1) +fig.show(maintain_aspect=False) +fig[0, 0].auto_scale(zoom=0.4) + +rs = fig[0, 0].add_reference_frame( + scale=(1, 500, 1), +) +l2 = fig[0, 0].add_line(l2, reference_space=rs, colors="r") +l2.add_axes(rs) +l2.axes.y.line.material.color = "m" + + +@fig.renderer.add_event_handler("key_down") +def change_y_scale(ev: pygfx.KeyboardEvent): + if ev.key != "1": + return + + rs.controller.remove_camera(rs.camera) + rs.controller.add_camera(rs.camera, include_state={"height"}) + + fig[0, 0].controller.enabled = False + + +@fig.renderer.add_event_handler("key_down") +def change_y_scale(ev: pygfx.KeyboardEvent): + if ev.key != "0": + return + + rs.controller.remove_camera(rs.camera) + rs.controller.add_camera(rs.camera) + fig[0, 0].controller.enabled = True + + +debug_objs = [ + fig[0, 0].camera.get_state, + rs.camera.get_state +] + +debug_window = DebugWindow(debug_objs) +fig.add_gui(debug_window) + + +fpl.loop.run() diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py index 10774fc2a..1895406b0 100644 --- a/fastplotlib/graphics/_axes.py +++ b/fastplotlib/graphics/_axes.py @@ -144,7 +144,7 @@ def yz(self) -> Grid: class Axes: def __init__( self, - plot_area, + reference_space, intersection: tuple[int, int, int] | None = None, x_kwargs: dict = None, y_kwargs: dict = None, @@ -157,7 +157,7 @@ def __init__( [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] ), ): - self._plot_area = plot_area + self._reference_space = reference_space if x_kwargs is None: x_kwargs = dict() @@ -193,20 +193,20 @@ def __init__( self.x.end_pos = 100, 0, 0 self.x.start_value = self.x.start_pos[0] - offset[0] statsx = self.x.update( - self._plot_area.camera, self._plot_area.viewport.logical_size + self._reference_space.camera, self._reference_space.viewport.logical_size ) self.y.start_pos = 0, 0, 0 self.y.end_pos = 0, 100, 0 self.y.start_value = self.y.start_pos[1] - offset[1] statsy = self.y.update( - self._plot_area.camera, self._plot_area.viewport.logical_size + self._reference_space.camera, self._reference_space.viewport.logical_size ) self.z.start_pos = 0, 0, 0 self.z.end_pos = 0, 0, 100 self.z.start_value = self.z.start_pos[1] - offset[2] - self.z.update(self._plot_area.camera, self._plot_area.viewport.logical_size) + self.z.update(self._reference_space.camera, self._reference_space.viewport.logical_size) # world object for the rulers + grids self._world_object = pygfx.Group() @@ -219,7 +219,7 @@ def __init__( ) # set z ruler invisible for orthographic projections for now - if self._plot_area.camera.fov == 0: + if self._reference_space.camera.fov == 0: # TODO: allow any orientation in the future even for orthographic projections self.z.visible = False @@ -251,7 +251,7 @@ def __init__( self._grids = Grids(**_grids) self.world_object.add(self._grids) - if self._plot_area.camera.fov == 0: + if self._reference_space.camera.fov == 0: # orthographic projection, place grids far away self._grids.local.z = -1000 @@ -382,13 +382,13 @@ def update_using_bbox(self, bbox): """ # flip axes if camera scale is flipped - if self._plot_area.camera.local.scale_x < 0: + if self._reference_space.camera.local.scale_x < 0: bbox[0, 0], bbox[1, 0] = bbox[1, 0], bbox[0, 0] - if self._plot_area.camera.local.scale_y < 0: + if self._reference_space.camera.local.scale_y < 0: bbox[0, 1], bbox[1, 1] = bbox[1, 1], bbox[0, 1] - if self._plot_area.camera.local.scale_z < 0: + if self._reference_space.camera.local.scale_z < 0: bbox[0, 2], bbox[1, 2] = bbox[1, 2], bbox[0, 2] if self.intersection is None: @@ -413,8 +413,8 @@ def update_using_camera(self): if not self.visible: return - if self._plot_area.camera.fov == 0: - xpos, ypos, width, height = self._plot_area.viewport.rect + if self._reference_space.camera.fov == 0: + xpos, ypos, width, height = self._reference_space.viewport.rect # orthographic projection, get ranges using inverse # get range of screen space by getting the corners @@ -442,8 +442,8 @@ def update_using_camera(self): # self.y.local.rotation # ) - min_vals = self._plot_area.map_screen_to_world((xmin, ymin)) - max_vals = self._plot_area.map_screen_to_world((xmax, ymax)) + min_vals = self._reference_space.map_screen_to_world((xmin, ymin)) + max_vals = self._reference_space.map_screen_to_world((xmax, ymax)) if min_vals is None or max_vals is None: return @@ -462,14 +462,14 @@ def update_using_camera(self): else: # set ruler start and end positions based on scene bbox - bbox = self._plot_area._fpl_graphics_scene.get_world_bounding_box() + bbox = self._reference_space._fpl_graphics_scene.get_world_bounding_box() if self.intersection is None: - if self._plot_area.camera.fov == 0: + if self._reference_space.camera.fov == 0: # place the ruler close to the left and bottom edges of the viewport # TODO: determine this for perspective projections xscreen_10, yscreen_10 = xpos + (width * 0.1), ypos + (height * 0.9) - intersection = self._plot_area.map_screen_to_world( + intersection = self._reference_space.map_screen_to_world( (xscreen_10, yscreen_10) ) else: @@ -502,7 +502,7 @@ def update(self, bbox, intersection): world_x_10, world_y_10, world_z_10 = intersection # swap min and max for each dimension if necessary - if self._plot_area.camera.local.scale_y < 0: + if self._reference_space.camera.local.scale_y < 0: world_ymin, world_ymax = world_ymax, world_ymin self.y.tick_side = "right" # swap tick side self.x.tick_side = "right" @@ -510,7 +510,7 @@ def update(self, bbox, intersection): self.y.tick_side = "left" self.x.tick_side = "right" - if self._plot_area.camera.local.scale_x < 0: + if self._reference_space.camera.local.scale_x < 0: world_xmin, world_xmax = world_xmax, world_xmin self.x.tick_side = "left" @@ -519,7 +519,7 @@ def update(self, bbox, intersection): self.x.start_value = self.x.start_pos[0] - self.offset[0] statsx = self.x.update( - self._plot_area.camera, self._plot_area.viewport.logical_size + self._reference_space.camera, self._reference_space.viewport.logical_size ) self.y.start_pos = world_x_10, world_ymin, world_z_10 @@ -527,16 +527,16 @@ def update(self, bbox, intersection): self.y.start_value = self.y.start_pos[1] - self.offset[1] statsy = self.y.update( - self._plot_area.camera, self._plot_area.viewport.logical_size + self._reference_space.camera, self._reference_space.viewport.logical_size ) - if self._plot_area.camera.fov != 0: + if self._reference_space.camera.fov != 0: self.z.start_pos = world_x_10, world_y_10, world_zmin self.z.end_pos = world_x_10, world_y_10, world_zmax self.z.start_value = self.z.start_pos[2] - self.offset[2] statsz = self.z.update( - self._plot_area.camera, self._plot_area.viewport.logical_size + self._reference_space.camera, self._reference_space.viewport.logical_size ) major_step_z = statsz["tick_step"] @@ -546,7 +546,7 @@ def update(self, bbox, intersection): self.grids.xy.major_step = major_step_x, major_step_y self.grids.xy.minor_step = 0.2 * major_step_x, 0.2 * major_step_y - if self._plot_area.camera.fov != 0: + if self._reference_space.camera.fov != 0: self.grids.xz.major_step = major_step_x, major_step_z self.grids.xz.minor_step = 0.2 * major_step_x, 0.2 * major_step_z diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index e115107b0..168f976c8 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -447,15 +447,15 @@ def rotate(self, alpha: float, axis: Literal["x", "y", "z"] = "y"): def axes(self) -> Axes: return self._axes - def add_axes(self): + def add_axes(self, reference_frame): """Add axes onto this Graphic""" if self._axes is not None: raise AttributeError("Axes already added onto this graphic") - self._axes = Axes(self._plot_area, offset=self.offset, grids=False) + self._axes = Axes(reference_frame, offset=self.offset, grids=False) self._axes.world_object.local.rotation = self.world_object.local.rotation - self._plot_area.scene.add(self.axes.world_object) + reference_frame.scene.add(self.axes.world_object) self._axes.update_using_bbox(self.world_object.get_world_bounding_box()) @property diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index a753eec73..d6eedeee5 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -15,11 +15,16 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: else: center = False + if "reference_space" in kwargs.keys(): + reference_space = kwargs.pop("reference_space") + else: + reference_space = 0 + if "name" in kwargs.keys(): self._check_graphic_name_exists(kwargs["name"]) graphic = graphic_class(*args, **kwargs) - self.add_graphic(graphic, center=center) + self.add_graphic(graphic, center=center, reference_space=reference_space) return graphic @@ -32,7 +37,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -78,7 +83,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -96,7 +101,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -169,7 +174,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -183,7 +188,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Iterable] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -234,7 +239,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -253,7 +258,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -334,7 +339,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -349,7 +354,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -409,7 +414,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_text( @@ -422,7 +427,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -473,5 +478,5 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 2934e0589..f3a791117 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -8,8 +8,9 @@ from pylinalg import vec_transform, vec_unproject from rendercanvas import BaseRenderCanvas +from ._reference_space import ReferenceFrame from ._utils import create_controller -from ..graphics._base import Graphic +from ..graphics import Graphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend @@ -115,6 +116,8 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) + self._reference_frames: list[ReferenceFrame] = list() + def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: @@ -272,6 +275,42 @@ 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) + @property + def reference_frames(self) -> tuple[ReferenceFrame, ...]: + return tuple(self._reference_frames) + + def add_reference_frame( + self, + position: tuple[float, float, float] | None = None, + scale: tuple[float, float, float] | None = None, + controller_type: str = None, + controller_include_state=None, + controller_exclude_state=None, + name: str | None = None + ) -> ReferenceFrame: + camera = pygfx.PerspectiveCamera() + + state = self.camera.get_state() + camera.set_state(state) + + if position is not None: + camera.world.position = position + if scale is not None: + camera.world.scale = scale + + camera.maintain_aspect = self.camera.maintain_aspect + + scene = pygfx.Scene() + + controller = pygfx.PanZoomController() + controller.add_camera(camera, include_state=controller_include_state, exclude_state=controller_exclude_state) + controller.register_events(self.viewport) + + ref_frame = ReferenceFrame(scene, camera, controller, self.viewport, name) + self._reference_frames.append(ref_frame) + + return ref_frame + def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False ) -> np.ndarray | None: @@ -314,6 +353,9 @@ def _render(self): # does not flush, flush must be implemented in user-facing Plot objects self.viewport.render(self.scene, self.camera) + for reference_space in self.reference_frames: + self.viewport.render(reference_space.scene, reference_space.camera) + for child in self.children: child._render() @@ -393,7 +435,7 @@ def remove_animation(self, func): if func in self._animate_funcs_post: self._animate_funcs_post.remove(func) - def add_graphic(self, graphic: Graphic, center: bool = True): + def add_graphic(self, graphic: Graphic, center: bool = True, reference_frame: ReferenceFrame | int | str = 0): """ Add a Graphic to the scene @@ -413,7 +455,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): self._fpl_graphics_scene.add(graphic.world_object) return - self._add_or_insert_graphic(graphic=graphic, center=center, action="add") + self._add_or_insert_graphic(graphic=graphic, center=center, action="add", reference_frame=reference_frame) if self.camera.fov == 0: # for orthographic positions stack objects along the z-axis @@ -469,6 +511,7 @@ def _add_or_insert_graphic( center: bool = True, action: str = Literal["insert", "add"], index: int = 0, + reference_frame: ReferenceFrame | str | int = 0, ): """Private method to handle inserting or adding a graphic to a PlotArea.""" if not isinstance(graphic, Graphic): @@ -489,7 +532,10 @@ def _add_or_insert_graphic( elif isinstance(graphic, Graphic): obj_list = self._graphics - self._fpl_graphics_scene.add(graphic.world_object) + if isinstance(reference_frame, ReferenceFrame): + reference_frame.scene.add(graphic.world_object) + else: + self._fpl_graphics_scene.add(graphic.world_object) else: raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") diff --git a/fastplotlib/layouts/_reference_space.py b/fastplotlib/layouts/_reference_space.py new file mode 100644 index 000000000..930769dd6 --- /dev/null +++ b/fastplotlib/layouts/_reference_space.py @@ -0,0 +1,88 @@ +import numpy as np + +import pygfx +from pylinalg import vec_transform, vec_unproject + +from ..graphics import Graphic + + +class ReferenceFrame: + def __init__( + self, + scene: pygfx.Scene, + camera: pygfx.Camera, + controller: pygfx.Controller, + viewport: pygfx.Viewport, + name: str | None = None, + ): + self._scene = scene + self._camera = camera + self._controller = controller + self.viewport = viewport + self._name = name + + self._graphics: list[Graphic] = list() + + @property + def name(self) -> str: + return self._name + + @property + def scene(self) -> pygfx.Scene: + return self._scene + + @property + def camera(self) -> pygfx.Camera: + return self._camera + + @property + def controller(self) -> pygfx.Controller: + return self._controller + + def auto_scale(self): + pass + + def center(self): + pass + + @property + def graphics(self) -> np.ndarray[Graphic]: + graphics = np.asarray(self._graphics) + graphics.flags.writeable = False + return graphics + + def map_screen_to_world( + self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False + ) -> np.ndarray | None: + """ + Map screen position to world position + + Parameters + ---------- + pos: (float, float) | pygfx.PointerEvent + ``(x, y)`` screen coordinates, or ``pygfx.PointerEvent`` + + """ + if isinstance(pos, pygfx.PointerEvent): + pos = pos.x, pos.y + + if not allow_outside and not self.viewport.is_inside(*pos): + return None + + vs = self.viewport.logical_size + + # get position relative to viewport + pos_rel = ( + pos[0] - self.viewport.rect[0], + pos[1] - self.viewport.rect[1], + ) + + # convert screen position to NDC + pos_ndc = (pos_rel[0] / vs[0] * 2 - 1, -(pos_rel[1] / vs[1] * 2 - 1), 0) + + # get world position + pos_ndc += vec_transform(self.camera.world.position, self.camera.camera_matrix) + pos_world = vec_unproject(pos_ndc[:2], self.camera.camera_matrix) + + # default z is zero for now + return np.array([*pos_world[:2], 0]) diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 533ae77c6..1080d99da 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -19,6 +19,9 @@ for name, obj in inspect.getmembers(graphics): if inspect.isclass(obj): + if obj.__name__ == "Graphic": + # skip base class + continue modules.append(obj) @@ -42,10 +45,16 @@ def generate_add_graphics_methods(): f.write(" center = kwargs.pop('center')\n") f.write(" else:\n") f.write(" center = False\n\n") + + f.write(" if 'reference_space' in kwargs.keys():\n") + f.write(" reference_space = kwargs.pop('reference_space')\n") + f.write(" else:\n") + f.write(" reference_space = 0\n\n") + f.write(" if 'name' in kwargs.keys():\n") f.write(" self._check_graphic_name_exists(kwargs['name'])\n\n") f.write(" graphic = graphic_class(*args, **kwargs)\n") - f.write(" self.add_graphic(graphic, center=center)\n\n") + f.write(" self.add_graphic(graphic, center=center, reference_space=reference_space)\n\n") f.write(" return graphic\n\n") for m in modules: