Merge pull request #1323 from sahilmakhijani/shaders

Interactive Mobjects in Manim
This commit is contained in:
Grant Sanderson 2021-01-28 11:59:12 -08:00 committed by GitHub
commit c1ebb583c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 667 additions and 4 deletions

View file

@ -396,4 +396,42 @@ class SurfaceExample(Scene):
self.wait()
class ControlsExample(Scene):
def setup(self):
self.textbox = Textbox()
self.checkbox = Checkbox()
self.color_picker = ColorSliders()
self.panel = ControlPanel(
Text("Text", size=0.5), self.textbox, Line(),
Text("Show/Hide Text", size=0.5), self.checkbox, Line(),
Text("Color of Text", size=0.5), self.color_picker
)
self.add(self.panel)
def construct(self):
text = Text("", size=2)
def text_updater(old_text):
assert(isinstance(old_text, Text))
new_text = Text(self.textbox.get_value(), size=old_text.size)
new_text.align_data_and_family(old_text)
new_text.move_to(old_text)
if self.checkbox.get_value():
new_text.set_fill(
color=self.color_picker.get_picked_color(),
opacity=self.color_picker.get_picked_opacity()
)
else:
new_text.set_opacity(0)
old_text.become(new_text)
text.add_updater(text_updater)
self.add(MotionMobject(text))
self.textbox.set_value("Manim")
self.wait(60)
self.embed()
# See https://github.com/3b1b/videos for many, many more

View file

@ -43,6 +43,7 @@ from manimlib.mobject.number_line import *
from manimlib.mobject.numbers import *
from manimlib.mobject.probability import *
from manimlib.mobject.shape_matchers import *
from manimlib.mobject.interactive import *
from manimlib.mobject.svg.brace import *
from manimlib.mobject.svg.drawings import *
from manimlib.mobject.svg.svg_mobject import *

View file

