Repository: ManimCommunity/manim

Issue:
Ticks on axes not symmetrical with certain range and step values
## Description of bug / unexpected behavior
<!-- Add a clear and concise description of the problem you encountered. -->

When using the Axes class, the ticks are created based on the x_range / y_range and the steps. The steps default to 1 and for certain ranges the ticks will not be symmetrical across the x and y-axis. I am not sure if this is a bug I am reporting or if this is expected, but I think it would be an improvement to have the ticks be symmetrical across the axes.

## Expected behavior
<!-- Add a clear and concise description of what you expected to happen. -->

When determining the tick locations, to adjust their positions regardless of the step to be symmetrical across the axes. In this example I was using pi for min and max values for each axis, with the default step size of 1.

## How to reproduce the issue
<!-- Provide a piece of code illustrating the undesired behavior. -->

<details><summary>Code for reproducing the problem</summary>

```py
class AxesExample(Scene):

    def construct(self):
        axes = Axes(
            x_range=[-PI, PI],
            y_range=[-PI, PI]
        )
        self.add(axes)
```

</details>


## Additional media files
<!-- Paste in the files manim produced on rendering the code above. -->

<details><summary>Images/GIFs</summary>

![AxesExample_ManimCE_v0 5 0](https://user-images.githubusercontent.com/32387857/120207746-b539b000-c224-11eb-9fc1-231e9505b5eb.png)

</details>


## Logs
<details><summary>Terminal output</summary>
<!-- Add "-v DEBUG" when calling manim to generate more detailed logs -->

```
Manim Community v0.5.0

[05/31/21 15:26:27] INFO                                scene_file_writer.py:585
                             File ready at /Users/ryan/                         
                             PycharmProjects/manim-comm                         
                             unity/manim/media/images/i                         
                             ntegrals/AxesExample_Manim                         
                             CE_v0.5.0.png                                      
                                                                                
                    INFO     Rendered AxesExample                   scene.py:220
                             Played 0 animations                                
                    INFO     Previewed File at: /Users/ryan/Pych file_ops.py:101
                             armProjects/manim-community/manim/m                
                             edia/images/integrals/AxesExample_M                
                             animCE_v0.5.0.png 
```

<!-- Insert screenshots here (only when absolutely necessary, we prefer copy/pasted output!) -->

</details>


## System specifications

<details><summary>System Details</summary>

- OS (with version, e.g Windows 10 v2004 or macOS 11.2.3 (Big Sur)):
- RAM: 8gb
- Python version (`python/py/python3 --version`): 3.7.4
- Installed modules (provide output from `pip list`):
```
Package             Version
------------------- -----------
arrow               0.17.0
attrs               20.3.0
cairocffi           1.2.0
certifi             2020.12.5
cffi                1.14.4
chardet             4.0.0
click               7.1.2
click-default-group 1.2.2
cloup               0.7.0
colorama            0.4.4
colour              0.1.5
commonmark          0.9.1
decorator           4.4.2
ffprobe             0.5
glcontext           2.3.3
idna                2.10
importlib-metadata  3.1.0
iniconfig           1.1.1
Jinja2              2.11.2
jinja2-time         0.2.0
make                0.1.6.post2
manim               0.5.0
manimce             0.1.0
ManimPango          0.2.4
mapbox-earcut       0.12.10
MarkupSafe          1.1.1
moderngl            5.6.4
moderngl-window     2.3.0
multipledispatch    0.6.0
networkx            2.5
numpy               1.19.4
packaging           20.7
pangocairocffi      0.4.0
pangocffi           0.8.0
Pillow              6.1.0
pip                 20.3.3
pluggy              0.13.1
progressbar         2.5
py                  1.9.0
pycairo             1.20.0
pycparser           2.20
pydub               0.24.1
pyglet              1.5.15
Pygments            2.7.2
pyparsing           2.4.7
pyrr                0.10.3
pytest              6.1.2
python-dateutil     2.8.1
requests            2.25.1
rich                6.2.0
scipy               1.5.4
setuptools          50.3.2
six                 1.15.0
toml                0.10.2
tqdm                4.54.0
typing-extensions   3.7.4.3
urllib3             1.26.4
watchdog            2.1.2
zipp                3.4.0
```
</details>

<details><summary>LaTeX details</summary>

+ LaTeX distribution (e.g. TeX Live 2020):
+ Installed LaTeX packages:
<!-- output of `tlmgr list --only-installed` for TeX Live or a screenshot of the Packages page for MikTeX -->
</details>

<details><summary>FFMPEG</summary>

Output of `ffmpeg -version`:

```
ffmpeg version 4.3.1-tessus  https://evermeet.cx/ffmpeg/  Copyright (c) 2000-2020 the FFmpeg developers
built with Apple clang version 11.0.0 (clang-1100.0.33.17)
configuration: --cc=/usr/bin/clang --prefix=/opt/ffmpeg --extra-version=tessus --enable-avisynth --enable-fontconfig --enable-gpl --enable-libaom --enable-libass --enable-libbluray --enable-libdav1d --enable-libfreetype --enable-libgsm --enable-libmodplug --enable-libmp3lame --enable-libmysofa --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenh264 --enable-libopenjpeg --enable-libopus --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvmaf --enable-libvo-amrwbenc --enable-libvorbis --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-version3 --pkg-config-flags=--static --disable-ffplay
libavutil      56. 51.100 / 56. 51.100
libavcodec     58. 91.100 / 58. 91.100
libavformat    58. 45.100 / 58. 45.100
libavdevice    58. 10.100 / 58. 10.100
libavfilter     7. 85.100 /  7. 85.100
libswscale      5.  7.100 /  5.  7.100
libswresample   3.  7.100 /  3.  7.100
libpostproc    55.  7.100 / 55.  7.100
```
</details>

## Additional comments
<!-- Add further context that you think might be relevant for this issue here. -->



Discussion:
Adjusting the code to be an integer multiple of pi or divided by an integer appears to fix the asymmetry.

```py
class Axe(GraphScene):
    def construct(self):
        axes = Axes(
            x_range=[-PI,PI,PI/8],
            y_range=[-PI,PI,PI/3]
        )
        self.add(axes)  
```
![scriptoutput.png](https://user-images.githubusercontent.com/76829691/120230318-12316800-c21d-11eb-901e-70d6a3c303c0.png)
I gather that currently Axes steps up from an endpoint and steps up until it is past the other endpoint. To fix the symmetry it would have to step forward from 0 to an endpoint then step backwards. Which if someone could implement I think that would look good.

Another way would be to automatically adjust the step size based on the bounds but that would not work if the bounds were asymmetric/didn't have an easy pattern to them.
> I gather that currently Axes steps up from an endpoint and steps up until it is past the other endpoint. To fix the symmetry it would have to step forward from 0 to an endpoint then step backwards. Which if someone could implement I think that would look good.
> 
> Another way would be to automatically adjust the step size based on the bounds but that would not work if the bounds were asymmetric/didn't have an easy pattern to them.

Yes it currently operates across the whole axis so wouldn't take the origin into consideration.

`return np.arange(self.x_min, x_max, self.x_step)`

I was playing around with it and tried your' first suggestion - going from the origin to each endpoint. It seems to work well so far, I could raise a PR for it when I have tested it more
> I was playing around with it and tried your' first suggestion - going from the origin to each endpoint. It seems to work well so far, I could raise a PR for it when I have tested it more

That sounds great.

Resolving patch:
diff --git a/manim/mobject/coordinate_systems.py b/manim/mobject/coordinate_systems.py
--- a/manim/mobject/coordinate_systems.py
+++ b/manim/mobject/coordinate_systems.py
@@ -974,7 +974,11 @@ def __init__(
         VGroup.__init__(self, **kwargs)
         CoordinateSystem.__init__(self, x_range, y_range, x_length, y_length)
 
-        self.axis_config = {"include_tip": tips, "numbers_to_exclude": [0]}
+        self.axis_config = {
+            "include_tip": tips,
+            "numbers_to_exclude": [0],
+            "exclude_origin_tick": True,
+        }
         self.x_axis_config = {}
         self.y_axis_config = {"rotation": 90 * DEGREES, "label_direction": LEFT}
 
diff --git a/manim/mobject/number_line.py b/manim/mobject/number_line.py
--- a/manim/mobject/number_line.py
+++ b/manim/mobject/number_line.py
@@ -1,9 +1,7 @@
 """Mobject representing a number line."""
 
-
 __all__ = ["NumberLine", "UnitInterval", "NumberLineOld"]
 
-
 import operator as op
 
 import numpy as np
@@ -22,7 +20,9 @@
 
 
 class NumberLine(Line):
-    """Creates a number line with tick marks.
+    """Creates a number line with tick marks. Number ranges that include both negative and
+    positive values will be generated from the 0 point, and may not include a tick at the min / max
+    values as the tick locations are dependent on the step size.
 
     Parameters
     ----------
@@ -100,6 +100,7 @@ def __init__(
         numbers_to_include=None,
         # temp, because DecimalNumber() needs to be updated
         number_scale_value=0.75,
+        exclude_origin_tick=False,
         **kwargs
     ):
         # avoid mutable arguments in defaults
@@ -131,6 +132,7 @@ def __init__(
         self.tick_size = tick_size
         self.numbers_with_elongated_ticks = numbers_with_elongated_ticks
         self.longer_tick_multiple = longer_tick_multiple
+        self.exclude_origin_tick = exclude_origin_tick
         # visuals
         self.stroke_width = stroke_width
         self.rotation = rotation
@@ -210,7 +212,21 @@ def get_tick_range(self):
             x_max = self.x_max
         else:
             x_max = self.x_max + 1e-6
-        return np.arange(self.x_min, x_max, self.x_step)
+
+        # Handle cases where min and max are both positive or both negative
+        if self.x_min < x_max < 0 or self.x_max > self.x_min > 0:
+            return np.arange(self.x_min, x_max, self.x_step)
+
+        start_point = 0
+        if self.exclude_origin_tick:
+            start_point += self.x_step
+
+        x_min_segment = (
+            np.arange(start_point, np.abs(self.x_min) + 1e-6, self.x_step) * -1
+        )
+        x_max_segment = np.arange(start_point, x_max, self.x_step)
+
+        return np.unique(np.concatenate((x_min_segment, x_max_segment)))
 
     def number_to_point(self, number):
         alpha = float(number - self.x_min) / (self.x_max - self.x_min)


Test patch:
diff --git a/tests/test_graphical_units/test_axes.py b/tests/test_graphical_units/test_axes.py
--- a/tests/test_graphical_units/test_axes.py
+++ b/tests/test_graphical_units/test_axes.py
@@ -14,6 +14,7 @@ def construct(self):
             x_length=6,
             y_length=6,
             color=WHITE,
+            axis_config={"exclude_origin_tick": False},
         )
         labels = graph.get_axis_labels()
         self.add(graph, labels)
diff --git a/tests/test_graphical_units/test_threed.py b/tests/test_graphical_units/test_threed.py
--- a/tests/test_graphical_units/test_threed.py
+++ b/tests/test_graphical_units/test_threed.py
@@ -48,7 +48,7 @@ def construct(self):
 
 class AxesTest(ThreeDScene):
     def construct(self):
-        self.add(ThreeDAxes())
+        self.add(ThreeDAxes(axis_config={"exclude_origin_tick": False}))
 
 
 class CameraMoveTest(ThreeDScene):
diff --git a/tests/test_ticks.py b/tests/test_ticks.py
new file mode 100644
--- /dev/null
+++ b/tests/test_ticks.py
@@ -0,0 +1,54 @@
+import numpy as np
+
+from manim import PI, Axes, NumberLine
+
+
+def test_duplicate_ticks_removed_for_axes():
+    axis = NumberLine(
+        x_range=[-10, 10],
+    )
+    ticks = axis.get_tick_range()
+    assert np.unique(ticks).size == ticks.size
+
+
+def test_ticks_not_generated_on_origin_for_axes():
+    axes = Axes(
+        x_range=[-10, 10], y_range=[-10, 10], axis_config={"include_ticks": True}
+    )
+
+    x_axis_range = axes.x_axis.get_tick_range()
+    y_axis_range = axes.y_axis.get_tick_range()
+
+    assert 0 not in x_axis_range
+    assert 0 not in y_axis_range
+
+
+def test_expected_ticks_generated():
+    axes = Axes(x_range=[-2, 2], y_range=[-2, 2], axis_config={"include_ticks": True})
+    x_axis_range = axes.x_axis.get_tick_range()
+    y_axis_range = axes.y_axis.get_tick_range()
+
+    assert 1 in x_axis_range
+    assert 1 in y_axis_range
+    assert -1 in x_axis_range
+    assert -1 in y_axis_range
+
+
+def test_ticks_generated_from_origin_for_axes():
+    axes = Axes(
+        x_range=[-PI, PI], y_range=[-PI, PI], axis_config={"include_ticks": True}
+    )
+    x_axis_range = axes.x_axis.get_tick_range()
+    y_axis_range = axes.y_axis.get_tick_range()
+
+    assert -2 in x_axis_range
+    assert -1 in x_axis_range
+    assert 0 not in x_axis_range
+    assert 1 in x_axis_range
+    assert 2 in x_axis_range
+
+    assert -2 in y_axis_range
+    assert -1 in y_axis_range
+    assert 0 not in y_axis_range
+    assert 1 in y_axis_range
+    assert 2 in y_axis_range