Repository: ManimCommunity/manim

Issue:
ReplacementTransform replaces object to another one not in right order on the scene
## Description of bug / unexpected behavior
ReplacementTransform replace object to another one not in right order on the scene

## How to reproduce the issue
```py
class TestReplacementTransform(MovingCameraScene):
    def construct(self):
        mob_red = Circle(fill_color=RED, fill_opacity=1, stroke_opacity=0).shift(UP*0.5)
        mob_green = Circle(fill_color=GREEN, fill_opacity=1, stroke_opacity=0)
        mob_blue = Circle(fill_color=BLUE, fill_opacity=1, stroke_opacity=0).shift(DOWN*0.5)

        self.add(mob_green)
        self.add(mob_blue)
        
        self.play(
            ReplacementTransform(mob_green, mob_red),
            )
                
        self.wait()
```

## Additional media files
![TestReplacementTransform](https://user-images.githubusercontent.com/64367553/101447365-24011500-3936-11eb-891f-7ae1696bce5e.gif)

## Suggestions
ReplacementTransform should override clean_up_from_scene(...) method. Somehow like this:
```py
    def clean_up_from_scene(self, scene):
        super().clean_up_from_scene(scene)
        scene.replace(self.mobject, self.target_mobject)
```

However, to do that we need to create replace method in Scene class.

## System specifications
manim version: latest master-branch



Discussion:

Resolving patch:
diff --git a/manim/animation/transform.py b/manim/animation/transform.py
--- a/manim/animation/transform.py
+++ b/manim/animation/transform.py
@@ -211,8 +211,7 @@ def create_target(self) -> Mobject:
     def clean_up_from_scene(self, scene: Scene) -> None:
         super().clean_up_from_scene(scene)
         if self.replace_mobject_with_target_in_scene:
-            scene.remove(self.mobject)
-            scene.add(self.target_mobject)
+            scene.replace(self.mobject, self.target_mobject)
 
     def get_all_mobjects(self) -> Sequence[Mobject]:
         return [
diff --git a/manim/scene/scene.py b/manim/scene/scene.py
--- a/manim/scene/scene.py
+++ b/manim/scene/scene.py
@@ -519,6 +519,50 @@ def remove(self, *mobjects: Mobject):
                 self.restructure_mobjects(mobjects, list_name, False)
             return self
 
+    def replace(self, old_mobject: Mobject, new_mobject: Mobject) -> None:
+        """Replace one Mobject in the scene with another, preserving draw order.
+
+        If old_mobject is a submobject of some other Mobject (e.g. a Group),
+        the new_mobject will replace it inside the group, without otherwise
+        changing the parent mobject.
+
+        Parameters
+        ----------
+        old_mobject - A Mobject which must be in the scene. This method asserts if
+                      old_mobject is not in the scene
+        new_mobject - A Mobject which must not already be in the scene.
+
+        """
+        assert old_mobject is not None
+        assert new_mobject is not None
+
+        def replace_in_list(
+            mobj_list: list[Mobject], old_m: Mobject, new_m: Mobject
+        ) -> bool:
+            # We use breadth-first search because some Mobjects get very deep and
+            # we expect top-level elements to be the most common targets for replace.
+            for i in range(0, len(mobj_list)):
+                # Is this the old mobject?
+                if mobj_list[i] == old_m:
+                    # If so, write the new object to the same spot and stop looking.
+                    mobj_list[i] = new_m
+                    return True
+            # Now check all the children of all these mobs.
+            for mob in mobj_list:  # noqa: SIM110
+                if replace_in_list(mob.submobjects, old_m, new_m):
+                    # If we found it in a submobject, stop looking.
+                    return True
+            # If we did not find the mobject in the mobject list or any submobjects,
+            # (or the list was empty), indicate we did not make the replacement.
+            return False
+
+        # Make use of short-circuiting conditionals to check mobjects and then
+        # foreground_mobjects
+        replaced = replace_in_list(
+            self.mobjects, old_mobject, new_mobject
+        ) or replace_in_list(self.foreground_mobjects, old_mobject, new_mobject)
+        assert replaced, "Could not find old_mobject in Scene"
+
     def add_updater(self, func: Callable[[float], None]) -> None:
         """Add an update function to the scene.
 
diff --git a/scripts/extract_frames.py b/scripts/extract_frames.py
--- a/scripts/extract_frames.py
+++ b/scripts/extract_frames.py
@@ -1,3 +1,4 @@
+import pathlib
 import sys
 
 import numpy as np
@@ -9,7 +10,7 @@ def main():
         print_usage()
         sys.exit(1)
     npz_file = sys.argv[1]
-    output_folder = Path(sys.argv[2])
+    output_folder = pathlib.Path(sys.argv[2])
     if not output_folder.exists():
         output_folder.mkdir(parents=True)
 


Test patch:
diff --git a/tests/module/scene/test_scene.py b/tests/module/scene/test_scene.py
--- a/tests/module/scene/test_scene.py
+++ b/tests/module/scene/test_scene.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from manim import Circle, FadeIn, Mobject, Scene, Square, tempconfig
+from manim import Circle, FadeIn, Group, Mobject, Scene, Square, tempconfig
 from manim.animation.animation import Wait
 
 
@@ -73,3 +73,35 @@ def test_subcaption():
         assert subcaptions[1].start == datetime.timedelta(seconds=1.5)
         assert subcaptions[1].end == datetime.timedelta(seconds=3)
         assert subcaptions[1].content == "Testing Scene.play subcaption interface"
+
+
+def test_replace():
+    def assert_names(mobjs, names):
+        assert len(mobjs) == len(names)
+        for i in range(0, len(mobjs)):
+            assert mobjs[i].name == names[i]
+
+    with tempconfig({"dry_run": True}):
+        scene = Scene()
+
+        first = Mobject(name="first")
+        second = Mobject(name="second")
+        third = Mobject(name="third")
+        fourth = Mobject(name="fourth")
+
+        scene.add(first)
+        scene.add(Group(second, third, name="group"))
+        scene.add(fourth)
+        assert_names(scene.mobjects, ["first", "group", "fourth"])
+        assert_names(scene.mobjects[1], ["second", "third"])
+
+        alpha = Mobject(name="alpha")
+        beta = Mobject(name="beta")
+
+        scene.replace(first, alpha)
+        assert_names(scene.mobjects, ["alpha", "group", "fourth"])
+        assert_names(scene.mobjects[1], ["second", "third"])
+
+        scene.replace(second, beta)
+        assert_names(scene.mobjects, ["alpha", "group", "fourth"])
+        assert_names(scene.mobjects[1], ["beta", "third"])
diff --git a/tests/test_graphical_units/control_data/transform/ReplacementTransform.npz b/tests/test_graphical_units/control_data/transform/ReplacementTransform.npz
Binary files a/tests/test_graphical_units/control_data/transform/ReplacementTransform.npz and b/tests/test_graphical_units/control_data/transform/ReplacementTransform.npz differ
diff --git a/tests/test_graphical_units/test_transform.py b/tests/test_graphical_units/test_transform.py
--- a/tests/test_graphical_units/test_transform.py
+++ b/tests/test_graphical_units/test_transform.py
@@ -159,10 +159,30 @@ def test_AnimationBuilder(scene):
 
 @frames_comparison(last_frame=False)
 def test_ReplacementTransform(scene):
-    v1 = Vector()
-    v2 = Vector()
-    v3 = Line()
-    scene.play(ReplacementTransform(VGroup(v1, v2), v3))
+    yellow = Square(fill_opacity=1.0, fill_color=YELLOW)
+    yellow.move_to([0, 0.75, 0])
+
+    green = Square(fill_opacity=1.0, fill_color=GREEN)
+    green.move_to([-0.75, 0, 0])
+
+    blue = Square(fill_opacity=1.0, fill_color=BLUE)
+    blue.move_to([0.75, 0, 0])
+
+    orange = Square(fill_opacity=1.0, fill_color=ORANGE)
+    orange.move_to([0, -0.75, 0])
+
+    scene.add(yellow)
+    scene.add(VGroup(green, blue))
+    scene.add(orange)
+
+    purple = Circle(fill_opacity=1.0, fill_color=PURPLE)
+    purple.move_to(green)
+
+    scene.play(ReplacementTransform(green, purple))
+    # This pause is important to verify the purple circle remains behind
+    # the blue and orange squares, and the blue square remains behind the
+    # orange square after the transform fully completes.
+    scene.pause()
 
 
 @frames_comparison(last_frame=False)