@ -0,0 +1,517 @@
import numpy as np
from pyglet.window import key as PygletWindowKeys
from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH
from manimlib.constants import LEFT, RIGHT, UP, DOWN, ORIGIN
from manimlib.constants import SMALL_BUFF, MED_SMALL_BUFF, MED_LARGE_BUFF
from manimlib.constants import BLACK, GREY_A, GREY_C, RED, GREEN, BLUE, WHITE
from manimlib.mobject.mobject import Mobject, Group
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.geometry import Dot, Line, Square, Rectangle, RoundedRectangle, Circle
from manimlib.mobject.svg.text_mobject import Text
from manimlib.mobject.value_tracker import ValueTracker
from manimlib.utils.config_ops import digest_config
from manimlib.utils.space_ops import get_norm, get_closest_point_on_line
from manimlib.utils.color import rgb_to_color, color_to_rgba, rgb_to_hex
# Interactive Mobjects
class MotionMobject(Mobject):
"""
You could hold and drag this object to any position
"""
CONFIG = {
"listen_to_events": True
}
def __init__(self, mobject, **kwargs):
super().__init__(**kwargs)
self.mobject = mobject
self.mobject.add_updater(lambda mob: None) # To avoid locking it as static mobject
self.add(mobject)
def on_mouse_drag(self, point, d_point, buttons, modifiers):
if self.mobject.is_point_touching(point):
self.mobject.move_to(point)
return False
class Button(Mobject):
"""
Pass any mobject and register an on_click method
"""
CONFIG = {
"listen_to_events": True
}
def __init__(self, mobject, on_click, **kwargs):
super().__init__(**kwargs)
self.mobject, self.on_click = mobject, on_click
self.add(self.mobject)
def on_mouse_press(self, point, button, mods):
if self.mobject.is_point_touching(point):
self.on_click()
return False
# Controls
class ContolMobject(ValueTracker):
CONFIG = {
"listen_to_events": True
}
def __init__(self, value, *mobjects, **kwargs):
super().__init__(value=value, **kwargs)
self.add(*mobjects)
self.add_updater(lambda mob: None) # To avoid lock_static_mobject_data while waiting in scene
self.fix_in_frame()
def set_value(self, value):
self.assert_value(value)
self.set_value_anim(value)
return ValueTracker.set_value(self, value)
def assert_value(self, value):
# To be implemented in subclasses
pass
def set_value_anim(self, value):
# To be implemented in subclasses
pass
class EnableDisableButton(ContolMobject):
CONFIG = {
"value_type": np.dtype(bool),
"rect_kwargs": {
"width": 0.5,
"height": 0.5,
"fill_opacity": 1.0
},
"enable_color": GREEN,
"disable_color": RED
}
def __init__(self, value=True, **kwargs):
digest_config(self, kwargs)
self.box = Rectangle(**self.rect_kwargs)
super().__init__(value, self.box, **kwargs)
def assert_value(self, value):
assert(value == True or value == False)
def set_value_anim(self, value):
if value == True:
self.box.set_fill(self.enable_color)
elif value == False:
self.box.set_fill(self.disable_color)
def toggle_value(self):
super().set_value(not self.get_value())
def on_mouse_press(self, point, button, mods):
self.toggle_value()
return False
class Checkbox(ContolMobject):
CONFIG = {
"value_type": np.dtype(bool),
"rect_kwargs": {
"width": 0.5,
"height": 0.5,
"fill_opacity": 0.0
},
"checkmark_kwargs": {
"stroke_color": GREEN,
"stroke_width": 6,
},
"cross_kwargs": {
"stroke_color": RED,
"stroke_width": 6,
},
"box_content_buff": SMALL_BUFF
}
def __init__(self, value=True, **kwargs):
digest_config(self, kwargs)
self.box = Rectangle(**self.rect_kwargs)
self.box_content = self.get_checkmark() if value else self.get_cross()
super().__init__(value, self.box, self.box_content, **kwargs)
def assert_value(self, value):
assert(value == True or value == False)
def toggle_value(self):
super().set_value(not self.get_value())
def set_value_anim(self, value):
if value == True:
self.box_content.become(self.get_checkmark())
elif value == False:
self.box_content.become(self.get_cross())
def on_mouse_press(self, point, button, mods):
self.toggle_value()
return False
# Helper methods
def get_checkmark(self):
checkmark = VGroup(
Line(UP / 2 + 2 * LEFT, DOWN + LEFT, **self.checkmark_kwargs),
Line(DOWN + LEFT, UP + RIGHT, **self.checkmark_kwargs)
)
checkmark.stretch_to_fit_width(self.box.get_width())
checkmark.stretch_to_fit_height(self.box.get_height())
checkmark.scale(0.5)
checkmark.move_to(self.box)
return checkmark
def get_cross(self):
cross = VGroup(
Line(UP + LEFT, DOWN + RIGHT, **self.cross_kwargs),
Line(UP + RIGHT, DOWN + LEFT, **self.cross_kwargs)
)
cross.stretch_to_fit_width(self.box.get_width())
cross.stretch_to_fit_height(self.box.get_height())
cross.scale(0.5)
cross.move_to(self.box)
return cross
class LinearNumberSlider(ContolMobject):
CONFIG = {
"value_type": np.float64,
"min_value": -10.0,
"max_value": 10.0,
"step": 1.0,
"rounded_rect_kwargs": {
"height": 0.075,
"width": 2,
"corner_radius": 0.0375
},
"circle_kwargs": {
"radius": 0.1,
"stroke_color": GREY_A,
"fill_color": GREY_A,
"fill_opacity": 1.0
}
}
def __init__(self, value=0, **kwargs):
digest_config(self, kwargs)
self.bar = RoundedRectangle(**self.rounded_rect_kwargs)
self.slider = Circle(**self.circle_kwargs)
self.slider_axis = Line(
start=self.bar.get_bounding_box_point(LEFT),
end=self.bar.get_bounding_box_point(RIGHT)
)
self.slider_axis.set_opacity(0.0)
self.slider.move_to(self.slider_axis)
super().__init__(value, self.bar, self.slider, self.slider_axis, ** kwargs)
def assert_value(self, value):
assert(self.min_value <= value <= self.max_value)
def set_value_anim(self, value):
prop = (value - self.min_value) / (self.max_value - self.min_value)
self.slider.move_to(self.slider_axis.point_from_proportion(prop))
def on_mouse_drag(self, point, d_point, buttons, modifiers):
if self.slider.is_point_touching(point):
self.set_value(self.get_value_from_point(point))
return False
# Helper Methods
def get_value_from_point(self, point):
start, end = self.slider_axis.get_start_and_end()
point_on_line = get_closest_point_on_line(start, end, point)
prop = get_norm(point_on_line - start) / get_norm(end - start)
value = self.min_value + prop * (self.max_value - self.min_value)
no_of_steps = int((value - self.min_value) / self.step)
value_nearest_to_step = self.min_value + no_of_steps * self.step
return value_nearest_to_step
class ColorSliders(Group):
CONFIG = {
"sliders_kwargs": {},
"rect_kwargs": {
"width": 2.0,
"height": 0.5,
"stroke_opacity": 1.0
},
"background_grid_kwargs": {
"colors": [GREY_A, GREY_C],
"single_square_len": 0.1
},
"sliders_buff": MED_LARGE_BUFF,
"default_rgb_value": 255,
"default_a_value": 1,
}
def __init__(self, **kwargs):
digest_config(self, kwargs)
rgb_kwargs = {"value": self.default_rgb_value,"min_value": 0, "max_value": 255, "step": 1}
a_kwargs = {"value": self.default_a_value, "min_value": 0, "max_value": 1, "step": 0.04}
self.r_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
self.g_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
self.b_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
self.a_slider = LinearNumberSlider(**self.sliders_kwargs, **a_kwargs)
self.sliders = Group(
self.r_slider,
self.g_slider,
self.b_slider,
self.a_slider
)
self.sliders.arrange(DOWN, buff=self.sliders_buff)
self.r_slider.slider.set_color(RED)
self.g_slider.slider.set_color(GREEN)
self.b_slider.slider.set_color(BLUE)
self.a_slider.slider.set_color_by_gradient([BLACK, WHITE])
self.selected_color_box = Rectangle(**self.rect_kwargs)
self.selected_color_box.add_updater(
lambda mob: mob.set_fill(
self.get_picked_color(), self.get_picked_opacity()
)
)
self.background = self.get_background()
super().__init__(
Group(self.background, self.selected_color_box).fix_in_frame(),
self.sliders,
**kwargs
)
self.arrange(DOWN)
def get_background(self):
single_square_len = self.background_grid_kwargs["single_square_len"]
colors = self.background_grid_kwargs["colors"]
width = self.rect_kwargs["width"]
height = self.rect_kwargs["height"]
rows = int(height / single_square_len)
cols = int(width / single_square_len)
cols = (cols + 1) if (cols % 2 == 0) else cols
single_square = Square(single_square_len)
grid = single_square.get_grid(n_rows=rows, n_cols=cols, buff=0.0)
grid.stretch_to_fit_width(width)
grid.stretch_to_fit_height(height)
grid.move_to(self.selected_color_box)
for idx, square in enumerate(grid):
assert(isinstance(square, Square))
square.set_stroke(width=0.0, opacity=0.0)
square.set_fill(colors[idx % len(colors)], 1.0)
return grid
def set_value(self, r, g, b, a):
self.r_slider.set_value(r)
self.g_slider.set_value(g)
self.b_slider.set_value(b)
self.a_slider.set_value(a)
def get_value(self):
r = self.r_slider.get_value() / 255
g = self.g_slider.get_value() / 255
b = self.b_slider.get_value() / 255
alpha = self.a_slider.get_value()
return color_to_rgba(rgb_to_color((r, g, b)), alpha=alpha)
def get_picked_color(self):
rgba = self.get_value()
return rgb_to_hex(rgba[:3])
def get_picked_opacity(self):
rgba = self.get_value()
return rgba[3]
class Textbox(ContolMobject):
CONFIG = {
"value_type": np.dtype(object),
"box_kwargs": {
"width": 2.0,
"height": 1.0,
"fill_color": WHITE,
"fill_opacity": 1.0,
},
"text_kwargs": {
"color": BLUE
},
"text_buff": MED_SMALL_BUFF,
"isInitiallyActive": False,
"active_color": BLUE,
"deactive_color": RED,
}
def __init__(self, value="", **kwargs):
digest_config(self, kwargs)
self.isActive = self.isInitiallyActive
self.box = Rectangle(**self.box_kwargs)
self.text = Text(value, **self.text_kwargs)
super().__init__(value, self.box, self.text, **kwargs)
self.update_text(value)
self.active_anim(self.isActive)
def set_value_anim(self, value):
self.update_text(value)
def update_text(self, value):
self.remove(self.text)
self.text.__init__(value, **self.text_kwargs)
self.text.set_width(self.box.get_width() - 2*self.text_buff, stretch=True)
self.text.add_updater(lambda mob: mob.move_to(self.box))
self.text.fix_in_frame()
self.add(self.text)
def active_anim(self, isActive):
if isActive:
self.box.set_stroke(self.active_color)
else:
self.box.set_stroke(self.deactive_color)
def on_mouse_press(self, point, button, mods):
if self.box.is_point_touching(point):
self.isActive = not self.isActive
self.active_anim(self.isActive)
return False
def on_key_press(self, symbol, modifiers):
char = chr(symbol)
if self.isActive:
old_value = self.get_value()
new_value = old_value
if char.isalnum():
if (modifiers & PygletWindowKeys.MOD_SHIFT) or (modifiers & PygletWindowKeys.MOD_CAPSLOCK):
new_value = old_value + char.upper()
else:
new_value = old_value + char.lower()
elif symbol in [PygletWindowKeys.SPACE]:
new_value = old_value + char
elif symbol == PygletWindowKeys.TAB:
new_value = old_value + '\t'
elif symbol == PygletWindowKeys.BACKSPACE:
new_value = old_value[:-1] or ''
self.set_value(new_value)
return False
class ControlPanel(Group):
CONFIG = {
"listen_to_events": True,
"panel_kwargs": {
"width": FRAME_WIDTH / 4,
"height": MED_SMALL_BUFF + FRAME_HEIGHT,
"fill_color": GREY_C,
"fill_opacity": 1.0,
"stroke_width": 0.0
},
"opener_kwargs": {
"width": FRAME_WIDTH / 8,
"height": 0.5,
"fill_color": GREY_C,
"fill_opacity": 1.0
},
"opener_text_kwargs": {
"text": "Control Panel",
"size": 0.4
}
}
def __init__(self, *controls, **kwargs):
digest_config(self, kwargs)
self.panel = Rectangle(**self.panel_kwargs)
self.panel.to_corner(UP + LEFT, buff=0)
self.panel.shift(self.panel.get_height() * UP)
self.panel_opener_rect = Rectangle(**self.opener_kwargs)
self.panel_info_text = Text(**self.opener_text_kwargs)
self.panel_info_text.move_to(self.panel_opener_rect)
self.panel_opener = Group(self.panel_opener_rect, self.panel_info_text)
self.panel_opener.next_to(self.panel, DOWN, aligned_edge=DOWN)
self.controls = Group(*controls)
self.controls.arrange(DOWN, center=False, aligned_edge=ORIGIN)
self.controls.move_to(self.panel)
super().__init__(
self.panel, self.panel_opener,
self.controls,
**kwargs
)
self.move_panel_and_controls_to_panel_opener()
self.fix_in_frame()
def move_panel_and_controls_to_panel_opener(self):
self.panel.next_to(
self.panel_opener_rect,
direction=UP,
buff=0
)
controls_old_x = self.controls.get_x()
self.controls.next_to(
self.panel_opener_rect,
direction=UP,
buff=MED_SMALL_BUFF
)
self.controls.set_x(controls_old_x)
def add_controls(self, *new_controls):
self.controls.add(*new_controls)
self.move_panel_and_controls_to_panel_opener()
def remove_controls(self, *controls_to_remove):
self.controls.remove(*controls_to_remove)
self.move_panel_and_controls_to_panel_opener()
def open_panel(self):
panel_opener_x = self.panel_opener.get_x()
self.panel_opener.to_corner(DOWN + LEFT, buff=0.0)
self.panel_opener.set_x(panel_opener_x)
self.move_panel_and_controls_to_panel_opener()
return self
def close_panel(self):
panel_opener_x = self.panel_opener.get_x()
self.panel_opener.to_corner(UP + LEFT, buff=0.0)
self.panel_opener.set_x(panel_opener_x)
self.move_panel_and_controls_to_panel_opener()
return self
def on_mouse_drag(self, point, d_point, buttons, modifiers):
if self.panel_opener.is_point_touching(point):
self.panel_opener.match_y(Dot(point))
self.move_panel_and_controls_to_panel_opener()
return False
def on_mouse_scroll(self, point, offset):
if self.panel.is_point_touching(point):
factor = 10 * offset[1]
self.controls.set_y(self.controls.get_y() + factor)
return False

