Repository: RockRobotic/copc-lib (language: python)

Issue:
Point restructure to allow conversion between LAS point formats



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml
index a4c6f68e..83a9ea9e 100644
--- a/.github/workflows/cmake.yml
+++ b/.github/workflows/cmake.yml
@@ -24,7 +24,7 @@ jobs:
       with:
         path: lazperf
         repository: hobu/laz-perf
-        
+
     - name: Build lazperf
       shell: bash
       working-directory: lazperf
@@ -34,12 +34,27 @@ jobs:
         cmake ..
         make
         sudo make install
-    
+
     - name: Checkout Copclib
       uses: actions/checkout@v2
       with:
         submodules: true
 
+    - name: Set up Python 3.8
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.8
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install pytest wheel
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+
+    - name: Build Copclib Python Bindings
+      run: |
+        pip install .
+
     - name: Configure CMake
       # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
       # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
@@ -54,4 +69,3 @@ jobs:
       # Execute tests defined by the CMake configuration.  
       # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail
       run: ctest -C ${{env.BUILD_TYPE}} --output-on-failure
-      
diff --git a/.gitignore b/.gitignore
index 0e0230d8..2122035b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,4 +33,36 @@ build/*
 *.out
 *.app
 
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
 /CMakeSettings.json
+/test/data/autzen-classified.copc.laz
+/test/data/writer_test.copc.laz
+/test/data/autzen-trimmed.copc.laz
+/test/data/new-copc.copc.laz
diff --git a/.gitmodules b/.gitmodules
index 362f27d0..cd28315c 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
 [submodule "lib/Catch2"]
 	url = git@github.com:catchorg/Catch2.git
 	path = lib/Catch2
+[submodule "lib/pybind11"]
+	path = lib/pybind11
+	url = git@github.com:pybind/pybind11.git
+	branch = stable
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2556a240..c38dcdd1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,3 +11,8 @@ repos:
         always_run: true
         args: ["-i"]
         additional_dependencies: ["clang-format"]
+-   repo: https://github.com/ambv/black
+    rev: 21.8b0
+    hooks:
+    -   id: black
+        language_version: python
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f76dc41a..3407bbe6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,10 +1,11 @@
 cmake_minimum_required(VERSION 3.0.0)
-project(copc-lib VERSION 1.1)
+project(copc-lib VERSION 1.2)
 
 find_package(lazperf REQUIRED)
 
 set (COPC_SHARED_LIB copc-lib)
 set (COPC_STATIC_LIB copc-lib-s)
+set (COPC_PYTHON_LIB copclib)
 
 set(CMAKE_CXX_STANDARD 17)
 set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -20,19 +21,25 @@ if (NOT EMSCRIPTEN)
     add_library(${COPC_SHARED_LIB} SHARED ${SRCS})
     target_link_libraries(${COPC_SHARED_LIB} LAZPERF::lazperf)
     target_include_directories(${COPC_SHARED_LIB} INTERFACE $<INSTALL_INTERFACE:include>)
-
 endif()
+
 add_library(${COPC_STATIC_LIB} STATIC ${SRCS})
 target_link_libraries(${COPC_STATIC_LIB} LAZPERF::lazperf)
 
 # Choose package components
 set(WITH_TESTS TRUE CACHE BOOL "Choose if unit tests should be built")
 set(WITH_EXAMPLES TRUE CACHE BOOL "Choose if examples should be built")
-
-if (WITH_TESTS OR WITH_EXAMPLES)
-    file (DOWNLOAD
-    "https://github.com/PDAL/data/raw/62e514b6484ec59cd48bb48d5c6fe8a00216a6ac/autzen/autzen-classified.copc.laz"
-    "${CMAKE_BINARY_DIR}/test/data/autzen-classified.copc.laz")
+set(WITH_PYTHON TRUE CACHE BOOL "Choose if python bindings should be built")
+
+if (WITH_TESTS OR WITH_EXAMPLES OR WITH_PYTHON)
+    set(AutzenFilePath "${CMAKE_BINARY_DIR}/test/data/autzen-classified.copc.laz")
+    if(NOT EXISTS ${AutzenFilePath})
+        message(STATUS "Downloading test files")
+        file (DOWNLOAD
+        "https://github.com/PDAL/data/raw/62e514b6484ec59cd48bb48d5c6fe8a00216a6ac/autzen/autzen-classified.copc.laz"
+        ${AutzenFilePath}
+        )
+    endif()
 
     add_executable(example_reader "example/example-reader.cpp")
     target_link_libraries(example_reader ${COPC_SHARED_LIB})
@@ -59,6 +66,33 @@ if(WITH_TESTS)
     add_test(NAME example_writer COMMAND example_writer)
 endif()
 
+if(WITH_PYTHON)
+    if(NOT EXISTS "${CMAKE_SOURCE_DIR}/test/data/autzen-classified.copc.laz")
+        message(STATUS "Copying test files")
+        file(COPY "${AutzenFilePath}" 
+            DESTINATION "${CMAKE_SOURCE_DIR}/test/data")
+    endif()
+
+    add_subdirectory(lib/pybind11)
+    pybind11_add_module(${COPC_PYTHON_LIB} ${SRCS} python/bindings.cpp)
+    target_link_libraries(${COPC_PYTHON_LIB} PRIVATE ${COPC_STATIC_LIB})
+    target_compile_definitions(${COPC_PYTHON_LIB} PRIVATE VERSION_INFO=${EXAMPLE_VERSION_INFO})
+    if(WITH_TESTS)
+        add_test(NAME python-tests
+                COMMAND ${PYTHON_EXECUTABLE} -m pytest "${CMAKE_SOURCE_DIR}/test"
+                WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}/test"
+                )
+        add_test(NAME example_reader_py
+                COMMAND ${PYTHON_EXECUTABLE} "${CMAKE_SOURCE_DIR}/example/example-reader.py"
+                WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}/example"
+                )
+        add_test(NAME example_writer_py
+                COMMAND ${PYTHON_EXECUTABLE} "${CMAKE_SOURCE_DIR}/example/example-writer.py"
+                WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}/example"
+                )
+    endif()
+endif()
+
 # Install
 set(include_dest "include/copc-lib")
 set(lib_dest "lib/cmake/copc-lib")
diff --git a/README.md b/README.md
index 5e5ac33d..98847885 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ make
 sudo make install
 ```
 
+### Python
+Python bindings are supported using Pybind11 through a git submodule. 
+```bash
+pip install ./copc-lib
+```
+
 ## Usage
 
 The `Reader` and `Writer` objects provide the primary means of interfacing with your COPC files. For more complex use cases, we also provide additional objects such as LAZ Compressors and Decompressors (see [example/example-writer.cpp](example/example-writer.cpp)).
@@ -36,6 +42,10 @@ find_package(lazperf REQUIRED)
 add_executable(funfile fun-main.cpp)
 target_link_libraries(funfile copc-lib LAZPERF::lazperf)
 ```
+### Python
+```python
+import copclib
+```
 
 If other library functionality is needed, feel free to open an issue to see about getting it added.
 
diff --git a/example/example-reader.cpp b/example/example-reader.cpp
index de424003..42ed818e 100644
--- a/example/example-reader.cpp
+++ b/example/example-reader.cpp
@@ -1,15 +1,10 @@
-#include <cstdint>
-#include <cstring>
-#include <fstream>
-#include <iostream>
-
 #include <copc-lib/io/reader.hpp>
 #include <copc-lib/laz/decompressor.hpp>
 
 using namespace copc;
 using namespace std;
 
-int main()
+void ReaderExample()
 {
     // Create a reader object
     FileReader reader("test/data/autzen-classified.copc.laz");
@@ -24,8 +19,8 @@ int main()
     // Get the Las Header
     auto las_header = reader.GetLasHeader();
     cout << endl << "Las Header:" << endl;
-    cout << "\tPoint format: " << (int)las_header.point_format_id << endl
-         << "\tPoint count: " << (int)las_header.point_count << endl;
+    cout << "\tPoint Format: " << (int)las_header.point_format_id << endl
+         << "\tPoint Count: " << (int)las_header.point_count << endl;
 
     // Get the WKT string
     auto wkt = reader.GetWkt();
@@ -76,3 +71,5 @@ int main()
              << endl;
     }
 }
+
+int main() { ReaderExample(); }
diff --git a/example/example-reader.py b/example/example-reader.py
new file mode 100644
index 00000000..290e1455
--- /dev/null
+++ b/example/example-reader.py
@@ -0,0 +1,68 @@
+import copclib as copc
+
+
+def reader_example():
+    # Create a reader object
+    reader = copc.FileReader("../test/data/autzen-classified.copc.laz")
+
+    # We can get the CopcData struct
+    copc_vlr = reader.GetCopcHeader()
+    print("CopcData: ")
+    print("\tSpan: %d" % copc_vlr.span)
+    print("\tRoot Offset: %d" % copc_vlr.root_hier_offset)
+    print("\tRoot Size: %d" % copc_vlr.root_hier_size)
+
+    # Get the Las Header
+    las_header = reader.GetLasHeader()
+    print()
+    print("Las Header:")
+    print("\tPoint Format: %d" % las_header.point_format_id)
+    print("\tPoint Count: %d" % las_header.point_count)
+
+    # Get the WKT string
+    print("WKT: %s" % reader.GetWkt())
+
+    load_key = copc.VoxelKey(4, 11, 9, 0)
+
+    # FindNode will automatically load the minimum pages needed
+    # to find the key you request
+    node = reader.FindNode(load_key)
+    # If FindNode can't find the node, it will return an "invalid" node:
+    if not node.IsValid():
+        return
+    # GetPoints returns a Points object, which provides helper functions
+    # as well as a Get() function to access the underlying point vector
+    node_points = reader.GetPoints(node)
+
+    print(node_points)
+
+    print("First 5 points:")
+    for i in range(5):
+        print(node_points.Get(i))
+
+    # We can also get the raw compressed data if we want to decompress it ourselves:
+
+    loadKey = copc.VoxelKey(4, 11, 9, 0)
+
+    node = reader.FindNode(loadKey)
+    if not node.IsValid():
+        return
+    compressed_data = reader.GetPointDataCompressed(node)
+
+    # # We can decompress `n` number of points, or we can decompress the entire node
+    # # by setting n=node.point_count
+    num_points_to_decompress = 5
+    uncompressed_data = copc.DecompressBytes(
+        compressed_data, las_header, num_points_to_decompress
+    )
+
+    print(
+        "Successfully decompressed %d points!"
+        % (len(uncompressed_data) / las_header.point_record_length)
+    )
+
+    reader.Close()
+
+
+if __name__ == "__main__":
+    reader_example()
diff --git a/example/example-writer.cpp b/example/example-writer.cpp
index bd1885fe..ff334339 100644
--- a/example/example-writer.cpp
+++ b/example/example-writer.cpp
@@ -1,20 +1,19 @@
 #include <cassert>
 #include <cstdint>
-#include <cstring>
-#include <fstream>
 #include <random>
 
 #include <copc-lib/io/reader.hpp>
 #include <copc-lib/io/writer.hpp>
+#include <copc-lib/las/header.hpp>
 #include <copc-lib/las/point.hpp>
-//#include <copc-lib/laz/compressor.hpp>
-//#include <copc-lib/laz/decompressor.hpp>
+#include <copc-lib/laz/compressor.hpp>
+#include <copc-lib/laz/decompressor.hpp>
 
 using namespace copc;
 using namespace std;
 
 // In this example, we'll filter the autzen dataset to only contain depth levels 0-3.
-void TrimFileExample()
+void TrimFileExample(bool compressor_example_flag)
 {
     // We'll get our point data from this file
     FileReader reader("test/data/autzen-classified.copc.laz");
@@ -37,19 +36,22 @@ void TrimFileExample()
             if (node.key.d > 3)
                 continue;
 
-            // It's much faster to write and read compressed data, to avoid compression and decompression
-            writer.AddNodeCompressed(root_page, node.key, reader.GetPointDataCompressed(node), node.point_count);
-
-            // Alternatively, if we have uncompressed data and want to compress it without writing it to the file,
-            // (for example, compress multiple nodes in parallel and have one thread writing the data),
-            // we can use the Compressor class:
-            /*
-            las::LasHeader header = writer.GetLasHeader();
-            std::vector<char> uncompressed_points = reader.GetPointData(node);
-            std::vector<char> compressed_points = laz::Compressor::CompressBytes(
-                uncompressed_points, header.point_format_id, cfg.extra_bytes.items.size(), header.point_record_length);
-            writer.AddNodeCompressed(root_page, node.key, compressed_points, node.point_count);
-            */
+            if (!compressor_example_flag)
+            {
+                // It's much faster to write and read compressed data, to avoid compression and decompression
+                writer.AddNodeCompressed(root_page, node.key, reader.GetPointDataCompressed(node), node.point_count);
+            }
+            else
+            {
+                // Alternatively, if we have uncompressed data and want to compress it without writing it to the file,
+                // (for example, compress multiple nodes in parallel and have one thread writing the data),
+                // we can use the Compressor class:
+
+                std::vector<char> uncompressed_points = reader.GetPointData(node);
+                std::vector<char> compressed_points =
+                    laz::Compressor::CompressBytes(uncompressed_points, writer.GetLasHeader());
+                writer.AddNodeCompressed(root_page, node.key, compressed_points, node.point_count);
+            }
         }
 
         // Make sure we call close to finish writing the file!
@@ -66,18 +68,19 @@ void TrimFileExample()
 
         // Similarly, we could retrieve the compressed node data from the file
         // and decompress it later using the Decompressor class
-        /*
-        las::LasHeader header = new_reader.GetLasHeader();
-        std::vector<char> compressed_points = reader.GetPointDataCompressed(node.key);
-        std::vector<char> uncompressed_points = laz::Decompressor::DecompressBytes(compressed_points,
-        header, node.point_count);
-        */
+        if (compressor_example_flag)
+        {
+            las::LasHeader header = new_reader.GetLasHeader();
+            std::vector<char> compressed_points = reader.GetPointDataCompressed(node.key);
+            std::vector<char> uncompressed_points =
+                laz::Decompressor::DecompressBytes(compressed_points, header, node.point_count);
+        }
     }
 }
 
 // constants
-const copc::vector3 MIN_BOUNDS = {-2000, -5000, 20};
-const copc::vector3 MAX_BOUNDS = {5000, 1034, 125};
+const Vector3 MIN_BOUNDS = {-2000, -5000, 20};
+const Vector3 MAX_BOUNDS = {5000, 1034, 125};
 const int NUM_POINTS = 3000;
 
 // random num devices
@@ -101,15 +104,16 @@ las::Points RandomPoints(VoxelKey key, int8_t point_format_id)
     std::uniform_int_distribution<> rand_y((int)std::min(miny, MAX_BOUNDS.y), (int)std::min(miny + step, MAX_BOUNDS.y));
     std::uniform_int_distribution<> rand_z((int)std::min(minz, MAX_BOUNDS.z), (int)std::min(minz + step, MAX_BOUNDS.z));
 
