diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 59604ce..d4267aa 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: (c) 2025-2026 Trailblaze Software, all rights reserved # SPDX-License-Identifier: MIT -name: Multi-platform Build +name: Build and Test on: push: branches: ["main"] @@ -9,44 +9,60 @@ on: branches: ["main"] jobs: build: + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} - timeout-minutes: 5 + timeout-minutes: 15 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - build_type: [Release, Debug, Profile] - cpp_compiler: [g++, clang++, cl] include: - - os: windows-latest - cpp_compiler: cl - - os: ubuntu-latest - cpp_compiler: g++ - - os: ubuntu-latest - cpp_compiler: clang++ - exclude: - - os: windows-latest - cpp_compiler: g++ - - os: windows-latest - cpp_compiler: clang++ - - os: ubuntu-latest - cpp_compiler: cl - - os: windows-latest - build_type: Profile + - name: Ubuntu GCC Release + os: ubuntu-24.04 + cxx: g++-14 + build-type: Release + - name: Ubuntu GCC Debug + os: ubuntu-24.04 + cxx: g++-14 + build-type: Debug + - name: Ubuntu Clang Release + os: ubuntu-24.04 + cxx: clang++-18 + build-type: Release + - name: Ubuntu Clang Debug + os: ubuntu-24.04 + cxx: clang++-18 + build-type: Debug + - name: macOS Release + os: macos-14 + cxx: clang++ + build-type: Release + - name: macOS Debug + os: macos-14 + cxx: clang++ + build-type: Debug + - name: Windows MSVC Release + os: windows-latest + cxx: cl + build-type: Release + - name: Windows MSVC Debug + os: windows-latest + cxx: cl + build-type: Debug steps: - uses: actions/checkout@v4 - - name: Set reusable strings - id: strings - shell: bash - run: | - echo "build-output-dir=${{ github.workspace }}/build" >> \ - "$GITHUB_OUTPUT" - - name: Configure CMake + - name: Install GCC 14 + if: matrix.cxx == 'g++-14' + run: sudo apt-get update && sudo apt-get install -y g++-14 + - name: Install Clang 18 + if: matrix.cxx == 'clang++-18' + run: sudo apt-get update && sudo apt-get install -y clang-18 + - name: Configure + env: + CXX: ${{ matrix.cxx }} run: > - cmake -B ${{ steps.strings.outputs.build-output-dir }} -DCMAKE_CXX_COMPILER=${{ matrix.cpp_compiler }} -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} -S ${{ github.workspace }} + cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build-type }} -DLASPP_BUILD_TESTS=ON -DLASPP_BUILD_BENCHMARK=OFF -DLASPP_AGGRESSIVE_OPTIMIZATIONS=OFF - name: Build - run: cmake --build ${{ steps.strings.outputs.build-output-dir }} --config ${{ matrix.build_type }} -j 2 + run: cmake --build build --config ${{ matrix.build-type }} --parallel - name: Test - working-directory: ${{ steps.strings.outputs.build-output-dir }} - run: ctest --output-on-failure --build-config ${{ matrix.build_type }} + run: ctest --test-dir build --output-on-failure --build-config ${{ matrix.build-type }} diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 0000000..457bc92 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: (c) 2025-2026 Trailblaze Software, all rights reserved +# SPDX-License-Identifier: MIT + +name: Sanitizers +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + asan-ubsan: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: GCC ASan+UBSan + cxx: g++-14 + - name: Clang ASan+UBSan + cxx: clang++-18 + steps: + - uses: actions/checkout@v4 + - name: Install GCC + if: matrix.cxx == 'g++-14' + run: sudo apt-get update && sudo apt-get install -y g++-14 + - name: Install Clang + if: matrix.cxx == 'clang++-18' + run: sudo apt-get update && sudo apt-get install -y clang-18 + - name: Configure + env: + CXX: ${{ matrix.cxx }} + run: > + cmake -B build -DCMAKE_BUILD_TYPE=Debug -DLASPP_BUILD_TESTS=ON -DLASPP_BUILD_BENCHMARK=OFF -DLASPP_AGGRESSIVE_OPTIMIZATIONS=OFF -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" + + - name: Build + run: cmake --build build --parallel + - name: Test + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 34ed041..49ec2df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -262,13 +262,13 @@ if(LASPP_BUILD_TESTS) ${LASZIP_WRAPPER_SOURCES} PROPERTIES COMPILE_FLAGS "-Wno-old-style-cast -Wno-shadow -Wno-sign-conversion \ --Wno-conversion -Wno-useless-cast") +-Wno-conversion -Wno-useless-cast -Wno-deprecated-declarations") else() set_source_files_properties( ${LASZIP_WRAPPER_SOURCES} PROPERTIES COMPILE_FLAGS "-Wno-old-style-cast -Wno-shadow -Wno-sign-conversion \ --Wno-conversion") +-Wno-conversion -Wno-deprecated-declarations") endif() link_target_to_laszip(test_laszip_interop) diff --git a/cmake/SetupLaszip.cmake b/cmake/SetupLaszip.cmake index 364652c..face645 100644 --- a/cmake/SetupLaszip.cmake +++ b/cmake/SetupLaszip.cmake @@ -59,7 +59,8 @@ function(setup_laszip) # Quiet warnings coming from LASzip itself. if(NOT MSVC) # Common flags for both GCC and Clang - set(_common_flags -Wno-format -Wno-format-security -Wno-switch) + set(_common_flags -Wno-format -Wno-format-security -Wno-switch + -Wno-deprecated-declarations -fno-sanitize=undefined) # GCC-specific flags if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") list(APPEND _common_flags -Wno-maybe-uninitialized diff --git a/license_header.txt b/license_header.txt index 3abbb93..023c952 100644 --- a/license_header.txt +++ b/license_header.txt @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: (c) 2024 Trailblaze Software, all rights reserved +SPDX-FileCopyrightText: (c) 2025-2026 Trailblaze Software, all rights reserved SPDX-License-Identifier: MIT diff --git a/src/las_reader.hpp b/src/las_reader.hpp index c5811fb..6ef0eab 100644 --- a/src/las_reader.hpp +++ b/src/las_reader.hpp @@ -59,6 +59,12 @@ class LASReader { std::vector m_vlr_headers; std::vector m_evlr_headers; + // Optional progress callback: called with fraction [0,1] as chunks are processed. + // Serialised under m_progress_mutex so callers do not need to provide a + // thread-safe callback. + std::function m_progress_callback; + mutable std::mutex m_progress_mutex; + // Unified I/O helper: zero-copy view for memory-mapped path, owned buffer for stream path. // The returned object is non-copyable; its `data` span is always valid for its lifetime. struct ReadBuffer { @@ -288,6 +294,13 @@ class LASReader { const LASHeader& header() const { return m_header; } + // Set a progress callback that will be called with fraction [0,1] during + // read_chunks / read_chunks_list. Invocations are serialised under a mutex + // so the callback does not need to be thread-safe. + void set_progress_callback(std::function cb) { + m_progress_callback = std::move(cb); + } + const std::vector& vlr_headers() const { return m_vlr_headers; } const std::vector& evlr_headers() const { return m_evlr_headers; } @@ -340,7 +353,11 @@ class LASReader { const auto* las_point = reinterpret_cast(buf.data.data() + i * point_record_length); copy_if_possible(*las_point, points[i]); + copy_if_possible(*las_point, points[i]); copy_if_possible(*las_point, points[i]); + copy_if_possible(*las_point, points[i]); + copy_if_possible(*las_point, points[i]); + copy_if_possible(*las_point, points[i]); } } @@ -404,7 +421,9 @@ class LASReader { size_t file_data_offset = header().offset_to_point_data() + compressed_start_offset; auto buf = get_bytes(file_data_offset, total_compressed_size); - utilities::parallel_for(size_t{0}, chunk_indices.size(), [&](size_t idx) { + size_t chunks_done = 0; + const size_t total_chunks = chunk_indices.size(); + utilities::parallel_for(size_t{0}, total_chunks, [&](size_t idx) { size_t chunk_index = chunk_indices[idx]; size_t start_offset = chunk_table.chunk_offset(chunk_index) - compressed_start_offset; size_t compressed_chunk_size = chunk_table.compressed_chunk_size(chunk_index); @@ -415,16 +434,24 @@ class LASReader { chunk_table.decompressed_chunk_offsets()[chunk_indexes.first]; size_t n_points = chunk_table.points_per_chunk()[chunk_index]; if (chunk_index == 0) { - LASPP_ASSERT_EQ(point_offset, 0u); + LASPP_ASSERT_EQ(point_offset, 0U); } m_laz_reader->decompress_chunk(compressed_chunk, output_location.subspan(point_offset, n_points)); + + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + size_t done = ++chunks_done; + m_progress_callback(static_cast(done) / static_cast(total_chunks)); + } }); } else { // Stream-based path: read chunks in parallel (with mutex protection) and decompress. // This overlaps I/O and decompression - while one thread decompresses, others can read. std::mutex stream_mutex; - utilities::parallel_for(size_t{0}, chunk_indices.size(), [&](size_t idx) { + size_t chunks_done = 0; + const size_t total_chunks = chunk_indices.size(); + utilities::parallel_for(size_t{0}, total_chunks, [&](size_t idx) { size_t chunk_index = chunk_indices[idx]; size_t file_data_offset = header().offset_to_point_data() + chunk_table.chunk_offset(chunk_index); @@ -443,17 +470,28 @@ class LASReader { chunk_table.decompressed_chunk_offsets()[chunk_indexes.first]; size_t n_points = chunk_table.points_per_chunk()[chunk_index]; if (chunk_index == 0) { - LASPP_ASSERT_EQ(point_offset, 0u); + LASPP_ASSERT_EQ(point_offset, 0U); } m_laz_reader->decompress_chunk(std::span(compressed_buffer), output_location.subspan(point_offset, n_points)); + + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + size_t done = ++chunks_done; + m_progress_callback(static_cast(done) / static_cast(total_chunks)); + } }); } return output_location.subspan(0, total_n_points); } LASPP_ASSERT(chunk_indexes.first == 0); LASPP_ASSERT(chunk_indexes.second == 1); - return read_chunk(output_location, 0); + auto result = read_chunk(output_location, 0); + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + m_progress_callback(1.0); + } + return result; } std::vector read_vlr_data(const LASVLRWithGlobalOffset& vlr) { @@ -547,7 +585,9 @@ class LASReader { if (m_mapped_file.has_value()) { // Memory-mapped path: get_bytes is zero-copy and thread-safe. // Decompress all chunks in parallel — each call uses only local state. - utilities::parallel_for(size_t{0}, chunk_indices.size(), [&](size_t i) { + size_t chunks_done = 0; + const size_t total_chunks = chunk_indices.size(); + utilities::parallel_for(size_t{0}, total_chunks, [&](size_t i) { const size_t chunk_idx = chunk_indices[i]; const size_t file_data_offset = header().offset_to_point_data() + chunk_table.chunk_offset(chunk_idx); @@ -555,12 +595,20 @@ class LASReader { m_laz_reader->decompress_chunk( buf.data, output_location.subspan(output_offsets[i], points_per_chunk_vec[chunk_idx])); + + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + size_t done = ++chunks_done; + m_progress_callback(static_cast(done) / static_cast(total_chunks)); + } }); } else { // Stream-based path: read chunks in parallel (with mutex protection) and decompress. // This overlaps I/O and decompression - while one thread decompresses, others can read. std::mutex stream_mutex; - utilities::parallel_for(size_t{0}, chunk_indices.size(), [&](size_t i) { + size_t chunks_done = 0; + const size_t total_chunks = chunk_indices.size(); + utilities::parallel_for(size_t{0}, total_chunks, [&](size_t i) { const size_t chunk_idx = chunk_indices[i]; const size_t file_data_offset = header().offset_to_point_data() + chunk_table.chunk_offset(chunk_idx); @@ -578,6 +626,12 @@ class LASReader { m_laz_reader->decompress_chunk( std::span(compressed_buffer), output_location.subspan(output_offsets[i], points_per_chunk_vec[chunk_idx])); + + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + size_t done = ++chunks_done; + m_progress_callback(static_cast(done) / static_cast(total_chunks)); + } }); } @@ -586,7 +640,12 @@ class LASReader { // For non-LAZ files, there's only one chunk LASPP_ASSERT(chunk_indices.size() == 1 && chunk_indices[0] == 0, "Non-LAZ files should only have chunk index 0"); - return read_chunk(output_location, 0); + auto result = read_chunk(output_location, 0); + if (m_progress_callback) { + std::lock_guard lock(m_progress_mutex); + m_progress_callback(1.0); + } + return result; } } }; diff --git a/src/laz/integer_encoder.hpp b/src/laz/integer_encoder.hpp index c91e00a..474d0e9 100644 --- a/src/laz/integer_encoder.hpp +++ b/src/laz/integer_encoder.hpp @@ -130,7 +130,7 @@ class IntegerEncoder { if (k < 32) { if (k == 0) { - m_symbol_encoders->encode(stream, k, static_cast(integer)); + m_symbol_encoders->encode(stream, k, static_cast(integer)); return; } if (integer < 0) { diff --git a/src/laz/rgb12_encoder.hpp b/src/laz/rgb12_encoder.hpp index f771f43..bdb26c5 100644 --- a/src/laz/rgb12_encoder.hpp +++ b/src/laz/rgb12_encoder.hpp @@ -128,7 +128,7 @@ class RGB12EncoderT { uint8_t cur[6]; to_bytes(color_data, cur); - uint32_t sym = 0; + uint_fast16_t sym = 0; sym |= (last[0] != cur[0]) ? (1u << 0u) : 0u; sym |= (last[1] != cur[1]) ? (1u << 1u) : 0u; sym |= (last[2] != cur[2]) ? (1u << 2u) : 0u; diff --git a/src/tests/test_progress.cpp b/src/tests/test_progress.cpp new file mode 100644 index 0000000..1440aff --- /dev/null +++ b/src/tests/test_progress.cpp @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: (c) 2025-2026 Trailblaze Software, all rights reserved + * SPDX-License-Identifier: MIT + */ + +// Tests for LASReader::set_progress_callback — verifies the callback fires +// correctly with both single-chunk and multi-chunk files, across memory-mapped +// and stream-based I/O paths, and with multiple threads. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "las_header.hpp" +#include "las_reader.hpp" +#include "las_writer.hpp" +#include "utilities/assert.hpp" + +using namespace laspp; + +namespace { + +class TempFile { + public: + explicit TempFile(const std::string& prefix) { + auto base_dir = std::filesystem::temp_directory_path() / "laspp_tests"; + std::filesystem::create_directories(base_dir); + auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + path_ = base_dir / (prefix + "_" + std::to_string(timestamp) + ".las"); + } + ~TempFile() { + std::error_code ec; + std::filesystem::remove(path_, ec); + } + const std::filesystem::path& path() const { return path_; } + + private: + std::filesystem::path path_; +}; + +void set_env(const char* name, const char* value) { +#ifdef _WIN32 + _putenv_s(name, value ? value : ""); +#else + if (value && *value) + setenv(name, value, 1); + else + unsetenv(name); +#endif +} + +// Collect fractions from the progress callback in a thread-safe way, +// then verify they form the complete set {1/N, 2/N, ..., N/N}. +void verify_fractions(const std::vector& fractions, size_t expected_count) { + LASPP_ASSERT_EQ(fractions.size(), expected_count); + for (size_t i = 0; i < expected_count; ++i) { + double expected = static_cast(i + 1) / static_cast(expected_count); + LASPP_ASSERT(std::abs(fractions[i] - expected) < 1e-9); + } +} + +// Write a multi-chunk LAZ file by calling write_points in separate batches. +// Each batch becomes its own LAZ chunk, giving us deterministic chunk counts. +void write_multi_chunk_laz(const std::filesystem::path& path, size_t num_batches) { + std::fstream ofs(path, std::ios::binary | std::ios::in | std::ios::out | std::ios::trunc); + LASWriter writer(ofs, 128); // Format 0 + LAZ compression + + const size_t total_points = num_batches * 30; + std::vector points(total_points); + for (size_t i = 0; i < total_points; ++i) { + points[i].x = static_cast(i); + } + + for (size_t b = 0; b < num_batches; ++b) { + writer.write_points(std::span(points).subspan(b * 30, 30)); + } +} + +// Write a multi-chunk LAZ to a stringstream for stream-based tests +void write_multi_chunk_laz_stream(std::stringstream& stream, size_t num_batches) { + LASWriter writer(stream, 128); + + const size_t total_points = num_batches * 30; + std::vector points(total_points); + for (size_t i = 0; i < total_points; ++i) { + points[i].x = static_cast(i); + } + + for (size_t b = 0; b < num_batches; ++b) { + writer.write_points(std::span(points).subspan(b * 30, 30)); + } +} + +} // namespace + +int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) { + // ── Test 1: Non-LAZ (single chunk) calls progress with exactly 1.0 ── + { + std::stringstream stream; + { + LASWriter writer(stream, 0, 0); + std::vector points(50); + for (size_t i = 0; i < 50; ++i) points[i].x = static_cast(i); + writer.write_points(std::span(points)); + } + + LASReader reader(stream); + LASPP_ASSERT_EQ(reader.num_chunks(), 1u); + + std::vector fractions; + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(50); + reader.read_chunks(points, {0, 1}); + + LASPP_ASSERT_EQ(fractions.size(), 1u); + LASPP_ASSERT_EQ(fractions[0], 1.0); + } + + // ── Test 2: Non-LAZ read_chunks_list → single callback with 1.0 ── + { + std::stringstream stream; + { + LASWriter writer(stream, 0, 0); + std::vector points(40); + for (size_t i = 0; i < 40; ++i) points[i].x = static_cast(i); + writer.write_points(std::span(points)); + } + + LASReader reader(stream); + LASPP_ASSERT_EQ(reader.num_chunks(), 1u); + LASPP_ASSERT(!reader.header().is_laz_compressed()); + + std::vector fractions; + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(40); + reader.read_chunks_list(points, {0}); + + LASPP_ASSERT_EQ(fractions.size(), 1u); + LASPP_ASSERT_EQ(fractions[0], 1.0); + } + + // ── Test 3: LAZ multi-chunk read_chunks — monotonic and complete ── + { + std::stringstream stream; + write_multi_chunk_laz_stream(stream, 4); + + LASReader reader(stream); + size_t N = reader.num_chunks(); + LASPP_ASSERT_GE(N, 2u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(120); + reader.read_chunks(points, {0, N}); + + // Callbacks are serialised → must arrive strictly increasing + LASPP_ASSERT_EQ(fractions.size(), N); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + // Also verify the complete set + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, N); + } + + // ── Test 3: read_chunks_list — monotonic and complete ── + { + std::stringstream stream; + write_multi_chunk_laz_stream(stream, 4); + + LASReader reader(stream); + LASPP_ASSERT_GE(reader.num_chunks(), 3u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + // Read only chunks 0 and 2 (skip chunks 1 and 3) + std::vector chunk_list = {0, 2}; + const auto& ppc = reader.points_per_chunk(); + size_t total_pts = ppc[0] + ppc[2]; + std::vector points(total_pts); + reader.read_chunks_list(points, chunk_list); + + LASPP_ASSERT_EQ(fractions.size(), chunk_list.size()); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, chunk_list.size()); + } + + // ── Test 4: No callback set — no crash ── + { + std::stringstream stream; + { + LASWriter writer(stream, 128, 0); + std::vector points(50); + for (size_t i = 0; i < 50; ++i) points[i].x = static_cast(i); + writer.write_points(std::span(points)); + } + + LASReader reader(stream); + // Deliberately no set_progress_callback — must not crash + std::vector points(50); + reader.read_chunks(points, {0, reader.num_chunks()}); + + // Also exercise read_chunks_list without callback + reader.set_progress_callback({}); // explicit empty callback + reader.read_chunks_list(points, {0}); + } + + // ── Test 5: Memory-mapped I/O path — monotonic and complete ── + { + TempFile temp_file("test_progress_mmap"); + write_multi_chunk_laz(temp_file.path(), 4); + + LASReader reader(temp_file.path()); + LASPP_ASSERT(reader.is_using_memory_mapping()); + + size_t N = reader.num_chunks(); + LASPP_ASSERT_GE(N, 2u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(120); + reader.read_chunks(points, {0, N}); + + LASPP_ASSERT_EQ(fractions.size(), N); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, N); + } + + // ── Test 6: Stream-based I/O path (ifstream, not mmap) ── + { + TempFile temp_file("test_progress_stream"); + write_multi_chunk_laz(temp_file.path(), 4); + + std::ifstream ifs(temp_file.path(), std::ios::binary); + LASReader reader(ifs); + LASPP_ASSERT(!reader.is_using_memory_mapping()); + + size_t N = reader.num_chunks(); + LASPP_ASSERT_GE(N, 2u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(120); + reader.read_chunks(points, {0, N}); + + LASPP_ASSERT_EQ(fractions.size(), N); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, N); + } + + // ── Test 7: Monotonic with multiple threads (LASPP_NUM_THREADS=4) ── + // Callbacks are serialised by LASReader's internal mutex, so + // fractions must arrive in strictly increasing order even when + // multiple worker threads are decompressing chunks. + { + TempFile temp_file("test_progress_mt"); + write_multi_chunk_laz(temp_file.path(), 5); // 5 chunks for parallelism + + set_env("LASPP_NUM_THREADS", "4"); + + LASReader reader(temp_file.path()); + size_t N = reader.num_chunks(); + LASPP_ASSERT_GE(N, 3u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(150); + reader.read_chunks(points, {0, N}); + + set_env("LASPP_NUM_THREADS", nullptr); + + // Fractions must arrive strictly increasing (guaranteed by the mutex) + LASPP_ASSERT_EQ(fractions.size(), N); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + // Also verify the complete set + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, N); + } + + // ── Test 8: Monotonic with single thread (LASPP_NUM_THREADS=1) ── + { + TempFile temp_file("test_progress_st"); + write_multi_chunk_laz(temp_file.path(), 4); + + set_env("LASPP_NUM_THREADS", "1"); + + LASReader reader(temp_file.path()); + size_t N = reader.num_chunks(); + LASPP_ASSERT_GE(N, 2u); + + std::vector fractions; + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector points(120); + reader.read_chunks(points, {0, N}); + + set_env("LASPP_NUM_THREADS", nullptr); + + // Fractions must arrive strictly increasing + LASPP_ASSERT_EQ(fractions.size(), N); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, N); + } + + // ── Test 9: read_chunks_list with memory-mapped path ── + { + TempFile temp_file("test_progress_mmap_list"); + write_multi_chunk_laz(temp_file.path(), 4); + + LASReader reader(temp_file.path()); + LASPP_ASSERT(reader.is_using_memory_mapping()); + LASPP_ASSERT_GE(reader.num_chunks(), 3u); + + std::vector fractions; + + reader.set_progress_callback([&](double f) { fractions.push_back(f); }); + + std::vector chunk_list = {0, 3}; + const auto& ppc = reader.points_per_chunk(); + size_t total_pts = ppc[0] + ppc[3]; + std::vector points(total_pts); + reader.read_chunks_list(points, chunk_list); + + LASPP_ASSERT_EQ(fractions.size(), chunk_list.size()); + for (size_t i = 1; i < fractions.size(); ++i) { + LASPP_ASSERT(fractions[i] > fractions[i - 1]); + } + std::sort(fractions.begin(), fractions.end()); + verify_fractions(fractions, chunk_list.size()); + } + + return 0; +} diff --git a/src/tests/test_writer_copy_from_reader.cpp b/src/tests/test_writer_copy_from_reader.cpp index 7570143..5426455 100644 --- a/src/tests/test_writer_copy_from_reader.cpp +++ b/src/tests/test_writer_copy_from_reader.cpp @@ -319,6 +319,52 @@ int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) { } } + // Test that format 3 round-trips preserve ColorData (regression test) + { + std::stringstream input_stream; + { + LASWriter writer(input_stream, 3, 0); // Format 3: XYZ + GPS + RGB + writer.copy_header_metadata(LASHeader()); + writer.header().transform() = Transform({0.001, 0.001, 0.001}, {0.0, 0.0, 0.0}); + + std::vector points; + for (int i = 0; i < 50; ++i) { + LASPointFormat3 point; + point.x = i * 1000; + point.y = i * 1000; + point.z = i * 100; + point.intensity = 100; + point.bit_byte = 0; + point.classification_byte = 0; + point.scan_angle_rank = 0; + point.user_data = 0; + point.point_source_id = 0; + point.gps_time = GPSTime(static_cast(i)).gps_time; + point.red = static_cast(i * 100); + point.green = static_cast(i * 200); + point.blue = static_cast(i * 300); + points.push_back(point); + } + writer.write_points(std::span(points)); + } + + input_stream.seekg(0); + LASReader reader(input_stream); + LASPP_ASSERT_EQ(reader.header().num_points(), 50u); + LASPP_ASSERT_EQ(reader.header().point_format(), 3); + + // Read back and verify colors + std::vector output_points(50); + reader.read_chunks(output_points, {0, reader.num_chunks()}); + + for (size_t i = 0; i < 50; ++i) { + LASPP_ASSERT_EQ(output_points[i].red, static_cast(i * 100)); + LASPP_ASSERT_EQ(output_points[i].green, static_cast(i * 200)); + LASPP_ASSERT_EQ(output_points[i].blue, static_cast(i * 300)); + LASPP_ASSERT_EQ(output_points[i].gps_time.f64, static_cast(i)); + } + } + // Test copy_from_reader with LAZ compression { std::stringstream input_stream;