Repository: ManimCommunity/manim

Issue:
TransformMatchingAbstractBase leaks transparent mobjects into scene
## Description of bug / unexpected behavior
when there are unmatched targets in `TransformMatchingAbstractBase`, it appends a `FadeIn` on `fade_target.copy()`. This gets added to the scene as part of the `AnimationGroup.group` during `Scene.add_mobjects_from_animations` because `TransformMatchingAbstractBase` is not an introducer.

This bug seems to be hidden by the call to `anim.interpolate(0)` during `clean_up_from_scene`, which resets the source mobject, but also hides the copied `fade_target`s. This code hasn't changed since it was introduced in #1037

## Expected behavior
Things seem to work fine by just calling `FadeIn` on `fade_target` instead of a copy. This doesn't leak transparent mobjects into the scene, at the very least.

## How to reproduce the issue
<!-- Provide a piece of code illustrating the undesired behavior. -->

<details><summary>Code for reproducing the problem</summary>

```py
import manim as m

class MainScene(m.Scene):
    def construct(self):
        square = m.Square()
        circle = m.Circle()
        self.add(square)
        self.play(m.TransformMatchingShapes(square, circle))
        assert len(self.mobjects) == 1, len(self.mobjects)
```

</details>


## Logs
<details><summary>Terminal output</summary>
<!-- Add "-v DEBUG" when calling manim to generate more detailed logs -->

```
Manim Community v0.15.2

[06/12/22 21:15:55] INFO     Animation 0 : Partial movie file written in '[REDACTED]\reproducer\480p15\partial_movie_files\MainScene\3163782288_2194214193_2358810818.mp4' scene_file_writer.py:504
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ C:\Python39\lib\site-packages\manim\cli\render\commands.py:118 in render                         │
│                                                                                                  │
│   115 │   │   for SceneClass in scene_classes_from_file(file):                                   │
│   116 │   │   │   try:                                                                           │
│   117 │   │   │   │   scene = SceneClass()                                                       │
│ ❱ 118 │   │   │   │   scene.render()                                                             │
│   119 │   │   │   except Exception:                                                              │
│   120 │   │   │   │   error_console.print_exception()                                            │
│   121 │   │   │   │   sys.exit(1)                                                                │
│                                                                                                  │
│ C:\Python39\lib\site-packages\manim\scene\scene.py:222 in render                                 │
│                                                                                                  │
│    219 │   │   """                                                                               │
│    220 │   │   self.setup()                                                                      │
│    221 │   │   try:                                                                              │
│ ❱  222 │   │   │   self.construct()                                                              │
│    223 │   │   except EndSceneEarlyException:                                                    │
│    224 │   │   │   pass                                                                          │
│    225 │   │   except RerunSceneException as e:                                                  │
│                                                                                                  │
│ [REDACTED]\reproducer.py:9 in construct                                                          │
│                                                                                                  │
│   6 │   │   circle = m.Circle()                                                                  │
│   7 │   │   self.add(square)                                                                     │
│   8 │   │   self.play(m.TransformMatchingShapes(square, circle))                                 │
│ ❱ 9 │   │   assert len(self.mobjects) == 1, len(self.mobjects)                                   │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
AssertionError: 2
```

</details>


## System specifications

<details><summary>System Details</summary>

- OS: Windows 10 10.0.19043 Build 19043
- Python version: Python 3.9.5
- Installed modules:
```
manim                          0.15.2
...
```
</details>





Discussion:
The problem with calling `FadeIn` on the raw `fade_target` is that this causes the target mobject to disappear after the animation.

I did some poking around to see if I could figure out why this was the case and came up blank. I also couldn't persuade the program to give correct behavior through various tweaks in what exactly is copied and removed. The extra object is a `VGroup[VGroup[Circle]]`, and I can't figure out what the ghost `Circle` is or where it comes from.

Here's the base script I've been using for experimentation:
```py
import manim as m

class MainScene(m.Scene):
    def construct(self):
        square = m.Square()
        circle = m.Circle()
        self.add(square)
        self.play(m.TransformMatchingShapes(square, circle))
        # Should have one Circle Mobject
        print(self.mobjects)
        # Should see circle while waiting
        self.wait(0.5)
        # Should see the one circle shift with no duplication after shift
        self.play(circle.animate.shift(m.DOWN))
```
@Swiddis the target disappears because of the call to `anim.interpolate(0)` during `clean_up_from_scene`. To patch this behaviour, I have `clean_up_from_scene` skip the `anim.interpolate` call on any instance of `FadeIn`
```python
    for anim in self.animations:
        if not isinstance(anim, m.FadeIn):
            anim.interpolate(0)
    scene.remove(*self.mobject.get_family())
    scene.remove(*self.to_remove.get_family())
    scene.add(self.to_add)
```
the `interpolate(0)` is necessary to ensure that the source mobjects of the transformation go back to being what they used to be, but `fade_target` is not a source mobject.