-    las::Points points(point_format_id);
+    las::Points points(point_format_id, {1, 1, 1}, {0, 0, 0});
     for (int i = 0; i < NUM_POINTS; i++)
     {
         // Create a point with a given point format
-        las::Point point(point_format_id);
+        // The use of las::Point constructor is strongly discouraged, instead use las::Points::CreatePoint
+        las::Point point = points.CreatePoint();
         // point has getters/setters for all attributes
-        point.X(rand_x(gen));
-        point.Y(rand_y(gen));
-        point.Z(rand_z(gen));
+        point.UnscaledX(rand_x(gen));
+        point.UnscaledY(rand_y(gen));
+        point.UnscaledZ(rand_z(gen));
         // For visualization purposes
         point.PointSourceID(key.d + key.x + key.y + key.d);
 
@@ -125,10 +129,10 @@ void NewFileExample()
     Writer::LasConfig cfg(8, {1, 1, 1}, {0, 0, 0});
     // As of now, the library will not automatically compute the min/max of added points
     // so we will have to calculate it ourselves
-    cfg.min = copc::vector3{(MIN_BOUNDS.x * cfg.scale.x) - cfg.offset.x, (MIN_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
-                            (MIN_BOUNDS.z * cfg.scale.z) - cfg.offset.z};
-    cfg.max = copc::vector3{(MAX_BOUNDS.x * cfg.scale.x) - cfg.offset.x, (MAX_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
-                            (MAX_BOUNDS.z * cfg.scale.z) - cfg.offset.z};
+    cfg.min = Vector3{(MIN_BOUNDS.x * cfg.scale.x) - cfg.offset.x, (MIN_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
+                      (MIN_BOUNDS.z * cfg.scale.z) - cfg.offset.z};
+    cfg.max = Vector3{(MAX_BOUNDS.x * cfg.scale.x) - cfg.offset.x, (MAX_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
+                      (MAX_BOUNDS.z * cfg.scale.z) - cfg.offset.z};
 
     // Now, we can create our COPC writer, with an optional `span` and `wkt`:
     FileWriter writer("test/data/new-copc.copc.laz", cfg, 256, "TEST_WKT");
@@ -168,6 +172,7 @@ void NewFileExample()
 
 int main()
 {
-    TrimFileExample();
+    TrimFileExample(false);
+    TrimFileExample(true);
     NewFileExample();
 }
diff --git a/example/example-writer.py b/example/example-writer.py
new file mode 100644
index 00000000..75506c43
--- /dev/null
+++ b/example/example-writer.py
@@ -0,0 +1,188 @@
+import random
+
+import copclib as copc
+
+
+# In this example, we'll filter the autzen dataset to only contain depth levels 0-3.
+def TrimFileExample(compressor_example_flag):
+
+    # We'll get our point data from this file
+    reader = copc.FileReader("../test/data/autzen-classified.copc.laz")
+    old_header = reader.GetLasHeader()
+
+    # Copy the header to the new file
+    cfg = copc.LasConfig(old_header, reader.GetExtraByteVlr())
+
+    # Now, we can create our actual writer, with an optional `span` and `wkt`:
+    writer = copc.FileWriter(
+        "../test/data/autzen-trimmed.copc.laz",
+        cfg,
+        reader.GetCopcHeader().span,
+        reader.GetWkt(),
+    )
+
+    # The root page is automatically created and added for us
+    root_page = writer.GetRootPage()
+
+    # GetAllChildren will load the entire hierarchy under a given key
+    for node in reader.GetAllChildren(root_page.key):
+        # In this example, we'll only save up to depth level 3.
+        if node.key.d > 3:
+            continue
+
+        # It's much faster to write and read compressed data, to avoid compression and decompression
+        if not compressor_example_flag:
+            writer.AddNodeCompressed(
+                root_page,
+                node.key,
+                reader.GetPointDataCompressed(node),
+                node.point_count,
+            )
+
+        # Alternatively, if we have uncompressed data and want to compress it without writing it to the file,
+        # (for example, compress multiple nodes in parallel and have one thread writing the data),
+        # we can use the Compressor class:
+        else:
+            header = writer.GetLasHeader()
+            uncompressed_points = reader.GetPointData(node)
+            compressed_points = copc.CompressBytes(
+                uncompressed_points, header.point_format_id, cfg.NumExtraBytes()
+            )
+            writer.AddNodeCompressed(
+                root_page, node.key, compressed_points, node.point_count
+            )
+    # Make sure we call close to finish writing the file!
+    writer.Close()
+
+    # Now, let's test our new file
+    new_reader = copc.FileReader("../test/data/autzen-trimmed.copc.laz")
+
+    # Let's go through each node we've written and make sure it matches the original
+    for node in new_reader.GetAllChildren():
+        assert new_reader.GetPointDataCompressed(node) == reader.GetPointDataCompressed(
+            node.key
+        )
+
+        # Similarly, we could retrieve the compressed node data from the file
+        # and decompress it later using the Decompressor class
+        if compressor_example_flag:
+            header = writer.GetLasHeader()
+            compressed_points = reader.GetPointDataCompressed(node.key)
+            uncompressed_points = copc.DecompressBytes(
+                compressed_points, header, node.point_count
+            )
+    reader.Close()
+    new_reader.Close()
+
+
+# constants
+MIN_BOUNDS = copc.Vector3(-2000, -5000, 20)  # Argument Constructor
+MAX_BOUNDS = copc.Vector3([5000, 1034, 125])  # List Constructor
+NUM_POINTS = 3000
+
+
+# This function will generate `NUM_POINTS` random points within the voxel bounds
+def RandomPoints(key, point_format_id):
+
+    # Voxel cube dimensions will be calculated from the maximum span of the file
+    span = max(
+        {
+            MAX_BOUNDS.x - MIN_BOUNDS.x,
+            MAX_BOUNDS.y - MIN_BOUNDS.y,
+            MAX_BOUNDS.z - MIN_BOUNDS.z,
+        }
+    )
+    # Step size accounts for depth level
+    step = span / pow(2, key.d)
+
+    minx = MIN_BOUNDS.x + (step * key.x)
+    miny = MIN_BOUNDS.y + (step * key.y)
+    minz = MIN_BOUNDS.z + (step * key.z)
+
+    random.seed(0)
+
+    points = copc.Points(
+        point_format_id,
+        copc.Vector3.DefaultScale(),
+        copc.Vector3.DefaultOffset(),
+    )
+    for i in range(NUM_POINTS):
+        # Create a point with a given point format
+        point = points.CreatePoint()
+        # point has getters/setters for all attributes
+        point.UnscaledX = int(
+            random.uniform(min(minx, MAX_BOUNDS.x), min(minx + step, MAX_BOUNDS.x))
+        )
+
+        point.UnscaledY = int(
+            random.uniform(min(miny, MAX_BOUNDS.y), min(miny + step, MAX_BOUNDS.y))
+        )
+
+        point.UnscaledZ = int(
+            random.uniform(min(minz, MAX_BOUNDS.z), min(minz + step, MAX_BOUNDS.z))
+        )
+
+        # For visualization purposes
+        point.PointSourceID = key.d + key.x + key.y + key.d
+
+        points.AddPoint(point)
+    return points
+
+
+# In this example, we'll create our own file from scratch
+def NewFileExample():
+
+    # Create our new file with the specified format, scale, and offset
+    cfg = copc.LasConfig(8, [1, 1, 1], [0, 0, 0])
+    # As of now, the library will not automatically compute the min/max of added points
+    # so we will have to calculate it ourselves
+    cfg.min = [
+        (MIN_BOUNDS.x * cfg.scale.x) - cfg.offset.x,
+        (MIN_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
+        (MIN_BOUNDS.z * cfg.scale.z) - cfg.offset.z,
+    ]
+    cfg.max = [
+        (MAX_BOUNDS.x * cfg.scale.x) - cfg.offset.x,
+        (MAX_BOUNDS.y * cfg.scale.y) - cfg.offset.y,
+        (MAX_BOUNDS.z * cfg.scale.z) - cfg.offset.z,
+    ]
+
+    # Now, we can create our COPC writer, with an optional `span` and `wkt`:
+    writer = copc.FileWriter("../test/data/new-copc.copc.laz", cfg, 256, "TEST_WKT")
+
+    # The root page is automatically created
+    root_page = writer.GetRootPage()
+
+    # First we'll add a root node
+    key = copc.VoxelKey(0, 0, 0, 0)
+    points = RandomPoints(key, cfg.point_format_id)
+    # The node will be written to the file when we call AddNode
+    writer.AddNode(root_page, key, points)
+
+    # We can also add pages in the same way, as long as the Key we specify
+    # is a child of the parent page
+
+    page = writer.AddSubPage(root_page, copc.VoxelKey(1, 1, 1, 0))
+
+    # Once our page is created, we can add nodes to it like before
+    key = copc.VoxelKey(1, 1, 1, 0)
+    points = RandomPoints(key, cfg.point_format_id)
+    writer.AddNode(page, key, points)
+
+    key = copc.VoxelKey(2, 2, 2, 0)
+    points = RandomPoints(key, cfg.point_format_id)
+    writer.AddNode(page, key, points)
+
+    # We can nest subpages as much as we want, as long as they are children of the parent
+    sub_page = writer.AddSubPage(page, copc.VoxelKey(3, 4, 4, 2))
+    points = RandomPoints(sub_page.key, cfg.point_format_id)
+    writer.AddNode(page, sub_page.key, points)
+
+    # Make sure we call close to finish writing the file!
+    writer.Close()
+
+
+if __name__ == "__main__":
+    TrimFileExample(False)
+    TrimFileExample(True)
+    NewFileExample()
diff --git a/include/copc-lib/hierarchy/entry.hpp b/include/copc-lib/hierarchy/entry.hpp
index d3076bfc..cf266fc9 100644
--- a/include/copc-lib/hierarchy/entry.hpp
+++ b/include/copc-lib/hierarchy/entry.hpp
@@ -16,17 +16,17 @@ class Entry
   public:
     static const int ENTRY_SIZE = 32;
 
-    Entry() : offset(-1), size(-1), key(VoxelKey::InvalidKey()), point_count(-1){};
+    Entry() : offset(-1), byte_size(-1), key(VoxelKey::InvalidKey()), point_count(-1){};
     Entry(VoxelKey key, int64_t offset, int32_t size, int32_t point_count)
-        : offset(offset), size(size), key(key), point_count(point_count){};
+        : offset(offset), byte_size(size), key(key), point_count(point_count){};
 
-    virtual bool IsValid() const { return offset >= 0 && size >= 0 && key.IsValid(); }
+    virtual bool IsValid() const { return offset >= 0 && byte_size >= 0 && key.IsValid(); }
     virtual bool IsPage() const { return IsValid() && point_count == -1; }
 
     std::string ToString() const
     {
         std::stringstream ss;
-        ss << "Entry " << key.ToString() << ": off=" << offset << ", size=" << size << ", count=" << point_count
+        ss << "Entry " << key.ToString() << ": off=" << offset << ", size=" << byte_size << ", count=" << point_count
            << ", is_valid=" << IsValid();
         return ss.str();
     }
@@ -39,7 +39,7 @@ class Entry
         out_stream.write(reinterpret_cast<char *>(&key.z), sizeof(key.z));
 
         out_stream.write(reinterpret_cast<char *>(&offset), sizeof(offset));
-        out_stream.write(reinterpret_cast<char *>(&size), sizeof(size));
+        out_stream.write(reinterpret_cast<char *>(&byte_size), sizeof(byte_size));
         out_stream.write(reinterpret_cast<char *>(&point_count), sizeof(point_count));
     }
 
@@ -61,16 +61,16 @@ class Entry
         return Entry(key, offset, size, point_count);
     }
 
-    int32_t point_count;
     VoxelKey key;
     uint64_t offset;
-    int32_t size;
+    int32_t byte_size;
+    int32_t point_count;
 
   protected:
     // Helper function for the equality comparisons of children classes
     bool IsEqual(const Entry &rhs) const
     {
-        return offset == rhs.offset && size == rhs.size && point_count == rhs.point_count && key == rhs.key;
+        return offset == rhs.offset && byte_size == rhs.byte_size && point_count == rhs.point_count && key == rhs.key;
     }
 };
 
diff --git a/include/copc-lib/hierarchy/internal/page.hpp b/include/copc-lib/hierarchy/internal/page.hpp
index 0ed0e190..cfc8c70a 100644
--- a/include/copc-lib/hierarchy/internal/page.hpp
+++ b/include/copc-lib/hierarchy/internal/page.hpp
@@ -11,7 +11,7 @@ class PageInternal : public Page
 {
   public:
     PageInternal(Entry e) : Page(e){};
-    PageInternal(VoxelKey key, int64_t offset, int32_t size) : Page(key, offset, size){};
+    PageInternal(VoxelKey key, int64_t offset, int32_t byte_size) : Page(key, offset, byte_size){};
     PageInternal(VoxelKey key) : Page(key, -1, -1){};
 
     std::vector<std::shared_ptr<PageInternal>> sub_pages;
diff --git a/include/copc-lib/hierarchy/page.hpp b/include/copc-lib/hierarchy/page.hpp
index b477604f..0f41f020 100644
--- a/include/copc-lib/hierarchy/page.hpp
+++ b/include/copc-lib/hierarchy/page.hpp
@@ -14,11 +14,11 @@ class Page : public Entry
 {
   public:
     Page(Entry e) : Entry(e){};
-    Page(VoxelKey key, int64_t offset, int32_t size) : Entry(key, offset, size, -1){};
+    Page(VoxelKey key, int64_t offset, int32_t byte_size) : Entry(key, offset, byte_size, -1){};
 
     // If a page is "loaded" it doesn't matter the offset/size
     // (since the writer will default the offset/size to -1)
-    bool IsValid() const override { return (loaded || (offset >= 0 && size >= 0)) && key.IsValid(); }
+    bool IsValid() const override { return (loaded || (offset >= 0 && byte_size >= 0)) && key.IsValid(); }
     bool IsPage() const override { return IsValid() && point_count == -1; }
 
     bool loaded = false;
diff --git a/include/copc-lib/io/internal/writer_internal.hpp b/include/copc-lib/io/internal/writer_internal.hpp
index fd20bdc2..0e6fa5a7 100644
--- a/include/copc-lib/io/internal/writer_internal.hpp
+++ b/include/copc-lib/io/internal/writer_internal.hpp
@@ -27,6 +27,8 @@ class WriterInternal
     // Call close on destructor if needed
     ~WriterInternal() { Close(); }
 
+    bool IsOpen() const { return open_; }
+
     // Writes a chunk to the laz file
     Entry WriteNode(std::vector<char> in, int32_t point_count, bool compressed);
 
diff --git a/include/copc-lib/io/reader.hpp b/include/copc-lib/io/reader.hpp
index b041209b..b4c8c93b 100644
--- a/include/copc-lib/io/reader.hpp
+++ b/include/copc-lib/io/reader.hpp
@@ -33,7 +33,7 @@ class Reader : public BaseIO
 
     // Return all children of a page with a given key
     // (or the node itself, if it exists, if there isn't a page with that key)
-    std::vector<Node> GetAllChildren(VoxelKey key);
+    std::vector<Node> GetAllChildren(const VoxelKey &key);
     // Helper function to get all nodes from the root
     std::vector<Node> GetAllChildren() { return GetAllChildren(VoxelKey::BaseKey()); }
 
@@ -61,7 +61,7 @@ class Reader : public BaseIO
 class FileReader : public Reader
 {
   public:
-    FileReader(const std::string &file_path)
+    FileReader(const std::string &file_path) : is_open_(true)
     {
         auto f_stream = new std::fstream;
         f_stream->open(file_path.c_str(), std::ios::in | std::ios::binary);
@@ -70,11 +70,20 @@ class FileReader : public Reader
         InitReader();
     }
 
-    ~FileReader()
+    void Close()
     {
-        dynamic_cast<std::fstream *>(in_stream_)->close();
-        delete in_stream_;
+        if (is_open_)
+        {
+            dynamic_cast<std::fstream *>(in_stream_)->close();
+            delete in_stream_;
+            is_open_ = false;
+        }
     }
+
+    ~FileReader() { Close(); }
+
+  private:
+    bool is_open_;
 };
 
 } // namespace copc
diff --git a/include/copc-lib/io/writer.hpp b/include/copc-lib/io/writer.hpp
index 23eba7b5..ff62aa70 100644
--- a/include/copc-lib/io/writer.hpp
+++ b/include/copc-lib/io/writer.hpp
@@ -1,7 +1,10 @@
 #ifndef COPCLIB_IO_WRITER_H_
 #define COPCLIB_IO_WRITER_H_
 
+#include <array>
 #include <ostream>
+#include <stdexcept>
+#include <string>
 
 #include <copc-lib/copc/file.hpp>
 #include <copc-lib/io/base_io.hpp>
@@ -12,25 +15,6 @@
 namespace copc
 {
 
-struct vector3
-{
-    vector3() : x(0), y(0), z(0) {}
-    vector3(double x, double y, double z) : x(x), y(y), z(z) {}
-
-    double x;
-    double y;
-    double z;
-
-    vector3 &operator=(const lazperf::vector3 &a)
-    {
-        x = a.x;
-        y = a.y;
-        z = a.z;
-
-        return *this; // Return a reference to myself.
-    }
-};
-
 // Provides the public interface for writing COPC files
 class Writer : public BaseIO
 {
@@ -40,23 +24,44 @@ class Writer : public BaseIO
     {
         // Not sure we should allow default constructor
         // LasConfig()= default;
-        LasConfig(int8_t point_format_id, vector3 scale = {0.01, 0.01, 0.01}, vector3 offset = {0, 0, 0})
+        LasConfig(const int8_t &point_format_id, const Vector3 &scale = {DEFAULT_SCALE, DEFAULT_SCALE, DEFAULT_SCALE},
+                  const Vector3 &offset = {0, 0, 0})
             : point_format_id(point_format_id), scale(scale), offset(offset){};
         // Allow for "copying" a lasheader from one file to another
-        LasConfig(las::LasHeader config, const las::EbVlr &extra_bytes_);
+        LasConfig(const las::LasHeader &config, const las::EbVlr &extra_bytes_);
 
-        uint16_t file_source_id{};
-        uint16_t global_encoding{};
-        char guid[16]{};
+        // Getters/Setters for string attributes
+        void GUID(const std::string &guid)
+        {
+            if (guid.size() > 16)
+                throw std::runtime_error("GUID length must be <= 16.");
+            guid_ = guid;
+        }
+        std::string GUID() const { return guid_; }
 
-        char system_identifier[32]{};
-        char generating_software[32]{};
+        void SystemIdentifier(const std::string &system_identifier)
+        {
+            if (system_identifier.size() > 32)
+                throw std::runtime_error("System Identifier length must be <= 32.");
+            system_identifier_ = system_identifier;
+        }
+        std::string SystemIdentifier() const { return system_identifier_; }
 
-        struct
+        void GeneratingSoftware(const std::string &generating_software)
         {
-            uint16_t day{};
-            uint16_t year{};
-        } creation;
+            if (generating_software.size() > 32)
+                throw std::runtime_error("System Identifier length must be <= 32.");
+            generating_software_ = generating_software;
+        }
+        std::string GeneratingSoftware() const { return generating_software_; }
+
+        uint16_t NumExtraBytes() const { return extra_bytes.items.size(); }
+
+        uint16_t file_source_id{};
+        uint16_t global_encoding{};
+
+        uint16_t creation_day{};
+        uint16_t creation_year{};
 
         // default to 0
         int8_t point_format_id{};
@@ -64,14 +69,19 @@ class Writer : public BaseIO
         las::EbVlr extra_bytes;
 
         // xyz scale/offset
-        vector3 scale{0.01, 0.01, 0.01};
-        vector3 offset{0, 0, 0};
+        Vector3 scale{0.01, 0.01, 0.01};
+        Vector3 offset{0, 0, 0};
         // xyz min/max for las header
-        vector3 max{0, 0, 0};
-        vector3 min{0, 0, 0};
+        Vector3 max{0, 0, 0};
+        Vector3 min{0, 0, 0};
 
         // # of points per return 0-14
-        uint64_t points_by_return_14[15]{};
+        std::array<uint64_t, 15> points_by_return_14{};
+
+      private:
+        std::string guid_{};
+        std::string system_identifier_{};
+        std::string generating_software_{};
     };
 
     Writer(std::ostream &out_stream, LasConfig const &config, int span = 0, const std::string &wkt = "")
@@ -85,9 +95,10 @@ class Writer : public BaseIO
     virtual void Close() { writer_->Close(); }
 
     // Adds a node to a given page
-    Node AddNode(Page &page, VoxelKey key, las::Points &points);
-    Node AddNodeCompressed(Page &page, VoxelKey key, std::vector<char> const &compressed, uint64_t point_count);
-    Node AddNode(Page &page, VoxelKey key, std::vector<char> const &uncompressed);
+    Node AddNode(Page &page, const VoxelKey &key, las::Points &points); // TODO[Leo]: Add this to tests
+    Node AddNodeCompressed(Page &page, const VoxelKey &key, std::vector<char> const &compressed,
+                           uint64_t point_count); // TODO[Leo]: Add this to tests
+    Node AddNode(Page &page, const VoxelKey &key, std::vector<char> const &uncompressed);
 
     // Adds a subpage to a given page
     Page AddSubPage(Page &page, VoxelKey key);
@@ -106,7 +117,7 @@ class Writer : public BaseIO
     };
 
     // Constructor helper function, initializes the file and hierarchy
-    void InitWriter(std::ostream &out_stream, LasConfig const &config, int span, const std::string &wkt);
+    void InitWriter(std::ostream &out_stream, LasConfig const &config, const int &span, const std::string &wkt);
     // Converts the lasconfig object into an actual LasHeader
     static las::LasHeader HeaderFromConfig(LasConfig const &config);
     // Gets the sum of the byte size the extra bytes will take up, for calculating point_record_len
@@ -116,7 +127,7 @@ class Writer : public BaseIO
 class FileWriter : public Writer
 {
   public:
-    FileWriter(const std::string &file_path, LasConfig const &config, int span = 0, const std::string &wkt = "")
+    FileWriter(const std::string &file_path, LasConfig const &config, const int &span = 0, const std::string &wkt = "")
     {
         f_stream_.open(file_path.c_str(), std::ios::out | std::ios::binary);
         InitWriter(f_stream_, config, span, wkt);
diff --git a/include/copc-lib/las/header.hpp b/include/copc-lib/las/header.hpp
index 938de659..294b6986 100644
--- a/include/copc-lib/las/header.hpp
+++ b/include/copc-lib/las/header.hpp
@@ -1,13 +1,134 @@
 #ifndef COPCLIB_LAS_HEADER_H_
 #define COPCLIB_LAS_HEADER_H_
 
+#include <array>
+#include <limits>
+#include <sstream>
+#include <string>
+
 #include <lazperf/header.hpp>
 #include <lazperf/readers.hpp>
 
-namespace copc::las
+namespace copc
+{
+const double DEFAULT_SCALE = 0.01;
+
+struct Vector3
+{
+    Vector3() : x(0), y(0), z(0) {}
+    Vector3(const double &x, const double &y, const double &z) : x(x), y(y), z(z) {}
+    static Vector3 DefaultScale() { return Vector3(DEFAULT_SCALE, DEFAULT_SCALE, DEFAULT_SCALE); }
+    static Vector3 DefaultOffset() { return Vector3{}; }
+
+    Vector3(const std::vector<double> &vec)
+    {
+        if (vec.size() != 3)
+            throw std::runtime_error("Vector must be of size 3.");
+        x = vec[0];
+        y = vec[1];
+        z = vec[2];
+    }
+
+    double x{};
+    double y{};
+    double z{};
+
+    Vector3 &operator=(const lazperf::vector3 &a)
+    {
+        x = a.x;
+        y = a.y;
+        z = a.z;
+
+        return *this; // Return a reference to myself.
+    }
+
+    std::string ToString()
+    {
+        std::stringstream ss;
+        ss << "Vector3: x=" << x << ", y=" << y << ", z=" << z;
+        return ss.str();
+    }
+
+    bool operator==(Vector3 a) const { return x == a.x && y == a.y && z == a.z; }
+};
+
+namespace las
 {
 using VlrHeader = lazperf::vlr_header;
-// todo: create our own lasheader class so we can switch between header12/14
-using LasHeader = lazperf::header14;
-} // namespace copc::las
+
+class LasHeader
+{
+  public:
+    LasHeader(){};
+    uint16_t NumExtraBytes() const;
+
+    static LasHeader FromLazPerf(const lazperf::header14 &header);
+    lazperf::header14 ToLazPerf() const;
+
+    // Getters/Setters for string attributes
+    void GUID(const std::string &guid)
+    {
+        if (guid.size() > 16)
+            throw std::runtime_error("GUID length must be <= 16.");
+        guid_ = guid;
+    }
+    std::string GUID() const { return guid_; }
+
+    void SystemIdentifier(const std::string &system_identifier)
+    {
+        if (system_identifier.size() > 32)
+            throw std::runtime_error("System Identifier length must be <= 32.");
+        system_identifier_ = system_identifier;
+    }
+    std::string SystemIdentifier() const { return system_identifier_; }
+
+    void GeneratingSoftware(const std::string &generating_software)
+    {
+        if (generating_software.size() > 32)
+            throw std::runtime_error("System Identifier length must be <= 32.");
+        generating_software_ = generating_software;
+    }
+    std::string GeneratingSoftware() const { return generating_software_; }
+
+    uint16_t file_source_id{};
+    uint16_t global_encoding{};
+
+    uint8_t version_major{1};
+    uint8_t version_minor{4};
+
+    uint16_t creation_day{};
+    uint16_t creation_year{};
+
+    uint16_t header_size{};
+    uint32_t point_offset{};
+    uint32_t vlr_count{};
+
+    int8_t point_format_id{};
+    uint16_t point_record_length{};
+
+    uint32_t point_count{};
+    std::array<uint32_t, 5> points_by_return{};
+
+    Vector3 scale{Vector3::DefaultScale()};
+    Vector3 offset{Vector3::DefaultOffset()};
+    // xyz min/max for las header
+    Vector3 max{};
+    Vector3 min{};
+
+    uint64_t wave_offset{0};
+
+    uint64_t evlr_offset{0};
+    uint32_t evlr_count{0};
+    uint64_t point_count_14{0};
+    std::array<uint64_t, 15> points_by_return_14{};
+
+    static const size_t size = 375; // Size of header for LAS 1.4
+  private:
+    std::string guid_{};
+    std::string system_identifier_{};
+    std::string generating_software_{};
+};
+
+} // namespace las
+} // namespace copc
 #endif // COPCLIB_LAS_HEADER_H_
diff --git a/include/copc-lib/las/point.hpp b/include/copc-lib/las/point.hpp
index be465910..5c832e32 100644
--- a/include/copc-lib/las/point.hpp
+++ b/include/copc-lib/las/point.hpp
@@ -4,69 +4,47 @@
 #include <sstream>
 #include <vector>
 
+#include "header.hpp"
+#include <copc-lib/las/header.hpp>
+#include <copc-lib/las/utils.hpp>
+
 namespace copc::las
 {
 class Point
 {
   public:
-    Point(const int8_t &point_format_id, const uint16_t &num_extra_bytes = 0);
+    Point(const int8_t &point_format_id, const Vector3 &scale = Vector3::DefaultScale(),
+          const Vector3 &offset = Vector3::DefaultOffset(), const uint16_t &num_extra_bytes = 0);
+    Point(const LasHeader &header);
     Point(const Point &other);
 
-    static Point Unpack(std::istream &in_stream, const int8_t &point_format_id, const uint16_t &num_extra_bytes);
-
-    void Pack(std::ostream &out_stream) const;
+    // Getters and Setters
 
-    void ToPointFormat(const int8_t &point_format_id);
+#pragma region XYZ
+    // XYZ Scaled
+    double X() const { return ApplyScale(x_, scale_.x, offset_.x); }
+    void X(const double &x) { x_ = RemoveScale<int32_t>(x, scale_.x, offset_.x); }
 
-    std::string ToString() const;
+    double Y() const { return ApplyScale(y_, scale_.y, offset_.y); }
+    void Y(const double &y) { y_ = RemoveScale<int32_t>(y, scale_.y, offset_.y); }
 
-    static uint8_t BaseByteSize(const int8_t &point_format_id);
+    double Z() const { return ApplyScale(z_, scale_.z, offset_.z); }
+    void Z(const double &z) { z_ = RemoveScale<int32_t>(z, scale_.z, offset_.z); }
 
-    // Getters and Setters
-    int32_t X() const { return x_; }
-    void X(const int32_t &x) { x_ = x; }
+    // XYZ Unscaled
+    int32_t UnscaledX() const { return x_; }
+    void UnscaledX(const int32_t &x) { x_ = x; }
 
-    int32_t Y() const { return y_; }
-    void Y(const int32_t &y) { y_ = y; }
+    int32_t UnscaledY() const { return y_; }
+    void UnscaledY(const int32_t &y) { y_ = y; }
 
-    int32_t Z() const { return z_; }
-    void Z(const int32_t &z) { z_ = z; }
+    int32_t UnscaledZ() const { return z_; }
+    void UnscaledZ(const int32_t &z) { z_ = z; }
+#pragma endregion XYZ
 
     uint16_t Intensity() const { return intensity_; }
     void Intensity(const uint16_t &intensity) { intensity_ = intensity; }
 
-    uint8_t ReturnsScanDirEofBitFields() const
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have ReturnsScanDirEofBitFields, use ExtendedReturnsBitFields "
-                                     "or ExtendedFlagsBitFields instead.");
-        else
-            return returns_flags_eof_;
-    }
-    void ReturnsScanDirEofBitFields(const uint8_t &returns_bit_fields)
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have ReturnsScanDirEofBitFields, use ExtendedReturnsBitFields "
-                                     "or ExtendedFlagsBitFields instead.");
-        else
-            returns_flags_eof_ = returns_bit_fields;
-    }
-
-    uint8_t ExtendedReturnsBitFields() const
-    {
-        if (!extended_point_type_)
-            throw std::runtime_error("Point10 does not have ReturnsBitFields, use ReturnsScanDirEofBitFields instead.");
-        else
-            return extended_returns_;
-    }
-    void ExtendedReturnsBitFields(const uint8_t &extended_returns)
-    {
-        if (!extended_point_type_)
-            throw std::runtime_error("Point10 does not have ReturnsBitFields, use ReturnsScanDirEofBitFields instead.");
-        else
-            extended_returns_ = extended_returns;
-    }
-
     uint8_t ReturnNumber() const { return extended_point_type_ ? extended_returns_ & 0xF : returns_flags_eof_ & 0x7; }
     void ReturnNumber(const uint8_t &return_number)
     {
@@ -108,21 +86,88 @@ class Point
         }
     }
 
-    uint8_t ExtendedFlagsBitFields() const
+    uint8_t Classification() const { return extended_point_type_ ? extended_classification_ : classification_ & 0x1F; }
+    void Classification(const uint8_t &classification)
     {
         if (extended_point_type_)
-            return extended_flags_;
+            extended_classification_ = classification;
         else
-            throw std::runtime_error("Point10 does not have extended flags, use ReturnsScanDirEofBitFields instead.");
+        {
+            if (classification > 31)
+                throw std::runtime_error(
+                    "Classification for Point10 must be <= 31. To override this, use ClassificationBitFields.");
+            else
+                classification_ = (classification_ & 0xE0) | classification;
+        }
     }
-    void ExtendedFlagsBitFields(const uint8_t &class_flags)
+
+#pragma region ScanAngle
+    int8_t ScanAngleRank() const
     {
         if (extended_point_type_)
-            extended_flags_ = class_flags;
+            throw std::runtime_error("Point14 does not have Scan Angle Rank.");
         else
-            throw std::runtime_error("Point10 does not have FlagsBitFields, use ReturnsScanDirEofBitFields instead.");
+            return scan_angle_rank_;
+    }
+
+    void ScanAngleRank(const int8_t &scan_angle_rank)
+    {
+        if (extended_point_type_)
+            throw std::runtime_error("Point14 does not have Scan Angle Rank.");
+        else if (scan_angle_rank < -90 || scan_angle_rank > 90)
+            throw std::runtime_error("Scan Angle Rank must be between -90 and 90");
+        else
+            scan_angle_rank_ = scan_angle_rank;
+    }
+
+    int16_t ExtendedScanAngle() const
+    {
+        if (extended_point_type_)
+            return extended_scan_angle_;
+        else
+            throw std::runtime_error("Point10 does not have Scan Angle.");
     }
 
+    void ExtendedScanAngle(const int16_t &scan_angle)
+    {
+        if (extended_point_type_)
+        {
+            if (scan_angle < -30000 || scan_angle > 30000)
+                throw std::runtime_error("Scan Angle must be between -30000 and 30000");
+            else
+                extended_scan_angle_ = scan_angle;
+        }
+        else
+            throw std::runtime_error("Point10 does not have Scan Angle.");
+    }
+#pragma endregion ScanAngle
+
+    float ScanAngle() const
+    {
+        return extended_point_type_ ? 0.006f * float(extended_scan_angle_) : float(scan_angle_rank_);
+    }
+
+    void ScanAngle(const float &scan_angle)
+    {
+        if (extended_point_type_)
+            extended_scan_angle_ = static_cast<int16_t>(scan_angle / 0.006f);
+        else
+        {
+            if (scan_angle < -90 || scan_angle > 90)
+                throw std::runtime_error("Scan Angle Rank must be between -90 and 90");
+            else
+                scan_angle_rank_ = static_cast<int8_t>(scan_angle);
+        }
+    }
+
+    uint8_t UserData() const { return user_data_; }
+    void UserData(const uint8_t &user_data) { user_data_ = user_data; }
+
+    uint16_t PointSourceID() const { return point_source_id_; }
+    void PointSourceID(const uint16_t &point_source_id) { point_source_id_ = point_source_id; }
+
+#pragma region Flags
+
     bool Synthetic() const { return extended_point_type_ ? extended_flags_ & 0x1 : (classification_ >> 5) & 0x1; }
     void Synthetic(const bool &synthetic)
     {
@@ -197,101 +242,23 @@ class Point
         else
             returns_flags_eof_ = (returns_flags_eof_ & 0x7F) | (edge_of_flight_line << 7);
     }
+#pragma endregion Flags
 
-    uint8_t ClassificationBitFields() const
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have Classification BitFields.");
-        else
-            return classification_;
-    }
-
-    void ClassificationBitFields(const uint8_t &classification_bitfields)
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have Classification Bit Fields.");
-        else
-            classification_ = classification_bitfields;
-    }
-
-    uint8_t Classification() const { return extended_point_type_ ? extended_classification_ : classification_ & 0x1F; }
-    void Classification(const uint8_t &classification)
+#pragma region RGB
+    void RGB(const std::vector<uint16_t> &rgb)
     {
-        if (extended_point_type_)
-            extended_classification_ = classification;
-        else
-        {
-            if (classification > 31)
-                throw std::runtime_error(
-                    "Classification for Point10 must be <= 31. To override this, use ClassificationBitFields.");
-            else
-                classification_ = (classification_ & 0xE0) | classification;
-        }
-    }
-
-    int8_t ScanAngleRank() const
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have Scan Angle Rank.");
-        else
-            return scan_angle_rank_;
-    }
-
-    void ScanAngleRank(const int8_t &scan_angle_rank)
-    {
-        if (extended_point_type_)
-            throw std::runtime_error("Point14 does not have Scan Angle Rank.");
-        else if (scan_angle_rank < -90 || scan_angle_rank > 90)
-            throw std::runtime_error("Scan Angle Rank must be between -90 and 90");
-        else
-            scan_angle_rank_ = scan_angle_rank;
-    }
-
-    int16_t ExtendedScanAngle() const
-    {
-        if (extended_point_type_)
-            return extended_scan_angle_;
-        else
-            throw std::runtime_error("Point10 does not have Scan Angle.");
-    }
-
-    void ExtendedScanAngle(const int16_t &scan_angle)
-    {
-        if (extended_point_type_)
+        if (has_rgb_)
         {
-            if (scan_angle < -30000 || scan_angle > 30000)
-                throw std::runtime_error("Scan Angle must be between -30000 and 30000");
-            else
-                extended_scan_angle_ = scan_angle;
+            if (rgb.size() != 3)
+                throw std::runtime_error("RGB vector must be of size 3.");
+            rgb_[0] = rgb[0];
+            rgb_[1] = rgb[1];
+            rgb_[2] = rgb[2];
         }
         else
-            throw std::runtime_error("Point10 does not have Scan Angle.");
-    }
-
-    float ScanAngle() const
-    {
-        return extended_point_type_ ? 0.006f * float(extended_scan_angle_) : float(scan_angle_rank_);
-    }
-
-    void ScanAngle(const float &scan_angle)
-    {
-        if (extended_point_type_)
-            extended_scan_angle_ = static_cast<int16_t>(scan_angle / 0.006f);
-        else
-        {
-            if (scan_angle < -90 || scan_angle > 90)
-                throw std::runtime_error("Scan Angle Rank must be between -90 and 90");
-            else
-                scan_angle_rank_ = static_cast<int8_t>(scan_angle);
-        }
+            throw std::runtime_error("This point format does not have RGB.");
     }
 
