Repository: laspy/laspy

Issue:
Writing ScaleAwarePointRecord ignores scaling and offsets
Hello,

I was creating a small script to merge las files:
```python
import laspy

las_paths = ["path/to/file1.las", "path/to/file2.las"]
save_path = R"where/to/save/file3.las"

with laspy.open(las_paths[0]) as las_reader:
    header = las_reader.header
    header.partial_reset()

writer = laspy.open(
    save_path, 
    mode="w", 
    header=header
)

for p in las_paths:
    with laspy.open(p, "r") as las_reader:
        points = las_reader.read_points(las_reader.header.point_count)
    writer.write_points(points)

writer.close()
```
And my results were very messed up. I found out that it was because the scaling and offset of my source files were individually different. Now it seems to me that when I write a ScaleAwarePointRecord (which is what is returned when reading points), and writing these "scale aware" points to a new file, that it would take the scaling and offsets into account, but apparently it just copies the raw X, Y, and Z fields, as if it were a PackedPointRecord. Would it be a good idea to change the write_points function to take into account whether the points are scale aware? 

I understand I can use `points.change_scaling(scales=header.scales, offsets=header.offsets)`, but in my opinion it would make sense that write points automatically uses the given scaling and offsets when these are incorporated in the ScaleAwarePointRecord.

Discussion:
@tmontaigu: Hum 🧐 maybe we can do that yes

Solution patch:
diff --git a/laspy/laswriter.py b/laspy/laswriter.py
index 0194c5a..e73b3d4 100644
--- a/laspy/laswriter.py
+++ b/laspy/laswriter.py
@@ -2,12 +2,14 @@ import logging
 from copy import deepcopy
 from typing import BinaryIO, Iterable, Optional, Union
 
+import numpy as np
+
 from ._pointwriter import IPointWriter
 from .compression import LazBackend
 from .errors import LaspyException
 from .header import LasHeader
 from .point import dims
-from .point.record import PackedPointRecord
+from .point.record import PackedPointRecord, ScaleAwarePointRecord
 from .vlrs.vlrlist import VLRList
 
 logger = logging.getLogger(__name__)
@@ -56,7 +58,7 @@ class LasWriter:
         self.closefd = closefd
         self.encoding_errors = encoding_errors
         self.header = deepcopy(header)
-        # The point writer will take take of creating and writing
+        # The point writer will take care of creating and writing
         # the correct laszip vlr, however we have to make sure
         # no prior laszip vlr exists
         try:
@@ -95,12 +97,16 @@ class LasWriter:
         """
         .. note ::
 
-            If you are writing points coming from multiple different input files
-            into one output file, you have to make sure the point record
-            you write all use the same scales and offset of the writer.
 
-            You can use :meth:`.LasData.change_scaling` or :meth:`.ScaleAwarePointRecord.change_scaling`
-            to do that.
+            If the points type is PackedPointRecord, then you need to make sure
+            the points are expressed in the same scales and offsets 'system' than
+            this writer.
+
+            You can use :meth:`.LasData.change_scaling` before getting the points
+            to ensure that.
+
+            If the points type is ScaleAwarePointsRecord, then points will automatically
+            re-scaled.
 
         Parameters
         ----------
@@ -130,9 +136,29 @@ class LasWriter:
                 )
             )
 
+        restore_needed = False
+        if isinstance(points, ScaleAwarePointRecord) and (
+            np.any(points.scales != self.header.scales)
+            or np.any(points.offsets != self.header.offsets)
+        ):
+            saved_offsets, saved_scales = points.offsets, points.scales
+            saved_X, saved_Y, saved_Z = (
+                points.X.copy(),
+                points.Y.copy(),
+                points.Z.copy(),
+            )
+            points.change_scaling(
+                scales=self.header.scales, offsets=self.header.offsets
+            )
+            restore_needed = True
+
         self.header.grow(points)
         self.point_writer.write_points(points)
 
+        if restore_needed:
+            points.offsets, points.scales = saved_offsets, saved_scales
+            points.X, points.Y, points.Z = saved_X, saved_Y, saved_Z
+
     def write_evlrs(self, evlrs: VLRList) -> None:
         """Writes the EVLRs to the file
 


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index b55842c..8e40f33 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -346,6 +346,81 @@ def test_change_scaling():
     assert np.all(las.Z == [-2900, -2800, -2700, -2600])
 
 
