From 58b47c45b846535804a2f2214fb4f3c7f69a9707 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 16:11:28 +0200 Subject: [PATCH 1/6] feat(pj_base): add CameraInfo + OccupancyGridUpdate canonical builtin types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining canonical-object gaps for the ROS pipeline: every common ROS 2 message now maps onto a builtin type. Both are wired end-to-end (struct + codec + proto + enum/ABI/typeOf + tests + docs) at enum values 14/15, following the existing owned (FrameTransforms) and byte-backed (OccupancyGrid) templates. - CameraInfo (owned): pinhole calibration K/D/R/P + dimensions, packed-double codec. Mirrors sensor_msgs/CameraInfo; binning/ROI omitted (additive later). Correlated to an image topic by name convention; carries no topic linkage. - OccupancyGridUpdate (byte-backed): incremental sub-rectangle patch for an OccupancyGrid. Deliberately no origin/resolution — a stateful consumer pairs it with the base grid (/costmap_updates <-> /costmap) and places it. Signed int32 cell offsets round-trip via the varint bit pattern. Also dedups a duplicate RobotDescription row in the conversion-examples table. Build 100%; ctest 5/5 (camera_info_codec, occupancy_grid_update_codec, builtin_object, abi_layout_sentinels, occupancy_grid_codec). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/builtin_type.md | 66 ++++++++- pj_base/CMakeLists.txt | 4 + .../pj_base/builtin/builtin_object.hpp | 20 +++ .../include/pj_base/builtin/camera_info.hpp | 45 ++++++ .../pj_base/builtin/camera_info_codec.hpp | 24 +++ .../pj_base/builtin/occupancy_grid_update.hpp | 45 ++++++ .../builtin/occupancy_grid_update_codec.hpp | 25 ++++ pj_base/include/pj_base/builtin_object_abi.h | 2 + pj_base/proto/pj/CameraInfo.proto | 48 ++++++ pj_base/proto/pj/OccupancyGridUpdate.proto | 50 +++++++ pj_base/proto/pj/README.md | 7 + pj_base/src/builtin/camera_info_codec.cpp | 122 ++++++++++++++++ .../builtin/occupancy_grid_update_codec.cpp | 137 ++++++++++++++++++ pj_base/tests/abi_layout_sentinels_test.cpp | 2 + pj_base/tests/builtin_object_test.cpp | 6 + pj_base/tests/camera_info_codec_test.cpp | 58 ++++++++ .../occupancy_grid_update_codec_test.cpp | 67 +++++++++ 17 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 pj_base/include/pj_base/builtin/camera_info.hpp create mode 100644 pj_base/include/pj_base/builtin/camera_info_codec.hpp create mode 100644 pj_base/include/pj_base/builtin/occupancy_grid_update.hpp create mode 100644 pj_base/include/pj_base/builtin/occupancy_grid_update_codec.hpp create mode 100644 pj_base/proto/pj/CameraInfo.proto create mode 100644 pj_base/proto/pj/OccupancyGridUpdate.proto create mode 100644 pj_base/src/builtin/camera_info_codec.cpp create mode 100644 pj_base/src/builtin/occupancy_grid_update_codec.cpp create mode 100644 pj_base/tests/camera_info_codec_test.cpp create mode 100644 pj_base/tests/occupancy_grid_update_codec_test.cpp diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 6801b10..efe7e45 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -83,8 +83,8 @@ Builtin objects fall into two serialization families: | Family | Current types | Storage model | Codec policy | |--------|---------------|---------------|--------------| -| Byte-backed views | `Image`, `DepthImage`, `PointCloud`, `CompressedPointCloud`, `OccupancyGrid`, `Mesh3D`, `VideoFrame` | Header fields live in the SDK struct; payload bytes live behind `Span` plus `BufferAnchor`. | No mandatory canonical codec; preserve zero-copy views over ROS, MCAP, compressed image, point-cloud, or plugin-owned payloads. If conversion is unavoidable, allocate a new payload and anchor it. | -| Owned values | `ImageAnnotations`, `FrameTransforms`, `SceneEntities`, `AssetVideo`, `RobotDescription`; future marker types | SDK structs own their vectors/strings/scalars directly. | Add explicit codecs when canonical bytes are needed. Codecs serialize the owned value to the protobuf-wire payload described by the `.proto` contract, using shared private wire primitives. `RobotDescription` carries source-format text as-is (no canonical codec) — the format hint distinguishes URDF / SDF / MJCF. | +| Byte-backed views | `Image`, `DepthImage`, `PointCloud`, `CompressedPointCloud`, `OccupancyGrid`, `OccupancyGridUpdate`, `Mesh3D`, `VideoFrame` | Header fields live in the SDK struct; payload bytes live behind `Span` plus `BufferAnchor`. | No mandatory canonical codec; preserve zero-copy views over ROS, MCAP, compressed image, point-cloud, or plugin-owned payloads. If conversion is unavoidable, allocate a new payload and anchor it. | +| Owned values | `ImageAnnotations`, `FrameTransforms`, `SceneEntities`, `AssetVideo`, `RobotDescription`, `CameraInfo`; future marker types | SDK structs own their vectors/strings/scalars directly. | Add explicit codecs when canonical bytes are needed. Codecs serialize the owned value to the protobuf-wire payload described by the `.proto` contract, using shared private wire primitives. `RobotDescription` carries source-format text as-is (no canonical codec) — the format hint distinguishes URDF / SDF / MJCF. | Canonical `.proto` files live under `pj_base/proto/pj` and act as the wire format contract. One file per top-level message, each named after its message @@ -120,6 +120,8 @@ annotations, frame transforms, or no builtin object. | `kSceneEntities` | `PJ::sdk::SceneEntities` | Procedural 3D scene primitives (arrows, cubes, lines, text, …). | | `kAssetVideo` | `PJ::sdk::AssetVideo` | File-backed video reference plus typed playback metadata. | | `kRobotDescription` | `PJ::sdk::RobotDescription` | Raw URDF/SDF/MJCF text + format hint. | +| `kCameraInfo` | `PJ::sdk::CameraInfo` | Pinhole camera calibration (intrinsics K, distortion D, rectification R, projection P). | +| `kOccupancyGridUpdate` | `PJ::sdk::OccupancyGridUpdate` | Incremental sub-rectangle patch for a previously-published `OccupancyGrid`. | `BuiltinObject` is `std::any`. Producers store a concrete builtin value in it; consumers recover the concrete type with `std::any_cast(&object)` or ask @@ -315,6 +317,34 @@ renderer cares about cell-to-world placement, not pixel layout. `pj_base/builtin/occupancy_grid_codec.hpp` serializes and deserializes this type using the canonical `PJ.OccupancyGrid` protobuf wire format. +## OccupancyGridUpdate + +`OccupancyGridUpdate` is the incremental counterpart to `OccupancyGrid`: a +row-major sub-rectangle patch into a previously-published base grid (ROS +`map_msgs/OccupancyGridUpdate`, e.g. `/costmap_updates`). + +It deliberately carries **no** `origin` / `resolution` — a patch is not +independently placeable. A stateful consumer pairs the update with its base +grid (by topic-name convention, `/costmap_updates` ↔ `/costmap`) +and positions it at the base's `origin + (x, y) * resolution`. This keeps the +producer stateless and cross-topic-blind; all accumulation / placement lives in +the consumer. The patch is a self-contained snapshot at its own timestamp, so it +stores and decodes like any other object (no replay required at decode time). + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp_ns` | `Timestamp` | Timestamp of the update. | +| `frame_id` | `std::string` | Must match the base grid's frame. | +| `x` | `int32_t` | Column offset (cells) of the patch top-left into the base grid. | +| `y` | `int32_t` | Row offset (cells) of the patch top-left into the base grid. | +| `width` | `uint32_t` | Patch width in cells. | +| `height` | `uint32_t` | Patch height in cells. | +| `data` | `Span` | Row-major signed-8-bit cells; size must equal `width * height`. | +| `anchor` | `BufferAnchor` | Keeps `data` alive when it references shared storage. | + +`pj_base/builtin/occupancy_grid_update_codec.hpp` serializes and deserializes +this type using the canonical `PJ.OccupancyGridUpdate` protobuf wire format. + ## CompressedPointCloud `CompressedPointCloud` carries a point cloud delivered in a format-specific @@ -487,6 +517,35 @@ Design notes: ``) before emission. Generic `std_msgs/String` payloads on unrelated topics should not surface as RobotDescription. +## CameraInfo + +`CameraInfo` carries pinhole camera calibration — intrinsics, distortion, +rectification, and projection — for one camera frame (ROS +`sensor_msgs/CameraInfo`). Consumers use it to draw camera frustums, back-project +depth pixels into 3D, and rectify or overlay onto images. + +Like `OccupancyGridUpdate`, it is correlated to its image / depth topic by +topic-name convention (`/camera_info` ↔ `/image_raw`); the object itself +carries no topic linkage. It is an owned value (small matrices and a distortion +vector, no byte blob), so no `BufferAnchor` is needed. + +| Field | Type | Notes | +|-------|------|-------| +| `timestamp_ns` | `Timestamp` | Timestamp associated with this calibration. | +| `frame_id` | `std::string` | Camera optical frame. | +| `width` | `uint32_t` | Image width in pixels. | +| `height` | `uint32_t` | Image height in pixels. | +| `distortion_model` | `std::string` | e.g. `plumb_bob`, `rational_polynomial`, `equidistant`; empty when rectified. | +| `D` | `std::vector` | Distortion coefficients; size depends on the model. | +| `K` | `std::array` | 3x3 row-major intrinsics `[fx 0 cx; 0 fy cy; 0 0 1]`. | +| `R` | `std::array` | 3x3 row-major rectification (identity for monocular). | +| `P` | `std::array` | 3x4 row-major projection / camera matrix. | + +Sub-window fields (binning, ROI) from `sensor_msgs/CameraInfo` are intentionally +omitted; they are additive later if a consumer needs them. +`pj_base/builtin/camera_info_codec.hpp` serializes and deserializes this type +using the canonical `PJ.CameraInfo` protobuf wire format. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | @@ -503,7 +562,8 @@ Design notes: | Detection or tracking message | `ImageAnnotations` | Convert boxes, points, circles, and labels into pixel-space primitives. | | ROS `tf2_msgs/TFMessage` | `FrameTransforms` | Convert transform batches into named parent/child frame relationships. | | ROS `std_msgs/String` on `/robot_description` (or matching name) carrying URDF XML | `RobotDescription` | Validate root element matches `format`, then carry the raw text + format hint. No mesh resolution at parse time. | -| ROS `std_msgs/String` on `/robot_description` (or matching name) carrying URDF XML | `RobotDescription` | Validate root element matches `format`, then carry the raw text + format hint. No mesh resolution at parse time. | +| ROS `sensor_msgs/CameraInfo` | `CameraInfo` | Map K / D / R / P plus dimensions; correlate to the image topic by name. Sub-window (binning / ROI) is dropped. | +| ROS `map_msgs/OccupancyGridUpdate` | `OccupancyGridUpdate` | Forward the cell-space patch (`x`/`y`/`width`/`height` + bytes); the consumer pairs it with the base grid and supplies origin/resolution. | The builtin type is the boundary object. After conversion, consumers should not need to know which third-party schema produced it. diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 1384f1d..1fbf6e9 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(pj_base STATIC src/builtin/asset_video_codec.cpp + src/builtin/camera_info_codec.cpp src/builtin/compressed_point_cloud_codec.cpp src/builtin/depth_image_codec.cpp src/builtin/frame_transforms_codec.cpp @@ -11,6 +12,7 @@ add_library(pj_base STATIC src/builtin/image_codec.cpp src/builtin/mesh3d_codec.cpp src/builtin/occupancy_grid_codec.cpp + src/builtin/occupancy_grid_update_codec.cpp src/builtin/point_cloud_codec.cpp src/builtin/scene_entities_codec.cpp src/builtin/video_frame_codec.cpp @@ -83,6 +85,8 @@ if(PJ_BUILD_TESTS) tests/point_cloud_codec_test.cpp tests/compressed_point_cloud_codec_test.cpp tests/occupancy_grid_codec_test.cpp + tests/occupancy_grid_update_codec_test.cpp + tests/camera_info_codec_test.cpp tests/mesh3d_codec_test.cpp tests/video_frame_codec_test.cpp tests/scene_entities_codec_test.cpp diff --git a/pj_base/include/pj_base/builtin/builtin_object.hpp b/pj_base/include/pj_base/builtin/builtin_object.hpp index 67274a3..e666478 100644 --- a/pj_base/include/pj_base/builtin/builtin_object.hpp +++ b/pj_base/include/pj_base/builtin/builtin_object.hpp @@ -27,6 +27,7 @@ #include #include "pj_base/builtin/asset_video.hpp" +#include "pj_base/builtin/camera_info.hpp" #include "pj_base/builtin/compressed_point_cloud.hpp" #include "pj_base/builtin/depth_image.hpp" #include "pj_base/builtin/frame_transforms.hpp" @@ -34,6 +35,7 @@ #include "pj_base/builtin/image_annotations.hpp" #include "pj_base/builtin/mesh3d.hpp" #include "pj_base/builtin/occupancy_grid.hpp" +#include "pj_base/builtin/occupancy_grid_update.hpp" #include "pj_base/builtin/point_cloud.hpp" #include "pj_base/builtin/robot_description.hpp" #include "pj_base/builtin/scene_entities.hpp" @@ -56,6 +58,8 @@ enum class BuiltinObjectType : uint16_t { kSceneEntities = 11, ///< sdk::SceneEntities — procedural 3D scene primitives. kAssetVideo = 12, ///< sdk::AssetVideo — file-backed video reference + playback metadata. kRobotDescription = 13, ///< sdk::RobotDescription — raw URDF/SDF/MJCF text + format hint. + kCameraInfo = 14, ///< sdk::CameraInfo — pinhole camera calibration (K/D/R/P). + kOccupancyGridUpdate = 15, ///< sdk::OccupancyGridUpdate — incremental sub-rectangle patch for an OccupancyGrid. }; /// A-priori classification of a schema. Currently carries only the type; @@ -94,6 +98,10 @@ struct SchemaClassification { return "kAssetVideo"; case BuiltinObjectType::kRobotDescription: return "kRobotDescription"; + case BuiltinObjectType::kCameraInfo: + return "kCameraInfo"; + case BuiltinObjectType::kOccupancyGridUpdate: + return "kOccupancyGridUpdate"; } return "kNone"; } @@ -140,6 +148,12 @@ struct SchemaClassification { if (s == "kRobotDescription") { return BuiltinObjectType::kRobotDescription; } + if (s == "kCameraInfo") { + return BuiltinObjectType::kCameraInfo; + } + if (s == "kOccupancyGridUpdate") { + return BuiltinObjectType::kOccupancyGridUpdate; + } return std::nullopt; } @@ -189,6 +203,12 @@ using BuiltinObject = std::any; if (t == typeid(RobotDescription)) { return BuiltinObjectType::kRobotDescription; } + if (t == typeid(CameraInfo)) { + return BuiltinObjectType::kCameraInfo; + } + if (t == typeid(OccupancyGridUpdate)) { + return BuiltinObjectType::kOccupancyGridUpdate; + } return BuiltinObjectType::kNone; } diff --git a/pj_base/include/pj_base/builtin/camera_info.hpp b/pj_base/include/pj_base/builtin/camera_info.hpp new file mode 100644 index 0000000..f32e0ee --- /dev/null +++ b/pj_base/include/pj_base/builtin/camera_info.hpp @@ -0,0 +1,45 @@ +/** + * @file camera_info.hpp + * @brief Pinhole camera calibration (intrinsics + distortion) for a camera frame. + * + * CameraInfo is a small owned builtin: it carries calibration matrices and + * distortion coefficients directly, with no byte blob, so no BufferAnchor is + * needed. It is correlated to an image / depth topic by convention — a consumer + * pairs `/camera_info` with `/image_raw` — so the canonical object + * itself carries no topic linkage. Sub-window fields (binning, ROI) are + * intentionally omitted; they are additive later if a consumer needs them. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include + +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// Pinhole camera calibration. Mirrors the calibration payload of +/// sensor_msgs/CameraInfo (K/D/R/P + distortion model), expressed as canonical +/// PJ vocabulary rather than a ROS-specific type. Matrices are row-major. +struct CameraInfo { + Timestamp timestamp_ns = 0; + std::string frame_id; ///< Camera optical frame. + uint32_t width = 0; ///< Image width in pixels. + uint32_t height = 0; ///< Image height in pixels. + std::string distortion_model; ///< e.g. "plumb_bob", "rational_polynomial", "equidistant". + std::vector D; ///< Distortion coefficients; size depends on the model. + std::array K{}; ///< 3x3 intrinsics [fx 0 cx; 0 fy cy; 0 0 1]. + std::array R{}; ///< 3x3 rectification (identity for monocular). + std::array P{}; ///< 3x4 projection / camera matrix. + + bool operator==(const CameraInfo&) const = default; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/camera_info_codec.hpp b/pj_base/include/pj_base/builtin/camera_info_codec.hpp new file mode 100644 index 0000000..8cd684a --- /dev/null +++ b/pj_base/include/pj_base/builtin/camera_info_codec.hpp @@ -0,0 +1,24 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "pj_base/builtin/camera_info.hpp" +#include "pj_base/expected.hpp" + +namespace PJ { + +inline constexpr std::string_view kSchemaCameraInfo = "PJ.CameraInfo"; + +/// Serializes sdk::CameraInfo to canonical PJ.CameraInfo wire bytes +/// (see pj_base/proto/pj/CameraInfo.proto). +[[nodiscard]] std::vector serializeCameraInfo(const sdk::CameraInfo& info); + +/// Decodes canonical PJ.CameraInfo wire bytes into an owned sdk::CameraInfo. +[[nodiscard]] Expected deserializeCameraInfo(const uint8_t* data, size_t size); + +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/occupancy_grid_update.hpp b/pj_base/include/pj_base/builtin/occupancy_grid_update.hpp new file mode 100644 index 0000000..c0eccad --- /dev/null +++ b/pj_base/include/pj_base/builtin/occupancy_grid_update.hpp @@ -0,0 +1,45 @@ +/** + * @file occupancy_grid_update.hpp + * @brief Incremental sub-rectangle patch for a previously-published OccupancyGrid. + * + * OccupancyGridUpdate is the delta counterpart to OccupancyGrid: a row-major + * patch covering a sub-window of a base grid. It deliberately carries NO + * origin/resolution — the patch is not independently placeable. A stateful + * consumer pairs it with the base grid (by topic-name convention, e.g. + * `/costmap_updates` <-> `/costmap`) and positions it using the + * base's origin + resolution. Byte-backed: cell bytes live behind a + * `Span` plus `BufferAnchor`, the same pattern as OccupancyGrid. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// A sub-rectangle patch into a base OccupancyGrid. Cell values are signed +/// 8-bit integers stored row-major in `data` (-1 unknown, 0..100 occupancy +/// percent); `data.size()` must equal `width * height`. The patch carries no +/// origin/resolution: the consumer places it at the base grid's +/// `origin + (x, y) * resolution`. +struct OccupancyGridUpdate { + Timestamp timestamp_ns = 0; + std::string frame_id; ///< Must match the base grid's frame. + int32_t x = 0; ///< Column offset (cells) of the patch top-left into the base grid. + int32_t y = 0; ///< Row offset (cells) of the patch top-left into the base grid. + uint32_t width = 0; ///< Patch width in cells. + uint32_t height = 0; ///< Patch height in cells. + Span data; ///< Row-major signed-8-bit cells; size() == width * height. + BufferAnchor anchor; ///< Keeps `data` alive; no wire equivalent. +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/occupancy_grid_update_codec.hpp b/pj_base/include/pj_base/builtin/occupancy_grid_update_codec.hpp new file mode 100644 index 0000000..0ddb653 --- /dev/null +++ b/pj_base/include/pj_base/builtin/occupancy_grid_update_codec.hpp @@ -0,0 +1,25 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "pj_base/builtin/occupancy_grid_update.hpp" +#include "pj_base/expected.hpp" + +namespace PJ { + +inline constexpr std::string_view kSchemaOccupancyGridUpdate = "PJ.OccupancyGridUpdate"; + +/// Serializes sdk::OccupancyGridUpdate to canonical PJ.OccupancyGridUpdate wire +/// bytes (see pj_base/proto/pj/OccupancyGridUpdate.proto). +[[nodiscard]] std::vector serializeOccupancyGridUpdate(const sdk::OccupancyGridUpdate& update); + +/// Decodes canonical PJ.OccupancyGridUpdate wire bytes. The returned object +/// owns its cell bytes via `anchor`. +[[nodiscard]] Expected deserializeOccupancyGridUpdate(const uint8_t* data, size_t size); + +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin_object_abi.h b/pj_base/include/pj_base/builtin_object_abi.h index 638a288..cfb5e78 100644 --- a/pj_base/include/pj_base/builtin_object_abi.h +++ b/pj_base/include/pj_base/builtin_object_abi.h @@ -53,6 +53,8 @@ typedef enum PJ_builtin_object_type_t { PJ_BUILTIN_OBJECT_TYPE_SCENE_ENTITIES = 11, PJ_BUILTIN_OBJECT_TYPE_ASSET_VIDEO = 12, PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION = 13, + PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO = 14, + PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE = 15, /* Reserve future types; appended at the tail. Numeric values are stable * across releases — never renumber. Each new value here must match the * matching kFoo entry in BuiltinObjectType (builtin_object.hpp). */ diff --git a/pj_base/proto/pj/CameraInfo.proto b/pj_base/proto/pj/CameraInfo.proto new file mode 100644 index 0000000..a905cd5 --- /dev/null +++ b/pj_base/proto/pj/CameraInfo.proto @@ -0,0 +1,48 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +// PlotJuggler canonical camera calibration protobuf schema. +// Wire-level contract for the owned `CameraInfo` builtin +// (SDK struct lives in pj_base/include/pj_base/builtin/camera_info.hpp). + +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +package PJ; + +// Pinhole camera calibration: intrinsics, distortion, rectification, and +// projection for one camera frame. Mirrors the calibration payload of +// sensor_msgs/CameraInfo. Matrices are row-major. Correlated to an image or +// depth topic by topic-name convention (`/camera_info` <-> `/image_raw`); +// the message carries no topic linkage. Sub-window fields (binning, ROI) are +// intentionally omitted and may be appended later. +message CameraInfo { + // Timestamp associated with this calibration. + google.protobuf.Timestamp timestamp = 1; + + // Camera optical frame. + string frame_id = 2; + + // Image width in pixels. + uint32 width = 3; + + // Image height in pixels. + uint32 height = 4; + + // Distortion model name, e.g. "plumb_bob", "rational_polynomial", "equidistant". + // Empty for a rectified camera with no distortion. + string distortion_model = 5; + + // Distortion coefficients; size depends on `distortion_model`. + repeated double D = 6; + + // 3x3 row-major intrinsic matrix [fx 0 cx; 0 fy cy; 0 0 1] (9 values). + repeated double K = 7; + + // 3x3 row-major rectification matrix (identity for monocular) (9 values). + repeated double R = 8; + + // 3x4 row-major projection / camera matrix (12 values). + repeated double P = 9; +} diff --git a/pj_base/proto/pj/OccupancyGridUpdate.proto b/pj_base/proto/pj/OccupancyGridUpdate.proto new file mode 100644 index 0000000..f89a973 --- /dev/null +++ b/pj_base/proto/pj/OccupancyGridUpdate.proto @@ -0,0 +1,50 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +// PlotJuggler canonical occupancy-grid-update protobuf schema. +// Wire-level contract for the byte-backed `OccupancyGridUpdate` builtin +// (SDK struct lives in pj_base/include/pj_base/builtin/occupancy_grid_update.hpp). + +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +package PJ; + +// An incremental sub-rectangle patch for a previously-published OccupancyGrid. +// +// The patch deliberately carries NO origin/resolution: it is not independently +// placeable. A stateful consumer pairs it with the base grid by topic-name +// convention (`/costmap_updates` <-> `/costmap`) and positions it +// at the base grid's `origin + (x, y) * resolution`. +// +// Cell values are signed 8-bit integers stored row-major in `data`: +// -1 = unknown / no data +// 0..100 = probability of occupied, in percent +// `data.size()` must equal `width * height`. +// +// On the SDK side, `data` is exposed as `Span` plus a +// `BufferAnchor` (same byte-backed view pattern as OccupancyGrid). The anchor +// is a C++ lifetime concept with no wire-format equivalent. +message OccupancyGridUpdate { + // Timestamp of the update. + google.protobuf.Timestamp timestamp = 1; + + // Frame of reference; must match the base grid's frame. + string frame_id = 2; + + // Column offset (in cells) of the patch top-left into the base grid. + int32 x = 3; + + // Row offset (in cells) of the patch top-left into the base grid. + int32 y = 4; + + // Patch width in cells. + uint32 width = 5; + + // Patch height in cells. + uint32 height = 6; + + // Row-major signed 8-bit cell values. `data.size()` must equal `width * height`. + bytes data = 7; +} diff --git a/pj_base/proto/pj/README.md b/pj_base/proto/pj/README.md index bd09694..7177e17 100644 --- a/pj_base/proto/pj/README.md +++ b/pj_base/proto/pj/README.md @@ -23,6 +23,11 @@ rationale. - **`FrameTransforms.proto`** — TF-style coordinate frame relationships so consumers can place data in a common world frame. - `FrameTransform`, `FrameTransforms` +### Camera calibration + +- **`CameraInfo.proto`** — pinhole camera calibration (intrinsics, distortion, rectification, projection) so consumers can draw frustums, back-project depth, and rectify; correlated to an image topic by name. + - `CameraInfo` + ### Byte-backed raster builtins - **`Image.proto`** — single 2D image, raw (`rgb8`, `mono16`, …) or compressed (`jpeg`, `png`, `qoi`) unified under a single `encoding` string. @@ -31,6 +36,8 @@ rationale. - `DepthImage` - **`OccupancyGrid.proto`** — 2D metric occupancy grid (maps, costmaps) placed in world coordinates via an origin pose + cell resolution. - `OccupancyGrid` +- **`OccupancyGridUpdate.proto`** — incremental sub-rectangle patch for a previously-published `OccupancyGrid`; placed by the consumer against the base grid (no own origin/resolution). + - `OccupancyGridUpdate` - **`VideoFrame.proto`** — one frame of an inter-frame-coded video stream (`h264`, `h265`, `vp9`, `av1`) when per-frame `Image` messages would be wasteful. - `VideoFrame` - **`AssetVideo.proto`** — reference to a file-backed video plus typed playback metadata (path, MIME type, dimensions, frame rate) so consumers can size playback windows without opening the file. diff --git a/pj_base/src/builtin/camera_info_codec.cpp b/pj_base/src/builtin/camera_info_codec.cpp new file mode 100644 index 0000000..4cd65b4 --- /dev/null +++ b/pj_base/src/builtin/camera_info_codec.cpp @@ -0,0 +1,122 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/camera_info_codec.hpp" + +#include +#include +#include + +#include "geometry_codec.hpp" +#include "protobuf_wire.hpp" + +namespace PJ { +namespace { + +using builtin_wire::parseFields; +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; +using builtin_wire::Writer; +using sdk::CameraInfo; + +} // namespace + +std::vector serializeCameraInfo(const CameraInfo& info) { + std::vector out; + Writer writer(out); + + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, info.timestamp_ns); }); + writer.string(2, info.frame_id); + writer.varint(3, info.width); + writer.varint(4, info.height); + writer.string(5, info.distortion_model); + writer.packedDouble(6, info.D); + writer.packedDouble(7, info.K); + writer.packedDouble(8, info.R); + writer.packedDouble(9, info.P); + + return out; +} + +Expected deserializeCameraInfo(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("CameraInfo wire: empty buffer")); + } + + Reader reader(data, size); + sdk::CameraInfo info; + + const bool ok = parseFields(reader, [&](Tag tag, Reader& r) { + switch (tag.field) { + case 1: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return builtin_wire::readTimestampMessage(r, info.timestamp_ns); + case 2: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return r.readString(info.frame_id); + case 3: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + info.width = static_cast(v); + return true; + } + case 4: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + info.height = static_cast(v); + return true; + } + case 5: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return r.readString(info.distortion_model); + case 6: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + info.D.clear(); + return builtin_wire::readPackedDouble(r, info.D); + case 7: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return builtin_wire::readPackedDoubleArray(r, info.K); + case 8: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return builtin_wire::readPackedDoubleArray(r, info.R); + case 9: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return builtin_wire::readPackedDoubleArray(r, info.P); + default: + return false; + } + }); + + if (!ok) { + return unexpected(std::string("CameraInfo wire: decode failed")); + } + + return info; +} + +} // namespace PJ diff --git a/pj_base/src/builtin/occupancy_grid_update_codec.cpp b/pj_base/src/builtin/occupancy_grid_update_codec.cpp new file mode 100644 index 0000000..4c34619 --- /dev/null +++ b/pj_base/src/builtin/occupancy_grid_update_codec.cpp @@ -0,0 +1,137 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/occupancy_grid_update_codec.hpp" + +#include +#include +#include +#include +#include + +#include "geometry_codec.hpp" +#include "protobuf_wire.hpp" + +namespace PJ { +namespace { + +using builtin_wire::parseFields; +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; +using builtin_wire::Writer; +using sdk::OccupancyGridUpdate; + +bool readBytesIntoUpdate(Reader& reader, OccupancyGridUpdate& out) { + const uint8_t* data = nullptr; + size_t size = 0; + if (!reader.readBytes(data, size)) { + return false; + } + auto owned = std::make_shared>(data, data + size); + out.data = Span(owned->data(), owned->size()); + out.anchor = owned; + return true; +} + +} // namespace + +std::vector serializeOccupancyGridUpdate(const OccupancyGridUpdate& update) { + std::vector out; + Writer writer(out); + + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, update.timestamp_ns); }); + writer.string(2, update.frame_id); + // x / y are signed cell offsets. Round-trip the full int32 range by writing + // the 32-bit pattern as a varint (zero-extended) and reinterpreting on read. + writer.varint(3, static_cast(update.x)); + writer.varint(4, static_cast(update.y)); + writer.varint(5, update.width); + writer.varint(6, update.height); + writer.bytes(7, update.data.data(), update.data.size()); + + return out; +} + +Expected deserializeOccupancyGridUpdate(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("OccupancyGridUpdate wire: empty buffer")); + } + + Reader reader(data, size); + sdk::OccupancyGridUpdate update; + + const bool ok = parseFields(reader, [&](Tag tag, Reader& r) { + switch (tag.field) { + case 1: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return builtin_wire::readTimestampMessage(r, update.timestamp_ns); + case 2: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return r.readString(update.frame_id); + case 3: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + update.x = static_cast(static_cast(v)); + return true; + } + case 4: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + update.y = static_cast(static_cast(v)); + return true; + } + case 5: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + update.width = static_cast(v); + return true; + } + case 6: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + update.height = static_cast(v); + return true; + } + case 7: + if (tag.type != WireType::kLengthDelimited) { + return false; + } + return readBytesIntoUpdate(r, update); + default: + return false; + } + }); + + if (!ok) { + return unexpected(std::string("OccupancyGridUpdate wire: decode failed")); + } + + return update; +} + +} // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 5d2c56f..374a16a 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -115,6 +115,8 @@ static_assert(PJ_BUILTIN_OBJECT_TYPE_VIDEO_FRAME == 10, "VideoFrame type id pinn static_assert(PJ_BUILTIN_OBJECT_TYPE_SCENE_ENTITIES == 11, "SceneEntities type id pinned"); static_assert(PJ_BUILTIN_OBJECT_TYPE_ASSET_VIDEO == 12, "AssetVideo type id pinned"); static_assert(PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION == 13, "RobotDescription type id pinned"); +static_assert(PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO == 14, "CameraInfo type id pinned"); +static_assert(PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE == 15, "OccupancyGridUpdate type id pinned"); static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); static_assert(offsetof(PJ_schema_classification_t, object_type) == 0, "object_type at offset 0"); static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); diff --git a/pj_base/tests/builtin_object_test.cpp b/pj_base/tests/builtin_object_test.cpp index 6e88e5a..b53a279 100644 --- a/pj_base/tests/builtin_object_test.cpp +++ b/pj_base/tests/builtin_object_test.cpp @@ -8,6 +8,7 @@ using PJ::sdk::AssetVideo; using PJ::sdk::BuiltinObject; using PJ::sdk::BuiltinObjectType; +using PJ::sdk::CameraInfo; using PJ::sdk::CompressedPointCloud; using PJ::sdk::DepthImage; using PJ::sdk::FrameTransforms; @@ -16,6 +17,7 @@ using PJ::sdk::ImageAnnotations; using PJ::sdk::Mesh3D; using PJ::sdk::name; using PJ::sdk::OccupancyGrid; +using PJ::sdk::OccupancyGridUpdate; using PJ::sdk::parseBuiltinObjectType; using PJ::sdk::PointCloud; using PJ::sdk::RobotDescription; @@ -37,6 +39,8 @@ TEST(BuiltinObjectTest, TypeOfRecognizesKnownBuiltinTypes) { EXPECT_EQ(typeOf(BuiltinObject{SceneEntities{}}), BuiltinObjectType::kSceneEntities); EXPECT_EQ(typeOf(BuiltinObject{AssetVideo{}}), BuiltinObjectType::kAssetVideo); EXPECT_EQ(typeOf(BuiltinObject{RobotDescription{}}), BuiltinObjectType::kRobotDescription); + EXPECT_EQ(typeOf(BuiltinObject{CameraInfo{}}), BuiltinObjectType::kCameraInfo); + EXPECT_EQ(typeOf(BuiltinObject{OccupancyGridUpdate{}}), BuiltinObjectType::kOccupancyGridUpdate); } TEST(BuiltinObjectTest, NameAndParseRoundTripForEveryEnumEntry) { @@ -54,6 +58,8 @@ TEST(BuiltinObjectTest, NameAndParseRoundTripForEveryEnumEntry) { BuiltinObjectType::kSceneEntities, BuiltinObjectType::kAssetVideo, BuiltinObjectType::kRobotDescription, + BuiltinObjectType::kCameraInfo, + BuiltinObjectType::kOccupancyGridUpdate, }) { const auto parsed = parseBuiltinObjectType(name(t)); ASSERT_TRUE(parsed.has_value()) << "parseBuiltinObjectType failed for " << name(t); diff --git a/pj_base/tests/camera_info_codec_test.cpp b/pj_base/tests/camera_info_codec_test.cpp new file mode 100644 index 0000000..2a4d4d4 --- /dev/null +++ b/pj_base/tests/camera_info_codec_test.cpp @@ -0,0 +1,58 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/camera_info_codec.hpp" + +#include + +#include +#include + +namespace PJ { +namespace { + +using sdk::CameraInfo; + +TEST(CameraInfoCodecTest, SchemaName) { + EXPECT_EQ(kSchemaCameraInfo, "PJ.CameraInfo"); +} + +TEST(CameraInfoCodecTest, EmptyBufferProducesError) { + EXPECT_FALSE(deserializeCameraInfo(nullptr, 0).has_value()); +} + +TEST(CameraInfoCodecTest, RoundTripFullCalibration) { + CameraInfo in; + in.timestamp_ns = 1'500'000'000LL; + in.frame_id = "camera_optical"; + in.width = 640; + in.height = 480; + in.distortion_model = "plumb_bob"; + in.D = {0.1, -0.2, 0.001, 0.002, 0.0}; + in.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + in.R = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + in.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}; + + const auto bytes = serializeCameraInfo(in); + auto out = deserializeCameraInfo(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(*out, in); +} + +TEST(CameraInfoCodecTest, RectifiedNoDistortionRoundTrips) { + CameraInfo in; + in.frame_id = "cam"; + in.width = 1; + in.height = 1; + in.K = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + // D empty, R/P default-zero — exercise the packed-empty / default paths. + + const auto bytes = serializeCameraInfo(in); + auto out = deserializeCameraInfo(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(*out, in); + EXPECT_TRUE(out->D.empty()); +} + +} // namespace +} // namespace PJ diff --git a/pj_base/tests/occupancy_grid_update_codec_test.cpp b/pj_base/tests/occupancy_grid_update_codec_test.cpp new file mode 100644 index 0000000..02ce208 --- /dev/null +++ b/pj_base/tests/occupancy_grid_update_codec_test.cpp @@ -0,0 +1,67 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/occupancy_grid_update_codec.hpp" + +#include + +#include +#include +#include + +namespace PJ { +namespace { + +using sdk::OccupancyGridUpdate; + +TEST(OccupancyGridUpdateCodecTest, SchemaName) { + EXPECT_EQ(kSchemaOccupancyGridUpdate, "PJ.OccupancyGridUpdate"); +} + +TEST(OccupancyGridUpdateCodecTest, EmptyBufferProducesError) { + EXPECT_FALSE(deserializeOccupancyGridUpdate(nullptr, 0).has_value()); +} + +TEST(OccupancyGridUpdateCodecTest, RoundTripPatch) { + OccupancyGridUpdate in; + in.timestamp_ns = 7'000'000'000LL; + in.frame_id = "map"; + in.x = 10; + in.y = 20; + in.width = 3; + in.height = 2; + const std::vector cells = {0, 50, 100, 0xFF /* -1 unknown */, 25, 75}; + in.data = Span(cells.data(), cells.size()); + + const auto bytes = serializeOccupancyGridUpdate(in); + auto out = deserializeOccupancyGridUpdate(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->timestamp_ns, in.timestamp_ns); + EXPECT_EQ(out->frame_id, in.frame_id); + EXPECT_EQ(out->x, in.x); + EXPECT_EQ(out->y, in.y); + EXPECT_EQ(out->width, in.width); + EXPECT_EQ(out->height, in.height); + ASSERT_EQ(out->data.size(), cells.size()); + EXPECT_EQ(std::memcmp(out->data.data(), cells.data(), cells.size()), 0); +} + +TEST(OccupancyGridUpdateCodecTest, NegativeOffsetsRoundTrip) { + OccupancyGridUpdate in; + in.frame_id = "map"; + in.x = -5; + in.y = -1; + in.width = 1; + in.height = 1; + const std::vector cells = {42}; + in.data = Span(cells.data(), cells.size()); + + const auto bytes = serializeOccupancyGridUpdate(in); + auto out = deserializeOccupancyGridUpdate(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->x, -5); + EXPECT_EQ(out->y, -1); +} + +} // namespace +} // namespace PJ From 3f3f31a64ea116c86b4f9182472e479bbf5fcd8e Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 22:20:56 +0200 Subject: [PATCH 2/6] feat(pj_base): SceneEntities gains ModelPrimitive + entity deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the SceneEntities canonical type so it can losslessly represent visualization_msgs/Marker: - ModelPrimitive (mesh asset by url or inline data) on SceneEntity — covers MESH_RESOURCE markers, which previously had no home. - deletions[] of SceneEntityDeletion{MATCHING_ID, ALL} on SceneEntities — lets a snapshot producer express the removal half of a stateful stream (Marker DELETE / DELETEALL) without an empty-entity sentinel. Both mirror fields in Foxglove's SceneEntity/SceneUpdate that the original port omitted. Additive only: new struct fields + proto field numbers (models=14, deletions=2), updated hand-written codec + round-trip tests, docs. No new BuiltinObjectType, so the C ABI and abi_layout_sentinels are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/builtin_type.md | 11 +- .../pj_base/builtin/scene_entities.hpp | 39 ++++++- pj_base/proto/pj/SceneEntities.proto | 61 +++++++++- pj_base/src/builtin/scene_entities_codec.cpp | 105 ++++++++++++++++++ pj_base/tests/scene_entities_codec_test.cpp | 72 ++++++++++++ 5 files changed, 280 insertions(+), 8 deletions(-) diff --git a/docs/builtin_type.md b/docs/builtin_type.md index efe7e45..2d35b8c 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -462,8 +462,8 @@ bundles heterogeneous primitives sharing a `frame_id` and timestamp; Use `SceneEntities` when the value is procedural 3D scene content expressible as a small set of primitives: arrows, cubes, spheres, -cylinders, line strips/loops/lists, triangles, text labels, or coordinate -axes glyphs. +cylinders, line strips/loops/lists, triangles, text labels, coordinate +axes glyphs, or model (mesh asset) references. | Field on `SceneEntity` | Type | Notes | |------------------------|------|-------| @@ -472,7 +472,12 @@ axes glyphs. | `id` | `std::string` | Republishing with the same `(topic, id)` replaces the previous entity. | | `lifetime_ns` | `int64_t` | `0` means persist until replaced; otherwise expire `lifetime_ns` after `timestamp`. | | `frame_locked` | `bool` | When true, track `frame_id` as it moves; when false, stamp into the fixed frame at publish time. | -| `arrows` / `cubes` / `spheres` / `cylinders` / `lines` / `triangles` / `texts` / `axes` | `std::vector<…Primitive>` | Heterogeneous primitive lists. | +| `arrows` / `cubes` / `spheres` / `cylinders` / `lines` / `triangles` / `texts` / `axes` / `models` | `std::vector<…Primitive>` | Heterogeneous primitive lists. `models` references a mesh asset by `url` or inline `data`. | + +The `SceneEntities` batch also carries `deletions` (`std::vector`): +removal commands that let a snapshot-based producer express the removal half of a +stateful stream (e.g. ROS Marker `DELETE` / `DELETEALL`). A deletion is either +`kMatchingId` (remove the entity with the given `id`) or `kAll` (clear the topic). Each primitive carries its own `Pose`, geometry-specific size or shape fields, and color (or per-vertex colors, where applicable). See diff --git a/pj_base/include/pj_base/builtin/scene_entities.hpp b/pj_base/include/pj_base/builtin/scene_entities.hpp index 18e0676..e977640 100644 --- a/pj_base/include/pj_base/builtin/scene_entities.hpp +++ b/pj_base/include/pj_base/builtin/scene_entities.hpp @@ -121,6 +121,21 @@ struct AxesPrimitive { bool operator==(const AxesPrimitive&) const = default; }; +/// A 3D model (mesh asset) primitive. Mirrors Foxglove's ModelPrimitive: the +/// source is either `url` (a resolvable resource, e.g. package://… or file://…) +/// or inline `data` tagged by `media_type`. `color` tints the model only when +/// `override_color` is true; otherwise the model's embedded materials are used. +struct ModelPrimitive { + Pose pose; + Vector3 scale; ///< Per-axis scale factor applied to the model. + ColorRGBA color; + bool override_color = false; ///< When true, `color` replaces the model's own materials. + std::string url; ///< Resource URI of the model (empty when inline). + std::string media_type; ///< MIME type of inline `data` (e.g. "model/gltf-binary"). + std::vector data; ///< Inline model bytes (empty when `url` is used). + bool operator==(const ModelPrimitive&) const = default; +}; + /// A visual element in a 3D scene composed of multiple primitives, all /// sharing the same frame of reference and timestamp. /// @@ -147,17 +162,37 @@ struct SceneEntity { std::vector triangles; std::vector texts; std::vector axes; + std::vector models; bool operator==(const SceneEntity&) const = default; }; -/// Batch of scene entities published together. +/// A command to remove previously-published entities on a topic. Mirrors +/// Foxglove's SceneEntityDeletion. Lets a snapshot-based producer express the +/// removal half of a stateful stream (e.g. ROS Marker DELETE / DELETEALL) +/// without an out-of-band channel. A consumer applies deletions against the +/// entities it has accumulated for the topic. +struct SceneEntityDeletion { + enum class Type : uint8_t { + kMatchingId = 0, ///< Delete the entity on this topic whose `id` matches. + kAll = 1, ///< Delete every entity on this topic. + }; + Type type = Type::kMatchingId; + Timestamp timestamp = 0; ///< Bounds the deletion to entities at or before this time. + std::string id; ///< Entity id to match when `type == kMatchingId`. + bool operator==(const SceneEntityDeletion&) const = default; +}; + +/// Batch of scene entities published together. A self-contained snapshot: +/// `entities` are added/replaced (by `(topic, id)`), `deletions` remove prior +/// entities. Entities not present and not deleted persist (subject to lifetime). struct SceneEntities { std::vector entities; + std::vector deletions; bool operator==(const SceneEntities&) const = default; [[nodiscard]] bool empty() const noexcept { - return entities.empty(); + return entities.empty() && deletions.empty(); } }; diff --git a/pj_base/proto/pj/SceneEntities.proto b/pj_base/proto/pj/SceneEntities.proto index f79b6f9..7b3aaba 100644 --- a/pj_base/proto/pj/SceneEntities.proto +++ b/pj_base/proto/pj/SceneEntities.proto @@ -184,6 +184,32 @@ message AxesPrimitive { bool scale_invariant = 4; } +// A 3D model (mesh asset) primitive. The source is either `url` (a resolvable resource) or inline `data` tagged by +// `media_type`. `color` tints the model only when `override_color` is true; otherwise the model's embedded materials +// are used. +message ModelPrimitive { + // Origin pose of the model + PJ.Pose pose = 1; + + // Per-axis scale factor applied to the model + PJ.Vector3 scale = 2; + + // Solid color; applied only when `override_color` is true + PJ.Color color = 3; + + // When true, `color` replaces the model's own/embedded materials + bool override_color = 4; + + // Resource URI of the model (empty when the model is provided inline via `data`) + string url = 5; + + // MIME type of `data` when the model is provided inline (e.g. "model/gltf-binary") + string media_type = 6; + + // Inline model bytes (empty when `url` is used) + bytes data = 7; +} + // A visual element in a 3D scene. An entity is composed of one or more primitives, all of which share the same frame // of reference and timestamp. The entity itself has no top-level pose; each primitive carries its own pose. // @@ -236,11 +262,40 @@ message SceneEntity { // Axes-glyph primitives composing this entity repeated PJ.AxesPrimitive axes = 13; + + // Model (mesh asset) primitives composing this entity + repeated PJ.ModelPrimitive models = 14; } -// A batch of scene entities published together. Each payload is a self-contained snapshot; entity-deletion semantics -// may be introduced later if streaming sources require them. +// A command to remove previously-published entities on a topic. `MATCHING_ID` removes the entity with the given `id`; +// `ALL` removes every entity on the topic. `timestamp` bounds the deletion to entities published at or before it. +message SceneEntityDeletion { + // Which entities a deletion matches + enum Type { + // Delete the entity on this topic whose id matches `id` + MATCHING_ID = 0; + + // Delete all entities on this topic + ALL = 1; + } + + // Timestamp of the deletion; only entities at or before this time are removed + google.protobuf.Timestamp timestamp = 1; + + // Type of deletion + Type type = 2; + + // Entity id to match when `type` is MATCHING_ID + string id = 3; +} + +// A batch of scene entities published together. Each payload is a self-contained snapshot: `entities` are added or +// replaced (keyed by `(topic, id)`) and `deletions` remove previously-published entities. Entities neither present nor +// deleted persist until they expire by lifetime. message SceneEntities { - // Array of scene entities + // Scene entities to add or replace repeated PJ.SceneEntity entities = 1; + + // Commands removing previously-published entities + repeated PJ.SceneEntityDeletion deletions = 2; } diff --git a/pj_base/src/builtin/scene_entities_codec.cpp b/pj_base/src/builtin/scene_entities_codec.cpp index 1195f9d..2983b4c 100644 --- a/pj_base/src/builtin/scene_entities_codec.cpp +++ b/pj_base/src/builtin/scene_entities_codec.cpp @@ -26,8 +26,10 @@ using sdk::CubePrimitive; using sdk::CylinderPrimitive; using sdk::LinePrimitive; using sdk::LineType; +using sdk::ModelPrimitive; using sdk::SceneEntities; using sdk::SceneEntity; +using sdk::SceneEntityDeletion; using sdk::SpherePrimitive; using sdk::TextPrimitive; using sdk::TrianglePrimitive; @@ -375,6 +377,95 @@ bool decodeAxesPrimitive(Reader& reader, AxesPrimitive& out) { }); } +// ---------- ModelPrimitive ---------- + +void writeModelPrimitive(Writer& writer, const ModelPrimitive& p) { + writer.message(1, [&](Writer& nested) { builtin_wire::writePose(nested, p.pose); }); + writer.message(2, [&](Writer& nested) { builtin_wire::writeVector3(nested, p.scale); }); + writer.message(3, [&](Writer& nested) { builtin_wire::writeColor(nested, p.color); }); + writer.varint(4, p.override_color ? 1u : 0u); + writer.string(5, p.url); + writer.string(6, p.media_type); + if (!p.data.empty()) { + writer.bytes(7, p.data.data(), p.data.size()); + } +} + +bool decodeModelPrimitive(Reader& reader, ModelPrimitive& out) { + return parseFields(reader, [&](Tag tag, Reader& r) { + switch (tag.field) { + case 1: + return tag.type == WireType::kLengthDelimited && builtin_wire::readPoseMessage(r, out.pose); + case 2: + return tag.type == WireType::kLengthDelimited && builtin_wire::readVector3Message(r, out.scale); + case 3: + return tag.type == WireType::kLengthDelimited && builtin_wire::readColorMessage(r, out.color); + case 4: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + out.override_color = (v != 0); + return true; + } + case 5: + return tag.type == WireType::kLengthDelimited && r.readString(out.url); + case 6: + return tag.type == WireType::kLengthDelimited && r.readString(out.media_type); + case 7: { + if (tag.type != WireType::kLengthDelimited) { + return false; + } + const uint8_t* data = nullptr; + size_t size = 0; + if (!r.readBytes(data, size)) { + return false; + } + out.data.assign(data, data + size); + return true; + } + default: + return false; + } + }); +} + +// ---------- SceneEntityDeletion ---------- + +void writeSceneEntityDeletion(Writer& writer, const SceneEntityDeletion& d) { + // Duration/Timestamp share the seconds+nanos wire shape. + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, d.timestamp); }); + writer.varint(2, static_cast(d.type)); + writer.string(3, d.id); +} + +bool decodeSceneEntityDeletion(Reader& reader, SceneEntityDeletion& out) { + return parseFields(reader, [&](Tag tag, Reader& r) { + switch (tag.field) { + case 1: + return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, out.timestamp); + case 2: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + out.type = (v == 1) ? SceneEntityDeletion::Type::kAll : SceneEntityDeletion::Type::kMatchingId; + return true; + } + case 3: + return tag.type == WireType::kLengthDelimited && r.readString(out.id); + default: + return false; + } + }); +} + // ---------- Nested-primitive read helpers ---------- template @@ -425,6 +516,9 @@ void writeSceneEntity(Writer& writer, const SceneEntity& e) { for (const auto& a : e.axes) { writer.message(13, [&](Writer& nested) { writeAxesPrimitive(nested, a); }); } + for (const auto& m : e.models) { + writer.message(14, [&](Writer& nested) { writeModelPrimitive(nested, m); }); + } } bool decodeSceneEntity(Reader& reader, SceneEntity& out) { @@ -467,6 +561,8 @@ bool decodeSceneEntity(Reader& reader, SceneEntity& out) { return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.texts, decodeTextPrimitive); case 13: return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.axes, decodeAxesPrimitive); + case 14: + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.models, decodeModelPrimitive); default: return false; } @@ -482,6 +578,9 @@ std::vector serializeSceneEntities(const SceneEntities& entities) { for (const auto& entity : entities.entities) { writer.message(1, [&](Writer& nested) { writeSceneEntity(nested, entity); }); } + for (const auto& deletion : entities.deletions) { + writer.message(2, [&](Writer& nested) { writeSceneEntityDeletion(nested, deletion); }); + } return out; } @@ -518,6 +617,12 @@ Expected deserializeSceneEntities(const uint8_t* data, size_ return unexpected(std::string("SceneEntities wire: SceneEntity decode failed")); } entities.entities.push_back(std::move(entity)); + } else if (tag.field == 2) { + SceneEntityDeletion deletion; + if (!decodeSceneEntityDeletion(nested, deletion)) { + return unexpected(std::string("SceneEntities wire: SceneEntityDeletion decode failed")); + } + entities.deletions.push_back(std::move(deletion)); } } diff --git a/pj_base/tests/scene_entities_codec_test.cpp b/pj_base/tests/scene_entities_codec_test.cpp index b186bb9..bb82442 100644 --- a/pj_base/tests/scene_entities_codec_test.cpp +++ b/pj_base/tests/scene_entities_codec_test.cpp @@ -20,11 +20,13 @@ using sdk::CubePrimitive; using sdk::CylinderPrimitive; using sdk::LinePrimitive; using sdk::LineType; +using sdk::ModelPrimitive; using sdk::Point3; using sdk::Pose; using sdk::Quaternion; using sdk::SceneEntities; using sdk::SceneEntity; +using sdk::SceneEntityDeletion; using sdk::SpherePrimitive; using sdk::TextPrimitive; using sdk::TrianglePrimitive; @@ -197,5 +199,75 @@ TEST(SceneEntitiesCodecTest, RoundTripLineWithPerVertexColorsAndIndices) { EXPECT_TRUE(ColorNear(ColorRGBA{0, 255, 0, 255}, dst_line.colors[1])); } +TEST(SceneEntitiesCodecTest, RoundTripModelPrimitive) { + SceneEntities in; + SceneEntity e; + e.frame_id = "base_link"; + e.id = "robot_mesh"; + ModelPrimitive model; + model.pose = makePose(1.0, 2.0, 3.0); + model.scale = {.x = 2.0, .y = 2.0, .z = 2.0}; + model.color = {10, 20, 30, 200}; + model.override_color = true; + model.url = "package://robot/meshes/base.dae"; + model.media_type = "model/vnd.collada+xml"; + model.data = {0x01, 0x02, 0x03, 0x04}; + e.models.push_back(std::move(model)); + in.entities.push_back(std::move(e)); + + const auto bytes = serializeSceneEntities(in); + auto out = deserializeSceneEntities(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + ASSERT_EQ(out->entities.size(), 1u); + ASSERT_EQ(out->entities.front().models.size(), 1u); + const auto& src = in.entities.front().models.front(); + const auto& dst = out->entities.front().models.front(); + EXPECT_EQ(dst.pose, src.pose); + EXPECT_EQ(dst.scale, src.scale); + EXPECT_TRUE(ColorNear(src.color, dst.color)); + EXPECT_TRUE(dst.override_color); + EXPECT_EQ(dst.url, src.url); + EXPECT_EQ(dst.media_type, src.media_type); + EXPECT_EQ(dst.data, src.data); +} + +TEST(SceneEntitiesCodecTest, RoundTripEntitiesAndDeletions) { + SceneEntities in; + SceneEntity e; + e.frame_id = "world"; + e.id = "keep"; + e.cubes.push_back( + CubePrimitive{ + .pose = makePose(0.0, 0.0, 0.0), .size = {.x = 1.0, .y = 1.0, .z = 1.0}, .color = {255, 255, 255, 255}}); + in.entities.push_back(std::move(e)); + in.deletions.push_back( + SceneEntityDeletion{.type = SceneEntityDeletion::Type::kMatchingId, .timestamp = 42, .id = "gone"}); + in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp = 99, .id = ""}); + + const auto bytes = serializeSceneEntities(in); + auto out = deserializeSceneEntities(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + ASSERT_EQ(out->entities.size(), 1u); + ASSERT_EQ(out->deletions.size(), 2u); + EXPECT_EQ(out->deletions[0].type, SceneEntityDeletion::Type::kMatchingId); + EXPECT_EQ(out->deletions[0].timestamp, 42); + EXPECT_EQ(out->deletions[0].id, "gone"); + EXPECT_EQ(out->deletions[1].type, SceneEntityDeletion::Type::kAll); + EXPECT_EQ(out->deletions[1].timestamp, 99); +} + +TEST(SceneEntitiesCodecTest, DeletionsOnlyBatchRoundTrips) { + SceneEntities in; + in.deletions.push_back(SceneEntityDeletion{.type = SceneEntityDeletion::Type::kAll, .timestamp = 7, .id = ""}); + EXPECT_FALSE(in.empty()); + + const auto bytes = serializeSceneEntities(in); + auto out = deserializeSceneEntities(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_TRUE(out->entities.empty()); + ASSERT_EQ(out->deletions.size(), 1u); + EXPECT_EQ(out->deletions[0].type, SceneEntityDeletion::Type::kAll); +} + } // namespace } // namespace PJ From 334b35dd881b00b9e62cd8209cfb4c802502339f Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 22:33:08 +0200 Subject: [PATCH 3/6] refactor(pj_base): align SceneEntities schema with Foxglove field order No consumers exist yet, so renumber freely to match Foxglove's SceneEntity/SceneUpdate 1:1: - SceneEntity: add metadata=6 (new KeyValuePair message) and shift the primitive lists to 7..14 (arrows..models), matching Foxglove exactly. - AxesPrimitive has no Foxglove counterpart; it moves to field 15, after the Foxglove range, as a documented PlotJuggler extension. - SceneEntities: order deletions=1, entities=2 (Foxglove SceneUpdate order). C++ struct member names are unchanged, so producers/consumers that build by name are unaffected; only the wire numbering moves. Codec + round-trip tests updated (metadata coverage added). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pj_base/builtin/scene_entities.hpp | 14 ++- pj_base/proto/pj/SceneEntities.proto | 43 ++++++---- pj_base/src/builtin/scene_entities_codec.cpp | 86 +++++++++++++------ pj_base/tests/scene_entities_codec_test.cpp | 4 + 4 files changed, 103 insertions(+), 44 deletions(-) diff --git a/pj_base/include/pj_base/builtin/scene_entities.hpp b/pj_base/include/pj_base/builtin/scene_entities.hpp index e977640..3e21b16 100644 --- a/pj_base/include/pj_base/builtin/scene_entities.hpp +++ b/pj_base/include/pj_base/builtin/scene_entities.hpp @@ -136,6 +136,14 @@ struct ModelPrimitive { bool operator==(const ModelPrimitive&) const = default; }; +/// Arbitrary key/value metadata attached to a SceneEntity (mirrors Foxglove's +/// KeyValuePair). Keys should be unique within an entity. +struct KeyValuePair { + std::string key; + std::string value; + bool operator==(const KeyValuePair&) const = default; +}; + /// A visual element in a 3D scene composed of multiple primitives, all /// sharing the same frame of reference and timestamp. /// @@ -154,6 +162,10 @@ struct SceneEntity { int64_t lifetime_ns = 0; ///< 0 means persist until replaced. bool frame_locked = false; + std::vector metadata; + + // Primitive lists, in Foxglove SceneEntity field order. `axes` is a + // PlotJuggler extension with no Foxglove counterpart and sorts last. std::vector arrows; std::vector cubes; std::vector spheres; @@ -161,8 +173,8 @@ struct SceneEntity { std::vector lines; std::vector triangles; std::vector texts; - std::vector axes; std::vector models; + std::vector axes; bool operator==(const SceneEntity&) const = default; }; diff --git a/pj_base/proto/pj/SceneEntities.proto b/pj_base/proto/pj/SceneEntities.proto index 7b3aaba..1fcda2e 100644 --- a/pj_base/proto/pj/SceneEntities.proto +++ b/pj_base/proto/pj/SceneEntities.proto @@ -239,32 +239,45 @@ message SceneEntity { // publish time. bool frame_locked = 5; + // Arbitrary user-provided key/value metadata. Keys should be unique. + repeated PJ.KeyValuePair metadata = 6; + // Arrow primitives composing this entity - repeated PJ.ArrowPrimitive arrows = 6; + repeated PJ.ArrowPrimitive arrows = 7; // Cube primitives composing this entity - repeated PJ.CubePrimitive cubes = 7; + repeated PJ.CubePrimitive cubes = 8; // Sphere primitives composing this entity - repeated PJ.SpherePrimitive spheres = 8; + repeated PJ.SpherePrimitive spheres = 9; // Cylinder primitives composing this entity - repeated PJ.CylinderPrimitive cylinders = 9; + repeated PJ.CylinderPrimitive cylinders = 10; // Line primitives composing this entity - repeated PJ.LinePrimitive lines = 10; + repeated PJ.LinePrimitive lines = 11; // Triangle primitives composing this entity - repeated PJ.TrianglePrimitive triangles = 11; + repeated PJ.TrianglePrimitive triangles = 12; // Text primitives composing this entity - repeated PJ.TextPrimitive texts = 12; - - // Axes-glyph primitives composing this entity - repeated PJ.AxesPrimitive axes = 13; + repeated PJ.TextPrimitive texts = 13; // Model (mesh asset) primitives composing this entity repeated PJ.ModelPrimitive models = 14; + + // Axes-glyph primitives composing this entity. PlotJuggler extension with no Foxglove counterpart; placed after the + // Foxglove field range (1..14). + repeated PJ.AxesPrimitive axes = 15; +} + +// Arbitrary key/value metadata attached to a SceneEntity (mirrors Foxglove's KeyValuePair). +message KeyValuePair { + // Key. Should be unique within a SceneEntity. + string key = 1; + + // Value. + string value = 2; } // A command to remove previously-published entities on a topic. `MATCHING_ID` removes the entity with the given `id`; @@ -291,11 +304,11 @@ message SceneEntityDeletion { // A batch of scene entities published together. Each payload is a self-contained snapshot: `entities` are added or // replaced (keyed by `(topic, id)`) and `deletions` remove previously-published entities. Entities neither present nor -// deleted persist until they expire by lifetime. +// deleted persist until they expire by lifetime. Field order matches Foxglove's SceneUpdate (deletions, then entities). message SceneEntities { - // Scene entities to add or replace - repeated PJ.SceneEntity entities = 1; - // Commands removing previously-published entities - repeated PJ.SceneEntityDeletion deletions = 2; + repeated PJ.SceneEntityDeletion deletions = 1; + + // Scene entities to add or replace + repeated PJ.SceneEntity entities = 2; } diff --git a/pj_base/src/builtin/scene_entities_codec.cpp b/pj_base/src/builtin/scene_entities_codec.cpp index 2983b4c..6f87875 100644 --- a/pj_base/src/builtin/scene_entities_codec.cpp +++ b/pj_base/src/builtin/scene_entities_codec.cpp @@ -24,6 +24,7 @@ using sdk::ArrowPrimitive; using sdk::AxesPrimitive; using sdk::CubePrimitive; using sdk::CylinderPrimitive; +using sdk::KeyValuePair; using sdk::LinePrimitive; using sdk::LineType; using sdk::ModelPrimitive; @@ -466,6 +467,29 @@ bool decodeSceneEntityDeletion(Reader& reader, SceneEntityDeletion& out) { }); } +// ---------- KeyValuePair (SceneEntity.metadata) ---------- + +void writeKeyValuePair(Writer& writer, const KeyValuePair& kv) { + writer.string(1, kv.key); + writer.string(2, kv.value); +} + +bool decodeKeyValuePair(Reader& reader, KeyValuePair& out) { + return parseFields(reader, [&](Tag tag, Reader& r) { + if (tag.type != WireType::kLengthDelimited) { + return false; + } + switch (tag.field) { + case 1: + return r.readString(out.key); + case 2: + return r.readString(out.value); + default: + return false; + } + }); +} + // ---------- Nested-primitive read helpers ---------- template @@ -492,33 +516,36 @@ void writeSceneEntity(Writer& writer, const SceneEntity& e) { writer.message(4, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, e.lifetime_ns); }); writer.varint(5, e.frame_locked ? 1u : 0u); + for (const auto& kv : e.metadata) { + writer.message(6, [&](Writer& nested) { writeKeyValuePair(nested, kv); }); + } for (const auto& a : e.arrows) { - writer.message(6, [&](Writer& nested) { writeArrowPrimitive(nested, a); }); + writer.message(7, [&](Writer& nested) { writeArrowPrimitive(nested, a); }); } for (const auto& c : e.cubes) { - writer.message(7, [&](Writer& nested) { writeCubePrimitive(nested, c); }); + writer.message(8, [&](Writer& nested) { writeCubePrimitive(nested, c); }); } for (const auto& s : e.spheres) { - writer.message(8, [&](Writer& nested) { writeSpherePrimitive(nested, s); }); + writer.message(9, [&](Writer& nested) { writeSpherePrimitive(nested, s); }); } for (const auto& c : e.cylinders) { - writer.message(9, [&](Writer& nested) { writeCylinderPrimitive(nested, c); }); + writer.message(10, [&](Writer& nested) { writeCylinderPrimitive(nested, c); }); } for (const auto& l : e.lines) { - writer.message(10, [&](Writer& nested) { writeLinePrimitive(nested, l); }); + writer.message(11, [&](Writer& nested) { writeLinePrimitive(nested, l); }); } for (const auto& t : e.triangles) { - writer.message(11, [&](Writer& nested) { writeTrianglePrimitive(nested, t); }); + writer.message(12, [&](Writer& nested) { writeTrianglePrimitive(nested, t); }); } for (const auto& t : e.texts) { - writer.message(12, [&](Writer& nested) { writeTextPrimitive(nested, t); }); - } - for (const auto& a : e.axes) { - writer.message(13, [&](Writer& nested) { writeAxesPrimitive(nested, a); }); + writer.message(13, [&](Writer& nested) { writeTextPrimitive(nested, t); }); } for (const auto& m : e.models) { writer.message(14, [&](Writer& nested) { writeModelPrimitive(nested, m); }); } + for (const auto& a : e.axes) { + writer.message(15, [&](Writer& nested) { writeAxesPrimitive(nested, a); }); + } } bool decodeSceneEntity(Reader& reader, SceneEntity& out) { @@ -544,25 +571,27 @@ bool decodeSceneEntity(Reader& reader, SceneEntity& out) { return true; } case 6: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.arrows, decodeArrowPrimitive); + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.metadata, decodeKeyValuePair); case 7: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.cubes, decodeCubePrimitive); + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.arrows, decodeArrowPrimitive); case 8: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.spheres, decodeSpherePrimitive); + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.cubes, decodeCubePrimitive); case 9: + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.spheres, decodeSpherePrimitive); + case 10: return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.cylinders, decodeCylinderPrimitive); - case 10: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.lines, decodeLinePrimitive); case 11: + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.lines, decodeLinePrimitive); + case 12: return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.triangles, decodeTrianglePrimitive); - case 12: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.texts, decodeTextPrimitive); case 13: - return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.axes, decodeAxesPrimitive); + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.texts, decodeTextPrimitive); case 14: return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.models, decodeModelPrimitive); + case 15: + return tag.type == WireType::kLengthDelimited && readPrimitiveIntoVector(r, out.axes, decodeAxesPrimitive); default: return false; } @@ -575,11 +604,12 @@ std::vector serializeSceneEntities(const SceneEntities& entities) { std::vector out; Writer writer(out); - for (const auto& entity : entities.entities) { - writer.message(1, [&](Writer& nested) { writeSceneEntity(nested, entity); }); - } + // Field order mirrors Foxglove SceneUpdate: deletions = 1, entities = 2. for (const auto& deletion : entities.deletions) { - writer.message(2, [&](Writer& nested) { writeSceneEntityDeletion(nested, deletion); }); + writer.message(1, [&](Writer& nested) { writeSceneEntityDeletion(nested, deletion); }); + } + for (const auto& entity : entities.entities) { + writer.message(2, [&](Writer& nested) { writeSceneEntity(nested, entity); }); } return out; @@ -612,17 +642,17 @@ Expected deserializeSceneEntities(const uint8_t* data, size_ } if (tag.field == 1) { - SceneEntity entity; - if (!decodeSceneEntity(nested, entity)) { - return unexpected(std::string("SceneEntities wire: SceneEntity decode failed")); - } - entities.entities.push_back(std::move(entity)); - } else if (tag.field == 2) { SceneEntityDeletion deletion; if (!decodeSceneEntityDeletion(nested, deletion)) { return unexpected(std::string("SceneEntities wire: SceneEntityDeletion decode failed")); } entities.deletions.push_back(std::move(deletion)); + } else if (tag.field == 2) { + SceneEntity entity; + if (!decodeSceneEntity(nested, entity)) { + return unexpected(std::string("SceneEntities wire: SceneEntity decode failed")); + } + entities.entities.push_back(std::move(entity)); } } diff --git a/pj_base/tests/scene_entities_codec_test.cpp b/pj_base/tests/scene_entities_codec_test.cpp index bb82442..e4efc45 100644 --- a/pj_base/tests/scene_entities_codec_test.cpp +++ b/pj_base/tests/scene_entities_codec_test.cpp @@ -204,6 +204,7 @@ TEST(SceneEntitiesCodecTest, RoundTripModelPrimitive) { SceneEntity e; e.frame_id = "base_link"; e.id = "robot_mesh"; + e.metadata.push_back({.key = "source", .value = "urdf"}); ModelPrimitive model; model.pose = makePose(1.0, 2.0, 3.0); model.scale = {.x = 2.0, .y = 2.0, .z = 2.0}; @@ -219,6 +220,9 @@ TEST(SceneEntitiesCodecTest, RoundTripModelPrimitive) { auto out = deserializeSceneEntities(bytes.data(), bytes.size()); ASSERT_TRUE(out.has_value()); ASSERT_EQ(out->entities.size(), 1u); + ASSERT_EQ(out->entities.front().metadata.size(), 1u); + EXPECT_EQ(out->entities.front().metadata.front().key, "source"); + EXPECT_EQ(out->entities.front().metadata.front().value, "urdf"); ASSERT_EQ(out->entities.front().models.size(), 1u); const auto& src = in.entities.front().models.front(); const auto& dst = out->entities.front().models.front(); From 3d696dcac61d13ead41321de94855acc8a8fee3e Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 22:33:23 +0200 Subject: [PATCH 4/6] fix(pj_base): preserve repeated D chunks in CameraInfo decode Drop the per-occurrence info.D.clear() in the field-6 handler. readPackedDouble appends, so a distortion-coefficient array split across multiple packed chunks (valid protobuf, e.g. after message merge/concatenation) was losing all but the last chunk. Now matches the DepthImage decoder. (Codex review P2.) Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/src/builtin/camera_info_codec.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pj_base/src/builtin/camera_info_codec.cpp b/pj_base/src/builtin/camera_info_codec.cpp index 4cd65b4..44ab8d4 100644 --- a/pj_base/src/builtin/camera_info_codec.cpp +++ b/pj_base/src/builtin/camera_info_codec.cpp @@ -90,7 +90,9 @@ Expected deserializeCameraInfo(const uint8_t* data, size_t size if (tag.type != WireType::kLengthDelimited) { return false; } - info.D.clear(); + // No clear(): readPackedDouble appends, so a `D` field split across + // multiple packed chunks (valid proto, e.g. after message merge) is + // preserved — matching the DepthImage decoder. return builtin_wire::readPackedDouble(r, info.D); case 7: if (tag.type != WireType::kLengthDelimited) { From 66cbe34095bf478a57109c91b90c0462992a05f7 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 22:54:53 +0200 Subject: [PATCH 5/6] fix(pj_base): use shared KeyValuePair.proto in SceneEntities SceneEntity.metadata referenced an inline KeyValuePair message duplicated in SceneEntities.proto, but a canonical pj/KeyValuePair.proto already exists (and is imported by the annotation schemas). Import it instead of redefining, so PJ.KeyValuePair has a single definition. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/proto/pj/SceneEntities.proto | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/pj_base/proto/pj/SceneEntities.proto b/pj_base/proto/pj/SceneEntities.proto index 1fcda2e..fe3355b 100644 --- a/pj_base/proto/pj/SceneEntities.proto +++ b/pj_base/proto/pj/SceneEntities.proto @@ -8,6 +8,7 @@ syntax = "proto3"; import "pj/Color.proto"; import "pj/Geometry.proto"; +import "pj/KeyValuePair.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; @@ -271,15 +272,6 @@ message SceneEntity { repeated PJ.AxesPrimitive axes = 15; } -// Arbitrary key/value metadata attached to a SceneEntity (mirrors Foxglove's KeyValuePair). -message KeyValuePair { - // Key. Should be unique within a SceneEntity. - string key = 1; - - // Value. - string value = 2; -} - // A command to remove previously-published entities on a topic. `MATCHING_ID` removes the entity with the given `id`; // `ALL` removes every entity on the topic. `timestamp` bounds the deletion to entities published at or before it. message SceneEntityDeletion { From f58666887804b3cca3d45c079a91e9af40d6e41a Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Wed, 27 May 2026 22:55:16 +0200 Subject: [PATCH 6/6] feat(pj_base): add Log canonical builtin type (kLog=16) A small owned builtin for textual log messages (timestamp, level, message, name), for a log/console panel. Mirrors the core of Foxglove's Log schema and rcl_interfaces/Log; the source-location fields (file, line) are intentionally omitted. Wires kLog=16 through builtin_object_abi.h (C enum), builtin_object.hpp (enum + name/parse/typeOf + include), abi_layout_sentinels + builtin_object tests, and CMake (codec source + round-trip test). New log.hpp / log_codec.* / Log.proto follow the established owned-type pattern; docs + proto README updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/builtin_type.md | 17 ++++ pj_base/CMakeLists.txt | 2 + .../pj_base/builtin/builtin_object.hpp | 10 ++ pj_base/include/pj_base/builtin/log.hpp | 44 +++++++++ pj_base/include/pj_base/builtin/log_codec.hpp | 24 +++++ pj_base/include/pj_base/builtin_object_abi.h | 1 + pj_base/proto/pj/Log.proto | 38 ++++++++ pj_base/proto/pj/README.md | 2 + pj_base/src/builtin/log_codec.cpp | 93 +++++++++++++++++++ pj_base/tests/abi_layout_sentinels_test.cpp | 1 + pj_base/tests/builtin_object_test.cpp | 3 + pj_base/tests/log_codec_test.cpp | 55 +++++++++++ 12 files changed, 290 insertions(+) create mode 100644 pj_base/include/pj_base/builtin/log.hpp create mode 100644 pj_base/include/pj_base/builtin/log_codec.hpp create mode 100644 pj_base/proto/pj/Log.proto create mode 100644 pj_base/src/builtin/log_codec.cpp create mode 100644 pj_base/tests/log_codec_test.cpp diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 2d35b8c..91bc5a1 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -122,6 +122,7 @@ annotations, frame transforms, or no builtin object. | `kRobotDescription` | `PJ::sdk::RobotDescription` | Raw URDF/SDF/MJCF text + format hint. | | `kCameraInfo` | `PJ::sdk::CameraInfo` | Pinhole camera calibration (intrinsics K, distortion D, rectification R, projection P). | | `kOccupancyGridUpdate` | `PJ::sdk::OccupancyGridUpdate` | Incremental sub-rectangle patch for a previously-published `OccupancyGrid`. | +| `kLog` | `PJ::sdk::Log` | Textual log message (severity level + text + originating name). | `BuiltinObject` is `std::any`. Producers store a concrete builtin value in it; consumers recover the concrete type with `std::any_cast(&object)` or ask @@ -551,6 +552,22 @@ omitted; they are additive later if a consumer needs them. `pj_base/builtin/camera_info_codec.hpp` serializes and deserializes this type using the canonical `PJ.CameraInfo` protobuf wire format. +## Log + +`Log` is a single textual log message, for a log/console panel. It mirrors the +core of Foxglove's `Log` schema (and `rcl_interfaces/Log` / `rosgraph_msgs/Log`). + +| Field on `Log` | Type | Notes | +|----------------|------|-------| +| `timestamp_ns` | `Timestamp` | Time of the log message. | +| `level` | `Log::Level` | `kUnknown`/`kDebug`/`kInfo`/`kWarning`/`kError`/`kFatal` (values match Foxglove). | +| `message` | `std::string` | Log text. | +| `name` | `std::string` | Originating process / node / logger name. | + +Foxglove's source-location fields (`file`, `line`) are intentionally omitted. +`pj_base/builtin/log_codec.hpp` serializes and deserializes this type using the +canonical `PJ.Log` protobuf wire format. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 1fbf6e9..879b33f 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(pj_base STATIC src/builtin/frame_transforms_codec.cpp src/builtin/image_annotations_codec.cpp src/builtin/image_codec.cpp + src/builtin/log_codec.cpp src/builtin/mesh3d_codec.cpp src/builtin/occupancy_grid_codec.cpp src/builtin/occupancy_grid_update_codec.cpp @@ -87,6 +88,7 @@ if(PJ_BUILD_TESTS) tests/occupancy_grid_codec_test.cpp tests/occupancy_grid_update_codec_test.cpp tests/camera_info_codec_test.cpp + tests/log_codec_test.cpp tests/mesh3d_codec_test.cpp tests/video_frame_codec_test.cpp tests/scene_entities_codec_test.cpp diff --git a/pj_base/include/pj_base/builtin/builtin_object.hpp b/pj_base/include/pj_base/builtin/builtin_object.hpp index e666478..2c0ebda 100644 --- a/pj_base/include/pj_base/builtin/builtin_object.hpp +++ b/pj_base/include/pj_base/builtin/builtin_object.hpp @@ -33,6 +33,7 @@ #include "pj_base/builtin/frame_transforms.hpp" #include "pj_base/builtin/image.hpp" #include "pj_base/builtin/image_annotations.hpp" +#include "pj_base/builtin/log.hpp" #include "pj_base/builtin/mesh3d.hpp" #include "pj_base/builtin/occupancy_grid.hpp" #include "pj_base/builtin/occupancy_grid_update.hpp" @@ -60,6 +61,7 @@ enum class BuiltinObjectType : uint16_t { kRobotDescription = 13, ///< sdk::RobotDescription — raw URDF/SDF/MJCF text + format hint. kCameraInfo = 14, ///< sdk::CameraInfo — pinhole camera calibration (K/D/R/P). kOccupancyGridUpdate = 15, ///< sdk::OccupancyGridUpdate — incremental sub-rectangle patch for an OccupancyGrid. + kLog = 16, ///< sdk::Log — textual log message (level + text + name). }; /// A-priori classification of a schema. Currently carries only the type; @@ -102,6 +104,8 @@ struct SchemaClassification { return "kCameraInfo"; case BuiltinObjectType::kOccupancyGridUpdate: return "kOccupancyGridUpdate"; + case BuiltinObjectType::kLog: + return "kLog"; } return "kNone"; } @@ -154,6 +158,9 @@ struct SchemaClassification { if (s == "kOccupancyGridUpdate") { return BuiltinObjectType::kOccupancyGridUpdate; } + if (s == "kLog") { + return BuiltinObjectType::kLog; + } return std::nullopt; } @@ -209,6 +216,9 @@ using BuiltinObject = std::any; if (t == typeid(OccupancyGridUpdate)) { return BuiltinObjectType::kOccupancyGridUpdate; } + if (t == typeid(Log)) { + return BuiltinObjectType::kLog; + } return BuiltinObjectType::kNone; } diff --git a/pj_base/include/pj_base/builtin/log.hpp b/pj_base/include/pj_base/builtin/log.hpp new file mode 100644 index 0000000..2f1b054 --- /dev/null +++ b/pj_base/include/pj_base/builtin/log.hpp @@ -0,0 +1,44 @@ +/** + * @file log.hpp + * @brief A single textual log message (level + text + originating name). + * + * Log is a small owned builtin — no byte blob, no BufferAnchor. It mirrors the + * core of Foxglove's Log schema (and rcl_interfaces/Log / rosgraph_msgs/Log), + * expressed as canonical PJ vocabulary. The Foxglove source-location fields + * (`file`, `line`) are intentionally omitted. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "pj_base/types.hpp" + +namespace PJ { +namespace sdk { + +/// A textual log message at a point in time. +struct Log { + /// Severity level. Values match Foxglove's Log.Level. + enum class Level : uint8_t { + kUnknown = 0, + kDebug = 1, + kInfo = 2, + kWarning = 3, + kError = 4, + kFatal = 5, + }; + + Timestamp timestamp_ns = 0; + Level level = Level::kUnknown; + std::string message; ///< Log text. + std::string name; ///< Originating process / node / logger name. + + bool operator==(const Log&) const = default; +}; + +} // namespace sdk +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin/log_codec.hpp b/pj_base/include/pj_base/builtin/log_codec.hpp new file mode 100644 index 0000000..4ac4421 --- /dev/null +++ b/pj_base/include/pj_base/builtin/log_codec.hpp @@ -0,0 +1,24 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include "pj_base/builtin/log.hpp" +#include "pj_base/expected.hpp" + +namespace PJ { + +inline constexpr std::string_view kSchemaLog = "PJ.Log"; + +/// Serializes sdk::Log to canonical PJ.Log wire bytes +/// (see pj_base/proto/pj/Log.proto). +[[nodiscard]] std::vector serializeLog(const sdk::Log& log); + +/// Decodes canonical PJ.Log wire bytes into an owned sdk::Log. +[[nodiscard]] Expected deserializeLog(const uint8_t* data, size_t size); + +} // namespace PJ diff --git a/pj_base/include/pj_base/builtin_object_abi.h b/pj_base/include/pj_base/builtin_object_abi.h index cfb5e78..35ec152 100644 --- a/pj_base/include/pj_base/builtin_object_abi.h +++ b/pj_base/include/pj_base/builtin_object_abi.h @@ -55,6 +55,7 @@ typedef enum PJ_builtin_object_type_t { PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION = 13, PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO = 14, PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE = 15, + PJ_BUILTIN_OBJECT_TYPE_LOG = 16, /* Reserve future types; appended at the tail. Numeric values are stable * across releases — never renumber. Each new value here must match the * matching kFoo entry in BuiltinObjectType (builtin_object.hpp). */ diff --git a/pj_base/proto/pj/Log.proto b/pj_base/proto/pj/Log.proto new file mode 100644 index 0000000..acf8007 --- /dev/null +++ b/pj_base/proto/pj/Log.proto @@ -0,0 +1,38 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +// PlotJuggler canonical log-message protobuf schema. +// Wire-level contract for the owned `Log` builtin +// (SDK struct lives in pj_base/include/pj_base/builtin/log.hpp). + +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +package PJ; + +// A single textual log message. Mirrors the core of Foxglove's Log schema; the +// source-location fields (file, line) are intentionally omitted. +message Log { + // Severity level. Values match Foxglove's Log.Level. + enum Level { + UNKNOWN = 0; + DEBUG = 1; + INFO = 2; + WARNING = 3; + ERROR = 4; + FATAL = 5; + } + + // Timestamp of the log message. + google.protobuf.Timestamp timestamp = 1; + + // Severity level. + Level level = 2; + + // Log text. + string message = 3; + + // Originating process / node / logger name. + string name = 4; +} diff --git a/pj_base/proto/pj/README.md b/pj_base/proto/pj/README.md index 7177e17..8d142ef 100644 --- a/pj_base/proto/pj/README.md +++ b/pj_base/proto/pj/README.md @@ -38,6 +38,8 @@ rationale. - `OccupancyGrid` - **`OccupancyGridUpdate.proto`** — incremental sub-rectangle patch for a previously-published `OccupancyGrid`; placed by the consumer against the base grid (no own origin/resolution). - `OccupancyGridUpdate` +- **`Log.proto`** — a single textual log message (severity level + text + originating name) for a log/console panel; mirrors the core of Foxglove's `Log` (file/line omitted). + - `Log` - **`VideoFrame.proto`** — one frame of an inter-frame-coded video stream (`h264`, `h265`, `vp9`, `av1`) when per-frame `Image` messages would be wasteful. - `VideoFrame` - **`AssetVideo.proto`** — reference to a file-backed video plus typed playback metadata (path, MIME type, dimensions, frame rate) so consumers can size playback windows without opening the file. diff --git a/pj_base/src/builtin/log_codec.cpp b/pj_base/src/builtin/log_codec.cpp new file mode 100644 index 0000000..821b7c8 --- /dev/null +++ b/pj_base/src/builtin/log_codec.cpp @@ -0,0 +1,93 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/log_codec.hpp" + +#include +#include +#include + +#include "geometry_codec.hpp" +#include "protobuf_wire.hpp" + +namespace PJ { +namespace { + +using builtin_wire::parseFields; +using builtin_wire::Reader; +using builtin_wire::Tag; +using builtin_wire::WireType; +using builtin_wire::Writer; +using sdk::Log; + +Log::Level levelFromWire(uint64_t value) { + switch (value) { + case 1: + return Log::Level::kDebug; + case 2: + return Log::Level::kInfo; + case 3: + return Log::Level::kWarning; + case 4: + return Log::Level::kError; + case 5: + return Log::Level::kFatal; + default: + return Log::Level::kUnknown; + } +} + +} // namespace + +std::vector serializeLog(const Log& log) { + std::vector out; + Writer writer(out); + + writer.message(1, [&](Writer& nested) { builtin_wire::writeTimestamp(nested, log.timestamp_ns); }); + writer.varint(2, static_cast(log.level)); + writer.string(3, log.message); + writer.string(4, log.name); + + return out; +} + +Expected deserializeLog(const uint8_t* data, size_t size) { + if (data == nullptr || size == 0) { + return unexpected(std::string("Log wire: empty buffer")); + } + + Reader reader(data, size); + sdk::Log log; + + const bool ok = parseFields(reader, [&](Tag tag, Reader& r) { + switch (tag.field) { + case 1: + return tag.type == WireType::kLengthDelimited && builtin_wire::readTimestampMessage(r, log.timestamp_ns); + case 2: { + if (tag.type != WireType::kVarint) { + return false; + } + uint64_t v = 0; + if (!r.readVarint(v)) { + return false; + } + log.level = levelFromWire(v); + return true; + } + case 3: + return tag.type == WireType::kLengthDelimited && r.readString(log.message); + case 4: + return tag.type == WireType::kLengthDelimited && r.readString(log.name); + default: + return false; + } + }); + + if (!ok) { + return unexpected(std::string("Log wire: decode failed")); + } + + return log; +} + +} // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 374a16a..bf39f3c 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -117,6 +117,7 @@ static_assert(PJ_BUILTIN_OBJECT_TYPE_ASSET_VIDEO == 12, "AssetVideo type id pinn static_assert(PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION == 13, "RobotDescription type id pinned"); static_assert(PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO == 14, "CameraInfo type id pinned"); static_assert(PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE == 15, "OccupancyGridUpdate type id pinned"); +static_assert(PJ_BUILTIN_OBJECT_TYPE_LOG == 16, "Log type id pinned"); static_assert(sizeof(PJ_schema_classification_t) == 4, "PJ_schema_classification_t layout pinned"); static_assert(offsetof(PJ_schema_classification_t, object_type) == 0, "object_type at offset 0"); static_assert(offsetof(PJ_schema_classification_t, reserved) == 2, "reserved at offset 2"); diff --git a/pj_base/tests/builtin_object_test.cpp b/pj_base/tests/builtin_object_test.cpp index b53a279..424e870 100644 --- a/pj_base/tests/builtin_object_test.cpp +++ b/pj_base/tests/builtin_object_test.cpp @@ -14,6 +14,7 @@ using PJ::sdk::DepthImage; using PJ::sdk::FrameTransforms; using PJ::sdk::Image; using PJ::sdk::ImageAnnotations; +using PJ::sdk::Log; using PJ::sdk::Mesh3D; using PJ::sdk::name; using PJ::sdk::OccupancyGrid; @@ -41,6 +42,7 @@ TEST(BuiltinObjectTest, TypeOfRecognizesKnownBuiltinTypes) { EXPECT_EQ(typeOf(BuiltinObject{RobotDescription{}}), BuiltinObjectType::kRobotDescription); EXPECT_EQ(typeOf(BuiltinObject{CameraInfo{}}), BuiltinObjectType::kCameraInfo); EXPECT_EQ(typeOf(BuiltinObject{OccupancyGridUpdate{}}), BuiltinObjectType::kOccupancyGridUpdate); + EXPECT_EQ(typeOf(BuiltinObject{Log{}}), BuiltinObjectType::kLog); } TEST(BuiltinObjectTest, NameAndParseRoundTripForEveryEnumEntry) { @@ -60,6 +62,7 @@ TEST(BuiltinObjectTest, NameAndParseRoundTripForEveryEnumEntry) { BuiltinObjectType::kRobotDescription, BuiltinObjectType::kCameraInfo, BuiltinObjectType::kOccupancyGridUpdate, + BuiltinObjectType::kLog, }) { const auto parsed = parseBuiltinObjectType(name(t)); ASSERT_TRUE(parsed.has_value()) << "parseBuiltinObjectType failed for " << name(t); diff --git a/pj_base/tests/log_codec_test.cpp b/pj_base/tests/log_codec_test.cpp new file mode 100644 index 0000000..2a6cdef --- /dev/null +++ b/pj_base/tests/log_codec_test.cpp @@ -0,0 +1,55 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/builtin/log_codec.hpp" + +#include + +#include +#include + +namespace PJ { +namespace { + +using sdk::Log; + +TEST(LogCodecTest, SchemaName) { + EXPECT_EQ(kSchemaLog, "PJ.Log"); +} + +TEST(LogCodecTest, EmptyBufferDeserializesAsError) { + EXPECT_FALSE(deserializeLog(nullptr, 0).has_value()); +} + +TEST(LogCodecTest, RoundTrip) { + Log in; + in.timestamp_ns = 1'234'567'890LL; + in.level = Log::Level::kWarning; + in.message = "disk almost full"; + in.name = "/storage_monitor"; + + const auto bytes = serializeLog(in); + auto out = deserializeLog(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->timestamp_ns, in.timestamp_ns); + EXPECT_EQ(out->level, Log::Level::kWarning); + EXPECT_EQ(out->message, in.message); + EXPECT_EQ(out->name, in.name); +} + +TEST(LogCodecTest, EveryLevelRoundTrips) { + for (auto level : + {Log::Level::kUnknown, Log::Level::kDebug, Log::Level::kInfo, Log::Level::kWarning, Log::Level::kError, + Log::Level::kFatal}) { + Log in; + in.level = level; + in.message = "x"; + const auto bytes = serializeLog(in); + auto out = deserializeLog(bytes.data(), bytes.size()); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->level, level); + } +} + +} // namespace +} // namespace PJ