-    uint8_t UserData() const { return user_data_; }
-    void UserData(const uint8_t &user_data) { user_data_ = user_data; }
-
-    uint16_t PointSourceID() const { return point_source_id_; }
-    void PointSourceID(const uint16_t &point_source_id) { point_source_id_ = point_source_id; }
-
     void RGB(const uint16_t &red, const uint16_t &green, const uint16_t &blue)
     {
         if (has_rgb_)
@@ -326,7 +293,7 @@ class Point
         else
             throw std::runtime_error("This point format does not have RGB");
     }
-    void Green(const uint16_t green)
+    void Green(const uint16_t &green)
     {
         if (has_rgb_)
             rgb_[1] = green;
@@ -348,6 +315,7 @@ class Point
         else
             throw std::runtime_error("This point format does not have RGB.");
     }
+#pragma endregion RGB
 
     double GPSTime() const
     {
@@ -379,22 +347,8 @@ class Point
             throw std::runtime_error("This point format does not have NIR.");
     }
 
-    bool HasExtendedPoint() const { return extended_point_type_; }
-
-    bool HasGPSTime() const { return has_gps_time_; }
-
-    bool HasRGB() const { return has_rgb_; }
-
-    bool HasNIR() const { return has_nir_; }
-
-    uint32_t PointRecordLength() const { return point_record_length_; }
-
-    int8_t PointFormatID() const { return point_format_id_; }
-
-    uint16_t NumExtraBytes() const { return point_record_length_ - BaseByteSize(point_format_id_); }
-
     std::vector<uint8_t> ExtraBytes() const { return extra_bytes_; }
-    void ExtraBytes(std::vector<uint8_t> in)
+    void ExtraBytes(const std::vector<uint8_t> &in)
     {
         if (in.size() != NumExtraBytes())
             throw std::runtime_error("Number of input bytes " + std::to_string(in.size()) +
@@ -402,125 +356,91 @@ class Point
         extra_bytes_ = in;
     }
 
-    static bool FormatHasGPSTime(const uint8_t &point_format_id)
+#pragma region BitFields
+    uint8_t ReturnsScanDirEofBitFields() const
     {
-        switch (point_format_id)
-        {
-        case 0:
-            return false;
-        case 1:
-            return true;
-        case 2:
-            return false;
-        case 3:
-            return true;
-        case 4:
-        case 5:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-        case 6:
-        case 7:
-        case 8:
-            return true;
-        case 9:
-        case 10:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-
-        default:
-            throw std::runtime_error("Point format must be 0-10");
-        }
+        if (extended_point_type_)
+            throw std::runtime_error("Point14 does not have ReturnsScanDirEofBitFields, use ExtendedReturnsBitFields "
+                                     "or ExtendedFlagsBitFields instead.");
+        else
+            return returns_flags_eof_;
     }
-
-    static bool FormatHasRGB(const uint8_t &point_format_id)
+    void ReturnsScanDirEofBitFields(const uint8_t &returns_bit_fields)
     {
-        switch (point_format_id)
-        {
-        case 0:
-        case 1:
-            return false;
-        case 2:
-        case 3:
-            return true;
-        case 4:
-        case 5:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-        case 6:
-            return false;
-        case 7:
-        case 8:
-            return true;
-        case 9:
-        case 10:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-        default:
-            throw std::runtime_error("Point format must be 0-10");
-        }
+        if (extended_point_type_)
+            throw std::runtime_error("Point14 does not have ReturnsScanDirEofBitFields, use ExtendedReturnsBitFields "
+                                     "or ExtendedFlagsBitFields instead.");
+        else
+            returns_flags_eof_ = returns_bit_fields;
     }
 
-    static bool FormatHasNIR(const uint8_t &point_format_id)
+    uint8_t ExtendedReturnsBitFields() const
     {
-        switch (point_format_id)
-        {
-        case 0:
-        case 1:
-        case 2:
-        case 3:
-            return false;
-        case 4:
-        case 5:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-        case 6:
-        case 7:
-            return false;
-        case 8:
-            return true;
-        case 9:
-        case 10:
-            throw std::runtime_error("Point formats with Wave Packets not yet supported");
-        default:
-            throw std::runtime_error("Point format must be 0-10");
-        }
+        if (!extended_point_type_)
+            throw std::runtime_error("Point10 does not have ReturnsBitFields, use ReturnsScanDirEofBitFields instead.");
+        else
+            return extended_returns_;
+    }
+    void ExtendedReturnsBitFields(const uint8_t &extended_returns)
+    {
+        if (!extended_point_type_)
+            throw std::runtime_error("Point10 does not have ReturnsBitFields, use ReturnsScanDirEofBitFields instead.");
+        else
+            extended_returns_ = extended_returns;
     }
 
-    bool operator==(const Point &other) const
-    {
-        if (point_format_id_ != other.point_format_id_ || point_record_length_ != other.point_record_length_)
-            return false;
-        if (x_ != other.X() || y_ != other.Y() || z_ != other.Z() || intensity_ != other.Intensity())
-            return false;
-        if (ReturnNumber() != other.ReturnNumber() || NumberOfReturns() != other.NumberOfReturns())
-            return false;
-        if (ScanDirectionFlag() != other.ScanDirectionFlag() || EdgeOfFlightLineFlag() != other.EdgeOfFlightLineFlag())
-            return false;
-        if (Classification() != other.Classification())
-            return false;
-        if (Synthetic() != other.Synthetic() || KeyPoint() != other.KeyPoint() || Withheld() != other.Withheld())
-            return false;
-        if (ScanAngle() != other.ScanAngle() || user_data_ != other.UserData() ||
-            point_source_id_ != other.PointSourceID())
-            return false;
-        if (ExtraBytes() != other.ExtraBytes())
-            return false;
-        if (extended_point_type_ && (Overlap() != other.Overlap() || ScannerChannel() != other.ScannerChannel()))
-            return false;
-        if (has_gps_time_ && (gps_time_ != other.GPSTime()))
-            return false;
-        if (has_rgb_ && (rgb_[0] != other.Red() || rgb_[1] != other.Green() || rgb_[2] != other.Blue()))
-            return false;
-        if (has_nir_ && (nir_ != other.NIR()))
-            return false;
-        return true;
-    };
-
-    bool operator!=(const Point &other) const { return !(*this == other); };
+    uint8_t ExtendedFlagsBitFields() const
+    {
+        if (extended_point_type_)
+            return extended_flags_;
+        else
+            throw std::runtime_error("Point10 does not have extended flags, use ReturnsScanDirEofBitFields instead.");
+    }
+    void ExtendedFlagsBitFields(const uint8_t &class_flags)
+    {
+        if (extended_point_type_)
+            extended_flags_ = class_flags;
+        else
+            throw std::runtime_error("Point10 does not have FlagsBitFields, use ReturnsScanDirEofBitFields instead.");
+    }
 
-    static uint16_t ComputeNumExtraBytes(int8_t point_format_id, uint32_t point_record_length)
+    uint8_t ClassificationBitFields() const
     {
-        return point_record_length - BaseByteSize(point_format_id);
+        if (extended_point_type_)
+            throw std::runtime_error("Point14 does not have Classification BitFields.");
+        else
+            return classification_;
     }
-    static uint16_t ComputePointBytes(int8_t point_format_id, uint16_t num_extra_bytes_)
+
+    void ClassificationBitFields(const uint8_t &classification_bitfields)
     {
-        return BaseByteSize(point_format_id) + num_extra_bytes_;
+        if (extended_point_type_)
+            throw std::runtime_error("Point14 does not have Classification Bit Fields.");
+        else
+            classification_ = classification_bitfields;
     }
+#pragma endregion BitFields
+
+    bool HasExtendedPoint() const { return extended_point_type_; }
+    bool HasGPSTime() const { return has_gps_time_; }
+    bool HasRGB() const { return has_rgb_; }
+    bool HasNIR() const { return has_nir_; }
+
+    uint32_t PointRecordLength() const { return point_record_length_; }
+    int8_t PointFormatID() const { return point_format_id_; }
+    uint16_t NumExtraBytes() const;
+
+    Vector3 Scale() const { return scale_; }
+    Vector3 Offset() const { return offset_; }
+
+    bool operator==(const Point &other) const;
+    bool operator!=(const Point &other) const { return !(*this == other); };
+    std::string ToString() const;
+
+    static Point Unpack(std::istream &in_stream, const int8_t &point_format_id, const Vector3 &scale,
+                        const Vector3 &offset, const uint16_t &num_extra_bytes);
+    void Pack(std::ostream &out_stream) const;
+    void ToPointFormat(const int8_t &point_format_id);
 
   protected:
     int32_t x_{};
@@ -550,8 +470,11 @@ class Point
 
     std::vector<uint8_t> extra_bytes_;
 
+  private:
     uint32_t point_record_length_;
     int8_t point_format_id_;
+    Vector3 scale_;
+    Vector3 offset_;
 };
 
 } // namespace copc::las
diff --git a/include/copc-lib/las/points.hpp b/include/copc-lib/las/points.hpp
index 683f5d16..d747c08c 100644
--- a/include/copc-lib/las/points.hpp
+++ b/include/copc-lib/las/points.hpp
@@ -1,53 +1,113 @@
 #ifndef COPCLIB_LAS_POINTS_H_
 #define COPCLIB_LAS_POINTS_H_
 
-#include <copc-lib/las/point.hpp>
+#include <algorithm>
 #include <utility>
 #include <vector>
 
+#include <copc-lib/las/header.hpp>
+#include <copc-lib/las/point.hpp>
+#include <copc-lib/las/utils.hpp>
+
 namespace copc::las
 {
 // The Points class provides a wrapper around a vector of copc::las::Point objects
-// It is recommended to use this class over a vector, and to use the NewPoint function
+// It is recommended to use this class over a vector, and to use the CreatePoint function
 // rather than calling the point constructor directly.
 class Points
 {
   public:
-    Points(const int8_t &point_format_id, const uint16_t &num_extra_bytes = 0);
+    Points(const int8_t &point_format_id, const Vector3 &scale, const Vector3 &offset,
+           const uint16_t &num_extra_bytes = 0);
+    Points(const LasHeader &header);
     // Will create Points object given a points vector
     Points(const std::vector<Point> &points);
 
     // Getters
     int8_t PointFormatID() const { return point_format_id_; }
     uint32_t PointRecordLength() const { return point_record_length_; }
-    uint32_t NumExtraBytes() const { return Point::ComputeNumExtraBytes(point_format_id_, point_record_length_); }
+    uint32_t NumExtraBytes() const { return ComputeNumExtraBytes(point_format_id_, point_record_length_); }
 
     // Vector functions
     std::vector<Point> Get() { return points_; }
-    Point Get(size_t idx) { return points_[idx]; }
+    Point Get(const size_t &idx) { return points_[idx]; }
     size_t Size() const { return points_.size(); }
-    void Reserve(size_t num) { points_.reserve(num); }
+    void Reserve(const size_t &num) { points_.reserve(num); }
 
     // Add points functions
     void AddPoint(const Point &point);
     void AddPoints(Points points);
+    // TODO[Leo]: Add this to tests
     void AddPoints(std::vector<las::Point> points);
 
     // Point functions
-    las::Point NewPoint() { return las::Point(point_format_id_, NumExtraBytes()); }
+    las::Point CreatePoint() { return las::Point(point_format_id_, scale_, offset_, NumExtraBytes()); }
     void ToPointFormat(const int8_t &point_format_id);
 
     // Pack/unpack
     std::vector<char> Pack();
     void Pack(std::ostream &out_stream);
-    static Points Unpack(const std::vector<char> &point_data, int8_t point_format_id, int point_record_length);
+    static Points Unpack(const std::vector<char> &point_data, const int8_t &point_format_id,
+                         const uint16_t &num_extra_bytes, const Vector3 &scale, const Vector3 &offset);
+    static Points Unpack(const std::vector<char> &point_data, const LasHeader &header);
 
     std::string ToString() const;
 
+    // Getters and setters
+    std::vector<double> X() const
+    {
+        std::vector<double> out;
+        out.resize(Size());
+        std::transform(points_.begin(), points_.end(), out.begin(), [](Point p) { return p.X(); });
+        return out;
+    }
+    void X(const std::vector<double> &in)
+    {
+        if (in.size() != Size())
+            throw std::runtime_error("X setter array must be same size as Points array!");
+
+        for (unsigned i = 0; i < points_.size(); ++i)
+            points_[i].X(in[i]);
+    }
+
+    std::vector<double> Y() const
+    {
+        std::vector<double> out;
+        out.resize(Size());
+        std::transform(points_.begin(), points_.end(), out.begin(), [](const Point &p) { return p.Y(); });
+        return out;
+    }
+    void Y(const std::vector<double> &in)
+    {
+        if (in.size() != Size())
+            throw std::runtime_error("Y setter array must be same size as Points array!");
+
+        for (unsigned i = 0; i < points_.size(); ++i)
+            points_[i].Y(in[i]);
+    }
+
+    std::vector<double> Z() const
+    {
+        std::vector<double> out;
+        out.resize(Size());
+        std::transform(points_.begin(), points_.end(), out.begin(), [](const Point &p) { return p.Z(); });
+        return out;
+    }
+    void Z(const std::vector<double> &in)
+    {
+        if (in.size() != Size())
+            throw std::runtime_error("Z setter array must be same size as Points array!");
+
+        for (unsigned i = 0; i < points_.size(); ++i)
+            points_[i].Z(in[i]);
+    }
+
   private:
     std::vector<Point> points_;
     int8_t point_format_id_;
     uint32_t point_record_length_;
+    Vector3 scale_;
+    Vector3 offset_;
 };
 } // namespace copc::las
 #endif // COPCLIB_LAS_POINTS_H_
diff --git a/include/copc-lib/las/utils.hpp b/include/copc-lib/las/utils.hpp
index 5b06a160..35714cf1 100644
--- a/include/copc-lib/las/utils.hpp
+++ b/include/copc-lib/las/utils.hpp
@@ -1,21 +1,42 @@
 #ifndef COPCLIB_LAS_UTILS_H_
 #define COPCLIB_LAS_UTILS_H_
 
+#include <cmath>
 #include <iostream>
 
-namespace copc::las::internal
+namespace copc::las
 {
+uint8_t PointBaseByteSize(const int8_t &point_format_id);
+uint16_t ComputeNumExtraBytes(const int8_t &point_format_id, const uint32_t &point_record_length);
+uint16_t ComputePointBytes(const int8_t &point_format_id, const uint16_t &num_extra_bytes);
+
+bool FormatHasGPSTime(const uint8_t &point_format_id);
+bool FormatHasRGB(const uint8_t &point_format_id);
+bool FormatHasNIR(const uint8_t &point_format_id);
+
+template <typename T> double ApplyScale(T value, double scale, double offset) { return (value * scale) + offset; }
+template <typename T> T RemoveScale(double value, double scale, double offset)
+{
+    double val = std::round((value - offset) / scale);
+
+    if (val < std::numeric_limits<T>::min() || val > std::numeric_limits<T>::max())
+        throw std::runtime_error("The value " + std::to_string(value) +
+                                 " is too large to save into the requested format." +
+                                 " Your scale and/or offset may be incorrect.");
+
+    return static_cast<T>(val);
+}
+
 template <typename T> T unpack(std::istream &in_stream)
 {
     T value;
     in_stream.read(reinterpret_cast<char *>(&value), sizeof(T));
     return value;
 }
-
 template <typename T> void pack(const T &value, std::ostream &out_stream)
 {
     out_stream.write(reinterpret_cast<const char *>(&value), sizeof(T));
-};
+}
 
-} // namespace copc::las::internal
+} // namespace copc::las
 #endif // COPCLIB_LAS_UTILS_H_
diff --git a/include/copc-lib/las/vlr.hpp b/include/copc-lib/las/vlr.hpp
index 1eb02e31..4ee7c5c9 100644
--- a/include/copc-lib/las/vlr.hpp
+++ b/include/copc-lib/las/vlr.hpp
@@ -24,8 +24,6 @@ inline bool operator==(const eb_vlr::ebfield &a, const eb_vlr::ebfield &b)
            std::memcmp(a.maxval, b.maxval, 3) == 0 && std::memcmp(a.scale, b.scale, 3) == 0 &&
            std::memcmp(a.offset, b.offset, 3) == 0 && a.description == b.description;
 }
-
-inline bool operator==(const vector3 &a, const vector3 &b) { return a.x == b.x && a.y == b.y && a.z == b.z; }
 } // namespace lazperf
 
 #endif // COPCLIB_LAS_VLR_H_
diff --git a/include/copc-lib/laz/compressor.hpp b/include/copc-lib/laz/compressor.hpp
index 0fb0d7b1..bfa9b738 100644
--- a/include/copc-lib/laz/compressor.hpp
+++ b/include/copc-lib/laz/compressor.hpp
@@ -2,6 +2,7 @@
 #define COPCLIB_LAZ_COMPRESS_H_
 
 #include <copc-lib/io/writer.hpp>
+#include <copc-lib/las/utils.hpp>
 
 #include <lazperf/filestream.hpp>
 
@@ -17,14 +18,14 @@ class Compressor
 {
   public:
     // Compresses bytes and writes them to the out stream
-    static uint32_t CompressBytes(std::ostream &out_stream, uint16_t point_format_id, uint16_t eb_count,
-                                  uint16_t point_record_length, std::vector<char> &in)
+    static uint32_t CompressBytes(std::ostream &out_stream, const int8_t &point_format_id,
+                                  const uint16_t &num_extra_bytes, std::vector<char> &in)
     {
         OutFileStream stream(out_stream);
 
-        las_compressor::ptr compressor = build_las_compressor(stream.cb(), point_format_id, eb_count);
+        las_compressor::ptr compressor = build_las_compressor(stream.cb(), point_format_id, num_extra_bytes);
 
-        int point_size = point_record_length;
+        int point_size = copc::las::ComputePointBytes(point_format_id, num_extra_bytes);
         if (in.size() % point_size != 0)
             throw std::runtime_error("Invalid input stream for compression!");
 
@@ -42,17 +43,22 @@ class Compressor
 
     static uint32_t CompressBytes(std::ostream &out_stream, las::LasHeader const &header, std::vector<char> &in)
     {
-        return CompressBytes(out_stream, header.point_format_id, header.ebCount(), header.point_record_length, in);
+        return CompressBytes(out_stream, header.point_format_id, header.NumExtraBytes(), in);
     }
 
-    static std::vector<char> CompressBytes(std::vector<char> &in, uint16_t point_format_id, uint16_t eb_count,
-                                           uint16_t point_record_length)
+    static std::vector<char> CompressBytes(std::vector<char> &in, const int8_t &point_format_id,
+                                           const uint16_t &num_extra_bytes)
     {
         std::ostringstream out_stream;
-        CompressBytes(out_stream, point_format_id, eb_count, point_record_length, in);
+        CompressBytes(out_stream, point_format_id, num_extra_bytes, in);
         auto ostr = out_stream.str();
         return std::vector<char>(ostr.begin(), ostr.end());
     }
+
+    static std::vector<char> CompressBytes(std::vector<char> &in, const las::LasHeader &header)
+    {
+        return CompressBytes(in, header.point_format_id, header.NumExtraBytes());
+    }
 };
 } // namespace copc::laz
 