+def test_automatic_scale_change_on_write():
+    """
+    Test that when passing ScaleAwarePointRecord to the LasWriter
+    a re-scaling is automatically done so that points coming from
+    different files (with possibly different scales/offsets)
+    are all under the same scales/offsets
+    """
+    hdr = laspy.LasHeader()
+    hdr.offsets = np.array([1.0, 1.0, 1.0])
+    hdr.scales = np.array([1.0, 1.0, 1.0])
+    hdr.point_format = laspy.PointFormat(3)
+
+    with io.BytesIO() as data:
+        with laspy.LasWriter(data, hdr, closefd=False) as writer:
+            points = laspy.ScaleAwarePointRecord.zeros(
+                4,
+                point_format=hdr.point_format,
+                scales=[1.0, 1.0, 1.0],
+                offsets=[0, 0, 0],
+            )
+            points.x = [1, 2, 3, 4]
+            points.y = [1, 2, 3, 4]
+            points.z = [1, 2, 3, 4]
+            writer.write_points(points)
+
+            points.change_scaling(scales=[0.5, 0.1, 0.01])
+            assert np.all(points.scales == [0.5, 0.1, 0.01])
+            assert np.all(points.offsets == [0, 0, 0])
+            assert np.all(points.x == [1, 2, 3, 4])
+            assert np.all(points.y == [1, 2, 3, 4])
+            assert np.all(points.z == [1, 2, 3, 4])
+            assert np.all(points.X == [2, 4, 6, 8])
+            assert np.all(points.Y == [10, 20, 30, 40])
+            assert np.all(points.Z == [100, 200, 300, 400])
+            writer.write_points(points)
+            # Make sure the input points did not change
+            assert np.all(points.scales == [0.5, 0.1, 0.01])
+            assert np.all(points.offsets == [0, 0, 0])
+            assert np.all(points.x == [1, 2, 3, 4])
+            assert np.all(points.y == [1, 2, 3, 4])
+            assert np.all(points.z == [1, 2, 3, 4])
+            assert np.all(points.X == [2, 4, 6, 8])
+            assert np.all(points.Y == [10, 20, 30, 40])
+            assert np.all(points.Z == [100, 200, 300, 400])
+
+            points.change_scaling(offsets=[1, 20, 30])
+            assert np.all(points.scales == [0.5, 0.1, 0.01])
+            assert np.all(points.offsets == [1, 20, 30])
+            assert np.all(points.x == [1, 2, 3, 4])
+            assert np.all(points.y == [1, 2, 3, 4])
+            assert np.all(points.z == [1, 2, 3, 4])
+            assert np.all(points.X == [0, 2, 4, 6])
+            assert np.all(points.Y == [-190, -180, -170, -160])
+            assert np.all(points.Z == [-2900, -2800, -2700, -2600])
+            writer.write_points(points)
+            # Make sure the input points did not change
+            assert np.all(points.scales == [0.5, 0.1, 0.01])
+            assert np.all(points.offsets == [1, 20, 30])
+            assert np.all(points.x == [1, 2, 3, 4])
+            assert np.all(points.y == [1, 2, 3, 4])
+            assert np.all(points.z == [1, 2, 3, 4])
+            assert np.all(points.X == [0, 2, 4, 6])
+            assert np.all(points.Y == [-190, -180, -170, -160])
+            assert np.all(points.Z == [-2900, -2800, -2700, -2600])
+
+        data.seek(0)
+        las = laspy.read(data)
+        assert np.all(las.x == np.array([1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]))
+        assert np.all(las.y == np.array([1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]))
+        assert np.all(las.z == np.array([1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4]))
+        assert np.all(las.X == np.array([0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]))
+        assert np.all(las.Y == np.array([0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]))
+        assert np.all(las.Z == np.array([0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3]))
+
+
 def test_setting_x_y_z_on_las_data():
     """
     The goal of this test if to make sure that when setting the `x`,`y` and `z`