Repository: scikit-hep/vector

Issue:
Allow users to pass new coordinate values in `to_Vector{X}D` and `to_{new coordinate names}`
### Describe the potential feature

Right now users can convert a 2D vector to 3D or 4D using `to_Vector3D()` and `to_Vector4D()`, but the returned vector always possesses 0 as the value of the new generated coordinate.

Example:
```py
In [1]: import vector

In [2]: v = vector.obj(x=1, y=2)

In [3]: v.to_Vector3D()
Out[3]: VectorObject3D(x=1, y=2, z=0)

In [4]: v.to_Vector3D(z=1)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [4], line 1
----> 1 v.to_Vector3D(z=1)

TypeError: to_Vector3D() got an unexpected keyword argument 'z'
```

This can be extended such that the users can pass in the value of the newly added coordinate.

Example:
```py
In [1]: import vector

In [2]: v  = vector.obj(x=1, y=2)

In [3]: v
Out[3]: VectorObject2D(x=1, y=2)

In [4]: v.to_Vector3D(z=1)
Out[4]: VectorObject3D(x=1, y=2, z=1)
```

Similarly for 3D and 4D vectors. This can then be further propagated to the `to_{new coordinate name}` (`to_xyzt`, `to_rhophi`, ...) methods.

### Motivation

The discussion on gitter (between @jpivarski and @henryiii). Quoting a part here -

> For 3D and 4D vectors, you probably want a specialized function, since that 4th dimension is qualitatively different from the other 3. Maybe something like scale_spatial(-1). (There's already a scale; it does the same thing as multiplication by a scalar.)
>
> But even without that, it would also be nice if functions like to_Vector4D, which is increasing the dimensionality of the 3D vector (x.to_Vector3D()) in this case, could take parameters like E= or mass= to assign a value to the new dimension. As it is, to_Vector4D applied to a 3D vector creates a 4th dimension whose value is 0 in Cartesian coordinates. To get the original x.E in it, I had to add vectors. If the coordinate were expressed as a mass, the above trick wouldn't work with x.mass because it would add after assigning a mass; non-Cartesian coordinates don't commute.

### Possible Implementation

Changing this -

https://github.com/scikit-hep/vector/blob/93cb78a017707f42b961b3a74e34ffb79c37f85d/src/vector/_methods.py#L1701-L1707

to this -
```py
def to_Vector3D(self, **coord) -> VectorProtocolSpatial:
    if len(coord) > 1:
        raise ArgumentError(coord, "can be `z`, `theta`, or `eta`")

    coord_type, coord_value = list(coord.keys())[0], list(coord.values())[0]

    if coord_type == "z":
        l_type = LongitudinalZ
    elif coord_type == "eta":
        l_type = LongitudinalEta
    elif coord_type == "theta":
        l_type = LongitudinalTheta

    return self._wrap_result(
        type(self),
        self.azimuthal.elements + (coord_value,),
        [_aztype(self), l_type, None],
        1,
    )
```

works -

```py
In [1]: import vector

In [2]: v = vector.obj(x=1, py=2)

In [3]: v.to_Vector3D(z=3)
Out[3]: vector.MomentumObject3D(px=1, py=2, pz=3)

In [4]: v.to_Vector3D(eta=3)
Out[4]: vector.MomentumObject3D(px=1, py=2, eta=3)

In [5]: v.to_Vector3D(theta=3)
Out[5]: vector.MomentumObject3D(px=1, py=2, theta=3)
```

Comments:

Patch:
diff --git a/src/vector/_methods.py b/src/vector/_methods.py
index 85befae..c95d5e9 100644
--- a/src/vector/_methods.py
+++ b/src/vector/_methods.py
@@ -2966,32 +2966,103 @@ class Vector2D(Vector, VectorProtocolPlanar):
         theta: float | None = None,
         eta: float | None = None,
     ) -> VectorProtocolSpatial:
+        """
+        Converts a 2D vector to 3D vector.
+
+        The scalar longitudinal coordinate is broadcasted for NumPy and Awkward
+        vectors. Only a single longitudinal coordinate should be provided. Generic
+        coordinate counterparts should be provided for the momentum coordinates.
+
+        Examples:
+            >>> import vector
+            >>> vec = vector.VectorObject2D(x=1, y=2)
+            >>> vec.to_Vector3D(z=1)
+            VectorObject3D(x=1, y=2, z=1)
+            >>> vec = vector.MomentumObject2D(px=1, py=2)
+            >>> vec.to_Vector3D(z=4)
+            MomentumObject3D(px=1, py=2, pz=4)
+        """
         if sum(x is not None for x in (z, theta, eta)) > 1:
