diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 570c84c..4ae1bdb 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -20,7 +20,7 @@ modules implement is fixed in | Identity | Directory | Kind | Status | Responsibility | | --- | --- | --- | --- | --- | | `usdGeoCore` | `libs/usd-geo-core` | plain CMake/OpenStrata static library | implemented | Format-independent geospatial values: coordinates and local-origin transforms, spatial bounds, CRS-related values, deterministic tile IDs, normalized cache-key inputs, and the typed diagnostic vocabulary shared across modules. | -| `usdPointCloudCore` | `libs/usd-pointcloud-core` | plain CMake/OpenStrata static library | implemented | Format-independent point-cloud contracts: point attribute definitions and types, `PointChunk` / `PointData` / `PointCloudAsset`, point validation, attribute selection, range and chunk read options, deterministic sampling, and the shared LOD hierarchy and tile value types. | +| `usdPointCloudCore` | `libs/usd-pointcloud-core` | plain CMake/OpenStrata static library | implemented | Format-independent point-cloud contracts: point attribute definitions and types, `PointChunk` / `PointData` / `PointCloudAsset`, multi-scan `PointCloudScan` / `PointCloudCollection` values, point validation, attribute selection, range and chunk read options, deterministic sampling, and the shared LOD hierarchy and tile value types. | | `usdLas` | `libs/usd-las` | plain CMake/OpenStrata static library | implemented | LAS parsing and decoding: 1.2-1.4 headers, VLR/EVLR, point formats 0-10, waveform packet metadata, GeoTIFF key records, scalar and vector Extra Bytes, metadata-only reads, and chunked and range-based reads behind `LasReader`. | | `usdLaz` | `libs/usd-laz` | plain CMake/OpenStrata static library | implemented | LAZ decompression through the vendored laz-perf codec, converted into the shared LAS and point-cloud contracts, with metadata-only reads, chunked reads, and stable diagnostics for unsupported compressed formats. Owns the vendored laz-perf subset. | | `usdPointCloudAuthoring` | `libs/usd-pointcloud-authoring` | plain CMake/OpenStrata static library | implemented | Shared OpenUSD authoring: `UsdGeomPoints` and geospatial metadata authoring, point-attribute authoring, `usdLod` hierarchy authoring, tiled point-cloud authoring, payload-backed tile assets and the ownership of generated payload files, root layer generation, and stage/layer validation. | @@ -32,7 +32,7 @@ modules implement is fixed in | `usdPointCloudTiling` | `libs/usd-pointcloud-tiling` | plain CMake/OpenStrata static library | implemented | Format-independent fixed-grid partitioning, spill-backed bounded-memory routing, deterministic tile and LOD ordering, validated tile manifest serialization, spool validation, and cleanup contracts. See the [streaming and tiling plan](../roadmap/streaming-and-tiling.md). | | `usdGeoCache` | `libs/usd-geo-cache` | plain CMake/OpenStrata static library | implemented | Descriptor-based stable cache keys, deterministic USDC root/payload layout, machine-readable lookup states, process-local lookup statistics, the stable cache-decision vocabulary, and entry invalidation. Entries are addressed by a generation key over what the caller asked for and a source identity key over everything read out of the source, so revisions of one source are siblings. The conversion tool owns generation and atomic publication; direct FileFormat adapters reuse committed entries through `USDGEO_CACHE_ROOT`. | | `usdPly` | `libs/usd-ply` | plain CMake/OpenStrata static library | implemented | PLY 1.0 header inspection, scalar vertex decoding, source filters, and explicit georeference conversion into shared point-cloud assets. | -| `usdAsciiPoints`, `usdE57` | `libs/` | plain libraries | reserved, not implemented | Additional point-cloud readers targeting the same shared contracts. | +| `usdAsciiPoints`, `usdE57` | `libs/` | plain libraries | reserved, not implemented | Additional point-cloud readers targeting the same shared contracts. The multi-scan collection contract needed by E57 is implemented in `usdPointCloudCore`; the decoder remains reserved. | | `pointcloud-ply` | `plugins/pointcloud-ply` | OpenStrata plugin bundle (`usd-fileformat`) | implemented | Thin `.ply` adapter requiring an explicit `epsg` argument and authoring through shared point-cloud contracts. | | `pointcloud-points-text`, `pointcloud-e57` | `plugins/` | reserved, not implemented | Future point-cloud FileFormat Plugin adapters, in the order fixed by [format support order](../roadmap/format-support-order.md). | diff --git a/docs/roadmap/implementation-status.md b/docs/roadmap/implementation-status.md index 6adf4ff..c557ddb 100644 --- a/docs/roadmap/implementation-status.md +++ b/docs/roadmap/implementation-status.md @@ -192,6 +192,9 @@ transaction state creation and retrying the same output workspace. and local COPC reads onto it - [x] Add resolver-backed `ArAsset` adaptation without introducing OpenUSD or transport dependencies into `usdCopc` +- [x] Add the format-independent multi-scan collection contract required by + E57, including per-scan assets, bounds, attributes, and poses; the E57 + decoder and multi-scan authoring remain deferred ### Next release direction diff --git a/libs/usd-pointcloud-core/README.md b/libs/usd-pointcloud-core/README.md index f8710b8..00f2c2c 100644 --- a/libs/usd-pointcloud-core/README.md +++ b/libs/usd-pointcloud-core/README.md @@ -5,8 +5,9 @@ `usdPointCloudCore` defines the format-independent point-cloud contracts every reader produces and the authoring library consumes: the attribute schema, the chunk and asset shapes, read options, deterministic sampling, and the shared -LOD value types. It is the seam that lets LAS and LAZ — and later COPC, PLY, -and E57 — reach OpenUSD through one path instead of one writer per format. +LOD value types, and multi-scan collection values. It is the seam that lets +LAS and LAZ — and later COPC, PLY, and E57 — reach OpenUSD through one path +instead of one writer per format. CMake package `usdPointCloudCore`, target `usdpointcloud::core`, C++ namespace `usdpointcloud`. @@ -18,6 +19,10 @@ CMake package `usdPointCloudCore`, target `usdpointcloud::core`, C++ namespace - `PointData` — the parallel per-attribute arrays, including RGB, NIR, GPS time, waveform packet fields, and named scalar Extra Bytes columns. - `PointCloudAsset` — a `GeoReference`, bounds, chunk, and data together. +- `PointCloudScan` — a named asset and its row-major rigid scan-to-collection + pose. +- `PointCloudCollection` — shared georeference and bounds for multiple scans; + collection bounds must contain every pose-transformed scan bound. - `MakePointChunk`, which derives a chunk schema from populated `PointData`. - Point data validation: array lengths agree, values are finite, the schema matches what is populated. @@ -44,7 +49,7 @@ CMake package `usdPointCloudCore`, target `usdpointcloud::core`, C++ namespace ## Public API ```text -usdpointcloud/PointCloud.h attributes, chunks, data, assets, read options +usdpointcloud/PointCloud.h attributes, chunks, data, assets, scans, read options usdpointcloud/Lod.h tile ids, LOD items, hierarchies, validation usdpointcloud/Sampling.h deterministic sampling and LOD asset building usdpointcloud/FileFormatArguments.h argument parsing, normalization, attribute selection @@ -89,6 +94,11 @@ PointChunk (+ GeoReference, SpatialBounds) -> PointCloudAsset PointLodHierarchy + per-level PointCloudAsset v usdPointCloudAuthoring + +E57 Data3D scans + | per-scan asset, bounds, attributes, and pose + v +PointCloudCollection ``` Sampling preserves source order and applies the same selected indices to every @@ -147,6 +157,8 @@ ctest --test-dir build -C Release -R usdPointCloudCore_unit --output-on-failure so adding an attribute is a source change in this module. - The whole-cloud shape (`PointCloudAsset`) still assumes the data fits in memory; format readers expose the bounded-memory path through `PointStream`. +- E57 decoding and multi-scan OpenUSD authoring are not implemented yet; the + collection types only establish the format-independent contract. ## Planned work diff --git a/libs/usd-pointcloud-core/include/usdpointcloud/PointCloud.h b/libs/usd-pointcloud-core/include/usdpointcloud/PointCloud.h index cb64f1f..004d0f0 100644 --- a/libs/usd-pointcloud-core/include/usdpointcloud/PointCloud.h +++ b/libs/usd-pointcloud-core/include/usdpointcloud/PointCloud.h @@ -4,6 +4,7 @@ #include "usdgeo/GeoReference.h" #include "usdgeo/SpatialBounds.h" +#include #include #include #include @@ -84,6 +85,26 @@ struct PointCloudAsset { bool IsValid() const noexcept; }; +struct PointCloudScan { + std::string id; + PointCloudAsset asset; + std::array pose{ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0}; + + bool IsValid() const noexcept; +}; + +struct PointCloudCollection { + usdgeo::GeoReference reference; + usdgeo::SpatialBounds bounds = usdgeo::SpatialBounds::Empty(); + std::vector scans; + + bool IsValid() const noexcept; +}; + PointChunk MakePointChunk(const PointData& data, const usdgeo::SpatialBounds& bounds); diff --git a/libs/usd-pointcloud-core/src/PointCloud.cpp b/libs/usd-pointcloud-core/src/PointCloud.cpp index 1d67164..6a4b319 100644 --- a/libs/usd-pointcloud-core/src/PointCloud.cpp +++ b/libs/usd-pointcloud-core/src/PointCloud.cpp @@ -1,6 +1,7 @@ #include "usdpointcloud/PointCloud.h" #include +#include #include #include @@ -152,6 +153,113 @@ bool PointCloudAsset::IsValid() const noexcept { chunk.IsValid() && chunk.pointCount == data.positions.size(); } +namespace { + +constexpr double kPoseTolerance = 1.0e-9; + +bool IsRigidPose(const std::array& pose) { + if (!std::all_of(pose.begin(), pose.end(), + [](double value) { return std::isfinite(value); }) || + std::abs(pose[12]) > kPoseTolerance || + std::abs(pose[13]) > kPoseTolerance || + std::abs(pose[14]) > kPoseTolerance || + std::abs(pose[15] - 1.0) > kPoseTolerance) { + return false; + } + + const auto dot = [&](int firstRow, int secondRow) { + double result = 0.0; + for (int column = 0; column != 3; ++column) { + result += pose[firstRow * 4 + column] * + pose[secondRow * 4 + column]; + } + return result; + }; + for (int row = 0; row != 3; ++row) { + if (std::abs(dot(row, row) - 1.0) > kPoseTolerance) { + return false; + } + for (int otherRow = row + 1; otherRow != 3; ++otherRow) { + if (std::abs(dot(row, otherRow)) > kPoseTolerance) { + return false; + } + } + } + + const auto determinant = + pose[0] * (pose[5] * pose[10] - pose[6] * pose[9]) - + pose[1] * (pose[4] * pose[10] - pose[6] * pose[8]) + + pose[2] * (pose[4] * pose[9] - pose[5] * pose[8]); + return std::abs(determinant - 1.0) <= kPoseTolerance; +} + +usdgeo::Vec3d ApplyPose(const std::array& pose, + const usdgeo::Vec3d& point) { + return {pose[0] * point.x + pose[1] * point.y + pose[2] * point.z + + pose[3], + pose[4] * point.x + pose[5] * point.y + pose[6] * point.z + + pose[7], + pose[8] * point.x + pose[9] * point.y + pose[10] * point.z + + pose[11]}; +} + +bool ContainsTransformedBounds(const usdgeo::SpatialBounds& container, + const PointCloudScan& scan) { + for (int x = 0; x != 2; ++x) { + for (int y = 0; y != 2; ++y) { + for (int z = 0; z != 2; ++z) { + const usdgeo::Vec3d corner{ + x == 0 ? scan.asset.bounds.minimum.x + : scan.asset.bounds.maximum.x, + y == 0 ? scan.asset.bounds.minimum.y + : scan.asset.bounds.maximum.y, + z == 0 ? scan.asset.bounds.minimum.z + : scan.asset.bounds.maximum.z}; + const auto transformed = ApplyPose(scan.pose, corner); + const auto tolerance = 1.0e-9 * + (std::max)({1.0, std::abs(container.minimum.x), + std::abs(container.minimum.y), + std::abs(container.minimum.z), + std::abs(container.maximum.x), + std::abs(container.maximum.y), + std::abs(container.maximum.z), + std::abs(transformed.x), + std::abs(transformed.y), + std::abs(transformed.z)}); + if (transformed.x < container.minimum.x - tolerance || + transformed.y < container.minimum.y - tolerance || + transformed.z < container.minimum.z - tolerance || + transformed.x > container.maximum.x + tolerance || + transformed.y > container.maximum.y + tolerance || + transformed.z > container.maximum.z + tolerance) { + return false; + } + } + } + } + return true; +} + +} // namespace + +bool PointCloudScan::IsValid() const noexcept { + return !id.empty() && asset.IsValid() && IsRigidPose(pose); +} + +bool PointCloudCollection::IsValid() const noexcept { + if (!reference.IsValid() || !bounds.IsValid() || scans.empty()) { + return false; + } + std::set ids; + for (const auto& scan : scans) { + if (!scan.IsValid() || !ids.insert(scan.id).second || + !ContainsTransformedBounds(bounds, scan)) { + return false; + } + } + return true; +} + PointChunk MakePointChunk(const PointData& data, const usdgeo::SpatialBounds& bounds) { PointChunk chunk; diff --git a/libs/usd-pointcloud-core/tests/test_point_cloud.cpp b/libs/usd-pointcloud-core/tests/test_point_cloud.cpp index 18ea39c..3beae03 100644 --- a/libs/usd-pointcloud-core/tests/test_point_cloud.cpp +++ b/libs/usd-pointcloud-core/tests/test_point_cloud.cpp @@ -69,6 +69,49 @@ void TestPointDataAndAssetChunk() { Check(!data.IsValid()); } +void TestMultiScanContract() { + usdgeo::GeoReference reference; + reference.epsgCode = 4978; + reference.localOrigin = {10.0, 20.0, 30.0}; + + usdpointcloud::PointData data; + data.positions = {{1.0, 2.0, 3.0}}; + usdgeo::SpatialBounds bounds; + bounds.Expand(data.positions.front()); + + usdpointcloud::PointCloudScan first; + first.id = "scan-1"; + first.asset = {reference, bounds, + usdpointcloud::MakePointChunk(data, bounds), data}; + Check(first.IsValid()); + + usdpointcloud::PointCloudScan second = first; + second.id = "scan-2"; + second.pose[3] = 4.0; + Check(second.IsValid()); + + usdpointcloud::PointCloudCollection collection; + collection.reference = reference; + collection.bounds = bounds; + collection.scans = {first, second}; + Check(!collection.IsValid()); + + collection.bounds.maximum.x = 5.0; + Check(collection.IsValid()); + + second.id = first.id; + collection.scans[1] = second; + Check(!collection.IsValid()); + + collection.scans[1] = first; + collection.scans[1].pose[15] = 2.0; + Check(!collection.scans[1].IsValid()); + + collection.scans[1] = first; + collection.scans[1].pose[0] = 0.5; + Check(!collection.scans[1].IsValid()); +} + void TestExtraByteNameNormalization() { const auto names = usdpointcloud::NormalizeExtraByteNames( {"air temperature", "2nd-return", "intensity", "air-temperature", @@ -487,6 +530,7 @@ int main() { TestAttributesAndChunks(); TestInvalidChunk(); TestPointDataAndAssetChunk(); + TestMultiScanContract(); TestExtraByteNameNormalization(); TestReadOptions(); TestPointStreamContract();