mirror of
https://github.com/3b1b/manim.git
synced 2025-08-05 16:49:03 +00:00
Add a rudimentary InteractiveScene to allow for Mobject editing in a GUI fashion
This commit is contained in:
parent
e579f4c955
commit
c3afc84bfe
2 changed files with 418 additions and 0 deletions
|
@ -53,6 +53,7 @@ from manimlib.mobject.value_tracker import *
|
|||
from manimlib.mobject.vector_field import *
|
||||
|
||||
from manimlib.scene.scene import *
|
||||
from manimlib.scene.interactive_scene import *
|
||||
from manimlib.scene.three_d_scene import *
|
||||
|
||||
from manimlib.utils.bezier import *
|
||||
|
|
417
manimlib/scene/interactive_scene.py
Normal file
417
manimlib/scene/interactive_scene.py
Normal file
|
@ -0,0 +1,417 @@
|
|||
import numpy as np
|
||||
import itertools as it
|
||||
import pyperclip
|
||||
import os
|
||||
|
||||
from manimlib.animation.fading import FadeIn
|
||||
from manimlib.constants import MANIM_COLORS, WHITE, YELLOW
|
||||
from manimlib.constants import ORIGIN, UP, DOWN, LEFT, RIGHT
|
||||
from manimlib.constants import FRAME_WIDTH, SMALL_BUFF
|
||||
from manimlib.constants import CTRL_SYMBOL, SHIFT_SYMBOL, DELETE_SYMBOL, ARROW_SYMBOLS
|
||||
from manimlib.constants import SHIFT_MODIFIER, COMMAND_MODIFIER
|
||||
from manimlib.mobject.mobject import Mobject
|
||||
from manimlib.mobject.geometry import Rectangle
|
||||
from manimlib.mobject.geometry import Square
|
||||
from manimlib.mobject.mobject import Group
|
||||
from manimlib.mobject.svg.tex_mobject import Tex
|
||||
from manimlib.mobject.svg.text_mobject import Text
|
||||
from manimlib.mobject.types.vectorized_mobject import VMobject
|
||||
from manimlib.mobject.types.vectorized_mobject import VGroup
|
||||
from manimlib.mobject.types.dot_cloud import DotCloud
|
||||
from manimlib.scene.scene import Scene
|
||||
from manimlib.utils.tex_file_writing import LatexError
|
||||
from manimlib.utils.family_ops import extract_mobject_family_members
|
||||
from manimlib.utils.space_ops import get_norm
|
||||
from manimlib.logger import log
|
||||
|
||||
|
||||
SELECT_KEY = 's'
|
||||
GRAB_KEY = 'g'
|
||||
HORIZONTAL_GRAB_KEY = 'h'
|
||||
VERTICAL_GRAB_KEY = 'v'
|
||||
RESIZE_KEY = 't'
|
||||
COLOR_KEY = 'c'
|
||||
|
||||
|
||||
# Note, a lot of the functionality here is still buggy and very much a work in progress.
|
||||
|
||||
class InteractiveScene(Scene):
|
||||
"""
|
||||
TODO, Document
|
||||
|
||||
To select mobjects on screen, hold ctrl and move the mouse to highlight a region,
|
||||
or just tap ctrl to select the mobject under the cursor.
|
||||
|
||||
Pressing command + t will toggle between modes where you either select top level
|
||||
mobjects part of the scene, or low level pieces.
|
||||
|
||||
Hold 'g' to grab the selection and move it around
|
||||
Hold 'h' to drag it constrained in the horizontal direction
|
||||
Hold 'v' to drag it constrained in the vertical direction
|
||||
Hold 't' to resize selection, adding 'shift' to resize with respect to a corner
|
||||
|
||||
Command + 'c' copies the ids of selections to clipboard
|
||||
Command + 'v' will paste either:
|
||||
- The copied mobject
|
||||
- A Tex mobject based on copied LaTeX
|
||||
- A Text mobject based on copied Text
|
||||
Command + 'z' restores selection back to its original state
|
||||
Command + 's' saves the selected mobjects to file
|
||||
"""
|
||||
corner_dot_config = dict(
|
||||
color=WHITE,
|
||||
radius=0.1,
|
||||
glow_factor=1.0,
|
||||
)
|
||||
selection_rectangle_stroke_color = WHITE
|
||||
selection_rectangle_stroke_width = 1.0
|
||||
colors = MANIM_COLORS
|
||||
selection_nudge_size = 0.05
|
||||
|
||||
def setup(self):
|
||||
self.selection = Group()
|
||||
self.selection_highlight = Group()
|
||||
self.selection_rectangle = self.get_selection_rectangle()
|
||||
self.color_palette = self.get_color_palette()
|
||||
self.unselectables = [
|
||||
self.selection,
|
||||
self.selection_highlight,
|
||||
self.selection_rectangle,
|
||||
self.camera.frame
|
||||
]
|
||||
self.saved_selection_state = []
|
||||
self.select_top_level_mobs = True
|
||||
|
||||
self.is_selecting = False
|
||||
self.add(self.selection_highlight)
|
||||
|
||||
def toggle_selection_mode(self):
|
||||
self.select_top_level_mobs = not self.select_top_level_mobs
|
||||
self.refresh_selection_scope()
|
||||
|
||||
def get_selection_search_set(self):
|
||||
mobs = [m for m in self.mobjects if m not in self.unselectables]
|
||||
if self.select_top_level_mobs:
|
||||
return mobs
|
||||
else:
|
||||
return [
|
||||
submob
|
||||
for mob in mobs
|
||||
for submob in mob.family_members_with_points()
|
||||
]
|
||||
|
||||
def refresh_selection_scope(self):
|
||||
curr = list(self.selection)
|
||||
if self.select_top_level_mobs:
|
||||
self.selection.set_submobjects([
|
||||
mob
|
||||
for mob in self.mobjects
|
||||
if any(sm in mob.get_family() for sm in curr)
|
||||
])
|
||||
self.selection.refresh_bounding_box(recurse_down=True)
|
||||
else:
|
||||
self.selection.set_submobjects(
|
||||
extract_mobject_family_members(
|
||||
curr, exclude_pointless=True,
|
||||
)
|
||||
)
|
||||
self.refresh_selection_highlight()
|
||||
|
||||
def get_selection_rectangle(self):
|
||||
rect = Rectangle(
|
||||
stroke_color=self.selection_rectangle_stroke_color,
|
||||
stroke_width=self.selection_rectangle_stroke_width,
|
||||
)
|
||||
rect.fix_in_frame()
|
||||
rect.fixed_corner = ORIGIN
|
||||
rect.add_updater(self.update_selection_rectangle)
|
||||
return rect
|
||||
|
||||
def get_color_palette(self):
|
||||
palette = VGroup(*(
|
||||
Square(fill_color=color, fill_opacity=1, side_length=1)
|
||||
for color in self.colors
|
||||
))
|
||||
palette.set_stroke(width=0)
|
||||
palette.arrange(RIGHT, buff=0.5)
|
||||
palette.set_width(FRAME_WIDTH - 0.5)
|
||||
palette.to_edge(DOWN, buff=SMALL_BUFF)
|
||||
palette.fix_in_frame()
|
||||
return palette
|
||||
|
||||
def get_stroke_highlight(self, vmobject):
|
||||
outline = vmobject.copy()
|
||||
for sm, osm in zip(vmobject.get_family(), outline.get_family()):
|
||||
osm.set_fill(opacity=0)
|
||||
osm.set_stroke(YELLOW, width=sm.get_stroke_width() + 1.5)
|
||||
outline.add_updater(lambda o: o.replace(vmobject))
|
||||
return outline
|
||||
|
||||
def get_corner_dots(self, mobject):
|
||||
dots = DotCloud(**self.corner_dot_config)
|
||||
dots.add_updater(lambda d: d.set_points(mobject.get_all_corners()))
|
||||
dots.scale((dots.get_width() + dots.get_radius()) / dots.get_width())
|
||||
# Since for flat object, all 8 corners really appear as four, dim the dots
|
||||
if mobject.get_depth() < 1e-2:
|
||||
dots.set_opacity(0.5)
|
||||
return dots
|
||||
|
||||
def get_highlight(self, mobject):
|
||||
if isinstance(mobject, VMobject) and mobject.has_points():
|
||||
return self.get_stroke_highlight(mobject)
|
||||
else:
|
||||
return self.get_corner_dots(mobject)
|
||||
|
||||
def refresh_selection_highlight(self):
|
||||
self.selection_highlight.set_submobjects([
|
||||
self.get_highlight(mob)
|
||||
for mob in self.selection
|
||||
])
|
||||
|
||||
def update_selection_rectangle(self, rect):
|
||||
p1 = rect.fixed_corner
|
||||
p2 = self.mouse_point.get_center()
|
||||
rect.set_points_as_corners([
|
||||
p1, [p2[0], p1[1], 0],
|
||||
p2, [p1[0], p2[1], 0],
|
||||
p1,
|
||||
])
|
||||
return rect
|
||||
|
||||
def add_to_selection(self, *mobjects):
|
||||
for mob in mobjects:
|
||||
if mob in self.unselectables:
|
||||
continue
|
||||
if mob not in self.selection:
|
||||
self.selection.add(mob)
|
||||
self.selection_highlight.add(self.get_highlight(mob))
|
||||
self.saved_selection_state = [
|
||||
(mob, mob.copy())
|
||||
for mob in self.selection
|
||||
]
|
||||
|
||||
def toggle_from_selection(self, *mobjects):
|
||||
for mob in mobjects:
|
||||
if mob in self.selection:
|
||||
self.selection.remove(mob)
|
||||
else:
|
||||
self.add_to_selection(mob)
|
||||
self.refresh_selection_highlight()
|
||||
|
||||
def clear_selection(self):
|
||||
self.selection.set_submobjects([])
|
||||
self.selection_highlight.set_submobjects([])
|
||||
|
||||
def add(self, *new_mobjects: Mobject):
|
||||
for mob in new_mobjects:
|
||||
mob.make_movable()
|
||||
super().add(*new_mobjects)
|
||||
|
||||
# Selection operations
|
||||
|
||||
def copy_selection(self):
|
||||
ids = map(id, self.selection)
|
||||
pyperclip.copy(",".join(map(str, ids)))
|
||||
|
||||
def paste_selection(self):
|
||||
clipboard_str = pyperclip.paste()
|
||||
# Try pasting a mobject
|
||||
try:
|
||||
ids = map(int, clipboard_str.split(","))
|
||||
mobs = map(self.id_to_mobject, ids)
|
||||
mob_copies = [m.copy() for m in mobs if m is not None]
|
||||
self.clear_selection()
|
||||
self.add_to_selection(*mob_copies)
|
||||
self.play(*(
|
||||
FadeIn(mc, run_time=0.5, scale=1.5)
|
||||
for mc in mob_copies
|
||||
))
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
# Otherwise, treat as tex or text
|
||||
if "\\" in clipboard_str: # Proxy to text for LaTeX
|
||||
try:
|
||||
new_mob = Tex(clipboard_str)
|
||||
except LatexError:
|
||||
return
|
||||
else:
|
||||
new_mob = Text(clipboard_str)
|
||||
self.clear_selection()
|
||||
self.add(new_mob)
|
||||
self.add_to_selection(new_mob)
|
||||
new_mob.move_to(self.mouse_point)
|
||||
|
||||
def delete_selection(self):
|
||||
self.remove(*self.selection)
|
||||
self.clear_selection()
|
||||
|
||||
def saved_selection_to_file(self):
|
||||
directory = self.file_writer.get_saved_mobject_directory()
|
||||
files = os.listdir(directory)
|
||||
for mob in self.selection:
|
||||
file_name = str(mob) + "_0.mob"
|
||||
index = 0
|
||||
while file_name in files:
|
||||
file_name = file_name.replace(str(index), str(index + 1))
|
||||
index += 1
|
||||
user_name = input(
|
||||
f"Enter mobject file name (default is {file_name}): "
|
||||
)
|
||||
if user_name:
|
||||
file_name = user_name
|
||||
files.append(file_name)
|
||||
self.save_mobect(mob, file_name)
|
||||
|
||||
def undo(self):
|
||||
mobs = []
|
||||
for mob, state in self.saved_selection_state:
|
||||
mob.become(state)
|
||||
mobs.append(mob)
|
||||
if mob not in self.mobjects:
|
||||
self.add(mob)
|
||||
self.selection.set_submobjects(mobs)
|
||||
self.refresh_selection_highlight()
|
||||
|
||||
def prepare_resizing(self, about_corner=False):
|
||||
center = self.selection.get_center()
|
||||
mp = self.mouse_point.get_center()
|
||||
if about_corner:
|
||||
self.scale_about_point = self.selection.get_corner(center - mp)
|
||||
else:
|
||||
self.scale_about_point = center
|
||||
self.scale_ref_vect = mp - self.scale_about_point
|
||||
self.scale_ref_width = self.selection.get_width()
|
||||
|
||||
# Event handlers
|
||||
|
||||
def on_key_press(self, symbol: int, modifiers: int) -> None:
|
||||
super().on_key_press(symbol, modifiers)
|
||||
char = chr(symbol)
|
||||
# Enable selection
|
||||
if char == SELECT_KEY and modifiers == 0:
|
||||
self.is_selecting = True
|
||||
self.add(self.selection_rectangle)
|
||||
self.selection_rectangle.fixed_corner = self.mouse_point.get_center().copy()
|
||||
# Prepare for move
|
||||
elif char in [GRAB_KEY, HORIZONTAL_GRAB_KEY, VERTICAL_GRAB_KEY] and modifiers == 0:
|
||||
mp = self.mouse_point.get_center()
|
||||
self.mouse_to_selection = mp - self.selection.get_center()
|
||||
# Prepare for resizing
|
||||
elif char == RESIZE_KEY and modifiers in [0, SHIFT_MODIFIER]:
|
||||
self.prepare_resizing(about_corner=(modifiers == SHIFT_MODIFIER))
|
||||
elif symbol == SHIFT_SYMBOL:
|
||||
if self.window.is_key_pressed(ord("t")):
|
||||
self.prepare_resizing(about_corner=True)
|
||||
# Show color palette
|
||||
elif char == COLOR_KEY and modifiers == 0:
|
||||
if len(self.selection) == 0:
|
||||
return
|
||||
if self.color_palette not in self.mobjects:
|
||||
self.add(self.color_palette)
|
||||
else:
|
||||
self.remove(self.color_palette)
|
||||
# Command + c -> Copy mobject ids to clipboard
|
||||
elif char == "c" and modifiers == COMMAND_MODIFIER:
|
||||
self.copy_selection()
|
||||
# Command + v -> Paste
|
||||
elif char == "v" and modifiers == COMMAND_MODIFIER:
|
||||
self.paste_selection()
|
||||
# Command + x -> Cut
|
||||
elif char == "x" and modifiers == COMMAND_MODIFIER:
|
||||
# TODO, this copy won't work, because once the objects are removed,
|
||||
# they're not searched for in the pasting.
|
||||
self.copy_selection()
|
||||
self.delete_selection()
|
||||
# Delete
|
||||
elif symbol == DELETE_SYMBOL:
|
||||
self.delete_selection()
|
||||
# Command + a -> Select all
|
||||
elif char == "a" and modifiers == COMMAND_MODIFIER:
|
||||
self.clear_selection()
|
||||
self.add_to_selection(*self.mobjects)
|
||||
# Command + g -> Group selection
|
||||
elif char == "g" and modifiers == COMMAND_MODIFIER:
|
||||
group = self.get_group(*self.selection)
|
||||
self.add(group)
|
||||
self.clear_selection()
|
||||
self.add_to_selection(group)
|
||||
# Command + shift + g -> Ungroup the selection
|
||||
elif char == "g" and modifiers == COMMAND_MODIFIER | SHIFT_MODIFIER:
|
||||
pieces = []
|
||||
for mob in list(self.selection):
|
||||
self.remove(mob)
|
||||
pieces.extend(list(mob))
|
||||
self.clear_selection()
|
||||
self.add(*pieces)
|
||||
self.add_to_selection(*pieces)
|
||||
# Command + t -> Toggle selection mode
|
||||
elif char == "t" and modifiers == COMMAND_MODIFIER:
|
||||
self.toggle_selection_mode()
|
||||
# Command + z -> Restore selection to original state
|
||||
elif char == "z" and modifiers == COMMAND_MODIFIER:
|
||||
self.undo()
|
||||
# Command + s -> Save selections to file
|
||||
elif char == "s" and modifiers == COMMAND_MODIFIER:
|
||||
self.saved_selection_to_file()
|
||||
# Keyboard movements
|
||||
elif symbol in ARROW_SYMBOLS:
|
||||
nudge = self.selection_nudge_size
|
||||
if (modifiers & SHIFT_MODIFIER):
|
||||
nudge *= 10
|
||||
vect = [LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)]
|
||||
self.selection.shift(nudge * vect)
|
||||
|
||||
def on_key_release(self, symbol: int, modifiers: int) -> None:
|
||||
super().on_key_release(symbol, modifiers)
|
||||
if chr(symbol) == SELECT_KEY:
|
||||
self.is_selecting = False
|
||||
self.remove(self.selection_rectangle)
|
||||
for mob in reversed(self.get_selection_search_set()):
|
||||
if mob.is_movable() and self.selection_rectangle.is_touching(mob):
|
||||
self.add_to_selection(mob)
|
||||
|
||||
elif symbol == SHIFT_SYMBOL:
|
||||
if self.window.is_key_pressed(ord(RESIZE_KEY)):
|
||||
self.prepare_resizing(about_corner=False)
|
||||
|
||||
def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None:
|
||||
super().on_mouse_motion(point, d_point)
|
||||
# Move selection
|
||||
if self.window.is_key_pressed(ord("g")):
|
||||
self.selection.move_to(point - self.mouse_to_selection)
|
||||
# Move selection restricted to horizontal
|
||||
elif self.window.is_key_pressed(ord("h")):
|
||||
self.selection.set_x((point - self.mouse_to_selection)[0])
|
||||
# Move selection restricted to vertical
|
||||
elif self.window.is_key_pressed(ord("v")):
|
||||
self.selection.set_y((point - self.mouse_to_selection)[1])
|
||||
# Scale selection
|
||||
elif self.window.is_key_pressed(ord("t")):
|
||||
# TODO, allow for scaling about the opposite corner
|
||||
vect = point - self.scale_about_point
|
||||
scalar = get_norm(vect) / get_norm(self.scale_ref_vect)
|
||||
self.selection.set_width(
|
||||
scalar * self.scale_ref_width,
|
||||
about_point=self.scale_about_point
|
||||
)
|
||||
|
||||
def on_mouse_release(self, point: np.ndarray, button: int, mods: int) -> None:
|
||||
super().on_mouse_release(point, button, mods)
|
||||
if self.color_palette in self.mobjects:
|
||||
# Search through all mobject on the screne, not just the palette
|
||||
to_search = list(it.chain(*(
|
||||
mobject.family_members_with_points()
|
||||
for mobject in self.mobjects
|
||||
if mobject not in self.unselectables
|
||||
)))
|
||||
mob = self.point_to_mobject(point, to_search)
|
||||
if mob is not None:
|
||||
self.selection.set_color(mob.get_fill_color())
|
||||
self.remove(self.color_palette)
|
||||
elif self.window.is_key_pressed(SHIFT_SYMBOL):
|
||||
mob = self.point_to_mobject(point)
|
||||
if mob is not None:
|
||||
self.toggle_from_selection(mob)
|
||||
else:
|
||||
self.clear_selection()
|
Loading…
Add table
Reference in a new issue