-            raise TypeError("Only one non-None parameter allowed")
+            raise TypeError(
+                "At most one longitudinal coordinate (`z`, `theta`, or `eta`) may be assigned (non-None)"
+            )
 
-        coord_value = 0.0
+        l_value = 0.0
         l_type: type[Longitudinal] = LongitudinalZ
         if z is not None:
-            coord_value = z
+            l_value = z
         elif eta is not None:
-            coord_value = eta
+            l_value = eta
             l_type = LongitudinalEta
         elif theta is not None:
-            coord_value = theta
+            l_value = theta
             l_type = LongitudinalTheta
 
         return self._wrap_result(
             type(self),
-            (*self.azimuthal.elements, coord_value),
+            (*self.azimuthal.elements, l_value),
             [_aztype(self), l_type, None],
             1,
         )
 
-    def to_Vector4D(self) -> VectorProtocolLorentz:
+    def to_Vector4D(
+        self,
+        *,
+        z: float | None = None,
+        theta: float | None = None,
+        eta: float | None = None,
+        t: float | None = None,
+        tau: float | None = None,
+    ) -> VectorProtocolLorentz:
+        """
+        Converts a 2D vector to 4D vector.
+
+        The scalar longitudinal and temporal coordinates are broadcasted for NumPy and
+        Awkward vectors. Only a single longitudinal and temporal coordinate should be
+        provided. Generic coordinate counterparts should be provided for the momentum
+        coordinates.
+
+        Examples:
+            >>> import vector
+            >>> vec = vector.VectorObject2D(x=1, y=2)
+            >>> vec.to_Vector4D(z=3, t=4)
+            VectorObject4D(x=1, y=2, z=3, t=4)
+            >>> vec = vector.MomentumObject2D(px=1, py=2)
+            >>> vec.to_Vector4D(z=4, t=4)
+            MomentumObject4D(px=1, py=2, pz=4, E=4)
+        """
+        if sum(x is not None for x in (z, theta, eta)) > 1:
+            raise TypeError(
+                "At most one longitudinal coordinate (`z`, `theta`, or `eta`) may be assigned (non-None)"
+            )
+        elif sum(x is not None for x in (t, tau)) > 1:
+            raise TypeError(
+                "At most one longitudinal coordinate (`t`, `tau`) may be assigned (non-None)"
+            )
+
+        t_value = 0.0
+        t_type: type[Temporal] = TemporalT
+        if t is not None:
+            t_value = t
+        elif tau is not None:
+            t_value = tau
+            t_type = TemporalTau
+
+        l_value = 0.0
+        l_type: type[Longitudinal] = LongitudinalZ
+        if z is not None:
+            l_value = z
+        elif eta is not None:
+            l_value = eta
+            l_type = LongitudinalEta
+        elif theta is not None:
+            l_value = theta
+            l_type = LongitudinalTheta
+
         return self._wrap_result(
             type(self),
-            (*self.azimuthal.elements, 0, 0),
-            [_aztype(self), LongitudinalZ, TemporalT],
+            (*self.azimuthal.elements, l_value, t_value),
+            [_aztype(self), l_type, t_type],
             1,
         )
 
@@ -3008,11 +3079,45 @@ class Vector3D(Vector, VectorProtocolSpatial):
     def to_Vector3D(self) -> VectorProtocolSpatial:
         return self
 
-    def to_Vector4D(self) -> VectorProtocolLorentz:
+    def to_Vector4D(
+        self,
+        *,
+        t: float | None = None,
+        tau: float | None = None,
+    ) -> VectorProtocolLorentz:
+        """
+        Converts a 3D vector to 4D vector.
+
+        The scalar temporal coordinate are broadcasted for NumPy and Awkward vectors.
+        Only a single temporal coordinate should be provided. Generic coordinate
+        counterparts should be provided for the momentum coordinates.
+
+        Examples:
+            >>> import vector
+            >>> vec = vector.VectorObject3D(x=1, y=2, z=3)
+            >>> vec.to_Vector4D(t=4)
+            VectorObject4D(x=1, y=2, z=3, t=4)
+            >>> vec = vector.MomentumObject3D(px=1, py=2, pz=3)
+            >>> vec.to_Vector4D(tau=4)
+            MomentumObject4D(px=1, py=2, pz=3, mass=4)
+        """
+        if sum(x is not None for x in (t, tau)) > 1:
+            raise TypeError(
+                "At most one longitudinal coordinate (`t`, `tau`) may be assigned (non-None)"
+            )
+
+        t_value = 0.0
+        t_type: type[Temporal] = TemporalT
+        if t is not None:
+            t_value = t
+        elif tau is not None:
+            t_value = tau
+            t_type = TemporalTau
+
         return self._wrap_result(
             type(self),
-            self.azimuthal.elements + self.longitudinal.elements + (0,),
-            [_aztype(self), _ltype(self), TemporalT],
+            (*self.azimuthal.elements, *self.longitudinal.elements, t_value),
+            [_aztype(self), _ltype(self), t_type],
             1,
         )
 