Another potential solution is to just delete any target mobjects of `m.FadeIn` instances in `self.animations`, and keeping `fade_target` as a copy. That would probably be safer for preserving existing behaviour, but I'm not sure.
@AndrewYY that fix looks good to me, it passes all the tests and I can't figure out any genuine uses for the transparent object if it stays. I agree that it seems fragile but I don't think it's a big deal at the moment. We should just get it under test so that it doesn't get broken in the future.

Tonight I'll see if I can write up unit tests corresponding to the three annotations in my last comment's code, if it goes well I can have a PR for this done tonight or tomorrow.

Resolving patch:
diff --git a/manim/animation/transform_matching_parts.py b/manim/animation/transform_matching_parts.py
--- a/manim/animation/transform_matching_parts.py
+++ b/manim/animation/transform_matching_parts.py
@@ -122,6 +122,7 @@ def __init__(
             fade_source.add(source_map[key])
         for key in set(target_map).difference(source_map):
             fade_target.add(target_map[key])
+        fade_target_copy = fade_target.copy()
 
         if transform_mismatches:
             if "replace_mobject_with_target_in_scene" not in kwargs:
@@ -132,12 +133,12 @@ def __init__(
         else:
             anims.append(FadeOut(fade_source, target_position=fade_target, **kwargs))
             anims.append(
-                FadeIn(fade_target.copy(), target_position=fade_target, **kwargs),
+                FadeIn(fade_target_copy, target_position=fade_target, **kwargs),
             )
 
         super().__init__(*anims)
 
-        self.to_remove = mobject
+        self.to_remove = [mobject, fade_target_copy]
         self.to_add = target_mobject
 
     def get_shape_map(self, mobject: Mobject) -> dict:
@@ -153,10 +154,11 @@ def get_shape_map(self, mobject: Mobject) -> dict:
         return shape_map
 
     def clean_up_from_scene(self, scene: Scene) -> None:
+        # Interpolate all animations back to 0 to ensure source mobjects remain unchanged.
         for anim in self.animations:
             anim.interpolate(0)
         scene.remove(self.mobject)
-        scene.remove(self.to_remove)
+        scene.remove(*self.to_remove)
         scene.add(self.to_add)
 
     @staticmethod


Test patch:
diff --git a/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingDisplaysCorrect.npz b/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingDisplaysCorrect.npz
new file mode 100644
Binary files /dev/null and b/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingDisplaysCorrect.npz differ
diff --git a/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingLeavesOneObject.npz b/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingLeavesOneObject.npz
new file mode 100644
Binary files /dev/null and b/tests/test_graphical_units/control_data/transform_matching_parts/TransformMatchingLeavesOneObject.npz differ
diff --git a/tests/test_graphical_units/test_transform_matching_parts.py b/tests/test_graphical_units/test_transform_matching_parts.py
new file mode 100644
--- /dev/null
+++ b/tests/test_graphical_units/test_transform_matching_parts.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from manim import *
+from manim.utils.testing.frames_comparison import frames_comparison
+
+__module_test__ = "transform_matching_parts"
+
+
+@frames_comparison(last_frame=True)
+def test_TransformMatchingLeavesOneObject(scene):
+    square = Square()
+    circle = Circle().shift(RIGHT)
+    scene.add(square)
+    scene.play(TransformMatchingShapes(square, circle))
+    assert len(scene.mobjects) == 1 and isinstance(scene.mobjects[0], Circle)
+
+
+@frames_comparison(last_frame=False)
+def test_TransformMatchingDisplaysCorrect(scene):
+    square = Square()
+    circle = Circle().shift(RIGHT)
+    scene.add(square)
+    scene.play(TransformMatchingShapes(square, circle))
+    # Wait to make sure object isn't missing in-between animations
+    scene.wait(0.5)
+    # Shift to make sure object isn't duplicated if moved
+    scene.play(circle.animate.shift(DOWN))