View file

@ -52,7 +52,9 @@ class Mobject(object):
# Must match in attributes of vert shader
"shader_dtype": [
('point', np.float32, (3,)),
]
],
# Event listener
"listen_to_events": False
}
def __init__(self, **kwargs):
@ -215,6 +217,14 @@ class Mobject(object):
parent.refresh_bounding_box()
return self
def is_point_touching(self, point, buff=MED_SMALL_BUFF):
self.refresh_bounding_box()
bb = self.get_bounding_box()
if np.all(point >= (bb[0] - buff)) and np.all(point <= (bb[2] + buff)):
return True
else:
return False
# Family matters
def __getitem__(self, value):
@ -1434,6 +1444,41 @@ class Mobject(object):
def get_shader_vert_indices(self):
return self.shader_indices
# Event Handlers
"""
Event handling follows the Event Bubbling model of DOM in javascript.
Return false to stop the event bubbling.
To learn more visit https://www.quirksmode.org/js/events_order.html
"""
def on_mouse_motion(self, point, d_point):
# To be implemented in subclasses
pass
def on_mouse_drag(self, point, d_point, buttons, modifiers):
# To be implemented in subclasses
pass
def on_mouse_press(self, point, button, mods):
# To be implemented in subclasses
pass
def on_mouse_release(self, point, button, mods):
# To be implemented in subclasses
pass
def on_mouse_scroll(self, point, offset):
# To be implemented in subclasses
pass
def on_key_release(self, symbol, modifiers):
# To be implemented in subclasses
pass
def on_key_press(self, symbol, modifiers):
# To be implemented in subclasses
pass
# Errors
def throw_error_if_no_points(self):