diff --git a/src/vector/backends/object.py b/src/vector/backends/object.py
index 6891a07..0f44ce5 100644
--- a/src/vector/backends/object.py
+++ b/src/vector/backends/object.py
@@ -1259,7 +1259,7 @@ class MomentumObject3D(SpatialMomentum, VectorObject3D):
         for x in lnames:
             y = _repr_generic_to_momentum.get(x, x)
             out.append(f"{y}={getattr(self.longitudinal, x)}")
-        return "vector.MomentumObject3D(" + ", ".join(out) + ")"
+        return "MomentumObject3D(" + ", ".join(out) + ")"
 
     def __array__(self) -> FloatArray:
         from vector.backends.numpy import MomentumNumpy3D


Test patch:
diff --git a/tests/backends/test_awkward.py b/tests/backends/test_awkward.py
index 6ad8766..c8e043c 100644
--- a/tests/backends/test_awkward.py
+++ b/tests/backends/test_awkward.py
@@ -9,11 +9,58 @@ import pytest
 
 import vector
 
-pytest.importorskip("awkward")
+ak = pytest.importorskip("awkward")
 
 pytestmark = pytest.mark.awkward
 
 
+def test_dimension_conversion():
+    # 2D -> 3D
+    vec = vector.Array(
+        [
+            [{"x": 1, "y": 1.1}, {"x": 2, "y": 2.1}],
+            [],
+        ]
+    )
+    assert ak.all(vec.to_Vector3D(z=1).z == 1)
+    assert ak.all(vec.to_Vector3D(eta=1).eta == 1)
+    assert ak.all(vec.to_Vector3D(theta=1).theta == 1)
+
+    assert ak.all(vec.to_Vector3D(z=1).x == vec.x)
+    assert ak.all(vec.to_Vector3D(z=1).y == vec.y)
+
+    # 2D -> 4D
+    assert ak.all(vec.to_Vector4D(z=1, t=1).t == 1)
+    assert ak.all(vec.to_Vector4D(z=1, t=1).z == 1)
+    assert ak.all(vec.to_Vector4D(eta=1, t=1).eta == 1)
+    assert ak.all(vec.to_Vector4D(eta=1, t=1).t == 1)
+    assert ak.all(vec.to_Vector4D(theta=1, t=1).theta == 1)
+    assert ak.all(vec.to_Vector4D(theta=1, t=1).t == 1)
+    assert ak.all(vec.to_Vector4D(z=1, tau=1).z == 1)
+    assert ak.all(vec.to_Vector4D(z=1, tau=1).tau == 1)
+    assert ak.all(vec.to_Vector4D(eta=1, tau=1).eta == 1)
+    assert ak.all(vec.to_Vector4D(eta=1, tau=1).tau == 1)
+    assert ak.all(vec.to_Vector4D(theta=1, tau=1).theta == 1)
+    assert ak.all(vec.to_Vector4D(theta=1, tau=1).tau == 1)
+
+    assert ak.all(vec.to_Vector4D(z=1, t=1).x == vec.x)
+    assert ak.all(vec.to_Vector4D(z=1, t=1).y == vec.y)
+
+    # 3D -> 4D
+    vec = vector.Array(
+        [
+            [{"x": 1, "y": 1.1, "z": 1.2}, {"x": 2, "y": 2.1, "z": 2.2}],
+            [],
+        ]
+    )
+    assert ak.all(vec.to_Vector4D(t=1).t == 1)
+    assert ak.all(vec.to_Vector4D(tau=1).tau == 1)
+
+    assert ak.all(vec.to_Vector4D(t=1).x == vec.x)
+    assert ak.all(vec.to_Vector4D(t=1).y == vec.y)
+    assert ak.all(vec.to_Vector4D(t=1).z == vec.z)
+
+
 def test_type_checks():
     with pytest.raises(TypeError):
         vector.Array(
diff --git a/tests/backends/test_numpy.py b/tests/backends/test_numpy.py
index 89d433e..47a5272 100644
--- a/tests/backends/test_numpy.py
+++ b/tests/backends/test_numpy.py
@@ -14,6 +14,49 @@ import pytest
 import vector.backends.numpy
 
 
+def test_dimension_conversion():
+    # 2D -> 3D
+    vec = vector.VectorNumpy2D(
+        [(1.0, 1.0), (2.0, 2.0)],
+        dtype=[("x", float), ("y", float)],
+    )
+    assert all(vec.to_Vector3D(z=1).z == 1)
+    assert all(vec.to_Vector3D(eta=1).eta == 1)
+    assert all(vec.to_Vector3D(theta=1).theta == 1)
+
+    assert all(vec.to_Vector3D(z=1).x == vec.x)
+    assert all(vec.to_Vector3D(z=1).y == vec.y)
+
+    # 2D -> 4D
+    assert all(vec.to_Vector4D(z=1, t=1).t == 1)
+    assert all(vec.to_Vector4D(z=1, t=1).z == 1)
+    assert all(vec.to_Vector4D(eta=1, t=1).eta == 1)
+    assert all(vec.to_Vector4D(eta=1, t=1).t == 1)
+    assert all(vec.to_Vector4D(theta=1, t=1).theta == 1)
+    assert all(vec.to_Vector4D(theta=1, t=1).t == 1)
+    assert all(vec.to_Vector4D(z=1, tau=1).z == 1)
+    assert all(vec.to_Vector4D(z=1, tau=1).tau == 1)
+    assert all(vec.to_Vector4D(eta=1, tau=1).eta == 1)
+    assert all(vec.to_Vector4D(eta=1, tau=1).tau == 1)
+    assert all(vec.to_Vector4D(theta=1, tau=1).theta == 1)
+    assert all(vec.to_Vector4D(theta=1, tau=1).tau == 1)
+
+    assert all(vec.to_Vector4D(z=1, t=1).x == vec.x)
+    assert all(vec.to_Vector4D(z=1, t=1).y == vec.y)
+
+    # 3D -> 4D
+    vec = vector.VectorNumpy3D(
+        [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)],
+        dtype=[("x", float), ("y", float), ("z", float)],
+    )
+    assert all(vec.to_Vector4D(t=1).t == 1)
+    assert all(vec.to_Vector4D(tau=1).tau == 1)
+
+    assert all(vec.to_Vector4D(t=1).x == vec.x)
+    assert all(vec.to_Vector4D(t=1).y == vec.y)
+    assert all(vec.to_Vector4D(t=1).z == vec.z)
+
+
 def test_type_checks():
     with pytest.raises(TypeError):
         vector.backends.numpy.VectorNumpy2D(
diff --git a/tests/backends/test_object.py b/tests/backends/test_object.py
index bad9574..a976611 100644
--- a/tests/backends/test_object.py
+++ b/tests/backends/test_object.py
@@ -11,6 +11,43 @@ import pytest
 import vector
 
 
+def test_dimension_conversion():
+    # 2D -> 3D
+    vec = vector.VectorObject2D(x=1, y=2)
+    assert vec.to_Vector3D(z=1).z == 1
+    assert vec.to_Vector3D(eta=1).eta == 1
+    assert vec.to_Vector3D(theta=1).theta == 1
+
+    assert vec.to_Vector3D(z=1).x == vec.x
+    assert vec.to_Vector3D(z=1).y == vec.y
+
+    # 2D -> 4D
+    assert vec.to_Vector4D(z=1, t=1).z == 1
+    assert vec.to_Vector4D(z=1, t=1).t == 1
+    assert vec.to_Vector4D(eta=1, t=1).eta == 1
+    assert vec.to_Vector4D(eta=1, t=1).t == 1
+    assert vec.to_Vector4D(theta=1, t=1).theta == 1
+    assert vec.to_Vector4D(theta=1, t=1).t == 1
+    assert vec.to_Vector4D(z=1, tau=1).z == 1
+    assert vec.to_Vector4D(z=1, tau=1).tau == 1
+    assert vec.to_Vector4D(eta=1, tau=1).eta == 1
+    assert vec.to_Vector4D(eta=1, tau=1).tau == 1
+    assert vec.to_Vector4D(theta=1, tau=1).theta == 1
+    assert vec.to_Vector4D(theta=1, tau=1).tau == 1
+
+    assert vec.to_Vector4D(z=1, t=1).x == vec.x
+    assert vec.to_Vector4D(z=1, t=1).y == vec.y
+
+    # 3D -> 4D
+    vec = vector.VectorObject3D(x=1, y=2, z=3)
+    assert vec.to_Vector4D(t=1).t == 1
+    assert vec.to_Vector4D(tau=1).tau == 1
+
+    assert vec.to_Vector4D(t=1).x == vec.x
+    assert vec.to_Vector4D(t=1).y == vec.y
+    assert vec.to_Vector4D(t=1).z == vec.z
+
+
 def test_constructors_2D():
     vec = vector.VectorObject2D(x=1, y=2)
     assert vec.x == 1