diff --git a/fury/data/files/test_ui_draw_panel_basic.json b/fury/data/files/test_ui_draw_panel_basic.json index 4e1cd457a..c806a95b5 100644 --- a/fury/data/files/test_ui_draw_panel_basic.json +++ b/fury/data/files/test_ui_draw_panel_basic.json @@ -1 +1 @@ -{"CharEvent": 0, "MouseMoveEvent": 993, "KeyPressEvent": 0, "KeyReleaseEvent": 0, "LeftButtonPressEvent": 17, "LeftButtonReleaseEvent": 17, "RightButtonPressEvent": 0, "RightButtonReleaseEvent": 0, "MiddleButtonPressEvent": 0, "MiddleButtonReleaseEvent": 0} \ No newline at end of file +{"CharEvent": 0, "MouseMoveEvent": 2917, "KeyPressEvent": 0, "KeyReleaseEvent": 0, "LeftButtonPressEvent": 17, "LeftButtonReleaseEvent": 17, "RightButtonPressEvent": 0, "RightButtonReleaseEvent": 0, "MiddleButtonPressEvent": 0, "MiddleButtonReleaseEvent": 0} \ No newline at end of file diff --git a/fury/data/files/test_ui_draw_panel_basic.log.gz b/fury/data/files/test_ui_draw_panel_basic.log.gz index bcb63eef9..a430052cf 100644 Binary files a/fury/data/files/test_ui_draw_panel_basic.log.gz and b/fury/data/files/test_ui_draw_panel_basic.log.gz differ diff --git a/fury/data/files/test_ui_draw_panel_grouping.json b/fury/data/files/test_ui_draw_panel_grouping.json new file mode 100644 index 000000000..a30501648 --- /dev/null +++ b/fury/data/files/test_ui_draw_panel_grouping.json @@ -0,0 +1 @@ +{"CharEvent": 0, "MouseMoveEvent": 3227, "KeyPressEvent": 7, "KeyReleaseEvent": 7, "LeftButtonPressEvent": 13, "LeftButtonReleaseEvent": 13, "RightButtonPressEvent": 0, "RightButtonReleaseEvent": 0, "MiddleButtonPressEvent": 0, "MiddleButtonReleaseEvent": 0} \ No newline at end of file diff --git a/fury/data/files/test_ui_draw_panel_grouping.log.gz b/fury/data/files/test_ui_draw_panel_grouping.log.gz new file mode 100644 index 000000000..6b0e2cf5b Binary files /dev/null and b/fury/data/files/test_ui_draw_panel_grouping.log.gz differ diff --git a/fury/data/files/test_ui_draw_panel_rotation.json b/fury/data/files/test_ui_draw_panel_rotation.json index 061f737fa..42c69fb20 100644 --- a/fury/data/files/test_ui_draw_panel_rotation.json +++ b/fury/data/files/test_ui_draw_panel_rotation.json @@ -1 +1 @@ -{"CharEvent": 0, "MouseMoveEvent": 208, "KeyPressEvent": 0, "KeyReleaseEvent": 0, "LeftButtonPressEvent": 13, "LeftButtonReleaseEvent": 13, "RightButtonPressEvent": 0, "RightButtonReleaseEvent": 0, "MiddleButtonPressEvent": 0, "MiddleButtonReleaseEvent": 0} \ No newline at end of file +{"CharEvent": 0, "MouseMoveEvent": 1863, "KeyPressEvent": 0, "KeyReleaseEvent": 0, "LeftButtonPressEvent": 8, "LeftButtonReleaseEvent": 8, "RightButtonPressEvent": 0, "RightButtonReleaseEvent": 0, "MiddleButtonPressEvent": 0, "MiddleButtonReleaseEvent": 0} \ No newline at end of file diff --git a/fury/data/files/test_ui_draw_panel_rotation.log.gz b/fury/data/files/test_ui_draw_panel_rotation.log.gz index e23bd308c..efc9ceab7 100644 Binary files a/fury/data/files/test_ui_draw_panel_rotation.log.gz and b/fury/data/files/test_ui_draw_panel_rotation.log.gz differ diff --git a/fury/ui/containers.py b/fury/ui/containers.py index efd1a7ca1..d6a510f30 100644 --- a/fury/ui/containers.py +++ b/fury/ui/containers.py @@ -1011,7 +1011,7 @@ def mouse_move_callback2(istyle, obj, self): ANTICLOCKWISE_ROTATION_X = np.array([-10, 1, 0, 0]) CLOCKWISE_ROTATION_X = np.array([10, 1, 0, 0]) - def key_press_callback(self, istyle, obj, _what): + def on_key_press_callback(self, istyle, obj, _what): has_changed = False if istyle.event.key == 'Left': has_changed = True @@ -1058,8 +1058,9 @@ def _setup(self): self.add_callback(actor, 'MouseMoveEvent', self.mouse_move_callback2) # TODO: this is currently not running - self.add_callback(actor, 'KeyPressEvent', self.key_press_callback) - # self.on_key_press = self.key_press_callback2 + self.add_callback(actor, "KeyPressEvent", + self.on_key_press_callback) + # self.on_key_press = self.on_key_press_callback2 def _get_actors(self): """Get the actors composing this UI component.""" diff --git a/fury/ui/core.py b/fury/ui/core.py index c0314fd94..39ff7c914 100644 --- a/fury/ui/core.py +++ b/fury/ui/core.py @@ -119,6 +119,7 @@ def __init__(self, position=(0, 0)): self.on_middle_mouse_double_clicked = lambda i_ren, obj, element: None self.on_middle_mouse_button_dragged = lambda i_ren, obj, element: None self.on_key_press = lambda i_ren, obj, element: None + self.on_key_release = lambda i_ren, obj, element: None @abc.abstractmethod def _setup(self): @@ -261,26 +262,21 @@ def set_visibility(self, visibility): actor.SetVisibility(visibility) def handle_events(self, actor): - self.add_callback( - actor, 'LeftButtonPressEvent', self.left_button_click_callback - ) - self.add_callback( - actor, 'LeftButtonReleaseEvent', self.left_button_release_callback - ) - self.add_callback( - actor, 'RightButtonPressEvent', self.right_button_click_callback - ) - self.add_callback( - actor, 'RightButtonReleaseEvent', self.right_button_release_callback - ) - self.add_callback( - actor, 'MiddleButtonPressEvent', self.middle_button_click_callback - ) - self.add_callback( - actor, 'MiddleButtonReleaseEvent', self.middle_button_release_callback - ) - self.add_callback(actor, 'MouseMoveEvent', self.mouse_move_callback) - self.add_callback(actor, 'KeyPressEvent', self.key_press_callback) + self.add_callback(actor, "LeftButtonPressEvent", + self.left_button_click_callback) + self.add_callback(actor, "LeftButtonReleaseEvent", + self.left_button_release_callback) + self.add_callback(actor, "RightButtonPressEvent", + self.right_button_click_callback) + self.add_callback(actor, "RightButtonReleaseEvent", + self.right_button_release_callback) + self.add_callback(actor, "MiddleButtonPressEvent", + self.middle_button_click_callback) + self.add_callback(actor, "MiddleButtonReleaseEvent", + self.middle_button_release_callback) + self.add_callback(actor, "MouseMoveEvent", self.mouse_move_callback) + self.add_callback(actor, "KeyPressEvent", self.on_key_press_callback) + self.add_callback(actor, "KeyReleaseEvent", self.on_key_release_callback) @staticmethod def left_button_click_callback(i_ren, obj, self): @@ -348,9 +344,13 @@ def mouse_move_callback(i_ren, obj, self): self.on_middle_mouse_button_dragged(i_ren, obj, self) @staticmethod - def key_press_callback(i_ren, obj, self): + def on_key_press_callback(i_ren, obj, self): self.on_key_press(i_ren, obj, self) + @staticmethod + def on_key_release_callback(i_ren, obj, self): + self.on_key_release(i_ren, obj, self) + class Rectangle2D(UI): """A 2D rectangle sub-classed from UI.""" diff --git a/fury/ui/elements.py b/fury/ui/elements.py index f70850580..d79e0c205 100644 --- a/fury/ui/elements.py +++ b/fury/ui/elements.py @@ -3254,10 +3254,146 @@ def directory_click_callback(self, i_ren, _obj, listboxitem): i_ren.event.abort() +class DrawShapeGroup: + def __init__(self, drawpanel): + self.grouped_shapes = [] + self._scene = None + self.drawpanel = drawpanel + + # Group rotation slider + self.group_rotation_slider = RingSlider2D(initial_value=0, + text_template="{angle:5.1f}°") + + self.group_rotation_slider.set_visibility(False) + + def update_rotation(slider): + angle = slider.value + previous_angle = slider.previous_value + rotation_angle = angle - previous_angle + + for shape in self.grouped_shapes: + current_center = shape.center + shape.rotate(np.deg2rad(rotation_angle)) + shape.update_shape_position( + current_center - shape.drawpanel.canvas.position) + + self.group_rotation_slider.on_change = update_rotation + + def add(self, shape): + """Add shape to the group. + + Parameters + ---------- + shape : DrawShape + + """ + if self.is_present(shape): + self.remove(shape) + else: + if self.is_empty(): + shape.drawpanel.update_shape_selection(shape) + self.add_rotation_slider(self._scene) + self.group_rotation_slider.set_visibility(True) + self.grouped_shapes.append(shape) + shape.is_selected = True + shape.rotation_slider.set_visibility(False) + + self.group_rotation_slider.center = shape.rotation_slider.center + + def remove(self, shape): + """Remove shape from the group. + + Parameters + ---------- + shape : DrawShape + + """ + self.grouped_shapes.remove(shape) + shape.is_selected = False + + def clear(self): + """Remove all the shapes from the group. + + """ + if self.is_empty(): + return + self._scene.rm(*self.group_rotation_slider.actors) + for shape in self.grouped_shapes: + shape.is_selected = False + self.grouped_shapes = [] + + def is_present(self, shape): + """Check whether the shape is present in the group. + + Parameters + ---------- + shape : DrawShape + + """ + if shape in self.grouped_shapes: + return True + return False + + def is_empty(self): + """Return whether the group is empty or not. + + """ + return not bool(len(self.grouped_shapes)) + + def delete_shapes(self): + """Delete all the shapes present in current group. + + """ + if not self.is_empty(): + for shape in self.grouped_shapes: + shape.remove() + self.clear() + + def update_position(self, offset): + """Update the position of all the shapes in the group. + + Parameters + ---------- + offset : (float, float) + Distance by which each shape is to be translated. + + """ + vertices = [] + for shape in self.grouped_shapes: + vertices.extend(shape.position + + vertices_from_actor(shape.shape.actor)[:, :-1]) + + bounding_box_min, bounding_box_max, \ + bounding_box_size = cal_bounding_box_2d(np.asarray(vertices)) + + group_center = bounding_box_min + bounding_box_size//2 + + shape_offset = [] + for shape in self.grouped_shapes: + shape_offset.append(shape.center - group_center) + + new_center = np.clip(group_center + offset, self.drawpanel.position + bounding_box_size//2, + self.drawpanel.position + self.drawpanel.size - bounding_box_size//2) + + for shape, soffset in zip(self.grouped_shapes, shape_offset): + shape.update_shape_position(new_center + soffset - self.drawpanel.position) + + def add_rotation_slider(self, scene): + """Add rotation slider to the scene. + + Parameters + ---------- + scene : scene + + """ + scene.add(self.group_rotation_slider) + + class DrawShape(UI): """Create and Manage 2D Shapes.""" - def __init__(self, shape_type, drawpanel=None, position=(0, 0)): + def __init__(self, shape_type, drawpanel=None, position=(0, 0), color=None, + highlight_color=(.8, 0, 0), debug=False): """Init this UI element. Parameters @@ -3268,14 +3404,17 @@ def __init__(self, shape_type, drawpanel=None, position=(0, 0)): Reference to the main canvas on which it is drawn. position : (float, float), optional (x, y) in pixels. + debug : bool, optional + Set visibility of the bounding box around the shapes. """ self.shape = None self.shape_type = shape_type.lower() self.drawpanel = drawpanel self.max_size = None - self.is_selected = True + self.debug = debug + self.color = np.random.random(3) if color is None else color + self.highlight_color = highlight_color super(DrawShape, self).__init__(position) - self.shape.color = np.random.random(3) def _setup(self): """Setup this UI component. @@ -3291,6 +3430,13 @@ def _setup(self): else: raise IOError('Unknown shape type: {}.'.format(self.shape_type)) + self.shape.color = self.color + + self.cal_bounding_box() + + if self.debug: + self.bb_box = [Rectangle2D(size=(3, 3)) for i in range(4)] + self.shape.on_left_mouse_button_pressed = self.left_button_pressed self.shape.on_left_mouse_button_dragged = self.left_button_dragged self.shape.on_left_mouse_button_released = self.left_button_released @@ -3333,6 +3479,8 @@ def _add_to_scene(self, scene): self._scene = scene self.shape.add_to_scene(scene) self.rotation_slider.add_to_scene(scene) + if self.debug: + scene.add(*[border.actor for border in self.bb_box]) def _get_size(self): return self.shape.size @@ -3361,6 +3509,7 @@ def update_shape_position(self, center_position): new_center = self.clamp_position(center=center_position) self.drawpanel.canvas.update_element(self, new_center, 'center') self.cal_bounding_box() + self.set_bb_box_visibility(True) @property def center(self): @@ -3393,8 +3542,36 @@ def is_selected(self, value): self.selection_change() def selection_change(self): - if not self.is_selected: + if self.is_selected: + self.highlight(True) + self.show_rotation_slider() + self.set_bb_box_visibility(True) + else: + self.highlight(False) self.rotation_slider.set_visibility(False) + self.set_bb_box_visibility(False) + + def highlight(self, value): + self.shape.color = self.highlight_color if value else self.color + + def set_bb_box_visibility(self, value): + if self.debug: + if value: + border_width = 3 + points = [self._bounding_box_min-(0, border_width), + [self._bounding_box_max[0], self._bounding_box_min[1]], + self._bounding_box_min - border_width, + [self._bounding_box_min[0]-border_width, self._bounding_box_max[1]]] + size = [(self._bounding_box_size[0]+border_width, border_width), + (border_width, self._bounding_box_size[1]+border_width), + (border_width, self._bounding_box_size[1] + border_width), + (self._bounding_box_size[0]+border_width, border_width)] + for i in range(4): + self.bb_box[i].position = points[i] + self.bb_box[i].resize(size[i]) + + for border in self.bb_box: + border.set_visibility(value) def rotate(self, angle): """Rotate the vertices of the UI component using specific angle. @@ -3469,23 +3646,34 @@ def resize(self, size): self.shape.outer_radius = hyp self.cal_bounding_box() + self.set_bb_box_visibility(True) def remove(self): - """Remove the Shape and all related actors.""" + """Remove the Shape and all related actors. + """ + self.drawpanel.shape_list.remove(self) self._scene.rm(self.shape.actor) self._scene.rm(*self.rotation_slider.actors) + if self.debug: + self._scene.rm(*[border.actor for border in self.bb_box]) def left_button_pressed(self, i_ren, _obj, shape): mode = self.drawpanel.current_mode - if mode == 'selection': - self.drawpanel.update_shape_selection(self) + if mode == "selection": + self.set_bb_box_visibility(True) + if self.drawpanel.key_status["Control_L"]: + self.drawpanel.shape_group.add(self) + elif not self.drawpanel.shape_group.is_present(self): + self.drawpanel.update_shape_selection(self) click_pos = np.array(i_ren.event.position) self._drag_offset = click_pos - self.center - self.show_rotation_slider() i_ren.event.abort() - elif mode == 'delete': - self.remove() + elif mode == "delete": + if self.drawpanel.shape_group.is_present(self): + self.drawpanel.shape_group.delete_shapes() + else: + self.remove() else: self.drawpanel.left_button_pressed(i_ren, _obj, self.drawpanel) i_ren.force_render() @@ -3495,24 +3683,30 @@ def left_button_dragged(self, i_ren, _obj, shape): self.rotation_slider.set_visibility(False) if self._drag_offset is not None: click_position = i_ren.event.position - relative_center_position = ( - click_position - self._drag_offset - self.drawpanel.canvas.position - ) - self.update_shape_position(relative_center_position) + relative_center_position = click_position - \ + self._drag_offset - self.drawpanel.position + + if self.drawpanel.shape_group.is_present(self): + self.drawpanel.shape_group.update_position( + relative_center_position - self.center) + else: + self.drawpanel.shape_group.clear() + self.update_shape_position(relative_center_position) i_ren.force_render() else: self.drawpanel.left_button_dragged(i_ren, _obj, self.drawpanel) def left_button_released(self, i_ren, _obj, shape): - if self.drawpanel.current_mode == 'selection': + if self.drawpanel.current_mode == "selection" and self.drawpanel.shape_group.is_empty(): self.show_rotation_slider() - i_ren.force_render() + i_ren.force_render() class DrawPanel(UI): """The main Canvas(Panel2D) on which everything would be drawn.""" - def __init__(self, size=(400, 400), position=(0, 0), is_draggable=False): + def __init__(self, size=(400, 400), position=(0, 0), is_draggable=False, + highlight_color=(1, .0, .0), debug=False): """Init this UI element. Parameters @@ -3523,16 +3717,26 @@ def __init__(self, size=(400, 400), position=(0, 0), is_draggable=False): (x, y) in pixels. is_draggable : bool, optional Whether the background canvas will be draggble or not. + debug : bool, optional + Set visibility of the bounding box around the shapes. """ self.panel_size = size super(DrawPanel, self).__init__(position) + self.shape_group = DrawShapeGroup(self) self.is_draggable = is_draggable self.current_mode = None + self.debug = debug + self.highlight_color = highlight_color if is_draggable: self.current_mode = 'selection' self.shape_list = [] + self.key_status = { + "Control_L": False, + "Shift_L": False, + "Alt_L": False + } self.current_shape = None def _setup(self): @@ -3541,8 +3745,14 @@ def _setup(self): Create a Canvas(Panel2D). """ self.canvas = Panel2D(size=self.panel_size) - self.canvas.background.on_left_mouse_button_pressed = self.left_button_pressed - self.canvas.background.on_left_mouse_button_dragged = self.left_button_dragged + self.canvas.background.on_left_mouse_button_pressed = \ + self.left_button_pressed + self.canvas.background.on_left_mouse_button_dragged = \ + self.left_button_dragged + self.canvas.background.on_key_press = \ + self.key_press + self.canvas.background.on_key_release = \ + self.key_release # Todo # Convert mode_data into a private variable and make it read-only @@ -3599,7 +3809,10 @@ def _add_to_scene(self, scene): """ self.current_scene = scene + iren = scene.GetRenderWindow().GetInteractor().GetInteractorStyle() + iren.add_active_prop(self.canvas.actors[0]) self.canvas.add_to_scene(scene) + self.shape_group._scene = scene def _get_size(self): return self.canvas.size @@ -3627,7 +3840,9 @@ def current_mode(self, mode): self.update_button_icons(mode) self._current_mode = mode if mode is not None: - self.mode_text.message = f'Mode: {mode}' + self.mode_text.message = f"Mode: {mode}" + if self.shape_group.is_empty() or mode != "delete": + self.shape_group.clear() def cal_min_boundary_distance(self, position): """Calculate minimum distance between the current position and canvas boundary. @@ -3660,15 +3875,16 @@ def draw_shape(self, shape_type, current_position): current_position: (float,float) Lower left corner position for the shape. """ - shape = DrawShape( - shape_type=shape_type, drawpanel=self, position=current_position - ) - if shape_type == 'circle': + shape = DrawShape(shape_type=shape_type, drawpanel=self, + position=current_position, + highlight_color=self.highlight_color, + debug=self.debug) + if shape_type == "circle": shape.max_size = self.cal_min_boundary_distance(current_position) - self.shape_list.append(shape) - self.update_shape_selection(shape) self.current_scene.add(shape) self.canvas.add_element(shape, current_position - self.canvas.position) + self.shape_list.append(shape) + self.update_shape_selection(shape) def resize_shape(self, current_position): """Resize the shape. @@ -3723,11 +3939,14 @@ def clamp_mouse_position(self, mouse_position): ) def handle_mouse_click(self, position): - if self.current_mode == 'selection': + if self.current_shape: + self.current_shape.is_selected = False + if not self.shape_group.is_empty(): + self.shape_group.clear() + if self.current_mode == "selection": if self.is_draggable: self._drag_offset = position - self.position - self.current_shape.is_selected = False - if self.current_mode in ['line', 'quad', 'circle']: + if self.current_mode in ["line", "quad", "circle"]: self.draw_shape(self.current_mode, position) def left_button_pressed(self, i_ren, _obj, element): @@ -3747,6 +3966,25 @@ def left_button_dragged(self, i_ren, _obj, element): self.handle_mouse_drag(mouse_position) i_ren.force_render() + def handle_keys(self, key, key_char): + mode_from_key = { + "s": "selection", + "l": "line", + "q": "quad", + "c": "circle", + "d": "delete", + } + if key.lower() in mode_from_key.keys(): + self.current_mode = mode_from_key[key.lower()] + + def key_press(self, i_ren, _obj, _drawpanel): + self.handle_keys(i_ren.event.key, i_ren.event.key_char) + self.key_status[i_ren.event.key] = True + i_ren.force_render() + + def key_release(self, i_ren, _obj, _drawpanel): + self.key_status[i_ren.event.key] = False + class PlaybackPanel(UI): """A playback controller that can do essential functionalities. diff --git a/fury/ui/tests/test_elements.py b/fury/ui/tests/test_elements.py index 64d17a632..3ee6f12cb 100644 --- a/fury/ui/tests/test_elements.py +++ b/fury/ui/tests/test_elements.py @@ -1208,6 +1208,37 @@ def test_ui_draw_panel_rotation(interactive=False): event_counter.check_counts(expected) +def test_ui_draw_panel_grouping(interactive=False): + filename = "test_ui_draw_panel_grouping" + recording_filename = pjoin(DATA_DIR, filename + ".log.gz") + expected_events_counts_filename = pjoin(DATA_DIR, filename + ".json") + + drawpanel = ui.DrawPanel(size=(600, 600), position=(30, 10)) + + # Assign the counter callback to every possible event. + event_counter = EventCounter() + event_counter.monitor(drawpanel) + + current_size = (680, 680) + show_manager = window.ShowManager( + size=current_size, title="DrawPanel Grouping UI Example") + show_manager.scene.add(drawpanel) + + # Recorded events: + # 1. Grouping/Ungrouping Shapes + # 2. Translation/Rotation of Grouped Shapes + + if interactive: + show_manager.record_events_to_file(recording_filename) + print(list(event_counter.events_counts.items())) + event_counter.save(expected_events_counts_filename) + + else: + show_manager.play_events_from_file(recording_filename) + expected = EventCounter.load(expected_events_counts_filename) + event_counter.check_counts(expected) + + def test_playback_panel(interactive=False): global playing, paused, stopped, loop, ts