diff --git a/include/copc-lib/laz/decompressor.hpp b/include/copc-lib/laz/decompressor.hpp
index 57948dec..e93d3827 100644
--- a/include/copc-lib/laz/decompressor.hpp
+++ b/include/copc-lib/laz/decompressor.hpp
@@ -19,13 +19,14 @@ class Decompressor
 {
   public:
     // Decompresses bytes from the instream and returns them
-    static std::vector<char> DecompressBytes(std::istream &in_stream, las::LasHeader &header, int point_count)
+    static std::vector<char> DecompressBytes(std::istream &in_stream, const las::LasHeader &header,
+                                             const int &point_count)
     {
         std::vector<char> out;
 
         InFileStream stre(in_stream);
         las_decompressor::ptr decompressor =
-            build_las_decompressor(stre.cb(), header.point_format_id, header.ebCount());
+            build_las_decompressor(stre.cb(), header.point_format_id, header.NumExtraBytes());
 
         int point_size = header.point_record_length;
         char buff[255];
@@ -39,7 +40,8 @@ class Decompressor
         return out;
     }
 
-    static std::vector<char> DecompressBytes(std::vector<char> compressed_data, las::LasHeader &header, int point_count)
+    static std::vector<char> DecompressBytes(const std::vector<char> &compressed_data, const las::LasHeader &header,
+                                             const int &point_count)
     {
         std::istringstream in_stream(std::string(compressed_data.begin(), compressed_data.end()));
         return DecompressBytes(in_stream, header, point_count);
diff --git a/lib/pybind11 b/lib/pybind11
new file mode 160000
index 00000000..787d2c88
--- /dev/null
+++ b/lib/pybind11
@@ -0,0 +1,1 @@
+Subproject commit 787d2c88cafa4d07fb38c9519c485a86323cfcf4
diff --git a/python/bindings.cpp b/python/bindings.cpp
new file mode 100644
index 00000000..7e089341
--- /dev/null
+++ b/python/bindings.cpp
@@ -0,0 +1,311 @@
+#include <iostream>
+#include <vector>
+
+#include <pybind11/operators.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+#include <pybind11/stl_bind.h>
+
+#include <copc-lib/hierarchy/key.hpp>
+#include <copc-lib/hierarchy/node.hpp>
+#include <copc-lib/io/reader.hpp>
+#include <copc-lib/io/writer.hpp>
+#include <copc-lib/las/file.hpp>
+#include <copc-lib/las/header.hpp>
+#include <copc-lib/las/point.hpp>
+#include <copc-lib/las/points.hpp>
+#include <copc-lib/laz/compressor.hpp>
+#include <copc-lib/laz/decompressor.hpp>
+
+namespace py = pybind11;
+using namespace copc;
+
+PYBIND11_MAKE_OPAQUE(std::vector<char>);
+
+PYBIND11_MODULE(copclib, m)
+{
+    py::bind_vector<std::vector<char>>(m, "VectorChar", py::buffer_protocol());
+
+    py::class_<VoxelKey>(m, "VoxelKey")
+        .def(py::init<>())
+        .def(py::init<int32_t, int32_t, int32_t, int32_t>(), py::arg("d"), py::arg("x"), py::arg("y"), py::arg("z"))
+        .def(py::self == py::self)
+        .def(py::self != py::self)
+        .def_readwrite("d", &VoxelKey::d)
+        .def_readwrite("x", &VoxelKey::x)
+        .def_readwrite("y", &VoxelKey::y)
+        .def_readwrite("z", &VoxelKey::z)
+        .def("IsValid", &VoxelKey::IsValid)
+        .def("BaseKey", &VoxelKey::BaseKey)
+        .def("InvalidKey", &VoxelKey::InvalidKey)
+        .def("Bisect", &VoxelKey::Bisect)
+        .def("GetParent", &VoxelKey::GetParent)
+        .def("GetParents", &VoxelKey::GetParents, py::arg("include_current"))
+        .def("ChildOf", &VoxelKey::ChildOf, py::arg("parent_key"))
+        .def("__str__", &VoxelKey::ToString)
+        .def("__repr__", &VoxelKey::ToString);
+
+    py::class_<Node>(m, "Node")
+        .def(py::init<>())
+        .def(py::init<Entry>())
+        .def_readwrite("point_count", &Node::point_count)
+        .def_readwrite("key", &Node::key)
+        .def_readwrite("offset", &Node::offset)
+        .def_readwrite("byte_size", &Node::byte_size)
+        .def("IsValid", &Node::IsValid)
+        .def("IsPage", &Node::IsPage)
+        .def("__str__", &Node::ToString)
+        .def("__repr__", &Node::ToString);
+
+    py::class_<Page>(m, "Page")
+        .def_readwrite("point_count", &Page::point_count)
+        .def_readwrite("key", &Page::key)
+        .def_readwrite("offset", &Page::offset)
+        .def_readwrite("byte_size", &Page::byte_size)
+        .def_readwrite("loaded", &Page::loaded)
+        .def("IsValid", &Page::IsValid)
+        .def("IsPage", &Page::IsPage)
+        .def("__str__", &Page::ToString)
+        .def("__repr__", &Page::ToString);
+
+    py::class_<Vector3>(m, "Vector3")
+        .def(py::init<const double &, const double &, const double &>())
+        .def(py::init<const std::vector<double> &>())
+        .def_readwrite("x", &Vector3::x)
+        .def_readwrite("y", &Vector3::y)
+        .def_readwrite("z", &Vector3::z)
+        .def("DefaultScale", &Vector3::DefaultScale)
+        .def("DefaultOffset", &Vector3::DefaultOffset)
+        .def(py::self == py::self)
+        .def("__str__", &Vector3::ToString)
+        .def("__repr__", &Vector3::ToString);
+
+    py::implicitly_convertible<py::list, Vector3>();
+
+    py::class_<las::Point>(m, "Point")
+        .def_property("X", py::overload_cast<>(&las::Point::X, py::const_),
+                      py::overload_cast<const double &>(&las::Point::X))
+        .def_property("Y", py::overload_cast<>(&las::Point::Y, py::const_),
+                      py::overload_cast<const double &>(&las::Point::Y))
+        .def_property("Z", py::overload_cast<>(&las::Point::Z, py::const_),
+                      py::overload_cast<const double &>(&las::Point::Z))
+        .def_property("UnscaledX", py::overload_cast<>(&las::Point::UnscaledX, py::const_),
+                      py::overload_cast<const int32_t &>(&las::Point::UnscaledX))
+        .def_property("UnscaledY", py::overload_cast<>(&las::Point::UnscaledY, py::const_),
+                      py::overload_cast<const int32_t &>(&las::Point::UnscaledY))
+        .def_property("UnscaledZ", py::overload_cast<>(&las::Point::UnscaledZ, py::const_),
+                      py::overload_cast<const int32_t &>(&las::Point::UnscaledZ))
+        .def_property("Intensity", py::overload_cast<>(&las::Point::Intensity, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::Intensity))
+        .def_property("ReturnsScanDirEofBitFields",
+                      py::overload_cast<>(&las::Point::ReturnsScanDirEofBitFields, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ReturnsScanDirEofBitFields))
+        .def_property("ExtendedReturnsBitFields",
+                      py::overload_cast<>(&las::Point::ExtendedReturnsBitFields, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ExtendedReturnsBitFields))
+        .def_property("ReturnNumber", py::overload_cast<>(&las::Point::ReturnNumber, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ReturnNumber))
+        .def_property("NumberOfReturns", py::overload_cast<>(&las::Point::NumberOfReturns, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::NumberOfReturns))
+        .def_property("ExtendedFlagsBitFields", py::overload_cast<>(&las::Point::ExtendedFlagsBitFields, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ExtendedFlagsBitFields))
+        .def_property("Synthetic", py::overload_cast<>(&las::Point::Synthetic, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::Synthetic))
+        .def_property("KeyPoint", py::overload_cast<>(&las::Point::KeyPoint, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::KeyPoint))
+        .def_property("Withheld", py::overload_cast<>(&las::Point::Withheld, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::Withheld))
+        .def_property("Overlap", py::overload_cast<>(&las::Point::Overlap, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::Overlap))
+        .def_property("ScannerChannel", py::overload_cast<>(&las::Point::ScannerChannel, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ScannerChannel))
+        .def_property("ScanDirectionFlag", py::overload_cast<>(&las::Point::ScanDirectionFlag, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::ScanDirectionFlag))
+        .def_property("EdgeOfFlightLineFlag", py::overload_cast<>(&las::Point::EdgeOfFlightLineFlag, py::const_),
+                      py::overload_cast<const bool &>(&las::Point::EdgeOfFlightLineFlag))
+        .def_property("ClassificationBitFields", py::overload_cast<>(&las::Point::ClassificationBitFields, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::ClassificationBitFields))
+        .def_property("Classification", py::overload_cast<>(&las::Point::Classification, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::Classification))
+        .def_property("ScanAngleRank", py::overload_cast<>(&las::Point::ScanAngleRank, py::const_),
+                      py::overload_cast<const int8_t &>(&las::Point::ScanAngleRank))
+        .def_property("ExtendedScanAngle", py::overload_cast<>(&las::Point::ExtendedScanAngle, py::const_),
+                      py::overload_cast<const int16_t &>(&las::Point::ExtendedScanAngle))
+        .def_property("ScanAngle", py::overload_cast<>(&las::Point::ScanAngle, py::const_),
+                      py::overload_cast<const float &>(&las::Point::ScanAngle))
+        .def_property("UserData", py::overload_cast<>(&las::Point::UserData, py::const_),
+                      py::overload_cast<const uint8_t &>(&las::Point::UserData))
+        .def_property("PointSourceID", py::overload_cast<>(&las::Point::PointSourceID, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::PointSourceID))
+        .def_property("RGB", nullptr, py::overload_cast<const std::vector<uint16_t> &>(&las::Point::RGB))
+        .def_property("Red", py::overload_cast<>(&las::Point::Red, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::Red))
+        .def_property("Green", py::overload_cast<>(&las::Point::Green, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::Green))
+        .def_property("Blue", py::overload_cast<>(&las::Point::Blue, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::Blue))
+        .def_property("GPSTime", py::overload_cast<>(&las::Point::GPSTime, py::const_),
+                      py::overload_cast<const double &>(&las::Point::GPSTime))
+        .def_property("NIR", py::overload_cast<>(&las::Point::NIR, py::const_),
+                      py::overload_cast<const uint16_t &>(&las::Point::NIR))
+
+        .def_property_readonly("HasExtendedPoint", &las::Point::HasExtendedPoint)
+        .def_property_readonly("HasGPSTime", &las::Point::HasGPSTime)
+        .def_property_readonly("HasRGB", &las::Point::HasRGB)
+        .def_property_readonly("HasRGB", &las::Point::HasRGB)
+        .def_property_readonly("HasNIR", &las::Point::HasNIR)
+
+        .def("ToPointFormat", &las::Point::ToPointFormat, py::arg("point_format_id"))
+
+        .def_property_readonly("PointFormatID", &las::Point::PointFormatID)
+        .def_property_readonly("PointRecordLength", &las::Point::PointRecordLength)
+        .def_property_readonly("NumExtraBytes", &las::Point::NumExtraBytes)
+
+        .def_property("ExtraBytes", py::overload_cast<>(&las::Point::ExtraBytes, py::const_),
+                      py::overload_cast<const std::vector<uint8_t> &>(&las::Point::ExtraBytes))
+
+        .def(py::self == py::self)
+        .def(py::self != py::self)
+        .def("__str__", &las::Point::ToString)
+        .def("__repr__", &las::Point::ToString);
+
+    py::class_<las::Points>(m, "Points")
+        .def(py::init<const uint8_t &, const Vector3 &, const Vector3 &, const uint16_t &>(),
+             py::arg("point_format_id"), py::arg("scale"), py::arg("offset"), py::arg("num_extra_bytes") = 0)
+        .def(py::init<const std::vector<las::Point> &>(), py::arg("points"))
+        .def(py::init<const las::LasHeader &>())
+        .def_property("X", py::overload_cast<>(&las::Points::X, py::const_),
+                      py::overload_cast<const std::vector<double> &>(&las::Points::X))
+        .def_property("Y", py::overload_cast<>(&las::Points::Y, py::const_),
+                      py::overload_cast<const std::vector<double> &>(&las::Points::Y))
+        .def_property("Z", py::overload_cast<>(&las::Points::Z, py::const_),
+                      py::overload_cast<const std::vector<double> &>(&las::Points::Z))
+        .def_property_readonly("PointFormatID", &las::Points::PointFormatID)
+        .def_property_readonly("PointRecordLength", &las::Points::PointRecordLength)
+        .def_property_readonly("NumExtraBytes", &las::Points::NumExtraBytes)
+        .def("Get", py::overload_cast<>(&las::Points::Get))
+        .def("Get", py::overload_cast<const size_t &>(&las::Points::Get), py::arg("idx"))
+        .def_property_readonly("Size", &las::Points::Size)
+        .def("AddPoint", &las::Points::AddPoint)
+        .def("AddPoints", py::overload_cast<las::Points>(&las::Points::AddPoints))
+        .def("AddPoints", py::overload_cast<std::vector<las::Point>>(&las::Points::AddPoints))
+        .def("CreatePoint", &las::Points::CreatePoint)
+        .def("ToPointFormat", &las::Points::ToPointFormat, py::arg("point_format_id"))
+        .def("Pack", py::overload_cast<>(&las::Points::Pack))
+        .def("Unpack", py::overload_cast<const std::vector<char> &, const las::LasHeader &>(&las::Points::Unpack))
+        .def("Unpack", py::overload_cast<const std::vector<char> &, const int8_t &, const uint16_t &, const Vector3 &,
+                                         const Vector3 &>(&las::Points::Unpack))
+        .def("__str__", &las::Points::ToString)
+        .def("__repr__", &las::Points::ToString);
+
+    py::class_<FileReader>(m, "FileReader")
+        .def(py::init<std::string &>())
+        .def("Close", &FileReader::Close)
+        .def("FindNode", &Reader::FindNode, py::arg("key"))
+        .def("GetWkt", &Reader::GetWkt)
+        .def("GetCopcHeader", &Reader::GetCopcHeader)
+        .def("GetLasHeader", &Reader::GetLasHeader)
+        .def("GetExtraByteVlr", &Reader::GetExtraByteVlr)
+        .def("GetPointData", py::overload_cast<const Node &>(&Reader::GetPointData), py::arg("node"))
+        .def("GetPointData", py::overload_cast<const VoxelKey &>(&Reader::GetPointData), py::arg("key"))
+        .def("GetPoints", py::overload_cast<const Node &>(&Reader::GetPoints), py::arg("node"))
+        .def("GetPoints", py::overload_cast<const VoxelKey &>(&Reader::GetPoints), py::arg("key"))
+        .def("GetPointDataCompressed", py::overload_cast<const Node &>(&Reader::GetPointDataCompressed),
+             py::arg("node"))
+        .def("GetPointDataCompressed", py::overload_cast<const VoxelKey &>(&Reader::GetPointDataCompressed),
+             py::arg("key"))
+        .def("GetAllChildren", py::overload_cast<const VoxelKey &>(&Reader::GetAllChildren), py::arg("key"))
+        .def("GetAllChildren", py::overload_cast<>(&Reader::GetAllChildren));
+
+    py::class_<FileWriter>(m, "FileWriter")
+        .def(py::init<const std::string &, Writer::LasConfig const &, const int &, const std::string &>(),
+             py::arg("file_path"), py::arg("config"), py::arg("span") = 0, py::arg("wkt") = "")
+        .def("FindNode", &Writer::FindNode)
+        .def("GetWkt", &Writer::GetWkt)
+        .def("GetCopcHeader", &Writer::GetCopcHeader)
+        .def("GetLasHeader", &Writer::GetLasHeader)
+        .def("GetExtraByteVlr", &Writer::GetExtraByteVlr)
+        .def("GetRootPage", &Writer::GetRootPage)
+        .def("Close", &FileWriter::Close)
+        .def("AddNode", py::overload_cast<Page &, const VoxelKey &, las::Points &>(&Writer::AddNode), py::arg("page"),
+             py::arg("key"), py::arg("points"))
+        .def("AddNodeCompressed", &Writer::AddNodeCompressed)
+        .def("AddNode", py::overload_cast<Page &, const VoxelKey &, std::vector<char> const &>(&Writer::AddNode),
+             py::arg("page"), py::arg("key"), py::arg("uncompressed_data"))
+        .def("AddSubPage", &Writer::AddSubPage, py::arg("parent_page"), py::arg("key"));
+
+    m.def("CompressBytes",
+          py::overload_cast<std::vector<char> &, const int8_t &, const uint16_t &>(&laz::Compressor::CompressBytes),
+          py::arg("in"), py::arg("point_format_id"), py::arg("num_extra_bytes"));
+    m.def("CompressBytes",
+          py::overload_cast<std::vector<char> &, const las::LasHeader &>(&laz::Compressor::CompressBytes));
+
+    m.def("DecompressBytes",
+          py::overload_cast<const std::vector<char> &, const las::LasHeader &, const int &>(
+              &laz::Decompressor::DecompressBytes),
+          py::arg("compressed_data"), py::arg("header"), py::arg("point_count"));
+
+    py::class_<las::LasHeader>(m, "LasHeader")
+        .def_readwrite("file_source_id", &las::LasHeader::file_source_id)
+        .def_readwrite("global_encoding", &las::LasHeader::global_encoding)
+        .def_property("guid", py::overload_cast<>(&las::LasHeader::GUID, py::const_),
+                      py::overload_cast<const std::string &>(&las::LasHeader::GUID))
+        .def_readwrite("version_minor", &las::LasHeader::version_minor)
+        .def_readwrite("version_major", &las::LasHeader::version_major)
+        .def_property("system_identifier", py::overload_cast<>(&las::LasHeader::SystemIdentifier, py::const_),
+                      py::overload_cast<const std::string &>(&las::LasHeader::SystemIdentifier))
+        .def_property("generating_software", py::overload_cast<>(&las::LasHeader::GeneratingSoftware, py::const_),
+                      py::overload_cast<const std::string &>(&las::LasHeader::GeneratingSoftware))
+        .def_readwrite("creation_day", &las::LasHeader::creation_day)
+        .def_readwrite("creation_year", &las::LasHeader::creation_year)
+        .def_readwrite("header_size", &las::LasHeader::header_size)
+        .def_readwrite("point_offset", &las::LasHeader::point_offset)
+        .def_readwrite("vlr_count", &las::LasHeader::vlr_count)
+        .def_readwrite("point_format_id", &las::LasHeader::point_format_id)
+        .def_readwrite("point_record_length", &las::LasHeader::point_record_length)
+        .def_readwrite("point_count", &las::LasHeader::point_count)
+        .def_readwrite("points_by_return", &las::LasHeader::points_by_return)
+        .def_readwrite("scale", &las::LasHeader::scale)
+        .def_readwrite("offset", &las::LasHeader::offset)
+        .def_readwrite("max", &las::LasHeader::max)
+        .def_readwrite("min", &las::LasHeader::min)
+        .def_readwrite("wave_offset", &las::LasHeader::wave_offset)
+        .def_readwrite("evlr_offset", &las::LasHeader::evlr_offset)
+        .def_readwrite("evlr_count", &las::LasHeader::evlr_count)
+        .def_readwrite("points_by_return_14", &las::LasHeader::points_by_return_14);
+
+    py::class_<Writer::LasConfig>(m, "LasConfig")
+        .def(py::init<const int8_t &, const Vector3 &, const Vector3 &>(), py::arg("point_format_id"),
+             py::arg("scale") = Vector3::DefaultScale(), py::arg("offset") = Vector3::DefaultOffset())
+        .def(py::init<const las::LasHeader &, const las::EbVlr &>())
+        .def_readwrite("file_source_id", &Writer::LasConfig::file_source_id)
+        .def_readwrite("global_encoding", &Writer::LasConfig::global_encoding)
+        .def_readwrite("creation_day", &Writer::LasConfig::creation_day)
+        .def_readwrite("creation_year", &Writer::LasConfig::creation_year)
+        .def_readwrite("point_format_id", &Writer::LasConfig::point_format_id)
+        .def_readwrite("scale", &Writer::LasConfig::scale)
+        .def_readwrite("offset", &Writer::LasConfig::offset)
+        .def_readwrite("max", &Writer::LasConfig::max)
+        .def_readwrite("min", &Writer::LasConfig::min)
+        .def_readwrite("points_by_return_14", &Writer::LasConfig::points_by_return_14)
+        .def_property("guid", py::overload_cast<>(&Writer::LasConfig::GUID, py::const_),
+                      py::overload_cast<const std::string &>(&Writer::LasConfig::GUID))
+        .def_property("system_identifier", py::overload_cast<>(&Writer::LasConfig::SystemIdentifier, py::const_),
+                      py::overload_cast<const std::string &>(&Writer::LasConfig::SystemIdentifier))
+        .def_property("generating_software", py::overload_cast<>(&Writer::LasConfig::GeneratingSoftware, py::const_),
+                      py::overload_cast<const std::string &>(&Writer::LasConfig::GeneratingSoftware))
+        .def("NumExtraBytes", &Writer::LasConfig::NumExtraBytes);
+
+    py::class_<las::CopcVlr>(m, "CopcVlr")
+        .def_readwrite("span", &las::CopcVlr::span)
+        .def_readwrite("root_hier_offset", &las::CopcVlr::root_hier_offset)
+        .def_readwrite("root_hier_size", &las::CopcVlr::root_hier_size)
+        .def_readwrite("laz_vlr_offset", &las::CopcVlr::laz_vlr_offset)
+        .def_readwrite("laz_vlr_size", &las::CopcVlr::laz_vlr_size)
+        .def_readwrite("wkt_vlr_offset", &las::CopcVlr::wkt_vlr_offset)
+        .def_readwrite("wkt_vlr_size", &las::CopcVlr::wkt_vlr_size)
+        .def_readwrite("eb_vlr_offset", &las::CopcVlr::eb_vlr_offset)
+        .def_readwrite("eb_vlr_size", &las::CopcVlr::eb_vlr_size);
+
+    py::class_<las::EbVlr>(m, "EbVlr").def(py::init<int>());
+}
diff --git a/setup.py b/setup.py
new file mode 100644
index 00000000..b17edb96
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+import os
+import re
+import subprocess
+import sys
+
+from setuptools import setup, Extension
+from setuptools.command.build_ext import build_ext
+
+# Convert distutils Windows platform specifiers to CMake -A arguments
+PLAT_TO_CMAKE = {
+    "win32": "Win32",
+    "win-amd64": "x64",
+    "win-arm32": "ARM",
+    "win-arm64": "ARM64",
+}
+
+
+# A CMakeExtension needs a sourcedir instead of a file list.
+# The name must be the _single_ output extension from the CMake build.
+# If you need multiple extensions, see scikit-build.
+class CMakeExtension(Extension):
+    def __init__(self, name, sourcedir=""):
+        Extension.__init__(self, name, sources=[])
+        self.sourcedir = os.path.abspath(sourcedir)
+
+
+class CMakeBuild(build_ext):
+    def build_extension(self, ext):
+        extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name)))
+
+        # required for auto-detection & inclusion of auxiliary "native" libs
+        if not extdir.endswith(os.path.sep):
+            extdir += os.path.sep
+
+        debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
+        cfg = "Debug" if debug else "Release"
+
+        # CMake lets you override the generator - we need to check this.
+        # Can be set with Conda-Build, for example.
+        cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
+
+        # Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON
+        # EXAMPLE_VERSION_INFO shows you how to pass a value into the C++ code
+        # from Python.
+        cmake_args = [
+            "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}".format(extdir),
+            "-DPYTHON_EXECUTABLE={}".format(sys.executable),
+            "-DCMAKE_BUILD_TYPE={}".format(cfg),  # not used on MSVC, but no harm
+        ]
+        build_args = []
+        # Adding CMake arguments set as environment variable
+        # (needed e.g. to build for ARM OSx on conda-forge)
+        if "CMAKE_ARGS" in os.environ:
+            cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item]
+
+        # In this example, we pass in the version to C++. You might not need to.
+        cmake_args += [
+            "-DEXAMPLE_VERSION_INFO={}".format(self.distribution.get_version())
+        ]
+
+        if self.compiler.compiler_type != "msvc":
+            # Using Ninja-build since it a) is available as a wheel and b)
+            # multithreads automatically. MSVC would require all variables be
+            # exported for Ninja to pick it up, which is a little tricky to do.
+            # Users can override the generator with CMAKE_GENERATOR in CMake
+            # 3.15+.
+            if not cmake_generator:
+                try:
+                    import ninja  # noqa: F401
+
+                    cmake_args += ["-GNinja"]
+                except ImportError:
+                    pass
+
+        else:
+
+            # Single config generators are handled "normally"
+            single_config = any(x in cmake_generator for x in {"NMake", "Ninja"})
+
+            # CMake allows an arch-in-generator style for backward compatibility
+            contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
+
+            # Specify the arch if using MSVC generator, but only if it doesn't
+            # contain a backward-compatibility arch spec already in the
+            # generator name.
+            if not single_config and not contains_arch:
+                cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
+
+            # Multi-config generators have a different way to specify configs
+            if not single_config:
+                cmake_args += [
+                    "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(cfg.upper(), extdir)
+                ]
+                build_args += ["--config", cfg]
+
+        if sys.platform.startswith("darwin"):
+            # Cross-compile support for macOS - respect ARCHFLAGS if set
+            archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", ""))
+            if archs:
+                cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))]
+
+        # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level
+        # across all generators.
+        if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
+            # self.parallel is a Python 3 only way to set parallel jobs by hand
+            # using -j in the build_ext call, not supported by pip or PyPA-build.
+            if hasattr(self, "parallel") and self.parallel:
+                # CMake 3.12+ only.
+                build_args += ["-j{}".format(self.parallel)]
+
+        if not os.path.exists(self.build_temp):
+            os.makedirs(self.build_temp)
+
+        subprocess.check_call(
+            ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp
+        )
+        subprocess.check_call(
+            ["cmake", "--build", "."] + build_args, cwd=self.build_temp
+        )
+
+
+# The information here can also be placed in setup.cfg - better separation of
+# logic and declaration, and simpler if you include description/version in a file.
+setup(
+    name="copclib",
+    version="1.2",
+    author="Rock Robotic Inc.",
+    author_email="support@rockrobotic.com",
+    description="A python interface to Cloud-Optimized Point Clouds (COPC)",
+    long_description="",
+    ext_modules=[CMakeExtension("copclib")],
+    cmdclass={"build_ext": CMakeBuild},
+    zip_safe=False,
+)
diff --git a/src/io/reader.cpp b/src/io/reader.cpp
index 24d3063e..5af3c67f 100644
--- a/src/io/reader.cpp
+++ b/src/io/reader.cpp
@@ -15,7 +15,7 @@ void Reader::InitReader()
 
     this->reader_ = std::make_unique<lazperf::reader::generic_file>(*this->in_stream_);
 
-    auto header = this->reader_->header();
+    auto header = las::LasHeader::FromLazPerf(this->reader_->header());
 
     std::map<uint64_t, las::VlrHeader> vlrs = ReadVlrs();
     auto copc_data = ReadCopcData();