View file

@ -513,8 +513,26 @@ class Scene(object):
self.mobjects = mobjects
# Event handling
def get_event_listeners_mobjects(self):
"""
This method returns all the mobjects that listen to events
in reversed order. So the top most mobject's event is called first.
This helps in event bubbling.
"""
return filter(
lambda mob: mob.listen_to_events,
reversed(self.get_mobject_family_members())
)
def on_mouse_motion(self, point, d_point):
self.mouse_point.move_to(point)
for mob_listener in self.get_event_listeners_mobjects():
if mob_listener.is_point_touching(point):
propagate_event = mob_listener.on_mouse_motion(point, d_point)
if propagate_event is not None and propagate_event is False:
return
frame = self.camera.frame
if self.window.is_key_pressed(ord("d")):
frame.increment_theta(-d_point[0])
@ -530,13 +548,33 @@ class Scene(object):
def on_mouse_drag(self, point, d_point, buttons, modifiers):
self.mouse_drag_point.move_to(point)
for mob_listener in self.get_event_listeners_mobjects():
if mob_listener.is_point_touching(point):
propagate_event = mob_listener.on_mouse_drag(point, d_point, buttons, modifiers)
if propagate_event is not None and propagate_event is False:
return
def on_mouse_press(self, point, button, mods):
pass
for mob_listener in self.get_event_listeners_mobjects():
if mob_listener.is_point_touching(point):
propagate_event = mob_listener.on_mouse_press(point, button, mods)
if propagate_event is not None and propagate_event is False:
return
def on_mouse_release(self, point, button, mods):
pass
for mob_listener in self.get_event_listeners_mobjects():
if mob_listener.is_point_touching(point):
propagate_event = mob_listener.on_mouse_release(point, button, mods)
if propagate_event is not None and propagate_event is False:
return
def on_mouse_scroll(self, point, offset):
for mob_listener in self.get_event_listeners_mobjects():
if mob_listener.is_point_touching(point):
propagate_event = mob_listener.on_mouse_scroll(point, offset)
if propagate_event is not None and propagate_event is False:
return
frame = self.camera.frame
if self.window.is_key_pressed(ord("z")):
factor = 1 + np.arctan(10 * offset[1])
@ -547,7 +585,10 @@ class Scene(object):
frame.shift(-20.0 * shift)
def on_key_release(self, symbol, modifiers):
pass
for mob_listener in self.get_event_listeners_mobjects():
propagate_event = mob_listener.on_key_release(symbol, modifiers)
if propagate_event is not None and propagate_event is False:
return
def on_key_press(self, symbol, modifiers):
try:
@ -555,6 +596,12 @@ class Scene(object):
except OverflowError:
print(" Warning: The value of the pressed key is too large.")
return
for mob_listener in self.get_event_listeners_mobjects():
propagate_event = mob_listener.on_key_press(symbol, modifiers)
if propagate_event is not None and propagate_event is False:
return
if char == "r":
self.camera.frame.to_default_state()
elif char == "q":

View file

@ -290,6 +290,21 @@ def find_intersection(p0, v0, p1, v1, threshold=1e-5):
return p0 + ratio * v0
def get_closest_point_on_line(a, b, p):
"""
It returns point x such that
x is on line ab and xp is perpendicular to ab.
If x lies beyond ab line, then it returns nearest edge(a or b).
"""
# x = b + t*(a-b) = t*a + (1-t)*b
t = np.dot(p - b, a - b) / np.dot(a - b, a - b)
if t < 0:
t = 0
if t > 1:
t = 1
return ((t * a) + ((1 - t) * b))
def get_winding_number(points):
total_angle = 0
for p1, p2 in adjacent_pairs(points):