From 063050e2cdc81508515e2f47e912bcc516ef6888 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Tue, 13 Oct 2026 19:58:23 +0200 Subject: [PATCH] feat(sdk): builtin field tables, unprojectPixel and ImageAnnotations additions (0.36.0) The header-only part of the derived-objects SDK work, split out of #202 so it can be released before the host contract, which stays in #202 for the next minor. - Builtin field tables (pj_base/builtin/*_fields.hpp, field_table.hpp, field_table_registry.hpp): one generic description of each builtin struct's members, so script binders and agents see the same fields. Buffer layouts never report more records than their bytes hold, and numeric setters saturate instead of converting out of range. - PinholeIntrinsics / unprojectPixel (depth_image_utils.hpp): unproject through the full inverse of K, validated once per image. Skew and projective terms are honoured, so a float32-rounded K is accepted. - ImageAnnotations: an optional timestamp and image topic on the wire (additive; a 0.35 reader skips them). - Object-topic metadata: pj_snapshot, and pj_derived documented as an open value set (the key means "produced by a host data processor", the value names the processor kind). Host contract: unchanged (no floor impact). Co-Authored-By: Claude Opus 5.5 --- .../references/builtin-objects.md | 6 +- CHANGELOG.md | 32 + VERSION | 2 +- docs/builtin_type.md | 47 + docs/image_annotations_format.md | 18 +- docs/sdk-utilities.md | 2 + pj_base/CLAUDE.md | 3 +- pj_base/CMakeLists.txt | 1 + .../pj_base/builtin/camera_info_fields.hpp | 39 + .../pj_base/builtin/depth_image_fields.hpp | 95 ++ .../pj_base/builtin/depth_image_utils.hpp | 69 ++ .../include/pj_base/builtin/field_table.hpp | 496 ++++++++ .../pj_base/builtin/field_table_registry.hpp | 66 ++ .../builtin/frame_transforms_fields.hpp | 84 ++ .../builtin/image_annotations_codec.hpp | 5 +- .../builtin/image_annotations_fields.hpp | 88 ++ .../include/pj_base/builtin/image_fields.hpp | 129 +++ .../pj_base/builtin/point_cloud_fields.hpp | 82 ++ .../pj_base/builtin/scene_entities_fields.hpp | 212 ++++ .../pj_base/builtin/video_frame_fields.hpp | 55 + .../pj_base/sdk/object_topic_metadata.hpp | 37 + pj_base/proto/pj/ImageAnnotations.proto | 4 + pj_base/src/builtin/geometry_codec.hpp | 15 +- .../src/builtin/image_annotations_codec.cpp | 59 +- pj_base/tests/depth_image_codec_test.cpp | 92 ++ pj_base/tests/field_table_test.cpp | 1017 +++++++++++++++++ .../tests/image_annotations_codec_test.cpp | 118 +- pj_base/tests/object_topic_metadata_test.cpp | 39 + 28 files changed, 2862 insertions(+), 50 deletions(-) create mode 100644 pj_base/include/pj_base/builtin/camera_info_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/depth_image_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/field_table.hpp create mode 100644 pj_base/include/pj_base/builtin/field_table_registry.hpp create mode 100644 pj_base/include/pj_base/builtin/frame_transforms_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/image_annotations_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/image_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/point_cloud_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/scene_entities_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/video_frame_fields.hpp create mode 100644 pj_base/tests/field_table_test.cpp diff --git a/.claude/skills/plotjuggler-plugin/references/builtin-objects.md b/.claude/skills/plotjuggler-plugin/references/builtin-objects.md index 8c62eda4..21aced34 100644 --- a/.claude/skills/plotjuggler-plugin/references/builtin-objects.md +++ b/.claude/skills/plotjuggler-plugin/references/builtin-objects.md @@ -114,10 +114,8 @@ exactly once. Do not keep a raw pointer into anything the closure captured. - `ImageAnnotations.image_topic` explicitly names the base image topic; camera calibration correlates by **name convention** (`/camera_info` ↔ `/image_raw`), not by a reference. -- **Association fields ride *outside* the serialized bytes.** The annotations - codec deliberately does not encode `image_topic` (or a timestamp) into the - payload — the store keys entries by (topic, timestamp), and association is a - topic/metadata convention. Don't expect a codec round-trip to carry them. +- The annotations codec carries the top-level `timestamp` (only when non-zero) and + `image_topic` (only when non-empty); see `docs/image_annotations_format.md`. ## Codec round-trips are not bit-exact diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e97156e..dc00f511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [0.36.0] + +Host contract: unchanged (no floor impact) — client-side helpers and an additive codec +change, no new host surface. + +Builtin objects (client-side): + +- Add compile-time field tables (`pj_base/builtin/field_table.hpp`, one + `_fields.hpp` per tabled struct, `describe(BuiltinObjectType)` in + `field_table_registry.hpp`) for `FrameTransforms`, `ImageAnnotations`, `PointCloud`, + `SceneEntities`, `Image`, `DepthImage`, `CameraInfo` and `VideoFrame`: a generic + binder reads and writes any described field by name. Packed payloads are `kBuffer` + descriptors; nullable numbers are `kOptionalNumber`; `std::array` members are + fixed-size `kList`s written through `list_replace`. A `kBuffer` layout never promises + more records than its bytes hold (`record_count` is 0 otherwise), and `set_number` + saturates to the target integer type (NaN becomes 0) instead of an undefined + float-to-integer conversion. +- Add `sdk::PinholeIntrinsics` and `sdk::unprojectPixel` (`depth_image_utils.hpp`): + unproject a pixel with positive metric depth through K^-1, validating K once per + image. Skew and projective terms are honoured (a float32-rounded K is accepted); + only a non-finite or singular K is rejected. Distortion correction stays the + caller's job. +- Add the object-topic metadata keys `pj_snapshot` + (`ObjectTopicMetadataBuilder::snapshot`: every entry is a complete clear-and-replace + snapshot) and `pj_derived` (`kDerivedMetadataKey`; set by a host on topics produced + by a host data processor, the value names the processor kind, an open set: + `kDerivedOnDemandValue` is `"on_demand"`). +- The ImageAnnotations wire carries the top-level `timestamp` and `image_topic`; + additive, old readers skip them. +- Fix the shared Timestamp decoder rejecting `INT64_MIN` nanoseconds (and anything within + one second of it): a payload our own encoder wrote failed to decode. + ## [0.35.0] Host contract: unchanged (no floor impact) — a client-side codec helper, no new diff --git a/VERSION b/VERSION index 7b52f5e5..93d4c1ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.0 +0.36.0 diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 7c212713..78ea5510 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -673,6 +673,53 @@ splice form and decodes with an empty span; the host runs `validateGridMap()` right after attaching the spliced bytes, and consumers that index cells run it again defensively. +## Field tables + +`pj_base/builtin/field_table.hpp` describes a builtin struct's members at +compile time — name, shape (`FieldKind`), and type-erased get/set accessors — +so a generic consumer (e.g. a script binder) can read or write any described +field by name without hand-written per-type glue. A struct opts in with a +`FieldTable` specialization built from `field<&T::member>("member")` calls +(a `kBuffer` factory such as `pointCloudDataField()` for a packed record buffer +like `PointCloud::data`); nested +structs and lists of described structs link to their own table through +`FieldDescriptor::nested`, so a consumer walks an arbitrarily deep struct tree +with one generic recursive routine. `field_table_registry.hpp` exposes +`describe(BuiltinObjectType)` to look up a table from the runtime tag a +`BuiltinObject` carries. + +Each tabled builtin has a `_fields.hpp` header; `describe()` in +`field_table_registry.hpp` is the authoritative list. `Image::data`, +`DepthImage::data`, and `VideoFrame::data` each expose their packed pixel / +bitstream bytes through a `kBuffer` descriptor the same way `PointCloud::data` +does, with the per-record layout derived from the encoding/format string +rather than a channel list (see each header's doc comment for how a raw vs. +compressed encoding differs). `CameraInfo`'s `K`/`R`/`P` (and `DepthImage`'s +`K`) are `std::array` members, described as a fixed-size `kList` +written through `list_replace` rather than `list_emplace`/`list_clear` (the +element count never changes); `Image::compressed_depth_min`/ +`compressed_depth_max` are `kOptionalNumber` — a nullable number read/written +like `kNumber` but guarded by `has_value()`. A field table is hand-maintained +alongside its struct: adding, renaming, or retyping a member requires updating the matching +`FieldTable` specialization in the same change, or the table silently +drifts from the struct it claims to describe. `pj_base/tests/field_table_test.cpp` +enforces this — its generic copy-through-table round trip is compared against +the struct's own `operator==` and its canonical wire-codec bytes, so a member +the table forgot to list shows up as a round-trip mismatch rather than a +silent gap. + +An object topic carrying a field-tabled type can mark itself with the +`pj_snapshot` object-topic metadata key (`PJ::sdk::kSnapshotMetadataKey`, +`ObjectTopicMetadataBuilder::snapshot()`, SDK 0.36.0). Value `"true"` means +every entry on the topic is a complete clear-and-replace snapshot — a +generic field-table consumer may render each entry alone, without replaying +the topic's history to reconstruct state first. This matters for +SceneEntities/ImageAnnotations producers in particular, whose entries can +otherwise be incremental (add/update/delete deltas); a producer that instead +republishes its whole set each time sets this flag so a stateless consumer +(and `set_object_topic_retention(topic, 1)`, which keeps only the latest +snapshot) can treat each entry as the full picture. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index 0fd54634..0e121f8a 100644 --- a/docs/image_annotations_format.md +++ b/docs/image_annotations_format.md @@ -66,16 +66,17 @@ the renderable annotation fields and ignores the rest: | `ImageAnnotations.circles` | Mapped to `ImageAnnotations::circles`. | | `ImageAnnotations.points` | Mapped to `ImageAnnotations::points`. | | `ImageAnnotations.texts` | Mapped to `ImageAnnotations::texts`. | -| Top-level `timestamp` | Not serialized or decoded today. | +| Top-level `timestamp` | Serialized when non-zero, decoded into `ImageAnnotations::timestamp`; a timestamp of 0 is indistinguishable from an absent one. | +| `image_topic` (field 6, string) | Serialized when non-empty, decoded into `ImageAnnotations::image_topic`. | | Top-level `metadata` | Not serialized or decoded today. | | Per-annotation `timestamp` | Not serialized or decoded today. | | Per-annotation `metadata` | Not serialized or decoded today. | | `TextAnnotation.background_color` | Not represented by the SDK type; skipped on decode and not emitted on encode. | -`PJ::sdk::ImageAnnotations::image_topic` is also not part of this payload. It is -runtime association metadata used by PlotJuggler to attach overlays to an image -stream. Adapters that need to preserve it across storage or transport must store -it outside the `PJ.ImageAnnotations` bytes. +`PJ::sdk::ImageAnnotations::image_topic` is part of the payload: it is written +as field 6 when non-empty, so runtime association between an annotation set +and its image stream can be carried on the wire. A reader predating this +addition ignores the unknown field. ## Codec Rules @@ -100,9 +101,10 @@ The writer never emits `UNKNOWN`/`0`. The reader maps unknown or zero topology values to `kPoints`, so incomplete or forward-compatible payloads still decode to a renderable annotation. -An annotation value with no circles, points, or texts serializes to an empty byte -buffer. Decoding an empty buffer is treated as invalid input by the current -reader. +An annotation value serializes to an empty byte buffer only when it has no circles, +points, or texts, a zero `timestamp`, and an empty `image_topic`; one carrying just a +non-zero `timestamp` and/or an `image_topic` produces a non-empty payload. Decoding an +empty buffer is treated as invalid input by the current reader. The reader accepts protobuf wire types `VARINT`, `I64`, `LEN`, and `I32`. It decodes the mapped fields and skips unknown fields, including unknown nested diff --git a/docs/sdk-utilities.md b/docs/sdk-utilities.md index 16b09913..0ab3413f 100644 --- a/docs/sdk-utilities.md +++ b/docs/sdk-utilities.md @@ -60,6 +60,8 @@ CMake helpers become available through `find_package(plotjuggler_sdk)`. | `PJ::sdk::MediaMetadataBuilder, ObjectTopicMetadataBuilder` | Build media hints and canonical renderer metadata | `pj_base/sdk/media_metadata.hpp; pj_base/sdk/object_topic_metadata.hpp` | | `PJ::sdk::ObjectBytes` | Own object-read bytes through move-only RAII | `pj_base/sdk/object_bytes.hpp` | | `PJ::sdk::ArrowSchemaHolder, ArrowArrayHolder, ArrowStreamHolder` | Own Arrow out-params; successful writes transfer ownership, failed writes retain it | `pj_base/sdk/arrow.hpp` | +| `PJ::sdk::FieldTable, field<&T::member>, describe` | Read/write builtin struct members by name in a generic binder; lists are element-addressable | `pj_base/builtin/field_table.hpp; pj_base/builtin/field_table_registry.hpp` | +| `PJ::sdk::PinholeIntrinsics, unprojectPixel` | Unproject rectified metric depth pixels; validate K once per image | `pj_base/builtin/depth_image_utils.hpp` | | `pj_embed_file, pj_configure_plugin` | Embed UI/manifest assets and configure plugin exports/build rules | `cmake/PjPlugin.cmake` | Outside this SDK, reuse pj-official-plugins common/: diff --git a/pj_base/CLAUDE.md b/pj_base/CLAUDE.md index 7957b0a0..ffe0bdde 100644 --- a/pj_base/CLAUDE.md +++ b/pj_base/CLAUDE.md @@ -18,7 +18,8 @@ build details. It must not depend on Qt, `pj_datastore` or `pj_plugins`. - `include/pj_base/builtin/` — 18 builtin struct headers (`*.hpp`) and all 18 wire codecs (`*_codec.hpp`). Numeric tags are stable; values 2 and 12 are permanently reserved. Also contains the tagged, type-erased `BuiltinObject` - holder and type-erased codec dispatcher. + holder and type-erased codec dispatcher, and the compile-time field tables + (`field_table.hpp`, one `_fields.hpp` per tabled struct). - `include/pj_base/sdk/` — C++ SDK over the ABI: DataSource + Toolbox `*_plugin_base.hpp`, `service_registry.hpp`/`service_traits.hpp`, host views, Arrow RAII holders, `testing/`. - `include/pj_base/*_protocol.h`, `plugin_data_api.h`, `builtin_object_abi.h`, `plugin_abi_export.hpp` — the stable C-ABI surface for DataSource/MessageParser/Toolbox (the Dialog protocol header lives in `pj_plugins/dialog_protocol/`). - `proto/pj/` — canonical `.proto` wire contracts for the builtin types (see its README). diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index e1b3075d..7a524252 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -186,6 +186,7 @@ if(PJ_BUILD_TESTS) tests/frame_transforms_codec_test.cpp tests/image_annotations_codec_test.cpp tests/image_annotations_decoder_test.cpp + tests/field_table_test.cpp tests/media_metadata_test.cpp tests/object_topic_metadata_test.cpp tests/push_message_test.cpp diff --git a/pj_base/include/pj_base/builtin/camera_info_fields.hpp b/pj_base/include/pj_base/builtin/camera_info_fields.hpp new file mode 100644 index 00000000..2ee36b63 --- /dev/null +++ b/pj_base/include/pj_base/builtin/camera_info_fields.hpp @@ -0,0 +1,39 @@ +/** + * @file camera_info_fields.hpp + * @brief `FieldTable` specialization for `camera_info.hpp`'s `CameraInfo`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/camera_info.hpp" +#include "pj_base/builtin/field_table.hpp" + +namespace PJ::sdk { + +/// Fields of `CameraInfo`. `K` and `R` are fixed-size lists of 9 numbers, +/// `P` a fixed-size list of 12 (`std::array` — see +/// `field_table.hpp`'s `kList` fixed-size case, written through +/// `list_replace` rather than `list_emplace`/`list_clear`); `D` is a +/// growable list of numbers (`std::vector`, size depends on +/// `distortion_model`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CameraInfo::timestamp_ns>("timestamp_ns"), + field<&CameraInfo::frame_id>("frame_id"), + field<&CameraInfo::width>("width"), + field<&CameraInfo::height>("height"), + field<&CameraInfo::distortion_model>("distortion_model"), + field<&CameraInfo::D>("D"), + field<&CameraInfo::K>("K"), + field<&CameraInfo::R>("R"), + field<&CameraInfo::P>("P"), + }; + static constexpr FieldTableView view{"CameraInfo", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/depth_image_fields.hpp b/pj_base/include/pj_base/builtin/depth_image_fields.hpp new file mode 100644 index 00000000..47a6ace9 --- /dev/null +++ b/pj_base/include/pj_base/builtin/depth_image_fields.hpp @@ -0,0 +1,95 @@ +/** + * @file depth_image_fields.hpp + * @brief `FieldTable` specialization for `depth_image.hpp`'s `DepthImage`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/builtin/depth_image.hpp" +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +namespace detail { + +/// Pixel datatype of a `DepthImage::encoding` value, or `kUnknown` when the +/// string is not one of the two encodings `depth_image.hpp`'s doc comment +/// documents ("16UC1" — millimeters as uint16 — or "32FC1" — meters as +/// float32). `DepthImage::encoding` is an open string (like `Image::encoding`), +/// so an unrecognized value is expected, not an error. +[[nodiscard]] constexpr PointField::Datatype depthImageDatatype(std::string_view encoding) noexcept { + if (encoding == "16UC1") { + return PointField::Datatype::kUint16; + } + if (encoding == "32FC1") { + return PointField::Datatype::kFloat32; + } + return PointField::Datatype::kUnknown; +} + +} // namespace detail + +/// `kBuffer` descriptor for `DepthImage::data`. Unlike `Image`, `DepthImage` +/// has no `row_step` member, so `buffer()` derives it as `width * bpp` (no +/// row padding representable) whenever `bpp` (from +/// `bytesPerElement(detail::depthImageDatatype(encoding))`) is known; an unrecognized +/// encoding reports `record_step = row_step = 0` and `bytes` alone (its full +/// span) is the payload — same convention as `imageDataField()` +/// (`image_fields.hpp`); a declared extent larger than `data` also reports 0 +/// records. `channels` always carries exactly one entry naming +/// the encoding string. `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor depthImageDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& image = *static_cast(p); + const PointField::Datatype datatype = detail::depthImageDatatype(image.encoding); + const uint32_t bpp = bytesPerElement(datatype); + BufferLayout layout; + layout.bytes = image.data; + layout.record_step = bpp; + const bool fits = detail::rowsFitIn(image.height, static_cast(image.width) * bpp, 0, image.data.size()); + layout.record_count = bpp == 0 || !fits ? 0 : static_cast(image.width) * image.height; + layout.row_step = bpp == 0 ? 0 : image.width * bpp; + layout.is_bigendian = false; // DepthImage carries no endianness member; native in-memory doubles/ints. + layout.channels.push_back( + BufferLayout::Channel{ + .name = image.encoding, + .offset = 0, + .datatype = static_cast(datatype), + .count = bpp == 0 ? 0 : uint32_t{1}}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `DepthImage`: every member is an ordinary field except `data` +/// (`depthImageDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. `K` is a fixed-size list of 9 +/// numbers (`std::array` — see `field_table.hpp`'s `kList` +/// fixed-size case); `D` is a growable list of numbers +/// (`std::vector`, size depends on `distortion_model`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&DepthImage::width>("width"), + field<&DepthImage::height>("height"), + field<&DepthImage::encoding>("encoding"), + depthImageDataField("data"), + field<&DepthImage::K>("K"), + field<&DepthImage::distortion_model>("distortion_model"), + field<&DepthImage::D>("D"), + field<&DepthImage::timestamp_ns>("timestamp_ns"), + }; + static constexpr FieldTableView view{"DepthImage", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/depth_image_utils.hpp b/pj_base/include/pj_base/builtin/depth_image_utils.hpp index e103fe56..c51f3055 100644 --- a/pj_base/include/pj_base/builtin/depth_image_utils.hpp +++ b/pj_base/include/pj_base/builtin/depth_image_utils.hpp @@ -15,12 +15,81 @@ #pragma once #include +#include +#include #include "pj_base/builtin/depth_image.hpp" namespace PJ { namespace sdk { +/// Camera matrix K reduced to its inverse, built once per image so a per-pixel loop +/// checks only its own inputs. +class PinholeIntrinsics { + public: + /// Rejects a non-finite or singular K and nothing else: skew and projective terms + /// are honoured, so a K that went through float32 is accepted. Distortion + /// correction belongs to the caller. + [[nodiscard]] static std::optional fromK(const std::array& k) noexcept { + for (double value : k) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + const double cofactor0 = k[4] * k[8] - k[5] * k[7]; + const double cofactor1 = k[5] * k[6] - k[3] * k[8]; + const double cofactor2 = k[3] * k[7] - k[4] * k[6]; + const double det = k[0] * cofactor0 + k[1] * cofactor1 + k[2] * cofactor2; + if (!std::isfinite(det) || det == 0) { + return std::nullopt; + } + const std::array inverse{ + cofactor0 / det, (k[2] * k[7] - k[1] * k[8]) / det, (k[1] * k[5] - k[2] * k[4]) / det, + cofactor1 / det, (k[0] * k[8] - k[2] * k[6]) / det, (k[2] * k[3] - k[0] * k[5]) / det, + cofactor2 / det, (k[1] * k[6] - k[0] * k[7]) / det, (k[0] * k[4] - k[1] * k[3]) / det}; + for (double value : inverse) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + return PinholeIntrinsics(inverse); + } + + /// Unproject a rectified pixel with positive metric depth: the ray K^-1 [u, v, 1] + /// scaled so the returned point's z equals `depth_m`. Rejects a non-finite result + /// or a ray with z <= 0. + [[nodiscard]] std::optional> unproject(double u, double v, double depth_m) const noexcept { + if (!std::isfinite(u) || !std::isfinite(v) || !std::isfinite(depth_m) || depth_m <= 0) { + return std::nullopt; + } + const double ray_z = k_inverse_[6] * u + k_inverse_[7] * v + k_inverse_[8]; + if (!std::isfinite(ray_z) || ray_z <= 0) { + return std::nullopt; + } + const double scale = depth_m / ray_z; + const double point_x = (k_inverse_[0] * u + k_inverse_[1] * v + k_inverse_[2]) * scale; + const double point_y = (k_inverse_[3] * u + k_inverse_[4] * v + k_inverse_[5]) * scale; + if (!std::isfinite(point_x) || !std::isfinite(point_y)) { + return std::nullopt; + } + return std::array{point_x, point_y, depth_m}; + } + + private: + explicit PinholeIntrinsics(const std::array& k_inverse) noexcept : k_inverse_(k_inverse) {} + + /// Row-major K^-1; only `fromK` builds it, so every entry is finite. + std::array k_inverse_; +}; + +/// One-pixel form of `PinholeIntrinsics::fromK(k)->unproject(u, v, depth_m)`; a loop +/// over an image should call `fromK` once instead. +[[nodiscard]] inline std::optional> unprojectPixel( + const std::array& k, double u, double v, double depth_m) noexcept { + const auto intrinsics = PinholeIntrinsics::fromK(k); + return intrinsics ? intrinsics->unproject(u, v, depth_m) : std::nullopt; +} + /// Rectification rotation. For a DepthImage with empty distortion_model /// (i.e. rectified) this returns the identity rotation. Unrectified /// depth has no canonical rectification rotation — the caller has the diff --git a/pj_base/include/pj_base/builtin/field_table.hpp b/pj_base/include/pj_base/builtin/field_table.hpp new file mode 100644 index 00000000..bf9ff02b --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table.hpp @@ -0,0 +1,496 @@ +/** + * @file field_table.hpp + * @brief Compile-time field tables describing the members of builtin object + * structs, so a generic binder (e.g. a script engine) can read/write + * any described field by name without per-type glue code. + * + * `FieldTable` is a template specialized once per describable struct + * (see `frame_transforms_fields.hpp`, `image_annotations_fields.hpp`). Each + * specialization lists its members as `FieldDescriptor`s built with + * `field<&T::member>("member")`, which deduces the member's `FieldKind` and + * generates non-capturing-lambda accessors from the member pointer alone — + * no per-field boilerplate at the call site. `field_table_registry.hpp` + * exposes `describe(BuiltinObjectType)` to look up a table from the runtime + * tag carried by a `BuiltinObject`. + * + * A table is a flat `Span`; nested structs and lists of + * described structs link to their own table through `FieldDescriptor::nested`, + * so a consumer walks an arbitrarily deep struct tree with one generic + * recursive routine (see `field_table_test.cpp` for the pattern). A packed + * record buffer (e.g. `PointCloud::data`) does not fit `field<>()`'s + * one-member-at-a-time shape — it is derived from several sibling members + * (bytes, stride, dimensions, channel layout) — so a buffer-bearing type + * instead provides its own `kBuffer` descriptor factory built directly + * against `FieldDescriptor` (e.g. `PointCloud`'s `pointCloudDataField()` in + * `point_cloud_fields.hpp`), resolving a `BufferLayout` on demand. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" + +namespace PJ::sdk { + +/// Runtime-visible shape of one field, driving which `FieldDescriptor` +/// accessors are populated. +enum class FieldKind : uint8_t { + kNumber, ///< Arithmetic member (not bool/int64/enum) — read/written as `double`. + kBool, ///< `bool` member — read/written as `double` (0.0/1.0). + kInt64, ///< `int64_t`/`uint64_t`/`Timestamp` member — read/written as `int64_t`, never `double` + ///< (a 53+ bit timestamp silently loses precision as a double). + kString, ///< `std::string` member. + kEnum, ///< `enum`/`enum class` member — read/written as its underlying integer via `double`. + kStruct, ///< Member whose type has its own `FieldTable` specialization; see `nested`. + kList, ///< `std::vector` (growable; see `list_emplace`/`list_clear`) or `std::array` + ///< (fixed-size; see `list_replace`) member; see `element_kind`/`nested` for the element shape. + kBuffer, ///< Raw record buffer (e.g. `PointCloud::data`), resolved through `BufferLayout`. A concrete + ///< describable type provides its own `kBuffer` descriptor factory (see `BufferLayout`'s doc comment). + kOptionalNumber, ///< `std::optional` member (e.g. `Image::compressed_depth_min`) — a + ///< nullable number. Presence is read through `has_value()`; the value itself, when + ///< present, is read/written as `double` through `get_number`/`set_number`, exactly + ///< like `kNumber`. Calling `get_number`/`set_number` when absent is only meaningful + ///< after a caller has checked `has_value()`; `set_number` also makes the field present. +}; + +/// Resolved view of a `kBuffer` field: a byte-packed record buffer plus a +/// PointField-style channel layout for interpreting each record. Built +/// on-demand by `FieldDescriptor::buffer()` — a fresh value each call, not a +/// pointer into a stored layout — so `channels` owns a small vector rather +/// than viewing one (`Channel`'s shape deliberately differs from the owner's +/// own channel-description type, e.g. `PointField`, so the two are not +/// safely alias-able through a reinterpreted span). Resolve once per object +/// and iterate its records from that one `BufferLayout`; `buffer()` +/// allocates the channel vector on every call, so calling it per-record is +/// wasteful. +struct BufferLayout { + /// Raw record bytes: `record_count` records of `record_step` bytes each + /// (or row-strided by `row_step` when `row_step != 0`). + Span bytes; + /// Byte length of one record. + uint32_t record_step = 0; + /// Number of records packed into `bytes`, clamped to what `bytes` holds: 0 when the + /// owner's declared size fields promise more than `bytes.size()` (channel `offset`/`count` + /// are declared values, so the consumer bounds-checks them against `record_step`). + uint64_t record_count = 0; + /// Byte length of one row when records are organized in rows; 0 when flat. + uint32_t row_step = 0; + /// True if multi-byte values inside `bytes` are big-endian. + bool is_bigendian = false; + + /// One named, typed channel packed at a fixed offset within every record. + struct Channel { + /// Channel name (e.g. "x", "intensity"). Views into the owner's own + /// channel-description member (e.g. `PointCloud::fields[i].name`), so it + /// is valid exactly as long as that owner is. + std::string_view name; + /// Byte offset of this channel within one record. + uint32_t offset = 0; + /// Wire datatype tag; codec-defined (mirrors PointField datatype values). + uint8_t datatype = 0; + /// Number of consecutive `datatype` values packed at `offset`. + uint32_t count = 1; + }; + /// Layout of every channel packed into one record of `bytes`, freshly + /// built by this call (owned, not a view — resolving it does one small + /// allocation, proportional to channel count, e.g. 4 for an XYZI cloud). + std::vector channels; +}; + +/// One field of a `FieldTable`: its name, shape, and type-erased accessors. +/// Every accessor not applicable to `kind` is `nullptr` — a consumer +/// switches on `kind` (or `element_kind` inside a `kList`) and calls only +/// the accessor group that kind documents. +struct FieldDescriptor { + /// Field name as declared in the owning struct (e.g. "translation"). + std::string_view name; + /// Shape of this field; selects which accessor group below is non-null. + FieldKind kind = FieldKind::kNumber; + /// For `kind == kList`: shape of one element. When `nested != nullptr` the + /// element is a struct and this is always `kStruct` — a consumer that + /// needs to know "struct element or scalar element" may branch on + /// `nested` first and only consult `element_kind` for the scalar case. + /// Unused (left at its default) for every other `kind`. + FieldKind element_kind = FieldKind::kNumber; + /// For `kind == kStruct`: the member type's own table. + /// For `kind == kList`: the element type's table, or `nullptr` for a scalar list. + const struct FieldTableView* nested = nullptr; + + /// Read/write a `kNumber`, `kBool`, `kEnum`, or `kOptionalNumber` field as + /// `double`. For `kOptionalNumber`, meaningful only when `has_value()` is + /// true; `set_number` also makes the field present. + double (*get_number)(const void*) = nullptr; + void (*set_number)(void*, double) = nullptr; + + /// For `kind == kOptionalNumber`: true iff the field currently holds a + /// value. `nullptr` for every other `kind`. + bool (*has_value)(const void*) = nullptr; + + /// Read/write a `kInt64` field. An underlying `uint64_t` round-trips through + /// `int64_t` via `std::bit_cast` (same 8 bytes, reinterpreted) rather than a + /// narrowing numeric conversion, so every bit pattern survives exactly. + int64_t (*get_int64)(const void*) = nullptr; + void (*set_int64)(void*, int64_t) = nullptr; + + /// Read/write a `kString` field. + std::string_view (*get_string)(const void*) = nullptr; + void (*set_string)(void*, std::string_view) = nullptr; + + /// For `kind == kStruct`: address the nested struct within the owner. + const void* (*struct_ptr)(const void*) = nullptr; + void* (*struct_ptr_mut)(void*) = nullptr; + + /// For `kind == kList`: element count and element address — populated for + /// both a growable list (`std::vector`) and a fixed-size one + /// (`std::array`). Elements are addressable one by one: a binder should + /// read `list_at(i)` lazily rather than materialize the whole list per access. + size_t (*list_size)(const void*) = nullptr; + const void* (*list_at)(const void*, size_t) = nullptr; + + /// For `kind == kList` backed by a growable `std::vector`: append-one + /// (returns the address of the newly appended element) and + /// truncate-to-empty. `nullptr` for a fixed-size list — use `list_replace` + /// instead. + void* (*list_emplace)(void*) = nullptr; + void (*list_clear)(void*) = nullptr; + + /// For `kind == kList` backed by a fixed-size `std::array` (e.g. + /// `CameraInfo::K`): overwrites element `i` in place and returns its + /// address, `i` in `[0, list_size)`. `nullptr` for a growable list — use + /// `list_emplace`/`list_clear` instead. The element count never changes + /// for such a field, so "append" and "truncate" don't apply; a generic + /// consumer branches on which of the two accessor pairs is non-null to + /// choose how to write the list (see `field_table_test.cpp`'s + /// `copyThroughTable` for the pattern). + void* (*list_replace)(void*, size_t) = nullptr; + + /// For `kind == kBuffer`: resolve the current buffer, or replace it + /// (taking ownership of the bytes and re-anchoring them). See + /// `BufferLayout`'s doc comment and a concrete factory such as + /// `pointCloudDataField()` (`point_cloud_fields.hpp`). + BufferLayout (*buffer)(const void*) = nullptr; + void (*buffer_assign)(void*, std::vector) = nullptr; +}; + +/// A described struct's field list plus its name, for diagnostics. +struct FieldTableView { + /// The described struct's type name (e.g. "FrameTransform"). + std::string_view type_name; + /// Every described member, in declaration order. + Span fields; +}; + +/// Per-type registry of field descriptors. Specialized once per describable +/// struct as: +/// ```cpp +/// template <> struct FieldTable { +/// static constexpr std::array fields{ +/// field<&Vector2::x>("x"), field<&Vector2::y>("y")}; +/// static constexpr FieldTableView view{"Vector2", Span(fields)}; +/// }; +/// ``` +/// The primary template is declared only, never defined: `HasFieldTable` +/// detects "no specialization exists for T" through that incompleteness. +template +struct FieldTable; + +namespace detail { + +/// Splits a data-member-pointer type `T C::*` into its owner (`C`) and +/// value (`T`) types, for deducing both from a `field<&C::member>` NTTP. +template +struct MemberPointerTraits; + +template +struct MemberPointerTraits { + using Owner = C; + using Value = T; +}; + +template +struct IsVector : std::false_type {}; +template +struct IsVector> : std::true_type {}; +template +inline constexpr bool kIsVector = IsVector::value; + +/// Detects `std::array` (any `E`, any `N`), for `field<>()`'s +/// fixed-size-list branch (e.g. `CameraInfo::K`). +template +struct IsArray : std::false_type {}; +template +struct IsArray> : std::true_type {}; +template +inline constexpr bool kIsArray = IsArray::value; + +/// Detects `std::optional`, for `field<>()`'s `kOptionalNumber` branch. +template +struct IsOptional : std::false_type {}; +template +struct IsOptional> : std::true_type {}; +template +inline constexpr bool kIsOptional = IsOptional::value; + +/// Always-false, but dependent on `T` — lets a `static_assert` inside the +/// final branch of an `if constexpr` chain fire only when instantiated, +/// instead of unconditionally. +template +inline constexpr bool kAlwaysFalse = false; + +/// True for every scalar type `setScalarAccessors` below knows how to +/// describe: `bool`/other arithmetic (covers `int64_t`/`uint64_t` too, +/// handled as a distinct `FieldKind` inside `setScalarAccessors`), +/// `enum`/`enum class`, and `std::string`. +template +inline constexpr bool kIsSupportedScalar = + std::is_arithmetic_v || std::is_enum_v || std::is_same_v; + +/// True iff `rows` rows of `row_bytes` data bytes, `row_stride` bytes apart (0 = tightly packed), +/// lie within `size` bytes; the last row needs no trailing padding. Never overflows, so a +/// hostile size field cannot make an oversized extent look small. +[[nodiscard]] constexpr bool rowsFitIn(uint64_t rows, uint64_t row_bytes, uint64_t row_stride, uint64_t size) { + if (rows == 0) { + return true; + } + if (row_bytes > size) { + return false; + } + const uint64_t stride = row_stride == 0 ? row_bytes : row_stride; + return stride == 0 || rows - 1 <= (size - row_bytes) / stride; +} + +/// `static_cast(value)` for a `double` fed in by a script: an integer `Target` +/// saturates at its limits and NaN becomes 0 (a plain cast is undefined out of range); +/// a floating `Target` keeps the plain cast. +template +[[nodiscard]] Target saturatingCast(double value) { + if constexpr (std::is_floating_point_v) { + return static_cast(value); + } else { + if (std::isnan(value)) { + return 0; + } + if (value <= static_cast(std::numeric_limits::min())) { + return std::numeric_limits::min(); + } + if (value >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(value); + } +} + +/// Locates a scalar value directly AT the given address — the adapter for a +/// `kList` element, where `list_at`/`list_emplace` already return a pointer +/// to the element itself. +template +struct DirectValueAccess { + static V* get(void* p) { + return static_cast(p); + } + static const V* get(const void* p) { + return static_cast(p); + } +}; + +/// Locates a scalar value at `owner->*Member` — the adapter for an ordinary +/// struct field, where the `(const) void*` a consumer passes in addresses +/// the OWNING struct, not the member. +template +struct MemberValueAccess { + using Owner = typename MemberPointerTraits::Owner; + static auto* get(void* p) { + return &(static_cast(p)->*Member); + } + static auto* get(const void* p) { + return &(static_cast(p)->*Member); + } +}; + +/// Populates `d`'s get/set accessor pair for scalar value type `V`, located +/// through `Access::get(p)`, and returns the matching `FieldKind`. Shared by +/// `field()`'s member branch (`Access = MemberValueAccess`, +/// caller assigns the result to `d.kind`) and its vector-element branch +/// (`Access = DirectValueAccess`, caller assigns the result to +/// `d.element_kind`) — the kind-deduction and lambda bodies are otherwise +/// identical between "a struct field" and "a scalar list element", so this +/// is the one place that logic is written. Only ever called for `V` with +/// `kIsSupportedScalar` true; the `field<>()` call sites gate on that +/// before calling in, so the `static_assert` below is unreachable through +/// them (kept as a safety net for a future direct caller). +template +constexpr FieldKind setScalarAccessors(FieldDescriptor& d) { + if constexpr (std::is_same_v) { + d.get_number = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_number = [](void* p, double v) { *Access::get(p) = (v != 0.0); }; + return FieldKind::kBool; + } else if constexpr (std::is_same_v) { + d.get_int64 = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_int64 = [](void* p, int64_t v) { *Access::get(p) = static_cast(v); }; + return FieldKind::kInt64; + } else if constexpr (std::is_same_v) { + // Round-trips through int64_t via bit_cast (same 8 bytes, reinterpreted) + // rather than a narrowing numeric conversion, so every bit pattern + // survives exactly — see FieldDescriptor::get_int64's doc comment. + d.get_int64 = [](const void* p) { return std::bit_cast(*Access::get(p)); }; + d.set_int64 = [](void* p, int64_t v) { *Access::get(p) = std::bit_cast(v); }; + return FieldKind::kInt64; + } else if constexpr (std::is_enum_v) { + using Underlying = std::underlying_type_t; + d.get_number = [](const void* p) { return static_cast(static_cast(*Access::get(p))); }; + d.set_number = [](void* p, double v) { *Access::get(p) = static_cast(saturatingCast(v)); }; + return FieldKind::kEnum; + } else if constexpr (std::is_arithmetic_v) { + d.get_number = [](const void* p) { return static_cast(*Access::get(p)); }; + d.set_number = [](void* p, double v) { *Access::get(p) = saturatingCast(v); }; + return FieldKind::kNumber; + } else if constexpr (std::is_same_v) { + d.get_string = [](const void* p) -> std::string_view { return *Access::get(p); }; + d.set_string = [](void* p, std::string_view v) { Access::get(p)->assign(v); }; + return FieldKind::kString; + } else { + static_assert(kAlwaysFalse, "setScalarAccessors: unsupported scalar type"); + } +} + +template +inline constexpr bool kHasFieldTableImpl = false; +/// SFINAE probe: substitution fails (leaving the primary `false` above) when +/// `FieldTable` has no specialization, since the primary template of +/// `FieldTable` is declared but never defined and so stays incomplete. +template +inline constexpr bool kHasFieldTableImpl::view)>> = true; + +/// `buffer_assign` of a builtin whose payload is a `data` view plus its `anchor`: +/// takes ownership of `bytes` (`makePayloadView`), `data` views them and `anchor` +/// keeps them alive. +template +void assignPayloadBytes(void* p, std::vector bytes) { + auto& object = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + object.data = view.bytes; + object.anchor = view.anchor; +} + +} // namespace detail + +/// True iff `FieldTable` has a specialization (i.e. `T` is describable), +/// detected without requiring the caller to define one. +template +concept HasFieldTable = detail::kHasFieldTableImpl; + +/// Builds the `FieldDescriptor` for data member `Member` (e.g. +/// `field<&Vector2::x>("x")`), deducing the owning struct and member type +/// from the member-pointer NTTP and generating non-capturing-lambda +/// accessors that cast `(const) void*` back to the owner type. `FieldKind` +/// is deduced from the member's type: +/// - `bool` -> kBool; `int64_t`/`uint64_t` (incl. `Timestamp`) -> kInt64; +/// other arithmetic -> kNumber; `enum`/`enum class` -> kEnum; +/// `std::string` -> kString. +/// - a class type `M` with `HasFieldTable` -> kStruct. +/// - `std::vector` -> kList (growable; see `list_emplace`/`list_clear`), +/// with `nested`/`element_kind` set from `E` the same way (kStruct + +/// nested table when `HasFieldTable`, otherwise the scalar +/// `FieldKind` of `E`). For a scalar `E`, the matching get_*/set_* pair +/// is ALSO populated, but operating on an ELEMENT address (as returned +/// by `list_at`/`list_emplace`), not on `owner->*Member` — the same +/// accessor a struct field of kind `E` would carry, repurposed for the +/// list's element type. +/// - `std::array` -> kList (fixed-size; see `list_replace`), `E` +/// restricted to a scalar (no struct-element fixed arrays today). Reads +/// the same way as a `std::vector` list (`list_size`/`list_at`); the +/// element count never changes, so writes go through `list_replace` +/// instead of `list_emplace`/`list_clear`. +/// - `std::optional` (`E` a non-bool arithmetic type) -> kOptionalNumber, +/// a nullable number: `has_value()` reports presence, `get_number`/ +/// `set_number` read/write the value like `kNumber` when present. +/// Any other member type fails to compile with a `static_assert`. +template +constexpr FieldDescriptor field(std::string_view name) { + using MemberPtr = decltype(Member); + using Owner = typename detail::MemberPointerTraits::Owner; + using Value = typename detail::MemberPointerTraits::Value; + + FieldDescriptor d{}; + d.name = name; + + if constexpr (detail::kIsSupportedScalar) { + d.kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsVector) { + using Element = typename Value::value_type; + d.kind = FieldKind::kList; + d.list_size = [](const void* p) -> size_t { return (static_cast(p)->*Member).size(); }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_emplace = [](void* p) -> void* { + auto& vec = static_cast(p)->*Member; + vec.emplace_back(); + return &vec.back(); + }; + d.list_clear = [](void* p) { (static_cast(p)->*Member).clear(); }; + + // A struct element links to its own table; a scalar element instead gets + // the matching get_*/set_* pair (see setScalarAccessors's doc comment), + // operating on an ELEMENT address (as returned by list_at/list_emplace) + // rather than on `owner->*Member`. Either way a generic consumer copies + // the list the same way: list_clear + list_emplace, then either recurse + // through `nested` or call the matching set_* on the element address. + if constexpr (HasFieldTable) { + d.element_kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + } else if constexpr (detail::kIsSupportedScalar) { + d.element_kind = detail::setScalarAccessors>(d); + } else { + static_assert(detail::kAlwaysFalse, "field: unsupported std::vector element type"); + } + } else if constexpr (detail::kIsArray) { + using Element = typename Value::value_type; + static_assert(detail::kIsSupportedScalar, "field: unsupported std::array element type"); + constexpr size_t kSize = std::tuple_size_v; + d.kind = FieldKind::kList; + d.list_size = [](const void*) -> size_t { return kSize; }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_replace = [](void* p, size_t i) -> void* { return &(static_cast(p)->*Member)[i]; }; + // Same scalar accessor pair a std::vector list would carry + // (see field<>()'s std::vector branch), operating on an element address. + d.element_kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsOptional) { + using Inner = typename Value::value_type; + static_assert( + std::is_arithmetic_v && !std::is_same_v, + "field: unsupported std::optional value type (must be non-bool arithmetic)"); + d.kind = FieldKind::kOptionalNumber; + d.has_value = [](const void* p) -> bool { return (static_cast(p)->*Member).has_value(); }; + d.get_number = [](const void* p) -> double { + return static_cast(*(static_cast(p)->*Member)); + }; + d.set_number = [](void* p, double v) { (static_cast(p)->*Member) = detail::saturatingCast(v); }; + } else if constexpr (HasFieldTable) { + d.kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + d.struct_ptr = [](const void* p) -> const void* { return &(static_cast(p)->*Member); }; + d.struct_ptr_mut = [](void* p) -> void* { return &(static_cast(p)->*Member); }; + } else { + static_assert( + detail::kAlwaysFalse, + "field: unsupported member type (no FieldTable and not a recognized scalar/vector/array/optional)"); + } + + return d; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/field_table_registry.hpp b/pj_base/include/pj_base/builtin/field_table_registry.hpp new file mode 100644 index 00000000..81d034ec --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table_registry.hpp @@ -0,0 +1,66 @@ +/** + * @file field_table_registry.hpp + * @brief Looks up a builtin struct's `FieldTable` from its runtime + * `BuiltinObjectType` tag, so a generic binder can go straight from + * a `BuiltinObject` to its field descriptors without a `switch` of + * its own. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/builtin_object.hpp" +#include "pj_base/builtin/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace PJ::sdk { + +/// Returns the field table for @p type, or `nullptr` for a type that has none +/// (buffer-only or not yet described) and for `kNone`. Every enumerator is +/// listed without a `default:` so `-Wswitch` flags a new `BuiltinObjectType` +/// until this function decides what it describes. +[[nodiscard]] inline const FieldTableView* describe(BuiltinObjectType type) noexcept { + switch (type) { + case BuiltinObjectType::kFrameTransforms: + return &FieldTable::view; + case BuiltinObjectType::kImageAnnotations: + return &FieldTable::view; + case BuiltinObjectType::kPointCloud: + return &FieldTable::view; + case BuiltinObjectType::kSceneEntities: + return &FieldTable::view; + case BuiltinObjectType::kImage: + return &FieldTable::view; + case BuiltinObjectType::kDepthImage: + return &FieldTable::view; + case BuiltinObjectType::kCameraInfo: + return &FieldTable::view; + case BuiltinObjectType::kVideoFrame: + return &FieldTable::view; + case BuiltinObjectType::kNone: + case BuiltinObjectType::kOccupancyGrid: + case BuiltinObjectType::kCompressedPointCloud: + case BuiltinObjectType::kMesh3D: + case BuiltinObjectType::kRobotDescription: + case BuiltinObjectType::kOccupancyGridUpdate: + case BuiltinObjectType::kLog: + case BuiltinObjectType::kPosesInFrame: + case BuiltinObjectType::kVoxelGrid: + case BuiltinObjectType::kPlotMarkers: + case BuiltinObjectType::kGridMap: + return nullptr; + } + return nullptr; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp new file mode 100644 index 00000000..ba2876b5 --- /dev/null +++ b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp @@ -0,0 +1,84 @@ +/** + * @file frame_transforms_fields.hpp + * @brief `FieldTable` specializations for `frame_transforms.hpp`'s structs, + * so a generic script binder can expose their fields by name. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms.hpp" + +namespace PJ::sdk { + +/// Fields of `Vector2`: `x`, `y`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Vector2::x>("x"), + field<&Vector2::y>("y"), + }; + static constexpr FieldTableView view{"Vector2", Span(fields)}; +}; + +/// Fields of `Vector3`: `x`, `y`, `z`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Vector3::x>("x"), + field<&Vector3::y>("y"), + field<&Vector3::z>("z"), + }; + static constexpr FieldTableView view{"Vector3", Span(fields)}; +}; + +/// Fields of `Quaternion`: `x`, `y`, `z`, `w`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Quaternion::x>("x"), + field<&Quaternion::y>("y"), + field<&Quaternion::z>("z"), + field<&Quaternion::w>("w"), + }; + static constexpr FieldTableView view{"Quaternion", Span(fields)}; +}; + +/// Fields of `Pose`: `position` (Vector3), `orientation` (Quaternion). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Pose::position>("position"), + field<&Pose::orientation>("orientation"), + }; + static constexpr FieldTableView view{"Pose", Span(fields)}; +}; + +/// Fields of `FrameTransform`: `timestamp` (int64), `parent_frame_id`, +/// `child_frame_id` (strings), `translation` (Vector3), `rotation` (Quaternion). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&FrameTransform::timestamp>("timestamp"), + field<&FrameTransform::parent_frame_id>("parent_frame_id"), + field<&FrameTransform::child_frame_id>("child_frame_id"), + field<&FrameTransform::translation>("translation"), + field<&FrameTransform::rotation>("rotation"), + }; + static constexpr FieldTableView view{"FrameTransform", Span(fields)}; +}; + +/// Fields of `FrameTransforms`: `transforms` (list of `FrameTransform`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&FrameTransforms::transforms>("transforms"), + }; + static constexpr FieldTableView view{"FrameTransforms", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/image_annotations_codec.hpp b/pj_base/include/pj_base/builtin/image_annotations_codec.hpp index 4f04bc1e..c8e92baa 100644 --- a/pj_base/include/pj_base/builtin/image_annotations_codec.hpp +++ b/pj_base/include/pj_base/builtin/image_annotations_codec.hpp @@ -17,8 +17,9 @@ inline constexpr std::string_view kSchemaImageAnnotations = "PJ.ImageAnnotations /// Serializes a sdk::ImageAnnotations to canonical PJ.ImageAnnotations wire bytes. /// -/// `timestamp` and `image_topic` are not serialized; callers that need them -/// must preserve them outside this payload. +/// The top-level `timestamp` (when non-zero) and `image_topic` (when non-empty) are +/// serialized; the payload is empty only when there are no circles, points or texts and +/// both are unset. [[nodiscard]] std::vector serializeImageAnnotations(const sdk::ImageAnnotations& ia); /// Decodes canonical PJ.ImageAnnotations wire bytes into sdk::ImageAnnotations. diff --git a/pj_base/include/pj_base/builtin/image_annotations_fields.hpp b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp new file mode 100644 index 00000000..281c3e84 --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp @@ -0,0 +1,88 @@ +/** + * @file image_annotations_fields.hpp + * @brief `FieldTable` specializations for `image_annotations.hpp`'s structs, + * so a generic script binder can expose their fields by name. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/image_annotations.hpp" + +namespace PJ::sdk { + +/// Fields of `Point2`: `x`, `y`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Point2::x>("x"), + field<&Point2::y>("y"), + }; + static constexpr FieldTableView view{"Point2", Span(fields)}; +}; + +/// Fields of `ColorRGBA`: `r`, `g`, `b`, `a` (each `uint8_t`, read/written as `double`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ColorRGBA::r>("r"), + field<&ColorRGBA::g>("g"), + field<&ColorRGBA::b>("b"), + field<&ColorRGBA::a>("a"), + }; + static constexpr FieldTableView view{"ColorRGBA", Span(fields)}; +}; + +/// Fields of `PointsAnnotation`: `topology` (enum), `points` (list of +/// `Point2`), `thickness`, `color`, `colors` (list of `ColorRGBA`), `fill_color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointsAnnotation::topology>("topology"), field<&PointsAnnotation::points>("points"), + field<&PointsAnnotation::thickness>("thickness"), field<&PointsAnnotation::color>("color"), + field<&PointsAnnotation::colors>("colors"), field<&PointsAnnotation::fill_color>("fill_color"), + }; + static constexpr FieldTableView view{"PointsAnnotation", Span(fields)}; +}; + +/// Fields of `CircleAnnotation`: `center` (Point2), `radius`, `thickness`, +/// `color`, `fill_color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CircleAnnotation::center>("center"), field<&CircleAnnotation::radius>("radius"), + field<&CircleAnnotation::thickness>("thickness"), field<&CircleAnnotation::color>("color"), + field<&CircleAnnotation::fill_color>("fill_color"), + }; + static constexpr FieldTableView view{"CircleAnnotation", Span(fields)}; +}; + +/// Fields of `TextAnnotation`: `position` (Point2), `font_size`, `color`, `text`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TextAnnotation::position>("position"), + field<&TextAnnotation::font_size>("font_size"), + field<&TextAnnotation::color>("color"), + field<&TextAnnotation::text>("text"), + }; + static constexpr FieldTableView view{"TextAnnotation", Span(fields)}; +}; + +/// Fields of `ImageAnnotations`: `timestamp` (int64), `image_topic` +/// (string), `points`, `circles`, `texts` (lists of the primitive structs above). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ImageAnnotations::timestamp>("timestamp"), field<&ImageAnnotations::image_topic>("image_topic"), + field<&ImageAnnotations::points>("points"), field<&ImageAnnotations::circles>("circles"), + field<&ImageAnnotations::texts>("texts"), + }; + static constexpr FieldTableView view{"ImageAnnotations", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/image_fields.hpp b/pj_base/include/pj_base/builtin/image_fields.hpp new file mode 100644 index 00000000..f710bf3f --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_fields.hpp @@ -0,0 +1,129 @@ +/** + * @file image_fields.hpp + * @brief `FieldTable` specialization for `image.hpp`'s `Image`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/image.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +namespace detail { + +/// Per-pixel layout of a raw (uncompressed) `Image` encoding: the datatype +/// and how many consecutive values of it make up one pixel. `bytes()` is the +/// pixel size, i.e. what `imageDataField()`'s buffer descriptor reports as +/// `record_step`. A compressed or unrecognized encoding has no static +/// per-pixel size: `kUnknown`, count 0. +struct ImagePixelLayout { + PointField::Datatype datatype = PointField::Datatype::kUnknown; + uint32_t count = 0; + + [[nodiscard]] constexpr uint32_t bytes() const noexcept { + return count * bytesPerElement(datatype); + } +}; + +/// Resolves `ImagePixelLayout` for the documented raw encodings ("rgb8", +/// "rgba8", "bgr8", "bgra8", "mono8", "mono16"); returns a zeroed +/// `ImagePixelLayout` for a compressed encoding ("jpeg", "png", "qoi", +/// "compressedDepth") or any string outside `CommonImageEncoding`'s +/// vocabulary — `Image::encoding` is an open string, so an unrecognized +/// value is expected, not an error. +[[nodiscard]] constexpr ImagePixelLayout imagePixelLayout(std::string_view encoding) noexcept { + const auto parsed = parseImageEncoding(encoding); + if (!parsed.has_value()) { + return {}; + } + switch (*parsed) { + case CommonImageEncoding::rgb8: + case CommonImageEncoding::bgr8: + return {.datatype = PointField::Datatype::kUint8, .count = 3}; + case CommonImageEncoding::rgba8: + case CommonImageEncoding::bgra8: + return {.datatype = PointField::Datatype::kUint8, .count = 4}; + case CommonImageEncoding::mono8: + return {.datatype = PointField::Datatype::kUint8, .count = 1}; + case CommonImageEncoding::mono16: + return {.datatype = PointField::Datatype::kUint16, .count = 1}; + case CommonImageEncoding::jpeg: + case CommonImageEncoding::png: + case CommonImageEncoding::qoi: + case CommonImageEncoding::compressedDepth: + return {}; + } + return {}; +} + +} // namespace detail + +/// `kBuffer` descriptor for `Image::data`. `buffer()` resolves the packed +/// pixel bytes: `record_step`/`record_count` come from `detail::imagePixelLayout(encoding)` +/// (a raw encoding's static per-pixel size times `width * height`); a +/// compressed encoding — or any string outside `CommonImageEncoding`'s +/// documented vocabulary — has no static per-pixel size, so both are +/// reported as 0 and `bytes` alone (its full span) is the payload; a raw +/// encoding whose declared extent exceeds `data` also reports 0 records. `row_step` +/// and `is_bigendian` are read straight from the struct (meaningful for raw +/// encodings only, per `image.hpp`'s doc comment). `channels` always carries +/// exactly one entry naming the encoding string, so a consumer that gets a +/// zeroed `record_step` still learns which codec/layout produced the bytes. +/// `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor imageDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& image = *static_cast(p); + const detail::ImagePixelLayout pixel = detail::imagePixelLayout(image.encoding); + BufferLayout layout; + layout.bytes = image.data; + layout.record_step = pixel.bytes(); + const bool fits = detail::rowsFitIn( + image.height, static_cast(image.width) * pixel.bytes(), image.row_step, image.data.size()); + layout.record_count = pixel.bytes() == 0 || !fits ? 0 : static_cast(image.width) * image.height; + layout.row_step = image.row_step; + layout.is_bigendian = image.is_bigendian; + layout.channels.push_back( + BufferLayout::Channel{ + .name = image.encoding, + .offset = 0, + .datatype = static_cast(pixel.datatype), + .count = pixel.count}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `Image`: every member is an ordinary field except `data` +/// (`imageDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. `compressed_depth_min`/ +/// `compressed_depth_max` are `kOptionalNumber` (nullable — see +/// `field_table.hpp`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Image::width>("width"), + field<&Image::height>("height"), + field<&Image::encoding>("encoding"), + field<&Image::row_step>("row_step"), + field<&Image::is_bigendian>("is_bigendian"), + imageDataField("data"), + field<&Image::compressed_depth_min>("compressed_depth_min"), + field<&Image::compressed_depth_max>("compressed_depth_max"), + field<&Image::timestamp_ns>("timestamp_ns"), + field<&Image::frame_id>("frame_id"), + }; + static constexpr FieldTableView view{"Image", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/point_cloud_fields.hpp b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp new file mode 100644 index 00000000..83b8a3b4 --- /dev/null +++ b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp @@ -0,0 +1,82 @@ +/** + * @file point_cloud_fields.hpp + * @brief `FieldTable` specializations for `point_cloud.hpp`'s structs. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/point_cloud.hpp" + +namespace PJ::sdk { + +/// Fields of `PointField`: `name`, `offset`, `datatype` (enum), `count`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointField::name>("name"), + field<&PointField::offset>("offset"), + field<&PointField::datatype>("datatype"), + field<&PointField::count>("count"), + }; + static constexpr FieldTableView view{"PointField", Span(fields)}; +}; + +/// `kBuffer` descriptor for `PointCloud::data`. `buffer()` resolves the packed +/// record bytes plus a per-channel layout read from `fields` / `point_step` / +/// `row_step` / `width` / `height` / `is_bigendian` (record count is +/// `width * height`, with `height == 0` read as one row; a zero `point_step`, or +/// a declared extent larger than `data`, yields zero records). `buffer_assign()` is `detail::assignPayloadBytes`. +/// `anchor` itself is not a table field. +[[nodiscard]] constexpr FieldDescriptor pointCloudDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& cloud = *static_cast(p); + BufferLayout layout; + layout.bytes = cloud.data; + layout.record_step = cloud.point_step; + const uint64_t rows = cloud.height == 0 ? 1 : cloud.height; + const bool fits = detail::rowsFitIn( + rows, static_cast(cloud.width) * cloud.point_step, cloud.row_step, cloud.data.size()); + layout.record_count = cloud.point_step == 0 || !fits ? 0 : static_cast(cloud.width) * rows; + layout.row_step = cloud.row_step; + layout.is_bigendian = cloud.is_bigendian; + layout.channels.reserve(cloud.fields.size()); + for (const PointField& f : cloud.fields) { + layout.channels.push_back( + BufferLayout::Channel{ + .name = f.name, .offset = f.offset, .datatype = static_cast(f.datatype), .count = f.count}); + } + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `PointCloud`. Every member is an ordinary field except `data` +/// (`pointCloudDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&PointCloud::width>("width"), + field<&PointCloud::height>("height"), + field<&PointCloud::point_step>("point_step"), + field<&PointCloud::row_step>("row_step"), + field<&PointCloud::is_bigendian>("is_bigendian"), + field<&PointCloud::is_dense>("is_dense"), + field<&PointCloud::frame_id>("frame_id"), + field<&PointCloud::fields>("fields"), + pointCloudDataField("data"), + field<&PointCloud::timestamp_ns>("timestamp_ns"), + }; + static constexpr FieldTableView view{"PointCloud", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/scene_entities_fields.hpp b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp new file mode 100644 index 00000000..4ffbdccf --- /dev/null +++ b/pj_base/include/pj_base/builtin/scene_entities_fields.hpp @@ -0,0 +1,212 @@ +/** + * @file scene_entities_fields.hpp + * @brief `FieldTable` specializations for `scene_entities.hpp`'s structs. + * + * `Pose`/`Vector3` are tabled by `frame_transforms_fields.hpp` and + * `ColorRGBA` by `image_annotations_fields.hpp` — `scene_entities.hpp` + * reuses those exact types (see its own file comment), so this header + * includes both rather than re-specializing `FieldTable` for them, which + * would be an ODR violation (two definitions of the same specialization). + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" // Pose, Vector3 +#include "pj_base/builtin/image_annotations_fields.hpp" // ColorRGBA +#include "pj_base/builtin/scene_entities.hpp" + +namespace PJ::sdk { + +/// Fields of `Point3`: `x`, `y`, `z`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&Point3::x>("x"), + field<&Point3::y>("y"), + field<&Point3::z>("z"), + }; + static constexpr FieldTableView view{"Point3", Span(fields)}; +}; + +/// Fields of `KeyValuePair`: `key`, `value`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&KeyValuePair::key>("key"), + field<&KeyValuePair::value>("value"), + }; + static constexpr FieldTableView view{"KeyValuePair", Span(fields)}; +}; + +/// Fields of `ArrowPrimitive`: `pose`, `shaft_length`, `shaft_diameter`, +/// `head_length`, `head_diameter`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ArrowPrimitive::pose>("pose"), + field<&ArrowPrimitive::shaft_length>("shaft_length"), + field<&ArrowPrimitive::shaft_diameter>("shaft_diameter"), + field<&ArrowPrimitive::head_length>("head_length"), + field<&ArrowPrimitive::head_diameter>("head_diameter"), + field<&ArrowPrimitive::color>("color"), + }; + static constexpr FieldTableView view{"ArrowPrimitive", Span(fields)}; +}; + +/// Fields of `CubePrimitive`: `pose`, `size`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CubePrimitive::pose>("pose"), + field<&CubePrimitive::size>("size"), + field<&CubePrimitive::color>("color"), + }; + static constexpr FieldTableView view{"CubePrimitive", Span(fields)}; +}; + +/// Fields of `SpherePrimitive`: `pose`, `size`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SpherePrimitive::pose>("pose"), + field<&SpherePrimitive::size>("size"), + field<&SpherePrimitive::color>("color"), + }; + static constexpr FieldTableView view{"SpherePrimitive", Span(fields)}; +}; + +/// Fields of `CylinderPrimitive`: `pose`, `size`, `bottom_scale`, +/// `top_scale`, `color`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&CylinderPrimitive::pose>("pose"), + field<&CylinderPrimitive::size>("size"), + field<&CylinderPrimitive::bottom_scale>("bottom_scale"), + field<&CylinderPrimitive::top_scale>("top_scale"), + field<&CylinderPrimitive::color>("color"), + }; + static constexpr FieldTableView view{"CylinderPrimitive", Span(fields)}; +}; + +/// Fields of `LinePrimitive`: `type` (enum), `pose`, `thickness`, +/// `scale_invariant`, `points` (list of `Point3`), `color`, `colors` (list +/// of `ColorRGBA`), `indices` (scalar list of `uint32_t` -> kNumber). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&LinePrimitive::type>("type"), field<&LinePrimitive::pose>("pose"), + field<&LinePrimitive::thickness>("thickness"), field<&LinePrimitive::scale_invariant>("scale_invariant"), + field<&LinePrimitive::points>("points"), field<&LinePrimitive::color>("color"), + field<&LinePrimitive::colors>("colors"), field<&LinePrimitive::indices>("indices"), + }; + static constexpr FieldTableView view{"LinePrimitive", Span(fields)}; +}; + +/// Fields of `TrianglePrimitive`: `pose`, `points` (list of `Point3`), +/// `color`, `colors` (list of `ColorRGBA`), `indices` (scalar list of +/// `uint32_t` -> kNumber). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TrianglePrimitive::pose>("pose"), field<&TrianglePrimitive::points>("points"), + field<&TrianglePrimitive::color>("color"), field<&TrianglePrimitive::colors>("colors"), + field<&TrianglePrimitive::indices>("indices"), + }; + static constexpr FieldTableView view{"TrianglePrimitive", Span(fields)}; +}; + +/// Fields of `TextPrimitive`: `pose`, `billboard`, `font_size`, +/// `scale_invariant`, `color`, `text`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&TextPrimitive::pose>("pose"), field<&TextPrimitive::billboard>("billboard"), + field<&TextPrimitive::font_size>("font_size"), field<&TextPrimitive::scale_invariant>("scale_invariant"), + field<&TextPrimitive::color>("color"), field<&TextPrimitive::text>("text"), + }; + static constexpr FieldTableView view{"TextPrimitive", Span(fields)}; +}; + +/// Fields of `AxesPrimitive`: `pose`, `length`, `thickness`, `scale_invariant`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&AxesPrimitive::pose>("pose"), + field<&AxesPrimitive::length>("length"), + field<&AxesPrimitive::thickness>("thickness"), + field<&AxesPrimitive::scale_invariant>("scale_invariant"), + }; + static constexpr FieldTableView view{"AxesPrimitive", Span(fields)}; +}; + +/// Fields of `ModelPrimitive`: `pose`, `scale`, `color`, `override_color`, +/// `url`, `media_type`, `data` (scalar list of `uint8_t` -> kNumber; a plain +/// `std::vector` member here, unlike `PointCloud::data`, which is a +/// `Span`+`BufferAnchor` pair described through a `kBuffer` descriptor such as `pointCloudDataField()` instead). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&ModelPrimitive::pose>("pose"), field<&ModelPrimitive::scale>("scale"), + field<&ModelPrimitive::color>("color"), field<&ModelPrimitive::override_color>("override_color"), + field<&ModelPrimitive::url>("url"), field<&ModelPrimitive::media_type>("media_type"), + field<&ModelPrimitive::data>("data"), + }; + static constexpr FieldTableView view{"ModelPrimitive", Span(fields)}; +}; + +/// Fields of `SceneEntity`: `timestamp` (int64), `frame_id`, `id`, +/// `lifetime_ns` (int64), `frame_locked`, `metadata` (list of +/// `KeyValuePair`), and the nine primitive lists in `SceneEntity`'s +/// declaration order (`arrows`, `cubes`, `spheres`, `cylinders`, `lines`, +/// `triangles`, `texts`, `models`, `axes`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntity::timestamp>("timestamp"), + field<&SceneEntity::frame_id>("frame_id"), + field<&SceneEntity::id>("id"), + field<&SceneEntity::lifetime_ns>("lifetime_ns"), + field<&SceneEntity::frame_locked>("frame_locked"), + field<&SceneEntity::metadata>("metadata"), + field<&SceneEntity::arrows>("arrows"), + field<&SceneEntity::cubes>("cubes"), + field<&SceneEntity::spheres>("spheres"), + field<&SceneEntity::cylinders>("cylinders"), + field<&SceneEntity::lines>("lines"), + field<&SceneEntity::triangles>("triangles"), + field<&SceneEntity::texts>("texts"), + field<&SceneEntity::models>("models"), + field<&SceneEntity::axes>("axes"), + }; + static constexpr FieldTableView view{"SceneEntity", Span(fields)}; +}; + +/// Fields of `SceneEntityDeletion`: `type` (enum), `timestamp` (int64), `id`. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntityDeletion::type>("type"), + field<&SceneEntityDeletion::timestamp>("timestamp"), + field<&SceneEntityDeletion::id>("id"), + }; + static constexpr FieldTableView view{"SceneEntityDeletion", Span(fields)}; +}; + +/// Fields of `SceneEntities`: `entities` (list of `SceneEntity`), +/// `deletions` (list of `SceneEntityDeletion`). +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&SceneEntities::entities>("entities"), + field<&SceneEntities::deletions>("deletions"), + }; + static constexpr FieldTableView view{"SceneEntities", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/video_frame_fields.hpp b/pj_base/include/pj_base/builtin/video_frame_fields.hpp new file mode 100644 index 00000000..fecd3d13 --- /dev/null +++ b/pj_base/include/pj_base/builtin/video_frame_fields.hpp @@ -0,0 +1,55 @@ +/** + * @file video_frame_fields.hpp + * @brief `FieldTable` specialization for `video_frame.hpp`'s `VideoFrame`. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/video_frame.hpp" + +namespace PJ::sdk { + +/// `kBuffer` descriptor for `VideoFrame::data`. Every `VideoFrame::format` +/// ("h264", "h265", "vp9", "av1") is a compressed bitstream with +/// inter-frame dependencies — there is no static per-record size at all, so +/// `record_step`/`record_count`/`row_step` are always 0 and `bytes` alone +/// (its full span) is the payload; `channels` carries exactly one entry +/// naming the codec (`format`), so a consumer that gets a zeroed +/// `record_step` still learns which codec produced the bytes — same +/// convention as `imageDataField()` (`image_fields.hpp`) for a compressed +/// `Image`. `buffer_assign()` is `detail::assignPayloadBytes`. +[[nodiscard]] constexpr FieldDescriptor videoFrameDataField(std::string_view name) { + FieldDescriptor d{}; + d.name = name; + d.kind = FieldKind::kBuffer; + d.buffer = [](const void* p) -> BufferLayout { + const auto& frame = *static_cast(p); + BufferLayout layout; + layout.bytes = frame.data; + layout.channels.push_back(BufferLayout::Channel{.name = frame.format, .offset = 0, .datatype = 0, .count = 0}); + return layout; + }; + d.buffer_assign = &detail::assignPayloadBytes; + return d; +} + +/// Fields of `VideoFrame`: every member is an ordinary field except `data` +/// (`videoFrameDataField`); `anchor` is written through that descriptor's +/// `buffer_assign()` and is not listed. +template <> +struct FieldTable { + static constexpr std::array fields{ + field<&VideoFrame::timestamp_ns>("timestamp_ns"), + field<&VideoFrame::frame_id>("frame_id"), + field<&VideoFrame::format>("format"), + videoFrameDataField("data"), + }; + static constexpr FieldTableView view{"VideoFrame", Span(fields)}; +}; + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp index c6ee473e..3b04a7a4 100644 --- a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp +++ b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp @@ -18,6 +18,30 @@ namespace PJ::sdk { /// @since 0.21.0 inline constexpr std::string_view kBuiltinObjectTypeMetadataKey = "builtin_object_type"; +/// Canonical metadata key marking a snapshot stream. Value `"true"` on a +/// SceneEntities/ImageAnnotations topic means EVERY entry is a complete +/// clear-and-replace snapshot, so a stateless consumer may render each entry +/// alone without accumulating state across prior entries. Absent (or any +/// other value) means entries may be incremental and a consumer must replay +/// the topic's history to reconstruct the current state. +/// @since 0.36.0 +inline constexpr std::string_view kSnapshotMetadataKey = "pj_snapshot"; + +/// Canonical metadata key marking a DERIVED object topic: one produced by a host +/// data processor rather than ingested from a data source. The value names the +/// kind of processor that produced it and is an open set: `kDerivedOnDemandValue` +/// ("on_demand") today, and a host may stamp other kinds (e.g. "markers"). A +/// consumer (a viewer, the assistant, a script author) checks the key's presence +/// for "is derived" and the value for one specific kind; it is set by the HOST, +/// never by the plugin that created the processor. Match the key by parsing the +/// metadata JSON, never by substring. +/// @since 0.36.0 +inline constexpr std::string_view kDerivedMetadataKey = "pj_derived"; + +/// The `kDerivedMetadataKey` value for a topic produced by an "on_demand" processor. +/// @since 0.36.0 +inline constexpr std::string_view kDerivedOnDemandValue = "on_demand"; + /// Builds deterministic metadata JSON for an object topic. /// /// `builtinObjectType()` accepts only the SDK enum and serializes its canonical @@ -64,6 +88,19 @@ class ObjectTopicMetadataBuilder { return *this; } + /// Mark this topic as a snapshot stream (see kSnapshotMetadataKey): every + /// entry is a complete clear-and-replace snapshot. `false` removes the key + /// again (incremental, the default). + /// @since 0.36.0 + ObjectTopicMetadataBuilder& snapshot(bool value = true) { + if (value) { + strings_.insert_or_assign(std::string(kSnapshotMetadataKey), std::string("true")); + } else { + strings_.erase(std::string(kSnapshotMetadataKey)); + } + return *this; + } + /// Serialize the accumulated metadata as a deterministic JSON object, or /// return the first contract error recorded by a setter. /// @since 0.21.0 diff --git a/pj_base/proto/pj/ImageAnnotations.proto b/pj_base/proto/pj/ImageAnnotations.proto index 83a84770..da87ad8c 100644 --- a/pj_base/proto/pj/ImageAnnotations.proto +++ b/pj_base/proto/pj/ImageAnnotations.proto @@ -32,4 +32,8 @@ message ImageAnnotations { // Additional user-provided metadata associated with the image annotations. Keys must be unique within this object. // Per-annotation metadata takes precedence over these values. repeated PJ.KeyValuePair metadata = 4; + + // Topic of the image these annotations were computed for. Runtime association carried on the wire so a derived + // or recorded annotations topic can be paired with its image. + string image_topic = 6; } diff --git a/pj_base/src/builtin/geometry_codec.hpp b/pj_base/src/builtin/geometry_codec.hpp index eeeae1f4..1cecd0f2 100644 --- a/pj_base/src/builtin/geometry_codec.hpp +++ b/pj_base/src/builtin/geometry_codec.hpp @@ -12,11 +12,11 @@ #include #include -#include #include "pj_base/builtin/frame_transforms.hpp" // Vector2, Vector3, Quaternion, Pose #include "pj_base/builtin/image_annotations.hpp" // ColorRGBA #include "pj_base/builtin/scene_entities.hpp" // Point3 +#include "pj_base/time_math.hpp" #include "pj_base/types.hpp" #include "protobuf_wire.hpp" @@ -43,18 +43,11 @@ inline TimestampParts splitTimestamp(Timestamp timestamp_ns) { } inline bool combineTimestamp(const TimestampParts& parts, Timestamp& out) { - if (parts.nanos < 0 || parts.nanos >= kNanosecondsPerSecond) { + const auto combined = combineSecondsAndNanos(parts.seconds, parts.nanos); + if (!combined) { return false; } - if (parts.seconds > std::numeric_limits::max() / kNanosecondsPerSecond || - parts.seconds < std::numeric_limits::min() / kNanosecondsPerSecond) { - return false; - } - const Timestamp seconds_ns = parts.seconds * kNanosecondsPerSecond; - if (seconds_ns > std::numeric_limits::max() - parts.nanos) { - return false; - } - out = seconds_ns + parts.nanos; + out = *combined; return true; } diff --git a/pj_base/src/builtin/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index 8481bfa0..5cf94848 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -3,20 +3,22 @@ #include "pj_base/builtin/image_annotations_codec.hpp" -#include #include #include #include #include +#include "geometry_codec.hpp" #include "protobuf_wire.hpp" namespace PJ { namespace { +using builtin_wire::normalizedToByte; using builtin_wire::Reader; using builtin_wire::Tag; using builtin_wire::WireType; +using builtin_wire::writeColor; using builtin_wire::Writer; using sdk::AnnotationTopology; using sdk::CircleAnnotation; @@ -31,13 +33,6 @@ void writePoint2(Writer& writer, const Point2& point) { writer.doubleField(2, point.y); } -void writeColor(Writer& writer, const ColorRGBA& color) { - writer.doubleField(1, static_cast(color.r) / 255.0); - writer.doubleField(2, static_cast(color.g) / 255.0); - writer.doubleField(3, static_cast(color.b) / 255.0); - writer.doubleField(4, static_cast(color.a) / 255.0); -} - uint32_t topologyToEnum(AnnotationTopology topology) { switch (topology) { case AnnotationTopology::kPoints: @@ -100,11 +95,6 @@ AnnotationTopology mapTopology(uint64_t type) { } } -uint8_t normalizedToByte(double value) { - value = std::clamp(value, 0.0, 1.0); - return static_cast(value * 255.0 + 0.5); -} - bool decodePoint2(Reader& reader, Point2& out) { while (!reader.eof()) { Tag tag; @@ -127,7 +117,8 @@ bool decodePoint2(Reader& reader, Point2& out) { return true; } -bool decodeColor(Reader& reader, ColorRGBA& out) { +// Not builtin_wire::decodeColor: annotation colours default alpha to 1 and skip unknown fields. +bool decodeAnnotationColor(Reader& reader, ColorRGBA& out) { double r = 0.0; double g = 0.0; double b = 0.0; @@ -174,9 +165,9 @@ bool readPoint2Message(Reader& reader, Point2& out) { return reader.readMessage(nested) && decodePoint2(nested, out); } -bool readColorMessage(Reader& reader, ColorRGBA& out) { +bool readAnnotationColorMessage(Reader& reader, ColorRGBA& out) { Reader nested; - return reader.readMessage(nested) && decodeColor(nested, out); + return reader.readMessage(nested) && decodeAnnotationColor(nested, out); } bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { @@ -211,7 +202,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { } case 4: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -222,7 +213,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { break; } ColorRGBA color; - if (!readColorMessage(reader, color)) { + if (!readAnnotationColorMessage(reader, color)) { return false; } out.colors.push_back(color); @@ -230,7 +221,7 @@ bool decodePointsAnnotation(Reader& reader, PointsAnnotation& out) { } case 6: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { + if (!readAnnotationColorMessage(reader, out.fill_color)) { return false; } continue; @@ -297,7 +288,7 @@ bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { break; case 5: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.fill_color)) { + if (!readAnnotationColorMessage(reader, out.fill_color)) { return false; } continue; @@ -305,7 +296,7 @@ bool decodeCircleAnnotation(Reader& reader, CircleAnnotation& out) { break; case 6: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -359,7 +350,7 @@ bool decodeTextAnnotation(Reader& reader, TextAnnotation& out) { break; case 5: if (tag.type == WireType::kLengthDelimited) { - if (!readColorMessage(reader, out.color)) { + if (!readAnnotationColorMessage(reader, out.color)) { return false; } continue; @@ -392,6 +383,13 @@ std::vector serializeImageAnnotations(const ImageAnnotations& annotatio writer.message(3, [&](Writer& nested) { writeTextAnnotation(nested, text); }); } + if (annotations.timestamp != 0) { + writer.message(5, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, annotations.timestamp); }); + } + if (!annotations.image_topic.empty()) { + writer.string(6, annotations.image_topic); + } + return out; } @@ -416,6 +414,17 @@ Expected deserializeImageAnnotations(const uint8_t* data, continue; } + // image_topic (field 6) is a plain string, not a nested message: read it + // directly off `reader` rather than through the generic readMessage() + // path below, which wraps the length-delimited payload as a sub-Reader + // for per-message field dispatch. + if (tag.field == 6) { + if (!reader.readString(annotations.image_topic)) { + return unexpected(std::string("ImageAnnotations wire: image_topic decode failed")); + } + continue; + } + Reader nested; if (!reader.readMessage(nested)) { return unexpected(std::string("ImageAnnotations wire: bad nested message length")); @@ -448,6 +457,12 @@ Expected deserializeImageAnnotations(const uint8_t* data, annotations.texts.push_back(std::move(text)); break; } + case 5: { + if (!builtin_wire::decodeTimestamp(nested, annotations.timestamp)) { + return unexpected(std::string("ImageAnnotations wire: Timestamp decode failed")); + } + break; + } default: break; } diff --git a/pj_base/tests/depth_image_codec_test.cpp b/pj_base/tests/depth_image_codec_test.cpp index f01830bb..c3f2fc7d 100644 --- a/pj_base/tests/depth_image_codec_test.cpp +++ b/pj_base/tests/depth_image_codec_test.cpp @@ -5,10 +5,15 @@ #include +#include +#include +#include #include #include +#include #include +#include "pj_base/builtin/depth_image_utils.hpp" #include "protobuf_wire_test_helpers.hpp" namespace PJ { @@ -68,5 +73,92 @@ TEST(DepthImageCodecTest, RoundTripPlumbBobDistortion) { } } +// K * p / p.z, the forward pinhole projection: the oracle for unprojection. +std::array project(const std::array& k, const std::array& p) { + const double x = k[0] * p[0] + k[1] * p[1] + k[2] * p[2]; + const double y = k[3] * p[0] + k[4] * p[1] + k[5] * p[2]; + const double z = k[6] * p[0] + k[7] * p[1] + k[8] * p[2]; + return {x / z, y / z}; +} + +// Unprojects (u, v, depth) through `k` and checks the point projects back to (u, v). +void expectReprojects(const std::array& k, double u, double v, double depth) { + const auto point = sdk::unprojectPixel(k, u, v, depth); + ASSERT_TRUE(point); + const auto reprojected = project(k, *point); + EXPECT_NEAR(reprojected[0], u, 1e-9); + EXPECT_NEAR(reprojected[1], v, 1e-9); +} + +TEST(DepthImageUtilsTest, PurePinholeMatchesTheShortFormula) { + const std::array k{525.3, 0, 319.5, 0, 524.7, 239.5, 0, 0, 1}; + const double u = 123.4; + const double v = 456.7; + const double depth = 3.21; + const auto p = sdk::unprojectPixel(k, u, v, depth); + ASSERT_TRUE(p); + const std::array expected{(u - k[2]) / k[0] * depth, (v - k[5]) / k[4] * depth, depth}; + for (size_t i = 0; i < 3; ++i) { + EXPECT_NEAR((*p)[i], expected[i], 1e-12 * std::abs(expected[i])) << "component " << i; + } +} + +TEST(DepthImageUtilsTest, AcceptsFloat32RoundedK) { + // k[8] = 1.0000001f and a float32 skew: neither is exactly 1/0 in double. + std::array k{525, static_cast(1e-4f), 319.5, 0, 525, 239.5, 0, 0, static_cast(1.0000001f)}; + ASSERT_NE(k[8], 1.0); + expectReprojects(k, 100, 50, 2); +} + +TEST(DepthImageUtilsTest, SkewedKReprojectsToThePixel) { + const std::array skewed{500, 2.5, 320, 0, 510, 240, 0, 0, 1}; + const std::array projective{500, 2.5, 320, 0.001, 510, 240, 1e-4, 2e-4, 1}; + expectReprojects(skewed, 37.5, 411.25, 1.75); + expectReprojects(projective, 37.5, 411.25, 1.75); + // A skewed K is not the pure-pinhole one: the short formula would be wrong here. + const auto p = sdk::unprojectPixel(skewed, 37.5, 411.25, 1.75); + ASSERT_TRUE(p); + EXPECT_GT(std::abs((*p)[0] - (37.5 - 320) / 500 * 1.75), 1e-3); +} + +TEST(DepthImageUtilsTest, RejectsSingularK) { + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({})); + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({0, 0, 1, 0, 4, 1, 0, 0, 1})); // fx == 0 + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1, 2, 3, 2, 4, 6, 0, 0, 1})); // proportional rows +} + +TEST(DepthImageUtilsTest, RejectsNonFiniteK) { + const double bad_values[] = {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}; + for (double bad : bad_values) { + for (size_t i = 0; i < 9; ++i) { + std::array k{2, 0, 1, 0, 4, 1, 0, 0, 1}; + k[i] = bad; + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK(k)) << "entry " << i; + } + } + // Finite entries whose 2x2 minor overflows (1e300 * 1e300): the inverse is not finite. + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1e300, 0, 0, 0, 1e300, 0, 0, 0, 1e-300})); + // Finite entries whose determinant overflows (1e110^3): the inverse would collapse to zeros. + EXPECT_FALSE(sdk::PinholeIntrinsics::fromK({1e110, 0, 0, 0, 1e110, 0, 0, 0, 1e110})); +} + +TEST(DepthImageUtilsTest, RejectsRaysBehindTheCamera) { + // K^-1 [u, v, 1] has z == -1 for every pixel. + const std::array k{1, 0, 0, 0, 1, 0, 0, 0, -1}; + const auto intrinsics = sdk::PinholeIntrinsics::fromK(k); + ASSERT_TRUE(intrinsics); + EXPECT_FALSE(intrinsics->unproject(3, 5, 2)); +} + +TEST(DepthImageUtilsTest, RejectsInvalidPixelOrDepth) { + const std::array k{2, 0, 1, 0, 4, 1, 0, 0, 1}; + ASSERT_TRUE(sdk::unprojectPixel(k, 3, 5, 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, 0)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, -1)); + EXPECT_FALSE(sdk::unprojectPixel(k, std::numeric_limits::quiet_NaN(), 5, 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, std::numeric_limits::infinity(), 2)); + EXPECT_FALSE(sdk::unprojectPixel(k, 3, 5, std::numeric_limits::quiet_NaN())); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp new file mode 100644 index 00000000..d8340388 --- /dev/null +++ b/pj_base/tests/field_table_test.cpp @@ -0,0 +1,1017 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/field_table.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/builtin/builtin_object_codec.hpp" +#include "pj_base/builtin/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table_registry.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace { + +using PJ::sdk::AnnotationTopology; +using PJ::sdk::ArrowPrimitive; +using PJ::sdk::AxesPrimitive; +using PJ::sdk::BufferLayout; +using PJ::sdk::BuiltinObjectType; +using PJ::sdk::CameraInfo; +using PJ::sdk::CircleAnnotation; +using PJ::sdk::ColorRGBA; +using PJ::sdk::CubePrimitive; +using PJ::sdk::CylinderPrimitive; +using PJ::sdk::DepthImage; +using PJ::sdk::FieldDescriptor; +using PJ::sdk::FieldKind; +using PJ::sdk::FieldTable; +using PJ::sdk::FieldTableView; +using PJ::sdk::FrameTransform; +using PJ::sdk::FrameTransforms; +using PJ::sdk::Image; +using PJ::sdk::ImageAnnotations; +using PJ::sdk::KeyValuePair; +using PJ::sdk::LinePrimitive; +using PJ::sdk::LineType; +using PJ::sdk::ModelPrimitive; +using PJ::sdk::PointCloud; +using PJ::sdk::PointField; +using PJ::sdk::PointsAnnotation; +using PJ::sdk::Pose; +using PJ::sdk::SceneEntities; +using PJ::sdk::SceneEntity; +using PJ::sdk::SceneEntityDeletion; +using PJ::sdk::SpherePrimitive; +using PJ::sdk::TextAnnotation; +using PJ::sdk::TextPrimitive; +using PJ::sdk::TrianglePrimitive; +using PJ::sdk::Vector2; +using PJ::sdk::VideoFrame; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const FieldDescriptor* findField(const FieldTableView& table, std::string_view name) { + for (const auto& f : table.fields) { + if (f.name == name) { + return &f; + } + } + return nullptr; +} + +// Walks `table` and every table reachable through `nested`, depth-first, +// visiting each distinct type name once (guards against re-visiting a type +// reachable through multiple paths, e.g. Vector3 via both Pose and FrameTransform). +void collectTables( + const FieldTableView& table, std::set& visited, std::vector& out) { + if (!visited.insert(table.type_name).second) { + return; + } + out.push_back(&table); + for (const auto& f : table.fields) { + if (f.nested != nullptr) { + collectTables(*f.nested, visited, out); + } + } +} + +// Recursively copies every described field from `src` to `dst` using only +// FieldDescriptor accessors — the same access pattern a generic script +// binder would use. A kList field either holds structs (nested != nullptr, +// recurse into the nested table) or scalars (nested == nullptr; copy via the +// get_*/set_* pair matching element_kind, applied to the element address +// returned by list_at/list_emplace for a growable list, or list_replace for +// a fixed-size one — see field_table.hpp's kList doc comment). A kBuffer +// field resolves its current bytes and re-assigns them onto dst, taking a +// fresh copy/anchor. A kOptionalNumber field copies the value only when +// present, leaving a freshly-constructed dst's absent default untouched +// otherwise. +void copyThroughTable(const FieldTableView& table, const void* src, void* dst) { + for (const auto& f : table.fields) { + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst, f.get_number(src)); + break; + case FieldKind::kInt64: + f.set_int64(dst, f.get_int64(src)); + break; + case FieldKind::kString: + f.set_string(dst, f.get_string(src)); + break; + case FieldKind::kOptionalNumber: + if (f.has_value(src)) { + f.set_number(dst, f.get_number(src)); + } + break; + case FieldKind::kStruct: + ASSERT_NE(f.nested, nullptr); + copyThroughTable(*f.nested, f.struct_ptr(src), f.struct_ptr_mut(dst)); + break; + case FieldKind::kList: { + const size_t n = f.list_size(src); + const bool fixed_size = f.list_replace != nullptr; + if (!fixed_size) { + f.list_clear(dst); + } else { + ASSERT_EQ(n, f.list_size(dst)) << "fixed-size list: src/dst element counts must match"; + } + for (size_t i = 0; i < n; ++i) { + void* dst_elem = fixed_size ? f.list_replace(dst, i) : f.list_emplace(dst); + const void* src_elem = f.list_at(src, i); + if (f.nested != nullptr) { + copyThroughTable(*f.nested, src_elem, dst_elem); + continue; + } + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst_elem, f.get_number(src_elem)); + break; + case FieldKind::kInt64: + f.set_int64(dst_elem, f.get_int64(src_elem)); + break; + case FieldKind::kString: + f.set_string(dst_elem, f.get_string(src_elem)); + break; + default: + FAIL() << "unsupported scalar element_kind in copyThroughTable"; + } + } + break; + } + case FieldKind::kBuffer: { + const BufferLayout layout = f.buffer(src); + std::vector bytes(layout.bytes.begin(), layout.bytes.end()); + f.buffer_assign(dst, std::move(bytes)); + break; + } + } + } +} + +// Serializes both objects through the canonical wire codec and expects identical +// bytes: the comparison for builtins whose payload has no meaningful operator==. +template +void expectSameWireBytes(const T& src, const T& dst) { + auto src_bytes = PJ::serializeBuiltinObject(PJ::sdk::BuiltinObject(src)); + auto dst_bytes = PJ::serializeBuiltinObject(PJ::sdk::BuiltinObject(dst)); + ASSERT_TRUE(src_bytes) << src_bytes.error(); + ASSERT_TRUE(dst_bytes) << dst_bytes.error(); + EXPECT_EQ(*src_bytes, *dst_bytes); +} + +// --------------------------------------------------------------------------- +// Sample data — populated, so every accessor path is exercised. +// --------------------------------------------------------------------------- + +FrameTransforms makeSampleFrameTransforms() { + FrameTransforms ft; + ft.transforms.push_back( + FrameTransform{ + .timestamp = 1'234'567'890'123'456, + .parent_frame_id = "map", + .child_frame_id = "base_link", + .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, + .rotation = {.x = 0.1, .y = 0.2, .z = 0.3, .w = 0.9}, + }); + ft.transforms.push_back( + FrameTransform{ + .timestamp = -42, + .parent_frame_id = "odom", + .child_frame_id = "map", + .translation = {.x = -1.5, .y = 2.5, .z = -3.5}, + .rotation = {.x = 0.0, .y = 0.0, .z = 0.707, .w = 0.707}, + }); + return ft; +} + +ImageAnnotations makeSampleImageAnnotations() { + ImageAnnotations ia; + ia.timestamp = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + ia.image_topic = "/camera/image"; + + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineStrip; // non-default (kPoints == 0) + pa.points = {{.x = 1.0, .y = 2.0}, {.x = 3.0, .y = 4.0}, {.x = 5.0, .y = 6.0}}; + pa.thickness = 3.5; + pa.color = {.r = 10, .g = 20, .b = 30, .a = 40}; + pa.colors = {{.r = 1, .g = 2, .b = 3, .a = 4}, {.r = 5, .g = 6, .b = 7, .a = 8}}; + pa.fill_color = {.r = 50, .g = 60, .b = 70, .a = 80}; + ia.points.push_back(pa); + + CircleAnnotation ca; + ca.center = {.x = 7.0, .y = 8.0}; + ca.radius = 4.0; + ca.thickness = 1.5; + ca.color = {.r = 100, .g = 110, .b = 120, .a = 130}; + ca.fill_color = {.r = 140, .g = 150, .b = 160, .a = 170}; + ia.circles.push_back(ca); + + TextAnnotation ta; + ta.position = {.x = 9.0, .y = 10.0}; + ta.font_size = 18.0; + ta.color = {.r = 200, .g = 210, .b = 220, .a = 230}; + ta.text = "hello"; + ia.texts.push_back(ta); + + return ia; +} + +// One entity carrying at least one of every SceneEntity primitive kind, plus +// metadata; the batch also carries one deletion. +SceneEntities makeSampleSceneEntities() { + SceneEntity e; + e.timestamp = 1'000'000'000'000'123; // > 2^53. + e.frame_id = "map"; + e.id = "entity-1"; + e.lifetime_ns = 5'000'000'000; + e.frame_locked = true; + e.metadata = { + KeyValuePair{.key = "k1", .value = "v1"}, + KeyValuePair{.key = "k2", .value = "v2"}, + }; + + e.arrows.push_back( + ArrowPrimitive{ + .pose = {.position = {.x = 1, .y = 2, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .shaft_length = 1.0, + .shaft_diameter = 0.1, + .head_length = 0.3, + .head_diameter = 0.2, + .color = {.r = 255, .g = 0, .b = 0, .a = 255}, + }); + + e.cubes.push_back( + CubePrimitive{ + .pose = {.position = {.x = 4, .y = 5, .z = 6}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 0, .g = 255, .b = 0, .a = 255}, + }); + + e.spheres.push_back( + SpherePrimitive{ + .pose = {.position = {.x = 7, .y = 8, .z = 9}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 2, .y = 2, .z = 2}, + .color = {.r = 0, .g = 0, .b = 255, .a = 255}, + }); + + e.cylinders.push_back( + CylinderPrimitive{ + .pose = {.position = {.x = 1, .y = 1, .z = 1}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 2}, + .bottom_scale = 0.5, + .top_scale = 0.8, + .color = {.r = 10, .g = 20, .b = 30, .a = 255}, + }); + + e.lines.push_back( + LinePrimitive{ + .type = LineType::kLineLoop, // non-default (kLineStrip == 0) + .pose = {.position = {.x = 0, .y = 0, .z = 0}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .thickness = 0.05, + .scale_invariant = true, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 1, .y = 1, .z = 0}}, + .color = {.r = 1, .g = 2, .b = 3, .a = 4}, + .colors = + {{.r = 5, .g = 6, .b = 7, .a = 8}, + {.r = 9, .g = 10, .b = 11, .a = 12}, + {.r = 13, .g = 14, .b = 15, .a = 16}}, + .indices = {0, 1, 2}, + }); + + e.triangles.push_back( + TrianglePrimitive{ + .pose = {.position = {.x = 2, .y = 2, .z = 2}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 0, .y = 1, .z = 0}}, + .color = {.r = 20, .g = 21, .b = 22, .a = 23}, + .colors = + {{.r = 24, .g = 25, .b = 26, .a = 27}, + {.r = 28, .g = 29, .b = 30, .a = 31}, + {.r = 32, .g = 33, .b = 34, .a = 35}}, + .indices = {0, 1, 2}, + }); + + e.texts.push_back( + TextPrimitive{ + .pose = {.position = {.x = 3, .y = 3, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .billboard = true, + .font_size = 12.0, + .scale_invariant = true, + .color = {.r = 40, .g = 41, .b = 42, .a = 43}, + .text = "hello scene", + }); + + e.models.push_back( + ModelPrimitive{ + .pose = {.position = {.x = 4, .y = 4, .z = 4}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .scale = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 50, .g = 51, .b = 52, .a = 53}, + .override_color = true, + .url = "", + .media_type = "model/gltf-binary", + .data = {0x01, 0x02, 0x03, 0x04}, + }); + + e.axes.push_back( + AxesPrimitive{ + .pose = {.position = {.x = 5, .y = 5, .z = 5}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .length = 0.5, + .thickness = 0.02, + .scale_invariant = false, + }); + + SceneEntities entities; + entities.entities.push_back(e); + entities.deletions.push_back( + SceneEntityDeletion{ + .type = SceneEntityDeletion::Type::kAll, // non-default (kMatchingId == 0) + .timestamp = 999, + .id = "", + }); + return entities; +} + +// Packs `points` (each {x, y, z, intensity} float32) at `point_step`-byte +// stride into a fresh owned buffer via `buffer_assign()`. +PointCloud makeSamplePointCloudUnorganized() { + PointCloud cloud; + cloud.width = 3; + cloud.height = 1; + cloud.point_step = 16; + cloud.row_step = 48; // 3 * 16, no padding. + cloud.is_bigendian = false; + cloud.is_dense = true; + cloud.frame_id = "lidar"; + cloud.fields = { + PointField{.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "intensity", .offset = 12, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + cloud.timestamp_ns = 1'234'567'890'123'456; + + std::vector bytes(static_cast(cloud.point_step) * cloud.width); + const float points[3][4] = { + {1.0f, 2.0f, 3.0f, 0.5f}, + {4.0f, 5.0f, 6.0f, 0.6f}, + {7.0f, 8.0f, 9.0f, 0.7f}, + }; + for (size_t i = 0; i < 3; ++i) { + std::memcpy(bytes.data() + i * cloud.point_step, points[i], sizeof(points[i])); + } + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&cloud, std::move(bytes)); + return cloud; +} + +// A 4x3 rgb8 image (36 bytes, no row padding), both optionals set even +// though they are only semantically meaningful for "compressedDepth" — this +// exercises the kOptionalNumber accessors on every populated fixture. +Image makeSampleImage() { + Image image; + image.width = 4; + image.height = 3; + image.encoding = "rgb8"; + image.row_step = 12; // 4 * 3, no padding. + image.is_bigendian = false; + image.compressed_depth_min = 0.1f; + image.compressed_depth_max = 6.4f; + image.timestamp_ns = 1'234'567'890'123'456; + image.frame_id = "camera_optical_frame"; + + std::vector bytes(static_cast(image.row_step) * image.height); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +// A 2x2 32FC1 depth image (16 bytes) with a populated K and a non-empty D. +DepthImage makeSampleDepthImage() { + DepthImage image; + image.width = 2; + image.height = 2; + image.encoding = "32FC1"; + image.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + image.distortion_model = "plumb_bob"; + image.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + image.timestamp_ns = -42; + + std::vector bytes(static_cast(image.width) * image.height * 4); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +CameraInfo makeSampleCameraInfo() { + CameraInfo info; + info.timestamp_ns = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + info.frame_id = "camera_optical_frame"; + info.width = 640; + info.height = 480; + info.distortion_model = "plumb_bob"; + info.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + info.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + info.R = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + info.P = {525.0, 0.0, 319.5, 0.0, 0.0, 525.0, 239.5, 0.0, 0.0, 0.0, 1.0, 0.0}; + return info; +} + +VideoFrame makeSampleVideoFrame() { + VideoFrame frame; + frame.timestamp_ns = 1'000'000'000'000'123; // > 2^53. + frame.frame_id = "cam0"; + frame.format = "h264"; + + std::vector bytes = {0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xC0, 0x1E}; + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&frame, std::move(bytes)); + return frame; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +TEST(FieldTableTest, NamesUniqueAndKindsConsistent) { + // Explicit roots so every specialization is exercised even when a struct + // (Vector2, Pose) is not reachable from any other root. + const std::array roots{ + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + }; + + std::set visited; + std::vector tables; + for (const auto* root : roots) { + collectTables(*root, visited, tables); + } + ASSERT_EQ(tables.size(), 32u) << "expected all 32 field_table specializations added through SDK block 5.1"; + + for (const auto* table : tables) { + SCOPED_TRACE(table->type_name); + std::set names; + for (const auto& f : table->fields) { + EXPECT_FALSE(f.name.empty()); + EXPECT_TRUE(names.insert(f.name).second) << "duplicate field name: " << f.name; + + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.set_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.set_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.set_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kStruct: + EXPECT_NE(f.struct_ptr, nullptr); + EXPECT_NE(f.struct_ptr_mut, nullptr); + EXPECT_NE(f.nested, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kOptionalNumber: + EXPECT_NE(f.has_value, nullptr); + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kList: + EXPECT_NE(f.list_size, nullptr); + EXPECT_NE(f.list_at, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.buffer, nullptr); + // Exactly one of the two write-accessor pairs is set: growable + // (list_emplace/list_clear, e.g. std::vector) or fixed-size + // (list_replace, e.g. std::array) — see field_table.hpp's kList + // doc comment. + if (f.list_replace != nullptr) { + EXPECT_EQ(f.list_emplace, nullptr); + EXPECT_EQ(f.list_clear, nullptr); + } else { + EXPECT_NE(f.list_emplace, nullptr); + EXPECT_NE(f.list_clear, nullptr); + } + if (f.nested != nullptr) { + // Struct-element list: nested table set, element_kind == kStruct, + // no scalar accessor (the elements are read via `nested`, not get_*/set_*). + EXPECT_EQ(f.element_kind, FieldKind::kStruct); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + } else { + // Scalar-element list: exactly one get_*/set_* pair set, matching element_kind. + EXPECT_NE(f.element_kind, FieldKind::kStruct); + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + break; + default: + ADD_FAILURE() << "unexpected scalar element_kind"; + } + } + break; + case FieldKind::kBuffer: + EXPECT_NE(f.buffer, nullptr); + EXPECT_NE(f.buffer_assign, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.nested, nullptr); + break; + } + } + } +} + +TEST(FieldTableTest, GenericCopyMatchesCodecRoundTrip) { + { + const FrameTransforms src = makeSampleFrameTransforms(); + ASSERT_FALSE(src.transforms.empty()); + FrameTransforms dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } + { + const ImageAnnotations src = makeSampleImageAnnotations(); + ASSERT_FALSE(src.points.empty()); + ASSERT_FALSE(src.circles.empty()); + ASSERT_FALSE(src.texts.empty()); + ImageAnnotations dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } + { + const SceneEntities src = makeSampleSceneEntities(); + ASSERT_FALSE(src.entities.empty()); + ASSERT_FALSE(src.deletions.empty()); + SceneEntities dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); + } +} + +// Image has no operator== (its payload bytes only compare meaningfully +// through the canonical wire codec, same as PointCloud), so compare +// serialized bytes instead. Exercises kBuffer (data) and kOptionalNumber +// (compressed_depth_min/max, both set on the fixture). +TEST(FieldTableTest, ImageBufferAndOptionalRoundTrip) { + const Image src = makeSampleImage(); + ASSERT_FALSE(src.data.empty()); + ASSERT_TRUE(src.compressed_depth_min.has_value()); + ASSERT_TRUE(src.compressed_depth_max.has_value()); + + Image dst; + copyThroughTable(FieldTable::view, &src, &dst); + + const FieldDescriptor* min_field = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(min_field, nullptr); + EXPECT_EQ(min_field->kind, FieldKind::kOptionalNumber); + EXPECT_TRUE(min_field->has_value(&dst)); + EXPECT_FLOAT_EQ(static_cast(min_field->get_number(&dst)), *src.compressed_depth_min); + + expectSameWireBytes(src, dst); +} + +// An Image with both optionals absent (the common case: only +// "compressedDepth" images carry them) must round-trip as absent too — a +// freshly-constructed dst starts absent and copyThroughTable's +// kOptionalNumber case only writes when has_value(src) is true. +TEST(FieldTableTest, ImageOptionalAbsentRoundTrip) { + Image src = makeSampleImage(); + src.compressed_depth_min.reset(); + src.compressed_depth_max.reset(); + + Image dst; + copyThroughTable(FieldTable::view, &src, &dst); + + const FieldDescriptor* min_field = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(min_field, nullptr); + EXPECT_FALSE(min_field->has_value(&dst)); + + expectSameWireBytes(src, dst); +} + +// DepthImage has no operator== either; exercises kBuffer (data, derived +// row_step/record_step) and the fixed-size kList (K, via list_replace). +TEST(FieldTableTest, DepthImageFixedArrayAndBufferRoundTrip) { + const DepthImage src = makeSampleDepthImage(); + ASSERT_FALSE(src.data.empty()); + ASSERT_FALSE(src.D.empty()); + + const FieldDescriptor* k_field = findField(FieldTable::view, "K"); + ASSERT_NE(k_field, nullptr); + EXPECT_EQ(k_field->kind, FieldKind::kList); + EXPECT_NE(k_field->list_replace, nullptr); + EXPECT_EQ(k_field->list_emplace, nullptr); + EXPECT_EQ(k_field->list_size(&src), 9u); + + DepthImage dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst.K, src.K); + EXPECT_EQ(dst.D, src.D); + + expectSameWireBytes(src, dst); +} + +// CameraInfo has operator== (no byte blob), so both the value comparison and +// the serialized-bytes comparison apply. Exercises the fixed-size kList +// (K, R, P) alongside the ordinary growable list (D). +TEST(FieldTableTest, CameraInfoFixedArrayRoundTrip) { + const CameraInfo src = makeSampleCameraInfo(); + ASSERT_FALSE(src.D.empty()); + + const FieldDescriptor* r_field = findField(FieldTable::view, "R"); + ASSERT_NE(r_field, nullptr); + EXPECT_EQ(r_field->list_size(&src), 9u); + const FieldDescriptor* p_field = findField(FieldTable::view, "P"); + ASSERT_NE(p_field, nullptr); + EXPECT_EQ(p_field->list_size(&src), 12u); + const FieldDescriptor* d_field = findField(FieldTable::view, "D"); + ASSERT_NE(d_field, nullptr); + EXPECT_NE(d_field->list_emplace, nullptr); // D is growable (std::vector), unlike K/R/P. + EXPECT_EQ(d_field->list_replace, nullptr); + + CameraInfo dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + expectSameWireBytes(src, dst); +} + +// VideoFrame has no operator== either; exercises kBuffer for a format with +// no static per-record size (record_step/record_count/row_step all 0). +TEST(FieldTableTest, VideoFrameBufferRoundTrip) { + const VideoFrame src = makeSampleVideoFrame(); + ASSERT_FALSE(src.data.empty()); + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&src); + EXPECT_EQ(layout.record_step, 0u); + EXPECT_EQ(layout.record_count, 0u); + EXPECT_EQ(layout.row_step, 0u); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "h264"); + + VideoFrame dst; + copyThroughTable(FieldTable::view, &src, &dst); + + expectSameWireBytes(src, dst); +} + +// The buffer descriptor's layout for a raw, uncompressed encoding: a 4x3 +// rgb8 image (3 bytes/pixel, no row padding). +TEST(FieldTableTest, ImageRgb8BufferLayout) { + const Image image = makeSampleImage(); + ASSERT_EQ(image.width, 4u); + ASSERT_EQ(image.height, 3u); + ASSERT_EQ(image.encoding, "rgb8"); + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&image); + EXPECT_EQ(layout.record_step, 3u); // 3 bytes/pixel. + EXPECT_EQ(layout.record_count, 12u); // 4 * 3 pixels. + EXPECT_EQ(layout.row_step, 12u); // 4 pixels * 3 bytes, no padding. + EXPECT_FALSE(layout.is_bigendian); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "rgb8"); + EXPECT_EQ(layout.channels[0].count, 3u); +} + +// A compressed encoding has no static per-pixel size: record_step and +// record_count are 0, but the encoding string still comes through the sole +// channel's name, and the full compressed payload is still in `bytes`. +TEST(FieldTableTest, ImageCompressedBufferLayoutHasNoStaticRecordSize) { + Image image = makeSampleImage(); + image.encoding = "jpeg"; + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + const BufferLayout layout = data_field->buffer(&image); + EXPECT_EQ(layout.record_step, 0u); + EXPECT_EQ(layout.record_count, 0u); + EXPECT_FALSE(layout.bytes.empty()); + ASSERT_EQ(layout.channels.size(), 1u); + EXPECT_EQ(layout.channels[0].name, "jpeg"); +} + +TEST(FieldTableTest, Int64FieldsAreNotNumbers) { + constexpr int64_t kBig = (int64_t{1} << 55) + 12345; // > 2^53: exact only as int64. + + const FieldDescriptor* ft_timestamp = findField(FieldTable::view, "timestamp"); + ASSERT_NE(ft_timestamp, nullptr); + EXPECT_EQ(ft_timestamp->kind, FieldKind::kInt64); + EXPECT_EQ(ft_timestamp->get_number, nullptr); + EXPECT_EQ(ft_timestamp->set_number, nullptr); + ASSERT_NE(ft_timestamp->get_int64, nullptr); + ASSERT_NE(ft_timestamp->set_int64, nullptr); + + FrameTransform ft; + ft_timestamp->set_int64(&ft, kBig); + EXPECT_EQ(ft.timestamp, kBig); + EXPECT_EQ(ft_timestamp->get_int64(&ft), kBig); + + const FieldDescriptor* ia_timestamp = findField(FieldTable::view, "timestamp"); + ASSERT_NE(ia_timestamp, nullptr); + EXPECT_EQ(ia_timestamp->kind, FieldKind::kInt64); + EXPECT_EQ(ia_timestamp->get_number, nullptr); + EXPECT_EQ(ia_timestamp->set_number, nullptr); + + ImageAnnotations ia; + ia_timestamp->set_int64(&ia, kBig); + EXPECT_EQ(ia.timestamp, kBig); + EXPECT_EQ(ia_timestamp->get_int64(&ia), kBig); +} + +TEST(FieldTableTest, PointCloudBufferRoundTrip) { + const PointCloud src = makeSamplePointCloudUnorganized(); + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + EXPECT_EQ(data_field->kind, FieldKind::kBuffer); + ASSERT_NE(data_field->buffer, nullptr); + ASSERT_NE(data_field->buffer_assign, nullptr); + + const BufferLayout layout = data_field->buffer(&src); + EXPECT_EQ(layout.record_step, 16u); + EXPECT_EQ(layout.record_count, 3u); + EXPECT_EQ(layout.row_step, 48u); + EXPECT_FALSE(layout.is_bigendian); + ASSERT_EQ(layout.channels.size(), 4u); + EXPECT_EQ(layout.channels[0].name, "x"); + EXPECT_EQ(layout.channels[0].offset, 0u); + EXPECT_EQ(layout.channels[0].datatype, static_cast(PointField::Datatype::kFloat32)); + EXPECT_EQ(layout.channels[0].count, 1u); + EXPECT_EQ(layout.channels[1].name, "y"); + EXPECT_EQ(layout.channels[1].offset, 4u); + EXPECT_EQ(layout.channels[2].name, "z"); + EXPECT_EQ(layout.channels[2].offset, 8u); + EXPECT_EQ(layout.channels[3].name, "intensity"); + EXPECT_EQ(layout.channels[3].offset, 12u); + + // PointCloud has no operator== (payload bytes only compare meaningfully + // through the canonical wire codec), so compare serialized bytes instead. + PointCloud dst; + copyThroughTable(FieldTable::view, &src, &dst); + + expectSameWireBytes(src, dst); +} + +TEST(FieldTableTest, PointCloudOrganizedBufferLayout) { + PointCloud cloud; + cloud.width = 2; + cloud.height = 2; + cloud.point_step = 16; + cloud.row_step = 40; // 2 * 16 + 8 bytes padding. + cloud.frame_id = "camera_depth"; + cloud.fields = { + PointField{.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "intensity", .offset = 12, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + std::vector bytes(static_cast(cloud.row_step) * cloud.height, 0); + data_field->buffer_assign(&cloud, std::move(bytes)); + + const BufferLayout layout = data_field->buffer(&cloud); + EXPECT_EQ(layout.record_step, 16u); + EXPECT_EQ(layout.record_count, 4u); // width * height, not row-count via row_step. + EXPECT_EQ(layout.row_step, 40u); +} + +// Size fields that declare more records than `bytes` holds must not be +// promised: record_count falls back to 0 so a consumer never reads past the +// span. +TEST(FieldTableTest, PointCloudBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + PointCloud wider = makeSamplePointCloudUnorganized(); // 3 points, 48 bytes. + wider.width = 4; + EXPECT_EQ(data_field->buffer(&wider).record_count, 0u); + + PointCloud taller = makeSamplePointCloudUnorganized(); + taller.height = 2; + EXPECT_EQ(data_field->buffer(&taller).record_count, 0u); + + // The last row needs no trailing padding: 40 + 2 * 16 = 72 bytes hold a 2x2 cloud with row_step 40. + PointCloud padded; + padded.width = 2; + padded.height = 2; + padded.point_step = 16; + padded.row_step = 40; + data_field->buffer_assign(&padded, std::vector(72, 0)); + EXPECT_EQ(data_field->buffer(&padded).record_count, 4u); + data_field->buffer_assign(&padded, std::vector(71, 0)); + EXPECT_EQ(data_field->buffer(&padded).record_count, 0u); + + // A declared extent that overflows 64 bits is not satisfiable either. + PointCloud huge = makeSamplePointCloudUnorganized(); + huge.width = UINT32_MAX; + huge.height = UINT32_MAX; + huge.point_step = UINT32_MAX; + huge.row_step = UINT32_MAX; + EXPECT_EQ(data_field->buffer(&huge).record_count, 0u); +} + +TEST(FieldTableTest, ImageBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + Image taller = makeSampleImage(); // 4x3 rgb8, 36 bytes. + taller.height = 4; + EXPECT_EQ(data_field->buffer(&taller).record_count, 0u); + + Image padded = makeSampleImage(); + padded.row_step = 16; // 2 * 16 + 12 = 44 bytes needed. + EXPECT_EQ(data_field->buffer(&padded).record_count, 0u); + + const Image consistent = makeSampleImage(); + EXPECT_EQ(data_field->buffer(&consistent).record_count, 12u); +} + +TEST(FieldTableTest, DepthImageBufferLayoutClampsToBytes) { + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + ASSERT_NE(data_field, nullptr); + + DepthImage wider = makeSampleDepthImage(); // 2x2 32FC1, 16 bytes. + wider.width = 3; + EXPECT_EQ(data_field->buffer(&wider).record_count, 0u); + + const DepthImage consistent = makeSampleDepthImage(); + EXPECT_EQ(data_field->buffer(&consistent).record_count, 4u); +} + +// set_number never performs an undefined float-to-integer conversion: NaN +// reads back 0 and out-of-range values clamp to the target type's limits. +TEST(FieldTableTest, SetNumberSaturatesIntegerTargets) { + const FieldDescriptor* red = findField(FieldTable::view, "r"); + ASSERT_NE(red, nullptr); + ColorRGBA color; + red->set_number(&color, 300.0); + EXPECT_EQ(color.r, 255); + red->set_number(&color, -5.0); + EXPECT_EQ(color.r, 0); + red->set_number(&color, 200.7); + EXPECT_EQ(color.r, 200); + color.r = 9; + red->set_number(&color, std::numeric_limits::quiet_NaN()); + EXPECT_EQ(color.r, 0); + + const FieldDescriptor* width = findField(FieldTable::view, "width"); + ASSERT_NE(width, nullptr); + Image image; + width->set_number(&image, -1.0); + EXPECT_EQ(image.width, 0u); + width->set_number(&image, 1e20); + EXPECT_EQ(image.width, UINT32_MAX); + width->set_number(&image, -std::numeric_limits::infinity()); + EXPECT_EQ(image.width, 0u); +} + +TEST(FieldTableTest, SetNumberSaturatesEnumUnderlyingRange) { + const FieldDescriptor* datatype = findField(FieldTable::view, "datatype"); + ASSERT_NE(datatype, nullptr); + ASSERT_EQ(datatype->kind, FieldKind::kEnum); + PointField field; + datatype->set_number(&field, 300.0); + EXPECT_EQ(static_cast(field.datatype), 255); + datatype->set_number(&field, -1.0); + EXPECT_EQ(static_cast(field.datatype), 0); +} + +// A float target keeps its plain conversion: no clamping to an integer range. +TEST(FieldTableTest, SetNumberKeepsFloatTargets) { + const FieldDescriptor* depth_min = findField(FieldTable::view, "compressed_depth_min"); + ASSERT_NE(depth_min, nullptr); + Image image; + depth_min->set_number(&image, 1e20); + ASSERT_TRUE(image.compressed_depth_min.has_value()); + EXPECT_FLOAT_EQ(*image.compressed_depth_min, 1e20f); +} + +TEST(FieldTableTest, DescribeCoversExactlyTheTabledTypes) { + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kFrameTransforms), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kImageAnnotations), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kPointCloud), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kSceneEntities), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kDepthImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kCameraInfo), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kVideoFrame), nullptr); + + // Every other stable BuiltinObjectType value (mirrors the array in + // builtin_object_codec_test.cpp) must resolve to nullptr. + const std::array other_types{ + BuiltinObjectType::kOccupancyGrid, + BuiltinObjectType::kCompressedPointCloud, + BuiltinObjectType::kMesh3D, + BuiltinObjectType::kRobotDescription, + BuiltinObjectType::kOccupancyGridUpdate, + BuiltinObjectType::kLog, + BuiltinObjectType::kPosesInFrame, + BuiltinObjectType::kVoxelGrid, + BuiltinObjectType::kGridMap, + BuiltinObjectType::kPlotMarkers, + }; + for (auto type : other_types) { + EXPECT_EQ(PJ::sdk::describe(type), nullptr) << PJ::sdk::name(type); + } + EXPECT_EQ(PJ::sdk::describe(BuiltinObjectType::kNone), nullptr); +} + +} // namespace diff --git a/pj_base/tests/image_annotations_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp index 88d68b6c..76d7f345 100644 --- a/pj_base/tests/image_annotations_codec_test.cpp +++ b/pj_base/tests/image_annotations_codec_test.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include "protobuf_wire_test_helpers.hpp" @@ -29,7 +30,7 @@ sdk::ImageAnnotations roundTrip(const sdk::ImageAnnotations& input) { auto bytes = serializeImageAnnotations(input); auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); EXPECT_TRUE(result.has_value()); - return *result; + return result.value(); // throws (a clean test failure) instead of reading an empty result } // Compare two ColorRGBA values allowing 1-LSB drift on each channel from the @@ -296,5 +297,120 @@ TEST(ImageAnnotationCodecTest, RoundTrip_PerVertexColors) { EXPECT_TRUE(colorEq(in.points[0].colors[2], out.points[0].colors[2])); } +// ----------------------------------------------------------------------------- +// 4. Top-level timestamp / image_topic (wire fields 5, 6). +// ----------------------------------------------------------------------------- + +TEST(ImageAnnotationCodecTest, RoundTrip_TimestampAndImageTopic) { + sdk::ImageAnnotations in; + in.timestamp = 5'250'000'000; // 5s, 250000000ns + in.image_topic = "/camera/image_raw"; + + CircleAnnotation ca; + ca.center = {1.0, 2.0}; + ca.radius = 3.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + in.circles.push_back(std::move(ca)); + + auto out = roundTrip(in); + EXPECT_EQ(out.timestamp, in.timestamp); + EXPECT_EQ(out.image_topic, in.image_topic); + ASSERT_EQ(out.circles.size(), 1u); + EXPECT_DOUBLE_EQ(out.circles[0].radius, 3.0); +} + +TEST(ImageAnnotationCodecTest, TimestampZeroAndEmptyTopicEmitNothing) { + // A default-constructed timestamp (0) and image_topic ("") must not emit + // fields 5/6, even when other annotation content is present. + sdk::ImageAnnotations with_defaults; + CircleAnnotation ca; + ca.center = {5.0, 6.0}; + ca.radius = 1.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + with_defaults.circles.push_back(ca); + + sdk::ImageAnnotations explicit_zero = with_defaults; + explicit_zero.timestamp = 0; + explicit_zero.image_topic.clear(); + + EXPECT_EQ(serializeImageAnnotations(with_defaults), serializeImageAnnotations(explicit_zero)); +} + +TEST(ImageAnnotationCodecTest, OldPayloadWithoutTheFieldsDecodesToDefaults) { + // A payload with only a circle annotation (as produced before fields 5/6 + // existed, and as still produced today when timestamp/image_topic are + // unset) must decode to Timestamp{0} and an empty image_topic. + sdk::ImageAnnotations in; + CircleAnnotation ca; + ca.center = {7.0, 8.0}; + ca.radius = 2.0; + ca.thickness = 1.0; + ca.color = {0, 255, 0, 255}; + ca.fill_color = {0, 0, 0, 0}; + in.circles.push_back(std::move(ca)); + + auto bytes = serializeImageAnnotations(in); + auto result = deserializeImageAnnotations(bytes.data(), bytes.size()); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->timestamp, 0); + EXPECT_TRUE(result->image_topic.empty()); +} + +TEST(ImageAnnotationCodecTest, GoldenBytes_TimestampAndImageTopic) { + sdk::ImageAnnotations ia; + ia.timestamp = 5'250'000'000; // 5s, 250000000ns + ia.image_topic = "/camera/image_raw"; + + // Field 5: Timestamp submessage (seconds=5, nanos=250000000). + std::vector expected; + pb::appendTag(expected, 5, 2); + pb::appendLenDelim(expected, pb::encodeTimestamp(ia.timestamp)); + + // Field 6: image_topic string. + pb::appendTag(expected, 6, 2); + pb::appendString(expected, "/camera/image_raw"); + + auto actual = serializeImageAnnotations(ia); + EXPECT_EQ(actual, expected) << "wire format mismatch"; +} + +TEST(ImageAnnotationCodecTest, RoundTrip_TimestampExtremes) { + // INT64_MIN is not a whole number of seconds: the encoder floors it to seconds + // -9223372037 with positive nanos, which the decoder must accept again. + for (const Timestamp timestamp : + {std::numeric_limits::min(), std::numeric_limits::min() + 1, + std::numeric_limits::max(), std::numeric_limits::max() - 1, Timestamp{-1}, + Timestamp{-1'000'000'000}}) { + sdk::ImageAnnotations in; + in.timestamp = timestamp; + EXPECT_EQ(roundTrip(in).timestamp, timestamp) << timestamp; + } +} + +TEST(ImageAnnotationCodecTest, TimestampOutsideInt64NanosIsRejected) { + // Hand-built field 5 (Timestamp{seconds, nanos}); INT64_MIN is (-9223372037, 145224192). + const auto decodes = [](int64_t seconds, uint32_t nanos) { + std::vector body; + pb::appendTag(body, 1, 0); + pb::appendVarint(body, static_cast(seconds)); + pb::appendTag(body, 2, 0); + pb::appendVarint(body, nanos); + std::vector bytes; + pb::appendTag(bytes, 5, 2); + pb::appendLenDelim(bytes, body); + return deserializeImageAnnotations(bytes.data(), bytes.size()).has_value(); + }; + EXPECT_TRUE(decodes(-9223372037, 145224192)); // INT64_MIN + EXPECT_FALSE(decodes(-9223372037, 145224191)); // INT64_MIN - 1 + EXPECT_FALSE(decodes(-9223372038, 999999999)); // whole second below the range + EXPECT_TRUE(decodes(9223372036, 854775807)); // INT64_MAX + EXPECT_FALSE(decodes(9223372036, 854775808)); // INT64_MAX + 1 + EXPECT_FALSE(decodes(9223372037, 0)); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/object_topic_metadata_test.cpp b/pj_base/tests/object_topic_metadata_test.cpp index 92ce68a2..0c0bbda1 100644 --- a/pj_base/tests/object_topic_metadata_test.cpp +++ b/pj_base/tests/object_topic_metadata_test.cpp @@ -202,6 +202,45 @@ TEST(ObjectTopicMetadataBuilderTest, CanonicalKeyCannotBeInsertedAsCustomMetadat #endif } +TEST(ObjectTopicMetadataBuilderTest, SnapshotFlagRoundTrips) { + // snapshot(true) sets the canonical key. + const auto snapshot_true = + ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kSceneEntities).snapshot().build(); + ASSERT_TRUE(snapshot_true); + EXPECT_EQ(*snapshot_true, R"({"builtin_object_type":"kSceneEntities","pj_snapshot":"true"})"); + + // snapshot(false) leaves the key unset (incremental, the default), also after a snapshot(true). + const auto snapshot_false = + ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kImageAnnotations).snapshot(false).build(); + ASSERT_TRUE(snapshot_false); + EXPECT_EQ(*snapshot_false, R"({"builtin_object_type":"kImageAnnotations"})"); + const auto snapshot_reset = ObjectTopicMetadataBuilder() + .builtinObjectType(BuiltinObjectType::kImageAnnotations) + .snapshot() + .snapshot(false) + .build(); + ASSERT_TRUE(snapshot_reset); + EXPECT_EQ(*snapshot_reset, R"({"builtin_object_type":"kImageAnnotations"})"); + + // Omitting snapshot() entirely leaves the key unset too. + const auto no_snapshot = ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kImageAnnotations).build(); + ASSERT_TRUE(no_snapshot); + EXPECT_EQ(*no_snapshot, *snapshot_false); + + EXPECT_EQ(kSnapshotMetadataKey, "pj_snapshot"); +} + +TEST(ObjectTopicMetadataBuilderTest, DerivedKeyIsStableAndRoundTripsAsACustomString) { + EXPECT_EQ(kDerivedMetadataKey, "pj_derived"); + EXPECT_EQ(kDerivedOnDemandValue, "on_demand"); + const auto built = ObjectTopicMetadataBuilder() + .builtinObjectType(BuiltinObjectType::kPointCloud) + .string(kDerivedMetadataKey, kDerivedOnDemandValue) + .build(); + ASSERT_TRUE(built); + EXPECT_EQ(*built, R"({"builtin_object_type":"kPointCloud","pj_derived":"on_demand"})"); +} + TEST(ObjectTopicMetadataRegistrationTest, SourceTypedOverloadForwardsBuiltJson) { RegistrationRecorder recorder; const PJ_object_write_host_vtable_t vtable = {