@@ -73,7 +73,7 @@ std::vector<Entry> Reader::ReadPage(std::shared_ptr<Internal::PageInternal> page
     in_stream_->seekg(page->offset);
 
     // Iterate through each Entry in the page
-    int num_entries = int(page->size / Entry::ENTRY_SIZE);
+    int num_entries = int(page->byte_size / Entry::ENTRY_SIZE);
     for (int i = 0; i < num_entries; i++)
     {
         Entry e = Entry::Unpack(*in_stream_);
@@ -90,20 +90,17 @@ std::vector<Entry> Reader::ReadPage(std::shared_ptr<Internal::PageInternal> page
 las::Points Reader::GetPoints(Node const &node)
 {
     std::vector<char> point_data = GetPointData(node);
-    return las::Points::Unpack(point_data, file_->GetLasHeader().point_format_id,
-                               file_->GetLasHeader().point_record_length);
+    return las::Points::Unpack(point_data, file_->GetLasHeader());
 }
 
 las::Points Reader::GetPoints(VoxelKey const &key)
 {
     std::vector<char> point_data = GetPointData(key);
 
-    auto point_format_id = file_->GetLasHeader().point_format_id;
-    auto point_record_length = file_->GetLasHeader().point_record_length;
-    auto eb_count = las::Point::ComputeNumExtraBytes(point_format_id, point_record_length);
     if (point_data.empty())
-        return las::Points(point_format_id, eb_count);
-    return las::Points::Unpack(point_data, point_format_id, point_record_length);
+        return las::Points(file_->GetLasHeader());
+
+    return las::Points::Unpack(point_data, file_->GetLasHeader());
 }
 
 std::vector<char> Reader::GetPointData(Node const &node)
@@ -139,8 +136,8 @@ std::vector<char> Reader::GetPointDataCompressed(Node const &node)
     in_stream_->seekg(node.offset);
 
     std::vector<char> out;
-    out.resize(node.size);
-    in_stream_->read(&out[0], node.size);
+    out.resize(node.byte_size);
+    in_stream_->read(&out[0], node.byte_size);
     return out;
 }
 
@@ -157,7 +154,7 @@ std::vector<char> Reader::GetPointDataCompressed(VoxelKey const &key)
     return GetPointDataCompressed(node);
 }
 
-std::vector<Node> Reader::GetAllChildren(VoxelKey key)
+std::vector<Node> Reader::GetAllChildren(const VoxelKey &key)
 {
     std::vector<Node> out;
     if (!key.IsValid())
diff --git a/src/io/writer_internal.cpp b/src/io/writer_internal.cpp
index e6d85ed6..ae8d0de0 100644
--- a/src/io/writer_internal.cpp
+++ b/src/io/writer_internal.cpp
@@ -51,30 +51,35 @@ void WriterInternal::Close()
 // Writes the LAS header and VLRs
 void WriterInternal::WriteHeader(las::LasHeader &head14)
 {
-    laz_vlr lazVlr(head14.pointFormat(), head14.ebCount(), VARIABLE_CHUNK_SIZE);
+
+    // Convert back to lazperf header for writing
+    auto laz_header = head14.ToLazPerf();
+
+    laz_vlr lazVlr(laz_header.point_format_id, laz_header.ebCount(), VARIABLE_CHUNK_SIZE);
 
     // point_format_id and point_record_length  are set on open().
-    head14.header_size = head14.sizeFromVersion();
-    head14.vlr_count = 2; // copc + laz
+    laz_header.header_size = laz_header.sizeFromVersion();
+    laz_header.vlr_count = 2; // copc + laz
 
-    head14.point_format_id |= (1 << 7);
-    head14.point_offset = OFFSET_TO_POINT_DATA;
-    head14.point_count_14 = point_count_14_;
+    laz_header.point_format_id |= (1 << 7);
+    laz_header.point_offset = OFFSET_TO_POINT_DATA;
+    laz_header.point_count_14 = point_count_14_;
 
-    if (head14.ebCount())
+    if (laz_header.ebCount())
     {
-        head14.vlr_count++;
+        laz_header.vlr_count++;
     }
 
-    if (head14.point_count_14 > (std::numeric_limits<uint32_t>::max)())
-        head14.point_count = 0;
+    if (laz_header.point_count_14 > (std::numeric_limits<uint32_t>::max)())
+        laz_header.point_count = 0;
     else
-        head14.point_count = (uint32_t)head14.point_count_14;
+        laz_header.point_count = (uint32_t)laz_header.point_count_14;
     // Set the WKT bit.
-    head14.global_encoding |= (1 << 4);
+    laz_header.global_encoding |= (1 << 4);
 
     out_stream_.seekp(0);
-    head14.write(out_stream_);
+
+    laz_header.write(out_stream_);
 
     // Write the VLR.
     copc_data_.span = file_->GetCopc().span;
@@ -84,7 +89,7 @@ void WriterInternal::WriteHeader(las::LasHeader &head14)
     lazVlr.header().write(out_stream_);
     lazVlr.write(out_stream_);
 
-    if (head14.ebCount())
+    if (head14.NumExtraBytes())
     {
         auto ebVlr = this->file_->GetExtraBytes();
         ebVlr.header().write(out_stream_);
@@ -171,7 +176,7 @@ Entry WriterInternal::WriteNode(std::vector<char> in, int32_t point_count, bool
     auto size = endpos - startpos;
     if (size > (std::numeric_limits<int32_t>::max)())
         throw std::runtime_error("Chunk is too large!");
-    entry.size = static_cast<int32_t>(size);
+    entry.byte_size = static_cast<int32_t>(size);
     if (point_count > (std::numeric_limits<int32_t>::max)())
         throw std::runtime_error("Chunk has too many points!");
     entry.point_count = point_count;
@@ -193,7 +198,7 @@ void WriterInternal::WritePage(const std::shared_ptr<PageInternal> &page)
     page->offset = offset;
     if (page_size > (std::numeric_limits<int32_t>::max)())
         throw std::runtime_error("Page is too large!");
-    page->size = static_cast<int32_t>(page_size);
+    page->byte_size = static_cast<int32_t>(page_size);
 
     // Set the copc header info if needed
     if (page->key == VoxelKey::BaseKey())
diff --git a/src/io/writer_public.cpp b/src/io/writer_public.cpp
index 5e3a0238..5a645ae1 100644
--- a/src/io/writer_public.cpp
+++ b/src/io/writer_public.cpp
@@ -7,7 +7,7 @@
 namespace copc
 {
 
-void Writer::InitWriter(std::ostream &out_stream, LasConfig const &config, int span, const std::string &wkt)
+void Writer::InitWriter(std::ostream &out_stream, LasConfig const &config, const int &span, const std::string &wkt)
 {
     auto header = HeaderFromConfig(config);
     this->file_ = std::make_shared<CopcFile>(header, span, wkt, config.extra_bytes);
@@ -57,7 +57,7 @@ Node Writer::DoAddNode(Page &page, VoxelKey key, std::vector<char> in, uint64_t
     return *node;
 }
 
-Node Writer::AddNode(Page &page, VoxelKey key, las::Points &points)
+Node Writer::AddNode(Page &page, const VoxelKey &key, las::Points &points)
 {
     auto header = file_->GetLasHeader();
     if (points.PointFormatID() != header.point_format_id || points.PointRecordLength() != header.point_record_length)
@@ -67,7 +67,7 @@ Node Writer::AddNode(Page &page, VoxelKey key, las::Points &points)
     return AddNode(page, key, uncompressed);
 }
 
-Node Writer::AddNode(Page &page, VoxelKey key, std::vector<char> const &uncompressed)
+Node Writer::AddNode(Page &page, const VoxelKey &key, std::vector<char> const &uncompressed)
 {
     int point_size = file_->GetLasHeader().point_record_length;
     if (uncompressed.size() < point_size || uncompressed.size() % point_size != 0)
@@ -76,7 +76,8 @@ Node Writer::AddNode(Page &page, VoxelKey key, std::vector<char> const &uncompre
     return DoAddNode(page, key, uncompressed, 0, false);
 }
 
-Node Writer::AddNodeCompressed(Page &page, VoxelKey key, std::vector<char> const &compressed, uint64_t point_count)
+Node Writer::AddNodeCompressed(Page &page, const VoxelKey &key, std::vector<char> const &compressed,
+                               uint64_t point_count)
 {
     if (point_count == 0)
         throw std::runtime_error("Point count must be >0!");
@@ -105,64 +106,44 @@ las::LasHeader Writer::HeaderFromConfig(LasConfig const &config)
     las::LasHeader h;
     h.file_source_id = config.file_source_id;
     h.global_encoding = config.global_encoding;
-    h.creation.day = config.creation.day;
-    h.creation.year = config.creation.year;
+    h.creation_day = config.creation_day;
+    h.creation_year = config.creation_year;
     h.point_format_id = config.point_format_id;
     h.point_record_length =
-        las::Point::BaseByteSize(config.point_format_id) + NumBytesFromExtraBytes(config.extra_bytes.items);
+        las::PointBaseByteSize(config.point_format_id) + NumBytesFromExtraBytes(config.extra_bytes.items);
 
-    std::strcpy(h.guid, config.guid);
-    std::strcpy(h.system_identifier, config.system_identifier);
-    std::strcpy(h.generating_software, config.generating_software);
+    h.GUID(config.GUID());
+    h.SystemIdentifier(config.SystemIdentifier());
+    h.GeneratingSoftware(config.GeneratingSoftware());
 
-    h.offset.x = config.offset.x;
-    h.offset.y = config.offset.y;
-    h.offset.z = config.offset.z;
-
-    h.scale.x = config.scale.x;
-    h.scale.y = config.scale.y;
-    h.scale.z = config.scale.z;
-
-    h.maxx = config.max.x;
-    h.minx = config.min.x;
-    h.maxy = config.max.y;
-    h.miny = config.min.y;
-    h.maxz = config.max.z;
-    h.minz = config.min.z;
-
-    memcpy(h.points_by_return_14, config.points_by_return_14, sizeof(config.points_by_return_14));
+    h.offset = config.offset;
+    h.scale = config.scale;
+    h.max = config.max;
+    h.min = config.min;
 
+    h.points_by_return_14 = config.points_by_return_14;
     return h;
 }
 
-copc::Writer::LasConfig::LasConfig(las::LasHeader config, const las::EbVlr &extra_bytes_)
+copc::Writer::LasConfig::LasConfig(const las::LasHeader &config, const las::EbVlr &extra_bytes_)
 {
     file_source_id = config.file_source_id;
     global_encoding = config.global_encoding;
-    creation.day = config.creation.day;
-    creation.year = config.creation.year;
+    creation_day = config.creation_day;
+    creation_year = config.creation_year;
     point_format_id = config.point_format_id;
 
-    std::strcpy(guid, config.guid);
-    std::strcpy(system_identifier, config.system_identifier);
-    std::strcpy(generating_software, config.generating_software);
-
-    offset.x = config.offset.x;
-    offset.y = config.offset.y;
-    offset.z = config.offset.z;
+    guid_ = config.GUID();
+    system_identifier_ = config.SystemIdentifier();
+    generating_software_ = config.GeneratingSoftware();
 
-    scale.x = config.scale.x;
-    scale.y = config.scale.y;
-    scale.z = config.scale.z;
+    offset = config.offset;
+    scale = config.scale;
 
-    max.x = config.maxx;
-    min.x = config.minx;
-    max.y = config.maxy;
-    min.y = config.miny;
-    max.z = config.maxz;
-    min.z = config.minz;
+    max = config.max;
+    min = config.min;
 
-    memcpy(points_by_return_14, config.points_by_return_14, sizeof(config.points_by_return_14));
+    points_by_return_14 = config.points_by_return_14;
     extra_bytes = extra_bytes_;
 }
 } // namespace copc
diff --git a/src/las/header.cpp b/src/las/header.cpp
new file mode 100644
index 00000000..e8a5df82
--- /dev/null
+++ b/src/las/header.cpp
@@ -0,0 +1,94 @@
+#include <copc-lib/las/header.hpp>
+
+#include <cstring>
+
+#include <copc-lib/las/utils.hpp>
+#include <lazperf/header.hpp>
+
+namespace copc::las
+{
+
+uint16_t LasHeader::NumExtraBytes() const { return ComputeNumExtraBytes(point_format_id, point_record_length); }
+
+LasHeader LasHeader::FromLazPerf(const lazperf::header14 &header)
+{
+    LasHeader h;
+    h.file_source_id = header.file_source_id;
+    h.global_encoding = header.global_encoding;
+    h.guid_ = header.guid;
+    h.version_major = header.version.major;
+    h.version_minor = header.version.minor;
+    h.system_identifier_ = header.system_identifier;
+    h.generating_software_ = header.generating_software;
+    h.creation_day = header.creation.day;
+    h.creation_year = header.creation.year;
+    h.header_size = header.header_size;
+    h.point_offset = header.point_offset;
+    h.vlr_count = header.vlr_count;
+    h.point_format_id = static_cast<int8_t>(header.point_format_id);
+    h.point_record_length = header.point_record_length;
+    h.point_count = header.point_count;
+    std::copy(std::begin(header.points_by_return), std::end(header.points_by_return), std::begin(h.points_by_return));
+    h.scale.x = header.scale.x;
+    h.scale.y = header.scale.y;
+    h.scale.z = header.scale.z;
+    h.offset.x = header.offset.x;
+    h.offset.y = header.offset.y;
+    h.offset.z = header.offset.z;
+    h.max.x = header.maxx;
+    h.min.x = header.minx;
+    h.max.y = header.maxy;
+    h.min.y = header.miny;
+    h.max.z = header.maxz;
+    h.min.z = header.minz;
+    h.wave_offset = header.wave_offset;
+    h.evlr_offset = header.evlr_offset;
+    h.evlr_count = header.evlr_count;
+    std::copy(std::begin(header.points_by_return_14), std::end(header.points_by_return_14),
+              std::begin(h.points_by_return_14));
+    return h;
+}
+lazperf::header14 LasHeader::ToLazPerf() const
+{
+    lazperf::header14 h;
+    h.file_source_id = file_source_id;
+    h.global_encoding = global_encoding;
+    std::strncpy(h.guid, guid_.c_str(), 16);
+    h.version.major = version_major;
+    h.version.minor = version_minor;
+    std::strncpy(h.system_identifier, system_identifier_.c_str(), 32);
+    std::strncpy(h.generating_software, generating_software_.c_str(), 32);
+    h.creation.day = creation_day;
+    h.creation.year = creation_year;
+    h.header_size = header_size;
+    h.point_offset = point_offset;
+    h.vlr_count = vlr_count;
+    h.point_format_id = point_format_id;
+    h.point_record_length = point_record_length;
+    h.point_count = point_count;
+    std::copy(std::begin(points_by_return), std::end(points_by_return), std::begin(h.points_by_return));
+
+    h.offset.x = offset.x;
+    h.offset.y = offset.y;
+    h.offset.z = offset.z;
+
+    h.scale.x = scale.x;
+    h.scale.y = scale.y;
+    h.scale.z = scale.z;
+
+    h.maxx = max.x;
+    h.minx = min.x;
+    h.maxy = max.y;
+    h.miny = min.y;
+    h.maxz = max.z;
+    h.minz = min.z;
+
+    h.wave_offset = wave_offset;
+    h.evlr_offset = evlr_offset;
+    h.evlr_count = evlr_count;
+
+    std::copy(std::begin(points_by_return_14), std::end(points_by_return_14), std::begin(h.points_by_return_14));
+    return h;
+}
+
+} // namespace copc::las
diff --git a/src/las/point.cpp b/src/las/point.cpp
index c3d654e9..037a5139 100644
--- a/src/las/point.cpp
+++ b/src/las/point.cpp
@@ -3,7 +3,9 @@
 
 namespace copc::las
 {
-Point::Point(const int8_t &point_format_id, const uint16_t &num_extra_bytes)
+Point::Point(const int8_t &point_format_id, const Vector3 &scale, const Vector3 &offset,
+             const uint16_t &num_extra_bytes)
+    : scale_(scale), offset_(offset), point_format_id_(point_format_id)
 {
     if (point_format_id > 10)
         throw std::runtime_error("Point format must be 0-10");
@@ -11,8 +13,7 @@ Point::Point(const int8_t &point_format_id, const uint16_t &num_extra_bytes)
     if (point_format_id > 5)
         extended_point_type_ = true;
 
-    point_format_id_ = point_format_id;
-    point_record_length_ = BaseByteSize(point_format_id) + num_extra_bytes;
+    point_record_length_ = PointBaseByteSize(point_format_id) + num_extra_bytes;
 
     has_gps_time_ = FormatHasGPSTime(point_format_id);
     has_rgb_ = FormatHasRGB(point_format_id);
@@ -21,7 +22,10 @@ Point::Point(const int8_t &point_format_id, const uint16_t &num_extra_bytes)
     extra_bytes_.resize(num_extra_bytes, 0);
 }
 
-Point::Point(const Point &other) : Point(other.point_format_id_, other.NumExtraBytes())
+Point::Point(const LasHeader &header)
+    : Point(header.point_format_id, header.scale, header.offset, header.NumExtraBytes()){};
+
+Point::Point(const Point &other) : Point(other.point_format_id_, other.scale_, other.offset_, other.NumExtraBytes())
 {
     x_ = other.x_;
     y_ = other.y_;
@@ -61,49 +65,82 @@ Point::Point(const Point &other) : Point(other.point_format_id_, other.NumExtraB
     extra_bytes_ = other.extra_bytes_;
 }
 
-Point Point::Unpack(std::istream &in_stream, const int8_t &point_format_id, const uint16_t &num_extra_bytes)
+uint16_t Point::NumExtraBytes() const { return point_record_length_ - PointBaseByteSize(point_format_id_); }
+
+bool Point::operator==(const Point &other) const
+{
+    if (point_format_id_ != other.point_format_id_ || point_record_length_ != other.point_record_length_)
+        return false;
+    if (x_ != other.UnscaledX() || y_ != other.UnscaledY() || z_ != other.UnscaledZ() ||
+        intensity_ != other.Intensity())
+        return false;
+    if (ReturnNumber() != other.ReturnNumber() || NumberOfReturns() != other.NumberOfReturns())
+        return false;
+    if (ScanDirectionFlag() != other.ScanDirectionFlag() || EdgeOfFlightLineFlag() != other.EdgeOfFlightLineFlag())
+        return false;
+    if (Classification() != other.Classification())
+        return false;
+    if (Synthetic() != other.Synthetic() || KeyPoint() != other.KeyPoint() || Withheld() != other.Withheld())
+        return false;
+    if (ScanAngle() != other.ScanAngle() || user_data_ != other.UserData() || point_source_id_ != other.PointSourceID())
+        return false;
+    if (ExtraBytes() != other.ExtraBytes())
+        return false;
+    if (extended_point_type_ && (Overlap() != other.Overlap() || ScannerChannel() != other.ScannerChannel()))
+        return false;
+    if (has_gps_time_ && (gps_time_ != other.GPSTime()))
+        return false;
+    if (has_rgb_ && (rgb_[0] != other.Red() || rgb_[1] != other.Green() || rgb_[2] != other.Blue()))
+        return false;
+    if (has_nir_ && (nir_ != other.NIR()))
+        return false;
+    return true;
+}
+
+Point Point::Unpack(std::istream &in_stream, const int8_t &point_format_id, const Vector3 &scale, const Vector3 &offset,
+                    const uint16_t &num_extra_bytes)
 {
-    Point p(point_format_id, num_extra_bytes);
+    Point p(point_format_id, scale, offset, num_extra_bytes);
 
-    p.x_ = internal::unpack<int32_t>(in_stream);
-    p.y_ = internal::unpack<int32_t>(in_stream);
-    p.z_ = internal::unpack<int32_t>(in_stream);
-    p.intensity_ = internal::unpack<uint16_t>(in_stream);
+    p.x_ = unpack<int32_t>(in_stream);
+    p.y_ = unpack<int32_t>(in_stream);
+    p.z_ = unpack<int32_t>(in_stream);
+    p.intensity_ = unpack<uint16_t>(in_stream);
     if (p.extended_point_type_)
     {
-        p.extended_returns_ = internal::unpack<uint8_t>(in_stream);
-        p.extended_flags_ = internal::unpack<uint8_t>(in_stream);
-        p.extended_classification_ = internal::unpack<uint8_t>(in_stream);
-        p.user_data_ = internal::unpack<uint8_t>(in_stream);
-        p.extended_scan_angle_ = internal::unpack<int16_t>(in_stream);
+        p.extended_returns_ = unpack<uint8_t>(in_stream);
+        p.extended_flags_ = unpack<uint8_t>(in_stream);
+        p.extended_classification_ = unpack<uint8_t>(in_stream);
+        p.user_data_ = unpack<uint8_t>(in_stream);
+        p.extended_scan_angle_ = unpack<int16_t>(in_stream);
     }
     else
     {
-        p.returns_flags_eof_ = internal::unpack<uint8_t>(in_stream);
-        p.classification_ = internal::unpack<uint8_t>(in_stream);
-        p.scan_angle_rank_ = internal::unpack<int8_t>(in_stream);
-        p.user_data_ = internal::unpack<uint8_t>(in_stream);
+        p.returns_flags_eof_ = unpack<uint8_t>(in_stream);
+        p.classification_ = unpack<uint8_t>(in_stream);
+        p.scan_angle_rank_ = unpack<int8_t>(in_stream);
+        p.user_data_ = unpack<uint8_t>(in_stream);
     }
-    p.point_source_id_ = internal::unpack<uint16_t>(in_stream);
+    p.point_source_id_ = unpack<uint16_t>(in_stream);
 
     if (p.has_gps_time_)
     {
-        p.gps_time_ = internal::unpack<double>(in_stream);
+        p.gps_time_ = unpack<double>(in_stream);
     }
     if (p.has_rgb_)
     {
-        p.rgb_[0] = internal::unpack<uint16_t>(in_stream);
-        p.rgb_[1] = internal::unpack<uint16_t>(in_stream);
-        p.rgb_[2] = internal::unpack<uint16_t>(in_stream);
+        p.rgb_[0] = unpack<uint16_t>(in_stream);
+        p.rgb_[1] = unpack<uint16_t>(in_stream);
+        p.rgb_[2] = unpack<uint16_t>(in_stream);
     }
     if (p.has_nir_)
     {
-        p.nir_ = internal::unpack<uint16_t>(in_stream);
+        p.nir_ = unpack<uint16_t>(in_stream);
     }
 
     for (uint32_t i = 0; i < num_extra_bytes; i++)
     {
-        p.extra_bytes_[i] = internal::unpack<uint8_t>(in_stream);
+        p.extra_bytes_[i] = unpack<uint8_t>(in_stream);
     }
     return p;
 }
@@ -111,73 +148,40 @@ Point Point::Unpack(std::istream &in_stream, const int8_t &point_format_id, cons
 void Point::Pack(std::ostream &out_stream) const
 {
     // Point
-    internal::pack(x_, out_stream);
-    internal::pack(y_, out_stream);
-    internal::pack(z_, out_stream);
-    internal::pack(intensity_, out_stream);
+    pack(x_, out_stream);
+    pack(y_, out_stream);
+    pack(z_, out_stream);
+    pack(intensity_, out_stream);
     if (extended_point_type_)
     {
-        internal::pack(extended_returns_, out_stream);
-        internal::pack(extended_flags_, out_stream);
-        internal::pack(extended_classification_, out_stream);
-        internal::pack(user_data_, out_stream);
-        internal::pack(extended_scan_angle_, out_stream);
+        pack(extended_returns_, out_stream);
+        pack(extended_flags_, out_stream);
+        pack(extended_classification_, out_stream);
+        pack(user_data_, out_stream);
+        pack(extended_scan_angle_, out_stream);
     }
     else
     {
-        internal::pack(returns_flags_eof_, out_stream);
-        internal::pack(classification_, out_stream);
-        internal::pack(scan_angle_rank_, out_stream);
-        internal::pack(user_data_, out_stream);
+        pack(returns_flags_eof_, out_stream);
+        pack(classification_, out_stream);
+        pack(scan_angle_rank_, out_stream);
+        pack(user_data_, out_stream);
     }
-    internal::pack(point_source_id_, out_stream);
+    pack(point_source_id_, out_stream);
 
     if (has_gps_time_)
-        internal::pack(gps_time_, out_stream);
+        pack(gps_time_, out_stream);
     if (has_rgb_)
     {
-        internal::pack(rgb_[0], out_stream);
-        internal::pack(rgb_[1], out_stream);
-        internal::pack(rgb_[2], out_stream);
+        pack(rgb_[0], out_stream);
+        pack(rgb_[1], out_stream);
+        pack(rgb_[2], out_stream);
     }
     if (has_nir_)
-        internal::pack(nir_, out_stream);
+        pack(nir_, out_stream);
 
     for (auto eb : extra_bytes_)
-        internal::pack(eb, out_stream);
-}
-
-uint8_t Point::BaseByteSize(const int8_t &point_format_id)
-{
-    switch (point_format_id)
-    {
-    case 0:
-        return 20;
-    case 1:
-        return 28;
-    case 2:
-        return 26;
-    case 3:
-        return 34;
-    case 4:
-        //        return 28;
-    case 5:
-        //        return 34;
-        throw std::runtime_error("Point formats with Wave Packets not yet supported");
-    case 6:
-        return 30;
-    case 7:
-        return 36;
-    case 8:
-        return 38;
-    case 9:
-        //        return 30;
-    case 10:
-        //        return 38;
-        throw std::runtime_error("Point formats with Wave Packets not yet supported");
-    default:
-        throw std::runtime_error("Point format must be 0-10");
-    }
+        pack(eb, out_stream);
 }
 
 void Point::ToPointFormat(const int8_t &point_format_id)
@@ -215,7 +219,7 @@ void Point::ToPointFormat(const int8_t &point_format_id)
     has_gps_time_ = FormatHasGPSTime(point_format_id);
     has_rgb_ = FormatHasRGB(point_format_id);
     has_nir_ = FormatHasNIR(point_format_id);
-    point_record_length_ = BaseByteSize(point_format_id) + extra_bytes_.size();
+    point_record_length_ = PointBaseByteSize(point_format_id) + extra_bytes_.size();
     point_format_id_ = point_format_id;
 }
 
diff --git a/src/las/points.cpp b/src/las/points.cpp
index bf16c2d3..c518d412 100644
--- a/src/las/points.cpp
+++ b/src/las/points.cpp
@@ -2,17 +2,24 @@
 #include <sstream>
 #include <string>
 
+#include <copc-lib/las/utils.hpp>
+
 namespace copc::las
 {
 
-Points::Points(const int8_t &point_format_id, const uint16_t &num_extra_bytes) : point_format_id_(point_format_id)
+Points::Points(const int8_t &point_format_id, const Vector3 &scale, const Vector3 &offset,
+               const uint16_t &num_extra_bytes)
+    : point_format_id_(point_format_id), scale_(scale), offset_(offset)
 {
     if (point_format_id < 0 || point_format_id > 10)
         throw std::runtime_error("Point format must be 0-10.");
 
-    point_record_length_ = Point::ComputePointBytes(point_format_id, num_extra_bytes);
+    point_record_length_ = ComputePointBytes(point_format_id, num_extra_bytes);
 }
 
+Points::Points(const LasHeader &header)
+    : Points(header.point_format_id, header.scale, header.offset, header.NumExtraBytes()){};
+
 Points::Points(const std::vector<Point> &points)
 {
     if (points.empty())
@@ -20,6 +27,8 @@ Points::Points(const std::vector<Point> &points)
 
     point_record_length_ = points[0].PointRecordLength();
     point_format_id_ = points[0].PointFormatID();
+    scale_ = points[0].Scale();
+    offset_ = points[0].Offset();
 
     AddPoints(points);
 }
@@ -38,13 +47,13 @@ void Points::AddPoint(const Point &point)
     if (point.PointFormatID() == point_format_id_ && point.PointRecordLength() == point_record_length_)
         points_.push_back(point);
     else
-        throw std::runtime_error("New point must be of same format and size.");
+        throw std::runtime_error("New point must be of same format and byte_size.");
 }
 
 void Points::AddPoints(Points points)
 {
     if (points.PointFormatID() != point_format_id_ || points.PointRecordLength() != point_record_length_)
-        throw std::runtime_error("New points must be of same format and size.");
+        throw std::runtime_error("New points must be of same format and byte_size.");
 
     auto point_vec = points.Get();
     points_.insert(points_.end(), point_vec.begin(), point_vec.end());
@@ -55,31 +64,37 @@ void Points::AddPoints(std::vector<las::Point> points)
     for (const auto &point : points)
     {
         if (point.PointFormatID() != point_format_id_ || point.PointRecordLength() != point_record_length_)
-            throw std::runtime_error("New points must be of same format and size.");
+            throw std::runtime_error("New points must be of same format and byte_size.");
     }
 
     points_.insert(points_.end(), points.begin(), points.end());
 }
 
-Points Points::Unpack(const std::vector<char> &point_data, int8_t point_format_id, int point_record_length)
+Points Points::Unpack(const std::vector<char> &point_data, const LasHeader &header)
+{
+    return Unpack(point_data, header.point_format_id, header.NumExtraBytes(), header.scale, header.offset);
+}
+
+Points Points::Unpack(const std::vector<char> &point_data, const int8_t &point_format_id,
+                      const uint16_t &num_extra_bytes, const Vector3 &scale, const Vector3 &offset)
 {
+    auto point_record_length = ComputePointBytes(point_format_id, num_extra_bytes);
     if (point_data.size() % point_record_length != 0)
         throw std::runtime_error("Invalid input point array!");
 
-    auto num_extra_bytes = las::Point::ComputeNumExtraBytes(point_format_id, point_record_length);
     uint64_t point_count = point_data.size() / point_record_length;
 
     // Make a stream out of the vector of char
     auto ss = std::istringstream(std::string(point_data.begin(), point_data.end()));
 
     // Go through each Point to unpack the data from the stream
-    Points points(point_format_id, num_extra_bytes);
+    Points points(point_format_id, scale, offset, num_extra_bytes);
     points.Reserve(point_count);
 
     // Unpack points
     for (int i = 0; i < point_count; i++)
     {
-        points.AddPoint(las::Point::Unpack(ss, point_format_id, num_extra_bytes));
+        points.AddPoint(las::Point::Unpack(ss, point_format_id, scale, offset, num_extra_bytes));
     }
 
     return points;
diff --git a/src/las/utils.cpp b/src/las/utils.cpp
new file mode 100644
index 00000000..982b9818
--- /dev/null
+++ b/src/las/utils.cpp
@@ -0,0 +1,128 @@
+#include <copc-lib/las/utils.hpp>
+
+namespace copc::las
+{
+
+uint8_t PointBaseByteSize(const int8_t &point_format_id)
+{
+    switch (point_format_id)
+    {
+    case 0:
+        return 20;
+    case 1:
+        return 28;
+    case 2:
+        return 26;
+    case 3:
+        return 34;
+    case 4:
+        //        return 28;
+    case 5:
+        //        return 34;
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    case 6:
+        return 30;
+    case 7:
+        return 36;
+    case 8:
+        return 38;
+    case 9:
+        //        return 30;
+    case 10:
+        //        return 38;
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    default:
+        throw std::runtime_error("Point format must be 0-10");
+    }
+}
+
+bool FormatHasGPSTime(const uint8_t &point_format_id)
+{
+    switch (point_format_id)
+    {
+    case 0:
+        return false;
+    case 1:
+        return true;
+    case 2:
+        return false;
+    case 3:
+        return true;
+    case 4:
+    case 5:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    case 6:
+    case 7:
+    case 8:
+        return true;
+    case 9:
+    case 10:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+
+    default:
+        throw std::runtime_error("Point format must be 0-10");
+    }
+}
+
+bool FormatHasRGB(const uint8_t &point_format_id)
+{
+    switch (point_format_id)
+    {
+    case 0:
+    case 1:
+        return false;
+    case 2:
+    case 3:
+        return true;
+    case 4:
+    case 5:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    case 6:
+        return false;
+    case 7:
+    case 8:
+        return true;
+    case 9:
+    case 10:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    default:
+        throw std::runtime_error("Point format must be 0-10");
+    }
+}
+
+bool FormatHasNIR(const uint8_t &point_format_id)
+{
+    switch (point_format_id)
+    {
+    case 0:
+    case 1:
+    case 2:
+    case 3:
+        return false;
+    case 4:
+    case 5:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    case 6:
+    case 7:
+        return false;
+    case 8:
+        return true;
+    case 9:
+    case 10:
+        throw std::runtime_error("Point formats with Wave Packets not yet supported");
+    default:
+        throw std::runtime_error("Point format must be 0-10");
+    }
+}
+
+uint16_t ComputeNumExtraBytes(const int8_t &point_format_id, const uint32_t &point_record_length)
+{
+    return point_record_length - PointBaseByteSize(point_format_id);
+}
+
+uint16_t ComputePointBytes(const int8_t &point_format_id, const uint16_t &num_extra_bytes)
+{
+    return PointBaseByteSize(point_format_id) + num_extra_bytes;
+}
+
+} // namespace copc::las


Test patch:
diff --git a/test/key_test.cpp b/test/key_test.cpp
index 6785381c..b542b928 100644
--- a/test/key_test.cpp
+++ b/test/key_test.cpp
@@ -5,6 +5,7 @@ using namespace copc;
 
 TEST_CASE("Voxel Key checks key validity", "[Key]")
 {
+    REQUIRE(VoxelKey().IsValid() == false);
     REQUIRE(VoxelKey(-1, -1, -1, -1).IsValid() == false);
     REQUIRE(VoxelKey(-1, 0, 0, 0).IsValid() == false);
     REQUIRE(VoxelKey(0, -1, 0, 0).IsValid() == false);
diff --git a/test/key_test.py b/test/key_test.py
new file mode 100644
index 00000000..8469486d
--- /dev/null
+++ b/test/key_test.py
@@ -0,0 +1,73 @@
+import copclib as copc
+
+
+def test_key_validity():
+    assert copc.VoxelKey().IsValid() is False
+    assert copc.VoxelKey(d=-1, x=-1, y=-1, z=-1).IsValid() is False
+    assert copc.VoxelKey(-1, 0, 0, 0).IsValid() is False
+    assert copc.VoxelKey(0, -1, 0, 0).IsValid() is False
+    assert copc.VoxelKey(0, 0, -1, 0).IsValid() is False
+    assert copc.VoxelKey(0, 0, 0, -1).IsValid() is False
+    assert copc.VoxelKey(0, 0, 0, 0).IsValid() is True
+    assert copc.VoxelKey(1, 1, 1, 1).IsValid() is True
+
+
+def test_key_operators():
+    assert copc.VoxelKey(0, 0, 0, 0) == copc.VoxelKey(0, 0, 0, 0)
+    assert copc.VoxelKey(-1, -1, -1, -1) == copc.VoxelKey(-1, -1, -1, -1)
+    assert copc.VoxelKey(0, 0, 0, 0) != copc.VoxelKey(1, 1, 1, 1)
+
+
+def test_get_parent():
+    assert copc.VoxelKey(-1, -1, -1, -1).GetParent().IsValid() is False
+
+    assert copc.VoxelKey(4, 4, 6, 12).GetParent() == copc.VoxelKey(3, 2, 3, 6)
+    assert copc.VoxelKey(4, 5, 6, 13).GetParent() == copc.VoxelKey(3, 2, 3, 6)
+    assert copc.VoxelKey(3, 2, 3, 6).GetParent() == copc.VoxelKey(2, 1, 1, 3)
+
+    # TODO[Leo]: Check if there is a way to avoid self in function definition
+    assert not copc.VoxelKey(3, 2, 3, 6).GetParent() == copc.VoxelKey.BaseKey()
+
+    assert copc.VoxelKey(1, 1, 1, 1).GetParent() == copc.VoxelKey.BaseKey()
+    assert copc.VoxelKey(1, 1, 1, -1).GetParent() == copc.VoxelKey.InvalidKey()
+
+    assert copc.VoxelKey(1, 1, 1, -1).GetParent().IsValid() is False
+    assert copc.VoxelKey(0, 0, 0, 0).GetParent().IsValid() is False
+
+
+def test_is_child():
+    assert (
+        copc.VoxelKey(-1, -1, -1, -1).ChildOf(parent_key=copc.VoxelKey.BaseKey())
+        is False
+    )
+    assert copc.VoxelKey.BaseKey().ChildOf(copc.VoxelKey.InvalidKey()) is False
+
+    assert copc.VoxelKey(4, 4, 6, 12).ChildOf(copc.VoxelKey(3, 2, 3, 6))
+    assert copc.VoxelKey(3, 2, 3, 6).ChildOf(copc.VoxelKey(2, 1, 1, 3))
+    assert copc.VoxelKey(3, 2, 3, 6).ChildOf(copc.VoxelKey.BaseKey())
+
+    assert not copc.VoxelKey(4, 4, 6, 12).ChildOf(copc.VoxelKey(3, 4, 8, 6))
+    assert not copc.VoxelKey(3, 2, 3, 6).ChildOf(copc.VoxelKey(2, 2, 2, 2))
+
+
+def test_get_parents():
+    assert len(copc.VoxelKey(-1, -1, -1, -1).GetParents(True)) == 0
+    assert len(copc.VoxelKey(-1, -1, -1, -1).GetParents(False)) == 0
+
+    test_keys = [
+        copc.VoxelKey(4, 4, 6, 12),
+        copc.VoxelKey(3, 2, 3, 6),
+        copc.VoxelKey(2, 1, 1, 3),
+        copc.VoxelKey(1, 0, 0, 1),
+        copc.VoxelKey(0, 0, 0, 0),
+    ]
+    #
+    get_parents_inclusive = test_keys[0].GetParents(include_current=True)
+    assert len(get_parents_inclusive) == len(test_keys)
+    assert get_parents_inclusive == test_keys
+
+    test_keys_exclusive = test_keys[1:]
+
+    get_parents_non_inclusive = test_keys[0].GetParents(False)
+    assert len(get_parents_non_inclusive) == len(test_keys) - 1
+    assert get_parents_non_inclusive == test_keys_exclusive
diff --git a/test/las_header_test.cpp b/test/las_header_test.cpp
new file mode 100644
index 00000000..caf6c53f
--- /dev/null
+++ b/test/las_header_test.cpp
@@ -0,0 +1,45 @@
+#include <catch2/catch.hpp>
+#include <copc-lib/io/reader.hpp>
+#include <copc-lib/las/header.hpp>
+
+using namespace copc;
+
+TEST_CASE("Test constructor and conversions", "[LasHeader]")
+{
+    GIVEN("A valid file_path")
+    {
+        FileReader reader("test/data/autzen-classified.copc.laz");
+        auto las_header = reader.GetLasHeader();
+        auto lazperf_header = las_header.ToLazPerf();
+        auto las_header_orig = las::LasHeader::FromLazPerf(lazperf_header);
+
+        REQUIRE(las_header_orig.file_source_id == las_header.file_source_id);
+        REQUIRE(las_header_orig.global_encoding == las_header.global_encoding);
+        REQUIRE(las_header_orig.GUID() == las_header.GUID());
+        REQUIRE(las_header_orig.version_major == las_header.version_major);
+        REQUIRE(las_header_orig.version_minor == las_header.version_minor);
+        REQUIRE(las_header_orig.SystemIdentifier() == las_header.SystemIdentifier());
+        REQUIRE(las_header_orig.GeneratingSoftware() == las_header.GeneratingSoftware());
+        REQUIRE(las_header_orig.creation_day == las_header.creation_day);
+        REQUIRE(las_header_orig.creation_year == las_header.creation_year);
+        REQUIRE(las_header_orig.header_size == las_header.header_size);
+        REQUIRE(las_header_orig.point_offset == las_header.point_offset);
+        REQUIRE(las_header_orig.vlr_count == las_header.vlr_count);
+        REQUIRE(las_header_orig.point_format_id == las_header.point_format_id);
+        REQUIRE(las_header_orig.point_record_length == las_header.point_record_length);
+        REQUIRE(las_header_orig.point_count == las_header.point_count);
+        REQUIRE(las_header_orig.points_by_return == las_header.points_by_return);
+        REQUIRE(las_header_orig.scale.x == las_header.scale.x);
+        REQUIRE(las_header_orig.scale.y == las_header.scale.y);
+        REQUIRE(las_header_orig.scale.z == las_header.scale.z);
+        REQUIRE(las_header_orig.offset.x == las_header.offset.x);
+        REQUIRE(las_header_orig.offset.y == las_header.offset.y);
+        REQUIRE(las_header_orig.offset.z == las_header.offset.z);
+        REQUIRE(las_header_orig.max == las_header.max);
+        REQUIRE(las_header_orig.min == las_header.min);
+        REQUIRE(las_header_orig.wave_offset == las_header.wave_offset);
+        REQUIRE(las_header_orig.evlr_offset == las_header.evlr_offset);
+        REQUIRE(las_header_orig.evlr_count == las_header.evlr_count);
+        REQUIRE(las_header_orig.points_by_return_14 == las_header.points_by_return_14);
+    }
+}
diff --git a/test/las_header_test.py b/test/las_header_test.py
new file mode 100644
index 00000000..4f22c1ae
--- /dev/null
+++ b/test/las_header_test.py
@@ -0,0 +1,36 @@
+import copclib as copc
+
+
+def test_las_header():
+
+    reader = copc.FileReader("../test/data/autzen-classified.copc.laz")
+    las_header = reader.GetLasHeader()
+
+    assert las_header.file_source_id == las_header.file_source_id
+    assert las_header.global_encoding == las_header.global_encoding
+    assert las_header.guid == las_header.guid
+    assert las_header.version_major == las_header.version_major
+    assert las_header.version_minor == las_header.version_minor
+    assert las_header.system_identifier == las_header.system_identifier
+    assert las_header.generating_software == las_header.generating_software
+    assert las_header.creation_day == las_header.creation_day
+    assert las_header.creation_year == las_header.creation_year
+    assert las_header.header_size == las_header.header_size
+    assert las_header.point_offset == las_header.point_offset
+    assert las_header.vlr_count == las_header.vlr_count
+    assert las_header.point_format_id == las_header.point_format_id
+    assert las_header.point_record_length == las_header.point_record_length
+    assert las_header.point_count == las_header.point_count
+    assert las_header.points_by_return == las_header.points_by_return
+    assert las_header.scale.x == las_header.scale.x
+    assert las_header.scale.y == las_header.scale.y
+    assert las_header.scale.z == las_header.scale.z
+    assert las_header.offset.x == las_header.offset.x
+    assert las_header.offset.y == las_header.offset.y
+    assert las_header.offset.z == las_header.offset.z
+    assert las_header.max == las_header.max
+    assert las_header.min == las_header.min
+    assert las_header.wave_offset == las_header.wave_offset
+    assert las_header.evlr_offset == las_header.evlr_offset
+    assert las_header.evlr_count == las_header.evlr_count
+    assert las_header.points_by_return_14 == las_header.points_by_return_14
diff --git a/test/point_test.cpp b/test/point_test.cpp
index 8733a0e4..ae800370 100644
--- a/test/point_test.cpp
+++ b/test/point_test.cpp
@@ -3,10 +3,12 @@
 #include <sstream>
 
 #include <catch2/catch.hpp>
+#include <copc-lib/las/header.hpp>
 #include <copc-lib/las/point.hpp>
 
 using namespace copc::las;
 using namespace std;
+using Catch::Matchers::WithinAbs;
 
 TEST_CASE("Point tests", "[Point]")
 {
@@ -32,16 +34,16 @@ TEST_CASE("Point tests", "[Point]")
     {
         auto point0 = Point(0);
         // Position
-        point0.X(INT32_MAX);
-        point0.Y(INT32_MAX);
-        point0.Z(INT32_MAX);
-        REQUIRE(point0.X() == INT32_MAX);
-        REQUIRE(point0.Y() == INT32_MAX);
-        REQUIRE(point0.Z() == INT32_MAX);
+        point0.UnscaledX(INT32_MAX);
+        point0.UnscaledY(INT32_MAX);
+        point0.UnscaledZ(INT32_MAX);
+        REQUIRE(point0.UnscaledX() == INT32_MAX);
+        REQUIRE(point0.UnscaledY() == INT32_MAX);
+        REQUIRE(point0.UnscaledZ() == INT32_MAX);
 
         // Intensity
-        point0.Intensity(INT8_MAX);
-        REQUIRE(point0.Intensity() == INT8_MAX);
+        point0.Intensity(UINT16_MAX);
+        REQUIRE(point0.Intensity() == UINT16_MAX);
 
         // Return Number
         point0.ReturnNumber(7);
@@ -188,16 +190,16 @@ TEST_CASE("Point tests", "[Point]")
     {
         auto point6 = Point(6);
         // Position
-        point6.X(INT32_MAX);
-        point6.Y(INT32_MAX);
-        point6.Z(INT32_MAX);
-        REQUIRE(point6.X() == INT32_MAX);
-        REQUIRE(point6.Y() == INT32_MAX);
-        REQUIRE(point6.Z() == INT32_MAX);
+        point6.UnscaledX(INT32_MAX);
+        point6.UnscaledY(INT32_MAX);
+        point6.UnscaledZ(INT32_MAX);
+        REQUIRE(point6.UnscaledX() == INT32_MAX);
+        REQUIRE(point6.UnscaledY() == INT32_MAX);
+        REQUIRE(point6.UnscaledZ() == INT32_MAX);
 
         // Intensity
-        point6.Intensity(INT8_MAX);
-        REQUIRE(point6.Intensity() == INT8_MAX);
+        point6.Intensity(UINT16_MAX);
+        REQUIRE(point6.Intensity() == UINT16_MAX);
 
         // Return BitField
         point6.ExtendedReturnsBitFields(UINT8_MAX);
@@ -495,19 +497,19 @@ TEST_CASE("Point tests", "[Point]")
 
         // Atributes
 
-        point.X(4);
+        point.UnscaledX(4);
         REQUIRE(point != point_other);
-        point_other.X(4);
+        point_other.UnscaledX(4);
         REQUIRE(point == point_other);
 
-        point.Y(4);
+        point.UnscaledY(4);
         REQUIRE(point != point_other);
-        point_other.Y(4);
+        point_other.UnscaledY(4);
         REQUIRE(point == point_other);
 
-        point.Z(4);
+        point.UnscaledZ(4);
         REQUIRE(point != point_other);
-        point_other.Z(4);
+        point_other.UnscaledZ(4);
         REQUIRE(point == point_other);
 
         point.Intensity(4);
@@ -610,7 +612,7 @@ TEST_CASE("Point tests", "[Point]")
         REQUIRE(point.ExtraBytes().size() == 0);
         REQUIRE(point.NumExtraBytes() == 0);
 
-        point = Point(0, 5);
+        point = Point(0, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 5);
         REQUIRE(point.PointFormatID() == 0);
         REQUIRE(point.PointRecordLength() == 20 + 5);
         REQUIRE(point.NumExtraBytes() == 5);
@@ -630,11 +632,11 @@ TEST_CASE("Point tests", "[Point]")
 
     SECTION("Operator =")
     {
-        auto point = Point(8, 2);
+        auto point = Point(8, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 2);
 
-        point.X(4);
-        point.Y(4);
-        point.Z(4);
+        point.UnscaledX(4);
+        point.UnscaledY(4);
+        point.UnscaledZ(4);
 
         point.GPSTime(4.0);
         point.Red(4.0);
@@ -652,56 +654,176 @@ TEST_CASE("Point tests", "[Point]")
     {
 
         std::stringstream ss;
-        auto orig_point = Point(0, 2); // use two extra bytes
-        orig_point.X(20);
-        orig_point.Y(-20);
-        orig_point.Z(100000);
+        auto orig_point =
+            Point(0, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 2); // use two extra bytes
+        orig_point.UnscaledX(20);
+        orig_point.UnscaledY(-20);
+        orig_point.UnscaledZ(100000);
         orig_point.ScanAngleRank(90);
         orig_point.ClassificationBitFields(124);
 
         // Format 0
         auto point = orig_point;
         point.Pack(ss);
-        auto point_other = Point::Unpack(ss, 0, point.NumExtraBytes());
+        auto point_other =
+            Point::Unpack(ss, 0, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 1
         point.ToPointFormat(1);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 1, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 1, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 2
         point.ToPointFormat(2);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 2, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 2, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 3
         point.ToPointFormat(3);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 3, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 6
         point.ToPointFormat(6);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 6, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 6, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 7
         point.ToPointFormat(7);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 7, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 7, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         // Format 8
         point.ToPointFormat(8);
         point.Pack(ss);
-        point_other = Point::Unpack(ss, 8, point.NumExtraBytes());
+        point_other =
+            Point::Unpack(ss, 8, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), point.NumExtraBytes());
         REQUIRE(point == point_other);
 
         point.ToPointFormat(0);
         REQUIRE(point == orig_point);
     }
+
+    SECTION("Scaled XYZ")
+    {
+        int8_t pfid = 8;
+
+        SECTION("No scale and offset")
+        {
+            auto point = Point(pfid, {1, 1, 1}, {0, 0, 0});
+
+            point.UnscaledX(4);
+            point.UnscaledY(4);
+            point.UnscaledZ(4);
+
+            REQUIRE(point.X() == 4);
+            REQUIRE(point.Y() == 4);
+            REQUIRE(point.Z() == 4);
+
+            point.X(5);
+            point.Y(6);
+            point.Z(7);
+
+            REQUIRE(point.UnscaledX() == 5);
+            REQUIRE(point.UnscaledY() == 6);
+            REQUIRE(point.UnscaledZ() == 7);
+        }
+        SECTION("Scale test")
+        {
+            auto point = Point(pfid, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset());
+
+            point.UnscaledX(1);
+            point.UnscaledY(2);
+            point.UnscaledZ(3);
+
+            REQUIRE(point.X() == 0.01);
+            REQUIRE(point.Y() == 0.02);
+            REQUIRE(point.Z() == 0.03);
+
+            point.X(200);
+            point.Y(300);
+            point.Z(400);
+
+            REQUIRE(point.UnscaledX() == 200 * 100);
+            REQUIRE(point.UnscaledY() == 300 * 100);
+            REQUIRE(point.UnscaledZ() == 400 * 100);
+        }
+        SECTION("Offset test")
+        {
+            copc::Vector3 scale = {1, 1, 1};
+            copc::Vector3 offset = {50001.456, 4443.123, -255.001};
+            auto point = Point(pfid, scale, offset);
+
+            point.UnscaledX(0);
+            point.UnscaledY(-800);
+            point.UnscaledZ(3);
+
+            REQUIRE(point.X() == 0 + offset.x);
+            REQUIRE(point.Y() == -800 + offset.y);
+            REQUIRE(point.Z() == 3 + offset.z);
+
+            point.X(50502.888);
+            point.Y(4002.111);
+            point.Z(-80.5);
+
+            // static_cast<T>(std::round((value - offset) / scale));
+            REQUIRE(point.UnscaledX() == 501); // 50502.888 - 50001.456 = 501.432
+            REQUIRE(point.UnscaledY() == -441);
+            REQUIRE(point.UnscaledZ() == 175); // -80.5 - -255.001 = 255.001 - 80.5 = 175
+
+            // (value * scale) + offset
+            REQUIRE_THAT(point.X(), WithinAbs(50502.456, 0.000001));
+            REQUIRE_THAT(point.Y(), WithinAbs(4002.123, 0.000001));
+            REQUIRE_THAT(point.Z(), WithinAbs(-80.001, 0.000001));
+        }
+        SECTION("Scale and Offset test")
+        {
+            auto point = Point(pfid, {0.001, 0.001, 0.001}, {50001.456, 4443.123, -255.001});
+
+            point.UnscaledX(0);
+            point.UnscaledY(-800);
+            point.UnscaledZ(300532);
+
+            REQUIRE_THAT(point.X(), WithinAbs(50001.456, 0.000001));
+            REQUIRE_THAT(point.Y(), WithinAbs(4442.323, 0.000001));
+            REQUIRE_THAT(point.Z(), WithinAbs(45.531, 0.000001));
+
+            point.X(50502.888);
+            point.Y(4002.111);
+            point.Z(-80.5);
+
+            // static_cast<T>(std::round((value - offset) / scale));
+            REQUIRE(point.UnscaledX() == 501432);
+            REQUIRE(point.UnscaledY() == -441012);
+            REQUIRE(point.UnscaledZ() == 174501);
+
+            // (value * scale) + offset
+            REQUIRE_THAT(point.X(), WithinAbs(50502.888, 0.000001));
+            REQUIRE_THAT(point.Y(), WithinAbs(4002.111, 0.000001));
+            REQUIRE_THAT(point.Z(), WithinAbs(-80.5, 0.000001));
+        }
+        SECTION("Precision checks")
+        {
+            {
+                auto point = Point(pfid, {0.000001, 0.000001, 0.000001}, {0, 0, 0});
+
+                REQUIRE_THROWS(point.X(50501132.888123));
+            }
+            {
+                auto point = Point(pfid, {1, 1, 1}, {-8001100065, 0, 0});
+                REQUIRE_THROWS(point.X(0));
+            }
+        }
+    }
 }
diff --git a/test/point_test.py b/test/point_test.py
new file mode 100644
index 00000000..99e92155
--- /dev/null
+++ b/test/point_test.py
@@ -0,0 +1,796 @@
+import sys
+
+import copclib as copc
+import pytest
+
+
+def test_constructor():
+    point = copc.Points(
+        point_format_id=0,
+        scale=copc.Vector3.DefaultScale(),
+        offset=copc.Vector3.DefaultOffset(),
+        num_extra_bytes=0,
+    ).CreatePoint()
+
+    assert point.HasExtendedPoint is False
+    assert point.HasGPSTime is False
+    assert point.HasRGB is False
+    assert point.HasNIR is False
+
+    point_ext = copc.Points(
+        8, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    assert point_ext.HasExtendedPoint is True
+    assert point_ext.HasGPSTime is True
+    assert point_ext.HasRGB is True
+    assert point_ext.HasNIR is True
+
+
+def test_point_format10():
+    point0 = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    # Position
+    point0.UnscaledX = 2147483647
+    point0.UnscaledY = 2147483647
+    point0.UnscaledZ = 2147483647
+    assert point0.UnscaledX == 2147483647
+    assert point0.UnscaledY == 2147483647
+    assert point0.UnscaledZ == 2147483647
+
+    # Intensity
+    point0.Intensity = 65535
+    assert point0.Intensity == 65535
+
+    # Return Number
+    point0.ReturnNumber = 7
+    assert point0.ReturnNumber == 7
+    with pytest.raises(RuntimeError):
+        point0.ReturnNumber = 8
+
+    # Number of Returns
+    point0.NumberOfReturns = 7
+    assert point0.NumberOfReturns == 7
+    with pytest.raises(RuntimeError):
+        point0.NumberOfReturns = 8
+
+    # Scan Direction
+    point0.ScanDirectionFlag = True
+    assert point0.ScanDirectionFlag is True
+
+    # Edge of Flight Line
+    point0.EdgeOfFlightLineFlag = True
+    assert point0.EdgeOfFlightLineFlag is True
+
+    # ReturnsScanDirEofBitField
+    point0.ReturnsScanDirEofBitFields = 172
+    assert point0.ReturnNumber == 4
+    assert point0.NumberOfReturns == 5
+    assert point0.ScanDirectionFlag is False
+    assert point0.EdgeOfFlightLineFlag is True
+
+    # Classification
+    point0.Classification = 31
+    assert point0.Classification == 31
+    with pytest.raises(RuntimeError):
+        point0.Classification = 32
+
+    # Synthetic
+    point0.Synthetic = True
+    assert point0.Synthetic is True
+
+    # KeyPoint
+    point0.KeyPoint = True
+    assert point0.KeyPoint is True
+
+    # Withheld
+    point0.Withheld = True
+    assert point0.Withheld is True
+
+    # Classification Bitfield
+    point0.ClassificationBitFields = 255
+    assert point0.ClassificationBitFields == 255
+    point0.ClassificationBitFields = 78
+    assert point0.Classification == 14
+    assert point0.Synthetic is False
+    assert point0.KeyPoint is True
+    assert point0.Withheld is False
+
+    # Scan Angle
+    point0.ScanAngleRank = 90
+    assert point0.ScanAngleRank == 90
+    assert point0.ScanAngle == 90.0
+    with pytest.raises(RuntimeError):
+        point0.ScanAngleRank = 91
+        point0.ScanAngleRank = -91
+
+    # User Data
+    point0.UserData = 255
+    assert point0.UserData == 255
+
+    # Point Source ID
+    point0.PointSourceID = 65535
+    assert point0.PointSourceID == 65535
+
+    assert point0.PointRecordLength == 20
+
+    # Checks
+    with pytest.raises(RuntimeError):
+        point0.ExtendedFlagsBitFields = 0
+        assert point0.ExtendedFlagsBitFields
+        point0.ScannerChannel = 0
+        assert point0.ScannerChannel
+        point0.NIR = 65535
+        assert point0.NIR
+        point0.Red = 65535
+        assert point0.Red
+        point0.Green = 65535
+        assert point0.Green
+        point0.Blue = 65535
+        assert point0.Blue
+        point0.GPSTime = sys.float_info.max
+        assert point0.GPSTime
+        point0.ExtendedScanAngle = 32767
+        assert point0.ExtendedScanAngle
+        point0.Overlap = True
+        assert point0.Overlap
+        point0.ExtendedReturnsBitFields = 255
+        assert point0.ExtendedReturnsBitFields
+        point0.ExtendedFlagsBitFields = 255
+        assert point0.ExtendedFlagsBitFields
+
+    str(point0)
+
+    point1 = copc.Points(
+        1, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    point1.GPSTime = sys.float_info.max
+    assert point1.GPSTime == sys.float_info.max
+    assert point1.PointRecordLength == 28
+    with pytest.raises(RuntimeError):
+        point0.Red = 65535
+        assert point0.Red
+        point0.Green = 65535
+        assert point0.Green
+        point0.Blue = 65535
+        assert point0.Blue
+        point0.NIR = 65535
+        assert point0.NIR
+
+    point2 = copc.Points(
+        2, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    assert point2.PointRecordLength == 26
+
+    point2.Red = 65535
+    assert point2.Red == 65535
+    point2.Green = 65535
+    assert point2.Green == 65535
+    point2.Blue = 65535
+    assert point2.Blue == 65535
+
+    point2.RGB = [65535, 65535, 65535]
+    assert point2.Red == 65535
+    assert point2.Green == 65535
+    assert point2.Blue == 65535
+
+    with pytest.raises(RuntimeError):
+        point2.GPSTime = sys.float_info.max
+        assert point2.GPSTime
+        point2.NIR = 65535
+        assert point2.NIR
+
+    point3 = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    assert point3.PointRecordLength == 34
+
+    point3.GPSTime = sys.float_info.max
+    assert point3.GPSTime
+    point3.RGB = [65535, 65535, 65535]
+    assert point3.Red
+    assert point3.Green
+    assert point3.Blue
+    with pytest.raises(RuntimeError):
+        point3.NIR = 65535
+        assert point3.NIR
+
+
+def test_point_format14():
+    point6 = copc.Points(
+        6, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    # Position
+    point6.UnscaledX = 2147483647
+    point6.UnscaledY = 2147483647
+    point6.UnscaledZ = 2147483647
+    assert point6.UnscaledX == 2147483647
+    assert point6.UnscaledY == 2147483647
+    assert point6.UnscaledZ == 2147483647
+
+    # Intensity
+    point6.Intensity = 65535
+    assert point6.Intensity == 65535
+
+    # Return BitField
+    point6.ExtendedReturnsBitFields = 255
+    assert point6.ExtendedReturnsBitFields == 255
+
+    # Return Number
+    point6.ReturnNumber = 0
+    assert point6.ReturnNumber == 0
+    point6.ReturnNumber = 15
+    assert point6.ReturnNumber == 15
+    with pytest.raises(RuntimeError):
+        point6.ReturnNumber = 16
+
+    # Number return
+    point6.NumberOfReturns = 0
+    assert point6.NumberOfReturns == 0
+    point6.NumberOfReturns = 15
+    assert point6.NumberOfReturns == 15
+    with pytest.raises(RuntimeError):
+        point6.NumberOfReturns = 16
+
+    # Flags
+    point6.ExtendedFlagsBitFields = 255
+    assert point6.ExtendedFlagsBitFields == 255
+
+    # Synthetic
+    point6.Synthetic = False
+    assert point6.Synthetic is False
+    point6.Synthetic = True
+    assert point6.Synthetic is True
+
+    # KeyPoint
+    point6.KeyPoint = False
+    assert point6.KeyPoint is False
+    point6.KeyPoint = True
+    assert point6.KeyPoint is True
+
+    # Withheld
+    point6.Withheld = False
+    assert point6.Withheld is False
+    point6.Withheld = True
+    assert point6.Withheld is True
+
+    # Overlap
+    point6.Overlap = False
+    assert point6.Overlap is False
+    point6.Overlap = True
+    assert point6.Overlap is True
+
+    # Scanner Channel
+    point6.ScannerChannel = 0
+    assert point6.ScannerChannel == 0
+    point6.ScannerChannel = 3
+    assert point6.ScannerChannel == 3
+    with pytest.raises(RuntimeError):
+        point6.ScannerChannel = 4
+
+    # Scan Direction
+    point6.ScanDirectionFlag = True
+    assert point6.ScanDirectionFlag is True
+    point6.ScanDirectionFlag = False
+    assert point6.ScanDirectionFlag is False
+
+    # Edge of Flight Line
+    point6.EdgeOfFlightLineFlag = True
+    assert point6.EdgeOfFlightLineFlag is True
+    point6.EdgeOfFlightLineFlag = False
+    assert point6.EdgeOfFlightLineFlag is False
+
+    # Classification
+    point6.Classification = 255
+    assert point6.Classification == 255
+    point6.Classification = 0
+    assert point6.Classification == 0
+
+    # Scan Angle
+    point6.ExtendedScanAngle = -30000
+    assert point6.ExtendedScanAngle == -30000
+    assert point6.ScanAngle == -180.0
+    with pytest.raises(RuntimeError):
+        point6.ExtendedScanAngle = -30001
+    point6.ExtendedScanAngle = 30000
+    assert point6.ExtendedScanAngle == 30000
+    assert point6.ScanAngle == 180.0
+    with pytest.raises(RuntimeError):
+        point6.ExtendedScanAngle = 30001
+
+    # User Data
+    point6.UserData = 255
+    assert point6.UserData == 255
+
+    # Point Source ID
+    point6.PointSourceID = 65535
+    assert point6.PointSourceID == 65535
+
+    # GPS Time
+    point6.GPSTime = sys.float_info.max
+    assert point6.GPSTime == sys.float_info.max
+
+    # Point Record Length
+    assert point6.PointRecordLength == 30
+
+    # Checks
+    with pytest.raises(RuntimeError):
+        point6.RGB = [65535, 65535, 65535]
+        assert point6.Red
+        assert point6.Green
+        assert point6.Blue
+        point6.NIR = 65535
+        assert point6.NIR
+        assert point6.ScanAngleRank
+        point6.ScanAngleRank = 127
+        point6.ReturnsScanDirEofBitFields = 255
+        assert point6.ReturnsScanDirEofBitFields
+        point6.ClassificationBitFields = 255
+        assert point6.ClassificationBitFields
+
+    # ToString
+    str(point6)
+
+    point7 = copc.Points(
+        7, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    point7.RGB = [65535, 65535, 65535]
+    assert point7.Red == 65535
+    assert point7.Green == 65535
+    assert point7.Blue == 65535
+    assert point7.PointRecordLength == 36
+
+    with pytest.raises(RuntimeError):
+        point7.NIR = 65535
+        assert point7.NIR
+
+    point8 = copc.Points(
+        8, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    point8.NIR = 65535
+    assert point8.NIR == 65535
+    assert point8.PointRecordLength == 38
+
+
+def test_point_conversion_10():
+    point = copc.Points(
+        1, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    assert point.GPSTime == 0
+
+    point.ToPointFormat(point_format_id=2)
+    assert point.Red == 0
+    assert point.Green == 0
+    assert point.Blue == 0
+
+    point.ToPointFormat(3)
+    assert point.GPSTime == 0
+    assert point.Red == 0
+    assert point.Green == 0
+    assert point.Blue == 0
+
+
+def test_point_conversion_14():
+    point = copc.Points(
+        6, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    assert point.GPSTime == 0
+    assert point.ScannerChannel == 0
+    assert point.Overlap == 0
+    assert point.GPSTime == 0
+
+    point.ToPointFormat(7)
+    assert point.GPSTime == 0
+    assert point.ScannerChannel == 0
+    assert point.Overlap == 0
+    assert point.GPSTime == 0
+    assert point.Red == 0
+    assert point.Green == 0
+    assert point.Blue == 0
+
+    point.ToPointFormat(8)
+    assert point.GPSTime == 0
+    assert point.ScannerChannel == 0
+    assert point.Overlap == 0
+    assert point.GPSTime == 0
+    assert point.Red == 0
+    assert point.Green == 0
+    assert point.Blue == 0
+    assert point.NIR == 0
+
+
+def test_point_conversion_10_to_14():
+    point = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    point.ReturnNumber = 5
+    point.NumberOfReturns = 6
+    point.ScanDirectionFlag = False
+    point.EdgeOfFlightLineFlag = True
+    point.Classification = 15
+    point.Synthetic = True
+    point.KeyPoint = False
+    point.Withheld = True
+    point.ScanAngleRank = 45
+
+    point.ToPointFormat(6)
+
+    assert point.ReturnNumber == 5
+    assert point.NumberOfReturns == 6
+    assert point.ScanDirectionFlag is False
+    assert point.EdgeOfFlightLineFlag is True
+    assert point.Classification == 15
+    assert point.Synthetic is True
+    assert point.KeyPoint is False
+    assert point.Withheld is True
+    assert point.Overlap is False
+    assert point.ExtendedScanAngle == 7500
+    assert point.ScannerChannel == 0
+    with pytest.raises(RuntimeError):
+        assert point.ReturnsScanDirEofBitFields
+
+    point.ToPointFormat(0)
+
+    assert point.ReturnNumber == 5
+    assert point.NumberOfReturns == 6
+    assert point.ScanDirectionFlag is False
+    assert point.EdgeOfFlightLineFlag is True
+    assert point.Classification == 15
+    assert point.Synthetic is True
+    assert point.KeyPoint is False
+    assert point.Withheld is True
+    assert point.ScanAngleRank == 45
+
+    point.ToPointFormat(6)
+
+    point.ReturnNumber = 13
+    point.NumberOfReturns = 14
+    point.Classification = 134
+    point.ScannerChannel = 2
+    point.Synthetic = True
+    point.KeyPoint = False
+    point.Withheld = True
+    point.ExtendedScanAngle = 28000
+
+    point.ToPointFormat(0)
+
+    assert point.ReturnNumber == 7
+    assert point.NumberOfReturns == 7
+    assert point.Classification == 31
+    assert point.ScanAngleRank == 90
+    with pytest.raises(RuntimeError):
+        assert point.Overlap
+        assert point.ScannerChannel
+        assert point.ExtendedReturnsBitFields
+        assert point.ExtendedFlagsBitFields
+
+    point.ToPointFormat(6)
+
+    assert point.ReturnNumber == 7
+    assert point.NumberOfReturns == 7
+    assert point.Classification == 31
+    assert point.ExtendedScanAngle == 15000
+    assert point.Overlap is False
+    assert point.ScannerChannel == 0
+
+
+def test_operators_equal():
+    # Format 0
+    point = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    point_other = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    assert point == point_other
+
+    # Format 1
+    point_other.ToPointFormat(1)
+    assert point != point_other
+    point.ToPointFormat(1)
+    assert point == point_other
+
+    # Format 2
+    point_other.ToPointFormat(2)
+    assert point != point_other
+    point.ToPointFormat(2)
+    assert point == point_other
+
+    # Format 3
+    point_other.ToPointFormat(3)
+    assert point != point_other
+    point.ToPointFormat(3)
+    assert point == point_other
+
+    # Format 6
+    point_other.ToPointFormat(6)
+    assert point != point_other
+    point.ToPointFormat(6)
+    assert point == point_other
+
+    # Format 7
+    point_other.ToPointFormat(7)
+    assert point != point_other
+    point.ToPointFormat(7)
+    assert point == point_other
+
+    # Format 8
+    point_other.ToPointFormat(8)
+    assert point != point_other
+    point.ToPointFormat(8)
+    assert point == point_other
+
+    # Atributes
+
+    point.UnscaledX = 4
+    assert point != point_other
+    point_other.UnscaledX = 4
+    assert point == point_other
+
+    point.UnscaledY = 4
+    assert point != point_other
+    point_other.UnscaledY = 4
+    assert point == point_other
+
+    point.UnscaledZ = 4
+    assert point != point_other
+    point_other.UnscaledZ = 4
+    assert point == point_other
+
+    point.Intensity = 4
+    assert point != point_other
+    point_other.Intensity = 4
+    assert point == point_other
+
+    point.ReturnNumber = 4
+    assert point != point_other
+    point_other.ReturnNumber = 4
+    assert point == point_other
+
+    point.NumberOfReturns = 4
+    assert point != point_other
+    point_other.NumberOfReturns = 4
+    assert point == point_other
+
+    point.Classification = 4
+    assert point != point_other
+    point_other.Classification = 4
+    assert point == point_other
+
+    point.ScanDirectionFlag = True
+    assert point != point_other
+    point_other.ScanDirectionFlag = True
+    assert point == point_other
+
+    point.EdgeOfFlightLineFlag = True
+    assert point != point_other
+    point_other.EdgeOfFlightLineFlag = True
+    assert point == point_other
+
+    point.Synthetic = True
+    assert point != point_other
+    point_other.Synthetic = True
+    assert point == point_other
+
+    point.KeyPoint = True
+    assert point != point_other
+    point_other.KeyPoint = True
+    assert point == point_other
+
+    point.Withheld = True
+    assert point != point_other
+    point_other.Withheld = True
+    assert point == point_other
+
+    point.Overlap = True
+    assert point != point_other
+    point_other.Overlap = True
+    assert point == point_other
+
+    point.ScannerChannel = 2
+    assert point != point_other
+    point_other.ScannerChannel = 2
+    assert point == point_other
+
+    point.UserData = 4
+    assert point != point_other
+    point_other.UserData = 4
+    assert point == point_other
+
+    point.PointSourceID = 4
+    assert point != point_other
+    point_other.PointSourceID = 4
+    assert point == point_other
+
+    point.GPSTime = 4.0
+    assert point != point_other
+    point_other.GPSTime = 4.0
+    assert point == point_other
+
+    point.Red = 4
+    assert point != point_other
+    point_other.Red = 4
+    assert point == point_other
+
+    point.Green = 4
+    assert point != point_other
+    point_other.Green = 4
+    assert point == point_other
+
+    point.Blue = 4
+    assert point != point_other
+    point_other.Blue = 4
+    assert point == point_other
+
+    point.NIR = 4
+    assert point != point_other
+    point_other.NIR = 4
+    assert point == point_other
+
+
+def test_extra_byte():
+    point = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+    assert point.PointFormatID == 0
+    assert point.PointRecordLength == 20
+    with pytest.raises(RuntimeError):
+        point.ExtraBytes = [2, 3, 4]
+    assert len(point.ExtraBytes) == 0
+    assert point.NumExtraBytes == 0
+
+    point = copc.Points(
+        0, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=5
+    ).CreatePoint()
+    assert point.PointFormatID == 0
+    assert point.PointRecordLength == 20 + 5
+    assert point.NumExtraBytes == 5
+    assert len(point.ExtraBytes) == 5
+    with pytest.raises(RuntimeError):
+        point.ExtraBytes = [2, 3, 4]
+    point.ExtraBytes = [2, 3, 4, 5, 6]
+    assert point.ExtraBytes == [2, 3, 4, 5, 6]
+
+    point.ToPointFormat(6)
+    assert point.PointFormatID == 6
+    assert point.PointRecordLength == 30 + 5
+    assert point.NumExtraBytes == 5
+    assert len(point.ExtraBytes) == 5
+    assert point.ExtraBytes == [2, 3, 4, 5, 6]
+    with pytest.raises(RuntimeError):
+        point.ExtraBytes = [2, 3, 4]
+
+
+def test_operator_copy():
+    point = copc.Points(
+        8, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=2
+    ).CreatePoint()
+
+    point.UnscaledX = 4
+    point.UnscaledY = 4
+    point.UnscaledZ = 4
+
+    point.GPSTime = 4.0
+    point.Red = 4
+    point.NIR = 4
+
+    point.ExtraBytes = [2, 5]
+
+    point_other = point
+
+    assert point == point_other
+
+
+def test_scaled_xyz():
+
+    pfid = 8
+
+    # No scale and offset
+    point = copc.Points(pfid, scale=[1, 1, 1], offset=[0, 0, 0]).CreatePoint()
+
+    point.UnscaledX = 4
+    point.UnscaledY = 4
+    point.UnscaledZ = 4
+
+    assert point.X == 4
+    assert point.Y == 4
+    assert point.Z == 4
+
+    point.X = 5
+    point.Y = 6
+    point.Z = 7
+
+    assert point.UnscaledX == 5
+    assert point.UnscaledY == 6
+    assert point.UnscaledZ == 7
+
+    # Scale test
+    point = copc.Points(
+        pfid, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset()
+    ).CreatePoint()
+
+    point.UnscaledX = 1
+    point.UnscaledY = 2
+    point.UnscaledZ = 3
+
+    assert point.X == 0.01
+    assert point.Y == 0.02
+    assert point.Z == 0.03
+
+    point.X = 200
+    point.Y = 300
+    point.Z = 400
+
+    assert point.UnscaledX == 200 * 100
+    assert point.UnscaledY == 300 * 100
+    assert point.UnscaledZ == 400 * 100
+    # Offset test
+
+    scale = [1, 1, 1]
+    offset = copc.Vector3([50001.456, 4443.123, -255.001])
+    point = copc.Points(pfid, scale, offset).CreatePoint()
+
+    point.UnscaledX = 0
+    point.UnscaledY = -800
+    point.UnscaledZ = 3
+
+    assert point.X == 0 + offset.x
+    assert point.Y == -800 + offset.y
+    assert point.Z == 3 + offset.z
+
+    point.X = 50502.888
+    point.Y = 4002.111
+    point.Z = -80.5
+
+    assert point.UnscaledX == 501  # 50502.888 - 50001.456 = 501.432
+    assert point.UnscaledY == -441
+    assert point.UnscaledZ == 175  # -80.5 - -255.001 = 255.001 - 80.5 = 175
+
+    # (value * scale) + offset
+    assert point.X == pytest.approx(50502.456, 0.000001)
+    assert point.Y == pytest.approx(4002.123, 0.000001)
+    assert point.Z == pytest.approx(-80.001, 0.000001)
+
+    # Scale and Offset test
+    point = copc.Points(
+        pfid, scale=[0.001, 0.001, 0.001], offset=[50001.456, 4443.123, -255.001]
+    ).CreatePoint()
+
+    point.UnscaledX = 0
+    point.UnscaledY = -800
+    point.UnscaledZ = 300532
+
+    assert point.X == pytest.approx(50001.456, 0.000001)
+    assert point.Y == pytest.approx(4442.323, 0.000001)
+    assert point.Z == pytest.approx(45.531, 0.000001)
+
+    point.X = 50502.888
+    point.Y = 4002.111
+    point.Z = -80.5
+
+    assert point.UnscaledX == 501432
+    assert point.UnscaledY == -441012
+    assert point.UnscaledZ == 174501
+
+    # (value * scale) + offset
+    assert point.X == pytest.approx(50502.888, 0.000001)
+    assert point.Y == pytest.approx(4002.111, 0.000001)
+    assert point.Z == pytest.approx(-80.5, 0.000001)
+
+    # Precision checks
+    point = copc.Points(pfid, [0.000001, 0.000001, 0.000001], [0, 0, 0]).CreatePoint()
+
+    with pytest.raises(RuntimeError):
+        point.X = 50501132.888123
+
+    point = copc.Points(pfid, [1, 1, 1], [-8001100065, 0, 0]).CreatePoint()
+    with pytest.raises(RuntimeError):
+        point.X = 0
diff --git a/test/points_test.cpp b/test/points_test.cpp
index 5ed03aff..f8f4449e 100644
--- a/test/points_test.cpp
+++ b/test/points_test.cpp
@@ -11,22 +11,22 @@ TEST_CASE("Points tests", "[Point]")
 {
     SECTION("Points constructors")
     {
-        auto points = Points(3, 4);
+        auto points = Points(3, {1, 1, 1}, {0, 0, 0}, 4);
         REQUIRE(points.PointFormatID() == 3);
         REQUIRE(points.PointRecordLength() == 38);
         REQUIRE(points.Get().empty());
 
         auto point_vec = std::vector<Point>();
-        auto point1 = Point(3, 4);
-        point1.X(11);
-        point1.Y(11);
-        point1.Z(11);
+        auto point1 = points.CreatePoint();
+        point1.UnscaledX(11);
+        point1.UnscaledY(11);
+        point1.UnscaledZ(11);
         point_vec.push_back(point1);
 
-        auto point2 = points.NewPoint();
+        auto point2 = points.CreatePoint();
         point_vec.push_back(point2);
 
-        auto point3 = Point(3, 4);
+        auto point3 = points.CreatePoint();
         point_vec.push_back(point3);
 
         points = Points(point_vec);
@@ -35,69 +35,74 @@ TEST_CASE("Points tests", "[Point]")
         for (const auto &point : points.Get())
             REQUIRE(point.PointFormatID() == 3);
         REQUIRE(points.PointRecordLength() == 38);
-        REQUIRE(points.Get(0).X() == 11);
-        REQUIRE(points.Get(0).Y() == 11);
-        REQUIRE(points.Get(0).Z() == 11);
+        REQUIRE(points.Get(0).UnscaledX() == 11);
+        REQUIRE(points.Get(0).UnscaledY() == 11);
+        REQUIRE(points.Get(0).UnscaledZ() == 11);
 
         points.ToString();
     }
 
     SECTION("Adding Point to Points")
     {
-        auto points = Points(3, 0);
-        auto point = Point(3, 0);
-        point.X(11);
-        point.Y(11);
-        point.Z(11);
+        auto points = Points(3, {1, 1, 1}, {0, 0, 0}, 0);
+        auto point = points.CreatePoint();
+        point.UnscaledX(11);
+        point.UnscaledY(11);
+        point.UnscaledZ(11);
 
         points.AddPoint(point);
 
         REQUIRE(points.Get().size() == 1);
-        REQUIRE(points.Get(0).X() == 11);
-        REQUIRE(points.Get(0).Y() == 11);
-        REQUIRE(points.Get(0).Z() == 11);
+        REQUIRE(points.Get(0).UnscaledX() == 11);
+        REQUIRE(points.Get(0).UnscaledY() == 11);
+        REQUIRE(points.Get(0).UnscaledZ() == 11);
 
-        point = Point(3, 0);
-        point.X(22);
-        point.Y(22);
-        point.Z(22);
+        point = points.CreatePoint();
+        point.UnscaledX(22);
+        point.UnscaledY(22);
+        point.UnscaledZ(22);
 
         points.AddPoint(point);
         REQUIRE(points.Get().size() == 2);
-        REQUIRE(points.Get(1).X() == 22);
-        REQUIRE(points.Get(1).Y() == 22);
-        REQUIRE(points.Get(1).Z() == 22);
+        REQUIRE(points.Get(1).UnscaledX() == 22);
+        REQUIRE(points.Get(1).UnscaledY() == 22);
+        REQUIRE(points.Get(1).UnscaledZ() == 22);
 
         // Test check on point format
-        point = Point(6, 0);
+        point = Point(6, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 0);
         REQUIRE_THROWS(points.AddPoint(point));
 
         // Test check on extra bytes
-        point = Point(3, 1);
+        point = Point(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 1);
         REQUIRE_THROWS(points.AddPoint(point));
     }
 
     SECTION("Adding Points to Points")
     {
-        auto points = Points(std::vector<Point>(10, Point(3, 4)));
-        auto points_other = Points(std::vector<Point>(10, Point(3, 4)));
+        auto points =
+            Points(std::vector<Point>(10, Point(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 4)));
+        auto points_other =
+            Points(std::vector<Point>(10, Point(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 4)));
 
         points.AddPoints(points_other);
 
         REQUIRE(points.Get().size() == 20);
 
         // Test check on point format
-        points_other = Points(std::vector<Point>(10, Point(6, 4)));
+        points_other =
+            Points(std::vector<Point>(10, Point(6, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 4)));
         REQUIRE_THROWS(points.AddPoints(points_other));
 
         // Test check on extra bytes
-        points_other = Points(std::vector<Point>(10, Point(3, 1)));
+        points_other =
+            Points(std::vector<Point>(10, Point(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 1)));
         REQUIRE_THROWS(points.AddPoints(points_other));
     }
 
     SECTION("Points format conversion")
     {
-        auto points = Points(std::vector<Point>(10, Point(3, 4)));
+        auto points =
+            Points(std::vector<Point>(10, Point(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 4)));
         points.ToPointFormat(6);
 
         REQUIRE(points.PointFormatID() == 6);
@@ -108,4 +113,77 @@ TEST_CASE("Points tests", "[Point]")
         REQUIRE_THROWS(points.ToPointFormat(-1));
         REQUIRE_THROWS(points.ToPointFormat(11));
     }
+
+    SECTION("Points Accessors")
+    {
+        auto points = Points(3, copc::Vector3::DefaultScale(), copc::Vector3::DefaultOffset(), 4);
+
+        // generate points
+        int num_points = 2000;
+        for (int i = 0; i < num_points; i++)
+        {
+            auto p = points.CreatePoint();
+            p.X(i);
+            p.Y(i * 3);
+            p.Z(i - 80);
+            points.AddPoint(p);
+        }
+
+        REQUIRE(points.Size() == num_points);
+
+        // test that the getters work
+        auto X = points.X();
+        auto Y = points.Y();
+        auto Z = points.Z();
+        for (int i = 0; i < num_points; i++)
+        {
+            REQUIRE(X[i] == i);
+            REQUIRE(Y[i] == i * 3);
+            REQUIRE(Z[i] == i - 80);
+        }
+
+        // generate vector of coordinates
+        std::vector<double> Xn;
+        std::vector<double> Yn;
+        std::vector<double> Zn;
+
+        REQUIRE_THROWS(points.X(Xn));
+        REQUIRE_THROWS(points.Y(Yn));
+        REQUIRE_THROWS(points.Z(Zn));
+
+        for (int i = 0; i < num_points - 1; i++)
+        {
+            Xn.push_back(i * 50 + 8);
+            Yn.push_back(i + 800);
+            Zn.push_back(i * 4);
+        }
+
+        REQUIRE_THROWS(points.X(Xn));
+        REQUIRE_THROWS(points.Y(Yn));
+        REQUIRE_THROWS(points.Z(Zn));
+
+        // add the last point
+        Xn.push_back(1);
+        Yn.push_back(2);
+        Zn.push_back(3);
+
+        // test setters
+        REQUIRE_NOTHROW(points.X(Xn));
+        REQUIRE_NOTHROW(points.Y(Yn));
+        REQUIRE_NOTHROW(points.Z(Zn));
+
+        for (int i = 0; i < num_points - 1; i++)
+        {
+            auto p = points.Get(i);
+            REQUIRE(p.X() == i * 50 + 8);
+            REQUIRE(p.Y() == i + 800);
+            REQUIRE(p.Z() == i * 4);
+        }
+
+        // test last point
+        auto last_point = points.Get(points.Size() - 1);
+        REQUIRE(last_point.X() == 1);
+        REQUIRE(last_point.Y() == 2);
+        REQUIRE(last_point.Z() == 3);
+    }
 }
diff --git a/test/points_test.py b/test/points_test.py
new file mode 100644
index 00000000..45b0b98b
--- /dev/null
+++ b/test/points_test.py
@@ -0,0 +1,240 @@
+import copclib as copc
+import pytest
+
+
+def test_points_constructor():
+    points = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), 4
+    )
+    assert points.PointFormatID == 3
+    assert points.PointRecordLength == 38
+    assert len(points.Get()) == 0
+
+    point1 = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=4
+    ).CreatePoint()
+    point1.UnscaledX = 11
+    point1.UnscaledY = 11
+    point1.UnscaledZ = 11
+
+    point_list = [
+        point1,
+        copc.Points(
+            3,
+            copc.Vector3.DefaultScale(),
+            copc.Vector3.DefaultOffset(),
+            num_extra_bytes=4,
+        ).CreatePoint(),
+        copc.Points(
+            3,
+            copc.Vector3.DefaultScale(),
+            copc.Vector3.DefaultOffset(),
+            num_extra_bytes=4,
+        ).CreatePoint(),
+    ]
+
+    points = copc.Points(point_list)
+    assert points.PointFormatID == 3
+    assert points.PointRecordLength == 38
+    for point in points.Get():
+        assert point.PointFormatID == 3
+    assert points.PointRecordLength == 38
+    assert points.Get(idx=0).UnscaledX == 11
+    assert points.Get(0).UnscaledY == 11
+    assert points.Get(0).UnscaledZ == 11
+
+    str(points)
+
+
+def test_adding_point_to_points():
+    points = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), 0
+    )
+    point = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=0
+    ).CreatePoint()
+    point.UnscaledX = 11
+    point.UnscaledY = 11
+    point.UnscaledZ = 11
+
+    points.AddPoint(point)
+
+    assert len(points.Get()) == 1
+    assert points.Get(0).UnscaledX == 11
+    assert points.Get(0).UnscaledY == 11
+    assert points.Get(0).UnscaledZ == 11
+
+    point = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=0
+    ).CreatePoint()
+    point.UnscaledX = 22
+    point.UnscaledY = 22
+    point.UnscaledZ = 22
+
+    points.AddPoint(point)
+    assert len(points.Get()) == 2
+    assert points.Get(1).UnscaledX == 22
+    assert points.Get(1).UnscaledY == 22
+    assert points.Get(1).UnscaledZ == 22
+
+    # Test check on point format
+    point = copc.Points(
+        6, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=0
+    ).CreatePoint()
+    with pytest.raises(RuntimeError):
+        points.AddPoint(point)
+
+    # Test check on extra bytes
+    point = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), num_extra_bytes=1
+    ).CreatePoint()
+    with pytest.raises(RuntimeError):
+        points.AddPoint(point)
+
+
+def test_adding_points_to_points():
+    points = copc.Points(
+        [
+            copc.Points(
+                3,
+                copc.Vector3.DefaultScale(),
+                copc.Vector3.DefaultOffset(),
+                num_extra_bytes=4,
+            ).CreatePoint()
+            for _ in range(10)
+        ]
+    )
+    points_other = copc.Points(
+        [
+            copc.Points(
+                3,
+                copc.Vector3.DefaultScale(),
+                copc.Vector3.DefaultOffset(),
+                num_extra_bytes=4,
+            ).CreatePoint()
+            for _ in range(10)
+        ]
+    )
+
+    points.AddPoints(points_other)
+
+    assert len(points.Get()) == 20
+
+    # Test check on point format
+    points_other = copc.Points(
+        [
+            copc.Points(
+                6,
+                copc.Vector3.DefaultScale(),
+                copc.Vector3.DefaultOffset(),
+                num_extra_bytes=4,
+            ).CreatePoint()
+            for _ in range(10)
+        ]
+    )
+    with pytest.raises(RuntimeError):
+        points.AddPoints(points_other)
+
+    # Test check on extra bytes
+    points_other = copc.Points(
+        [
+            copc.Points(
+                3,
+                copc.Vector3.DefaultScale(),
+                copc.Vector3.DefaultOffset(),
+                num_extra_bytes=1,
+            ).CreatePoint()
+            for _ in range(10)
+        ]
+    )
+    with pytest.raises(RuntimeError):
+        points.AddPoints(points_other)
+
+
+def test_points_format_conversion():
+    points = copc.Points(
+        [
+            copc.Points(
+                3,
+                copc.Vector3.DefaultScale(),
+                copc.Vector3.DefaultOffset(),
+                num_extra_bytes=4,
+            ).CreatePoint()
+            for _ in range(10)
+        ]
+    )
+    points.ToPointFormat(6)
+
+    assert points.PointFormatID == 6
+    assert points.PointRecordLength == 38
+    assert points.Get(0).PointFormatID == 6
+    assert points.Get(0).PointRecordLength == 34
+
+    with pytest.raises(RuntimeError):
+        points.ToPointFormat(-1)
+    with pytest.raises(RuntimeError):
+        points.ToPointFormat(11)
+
+
+def test_points_accessors():
+    points = copc.Points(
+        3, copc.Vector3.DefaultScale(), copc.Vector3.DefaultOffset(), 4
+    )
+
+    # generate points
+    num_points = 2000
+    for i in range(num_points):
+        p = points.CreatePoint()
+        p.X = i
+        p.Y = i * 3
+        p.Z = i - 80
+        points.AddPoint(p)
+
+    assert points.Size == num_points
+
+    # test that the getters work
+    for i in range(num_points):
+        assert points.X[i] == i
+        assert points.Y[i] == i * 3
+        assert points.Z[i] == i - 80
+
+    # generate vector of coordinates
+    Xn = []
+    Yn = []
+    Zn = []
+    with pytest.raises(RuntimeError):
+        points.X = Xn
+        points.Y = Yn
+        points.Z = Zn
+
+    for i in range(num_points - 1):
+        Xn.append(i * 50 + 8)
+        Yn.append(i + 800)
+        Zn.append(i * 4)
+
+    with pytest.raises(RuntimeError):
+        points.X = Xn
+        points.Y = Yn
+        points.Z = Zn
+
+    # add the last point
+    Xn.append(1)
+    Yn.append(2)
+    Zn.append(3)
+
+    # test setters
+    points.X = Xn
+    points.Y = Yn
+    points.Z = Zn
+
+    for i in range(num_points - 1):
+        p = points.Get(i)
+        assert p.X == i * 50 + 8
+        assert p.Y == i + 800
+        assert p.Z == i * 4
+
+    # test last point
+    last_point = points.Get(points.Size - 1)
+    assert last_point.X == 1
+    assert last_point.Y == 2
+    assert last_point.Z == 3
diff --git a/test/reader_node_test.cpp b/test/reader_node_test.cpp
index 31fbc93e..9de15590 100644
--- a/test/reader_node_test.cpp
+++ b/test/reader_node_test.cpp
@@ -75,9 +75,9 @@ TEST_CASE("GetPoints Test", "[Reader] ")
         REQUIRE(points.size() == hier_entry.point_count);
 
         // Getters
-        REQUIRE(points[0].X() == -144147);
-        REQUIRE(points[0].Y() == -13541);
-        REQUIRE(points[0].Z() == -227681);
+        REQUIRE(points[0].UnscaledX() == -144147);
+        REQUIRE(points[0].UnscaledY() == -13541);
+        REQUIRE(points[0].UnscaledZ() == -227681);
         REQUIRE(points[0].Intensity() == 11);
         REQUIRE(points[0].NumberOfReturns() == 3);
         REQUIRE(points[0].ReturnNumber() == 1);
@@ -103,9 +103,9 @@ TEST_CASE("GetPoints Test", "[Reader] ")
         REQUIRE_THROWS(points[0].ExtendedScanAngle());
 
         // Setters
-        points[0].X(INT32_MAX);
-        points[0].Y(INT32_MAX);
-        points[0].Z(INT32_MAX);
+        points[0].UnscaledX(INT32_MAX);
+        points[0].UnscaledY(INT32_MAX);
+        points[0].UnscaledZ(INT32_MAX);
         points[0].Intensity(UINT16_MAX);
         points[0].NumberOfReturns(7);
         points[0].ReturnNumber(7);
@@ -125,9 +125,9 @@ TEST_CASE("GetPoints Test", "[Reader] ")
         REQUIRE_THROWS(points[0].ScannerChannel(3));
         REQUIRE_THROWS(points[0].ScanAngle(INT16_MAX));
 
-        REQUIRE(points[0].X() == INT32_MAX);
-        REQUIRE(points[0].Y() == INT32_MAX);
-        REQUIRE(points[0].Z() == INT32_MAX);
+        REQUIRE(points[0].UnscaledX() == INT32_MAX);
+        REQUIRE(points[0].UnscaledY() == INT32_MAX);
+        REQUIRE(points[0].UnscaledZ() == INT32_MAX);
         REQUIRE(points[0].Intensity() == UINT16_MAX);
         REQUIRE(points[0].NumberOfReturns() == 7);
         REQUIRE(points[0].ReturnNumber() == 7);
diff --git a/test/reader_test.cpp b/test/reader_test.cpp
index 461e74d0..d4359aff 100644
--- a/test/reader_test.cpp
+++ b/test/reader_test.cpp
@@ -83,11 +83,11 @@ TEST_CASE("Reader tests", "[Reader]")
 
 TEST_CASE("FindKey Check", "[Reader]")
 {
-    GIVEN("A valid input stream")
+    GIVEN("A valid file path")
     {
         FileReader reader("test/data/autzen-classified.copc.laz");
 
-        auto key = VoxelKey(0, 0, 0, 0);
+        auto key = VoxelKey::BaseKey();
         auto hier_entry = reader.FindNode(key);
 
         REQUIRE(hier_entry.IsValid() == true);
diff --git a/test/reader_test.py b/test/reader_test.py
new file mode 100644
index 00000000..a7e08ea8
--- /dev/null
+++ b/test/reader_test.py
@@ -0,0 +1,95 @@
+import copclib as copc
+import pytest
+
+
+def test_reader():
+    # Given a valid file path
+    reader = copc.FileReader("data/autzen-classified.copc.laz")
+
+    # GetLasHeader Test
+    copc_header = reader.GetCopcHeader()
+    assert copc_header.span == 0
+    assert copc_header.root_hier_offset == 93169718
+    assert copc_header.root_hier_size == 8896
+    assert copc_header.laz_vlr_offset == 643
+    assert copc_header.laz_vlr_size == 58
+    assert copc_header.wkt_vlr_offset == 755
+    assert copc_header.wkt_vlr_size == 993
+    assert copc_header.eb_vlr_offset == 1802
+    assert copc_header.eb_vlr_size == 384
+
+    # GetLasHeader Test
+    header = reader.GetLasHeader()
+    assert header.header_size == 375
+    assert header.point_format_id == 3
+    assert header.point_count == 10653336
+
+    # WKT Test
+    wkt = reader.GetWkt()
+    assert len(wkt) > 0
+    assert wkt.startswith("COMPD_CS[")
+
+
+def test_find_key():
+    # Given a valid file path
+    reader = copc.FileReader("data/autzen-classified.copc.laz")
+
+    key = copc.VoxelKey.BaseKey()
+    hier_entry = reader.FindNode(key)
+
+    assert hier_entry.IsValid() == True
+    assert hier_entry.key == key
+    assert hier_entry.point_count == 61022
+
+    key = copc.VoxelKey(5, 9, 7, 0)
+    hier_entry = reader.FindNode(key)
+
+    assert hier_entry.IsValid() == True
+    assert hier_entry.key == key
+    assert hier_entry.point_count == 12019
+
+
+def test_get_all_children():
+    reader = copc.FileReader("data/autzen-classified.copc.laz")
+
+    # Get root key
+    nodes = reader.GetAllChildren()
+    assert len(nodes) == 278
+
+    # Get an invalid key
+    nodes = reader.GetAllChildren(copc.VoxelKey.InvalidKey())
+    assert len(nodes) == 0
+
+    # Get an existing key
+    nodes = reader.GetAllChildren(copc.VoxelKey(5, 9, 7, 0))
+    assert len(nodes) == 1
+    assert nodes[0].IsValid()
+    assert nodes[0].key == copc.VoxelKey(5, 9, 7, 0)
+
+    # Get a non-existing key
+    nodes = reader.GetAllChildren(copc.VoxelKey(20, 20, 20, 20))
+    assert len(nodes) == 0
+
+
+def test_point_error_handling():
+    reader = copc.FileReader("data/autzen-classified.copc.laz")
+
+    invalid_node = copc.Node()
+    with pytest.raises(RuntimeError):
+        reader.GetPointData(node=invalid_node)
+    valid_node = reader.FindNode(copc.VoxelKey(5, 9, 7, 0))
+    reader.GetPointData(valid_node)
+
+    assert len(reader.GetPointData(key=invalid_node.key)) == 0
+    assert len(reader.GetPointData(valid_node.key)) != 0
+
+    with pytest.raises(RuntimeError):
+        reader.GetPoints(invalid_node)
+    reader.GetPoints(valid_node)
+
+    assert len(reader.GetPoints(invalid_node.key).Get()) == 0
+    assert len(reader.GetPoints(valid_node.key).Get()) != 0
+
+    with pytest.raises(RuntimeError):
+        reader.GetPointDataCompressed(invalid_node)
+    reader.GetPointDataCompressed(valid_node)
diff --git a/test/writer_test.cpp b/test/writer_test.cpp
index 9e8b0157..1b9b5cd4 100644
--- a/test/writer_test.cpp
+++ b/test/writer_test.cpp
@@ -34,6 +34,15 @@ TEST_CASE("Writer Config Tests", "[Writer]")
 
             Writer::LasConfig cfg(8, {2, 3, 4}, {-0.02, -0.03, -40.8});
             cfg.file_source_id = 200;
+
+            // Test checks on string attributes
+            cfg.SystemIdentifier("test_string");
+            REQUIRE(cfg.SystemIdentifier() == "test_string");
+            REQUIRE_THROWS(cfg.SystemIdentifier(std::string(33, 'a')));
+            cfg.GeneratingSoftware("test_string");
+            REQUIRE(cfg.GeneratingSoftware() == "test_string");
+            REQUIRE_THROWS(cfg.GeneratingSoftware(std::string(33, 'a')));
+
             FileWriter writer(file_path, cfg);
 
             auto las_header = writer.GetLasHeader();
@@ -94,8 +103,8 @@ TEST_CASE("Writer Config Tests", "[Writer]")
             FileReader reader(file_path);
             REQUIRE(reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id);
             REQUIRE(reader.GetLasHeader().global_encoding == orig.GetLasHeader().global_encoding);
-            REQUIRE(reader.GetLasHeader().creation.day == orig.GetLasHeader().creation.day);
-            REQUIRE(reader.GetLasHeader().creation.year == orig.GetLasHeader().creation.year);
+            REQUIRE(reader.GetLasHeader().creation_day == orig.GetLasHeader().creation_day);
+            REQUIRE(reader.GetLasHeader().creation_year == orig.GetLasHeader().creation_year);
             REQUIRE(reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id);
             REQUIRE(reader.GetLasHeader().point_format_id == orig.GetLasHeader().point_format_id);
             REQUIRE(reader.GetLasHeader().point_record_length == orig.GetLasHeader().point_record_length);
@@ -206,8 +215,8 @@ TEST_CASE("Writer Config Tests", "[Writer]")
             Reader reader(&out_stream);
             REQUIRE(reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id);
             REQUIRE(reader.GetLasHeader().global_encoding == orig.GetLasHeader().global_encoding);
-            REQUIRE(reader.GetLasHeader().creation.day == orig.GetLasHeader().creation.day);
-            REQUIRE(reader.GetLasHeader().creation.year == orig.GetLasHeader().creation.year);
+            REQUIRE(reader.GetLasHeader().creation_day == orig.GetLasHeader().creation_day);
+            REQUIRE(reader.GetLasHeader().creation_year == orig.GetLasHeader().creation_year);
             REQUIRE(reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id);
             REQUIRE(reader.GetLasHeader().point_format_id == orig.GetLasHeader().point_format_id);
             REQUIRE(reader.GetLasHeader().point_record_length == orig.GetLasHeader().point_record_length);
@@ -369,8 +378,7 @@ TEST_CASE("Writer Copy", "[Writer]")
             REQUIRE(new_node.IsValid());
             REQUIRE(new_node.key == node.key);
             REQUIRE(new_node.point_count == node.point_count);
-            REQUIRE(new_node.size == node.size);
-            // REQUIRE(new_reader.GetPointData(new_node) == reader.GetPointData(node));
+            REQUIRE(new_node.byte_size == node.byte_size);
             REQUIRE(new_reader.GetPointDataCompressed(new_node) == reader.GetPointDataCompressed(node));
         }
 
diff --git a/test/writer_test.py b/test/writer_test.py
new file mode 100644
index 00000000..7df2928f
--- /dev/null
+++ b/test/writer_test.py
@@ -0,0 +1,188 @@
+import copclib as copc
+import pytest
+
+
+def test_writer_config():
+    # Given a valid file path
+    file_path = "data/writer_test.copc.laz"
+
+    # Default config
+    cfg = copc.LasConfig(0)
+    writer = copc.FileWriter(file_path, cfg)
+
+    las_header = writer.GetLasHeader()
+    assert las_header.scale == [0.01, 0.01, 0.01]
+    assert las_header.offset == [0.0, 0.0, 0.0]
+    assert las_header.point_format_id == 0
+
+    writer.Close()
+
+    # Custom config
+
+    cfg = copc.LasConfig(8, [2, 3, 4], [-0.02, -0.03, -40.8])
+    cfg.file_source_id = 200
+
+    # Test LasConfig attributes
+    cfg.creation_day = 18
+    assert cfg.creation_day == 18
+    cfg.creation_year = 11
+    assert cfg.creation_year == 11
+
+    # Test checks on string attributes
+    cfg.guid = "test_string"
+    assert cfg.guid == "test_string"
+    with pytest.raises(RuntimeError):
+        cfg.guid = "a" * 17
+
+    cfg.system_identifier = "test_string"
+    assert cfg.system_identifier == "test_string"
+    with pytest.raises(RuntimeError):
+        cfg.system_identifier = "a" * 33
+
+    cfg.generating_software = "test_string"
+    assert cfg.generating_software == "test_string"
+    with pytest.raises(RuntimeError):
+        cfg.generating_software = "a" * 33
+
+    writer = copc.FileWriter(file_path, cfg)
+
+    las_header = writer.GetLasHeader()
+    assert las_header.file_source_id == 200
+    assert las_header.point_format_id == 8
+    assert las_header.scale == [2.0, 3.0, 4.0]
+    assert las_header.offset == [-0.02, -0.03, -40.8]
+
+    writer.Close()
+
+    # COPC Span
+
+    cfg = copc.LasConfig(0)
+    writer = copc.FileWriter(file_path, cfg, 256)
+
+    # todo: use Reader to check all of these
+    assert writer.GetCopcHeader().span == 256
+
+    writer.Close()
+
+    reader = copc.FileReader(file_path)
+    assert reader.GetCopcHeader().span == 256
+
+    # WKT
+    cfg = copc.LasConfig(0)
+    writer = copc.FileWriter(file_path, cfg, 256, "TEST_WKT")
+
+    # todo: use Reader to check all of these
+    assert writer.GetWkt() == "TEST_WKT"
+
+    writer.Close()
+
+    reader = copc.FileReader(file_path)
+    assert reader.GetWkt() == "TEST_WKT"
+
+    # Copy
+    orig = copc.FileReader("data/autzen-classified.copc.laz")
+
+    cfg = copc.LasConfig(orig.GetLasHeader(), orig.GetExtraByteVlr())
+    writer = copc.FileWriter(file_path, cfg)
+    writer.Close()
+
+    reader = copc.FileReader(file_path)
+    assert reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id
+    assert reader.GetLasHeader().global_encoding == orig.GetLasHeader().global_encoding
+    assert reader.GetLasHeader().creation_day == orig.GetLasHeader().creation_day
+    assert reader.GetLasHeader().creation_year == orig.GetLasHeader().creation_year
+    assert reader.GetLasHeader().file_source_id == orig.GetLasHeader().file_source_id
+    assert reader.GetLasHeader().point_format_id == orig.GetLasHeader().point_format_id
+    assert (
+        reader.GetLasHeader().point_record_length
+        == orig.GetLasHeader().point_record_length
+    )
+    assert reader.GetLasHeader().point_count == 0
+    assert reader.GetLasHeader().scale == reader.GetLasHeader().scale
+    assert reader.GetLasHeader().offset == reader.GetLasHeader().offset
+
+
+def test_writer_pages():
+    # Given a valid file path
+    file_path = "data/writer_test.copc.laz"
+
+    # Root Page
+    writer = copc.FileWriter(file_path, copc.LasConfig(0))
+
+    assert not writer.FindNode(copc.VoxelKey.BaseKey()).IsValid()
+    assert not writer.FindNode(copc.VoxelKey.InvalidKey()).IsValid()
+    assert not writer.FindNode(copc.VoxelKey(5, 4, 3, 2)).IsValid()
+
+    writer.GetRootPage()
+    root_page = writer.GetRootPage()
+    assert root_page.IsValid()
+    assert root_page.IsPage()
+    assert root_page.loaded is True
+    with pytest.raises(RuntimeError):
+        writer.AddSubPage(parent_page=root_page, key=copc.VoxelKey.InvalidKey())
+
+    writer.Close()
+
+    reader = copc.FileReader(file_path)
+    assert reader.GetCopcHeader().root_hier_offset > 0
+    assert reader.GetCopcHeader().root_hier_size == 0
+    assert not reader.FindNode(key=copc.VoxelKey.InvalidKey()).IsValid()
+
+    # Nested page
+    writer = copc.FileWriter(file_path, copc.LasConfig(0))
+
+    root_page = writer.GetRootPage()
+
+    sub_page = writer.AddSubPage(root_page, copc.VoxelKey(1, 1, 1, 1))
+    assert sub_page.IsPage()
+    assert sub_page.IsValid()
+    assert sub_page.loaded is True
+
+    with pytest.raises(RuntimeError):
+        writer.AddSubPage(sub_page, copc.VoxelKey(1, 1, 1, 0))
+        writer.AddSubPage(sub_page, copc.VoxelKey(2, 4, 5, 0))
+
+    writer.Close()
+
+    reader = copc.FileReader(file_path)
+    assert reader.GetCopcHeader().root_hier_offset > 0
+    assert reader.GetCopcHeader().root_hier_size == 32
+    assert not reader.FindNode(copc.VoxelKey.InvalidKey()).IsValid()
+
+
+def test_writer_copy():
+    # Given a valid file path
+    file_path = "data/writer_test.copc.laz"
+
+    reader = copc.FileReader("data/autzen-classified.copc.laz")
+
+    cfg = copc.LasConfig(reader.GetLasHeader(), reader.GetExtraByteVlr())
+    writer = copc.FileWriter(file_path, cfg)
+
+    root_page = writer.GetRootPage()
+
+    for node in reader.GetAllChildren():
+        # only write/compare compressed data or otherwise tests take too long
+        writer.AddNodeCompressed(
+            root_page, node.key, reader.GetPointDataCompressed(node), node.point_count
+        )
+
+    writer.Close()
+
+    # validate
+    new_reader = copc.FileReader(file_path)
+    for node in reader.GetAllChildren():
+        assert node.IsValid()
+        new_node = new_reader.FindNode(node.key)
+        assert new_node.IsValid()
+        assert new_node.key == node.key
+        assert new_node.point_count == node.point_count
+        assert new_node.byte_size == node.byte_size
+        assert new_reader.GetPointDataCompressed(
+            new_node
+        ) == reader.GetPointDataCompressed(node)
+
+    # we can do one uncompressed comparison here
+    assert new_reader.GetPointData(
+        new_reader.FindNode(copc.VoxelKey(5, 9, 7, 0))
+    ) == reader.GetPointData(reader.FindNode(copc.VoxelKey(5, 9, 7, 0)))