From eac07f8dfec30b47ed3ad6b2db7ef6b49d72cbd5 Mon Sep 17 00:00:00 2001 From: alvvm Date: Wed, 23 Sep 2026 18:38:32 +0200 Subject: [PATCH 01/22] feat(sdk): field tables for builtin objects and the on_demand data-processor contract Add header-only FieldTable descriptions for PointCloud, FrameTransforms, SceneEntities and ImageAnnotations with a describe(BuiltinObjectType) registry, so a generic script binder and a topic describer read one source of truth. Document kind="on_demand" (typed "name:type" outputs, luau only) and add the DataProcessorsHostView::createOnDemand shim. Client-side only: no ABI or wire change, host contract unchanged. --- CHANGELOG.md | 26 + docs/builtin_type.md | 26 + pj_base/CMakeLists.txt | 1 + .../include/pj_base/builtin/field_table.hpp | 367 ++++++++++ .../pj_base/builtin/field_table_registry.hpp | 58 ++ .../builtin/frame_transforms_fields.hpp | 84 +++ .../builtin/image_annotations_fields.hpp | 88 +++ .../pj_base/builtin/point_cloud_fields.hpp | 88 +++ .../pj_base/builtin/scene_entities_fields.hpp | 212 ++++++ pj_base/include/pj_base/plugin_data_api.h | 15 +- .../include/pj_base/sdk/plugin_data_api.hpp | 24 +- pj_base/tests/field_table_test.cpp | 649 ++++++++++++++++++ 12 files changed, 1633 insertions(+), 5 deletions(-) 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/point_cloud_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/scene_entities_fields.hpp create mode 100644 pj_base/tests/field_table_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6b2595..dd0bbf63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [Unreleased] + +Host contract: unchanged (no floor impact). + +- Add compile-time field tables (`pj_base/builtin/field_table.hpp`) 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. Per-type specializations live in `frame_transforms_fields.hpp`, + `image_annotations_fields.hpp`, `point_cloud_fields.hpp`, and + `scene_entities_fields.hpp`; `field_table_registry.hpp` exposes + `describe(BuiltinObjectType)` to look one up from the runtime tag carried + by a `BuiltinObject`. `FrameTransforms`, `ImageAnnotations`, `PointCloud`, + and `SceneEntities` are tabled today (`kTabledTypeCount == 4`); `PointCloud` + exposes its packed-bytes `data` field through a `kBuffer` descriptor + (`buffer<>()`) resolving a `BufferLayout` view rather than a plain + get/set pair. Client-side only: no ABI or wire-format change. +- Document the `kind="on_demand"` data-processor kind in + `PJ_data_processors_host_vtable_t`'s doc comment (`plugin_data_api.h`): + evaluated at a consumer-requested time rather than eagerly on every data + change; `outputs` entries carry a `":"` suffix (`number`, + `string`, or a `BuiltinObjectType` name); `language` must be `"luau"`. + There is no ABI surface for the on-demand request itself yet — a + tail-appended `evaluate_data_processor_at` slot is a proposed future + addition. Add the matching `DataProcessorsHostView::createOnDemand` + convenience shim over `create()`, alongside `createTransform`/`createMarkers`. + ## [0.34.1] - Fix a data race in `JobControl::armWatchdog`: the self-join guard read the diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 7c212713..95a78832 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -673,6 +673,32 @@ 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 +(`buffer<>()` 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. + +`FrameTransforms`, `ImageAnnotations`, `PointCloud`, and `SceneEntities` are +tabled today (`frame_transforms_fields.hpp`, `image_annotations_fields.hpp`, +`point_cloud_fields.hpp`, `scene_entities_fields.hpp`; `kTabledTypeCount == 4` +in `field_table_registry.hpp`). 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. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | 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/field_table.hpp b/pj_base/include/pj_base/builtin/field_table.hpp new file mode 100644 index 00000000..89c6aa4d --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table.hpp @@ -0,0 +1,367 @@ +/** + * @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 "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` 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). +}; + +/// 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`. + 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`, or `kEnum` field as `double`. + double (*get_number)(const void*) = nullptr; + void (*set_number)(void*, double) = 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, element address, append-one + /// (returns the address of the newly appended element), and truncate-to-empty. + size_t (*list_size)(const void*) = nullptr; + const void* (*list_at)(const void*, size_t) = nullptr; + void* (*list_emplace)(void*) = nullptr; + void (*list_clear)(void*) = 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; + +/// 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; + +/// 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(static_cast(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) = static_cast(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; + +} // 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, 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. +/// 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 (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)"); + } + + 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..6e949315 --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table_registry.hpp @@ -0,0 +1,58 @@ +/** + * @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/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_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::kNone: + case BuiltinObjectType::kImage: + case BuiltinObjectType::kDepthImage: + case BuiltinObjectType::kOccupancyGrid: + case BuiltinObjectType::kCompressedPointCloud: + case BuiltinObjectType::kMesh3D: + case BuiltinObjectType::kVideoFrame: + case BuiltinObjectType::kRobotDescription: + case BuiltinObjectType::kCameraInfo: + 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..8a4bc0cc --- /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_fields.hpp b/pj_base/include/pj_base/builtin/image_annotations_fields.hpp new file mode 100644 index 00000000..18dd6dff --- /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/point_cloud_fields.hpp b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp new file mode 100644 index 00000000..9aa31426 --- /dev/null +++ b/pj_base/include/pj_base/builtin/point_cloud_fields.hpp @@ -0,0 +1,88 @@ +/** + * @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` +/// yields zero records). `buffer_assign()` takes ownership of the new bytes: +/// `data` views them and `anchor` keeps them alive, the same idiom the codec +/// uses (`makePayloadView`). `anchor` itself is not a table field. Concrete on +/// purpose: no other buffer-bearing builtin carries a channel layout, so a +/// generic factory would describe a family of one. +[[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; + layout.record_count = cloud.point_step == 0 ? 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 = [](void* p, std::vector bytes) { + auto& cloud = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + cloud.data = view.bytes; + cloud.anchor = view.anchor; + }; + 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..ee8787c0 --- /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/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 5ee65d51..61488c28 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -908,6 +908,19 @@ typedef struct { * - kind="markers": a discrete PlotMarkers set (events / regions / value bands). * Exactly one output topic; the host publishes the serialized PlotMarkers to the * ObjectStore under markerObjectTopicName(key). + * - kind="on_demand": evaluated at a CONSUMER-requested time rather than eagerly on + * every data change — e.g. a script that reconstructs a PointCloud or + * SceneEntities frame for the sample nearest the playhead, too expensive to + * materialize for the whole series. There is no ABI surface for the request yet; + * a tail-appended `evaluate_data_processor_at` slot is a PROPOSED future addition, + * not part of this release. Each `outputs` entry carries a type suffix + * ":", where is "number", "string", or a BuiltinObjectType + * name (e.g. "kPointCloud", "kSceneEntities") — the host needs the declared shape + * up front to route a later on-demand evaluation without re-running the script. + * out_topics returns, 1:1 with outputs, the resolved physical topic name for an + * object-typed output and the bare (untyped) name for a number/string output. + * `language` must be "luau"; the same "unknown kind is rejected" rule as any + * other kind applies to a host that predates this one. * Future kinds (e.g. a host-owned engine backend) are added the same way — a new * `kind` string plus host routing, no ABI change. * @@ -918,7 +931,7 @@ typedef struct { * data_processor_config). The host scopes list/remove/config to the * calling plugin (per-plugin isolation): one plugin can neither * enumerate nor remove another's. - * - kind : output discriminator, see above ("transform", "markers"). + * - kind : output discriminator, see above ("transform", "markers", "on_demand"). * - language : script backend, "luau" today; the host rejects anything else. * - inputs : topic OR topic-field names ("pose/orientation" or * "pose/orientation/x") the script reads; the host resolves them diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index a1df513c..180c0d6f 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1502,10 +1502,10 @@ class ColorMapRegistryView { /// PJ_data_processors_host_vtable_t). Empty-constructible; `valid()` tells whether the /// host exposed the service. Strings returned by `list()`/`recipeOf()`/`create()` are /// copied into owned values, so they stay valid past the next vtable call. One -/// polymorphic surface serves every `kind` ("transform", "markers"); preview is -/// `create(..., flags=PJ_DATA_PROCESSOR_FLAG_EPHEMERAL)` and teardown is `remove(id)`. -/// `createTransform`/`createEphemeralTransform`/`createMarkers` are thin convenience -/// shims over `create`. +/// polymorphic surface serves every `kind` ("transform", "markers", "on_demand"); +/// preview is `create(..., flags=PJ_DATA_PROCESSOR_FLAG_EPHEMERAL)` and teardown is +/// `remove(id)`. `createTransform`/`createEphemeralTransform`/`createMarkers`/ +/// `createOnDemand` are thin convenience shims over `create`. class DataProcessorsHostView { public: DataProcessorsHostView() = default; @@ -1618,6 +1618,22 @@ class DataProcessorsHostView { return create(id, "markers", "luau", inputs, out_span, script, params_json, flags); } + /// Convenience: create a kind="on_demand" node — evaluated at a + /// CONSUMER-requested time rather than eagerly on every data change (see the + /// kind="on_demand" paragraph on PJ_data_processors_host_vtable_t in + /// plugin_data_api.h; there is no ABI surface for the request yet). Each entry + /// in `typed_outputs` carries the type suffix ":" ("number", + /// "string", or a BuiltinObjectType name such as "kPointCloud"/"kSceneEntities") + /// the host needs to route a later on-demand evaluation without re-running the + /// script. Returns the resolved output identifiers 1:1 with `typed_outputs`: + /// the physical topic name for an object-typed output, the bare name for a + /// number/string output. Inputs MAY be dataset-qualified (see create()). + [[nodiscard]] Expected> createOnDemand( + std::string_view id, Span inputs, Span typed_outputs, + std::string_view script, std::string_view params_json, uint32_t flags = 0) const { + return create(id, "on_demand", "luau", inputs, typed_outputs, script, params_json, flags); + } + /// Remove a previously created node by id (persistent or ephemeral preview). [[nodiscard]] Status remove(std::string_view id) const { if (!valid() || host_.vtable->remove_data_processor == nullptr) { diff --git a/pj_base/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp new file mode 100644 index 00000000..4112d722 --- /dev/null +++ b/pj_base/tests/field_table_test.cpp @@ -0,0 +1,649 @@ +// 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 "pj_base/builtin/builtin_object_codec.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/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_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::CircleAnnotation; +using PJ::sdk::CubePrimitive; +using PJ::sdk::CylinderPrimitive; +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::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; + +// --------------------------------------------------------------------------- +// 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). A kBuffer field resolves its current +// bytes and re-assigns them onto dst, taking a fresh copy/anchor. +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::kStruct: + ASSERT_NE(f.nested, nullptr); + copyThroughTable(*f.nested, f.struct_ptr(src), f.struct_ptr_mut(dst)); + break; + case FieldKind::kList: { + f.list_clear(dst); + const size_t n = f.list_size(src); + for (size_t i = 0; i < n; ++i) { + void* dst_elem = 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; + } + } + } +} + +// --------------------------------------------------------------------------- +// 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; +} + +// --------------------------------------------------------------------------- +// 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, + }; + + std::set visited; + std::vector tables; + for (const auto* root : roots) { + collectTables(*root, visited, tables); + } + ASSERT_EQ(tables.size(), 28u) << "expected all 28 field_table specializations added through step 2"; + + 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::kList: + EXPECT_NE(f.list_size, nullptr); + EXPECT_NE(f.list_at, nullptr); + EXPECT_NE(f.list_emplace, nullptr); + EXPECT_NE(f.list_clear, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.buffer, 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); + + 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); + } + { + 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); + + 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); + } + { + 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); + + 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); + } +} + +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); + + 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); +} + +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); +} + +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); + + // Every other stable BuiltinObjectType value (mirrors the array in + // builtin_object_codec_test.cpp) must resolve to nullptr. + const std::array other_types{ + BuiltinObjectType::kImage, + BuiltinObjectType::kDepthImage, + BuiltinObjectType::kOccupancyGrid, + BuiltinObjectType::kCompressedPointCloud, + BuiltinObjectType::kMesh3D, + BuiltinObjectType::kVideoFrame, + BuiltinObjectType::kRobotDescription, + BuiltinObjectType::kCameraInfo, + 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 From ccbc74fd09b35826c1bbf33668b3cf2e1cf98299 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 24 Sep 2026 07:04:27 +0200 Subject: [PATCH 02/22] test(abi): pin the data-processors host vtable layout; start 0.35.0 The pj.data_processors.v1 vtable had no layout sentinels, so tail slots appended to it could drift unnoticed. Pin the seven existing slots and the fat pointer before the on-demand slots are appended. VERSION moves to 0.35.0 (minor: additions only); the CHANGELOG entry is filled in with the new surfaces once they exist. --- CHANGELOG.md | 2 +- VERSION | 2 +- pj_base/tests/abi_layout_sentinels_test.cpp | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0bbf63..67b4abef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is ## [Unreleased] -Host contract: unchanged (no floor impact). +Host contract: extended: (surfaces listed at the end of phase 0) - Add compile-time field tables (`pj_base/builtin/field_table.hpp`) describing the members of builtin object structs, so a generic binder (e.g. a script diff --git a/VERSION b/VERSION index cd46610f..7b52f5e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.34.1 +0.35.0 diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 7d332c72..d422f026 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -318,6 +318,24 @@ static_assert( "toolbox host object-retention tail slot pinned"); static_assert(sizeof(PJ_toolbox_host_vtable_t) == 104, "Toolbox host size (update deliberately on append)"); +// --- Data processors host vtable ("pj.data_processors.v1", ABI-APPENDABLE) -- +static_assert(offsetof(PJ_data_processors_host_vtable_t, protocol_version) == 0, "data processors host prefix pinned"); +static_assert(offsetof(PJ_data_processors_host_vtable_t, struct_size) == 4, "data processors host prefix pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, create_data_processor) == 8, "data processors host create slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, remove_data_processor) == 16, "data processors host remove slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, list_data_processor_ids) == 24, "data processors host list slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, data_processor_config) == 32, "data processors host config slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, validate_data_processor_script) == 40, + "data processors host validate tail slot pinned"); +static_assert( + sizeof(PJ_data_processors_host_vtable_t) == 48, "Data processors host size (update deliberately on append)"); +static_assert(sizeof(PJ_data_processors_host_t) == 16, "Data processors host fat pointer pinned"); + // --- Toolbox runtime host vtable (ABI-APPENDABLE within v4) ------------------ // The vtable the host exposes to plugins under "pj.toolbox_runtime.v1". // Offsets of existing slots are pinned; size grows deliberately as tail slots append. From 7725916fe0c72c8318672bb575d646eb5ad10379 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 24 Sep 2026 07:26:41 +0200 Subject: [PATCH 03/22] =?UTF-8?q?feat(sdk):=200.35.0=20host=20surfaces=20f?= =?UTF-8?q?or=20derived=20objects=20=E2=80=94=20catalog=20snapshot=20v2,?= =?UTF-8?q?=20typed=20data-processor=20requests,=20evaluation=20handles,?= =?UTF-8?q?=20scene=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tail-appended, struct_size-gated additions (host contract extended, floor 0.35.0): - PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2 returns the scalar catalog plus every object topic with dataset, builtin type, entry count and raw time range in one deep copy (PJ_catalog_snapshot_v2_t, fixed strides). - PJ_data_processors_host_vtable_t gains create_data_processor_v2 (typed outputs, label, INSTANT pin or WINDOW), and the submit/poll/release evaluation triple: an evaluation of an installed on_demand node or of an ephemeral recipe, at an instant or over a window under a cooperative budget, read back as a JSON report through a per-host handle. The host may complete the work inline or in the background; the contract is the same. - pj.scene_views.v1: plugin-owned 3D/2D views with attach/detach/focus and a read-back config, the object counterpart of pj.plot_tabs.v1. - pj_snapshot object-topic metadata marker for clear-and-replace topics. C++ views, layout sentinels, fake-host tests, feature floors, snapshot and docs updated; abidiff shows no additional change over the existing baseline. --- CHANGELOG.md | 49 +- docs/builtin_type.md | 12 + pj_base/CMakeLists.txt | 1 + pj_base/feature_floors.json | 17 + pj_base/include/pj_base/plugin_data_api.h | 210 ++++++++- .../pj_base/sdk/object_topic_metadata.hpp | 20 + .../include/pj_base/sdk/plugin_data_api.hpp | 418 +++++++++++++++++- .../include/pj_base/sdk/service_traits.hpp | 13 + pj_base/tests/abi_layout_sentinels_test.cpp | 72 ++- pj_base/tests/data_processors_api_test.cpp | 235 ++++++++++ pj_base/tests/object_topic_metadata_test.cpp | 21 + pj_base/tests/plugin_data_api_test.cpp | 76 ++++ pj_base/tests/scene_views_api_test.cpp | 347 +++++++++++++++ pj_plugins/docs/toolbox-guide.md | 31 +- pj_plugins/tests/toolbox_plugin_test.cpp | 1 + tools/feature_floors/extract_host_surfaces.py | 1 + tools/feature_floors/host_surfaces.snapshot | 15 + 17 files changed, 1515 insertions(+), 24 deletions(-) create mode 100644 pj_base/tests/scene_views_api_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b4abef..0d56db60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". -## [Unreleased] +## [0.35.0] -Host contract: extended: (surfaces listed at the end of phase 0) +Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.scene_views.v1 (PJ_scene_view_host_vtable_t), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.35.0) - Add compile-time field tables (`pj_base/builtin/field_table.hpp`) describing the members of builtin object structs, so a generic binder (e.g. a script @@ -19,15 +19,42 @@ Host contract: extended: (surfaces listed at the end of phase 0) exposes its packed-bytes `data` field through a `kBuffer` descriptor (`buffer<>()`) resolving a `BufferLayout` view rather than a plain get/set pair. Client-side only: no ABI or wire-format change. -- Document the `kind="on_demand"` data-processor kind in - `PJ_data_processors_host_vtable_t`'s doc comment (`plugin_data_api.h`): - evaluated at a consumer-requested time rather than eagerly on every data - change; `outputs` entries carry a `":"` suffix (`number`, - `string`, or a `BuiltinObjectType` name); `language` must be `"luau"`. - There is no ABI surface for the on-demand request itself yet — a - tail-appended `evaluate_data_processor_at` slot is a proposed future - addition. Add the matching `DataProcessorsHostView::createOnDemand` - convenience shim over `create()`, alongside `createTransform`/`createMarkers`. +- Add catalog snapshot v2: `PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2` + (`ToolboxHostView::catalogSnapshotV2`) returns a second, ABI-VERSIONED + snapshot struct (`PJ_catalog_snapshot_v2_t`) carrying the scalar catalog of + `acquire_catalog_snapshot` PLUS every object topic — dataset, builtin type, + entry count, raw time range (`PJ_object_topic_info_t`). A new struct rather + than a tail-append, because object topics are an ARRAY ELEMENT with a FIXED + STRIDE: a field that cannot be zero-defaulted needs a new struct + slot, + never a layout change to an existing one. +- Add the typed data-processor request vocabulary (`PJ_data_processor_request_t`, + `PJ_data_processor_output_t`, `PJ_evaluation_budget_t`) and + `PJ_data_processors_host_vtable_t::create_data_processor_v2` + (`DataProcessorsHostView::createV2`): typed outputs, a human-readable label, + and — with `PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT` — a pinned evaluation time + for an on_demand finding. This is the ABI surface the `kind="on_demand"` + doc-comment on `PJ_data_processors_host_vtable_t` previously described as + not existing yet; `create_data_processor` (v1) stays valid for the + `":"` output-suffix grammar. +- Add asynchronous on_demand evaluation: `submit_evaluation`/`poll_evaluation`/ + `release_evaluation` (`DataProcessorsHostView::submitEvaluation`/ + `pollEvaluation`/`releaseEvaluation`), gated by the new + `PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW`/`PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT` + bits. A submitted evaluation returns a handle; a host may complete the work + before returning or in the background, and `poll_evaluation` is the only way + to read the result either way, as a JSON report + (`{"coverage":{...},"bundles":[...]}` — one bundle per requested instant, one + entry per output, `*_ns` values as raw int64 dataset nanoseconds). +- Add the `pj.scene_views.v1` service (`PJ_scene_view_host_vtable_t`, + `SceneViewHostView`): lets a plugin compose 3D/2D scene views of its own — + create (upsert by id), attach/detach object topics, read back what a view + actually holds as JSON, focus, close — scoped by the host to the calling + plugin the same way `pj.plot_tabs.v1` scopes plotting tabs. +- Add the `pj_snapshot` object-topic metadata key + (`ObjectTopicMetadataBuilder::snapshot`): marks a SceneEntities/ + ImageAnnotations topic whose every entry is a complete clear-and-replace + snapshot, so a stateless consumer may render each entry alone without + replaying the topic's history. ## [0.34.1] diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 95a78832..5b1b3106 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -699,6 +699,18 @@ 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.35.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/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 7a524252..6ae1e7cb 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -167,6 +167,7 @@ if(PJ_BUILD_TESTS) tests/data_processors_api_test.cpp tests/playback_viewport_api_test.cpp tests/plot_tabs_api_test.cpp + tests/scene_views_api_test.cpp tests/dataset_qualified_name_api_test.cpp tests/settings_store_host_test.cpp tests/parser_runtime_host_test.cpp diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 70d5dd84..350ea22d 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -14,6 +14,23 @@ }, "0.34.0": { "PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT": "" + }, + "0.35.0": { + "PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT": "", + "PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW": "", + "attachTopic": "PJ_scene_view_host_vtable_t::attach_topic", + "catalogSnapshotV2": "PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2", + "closeView": "PJ_scene_view_host_vtable_t::close_view", + "configOf": "PJ_scene_view_host_vtable_t::view_config", + "createV2": "PJ_data_processors_host_vtable_t::create_data_processor_v2", + "createView": "PJ_scene_view_host_vtable_t::create_view", + "detachTopic": "PJ_scene_view_host_vtable_t::detach_topic", + "focusView": "PJ_scene_view_host_vtable_t::focus_view", + "listViews": "PJ_scene_view_host_vtable_t::list_view_ids", + "pj.scene_views.v1": "", + "pollEvaluation": "PJ_data_processors_host_vtable_t::poll_evaluation", + "releaseEvaluation": "PJ_data_processors_host_vtable_t::release_evaluation", + "submitEvaluation": "PJ_data_processors_host_vtable_t::submit_evaluation" } }, "baseline": [ diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 61488c28..13a89346 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -363,6 +363,40 @@ typedef struct { void (*release)(void* release_ctx); } PJ_catalog_snapshot_t; +/* One object topic of the catalog (snapshot v2). ARRAY ELEMENT with a FIXED + * STRIDE: a field that cannot be zero-defaulted needs a new struct + slot, never + * a change of this layout. `reserved` must be 0. */ +typedef struct { + PJ_object_topic_handle_t handle; + PJ_data_source_handle_t source; /* the dataset the topic lives on */ + PJ_string_view_t name; + PJ_string_view_t builtin_object_type; /* PJ::sdk::name() value, "" when unknown */ + PJ_string_view_t metadata_json; /* the topic's whole metadata document */ + uint64_t entry_count; + int64_t time_min_ns; /* raw dataset-domain ns; 0/0 when entry_count == 0 */ + int64_t time_max_ns; + uint64_t reserved[2]; +} PJ_object_topic_info_t; + +/* ABI-VERSIONED (not appendable): a later shape is a new struct + new slot. The + * scalar arrays have exactly the content of PJ_catalog_snapshot_t. Object topics + * include derived and marker topics; clients filter by metadata_json (key + * "pj_derived") or by the "__markers__/" name prefix. */ +typedef struct { + uint32_t struct_size; /* = sizeof(PJ_catalog_snapshot_v2_t), set by the host */ + uint32_t reserved; /* 0 */ + const PJ_data_source_info_t* data_sources; + uint64_t data_source_count; + const PJ_topic_info_t* topics; + uint64_t topic_count; + const PJ_field_info_t* fields; + uint64_t field_count; + const PJ_object_topic_info_t* object_topics; + uint64_t object_topic_count; + void* release_ctx; + void (*release)(void* release_ctx); +} PJ_catalog_snapshot_v2_t; + /* ========================================================================== * Three distinct write-host vtables (protocol v4). * @@ -654,6 +688,13 @@ typedef struct PJ_toolbox_host_vtable_t { * ABI-APPENDED slot: gate via struct_size before calling. */ bool (*set_object_topic_retention)( void* ctx, PJ_object_topic_handle_t topic, uint64_t max_entries, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [stream-thread] Catalog snapshot v2: the scalar catalog of acquire_catalog_snapshot + * PLUS every object topic with its dataset, builtin type, entry count and raw time + * range, in one deep copy. Release with out_snapshot->release(out_snapshot->release_ctx). + * ABI-APPENDED slot: gate via struct_size before calling. */ + bool (*acquire_catalog_snapshot_v2)(void* ctx, PJ_catalog_snapshot_v2_t* out_snapshot, PJ_error_t* out_error) + PJ_NOEXCEPT; } PJ_toolbox_host_vtable_t; typedef struct { @@ -911,9 +952,12 @@ typedef struct { * - kind="on_demand": evaluated at a CONSUMER-requested time rather than eagerly on * every data change — e.g. a script that reconstructs a PointCloud or * SceneEntities frame for the sample nearest the playhead, too expensive to - * materialize for the whole series. There is no ABI surface for the request yet; - * a tail-appended `evaluate_data_processor_at` slot is a PROPOSED future addition, - * not part of this release. Each `outputs` entry carries a type suffix + * materialize for the whole series. The request's ABI surface is + * create_data_processor_v2 / submit_evaluation / poll_evaluation / + * release_evaluation (PJ_data_processor_request_t, PJ_evaluation_budget_t): + * create_data_processor (v1) stays valid to install an on_demand node using the + * ":" output-suffix grammar below, but reading a result requires the + * v2 evaluation surface. Each `outputs` entry carries a type suffix * ":", where is "number", "string", or a BuiltinObjectType * name (e.g. "kPointCloud", "kSceneEntities") — the host needs the declared shape * up front to route a later on-demand evaluation without re-running the script. @@ -999,6 +1043,66 @@ typedef struct { * the property. A missing/false property or failed read does not confirm it. */ #define PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT (1u << 1) +/* PJ_data_processor_request_t.time_flags */ +#define PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW (1u << 0) /* window_start_ns..window_end_ns are meaningful */ +#define PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT \ + (1u << 1) /* time_ns is meaningful: a pinned finding on create, the instant to evaluate on submit */ + +/* A declared output. ARRAY ELEMENT with a FIXED STRIDE (see PJ_object_topic_info_t). + * type: "number" | "string" | a builtin object type name ("kPointCloud") | "" (untyped, + * legacy transform/markers output). */ +typedef struct { + PJ_string_view_t name; + PJ_string_view_t type; +} PJ_data_processor_output_t; + +/* Typed request for create_data_processor_v2 and submit_evaluation. struct_size gates + * the readable prefix: the v1 minimum is offsetof(time_ns) + sizeof(time_ns). A host + * REJECTS (never ignores) a request it cannot honour: unknown flags or time_flags bits, + * nonzero reserved, a count > 0 with a NULL pointer, struct_size below the v1 minimum, + * or struct_size larger than the host's own sizeof (a field added later must be + * zero-defaultable AND announced by a new flag bit, so an old host that does not know + * the bit rejects it). Inputs use the same grammar as create_data_processor (topic + * names, optionally dataset-qualified); the script reads each input under its literal + * name. All strings are borrowed for the duration of the call. */ +typedef struct { + uint32_t struct_size; /* = sizeof(PJ_data_processor_request_t) */ + uint32_t flags; /* PJ_DATA_PROCESSOR_FLAG_* */ + PJ_string_view_t id; + PJ_string_view_t kind; + PJ_string_view_t language; + PJ_string_view_t script; + PJ_string_view_t params_json; + PJ_string_view_t label; /* human-readable name, may be empty */ + const PJ_string_view_t* inputs; + uint64_t input_count; + const PJ_data_processor_output_t* outputs; + uint64_t output_count; + uint32_t time_flags; /* PJ_DATA_PROCESSOR_TIME_FLAG_* */ + uint32_t reserved; /* 0 */ + int64_t window_start_ns; /* raw ns, inclusive; WINDOW */ + int64_t window_end_ns; /* raw ns, inclusive; WINDOW */ + int64_t time_ns; /* raw ns; INSTANT */ +} PJ_data_processor_request_t; + +/* Cooperative computation budget of one submit_evaluation. Checked between + * evaluations and inside the host's native operations, NOT a wall-clock guarantee: + * one script or native call may overrun it. 0 = host default. */ +typedef struct { + uint32_t struct_size; /* = sizeof(PJ_evaluation_budget_t) */ + uint32_t reserved; /* 0 */ + uint64_t max_millis; /* default 1000, host cap 5000 */ + uint64_t max_bytes; /* per-evaluation VM + native ceiling */ + uint64_t max_evaluations; /* WINDOW: instants evaluated; default 200, host cap 2000 */ + uint64_t max_report_bytes; /* whole report; default 1 MiB, host cap 8 MiB */ +} PJ_evaluation_budget_t; + +/* poll_evaluation states */ +#define PJ_EVALUATION_STATE_PENDING 0u +#define PJ_EVALUATION_STATE_COMPLETED 1u +#define PJ_EVALUATION_STATE_FAILED 2u +#define PJ_EVALUATION_STATE_CANCELLED 3u + typedef struct PJ_data_processors_host_vtable_t { uint32_t protocol_version; // = 1 uint32_t struct_size; // = sizeof(PJ_data_processors_host_vtable_t) @@ -1051,6 +1155,45 @@ typedef struct PJ_data_processors_host_vtable_t { bool (*validate_data_processor_script)( void* ctx, PJ_string_view_t kind, PJ_string_view_t language, PJ_string_view_t script, PJ_string_view_t params_json, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] create_data_processor with a typed request: typed outputs, a label, + * and (INSTANT) a pinned evaluation time for an on_demand finding. Same upsert, + * transactional and out_topics (count-then-fill, borrowed until the next call on + * this host object) contract as create_data_processor. ABI-APPENDED slot. */ + bool (*create_data_processor_v2)( + void* ctx, const PJ_data_processor_request_t* request, PJ_string_view_t* out_topics, uint64_t out_topics_capacity, + uint64_t* out_topics_count, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Start an evaluation and return its handle. request->id naming an + * installed on_demand node of THIS plugin with an empty script evaluates that node; + * a non-empty script is an EPHEMERAL recipe (flags must carry EPHEMERAL) evaluated + * without installing or publishing anything. time_flags selects INSTANT (one bundle + * at time_ns) or WINDOW (one bundle per instant at which an input changes inside + * the window, in time order, until the budget stops it). A host may complete the + * work before returning (phase 0) or in the background; poll_evaluation is the only + * way to read the result either way. Handles are per host object, increasing, never + * reused. Kinds other than on_demand are an error. ABI-APPENDED slot. */ + bool (*submit_evaluation)( + void* ctx, const PJ_data_processor_request_t* request, const PJ_evaluation_budget_t* budget, uint64_t* out_handle, + PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Read an evaluation's state. On COMPLETED *out_json is the report: + * {"coverage":{"start_ns","end_ns","evaluated_until_ns"|null,"candidates","evaluated", + * "cache_hits","complete","stopped":"complete"|"budget_time"|"budget_evaluations"| + * "budget_bytes"|"budget_report"|"cancelled"|"error"},"bundles":[...]} + * with one bundle for INSTANT. A bundle is {"requested_ns","stamp_ns","from_cache", + * "revision","inputs":[{"alias","resolved_ns","is_object"}],"outputs":{name:{"status": + * "ok"|"unavailable"|"empty"|"error","value"?,"topic"?,"summary"?,"reason"?}}}. + * Objects never appear as bytes, only as a "summary" object. Every *_ns value is a + * raw dataset nanosecond count as a JSON integer (int64; do not round-trip through + * a double). On FAILED *out_json is {"error":"..."}. *out_json is borrowed until + * release_evaluation(handle). An unknown handle is an error. ABI-APPENDED slot. */ + bool (*poll_evaluation)( + void* ctx, uint64_t handle, uint32_t* out_state, PJ_string_view_t* out_json, PJ_error_t* out_error) PJ_NOEXCEPT; + + /* [main-thread] Cancel a pending evaluation (cooperative) and free its result. + * Releasing an unknown handle is an error; releasing twice is an error. ABI-APPENDED slot. */ + bool (*release_evaluation)(void* ctx, uint64_t handle, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_processors_host_vtable_t; typedef struct { @@ -1311,6 +1454,67 @@ typedef struct { const PJ_plot_tab_host_vtable_t* vtable; } PJ_plot_tab_host_t; +/** + * Scene-view host service ("pj.scene_views.v1", protocol_version 1). + * + * Lets a plugin compose 3D/2D scene views OF ITS OWN: create one, attach and + * detach object topics in it, read back what it holds, close it. Every slot is + * scoped to the calling binding — a view this plugin did not create is + * rejected exactly as an unknown id is, so the service never discloses, + * mutates or even confirms the existence of the user's views. That scoping is + * the host's job, not the plugin's: it is enforced where ownership is known, + * and ownership is derived from `ctx`, never passed in. + * + * Ids are independent of visible titles: titles may repeat or be renamed + * without changing an id. Ids are chosen by the plugin and namespaced per + * plugin by the host (the "pj.data_processors.v1" discipline), so an id is + * unique within this binding and cannot collide with another plugin's. A view + * is a VIEW: closing it discards the presentation only, never the object + * topics it showed. + * + * All slots are [main-thread]. ABI-APPENDABLE: new slots may be added at the + * tail; struct_size gates read. + */ +typedef struct PJ_scene_view_host_vtable_t { + uint32_t protocol_version; /* = 1 */ + uint32_t struct_size; /* = sizeof(PJ_scene_view_host_vtable_t) */ + /* Create or replace (upsert by id) a scene view owned by this plugin. kind is "3d" + * or "2d". Re-creating an id with a DIFFERENT kind closes the view and creates a + * new empty one; the same kind only updates the title. An empty title lets the host + * name it. An id containing '/' is rejected. */ + bool (*create_view)( + void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) PJ_NOEXCEPT; + /* Close one of this plugin's views (the VIEW only; the topics it showed are data and outlive it). */ + bool (*close_view)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; + /* Count-then-fill enumeration of THIS plugin's live view ids (borrowed until the next call on this host object). */ + bool (*list_view_ids)( + void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) PJ_NOEXCEPT; + /* {"kind":"3d","title":"...","topics":[{"topic":"...","dataset":"...","type":"kPointCloud","visible":true}]} + * — what the view ACTUALLY holds, datasets resolved. Borrowed until the next call on this host object. */ + bool (*view_config)(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) + PJ_NOEXCEPT; + /* Attach an object topic. dataset_source is the source NAME as PJ_data_source_info_t + * reports it; "" means the topic must be unique across loaded datasets (an ambiguous + * one is refused with the candidates). "3d" accepts every type the host's 3D view + * renders; "2d" accepts Image/DepthImage (replacing the current image) and + * ImageAnnotations (an overlay); any other type is an error naming it. Attaching a + * topic already there is success. */ + bool (*attach_topic)( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) PJ_NOEXCEPT; + /* Take one topic back out (same dataset_source rule). A topic that is not there is an error. */ + bool (*detach_topic)( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) PJ_NOEXCEPT; + /* Raise the view's tab. */ + bool (*focus_view)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; +} PJ_scene_view_host_vtable_t; + +typedef struct { + void* ctx; + const PJ_scene_view_host_vtable_t* vtable; +} PJ_scene_view_host_t; + #ifdef __cplusplus } #endif 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..733a2258 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,15 @@ namespace PJ::sdk { /// @since 0.21.0 inline constexpr std::string_view kBuiltinObjectTypeMetadataKey = "builtin_object_type"; +/// Canonical metadata key marking a "field table" 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.35.0 +inline constexpr std::string_view kSnapshotMetadataKey = "pj_snapshot"; + /// Builds deterministic metadata JSON for an object topic. /// /// `builtinObjectType()` accepts only the SDK enum and serializes its canonical @@ -64,6 +73,17 @@ class ObjectTopicMetadataBuilder { return *this; } + /// Mark this topic as a snapshot stream (see kSnapshotMetadataKey): every + /// entry is a complete clear-and-replace snapshot. Sets the key when + /// `value` is true; leaves it unset (the default, incremental) when false. + /// @since 0.35.0 + ObjectTopicMetadataBuilder& snapshot(bool value = true) { + if (value) { + strings_.insert_or_assign(std::string(kSnapshotMetadataKey), std::string("true")); + } + 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/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 180c0d6f..a665a8b3 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -174,6 +174,65 @@ class CatalogSnapshot { } }; +/// RAII wrapper around PJ_catalog_snapshot_v2_t — the scalar catalog plus +/// every object topic (dataset, builtin type, entry count, raw time range). +/// See ToolboxHostView::catalogSnapshotV2(). +class CatalogSnapshotV2 { + public: + CatalogSnapshotV2() = default; + explicit CatalogSnapshotV2(PJ_catalog_snapshot_v2_t raw) : raw_(raw) {} + ~CatalogSnapshotV2() { + reset(); + } + + CatalogSnapshotV2(const CatalogSnapshotV2&) = delete; + CatalogSnapshotV2& operator=(const CatalogSnapshotV2&) = delete; + + CatalogSnapshotV2(CatalogSnapshotV2&& other) noexcept : raw_(other.release()) {} + + CatalogSnapshotV2& operator=(CatalogSnapshotV2&& other) noexcept { + if (this != &other) { + reset(); + raw_ = other.release(); + } + return *this; + } + + [[nodiscard]] Span dataSources() const { + return Span(raw_.data_sources, raw_.data_source_count); + } + + [[nodiscard]] Span topics() const { + return Span(raw_.topics, raw_.topic_count); + } + + [[nodiscard]] Span fields() const { + return Span(raw_.fields, raw_.field_count); + } + + /// Object topics (derived and marker topics included). Filter by + /// metadata_json (key "pj_derived") or by the "__markers__/" name prefix. + [[nodiscard]] Span objectTopics() const { + return Span(raw_.object_topics, raw_.object_topic_count); + } + + private: + PJ_catalog_snapshot_v2_t raw_{}; + + [[nodiscard]] PJ_catalog_snapshot_v2_t release() noexcept { + auto raw = raw_; + raw_ = {}; + return raw; + } + + void reset() { + if (raw_.release != nullptr) { + raw_.release(raw_.release_ctx); + raw_ = {}; + } + } +}; + [[nodiscard]] inline std::string_view toStringView(PJ_string_view_t view) { return std::string_view(view.data == nullptr ? "" : view.data, view.size); } @@ -1261,6 +1320,28 @@ class ToolboxHostView { return CatalogSnapshot(raw); } + /// Snapshot v2: the scalar catalog of catalogSnapshot() PLUS every object + /// topic with its dataset, builtin type, entry count and raw time range, + /// in one deep copy. + [[nodiscard]] Expected catalogSnapshotV2() const { + if (!valid()) { + return unexpected("toolbox host is not bound"); + } + if (!PJ_HAS_TAIL_SLOT(PJ_toolbox_host_vtable_t, host_.vtable, acquire_catalog_snapshot_v2)) { + return unexpected("toolbox host does not support acquire_catalog_snapshot_v2"); + } + PJ_catalog_snapshot_v2_t raw{}; + PJ_error_t err{}; + if (!host_.vtable->acquire_catalog_snapshot_v2(host_.ctx, &raw, &err)) { + return unexpected(errorToString(err)); + } + if (raw.struct_size < sizeof(PJ_catalog_snapshot_v2_t)) { + CatalogSnapshotV2 undersized(raw); + return unexpected("toolbox host returned an undersized PJ_catalog_snapshot_v2_t"); + } + return CatalogSnapshotV2(raw); + } + /// Read one field's time series into host-owned Arrow structs. /// /// The caller passes in zero-initialised @p out_schema and @p out_array; @@ -1497,6 +1578,101 @@ class ColorMapRegistryView { // DataProcessorsHostView — typed C++ view over PJ_data_processors_host_t // --------------------------------------------------------------------------- +/// A declared output for the typed create/evaluate request surface. `type` is +/// "number", "string", a builtin object type name ("kPointCloud"), or "" +/// (untyped, legacy transform/markers output). Mirrors PJ_data_processor_output_t. +struct DataProcessorOutput { + std::string name; + std::string type; +}; + +/// Typed request for DataProcessorsHostView::createV2/submitEvaluation. Mirrors +/// PJ_data_processor_request_t (see its doc-comment in plugin_data_api.h for the +/// full contract). `window`/`instant_ns` set time_flags: a `window` selects +/// PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, an `instant_ns` selects +/// PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT; both may be set together. +struct DataProcessorRequest { + std::string id; + std::string kind; + std::string language; + std::string script; + std::string params_json; + std::string label; + std::vector inputs; + std::vector outputs; + uint32_t flags = 0; + std::optional> window; + std::optional instant_ns; +}; + +/// Cooperative computation budget of one submit_evaluation. Mirrors +/// PJ_evaluation_budget_t; 0 fields mean "host default". +struct EvaluationBudget { + uint64_t max_millis = 0; + uint64_t max_bytes = 0; + uint64_t max_evaluations = 0; + uint64_t max_report_bytes = 0; +}; + +/// Mirrors the poll_evaluation states (PJ_EVALUATION_STATE_*). +enum class EvaluationState { kPending, kCompleted, kFailed, kCancelled }; + +/// Result of DataProcessorsHostView::pollEvaluation(): the evaluation's state +/// plus its report JSON (owned copy), see poll_evaluation's doc-comment in +/// plugin_data_api.h for the report schema. +struct EvaluationPoll { + EvaluationState state = EvaluationState::kPending; + std::string json; +}; + +namespace detail { + +/// Builds the ABI request for a DataProcessorRequest, filling `in_abi`/`out_abi` +/// with borrowed views into `request`'s owned strings — the caller must keep +/// `request`, `in_abi`, and `out_abi` alive for the duration of the ABI call. +[[nodiscard]] inline PJ_data_processor_request_t toAbiRequest( + const DataProcessorRequest& request, std::vector& in_abi, + std::vector& out_abi) { + in_abi.clear(); + in_abi.reserve(request.inputs.size()); + for (const auto& name : request.inputs) { + in_abi.push_back(toAbiString(name)); + } + out_abi.clear(); + out_abi.reserve(request.outputs.size()); + for (const auto& output : request.outputs) { + out_abi.push_back(PJ_data_processor_output_t{toAbiString(output.name), toAbiString(output.type)}); + } + uint32_t time_flags = 0; + if (request.window.has_value()) { + time_flags |= PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW; + } + if (request.instant_ns.has_value()) { + time_flags |= PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT; + } + PJ_data_processor_request_t raw{}; + raw.struct_size = sizeof(PJ_data_processor_request_t); + raw.flags = request.flags; + raw.id = toAbiString(request.id); + raw.kind = toAbiString(request.kind); + raw.language = toAbiString(request.language); + raw.script = toAbiString(request.script); + raw.params_json = toAbiString(request.params_json); + raw.label = toAbiString(request.label); + raw.inputs = in_abi.data(); + raw.input_count = in_abi.size(); + raw.outputs = out_abi.data(); + raw.output_count = out_abi.size(); + raw.time_flags = time_flags; + raw.reserved = 0; + raw.window_start_ns = request.window.has_value() ? request.window->first : 0; + raw.window_end_ns = request.window.has_value() ? request.window->second : 0; + raw.time_ns = request.instant_ns.value_or(0); + return raw; +} + +} // namespace detail + /// C++ wrapper around PJ_data_processors_host_t for plugins that submit WHOLE-SERIES /// data processors to the host by data (see the C ABI doc-comment on /// PJ_data_processors_host_vtable_t). Empty-constructible; `valid()` tells whether the @@ -1621,13 +1797,14 @@ class DataProcessorsHostView { /// Convenience: create a kind="on_demand" node — evaluated at a /// CONSUMER-requested time rather than eagerly on every data change (see the /// kind="on_demand" paragraph on PJ_data_processors_host_vtable_t in - /// plugin_data_api.h; there is no ABI surface for the request yet). Each entry - /// in `typed_outputs` carries the type suffix ":" ("number", - /// "string", or a BuiltinObjectType name such as "kPointCloud"/"kSceneEntities") - /// the host needs to route a later on-demand evaluation without re-running the - /// script. Returns the resolved output identifiers 1:1 with `typed_outputs`: - /// the physical topic name for an object-typed output, the bare name for a - /// number/string output. Inputs MAY be dataset-qualified (see create()). + /// plugin_data_api.h). Each entry in `typed_outputs` carries the type suffix + /// ":" ("number", "string", or a BuiltinObjectType name such as + /// "kPointCloud"/"kSceneEntities") the host needs to route a later on-demand + /// evaluation without re-running the script. Returns the resolved output + /// identifiers 1:1 with `typed_outputs`: the physical topic name for an + /// object-typed output, the bare name for a number/string output. Inputs MAY + /// be dataset-qualified (see create()). For typed outputs, a label, or a + /// pinned evaluation time, use createV2() instead. [[nodiscard]] Expected> createOnDemand( std::string_view id, Span inputs, Span typed_outputs, std::string_view script, std::string_view params_json, uint32_t flags = 0) const { @@ -1702,6 +1879,119 @@ class DataProcessorsHostView { return okStatus(); } + /// create_data_processor with a typed request: typed outputs, a label, and + /// (with `request.instant_ns` set) a pinned evaluation time for an on_demand + /// finding. Same upsert, transactional, and resolved-topic-names contract as + /// create(). Errors if the host predates this slot. + [[nodiscard]] Expected> createV2(const DataProcessorRequest& request) const { + if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2)) { + return unexpected("data processors host does not support create_data_processor_v2"); + } + std::vector in_abi; + std::vector out_abi; + const PJ_data_processor_request_t raw = detail::toAbiRequest(request, in_abi, out_abi); + PJ_error_t err{}; + uint64_t capacity = out_abi.empty() ? 8 : out_abi.size(); + std::vector resolved(capacity); + uint64_t count = 0; + if (!host_.vtable->create_data_processor_v2(host_.ctx, &raw, resolved.data(), resolved.size(), &count, &err)) { + return unexpected(errorToString(err)); + } + if (count > capacity) { + resolved.assign(count, PJ_string_view_t{}); + if (!host_.vtable->create_data_processor_v2(host_.ctx, &raw, resolved.data(), resolved.size(), &count, &err)) { + return unexpected(errorToString(err)); + } + } + std::vector topics; + topics.reserve(count); + for (uint64_t i = 0; i < count && i < resolved.size(); ++i) { + topics.emplace_back(toStringView(resolved[i])); + } + return topics; + } + + /// Start an evaluation and return its handle. `request.id` naming an + /// installed on_demand node of this plugin with an empty script evaluates + /// that node; a non-empty script is an EPHEMERAL recipe (flags must carry + /// PJ_DATA_PROCESSOR_FLAG_EPHEMERAL) evaluated without installing or + /// publishing anything. `request.instant_ns` selects one bundle at that + /// instant; `request.window` selects one bundle per instant an input + /// changes inside the window, in time order, until the budget stops it. A + /// host may complete the work before returning or in the background; + /// pollEvaluation() is the only way to read the result either way. Errors + /// if the host predates this slot. + [[nodiscard]] Expected submitEvaluation( + const DataProcessorRequest& request, const EvaluationBudget& budget = {}) const { + if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, submit_evaluation)) { + return unexpected("data processors host does not support submit_evaluation"); + } + std::vector in_abi; + std::vector out_abi; + const PJ_data_processor_request_t raw = detail::toAbiRequest(request, in_abi, out_abi); + PJ_evaluation_budget_t raw_budget{}; + raw_budget.struct_size = sizeof(PJ_evaluation_budget_t); + raw_budget.reserved = 0; + raw_budget.max_millis = budget.max_millis; + raw_budget.max_bytes = budget.max_bytes; + raw_budget.max_evaluations = budget.max_evaluations; + raw_budget.max_report_bytes = budget.max_report_bytes; + PJ_error_t err{}; + uint64_t handle = 0; + if (!host_.vtable->submit_evaluation(host_.ctx, &raw, &raw_budget, &handle, &err)) { + return unexpected(errorToString(err)); + } + return handle; + } + + /// Read an evaluation's state. On kCompleted, `EvaluationPoll::json` is the + /// coverage+bundles report (owned copy); on kFailed it is `{"error":"..."}`. + /// See poll_evaluation's doc-comment in plugin_data_api.h for the full + /// report schema. Errors if the host predates this slot or `handle` is + /// unknown. + [[nodiscard]] Expected pollEvaluation(uint64_t handle) const { + if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, poll_evaluation)) { + return unexpected("data processors host does not support poll_evaluation"); + } + PJ_error_t err{}; + uint32_t state = PJ_EVALUATION_STATE_PENDING; + PJ_string_view_t json{}; + if (!host_.vtable->poll_evaluation(host_.ctx, handle, &state, &json, &err)) { + return unexpected(errorToString(err)); + } + EvaluationPoll result; + result.json = std::string(toStringView(json)); + switch (state) { + case PJ_EVALUATION_STATE_COMPLETED: + result.state = EvaluationState::kCompleted; + break; + case PJ_EVALUATION_STATE_FAILED: + result.state = EvaluationState::kFailed; + break; + case PJ_EVALUATION_STATE_CANCELLED: + result.state = EvaluationState::kCancelled; + break; + default: + result.state = EvaluationState::kPending; + break; + } + return result; + } + + /// Cancel a pending evaluation (cooperative) and free its result. Errors if + /// the host predates this slot, `handle` is unknown, or it was already + /// released. + [[nodiscard]] Status releaseEvaluation(uint64_t handle) const { + if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, release_evaluation)) { + return unexpected("data processors host does not support release_evaluation"); + } + PJ_error_t err{}; + if (!host_.vtable->release_evaluation(host_.ctx, handle, &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + private: PJ_data_processors_host_t host_{}; }; @@ -1997,6 +2287,120 @@ class PlotTabHostView { PJ_plot_tab_host_t host_{}; }; +// --------------------------------------------------------------------------- +// SceneViewHostView — typed C++ view over PJ_scene_view_host_t +// --------------------------------------------------------------------------- + +/// C++ wrapper around PJ_scene_view_host_t for plugins that compose 3D/2D +/// scene views of their own (service "pj.scene_views.v1"). Empty-constructible; +/// `valid()` tells whether the host exposed the service. Mirrors PlotTabHostView. +class SceneViewHostView { + public: + SceneViewHostView() = default; + explicit SceneViewHostView(PJ_scene_view_host_t host) : host_(host) {} + + [[nodiscard]] bool valid() const noexcept { + return host_.vtable != nullptr && host_.ctx != nullptr; + } + + /// Create (or replace, upsert by id) a scene view owned by this plugin. + /// `kind` is "3d" or "2d". Re-creating an id with a DIFFERENT kind closes + /// the view and creates a new empty one; the same kind only updates the + /// title. An empty `title` lets the host name it. + [[nodiscard]] Status createView(std::string_view id, std::string_view kind, std::string_view title = {}) const { + if (!valid() || host_.vtable->create_view == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->create_view(host_.ctx, toAbiString(id), toAbiString(kind), toAbiString(title), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Close one of this plugin's views. The topics it showed are data and + /// outlive the view. + [[nodiscard]] Status closeView(std::string_view id) const { + if (!valid() || host_.vtable->close_view == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->close_view(host_.ctx, toAbiString(id), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Enumerate the ids of this plugin's live views (owned copies). + [[nodiscard]] Expected> listViews() const { + if (!valid() || host_.vtable->list_view_ids == nullptr) { + return unexpected("scene views host is not bound"); + } + return detail::listBorrowedStrings(host_.ctx, host_.vtable->list_view_ids); + } + + /// Read back what a view actually holds, as JSON (owned copy): + /// {"kind":"3d","title":"...","topics":[{"topic":"...","dataset":"...","type":"kPointCloud","visible":true}]}. + [[nodiscard]] Expected configOf(std::string_view id) const { + if (!valid() || host_.vtable->view_config == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + PJ_string_view_t out{}; + if (!host_.vtable->view_config(host_.ctx, toAbiString(id), &out, &err)) { + return unexpected(errorToString(err)); + } + return std::string(toStringView(out)); + } + + /// Attach an object topic. An empty `dataset_source` requires the topic to + /// be unique across loaded datasets; an ambiguous one is refused by the + /// host with the qualified candidates. Attaching a topic already there is + /// success. + [[nodiscard]] Status attachTopic( + std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { + if (!valid() || host_.vtable->attach_topic == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->attach_topic( + host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(dataset_source), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Take one topic back out, resolved by the same rule as attachTopic. A + /// topic that is not there is an error. + [[nodiscard]] Status detachTopic( + std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { + if (!valid() || host_.vtable->detach_topic == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->detach_topic( + host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(dataset_source), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + /// Raise the view's tab. + [[nodiscard]] Status focusView(std::string_view id) const { + if (!valid() || host_.vtable->focus_view == nullptr) { + return unexpected("scene views host is not bound"); + } + PJ_error_t err{}; + if (!host_.vtable->focus_view(host_.ctx, toAbiString(id), &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + + private: + PJ_scene_view_host_t host_{}; +}; + // --------------------------------------------------------------------------- // SettingsView — typed C++ view over PJ_settings_store_t // --------------------------------------------------------------------------- diff --git a/pj_base/include/pj_base/sdk/service_traits.hpp b/pj_base/include/pj_base/sdk/service_traits.hpp index 75d1d669..c88b652d 100644 --- a/pj_base/include/pj_base/sdk/service_traits.hpp +++ b/pj_base/include/pj_base/sdk/service_traits.hpp @@ -226,6 +226,19 @@ struct PlotTabHostService { static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); }; +/// "pj.scene_views.v1" — compose 3D/2D scene views of the plugin's own: create, +/// attach and detach object topics, read back what they hold, close. Scoped by +/// the host to this plugin's views, the same discipline as "pj.plot_tabs.v1". +/// Hosts with no scene workspace (headless) simply do not register it. +struct SceneViewHostService { + static constexpr const char* kName = "pj.scene_views.v1"; + static constexpr uint32_t kMinVersion = 1; + using Raw = PJ_scene_view_host_t; + using Vtable = PJ_scene_view_host_vtable_t; + using View = SceneViewHostView; + static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); +}; + /// Optional QSettings-like key/value persistence exposed to any plugin family. /// Host-backed (QSettings in the GUI app, JSON in a headless host); keys are /// namespaced per plugin by the host. diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index d422f026..6e819b24 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -316,7 +316,10 @@ static_assert( static_assert( offsetof(PJ_toolbox_host_vtable_t, set_object_topic_retention) == 96, "toolbox host object-retention tail slot pinned"); -static_assert(sizeof(PJ_toolbox_host_vtable_t) == 104, "Toolbox host size (update deliberately on append)"); +static_assert( + offsetof(PJ_toolbox_host_vtable_t, acquire_catalog_snapshot_v2) == 104, + "toolbox host catalog-snapshot-v2 tail slot pinned"); +static_assert(sizeof(PJ_toolbox_host_vtable_t) == 112, "Toolbox host size (update deliberately on append)"); // --- Data processors host vtable ("pj.data_processors.v1", ABI-APPENDABLE) -- static_assert(offsetof(PJ_data_processors_host_vtable_t, protocol_version) == 0, "data processors host prefix pinned"); @@ -333,9 +336,74 @@ static_assert( offsetof(PJ_data_processors_host_vtable_t, validate_data_processor_script) == 40, "data processors host validate tail slot pinned"); static_assert( - sizeof(PJ_data_processors_host_vtable_t) == 48, "Data processors host size (update deliberately on append)"); + offsetof(PJ_data_processors_host_vtable_t, create_data_processor_v2) == 48, + "data processors host typed-create tail slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, submit_evaluation) == 56, + "data processors host submit-evaluation tail slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, poll_evaluation) == 64, + "data processors host poll-evaluation tail slot pinned"); +static_assert( + offsetof(PJ_data_processors_host_vtable_t, release_evaluation) == 72, + "data processors host release-evaluation tail slot pinned"); +static_assert( + sizeof(PJ_data_processors_host_vtable_t) == 80, "Data processors host size (update deliberately on append)"); static_assert(sizeof(PJ_data_processors_host_t) == 16, "Data processors host fat pointer pinned"); +// --- Catalog snapshot v2 (ABI-VERSIONED struct, not appendable) -------------- +static_assert(sizeof(PJ_object_topic_info_t) == 96, "PJ_object_topic_info_t size pinned (fixed stride)"); +static_assert(offsetof(PJ_object_topic_info_t, entry_count) == 56, "PJ_object_topic_info_t.entry_count offset pinned"); +static_assert(offsetof(PJ_object_topic_info_t, reserved) == 80, "PJ_object_topic_info_t.reserved offset pinned"); + +static_assert(sizeof(PJ_catalog_snapshot_v2_t) == 88, "PJ_catalog_snapshot_v2_t size pinned at introduction (0.35.0)"); +static_assert( + offsetof(PJ_catalog_snapshot_v2_t, struct_size) == 0, "PJ_catalog_snapshot_v2_t.struct_size offset pinned"); +static_assert( + offsetof(PJ_catalog_snapshot_v2_t, object_topics) == 56, "PJ_catalog_snapshot_v2_t.object_topics offset pinned"); +static_assert( + offsetof(PJ_catalog_snapshot_v2_t, object_topic_count) == 64, + "PJ_catalog_snapshot_v2_t.object_topic_count offset pinned"); +static_assert( + offsetof(PJ_catalog_snapshot_v2_t, release_ctx) == 72, "PJ_catalog_snapshot_v2_t.release_ctx offset pinned"); +static_assert(offsetof(PJ_catalog_snapshot_v2_t, release) == 80, "PJ_catalog_snapshot_v2_t.release offset pinned"); + +// --- Data-processor typed request vocabulary (introduced 0.35.0) ------------ +static_assert(sizeof(PJ_data_processor_output_t) == 32, "PJ_data_processor_output_t size pinned (fixed stride)"); +static_assert(offsetof(PJ_data_processor_output_t, name) == 0, "PJ_data_processor_output_t.name offset pinned"); +static_assert(offsetof(PJ_data_processor_output_t, type) == 16, "PJ_data_processor_output_t.type offset pinned"); + +static_assert( + sizeof(PJ_data_processor_request_t) == 168, "PJ_data_processor_request_t size pinned at introduction (0.35.0)"); +static_assert( + offsetof(PJ_data_processor_request_t, struct_size) == 0, "PJ_data_processor_request_t.struct_size offset pinned"); +static_assert(offsetof(PJ_data_processor_request_t, inputs) == 104, "PJ_data_processor_request_t.inputs offset pinned"); +static_assert( + offsetof(PJ_data_processor_request_t, time_flags) == 136, "PJ_data_processor_request_t.time_flags offset pinned"); +static_assert( + offsetof(PJ_data_processor_request_t, window_start_ns) == 144, + "PJ_data_processor_request_t.window_start_ns offset pinned"); +static_assert( + offsetof(PJ_data_processor_request_t, time_ns) == 160, "PJ_data_processor_request_t.time_ns offset pinned"); + +static_assert(sizeof(PJ_evaluation_budget_t) == 40, "PJ_evaluation_budget_t size pinned at introduction (0.35.0)"); +static_assert(offsetof(PJ_evaluation_budget_t, struct_size) == 0, "PJ_evaluation_budget_t.struct_size offset pinned"); +static_assert( + offsetof(PJ_evaluation_budget_t, max_report_bytes) == 32, "PJ_evaluation_budget_t.max_report_bytes offset pinned"); + +// --- Scene-view host vtable ("pj.scene_views.v1", ABI-APPENDABLE) ----------- +static_assert(offsetof(PJ_scene_view_host_vtable_t, protocol_version) == 0, "scene view host prefix pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, struct_size) == 4, "scene view host prefix pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, create_view) == 8, "scene view host create_view slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, close_view) == 16, "scene view host close_view slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, list_view_ids) == 24, "scene view host list_view_ids slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, view_config) == 32, "scene view host view_config slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, attach_topic) == 40, "scene view host attach_topic slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, detach_topic) == 48, "scene view host detach_topic slot pinned"); +static_assert(offsetof(PJ_scene_view_host_vtable_t, focus_view) == 56, "scene view host focus_view slot pinned"); +static_assert(sizeof(PJ_scene_view_host_vtable_t) == 64, "Scene view host vtable size (update deliberately on append)"); +static_assert(sizeof(PJ_scene_view_host_t) == 16, "Scene view host fat pointer pinned"); + // --- Toolbox runtime host vtable (ABI-APPENDABLE within v4) ------------------ // The vtable the host exposes to plugins under "pj.toolbox_runtime.v1". // Offsets of existing slots are pinned; size grows deliberately as tail slots append. diff --git a/pj_base/tests/data_processors_api_test.cpp b/pj_base/tests/data_processors_api_test.cpp index 3c7cdfb9..357985a2 100644 --- a/pj_base/tests/data_processors_api_test.cpp +++ b/pj_base/tests/data_processors_api_test.cpp @@ -4,8 +4,11 @@ #include #include +#include #include #include +#include +#include #include #include "pj_base/plugin_data_api.h" @@ -44,6 +47,35 @@ struct FakeDataProcessorsHost { bool validate_should_fail = false; std::string last_validate_kind; std::string last_validate_script; + + // --- create_data_processor_v2 / submit_evaluation / poll_evaluation / release_evaluation --- + + bool create_v2_called = false; + bool submit_called = false; + uint32_t last_request_struct_size = 0; + uint32_t last_request_flags = 0; + uint32_t last_request_time_flags = 0; + uint32_t last_request_reserved = 0; + int64_t last_request_window_start_ns = 0; + int64_t last_request_window_end_ns = 0; + int64_t last_request_time_ns = 0; + std::string last_request_id; + std::string last_request_kind; + std::string last_request_language; + std::string last_request_script; + std::string last_request_params; + std::string last_request_label; + std::vector last_request_inputs; + std::vector> last_request_outputs; // (name, type) + + std::vector v2_resolved_storage; // host storage out_topics point into + std::string v2_auto_topic = "__on_demand__/__preview__/finding"; + + struct Evaluation { + std::string json; + }; + std::unordered_map evaluations; // handle -> stored report + uint64_t next_handle = 1; }; bool dpCreate( @@ -133,6 +165,106 @@ bool dpValidate( return true; } +bool dpCreateV2( + void* ctx, const PJ_data_processor_request_t* request, PJ_string_view_t* out_topics, uint64_t out_topics_capacity, + uint64_t* out_topics_count, PJ_error_t* /*out_error*/) noexcept { + auto* self = static_cast(ctx); + self->create_v2_called = true; + // Copy every borrowed string/array out of `request` immediately: the SDK contract + // says they are borrowed for the duration of the call only. + self->last_request_struct_size = request->struct_size; + self->last_request_flags = request->flags; + self->last_request_time_flags = request->time_flags; + self->last_request_reserved = request->reserved; + self->last_request_window_start_ns = request->window_start_ns; + self->last_request_window_end_ns = request->window_end_ns; + self->last_request_time_ns = request->time_ns; + self->last_request_id = std::string(sdk::toStringView(request->id)); + self->last_request_kind = std::string(sdk::toStringView(request->kind)); + self->last_request_language = std::string(sdk::toStringView(request->language)); + self->last_request_script = std::string(sdk::toStringView(request->script)); + self->last_request_params = std::string(sdk::toStringView(request->params_json)); + self->last_request_label = std::string(sdk::toStringView(request->label)); + self->last_request_inputs.clear(); + for (uint64_t i = 0; i < request->input_count; ++i) { + self->last_request_inputs.emplace_back(sdk::toStringView(request->inputs[i])); + } + self->last_request_outputs.clear(); + for (uint64_t i = 0; i < request->output_count; ++i) { + self->last_request_outputs.emplace_back( + std::string(sdk::toStringView(request->outputs[i].name)), + std::string(sdk::toStringView(request->outputs[i].type))); + } + + // Resolve sink names: echo provided output names, else host-named (auto preview). + self->v2_resolved_storage.clear(); + if (request->output_count > 0) { + for (uint64_t i = 0; i < request->output_count; ++i) { + self->v2_resolved_storage.emplace_back(sdk::toStringView(request->outputs[i].name)); + } + } else { + self->v2_resolved_storage.push_back(self->v2_auto_topic); + } + if (out_topics_count != nullptr) { + *out_topics_count = self->v2_resolved_storage.size(); + } + if (out_topics != nullptr) { + const uint64_t n = std::min(out_topics_capacity, self->v2_resolved_storage.size()); + for (uint64_t i = 0; i < n; ++i) { + out_topics[i] = sdk::toAbiString(self->v2_resolved_storage[i]); + } + } + return true; +} + +bool dpSubmitEvaluation( + void* ctx, const PJ_data_processor_request_t* request, const PJ_evaluation_budget_t* /*budget*/, + uint64_t* out_handle, PJ_error_t* /*out_error*/) noexcept { + auto* self = static_cast(ctx); + self->submit_called = true; + self->last_request_id = std::string(sdk::toStringView(request->id)); + self->last_request_time_flags = request->time_flags; + self->last_request_time_ns = request->time_ns; + + // "Phase 0" fake: completes inline, one canned bundle. + const uint64_t handle = self->next_handle++; + self->evaluations[handle] = FakeDataProcessorsHost::Evaluation{ + .json = R"({"coverage":{"start_ns":0,"end_ns":0,"evaluated_until_ns":null,"candidates":1,)" + R"("evaluated":1,"cache_hits":0,"complete":true,"stopped":"complete"},)" + R"("bundles":[{"requested_ns":0,"stamp_ns":0,"from_cache":false,"revision":1,)" + R"("inputs":[],"outputs":{}}]})"}; + *out_handle = handle; + return true; +} + +bool dpPollEvaluation( + void* ctx, uint64_t handle, uint32_t* out_state, PJ_string_view_t* out_json, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + auto it = self->evaluations.find(handle); + if (it == self->evaluations.end()) { + if (out_error != nullptr) { + sdk::fillError(out_error, 1, "data_processors", "unknown evaluation handle"); + } + return false; + } + *out_state = PJ_EVALUATION_STATE_COMPLETED; + *out_json = sdk::toAbiString(it->second.json); + return true; +} + +bool dpReleaseEvaluation(void* ctx, uint64_t handle, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + auto it = self->evaluations.find(handle); + if (it == self->evaluations.end()) { + if (out_error != nullptr) { + sdk::fillError(out_error, 1, "data_processors", "unknown evaluation handle"); + } + return false; + } + self->evaluations.erase(it); + return true; +} + PJ_data_processors_host_vtable_t makeVtable() { return PJ_data_processors_host_vtable_t{ .protocol_version = 1, @@ -142,6 +274,10 @@ PJ_data_processors_host_vtable_t makeVtable() { .list_data_processor_ids = dpList, .data_processor_config = dpConfig, .validate_data_processor_script = dpValidate, + .create_data_processor_v2 = dpCreateV2, + .submit_evaluation = dpSubmitEvaluation, + .poll_evaluation = dpPollEvaluation, + .release_evaluation = dpReleaseEvaluation, }; } @@ -370,5 +506,104 @@ TEST(DataProcessorsApiTest, ValidateFailureSurfacesError) { EXPECT_NE(status.error().find("syntax boom"), std::string::npos); } +// --- createV2 / submitEvaluation / pollEvaluation / releaseEvaluation ------------ + +TEST(DataProcessorsApiTest, CreateV2ForwardsTypedOutputsAndInstant) { + FakeDataProcessorsHost host; + const auto vtable = makeVtable(); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + sdk::DataProcessorRequest request; + request.id = "nearest_cloud"; + request.kind = "on_demand"; + request.language = "luau"; + request.script = "return {}"; + request.params_json = "{}"; + request.label = "Nearest cloud"; + request.inputs = {"lidar/points"}; + request.outputs = {sdk::DataProcessorOutput{"cloud", "kPointCloud"}}; + request.instant_ns = 123456789; + + auto topics = view.createV2(request); + ASSERT_TRUE(topics) << topics.error(); + EXPECT_TRUE(host.create_v2_called); + EXPECT_EQ(host.last_request_id, "nearest_cloud"); + EXPECT_EQ(host.last_request_kind, "on_demand"); + EXPECT_EQ(host.last_request_label, "Nearest cloud"); + ASSERT_EQ(host.last_request_outputs.size(), 1u); + EXPECT_EQ(host.last_request_outputs[0].first, "cloud"); + EXPECT_EQ(host.last_request_outputs[0].second, "kPointCloud"); + EXPECT_EQ(host.last_request_time_flags, static_cast(PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT)); + EXPECT_EQ(host.last_request_time_ns, 123456789); + ASSERT_EQ(topics->size(), 1u); + EXPECT_EQ((*topics)[0], "cloud"); +} + +TEST(DataProcessorsApiTest, CreateV2OnOldHostReportsNotSupported) { + FakeDataProcessorsHost host; + auto vtable = makeVtable(); + vtable.struct_size = offsetof(PJ_data_processors_host_vtable_t, create_data_processor_v2); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + sdk::DataProcessorRequest request; + request.id = "x"; + request.kind = "on_demand"; + + auto topics = view.createV2(request); + EXPECT_FALSE(topics); + EXPECT_NE(topics.error().find("create_data_processor_v2"), std::string::npos); + EXPECT_FALSE(host.create_v2_called); +} + +TEST(DataProcessorsApiTest, SubmitPollReleaseRoundTrip) { + FakeDataProcessorsHost host; + const auto vtable = makeVtable(); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + sdk::DataProcessorRequest request; + request.id = "finding"; + request.kind = "on_demand"; + request.instant_ns = 10; + + auto handle = view.submitEvaluation(request); + ASSERT_TRUE(handle) << handle.error(); + EXPECT_TRUE(host.submit_called); + + auto poll = view.pollEvaluation(*handle); + ASSERT_TRUE(poll) << poll.error(); + EXPECT_EQ(poll->state, sdk::EvaluationState::kCompleted); + EXPECT_NE(poll->json.find("\"coverage\""), std::string::npos); + + ASSERT_TRUE(view.releaseEvaluation(*handle)); + auto second_release = view.releaseEvaluation(*handle); + EXPECT_FALSE(second_release); +} + +TEST(DataProcessorsApiTest, PollUnknownHandleIsAnError) { + FakeDataProcessorsHost host; + const auto vtable = makeVtable(); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + auto poll = view.pollEvaluation(/*handle=*/999); + EXPECT_FALSE(poll); +} + +TEST(DataProcessorsApiTest, RequestStructSizeAndFlagsAreSet) { + FakeDataProcessorsHost host; + const auto vtable = makeVtable(); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + sdk::DataProcessorRequest request; + request.id = "x"; + request.kind = "on_demand"; + request.instant_ns = 42; + + auto topics = view.createV2(request); + ASSERT_TRUE(topics) << topics.error(); + EXPECT_EQ(host.last_request_struct_size, sizeof(PJ_data_processor_request_t)); + EXPECT_EQ(host.last_request_time_flags, static_cast(PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT)); + EXPECT_EQ(host.last_request_reserved, 0u); +} + } // 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..fa557c28 100644 --- a/pj_base/tests/object_topic_metadata_test.cpp +++ b/pj_base/tests/object_topic_metadata_test.cpp @@ -202,6 +202,27 @@ 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) is a no-op: the key stays unset (incremental, the default). + const auto snapshot_false = + ObjectTopicMetadataBuilder().builtinObjectType(BuiltinObjectType::kImageAnnotations).snapshot(false).build(); + ASSERT_TRUE(snapshot_false); + EXPECT_EQ(*snapshot_false, 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(ObjectTopicMetadataRegistrationTest, SourceTypedOverloadForwardsBuiltJson) { RegistrationRecorder recorder; const PJ_object_write_host_vtable_t vtable = { diff --git a/pj_base/tests/plugin_data_api_test.cpp b/pj_base/tests/plugin_data_api_test.cpp index 155d4bc1..e0ab9630 100644 --- a/pj_base/tests/plugin_data_api_test.cpp +++ b/pj_base/tests/plugin_data_api_test.cpp @@ -35,6 +35,7 @@ static_assert( struct TailSlotRecorder { bool called = false; + int release_count = 0; }; bool sourceAppendArrowStream( @@ -81,6 +82,31 @@ bool toolboxReadSeriesArrow( return true; } +void releaseCatalogSnapshotV2(void* release_ctx) noexcept { + static_cast(release_ctx)->release_count++; +} + +bool toolboxAcquireCatalogSnapshotV2(void* ctx, PJ_catalog_snapshot_v2_t* out_snapshot, PJ_error_t*) noexcept { + auto* recorder = static_cast(ctx); + recorder->called = true; + *out_snapshot = PJ_catalog_snapshot_v2_t{}; + out_snapshot->struct_size = sizeof(PJ_catalog_snapshot_v2_t); + out_snapshot->release_ctx = ctx; + out_snapshot->release = releaseCatalogSnapshotV2; + return true; +} + +bool toolboxAcquireUndersizedCatalogSnapshotV2( + void* ctx, PJ_catalog_snapshot_v2_t* out_snapshot, PJ_error_t*) noexcept { + auto* recorder = static_cast(ctx); + recorder->called = true; + *out_snapshot = PJ_catalog_snapshot_v2_t{}; + out_snapshot->struct_size = 8; // deliberately below sizeof(PJ_catalog_snapshot_v2_t) + out_snapshot->release_ctx = ctx; + out_snapshot->release = releaseCatalogSnapshotV2; + return true; +} + TEST(PluginDataApiTest, PrimitiveTypeRoundTripsThroughAbiEnum) { EXPECT_EQ(sdk::fromAbiType(sdk::toAbiType(PrimitiveType::kFloat32)), PrimitiveType::kFloat32); EXPECT_EQ(sdk::fromAbiType(sdk::toAbiType(PrimitiveType::kInt8)), PrimitiveType::kInt8); @@ -256,5 +282,55 @@ TEST(PluginDataApiTest, ToolboxHostViewRejectsMissingReadSeriesTailSlot) { EXPECT_NE(status.error().find("read_series_arrow"), std::string::npos); } +TEST(PluginDataApiTest, CatalogSnapshotV2ReleasesOnce) { + TailSlotRecorder recorder; + const PJ_toolbox_host_vtable_t vtable = { + .abi_version = PJ_PLUGIN_DATA_API_VERSION, + .struct_size = sizeof(PJ_toolbox_host_vtable_t), + .acquire_catalog_snapshot_v2 = toolboxAcquireCatalogSnapshotV2, + }; + sdk::ToolboxHostView view(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}); + + { + auto snapshot = view.catalogSnapshotV2(); + ASSERT_TRUE(snapshot) << snapshot.error(); + EXPECT_TRUE(recorder.called); + EXPECT_EQ(recorder.release_count, 0); + } + EXPECT_EQ(recorder.release_count, 1); +} + +TEST(PluginDataApiTest, CatalogSnapshotV2OnOldHostReportsNotSupported) { + TailSlotRecorder recorder; + const PJ_toolbox_host_vtable_t vtable = { + .abi_version = PJ_PLUGIN_DATA_API_VERSION, + .struct_size = offsetof(PJ_toolbox_host_vtable_t, acquire_catalog_snapshot_v2), + .acquire_catalog_snapshot_v2 = toolboxAcquireCatalogSnapshotV2, + }; + sdk::ToolboxHostView view(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}); + + auto snapshot = view.catalogSnapshotV2(); + + EXPECT_FALSE(snapshot); + EXPECT_FALSE(recorder.called); + EXPECT_NE(snapshot.error().find("acquire_catalog_snapshot_v2"), std::string::npos); +} + +TEST(PluginDataApiTest, CatalogSnapshotV2RejectsUndersizedStruct) { + TailSlotRecorder recorder; + const PJ_toolbox_host_vtable_t vtable = { + .abi_version = PJ_PLUGIN_DATA_API_VERSION, + .struct_size = sizeof(PJ_toolbox_host_vtable_t), + .acquire_catalog_snapshot_v2 = toolboxAcquireUndersizedCatalogSnapshotV2, + }; + sdk::ToolboxHostView view(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}); + + auto snapshot = view.catalogSnapshotV2(); + + EXPECT_FALSE(snapshot); + EXPECT_TRUE(recorder.called); + EXPECT_EQ(recorder.release_count, 1) << "undersized snapshot must still be released before returning the error"; +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/scene_views_api_test.cpp b/pj_base/tests/scene_views_api_test.cpp new file mode 100644 index 00000000..e4d1978a --- /dev/null +++ b/pj_base/tests/scene_views_api_test.cpp @@ -0,0 +1,347 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include +#include +#include + +#include "pj_base/plugin_data_api.h" +#include "pj_base/sdk/plugin_data_api.hpp" + +namespace PJ { +namespace { + +// Fake host for pj.scene_views.v1: a small model of views-with-topics, not a bare +// recorder, so the round-trip tests below can assert on what a read-back +// actually contains rather than merely that a call was forwarded. +struct FakeSceneViewHost { + struct Topic { + std::string topic; + std::string dataset; + }; + struct View { + std::string id; + std::string kind; + std::string title; + std::vector topics; + }; + + std::vector views; + bool should_fail = false; + std::string last_config_json; // storage backing the borrowed view_config out-string + int focus_calls = 0; + std::string last_focused_id; + + View* find(std::string_view id) { + auto it = std::find_if(views.begin(), views.end(), [&](const View& v) { return v.id == id; }); + return it == views.end() ? nullptr : &(*it); + } +}; + +bool svFail(FakeSceneViewHost* self, PJ_error_t* out_error) noexcept { + if (self->should_fail) { + sdk::fillError(out_error, 1, "scene_views", "view boom"); + return true; + } + return false; +} + +bool svCreateView( + void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + const auto kind_sv = sdk::toStringView(kind); + auto* existing = self->find(id_sv); + if (existing != nullptr) { + if (existing->kind != kind_sv) { + existing->kind = std::string(kind_sv); + existing->topics.clear(); + } + existing->title = std::string(sdk::toStringView(title)); + return true; + } + self->views.push_back( + FakeSceneViewHost::View{ + .id = std::string(id_sv), + .kind = std::string(kind_sv), + .title = std::string(sdk::toStringView(title)), + .topics = {}}); + return true; +} + +bool svCloseView(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + auto it = std::find_if(self->views.begin(), self->views.end(), [&](const auto& v) { return v.id == id_sv; }); + if (it == self->views.end()) { + sdk::fillError(out_error, 2, "scene_views", "unknown view id"); + return false; + } + self->views.erase(it); + return true; +} + +bool svListViewIds( + void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + const auto total = static_cast(self->views.size()); + if (capacity == 0) { + *out_count = total; + return true; + } + const uint64_t filled = std::min(capacity, total); + for (uint64_t i = 0; i < filled; ++i) { + out_ids[i] = sdk::toAbiString(self->views[i].id); + } + *out_count = total; + return true; +} + +bool svViewConfig(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + auto* view = self->find(sdk::toStringView(id)); + if (view == nullptr) { + sdk::fillError(out_error, 2, "scene_views", "unknown view id"); + return false; + } + std::string json = "{\"kind\":\"" + view->kind + "\",\"title\":\"" + view->title + "\",\"topics\":["; + for (size_t i = 0; i < view->topics.size(); ++i) { + if (i != 0) { + json += ","; + } + const auto& topic = view->topics[i]; + json += "{\"topic\":\"" + topic.topic + "\",\"dataset\":\"" + topic.dataset + + "\",\"type\":\"kPointCloud\",\"visible\":true}"; + } + json += "]}"; + self->last_config_json = std::move(json); + *out_config_json = sdk::toAbiString(self->last_config_json); + return true; +} + +bool svAttachTopic( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + auto* view = self->find(sdk::toStringView(id)); + if (view == nullptr) { + sdk::fillError(out_error, 2, "scene_views", "unknown view id"); + return false; + } + const auto topic_sv = sdk::toStringView(topic); + const auto dataset_sv = sdk::toStringView(dataset_source); + auto it = std::find_if(view->topics.begin(), view->topics.end(), [&](const auto& t) { + return t.topic == topic_sv && t.dataset == dataset_sv; + }); + if (it != view->topics.end()) { + return true; // already attached: success + } + view->topics.push_back(FakeSceneViewHost::Topic{.topic = std::string(topic_sv), .dataset = std::string(dataset_sv)}); + return true; +} + +bool svDetachTopic( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + auto* view = self->find(sdk::toStringView(id)); + if (view == nullptr) { + sdk::fillError(out_error, 2, "scene_views", "unknown view id"); + return false; + } + const auto topic_sv = sdk::toStringView(topic); + const auto dataset_sv = sdk::toStringView(dataset_source); + auto it = std::find_if(view->topics.begin(), view->topics.end(), [&](const auto& t) { + return t.topic == topic_sv && t.dataset == dataset_sv; + }); + if (it == view->topics.end()) { + sdk::fillError(out_error, 3, "scene_views", "topic not present"); + return false; + } + view->topics.erase(it); + return true; +} + +bool svFocusView(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (svFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + auto* view = self->find(id_sv); + if (view == nullptr) { + sdk::fillError(out_error, 2, "scene_views", "unknown view id"); + return false; + } + ++self->focus_calls; + self->last_focused_id = std::string(id_sv); + return true; +} + +PJ_scene_view_host_vtable_t makeSceneViewVtable() { + return PJ_scene_view_host_vtable_t{ + .protocol_version = 1, + .struct_size = sizeof(PJ_scene_view_host_vtable_t), + .create_view = svCreateView, + .close_view = svCloseView, + .list_view_ids = svListViewIds, + .view_config = svViewConfig, + .attach_topic = svAttachTopic, + .detach_topic = svDetachTopic, + .focus_view = svFocusView, + }; +} + +// --- SceneViewHostView ------------------------------------------------------- + +TEST(SceneViewsApiTest, CreateAndListRoundTrip) { + FakeSceneViewHost host; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createView("view-a", "3d", "First")); + ASSERT_TRUE(view.createView("view-b", "2d", "Second")); + + auto ids = view.listViews(); + ASSERT_TRUE(ids) << ids.error(); + ASSERT_EQ(ids->size(), 2u); + EXPECT_EQ((*ids)[0], "view-a"); + EXPECT_EQ((*ids)[1], "view-b"); +} + +TEST(SceneViewsApiTest, ConfigReadsBackWhatWasAttached) { + FakeSceneViewHost host; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createView("view-a", "3d", "My View")); + ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); + ASSERT_TRUE(view.attachTopic("view-a", "camera/image", "bag1")); + + auto config = view.configOf("view-a"); + ASSERT_TRUE(config) << config.error(); + EXPECT_NE(config->find("My View"), std::string::npos); + EXPECT_NE(config->find("lidar/points"), std::string::npos); + EXPECT_NE(config->find("camera/image"), std::string::npos); + + const auto grown_size = config->size(); + ASSERT_TRUE(view.attachTopic("view-a", "imu/data", "bag1")); + auto config2 = view.configOf("view-a"); + ASSERT_TRUE(config2) << config2.error(); + EXPECT_NE(config2->find("imu/data"), std::string::npos); + EXPECT_GT(config2->size(), grown_size); +} + +TEST(SceneViewsApiTest, DetachMissingTopicIsAnError) { + FakeSceneViewHost host; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createView("view-a", "3d")); + auto status = view.detachTopic("view-a", "lidar/points", "bag1"); + EXPECT_FALSE(status); +} + +TEST(SceneViewsApiTest, UnknownIdIsAnError) { + FakeSceneViewHost host; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + EXPECT_FALSE(view.attachTopic("nope", "lidar/points")); + EXPECT_FALSE(view.configOf("nope")); + EXPECT_FALSE(view.closeView("nope")); + EXPECT_FALSE(view.focusView("nope")); +} + +TEST(SceneViewsApiTest, HostFailureSurfacesTheMessage) { + FakeSceneViewHost host; + host.should_fail = true; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + auto create_status = view.createView("view-a", "3d"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("view boom"), std::string::npos); + + auto attach_status = view.attachTopic("view-a", "lidar/points"); + EXPECT_FALSE(attach_status); + EXPECT_NE(attach_status.error().find("view boom"), std::string::npos); + + auto list_status = view.listViews(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("view boom"), std::string::npos); +} + +TEST(SceneViewsApiTest, UnboundViewReportsNotBound) { + sdk::SceneViewHostView view; // default-constructed = not bound + EXPECT_FALSE(view.valid()); + + auto create_status = view.createView("view-a", "3d"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("not bound"), std::string::npos); + + auto close_status = view.closeView("view-a"); + EXPECT_FALSE(close_status); + EXPECT_NE(close_status.error().find("not bound"), std::string::npos); + + auto list_status = view.listViews(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("not bound"), std::string::npos); + + auto config_status = view.configOf("view-a"); + EXPECT_FALSE(config_status); + EXPECT_NE(config_status.error().find("not bound"), std::string::npos); + + auto attach_status = view.attachTopic("view-a", "lidar/points"); + EXPECT_FALSE(attach_status); + EXPECT_NE(attach_status.error().find("not bound"), std::string::npos); + + auto detach_status = view.detachTopic("view-a", "lidar/points"); + EXPECT_FALSE(detach_status); + EXPECT_NE(detach_status.error().find("not bound"), std::string::npos); + + auto focus_status = view.focusView("view-a"); + EXPECT_FALSE(focus_status); + EXPECT_NE(focus_status.error().find("not bound"), std::string::npos); +} + +TEST(SceneViewsApiTest, DatasetQualifierReachesTheHost) { + FakeSceneViewHost host; + const auto vtable = makeSceneViewVtable(); + sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createView("view-a", "3d")); + ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); + ASSERT_TRUE(view.attachTopic("view-a", "camera/image")); + + auto* found = host.find("view-a"); + ASSERT_NE(found, nullptr); + ASSERT_EQ(found->topics.size(), 2u); + EXPECT_EQ(found->topics[0].dataset, "bag1"); + EXPECT_TRUE(found->topics[1].dataset.empty()); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 9d71fa18..ee7c28a0 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -200,6 +200,7 @@ data store. | `appendBoundRecord(topic, timestamp, fields)` | Write using pre-resolved field handles (faster). | | `appendArrowStream(topic, stream, ts_col)` | Hand an `ArrowArrayStream*` (Arrow C Data Interface) to the host for bulk ingest. Same ownership rule as the source write path: success transfers, failure retains. | | `catalogSnapshot()` | Acquire a read-only snapshot of all data sources, topics, and fields. | +| `catalogSnapshotV2()` (0.35.0) | Like `catalogSnapshot()`, plus every object topic (`objectTopics()`) with its dataset, builtin type, entry count and raw time range, in one deep copy. | | `readSeriesArrow(field, schema*, array*)` | Read one field's full time series into host-owned `ArrowSchema` + `ArrowArray` out-params (two columns: `timestamp` int64 ns, then the typed field value). | | `registerObjectTopic(source, name, type[, extra_metadata])` | Register a built-in media/object topic under a data source. The typed overload emits the canonical `builtin_object_type` renderer metadata and returns an `ObjectTopicHandle`. | | `registerObjectTopic(source, name, metadata_json)` | Raw-metadata overload for custom or untyped object topics. The store retains the JSON verbatim. | @@ -214,7 +215,7 @@ Access via `runtimeHost()`. Use this for diagnostics and UI refresh. | `reportMessage(level, text)` | Send info/warning/error to the host UI log. | | `notifyDataChanged()` | Tell the host that data was modified; refresh UI. Idempotent and cheap; coalesce per logical operation, not per record. | -### Playback, viewport, and owned tabs (SDK 0.28.0) +### Playback, viewport, owned tabs, and scene views (SDK 0.28.0; scene views 0.35.0) Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: @@ -223,6 +224,7 @@ Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: | `PJ::sdk::PlaybackHostService` | `play`, `pause`, `seek`, `setPlaybackRate`, `state`: the global playback cursor. `toDisplayTime` and `toDisplayTimeForSource` convert absolute nanoseconds to display-axis seconds. | | `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. | | `PJ::sdk::ViewportHostService` | `zoomToTimeRange`, `zoomReset`: all eligible plots in the calling plugin's tabs. | +| `PJ::sdk::SceneViewHostService` (`pj.scene_views.v1`, 0.35.0) | `createView(id, kind, title)`, `closeView`, `list`, `configOf`, `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focusView`: only the calling plugin's 3D/2D scene views. `kind` is `"3d"` or `"2d"`; re-creating an id with a different kind closes and recreates it empty. `configOf` reports what a view actually holds as JSON (topics with dataset, type, visibility). | All calls run on the main thread. Services are optional. Check acquisition and each operation's result. A host offering viewport control also offers @@ -290,6 +292,33 @@ and with both sources loaded the parser chooses `a:b`. Composition round-trips only when the intended source is the longest matching prefix. Do not treat this string as a persistent dataset identity. Hosts still validate the split result. +### Typed requests and on-demand evaluation (SDK 0.35.0) + +`DataProcessorsHostView::createV2(request)` upserts a `kind="on_demand"` node like +`createOnDemand`, but the request is typed (`DataProcessorRequest`): outputs carry +a `DataProcessorOutput{name, type}` pair instead of a `":"` string +suffix, plus an optional human-readable `label` and, with `instant_ns` set, a +pinned evaluation time. `create_data_processor` (v1) remains valid for the +untyped suffix grammar. + +`submitEvaluation(request, budget)` starts an evaluation and returns a handle. +`request.id` naming an installed on_demand node with an empty `script` evaluates +that node; a non-empty `script` is an ephemeral recipe (`flags` must carry +`PJ_DATA_PROCESSOR_FLAG_EPHEMERAL`) evaluated without installing or publishing +anything. `request.instant_ns` asks for one bundle at that instant; +`request.window` asks for one bundle per instant an input changes inside the +window, in time order, until the `EvaluationBudget` (`max_millis`, `max_bytes`, +`max_evaluations`, `max_report_bytes`; 0 = host default) stops it. + +`pollEvaluation(handle)` is the only way to read the result, whether the host +completed the work before returning or in the background: on `kCompleted` its +`json` is `{"coverage":{...},"bundles":[...]}` (one bundle for INSTANT, one per +evaluated instant for WINDOW; every `*_ns` value is a raw dataset nanosecond +count as a JSON integer). On `kFailed` it is `{"error":"..."}`. Handles are per +host object, increasing, never reused. `releaseEvaluation(handle)` cancels a +pending evaluation and frees its result; releasing an unknown or already-released +handle is an error. + ### Reading a series via Arrow `readSeriesArrow()` is the only read path in v4 — it returns diff --git a/pj_plugins/tests/toolbox_plugin_test.cpp b/pj_plugins/tests/toolbox_plugin_test.cpp index 62464de6..4a6a1cbe 100644 --- a/pj_plugins/tests/toolbox_plugin_test.cpp +++ b/pj_plugins/tests/toolbox_plugin_test.cpp @@ -90,6 +90,7 @@ PJ_toolbox_host_t makeToolboxHost(ToolboxState* state) { .push_owned_object = nullptr, .register_object_topic_on_dataset = nullptr, .set_object_topic_retention = nullptr, + .acquire_catalog_snapshot_v2 = nullptr, }; return PJ_toolbox_host_t{.ctx = state, .vtable = &vtable}; } diff --git a/tools/feature_floors/extract_host_surfaces.py b/tools/feature_floors/extract_host_surfaces.py index 5c2f0530..36082a70 100644 --- a/tools/feature_floors/extract_host_surfaces.py +++ b/tools/feature_floors/extract_host_surfaces.py @@ -57,6 +57,7 @@ "pj.playback.v1", "pj.plot_tabs.v1", "pj.runtime.v1", + "pj.scene_views.v1", "pj.settings.v1", "pj.source_object_write.v1", "pj.source_promotion.v1", diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index 9834fc27..14e1d495 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -1,5 +1,7 @@ PJ_DATA_PROCESSOR_FLAG_EPHEMERAL PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT +PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT +PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW PJ_DESCRIPTOR_IMPORT_START_FLAGS_V1_MASK PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE PJ_INGEST_COMPLETION_FLAGS_V1_MASK @@ -11,9 +13,13 @@ PJ_PARSER_ROUTE_FLAG_SCALAR_V1 PJ_colormap_registry_vtable_t::register_map PJ_colormap_registry_vtable_t::unregister_map PJ_data_processors_host_vtable_t::create_data_processor +PJ_data_processors_host_vtable_t::create_data_processor_v2 PJ_data_processors_host_vtable_t::data_processor_config PJ_data_processors_host_vtable_t::list_data_processor_ids +PJ_data_processors_host_vtable_t::poll_evaluation +PJ_data_processors_host_vtable_t::release_evaluation PJ_data_processors_host_vtable_t::remove_data_processor +PJ_data_processors_host_vtable_t::submit_evaluation PJ_data_processors_host_vtable_t::validate_data_processor_script PJ_data_source_runtime_host_vtable_t::attach_source_record PJ_data_source_runtime_host_vtable_t::complete_ingest @@ -64,6 +70,13 @@ PJ_plot_tab_host_vtable_t::create_tab PJ_plot_tab_host_vtable_t::list_tab_ids PJ_plot_tab_host_vtable_t::remove_curve PJ_plot_tab_host_vtable_t::tab_config +PJ_scene_view_host_vtable_t::attach_topic +PJ_scene_view_host_vtable_t::close_view +PJ_scene_view_host_vtable_t::create_view +PJ_scene_view_host_vtable_t::detach_topic +PJ_scene_view_host_vtable_t::focus_view +PJ_scene_view_host_vtable_t::list_view_ids +PJ_scene_view_host_vtable_t::view_config PJ_service_registry_vtable_t::get_service PJ_settings_store_vtable_t::contains PJ_settings_store_vtable_t::get_string @@ -78,6 +91,7 @@ PJ_source_write_host_vtable_t::append_record PJ_source_write_host_vtable_t::ensure_field PJ_source_write_host_vtable_t::ensure_topic PJ_toolbox_host_vtable_t::acquire_catalog_snapshot +PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2 PJ_toolbox_host_vtable_t::append_arrow_stream PJ_toolbox_host_vtable_t::append_bound_record PJ_toolbox_host_vtable_t::append_record @@ -104,6 +118,7 @@ pj.parser_write.v1 pj.playback.v1 pj.plot_tabs.v1 pj.runtime.v1 +pj.scene_views.v1 pj.settings.v1 pj.source_object_write.v1 pj.source_promotion.v1 From c2c2d653522f1b9591b307cbe75b1848eab93f9d Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 25 Sep 2026 01:32:28 +0200 Subject: [PATCH 04/22] feat(sdk): field tables for Image, DepthImage, CameraInfo and VideoFrame Eight builtin types now describe themselves. Two additive descriptor features were needed: an optional-number kind for the compressed depth range, and in-place element replacement for fixed-size arrays (camera matrices), which cannot grow. Raw image buffers expose their pixel record layout when the encoding is a known raw layout; compressed and video payloads keep the codec name and no static record size. Client-side only: no host-contract change. --- CHANGELOG.md | 18 ++ docs/builtin_type.md | 22 +- .../pj_base/builtin/camera_info_fields.hpp | 39 +++ .../pj_base/builtin/depth_image_fields.hpp | 97 ++++++ .../include/pj_base/builtin/field_table.hpp | 102 +++++- .../pj_base/builtin/field_table_registry.hpp | 14 +- .../include/pj_base/builtin/image_fields.hpp | 127 ++++++++ .../pj_base/builtin/video_frame_fields.hpp | 61 ++++ pj_base/tests/field_table_test.cpp | 306 +++++++++++++++++- 9 files changed, 753 insertions(+), 33 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/image_fields.hpp create mode 100644 pj_base/include/pj_base/builtin/video_frame_fields.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d56db60..9adec425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,24 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, exposes its packed-bytes `data` field through a `kBuffer` descriptor (`buffer<>()`) resolving a `BufferLayout` view rather than a plain get/set pair. Client-side only: no ABI or wire-format change. +- Add field tables for `Image`, `DepthImage`, `CameraInfo`, and `VideoFrame` + (`image_fields.hpp`, `depth_image_fields.hpp`, `camera_info_fields.hpp`, + `video_frame_fields.hpp`); `describe(BuiltinObjectType)` now covers 8 + types. `Image::data`/`DepthImage::data`/`VideoFrame::data` each get a + `kBuffer` descriptor whose layout is derived from the encoding/format + string: a raw `Image` encoding (e.g. "rgb8") or a recognized `DepthImage` + encoding ("16UC1"/"32FC1") resolves a static per-pixel `record_step`; + a compressed encoding or a `VideoFrame`'s codec bitstream has none, so + `record_step`/`record_count` are 0 and the encoding/format string still + comes through the buffer's sole channel name. `FieldKind` gains two new + cases to describe these structs honestly rather than faking them: + `kOptionalNumber` (a nullable number, `has_value()` + `get_number`/ + `set_number`, for `Image::compressed_depth_min`/`compressed_depth_max`) + and a fixed-size `kList` (for a `std::array` member — e.g. + `CameraInfo::K`/`R`/`P`, `DepthImage::K` — written through the new + `list_replace` accessor instead of `list_emplace`/`list_clear`, since the + element count never changes). Client-side only: no ABI or wire-format + change. - Add catalog snapshot v2: `PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2` (`ToolboxHostView::catalogSnapshotV2`) returns a second, ABI-VERSIONED snapshot struct (`PJ_catalog_snapshot_v2_t`) carrying the scalar catalog of diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 5b1b3106..1c9f2bfe 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -687,11 +687,23 @@ with one generic recursive routine. `field_table_registry.hpp` exposes `describe(BuiltinObjectType)` to look up a table from the runtime tag a `BuiltinObject` carries. -`FrameTransforms`, `ImageAnnotations`, `PointCloud`, and `SceneEntities` are -tabled today (`frame_transforms_fields.hpp`, `image_annotations_fields.hpp`, -`point_cloud_fields.hpp`, `scene_entities_fields.hpp`; `kTabledTypeCount == 4` -in `field_table_registry.hpp`). A field table is hand-maintained alongside its -struct: adding, renaming, or retyping a member requires updating the matching +`FrameTransforms`, `ImageAnnotations`, `PointCloud`, `SceneEntities`, `Image`, +`DepthImage`, `CameraInfo`, and `VideoFrame` are tabled today +(`frame_transforms_fields.hpp`, `image_annotations_fields.hpp`, +`point_cloud_fields.hpp`, `scene_entities_fields.hpp`, `image_fields.hpp`, +`depth_image_fields.hpp`, `camera_info_fields.hpp`, `video_frame_fields.hpp`; +`kTabledTypeCount == 8` in `field_table_registry.hpp`). `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 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..d9e357ef --- /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..06931040 --- /dev/null +++ b/pj_base/include/pj_base/builtin/depth_image_fields.hpp @@ -0,0 +1,97 @@ +/** + * @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" + +namespace PJ::sdk { + +namespace detail { + +/// Bytes-per-pixel for a `DepthImage::encoding` value, or 0 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 uint32_t depthImageBytesPerPixel(std::string_view encoding) noexcept { + if (encoding == "16UC1") { + return 2; + } + if (encoding == "32FC1") { + return 4; + } + return 0; +} + +} // 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 +/// `detail::depthImageBytesPerPixel(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`). `channels` always carries exactly one entry naming +/// the encoding string. `buffer_assign()` follows the same +/// take-ownership-and-re-anchor idiom as `pointCloudDataField()`. +[[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 uint32_t bpp = detail::depthImageBytesPerPixel(image.encoding); + BufferLayout layout; + layout.bytes = image.data; + layout.record_step = bpp; + layout.record_count = bpp == 0 ? 0 : static_cast(image.width) * static_cast(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 = bpp == 2 ? uint8_t{4} : (bpp == 4 ? uint8_t{7} : uint8_t{0}), // 4=uint16, 7=float32 + .count = bpp == 0 ? 0 : uint32_t{1}}); + return layout; + }; + d.buffer_assign = [](void* p, std::vector bytes) { + auto& image = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + image.data = view.bytes; + image.anchor = view.anchor; + }; + 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/field_table.hpp b/pj_base/include/pj_base/builtin/field_table.hpp index 89c6aa4d..9c0a7d69 100644 --- a/pj_base/include/pj_base/builtin/field_table.hpp +++ b/pj_base/include/pj_base/builtin/field_table.hpp @@ -29,9 +29,11 @@ #pragma once +#include #include #include #include +#include #include #include #include @@ -53,9 +55,15 @@ enum class FieldKind : uint8_t { 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` member; see `element_kind`/`nested` for the element shape. + 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 @@ -119,10 +127,16 @@ struct FieldDescriptor { /// For `kind == kList`: the element type's table, or `nullptr` for a scalar list. const struct FieldTableView* nested = nullptr; - /// Read/write a `kNumber`, `kBool`, or `kEnum` field as `double`. + /// 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. @@ -137,13 +151,29 @@ struct FieldDescriptor { const void* (*struct_ptr)(const void*) = nullptr; void* (*struct_ptr_mut)(void*) = nullptr; - /// For `kind == kList`: element count, element address, append-one - /// (returns the address of the newly appended element), and truncate-to-empty. + /// For `kind == kList`: element count and element address — populated for + /// both a growable list (`std::vector`) and a fixed-size one + /// (`std::array`). 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 @@ -194,6 +224,23 @@ 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. @@ -305,13 +352,22 @@ concept HasFieldTable = detail::kHasFieldTableImpl; /// other arithmetic -> kNumber; `enum`/`enum class` -> kEnum; /// `std::string` -> kString. /// - a class type `M` with `HasFieldTable` -> kStruct. -/// - `std::vector` -> kList, 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::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) { @@ -350,6 +406,28 @@ constexpr FieldDescriptor field(std::string_view name) { } 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) = static_cast(v); }; } else if constexpr (HasFieldTable) { d.kind = FieldKind::kStruct; d.nested = &FieldTable::view; @@ -358,7 +436,7 @@ constexpr FieldDescriptor field(std::string_view name) { } else { static_assert( detail::kAlwaysFalse, - "field: unsupported member type (no FieldTable and not a recognized scalar/vector)"); + "field: unsupported member type (no FieldTable and not a recognized scalar/vector/array/optional)"); } return d; diff --git a/pj_base/include/pj_base/builtin/field_table_registry.hpp b/pj_base/include/pj_base/builtin/field_table_registry.hpp index 6e949315..81d034ec 100644 --- a/pj_base/include/pj_base/builtin/field_table_registry.hpp +++ b/pj_base/include/pj_base/builtin/field_table_registry.hpp @@ -13,11 +13,15 @@ #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 { @@ -35,15 +39,19 @@ namespace PJ::sdk { return &FieldTable::view; case BuiltinObjectType::kSceneEntities: return &FieldTable::view; - case BuiltinObjectType::kNone: 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::kVideoFrame: case BuiltinObjectType::kRobotDescription: - case BuiltinObjectType::kCameraInfo: case BuiltinObjectType::kOccupancyGridUpdate: case BuiltinObjectType::kLog: case BuiltinObjectType::kPosesInFrame: 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..0f380b6e --- /dev/null +++ b/pj_base/include/pj_base/builtin/image_fields.hpp @@ -0,0 +1,127 @@ +/** + * @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" + +namespace PJ::sdk { + +namespace detail { + +/// Per-pixel layout of a raw (uncompressed) `Image` encoding: the wire +/// datatype tag (mirrors `PointField::Datatype` — 2 = uint8, 4 = uint16) and +/// how many consecutive values of that datatype make up one pixel. `bytes` +/// is the resulting pixel size, i.e. what `imageDataField()`'s buffer +/// descriptor reports as `record_step`. A compressed or unrecognized +/// encoding has no static per-pixel size, so every field is left 0. +struct ImagePixelLayout { + uint8_t datatype = 0; + uint32_t count = 0; + uint32_t bytes = 0; +}; + +/// 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 = 2, .count = 3, .bytes = 3}; // 2 = uint8 + case CommonImageEncoding::rgba8: + case CommonImageEncoding::bgra8: + return {.datatype = 2, .count = 4, .bytes = 4}; // 2 = uint8 + case CommonImageEncoding::mono8: + return {.datatype = 2, .count = 1, .bytes = 1}; // 2 = uint8 + case CommonImageEncoding::mono16: + return {.datatype = 4, .count = 1, .bytes = 2}; // 4 = uint16 + 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. `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()` takes ownership of the new bytes the same way +/// `pointCloudDataField()` does (`point_cloud_fields.hpp`): `data` views them +/// and `anchor` keeps them alive. +[[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; + layout.record_count = + pixel.bytes == 0 ? 0 : static_cast(image.width) * static_cast(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 = pixel.datatype, .count = pixel.count}); + return layout; + }; + d.buffer_assign = [](void* p, std::vector bytes) { + auto& image = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + image.data = view.bytes; + image.anchor = view.anchor; + }; + 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/video_frame_fields.hpp b/pj_base/include/pj_base/builtin/video_frame_fields.hpp new file mode 100644 index 00000000..146987c1 --- /dev/null +++ b/pj_base/include/pj_base/builtin/video_frame_fields.hpp @@ -0,0 +1,61 @@ +/** + * @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()` follows the same take-ownership-and-re-anchor +/// idiom as `pointCloudDataField()`. +[[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 = [](void* p, std::vector bytes) { + auto& frame = *static_cast(p); + const PayloadView view = makePayloadView(std::move(bytes)); + frame.data = view.bytes; + frame.anchor = view.anchor; + }; + 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/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp index 4112d722..eadd3f70 100644 --- a/pj_base/tests/field_table_test.cpp +++ b/pj_base/tests/field_table_test.cpp @@ -14,11 +14,15 @@ #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 { @@ -27,15 +31,18 @@ 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::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; @@ -53,6 +60,7 @@ using PJ::sdk::TextAnnotation; using PJ::sdk::TextPrimitive; using PJ::sdk::TrianglePrimitive; using PJ::sdk::Vector2; +using PJ::sdk::VideoFrame; // --------------------------------------------------------------------------- // Shared helpers @@ -88,8 +96,12 @@ void collectTables( // 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). A kBuffer field resolves its current -// bytes and re-assigns them onto dst, taking a fresh copy/anchor. +// 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) { @@ -104,15 +116,25 @@ void copyThroughTable(const FieldTableView& table, const void* src, void* dst) { 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: { - f.list_clear(dst); 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 = f.list_emplace(dst); + 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); @@ -351,6 +373,76 @@ PointCloud makeSamplePointCloudUnorganized() { 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 // --------------------------------------------------------------------------- @@ -358,10 +450,12 @@ PointCloud makeSamplePointCloudUnorganized() { 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{ + 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; @@ -369,7 +463,7 @@ TEST(FieldTableTest, NamesUniqueAndKindsConsistent) { for (const auto* root : roots) { collectTables(*root, visited, tables); } - ASSERT_EQ(tables.size(), 28u) << "expected all 28 field_table specializations added through step 2"; + 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); @@ -422,14 +516,34 @@ TEST(FieldTableTest, NamesUniqueAndKindsConsistent) { 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_NE(f.list_emplace, nullptr); - EXPECT_NE(f.list_clear, 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_*). @@ -526,6 +640,172 @@ TEST(FieldTableTest, GenericCopyMatchesCodecRoundTrip) { } } +// 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); + + 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); +} + +// 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)); + + 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); +} + +// 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); + + 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); +} + +// 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); + + 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); +} + +// 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); + + 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); +} + +// 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. @@ -621,18 +901,18 @@ TEST(FieldTableTest, DescribeCoversExactlyTheTabledTypes) { 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::kImage, - BuiltinObjectType::kDepthImage, + const std::array other_types{ BuiltinObjectType::kOccupancyGrid, BuiltinObjectType::kCompressedPointCloud, BuiltinObjectType::kMesh3D, - BuiltinObjectType::kVideoFrame, BuiltinObjectType::kRobotDescription, - BuiltinObjectType::kCameraInfo, BuiltinObjectType::kOccupancyGridUpdate, BuiltinObjectType::kLog, BuiltinObjectType::kPosesInFrame, From c96511c6a6cb36ff2e25b2139425c0cc65d6792e Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 25 Sep 2026 12:51:10 +0200 Subject: [PATCH 05/22] feat(sdk): unprojectPixel for rectified metric depth Client-side helper next to the depth image utilities: a pixel with a positive metric depth and a conventional pinhole K unprojects to a camera frame point; singular, non-finite or skewed intrinsics are rejected. --- .../pj_base/builtin/depth_image_utils.hpp | 25 +++++++++++++++++++ pj_base/tests/depth_image_codec_test.cpp | 15 +++++++++++ 2 files changed, 40 insertions(+) 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..d77506f7 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,37 @@ #pragma once #include +#include +#include #include "pj_base/builtin/depth_image.hpp" namespace PJ { namespace sdk { +/// Unproject a rectified pixel with positive metric depth using a conventional +/// pinhole intrinsic matrix. Rejects singular/nonfinite intrinsics and unsupported +/// skew/projective terms; distortion correction belongs to the caller. +[[nodiscard]] inline std::optional> unprojectPixel( + const std::array& k, double u, double v, double depth_m) noexcept { + for (double value : k) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + if (!std::isfinite(u) || !std::isfinite(v) || !std::isfinite(depth_m) || depth_m <= 0 || k[0] <= 0 || k[4] <= 0 || + k[1] != 0 || k[3] != 0 || k[6] != 0 || k[7] != 0 || k[8] != 1) { + return std::nullopt; + } + std::array result{(u - k[2]) * depth_m / k[0], (v - k[5]) * depth_m / k[4], depth_m}; + for (double value : result) { + if (!std::isfinite(value)) { + return std::nullopt; + } + } + return result; +} + /// 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/tests/depth_image_codec_test.cpp b/pj_base/tests/depth_image_codec_test.cpp index f01830bb..df106515 100644 --- a/pj_base/tests/depth_image_codec_test.cpp +++ b/pj_base/tests/depth_image_codec_test.cpp @@ -70,3 +70,18 @@ TEST(DepthImageCodecTest, RoundTripPlumbBobDistortion) { } // namespace } // namespace PJ + +#include "pj_base/builtin/depth_image_utils.hpp" + +TEST(DepthImageUtils, UnprojectsRectifiedMetricDepthAndRejectsInvalidIntrinsics) { + std::array k{2, 0, 1, 0, 4, 1, 0, 0, 1}; + auto p = PJ::sdk::unprojectPixel(k, 3, 5, 2); + ASSERT_TRUE(p); + EXPECT_EQ(*p, (std::array{2, 2, 2})); + EXPECT_FALSE(PJ::sdk::unprojectPixel(k, 3, 5, 0)); + k[0] = 0; + EXPECT_FALSE(PJ::sdk::unprojectPixel(k, 3, 5, 2)); + k[0] = 2; + k[1] = 1; + EXPECT_FALSE(PJ::sdk::unprojectPixel(k, 3, 5, 2)); +} From 9a559a84117709046b32d6f80816166ebdd2f3fb Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 25 Sep 2026 12:57:03 +0200 Subject: [PATCH 06/22] feat(sdk): ImageAnnotations wire carries the top-level timestamp and image_topic Both are struct fields that never reached the wire, so a canonical or recorded annotations topic could not say which image it belongs to. Written only when set, so existing payloads are byte-identical; readers that predate the fields skip them. --- CHANGELOG.md | 2 + docs/image_annotations_format.md | 11 +-- pj_base/proto/pj/ImageAnnotations.proto | 4 + .../src/builtin/image_annotations_codec.cpp | 57 +++++++++---- .../tests/image_annotations_codec_test.cpp | 81 +++++++++++++++++++ 5 files changed, 134 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adec425..f9c5aff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, ImageAnnotations topic whose every entry is a complete clear-and-replace snapshot, so a stateless consumer may render each entry alone without replaying the topic's history. +- ImageAnnotations wire carries the top-level timestamp and image_topic; + additive, old readers skip them. ## [0.34.1] diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index 0fd54634..e402716d 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`. | +| `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 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/image_annotations_codec.cpp b/pj_base/src/builtin/image_annotations_codec.cpp index 8481bfa0..cfc5ca28 100644 --- a/pj_base/src/builtin/image_annotations_codec.cpp +++ b/pj_base/src/builtin/image_annotations_codec.cpp @@ -9,6 +9,7 @@ #include #include +#include "geometry_codec.hpp" #include "protobuf_wire.hpp" namespace PJ { @@ -31,7 +32,7 @@ void writePoint2(Writer& writer, const Point2& point) { writer.doubleField(2, point.y); } -void writeColor(Writer& writer, const ColorRGBA& color) { +void writeAnnotationColor(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); @@ -59,13 +60,13 @@ void writePointsAnnotation(Writer& writer, const PointsAnnotation& points) { writer.message(3, [&](Writer& nested) { writePoint2(nested, point); }); } - writer.message(4, [&](Writer& nested) { writeColor(nested, points.color); }); + writer.message(4, [&](Writer& nested) { writeAnnotationColor(nested, points.color); }); for (const auto& color : points.colors) { - writer.message(5, [&](Writer& nested) { writeColor(nested, color); }); + writer.message(5, [&](Writer& nested) { writeAnnotationColor(nested, color); }); } - writer.message(6, [&](Writer& nested) { writeColor(nested, points.fill_color); }); + writer.message(6, [&](Writer& nested) { writeAnnotationColor(nested, points.fill_color); }); writer.doubleField(7, points.thickness); } @@ -73,15 +74,15 @@ void writeCircleAnnotation(Writer& writer, const CircleAnnotation& circle) { writer.message(2, [&](Writer& nested) { writePoint2(nested, circle.center); }); writer.doubleField(3, circle.radius * 2.0); writer.doubleField(4, circle.thickness); - writer.message(5, [&](Writer& nested) { writeColor(nested, circle.fill_color); }); - writer.message(6, [&](Writer& nested) { writeColor(nested, circle.color); }); + writer.message(5, [&](Writer& nested) { writeAnnotationColor(nested, circle.fill_color); }); + writer.message(6, [&](Writer& nested) { writeAnnotationColor(nested, circle.color); }); } void writeTextAnnotation(Writer& writer, const TextAnnotation& text) { writer.message(2, [&](Writer& nested) { writePoint2(nested, text.position); }); writer.string(3, text.text); writer.doubleField(4, text.font_size); - writer.message(5, [&](Writer& nested) { writeColor(nested, text.color); }); + writer.message(5, [&](Writer& nested) { writeAnnotationColor(nested, text.color); }); } AnnotationTopology mapTopology(uint64_t type) { @@ -127,7 +128,7 @@ bool decodePoint2(Reader& reader, Point2& out) { return true; } -bool decodeColor(Reader& reader, ColorRGBA& out) { +bool decodeAnnotationColor(Reader& reader, ColorRGBA& out) { double r = 0.0; double g = 0.0; double b = 0.0; @@ -174,9 +175,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 +212,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 +223,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 +231,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 +298,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 +306,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 +360,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 +393,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 +424,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 +467,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/image_annotations_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp index 88d68b6c..c81b72f2 100644 --- a/pj_base/tests/image_annotations_codec_test.cpp +++ b/pj_base/tests/image_annotations_codec_test.cpp @@ -296,5 +296,86 @@ 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"; +} + } // namespace } // namespace PJ From 0c7a96d521efc97a9cd7c1a3705e0a2e424d17ee Mon Sep 17 00:00:00 2001 From: alvvm Date: Sun, 27 Sep 2026 19:18:57 +0200 Subject: [PATCH 07/22] test(plugins): isolate implicit dependency search from loader environment --- pj_plugins/CLAUDE.md | 13 +++++++++ pj_plugins/CMakeLists.txt | 9 +++++- pj_plugins/docs/ARCHITECTURE.md | 13 +++++++++ .../tests/run_plugin_catalog_test.cmake | 28 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 pj_plugins/tests/run_plugin_catalog_test.cmake diff --git a/pj_plugins/CLAUDE.md b/pj_plugins/CLAUDE.md index 0724613b..aaef307a 100644 --- a/pj_plugins/CLAUDE.md +++ b/pj_plugins/CLAUDE.md @@ -69,3 +69,16 @@ order, then read this file or [pj_base/CLAUDE.md](../pj_base/CLAUDE.md) as relev | Authoring native functional parser modules | `../pj_base/include/pj_base/parser_module/README.md`, `module.hpp`, `../.claude/skills/plotjuggler-plugin/references/parser-module.md` | | Service wiring into `bind()` | `include/pj_plugins/host/service_registry_builder.hpp` | | Builtin-object ingest policy | `include/pj_plugins/sdk/object_ingest_policy.hpp` | + +## Loader-environment test isolation + +On ELF platforms the host uses ordinary `dlopen`; an explicit `LD_LIBRARY_PATH` +(including an empty component meaning the current directory) is a trusted process +configuration and can override a plugin's `RUNPATH`. The host does not rewrite that +configuration or claim to isolate it. `plugin_catalog_test` checks implicit CWD/PATH +search with sibling and decoy dependencies. Its CTest launcher removes empty and +relative `LD_LIBRARY_PATH` components before starting the test process, preserving +absolute dependency directories. This makes its assertions independent of a +shell's trailing colon; modifying the variable inside the already-running test +would not change glibc's cached search path. The Windows test retains the real +CWD/PATH decoys and exercises the restricted `LoadLibraryExW` search flags. diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index 0bca22bc..d452fd11 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -781,7 +781,14 @@ add_dependencies(plugin_catalog_test mock_data_source_plugin if(WIN32) add_dependencies(plugin_catalog_test entry_point_forwarder_plugin) endif() -add_test(NAME plugin_catalog_test COMMAND plugin_catalog_test) +if(UNIX AND NOT APPLE) + add_test(NAME plugin_catalog_test + COMMAND "${CMAKE_COMMAND}" + "-DTEST_EXECUTABLE=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_plugin_catalog_test.cmake") +else() + add_test(NAME plugin_catalog_test COMMAND plugin_catalog_test) +endif() endif() # PJ_BUILD_TESTS diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 2e511cdc..ce8a5c3e 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -512,6 +512,19 @@ Each family has a loader that: 3. Validates `protocol_version` and `struct_size`. 4. Stores the vtable pointer for creating handles. +Dependency lookup follows the platform loader. On Windows, restricted +`LoadLibraryExW` search flags exclude implicit CWD/PATH directories. On ELF, +`dlopen` still honors explicit process configuration such as `LD_LIBRARY_PATH`: +an empty component denotes CWD and can override a sibling dependency selected +through `RUNPATH`. Absolute plugin paths and symbol ownership checks do not +sandbox that environment. The application must start with a trusted loader +environment when this distinction matters. + +The Linux catalog regression fixture tests implicit CWD/PATH search with real +decoys. Its CTest launcher strips empty/relative `LD_LIBRARY_PATH` entries before +starting the process, retaining absolute dependency directories; changing the +variable after glibc has started would not change its cached lookup settings. + | Family | Loader class | Load method | |---|---|---| | DataSource | `DataSourceLibrary` | `load(path) → Expected` | diff --git a/pj_plugins/tests/run_plugin_catalog_test.cmake b/pj_plugins/tests/run_plugin_catalog_test.cmake new file mode 100644 index 00000000..2c9f0e50 --- /dev/null +++ b/pj_plugins/tests/run_plugin_catalog_test.cmake @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.24) + +# The fixture checks implicit dependency search, not explicit loader-environment +# injection. On ELF, an empty LD_LIBRARY_PATH component means CWD and relative +# components change meaning when the fixture enters its decoy directory. Remove +# those before launching the process (glibc snapshots this variable at startup), +# preserving absolute dependency directories needed by Conan/system libraries. +if(NOT DEFINED TEST_EXECUTABLE) + message(FATAL_ERROR "TEST_EXECUTABLE is required") +endif() +set(_absolute_library_paths) +string(REPLACE ":" ";" _library_paths "$ENV{LD_LIBRARY_PATH}") +foreach(_path IN LISTS _library_paths) + if(IS_ABSOLUTE "${_path}") + list(APPEND _absolute_library_paths "${_path}") + endif() +endforeach() +if(_absolute_library_paths) + list(JOIN _absolute_library_paths ":" _clean_library_path) + set(ENV{LD_LIBRARY_PATH} "${_clean_library_path}") +else() + unset(ENV{LD_LIBRARY_PATH}) +endif() +execute_process(COMMAND "${TEST_EXECUTABLE}" ${TEST_ARGUMENTS} RESULT_VARIABLE _result) +if(NOT _result STREQUAL "0") + message(FATAL_ERROR "plugin_catalog_test failed: ${_result}") +endif() From 40717c7fc6b04511fd682177a0bd85026b9c4c31 Mon Sep 17 00:00:00 2001 From: alvvm Date: Tue, 29 Sep 2026 10:56:27 +0200 Subject: [PATCH 08/22] feat(sdk): identify experimental derived-object contract as 0.36 Keep feature floors and metadata coherent with the experimental host contract. Include the official zero-copy point-cloud codec and its regression coverage. Validation: 99 ordinary SDK tests passed; historical ABI comparator remains a known failing gate. Local Conan package and matching consumers built. --- CHANGELOG.md | 7 ++- VERSION | 2 +- docs/builtin_type.md | 2 +- pj_base/feature_floors.json | 2 +- .../pj_base/builtin/point_cloud_codec.hpp | 9 +++ .../pj_base/sdk/object_topic_metadata.hpp | 4 +- pj_base/src/builtin/point_cloud_codec.cpp | 24 +++++++- pj_base/tests/abi_layout_sentinels_test.cpp | 8 +-- pj_base/tests/point_cloud_codec_test.cpp | 55 +++++++++++++++++++ pj_plugins/docs/toolbox-guide.md | 8 +-- 10 files changed, 103 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c5aff2..89dfd88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,12 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". -## [0.35.0] +## [0.36.0] — experimental, unreleased -Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.scene_views.v1 (PJ_scene_view_host_vtable_t), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.35.0) +Includes the official 0.35.0 zero-copy PointCloud codec. This development identity +is not an official release; build consumers from the matching source branches. + +Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.scene_views.v1 (PJ_scene_view_host_vtable_t), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.36.0) - Add compile-time field tables (`pj_base/builtin/field_table.hpp`) describing the members of builtin object structs, so a generic binder (e.g. a script 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 1c9f2bfe..ab0621ee 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -713,7 +713,7 @@ 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.35.0). Value `"true"` means +`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 diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 350ea22d..85c1fb51 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -15,7 +15,7 @@ "0.34.0": { "PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT": "" }, - "0.35.0": { + "0.36.0": { "PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT": "", "PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW": "", "attachTopic": "PJ_scene_view_host_vtable_t::attach_topic", diff --git a/pj_base/include/pj_base/builtin/point_cloud_codec.hpp b/pj_base/include/pj_base/builtin/point_cloud_codec.hpp index fce6d404..41c4830d 100644 --- a/pj_base/include/pj_base/builtin/point_cloud_codec.hpp +++ b/pj_base/include/pj_base/builtin/point_cloud_codec.hpp @@ -7,6 +7,7 @@ #include #include +#include "pj_base/buffer_anchor.hpp" #include "pj_base/builtin/point_cloud.hpp" #include "pj_base/expected.hpp" @@ -22,4 +23,12 @@ inline constexpr std::string_view kSchemaPointCloud = "PJ.PointCloud"; /// its packed point bytes via `anchor`. [[nodiscard]] Expected deserializePointCloud(const uint8_t* data, size_t size); +/// Decodes canonical PJ.PointCloud wire bytes without copying the packed point +/// bytes: the returned cloud's `data` ALIASES the input buffer and its `anchor` +/// is the supplied `anchor`, which must keep that buffer alive and unchanged for +/// as long as the cloud is used. A null `anchor` copies, like +/// deserializePointCloud(). +[[nodiscard]] Expected deserializePointCloudView( + const uint8_t* data, size_t size, sdk::BufferAnchor anchor); + } // namespace PJ 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 733a2258..a2659e9f 100644 --- a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp +++ b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp @@ -24,7 +24,7 @@ inline constexpr std::string_view kBuiltinObjectTypeMetadataKey = "builtin_objec /// 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.35.0 +/// @since 0.36.0 inline constexpr std::string_view kSnapshotMetadataKey = "pj_snapshot"; /// Builds deterministic metadata JSON for an object topic. @@ -76,7 +76,7 @@ class ObjectTopicMetadataBuilder { /// Mark this topic as a snapshot stream (see kSnapshotMetadataKey): every /// entry is a complete clear-and-replace snapshot. Sets the key when /// `value` is true; leaves it unset (the default, incremental) when false. - /// @since 0.35.0 + /// @since 0.36.0 ObjectTopicMetadataBuilder& snapshot(bool value = true) { if (value) { strings_.insert_or_assign(std::string(kSnapshotMetadataKey), std::string("true")); diff --git a/pj_base/src/builtin/point_cloud_codec.cpp b/pj_base/src/builtin/point_cloud_codec.cpp index 557fe632..b5b04395 100644 --- a/pj_base/src/builtin/point_cloud_codec.cpp +++ b/pj_base/src/builtin/point_cloud_codec.cpp @@ -28,12 +28,18 @@ using sdk::PointField; // ---------- PointCloud payload bytes ---------- -bool readBytesIntoCloud(Reader& reader, PointCloud& out) { +// Points `out.data` into the input when `input_anchor` owns it, else copies. +bool readBytesIntoCloud(Reader& reader, PointCloud& out, const sdk::BufferAnchor& input_anchor) { const uint8_t* data = nullptr; size_t size = 0; if (!reader.readBytes(data, size)) { return false; } + if (input_anchor) { + out.data = Span(data, size); + out.anchor = input_anchor; + return true; + } auto owned = std::make_shared>(data, data + size); out.data = Span(owned->data(), owned->size()); out.anchor = owned; @@ -62,7 +68,9 @@ std::vector serializePointCloud(const PointCloud& cloud) { return out; } -Expected deserializePointCloud(const uint8_t* data, size_t size) { +namespace { + +Expected decodePointCloud(const uint8_t* data, size_t size, const sdk::BufferAnchor& input_anchor) { if (data == nullptr || size == 0) { return unexpected(std::string("PointCloud wire: empty buffer")); } @@ -143,7 +151,7 @@ Expected deserializePointCloud(const uint8_t* data, size_t size case 8: return tag.type == WireType::kLengthDelimited && readPointFieldIntoVector(r, cloud.fields); case 9: - return tag.type == WireType::kLengthDelimited && readBytesIntoCloud(r, cloud); + return tag.type == WireType::kLengthDelimited && readBytesIntoCloud(r, cloud, input_anchor); case 10: return tag.type == WireType::kLengthDelimited && r.readString(cloud.frame_id); default: @@ -158,4 +166,14 @@ Expected deserializePointCloud(const uint8_t* data, size_t size return cloud; } +} // namespace + +Expected deserializePointCloud(const uint8_t* data, size_t size) { + return decodePointCloud(data, size, nullptr); +} + +Expected deserializePointCloudView(const uint8_t* data, size_t size, sdk::BufferAnchor anchor) { + return decodePointCloud(data, size, anchor); +} + } // namespace PJ diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 6e819b24..070c2f07 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -356,7 +356,7 @@ static_assert(sizeof(PJ_object_topic_info_t) == 96, "PJ_object_topic_info_t size static_assert(offsetof(PJ_object_topic_info_t, entry_count) == 56, "PJ_object_topic_info_t.entry_count offset pinned"); static_assert(offsetof(PJ_object_topic_info_t, reserved) == 80, "PJ_object_topic_info_t.reserved offset pinned"); -static_assert(sizeof(PJ_catalog_snapshot_v2_t) == 88, "PJ_catalog_snapshot_v2_t size pinned at introduction (0.35.0)"); +static_assert(sizeof(PJ_catalog_snapshot_v2_t) == 88, "PJ_catalog_snapshot_v2_t size pinned at introduction (0.36.0)"); static_assert( offsetof(PJ_catalog_snapshot_v2_t, struct_size) == 0, "PJ_catalog_snapshot_v2_t.struct_size offset pinned"); static_assert( @@ -368,13 +368,13 @@ static_assert( offsetof(PJ_catalog_snapshot_v2_t, release_ctx) == 72, "PJ_catalog_snapshot_v2_t.release_ctx offset pinned"); static_assert(offsetof(PJ_catalog_snapshot_v2_t, release) == 80, "PJ_catalog_snapshot_v2_t.release offset pinned"); -// --- Data-processor typed request vocabulary (introduced 0.35.0) ------------ +// --- Data-processor typed request vocabulary (introduced 0.36.0) ------------ static_assert(sizeof(PJ_data_processor_output_t) == 32, "PJ_data_processor_output_t size pinned (fixed stride)"); static_assert(offsetof(PJ_data_processor_output_t, name) == 0, "PJ_data_processor_output_t.name offset pinned"); static_assert(offsetof(PJ_data_processor_output_t, type) == 16, "PJ_data_processor_output_t.type offset pinned"); static_assert( - sizeof(PJ_data_processor_request_t) == 168, "PJ_data_processor_request_t size pinned at introduction (0.35.0)"); + sizeof(PJ_data_processor_request_t) == 168, "PJ_data_processor_request_t size pinned at introduction (0.36.0)"); static_assert( offsetof(PJ_data_processor_request_t, struct_size) == 0, "PJ_data_processor_request_t.struct_size offset pinned"); static_assert(offsetof(PJ_data_processor_request_t, inputs) == 104, "PJ_data_processor_request_t.inputs offset pinned"); @@ -386,7 +386,7 @@ static_assert( static_assert( offsetof(PJ_data_processor_request_t, time_ns) == 160, "PJ_data_processor_request_t.time_ns offset pinned"); -static_assert(sizeof(PJ_evaluation_budget_t) == 40, "PJ_evaluation_budget_t size pinned at introduction (0.35.0)"); +static_assert(sizeof(PJ_evaluation_budget_t) == 40, "PJ_evaluation_budget_t size pinned at introduction (0.36.0)"); static_assert(offsetof(PJ_evaluation_budget_t, struct_size) == 0, "PJ_evaluation_budget_t.struct_size offset pinned"); static_assert( offsetof(PJ_evaluation_budget_t, max_report_bytes) == 32, "PJ_evaluation_budget_t.max_report_bytes offset pinned"); diff --git a/pj_base/tests/point_cloud_codec_test.cpp b/pj_base/tests/point_cloud_codec_test.cpp index 374bf98f..738cb358 100644 --- a/pj_base/tests/point_cloud_codec_test.cpp +++ b/pj_base/tests/point_cloud_codec_test.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include "protobuf_wire_test_helpers.hpp" @@ -79,5 +80,59 @@ TEST(PointCloudCodecTest, FrameIdAbsentRoundTrips) { EXPECT_TRUE(out->frame_id.empty()); } +PointCloud smallCloud(std::vector& payload) { + PointCloud in; + in.width = 2; + in.point_step = 12; + in.row_step = 24; + in.frame_id = "lidar"; + in.fields = { + {.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + {.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + {.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + payload.assign(24, 0); + for (size_t i = 0; i < payload.size(); ++i) { + payload[i] = static_cast(i * 7); + } + in.data = Span(payload.data(), payload.size()); + return in; +} + +TEST(PointCloudCodecTest, ViewAliasesAnchoredBytes) { + std::vector points; + const PointCloud in = smallCloud(points); + const auto wire = std::make_shared>(serializePointCloud(in)); + auto out = deserializePointCloudView(wire->data(), wire->size(), wire); + ASSERT_TRUE(out.has_value()); + EXPECT_EQ(out->width, in.width); + EXPECT_EQ(out->frame_id, in.frame_id); + ASSERT_EQ(out->fields.size(), 3u); + ASSERT_EQ(out->data.size(), points.size()); + EXPECT_EQ(std::memcmp(out->data.data(), points.data(), points.size()), 0); + // No copy: the bytes live inside the wire buffer, which the result keeps alive. + EXPECT_GE(out->data.data(), wire->data()); + EXPECT_LE(out->data.data() + out->data.size(), wire->data() + wire->size()); + EXPECT_EQ(out->anchor, wire); +} + +TEST(PointCloudCodecTest, ViewWithoutAnchorCopies) { + std::vector points; + const PointCloud in = smallCloud(points); + const std::vector wire = serializePointCloud(in); + auto out = deserializePointCloudView(wire.data(), wire.size(), nullptr); + ASSERT_TRUE(out.has_value()); + ASSERT_EQ(out->data.size(), points.size()); + EXPECT_EQ(std::memcmp(out->data.data(), points.data(), points.size()), 0); + EXPECT_TRUE(out->data.data() < wire.data() || out->data.data() >= wire.data() + wire.size()); + EXPECT_NE(out->anchor, nullptr); +} + +TEST(PointCloudCodecTest, ViewRejectsWhatTheCopyingDecoderRejects) { + const auto garbage = std::make_shared>(std::vector{0xFF, 0xFF, 0xFF}); + EXPECT_FALSE(deserializePointCloudView(garbage->data(), garbage->size(), garbage).has_value()); + EXPECT_FALSE(deserializePointCloudView(nullptr, 0, nullptr).has_value()); +} + } // namespace } // namespace PJ diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index ee7c28a0..48200f93 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -200,7 +200,7 @@ data store. | `appendBoundRecord(topic, timestamp, fields)` | Write using pre-resolved field handles (faster). | | `appendArrowStream(topic, stream, ts_col)` | Hand an `ArrowArrayStream*` (Arrow C Data Interface) to the host for bulk ingest. Same ownership rule as the source write path: success transfers, failure retains. | | `catalogSnapshot()` | Acquire a read-only snapshot of all data sources, topics, and fields. | -| `catalogSnapshotV2()` (0.35.0) | Like `catalogSnapshot()`, plus every object topic (`objectTopics()`) with its dataset, builtin type, entry count and raw time range, in one deep copy. | +| `catalogSnapshotV2()` (0.36.0) | Like `catalogSnapshot()`, plus every object topic (`objectTopics()`) with its dataset, builtin type, entry count and raw time range, in one deep copy. | | `readSeriesArrow(field, schema*, array*)` | Read one field's full time series into host-owned `ArrowSchema` + `ArrowArray` out-params (two columns: `timestamp` int64 ns, then the typed field value). | | `registerObjectTopic(source, name, type[, extra_metadata])` | Register a built-in media/object topic under a data source. The typed overload emits the canonical `builtin_object_type` renderer metadata and returns an `ObjectTopicHandle`. | | `registerObjectTopic(source, name, metadata_json)` | Raw-metadata overload for custom or untyped object topics. The store retains the JSON verbatim. | @@ -215,7 +215,7 @@ Access via `runtimeHost()`. Use this for diagnostics and UI refresh. | `reportMessage(level, text)` | Send info/warning/error to the host UI log. | | `notifyDataChanged()` | Tell the host that data was modified; refresh UI. Idempotent and cheap; coalesce per logical operation, not per record. | -### Playback, viewport, owned tabs, and scene views (SDK 0.28.0; scene views 0.35.0) +### Playback, viewport, owned tabs, and scene views (SDK 0.28.0; scene views 0.36.0) Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: @@ -224,7 +224,7 @@ Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: | `PJ::sdk::PlaybackHostService` | `play`, `pause`, `seek`, `setPlaybackRate`, `state`: the global playback cursor. `toDisplayTime` and `toDisplayTimeForSource` convert absolute nanoseconds to display-axis seconds. | | `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. | | `PJ::sdk::ViewportHostService` | `zoomToTimeRange`, `zoomReset`: all eligible plots in the calling plugin's tabs. | -| `PJ::sdk::SceneViewHostService` (`pj.scene_views.v1`, 0.35.0) | `createView(id, kind, title)`, `closeView`, `list`, `configOf`, `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focusView`: only the calling plugin's 3D/2D scene views. `kind` is `"3d"` or `"2d"`; re-creating an id with a different kind closes and recreates it empty. `configOf` reports what a view actually holds as JSON (topics with dataset, type, visibility). | +| `PJ::sdk::SceneViewHostService` (`pj.scene_views.v1`, 0.36.0) | `createView(id, kind, title)`, `closeView`, `list`, `configOf`, `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focusView`: only the calling plugin's 3D/2D scene views. `kind` is `"3d"` or `"2d"`; re-creating an id with a different kind closes and recreates it empty. `configOf` reports what a view actually holds as JSON (topics with dataset, type, visibility). | All calls run on the main thread. Services are optional. Check acquisition and each operation's result. A host offering viewport control also offers @@ -292,7 +292,7 @@ and with both sources loaded the parser chooses `a:b`. Composition round-trips only when the intended source is the longest matching prefix. Do not treat this string as a persistent dataset identity. Hosts still validate the split result. -### Typed requests and on-demand evaluation (SDK 0.35.0) +### Typed requests and on-demand evaluation (SDK 0.36.0) `DataProcessorsHostView::createV2(request)` upserts a `kind="on_demand"` node like `createOnDemand`, but the request is typed (`DataProcessorRequest`): outputs carry From f8ced95e4f45adcfe062cb906496e64b83c21b98 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 1 Oct 2026 09:16:26 +0200 Subject: [PATCH 09/22] feat(sdk): scene tabs as kinds of pj.plot_tabs.v1 tabs pj.scene_views.v1 (never released) duplicated pj.plot_tabs.v1: both manage plugin-owned tabs in the same workspace. Scene 3D/2D tabs become kinds of plot tabs through four tail slots of PJ_plot_tab_host_vtable_t: create_tab_v2 (kind plot/3d/2d), attach_topic, detach_topic and focus_tab. The seven v1 slots, their offsets and the plot tab_config JSON are unchanged; a host without a scene workspace leaves the tail slots NULL. PlotTabHostView gains createV2/attachTopic/detachTopic/focus/hasSceneTabs. --- CHANGELOG.md | 12 +- pj_base/CMakeLists.txt | 1 - pj_base/feature_floors.json | 12 +- pj_base/include/pj_base/plugin_data_api.h | 108 +++--- .../include/pj_base/sdk/plugin_data_api.hpp | 146 +++----- .../include/pj_base/sdk/service_traits.hpp | 14 +- pj_base/tests/abi_layout_sentinels_test.cpp | 30 +- pj_base/tests/plot_tabs_api_test.cpp | 325 +++++++++++++++- pj_base/tests/scene_views_api_test.cpp | 347 ------------------ pj_plugins/docs/toolbox-guide.md | 3 +- tools/feature_floors/extract_host_surfaces.py | 1 - tools/feature_floors/host_surfaces.snapshot | 12 +- 12 files changed, 441 insertions(+), 570 deletions(-) delete mode 100644 pj_base/tests/scene_views_api_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c24b4eec..47ee7655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is ## [0.36.0] — Unreleased -Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.scene_views.v1 (PJ_scene_view_host_vtable_t), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.36.0) +Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.36.0) - Add compile-time field tables (`pj_base/builtin/field_table.hpp`) describing the members of builtin object structs, so a generic binder (e.g. a script @@ -63,11 +63,11 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, to read the result either way, as a JSON report (`{"coverage":{...},"bundles":[...]}` — one bundle per requested instant, one entry per output, `*_ns` values as raw int64 dataset nanoseconds). -- Add the `pj.scene_views.v1` service (`PJ_scene_view_host_vtable_t`, - `SceneViewHostView`): lets a plugin compose 3D/2D scene views of its own — - create (upsert by id), attach/detach object topics, read back what a view - actually holds as JSON, focus, close — scoped by the host to the calling - plugin the same way `pj.plot_tabs.v1` scopes plotting tabs. +- Scene 3D/2D tabs are kinds of `pj.plot_tabs.v1` tabs: tail slots + `create_tab_v2`, `attach_topic`, `detach_topic`, `focus_tab` (C++: + `PlotTabHostView::createV2/attachTopic/detachTopic/focus/hasSceneTabs`). The + slots are NULL when the host has no scene workspace; the plot `tab_config` + JSON is unchanged. - Add the `pj_snapshot` object-topic metadata key (`ObjectTopicMetadataBuilder::snapshot`): marks a SceneEntities/ ImageAnnotations topic whose every entry is a complete clear-and-replace diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 6ae1e7cb..7a524252 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -167,7 +167,6 @@ if(PJ_BUILD_TESTS) tests/data_processors_api_test.cpp tests/playback_viewport_api_test.cpp tests/plot_tabs_api_test.cpp - tests/scene_views_api_test.cpp tests/dataset_qualified_name_api_test.cpp tests/settings_store_host_test.cpp tests/parser_runtime_host_test.cpp diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 85c1fb51..dbf92643 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -18,16 +18,12 @@ "0.36.0": { "PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT": "", "PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW": "", - "attachTopic": "PJ_scene_view_host_vtable_t::attach_topic", + "attachTopic": "PJ_plot_tab_host_vtable_t::attach_topic", "catalogSnapshotV2": "PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2", - "closeView": "PJ_scene_view_host_vtable_t::close_view", - "configOf": "PJ_scene_view_host_vtable_t::view_config", "createV2": "PJ_data_processors_host_vtable_t::create_data_processor_v2", - "createView": "PJ_scene_view_host_vtable_t::create_view", - "detachTopic": "PJ_scene_view_host_vtable_t::detach_topic", - "focusView": "PJ_scene_view_host_vtable_t::focus_view", - "listViews": "PJ_scene_view_host_vtable_t::list_view_ids", - "pj.scene_views.v1": "", + "create_tab_v2": "PJ_plot_tab_host_vtable_t::create_tab_v2", + "detachTopic": "PJ_plot_tab_host_vtable_t::detach_topic", + "focus": "PJ_plot_tab_host_vtable_t::focus_tab", "pollEvaluation": "PJ_data_processors_host_vtable_t::poll_evaluation", "releaseEvaluation": "PJ_data_processors_host_vtable_t::release_evaluation", "submitEvaluation": "PJ_data_processors_host_vtable_t::submit_evaluation" diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 13a89346..cbc100d3 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1388,6 +1388,25 @@ typedef struct { * it. Whether such a tab is saved with the workspace is likewise the host's * policy, not this service's contract. * + * Tab kinds: "plot" (curves), "3d" and "2d" (scene tabs holding object topics). + * Scene tabs are served by the tail slots create_tab_v2 / attach_topic / + * detach_topic / focus_tab (0.36.0). The v1 slots on a scene tab: add_curve and + * remove_curve are errors "tab '' is a <3d|2d> scene tab: use + * attach_topic/detach_topic"; clear_tab detaches every topic and keeps the tab; + * close_tab, list_tab_ids (all kinds) and tab_config work. + * create_tab_v2 with the same id and kind replaces a plot tab with one empty plot + * (v1 "create or replace") and only updates the title of a scene tab; a different + * kind closes the old tab and creates a new one. + * + * tab_config JSON: for a plot tab, byte-identical to v1, + * {"title":"...","curves":[...]} with NO "kind" key. For a scene tab, + * {"kind":"3d","title":"...","topics":[{"topic":"...","dataset":"...","type":"kPointCloud","visible":true}]} + * ("kind" is "3d" or "2d"): what the tab ACTUALLY holds, datasets resolved. + * + * A host with no scene workspace leaves the four tail slots NULL (not an error): + * callers check with PJ_HAS_TAIL_SLOT before calling. An error return means the + * host has the capability but refused this request. + * * All slots are [main-thread]. ABI-APPENDABLE: new slots may be added at the * tail; struct_size gates read. */ @@ -1447,73 +1466,50 @@ typedef struct PJ_plot_tab_host_vtable_t { /* [main-thread] Remove every curve from a tab, keeping the tab itself. */ bool (*clear_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; -} PJ_plot_tab_host_vtable_t; - -typedef struct { - void* ctx; - const PJ_plot_tab_host_vtable_t* vtable; -} PJ_plot_tab_host_t; -/** - * Scene-view host service ("pj.scene_views.v1", protocol_version 1). - * - * Lets a plugin compose 3D/2D scene views OF ITS OWN: create one, attach and - * detach object topics in it, read back what it holds, close it. Every slot is - * scoped to the calling binding — a view this plugin did not create is - * rejected exactly as an unknown id is, so the service never discloses, - * mutates or even confirms the existence of the user's views. That scoping is - * the host's job, not the plugin's: it is enforced where ownership is known, - * and ownership is derived from `ctx`, never passed in. - * - * Ids are independent of visible titles: titles may repeat or be renamed - * without changing an id. Ids are chosen by the plugin and namespaced per - * plugin by the host (the "pj.data_processors.v1" discipline), so an id is - * unique within this binding and cannot collide with another plugin's. A view - * is a VIEW: closing it discards the presentation only, never the object - * topics it showed. - * - * All slots are [main-thread]. ABI-APPENDABLE: new slots may be added at the - * tail; struct_size gates read. - */ -typedef struct PJ_scene_view_host_vtable_t { - uint32_t protocol_version; /* = 1 */ - uint32_t struct_size; /* = sizeof(PJ_scene_view_host_vtable_t) */ - /* Create or replace (upsert by id) a scene view owned by this plugin. kind is "3d" - * or "2d". Re-creating an id with a DIFFERENT kind closes the view and creates a - * new empty one; the same kind only updates the title. An empty title lets the host - * name it. An id containing '/' is rejected. */ - bool (*create_view)( + /* [main-thread] Create (or update) a tab of the given `kind`: "plot", "3d" or "2d". + * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. One id namespace per + * plugin across kinds. The same id with the same kind: on a plot tab it replaces + * the tab with one empty plot (the released `create_tab` "create or replace" + * behaviour); on a scene tab it only updates the title. The same id with a DIFFERENT + * kind closes the old tab and creates a new empty one. `create_tab` (v1) is this call + * with kind "plot". An empty `id`, or an id containing '/', is an error here (v1 + * keeps its released id rules). An empty `title` lets the host name the tab. */ + bool (*create_tab_v2)( void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) PJ_NOEXCEPT; - /* Close one of this plugin's views (the VIEW only; the topics it showed are data and outlive it). */ - bool (*close_view)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; - /* Count-then-fill enumeration of THIS plugin's live view ids (borrowed until the next call on this host object). */ - bool (*list_view_ids)( - void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) PJ_NOEXCEPT; - /* {"kind":"3d","title":"...","topics":[{"topic":"...","dataset":"...","type":"kPointCloud","visible":true}]} - * — what the view ACTUALLY holds, datasets resolved. Borrowed until the next call on this host object. */ - bool (*view_config)(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) - PJ_NOEXCEPT; - /* Attach an object topic. dataset_source is the source NAME as PJ_data_source_info_t - * reports it; "" means the topic must be unique across loaded datasets (an ambiguous - * one is refused with the candidates). "3d" accepts every type the host's 3D view - * renders; "2d" accepts Image/DepthImage (replacing the current image) and - * ImageAnnotations (an overlay); any other type is an error naming it. Attaching a - * topic already there is success. */ + + /* [main-thread] Attach an object topic to a scene tab. ABI-APPENDED slot (0.36.0): + * gate with PJ_HAS_TAIL_SLOT. Scene tabs only. `dataset_source` follows the same + * rule as add_curve: empty means the topic must be unique across loaded datasets, + * and an ambiguous one is refused with the qualified candidates. A "3d" tab accepts + * every object type the 3D view renders; a "2d" tab accepts Image/DepthImage/ + * VideoFrame (replacing the background) and ImageAnnotations (an overlay). On a plot + * tab this is an error: "tab '' is a plot tab: use add_curve". */ bool (*attach_topic)( void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; - /* Take one topic back out (same dataset_source rule). A topic that is not there is an error. */ + + /* [main-thread] Take one topic back out of a scene tab (same `dataset_source` rule). + * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. A topic that is not + * attached is an error. On a plot tab this is an error: "tab '' is a plot tab: + * use remove_curve". */ bool (*detach_topic)( void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; - /* Raise the view's tab. */ - bool (*focus_view)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; -} PJ_scene_view_host_vtable_t; + + /* [main-thread] Bring one of this plugin's tabs (any kind) to the front. + * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. */ + bool (*focus_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; +} PJ_plot_tab_host_vtable_t; + +/* Minimum acceptable struct_size: the seven slots released with the v1 layout. + * Slots from create_tab_v2 on are tail slots: gate each with PJ_HAS_TAIL_SLOT. */ +#define PJ_PLOT_TAB_HOST_MIN_VTABLE_SIZE (offsetof(PJ_plot_tab_host_vtable_t, create_tab_v2)) typedef struct { void* ctx; - const PJ_scene_view_host_vtable_t* vtable; -} PJ_scene_view_host_t; + const PJ_plot_tab_host_vtable_t* vtable; +} PJ_plot_tab_host_t; #ifdef __cplusplus } diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index a665a8b3..13041564 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -2283,122 +2283,70 @@ class PlotTabHostView { return okStatus(); } - private: - PJ_plot_tab_host_t host_{}; -}; - -// --------------------------------------------------------------------------- -// SceneViewHostView — typed C++ view over PJ_scene_view_host_t -// --------------------------------------------------------------------------- - -/// C++ wrapper around PJ_scene_view_host_t for plugins that compose 3D/2D -/// scene views of their own (service "pj.scene_views.v1"). Empty-constructible; -/// `valid()` tells whether the host exposed the service. Mirrors PlotTabHostView. -class SceneViewHostView { - public: - SceneViewHostView() = default; - explicit SceneViewHostView(PJ_scene_view_host_t host) : host_(host) {} - - [[nodiscard]] bool valid() const noexcept { - return host_.vtable != nullptr && host_.ctx != nullptr; - } - - /// Create (or replace, upsert by id) a scene view owned by this plugin. - /// `kind` is "3d" or "2d". Re-creating an id with a DIFFERENT kind closes - /// the view and creates a new empty one; the same kind only updates the - /// title. An empty `title` lets the host name it. - [[nodiscard]] Status createView(std::string_view id, std::string_view kind, std::string_view title = {}) const { - if (!valid() || host_.vtable->create_view == nullptr) { - return unexpected("scene views host is not bound"); - } - PJ_error_t err{}; - if (!host_.vtable->create_view(host_.ctx, toAbiString(id), toAbiString(kind), toAbiString(title), &err)) { - return unexpected(errorToString(err)); - } - return okStatus(); - } - - /// Close one of this plugin's views. The topics it showed are data and - /// outlive the view. - [[nodiscard]] Status closeView(std::string_view id) const { - if (!valid() || host_.vtable->close_view == nullptr) { - return unexpected("scene views host is not bound"); - } - PJ_error_t err{}; - if (!host_.vtable->close_view(host_.ctx, toAbiString(id), &err)) { - return unexpected(errorToString(err)); - } - return okStatus(); - } - - /// Enumerate the ids of this plugin's live views (owned copies). - [[nodiscard]] Expected> listViews() const { - if (!valid() || host_.vtable->list_view_ids == nullptr) { - return unexpected("scene views host is not bound"); - } - return detail::listBorrowedStrings(host_.ctx, host_.vtable->list_view_ids); - } - - /// Read back what a view actually holds, as JSON (owned copy): - /// {"kind":"3d","title":"...","topics":[{"topic":"...","dataset":"...","type":"kPointCloud","visible":true}]}. - [[nodiscard]] Expected configOf(std::string_view id) const { - if (!valid() || host_.vtable->view_config == nullptr) { - return unexpected("scene views host is not bound"); - } - PJ_error_t err{}; - PJ_string_view_t out{}; - if (!host_.vtable->view_config(host_.ctx, toAbiString(id), &out, &err)) { - return unexpected(errorToString(err)); - } - return std::string(toStringView(out)); + /// True iff the host serves scene (3D/2D) tabs: all four tail slots + /// (create_tab_v2, attach_topic, detach_topic, focus_tab) are present. A host with + /// no scene workspace leaves them NULL; check this before calling them. + [[nodiscard]] bool hasSceneTabs() const noexcept { + return valid() && PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, create_tab_v2) && + PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, attach_topic) && + PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, detach_topic) && + PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, focus_tab); + } + + /// Create (or update) a tab of `kind` ("plot", "3d" or "2d"). Same id and kind: a plot + /// tab is replaced by one empty plot, a scene tab only gets its title updated; same id + /// with a different kind closes the old tab and creates a new empty one. An empty + /// `title` lets the host name it. + [[nodiscard]] Status createV2(std::string_view id, std::string_view kind, std::string_view title = {}) const { + return callTail<&PJ_plot_tab_host_vtable_t::create_tab_v2>( + "create_tab_v2", toAbiString(id), toAbiString(kind), toAbiString(title)); } - /// Attach an object topic. An empty `dataset_source` requires the topic to - /// be unique across loaded datasets; an ambiguous one is refused by the - /// host with the qualified candidates. Attaching a topic already there is - /// success. + /// Attach an object topic to a scene tab. An empty `dataset_source` requires the topic + /// to be unique across loaded datasets; an ambiguous one is refused by the host with + /// the qualified candidates. Attaching a topic already there is success. [[nodiscard]] Status attachTopic( std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { - if (!valid() || host_.vtable->attach_topic == nullptr) { - return unexpected("scene views host is not bound"); - } - PJ_error_t err{}; - if (!host_.vtable->attach_topic( - host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(dataset_source), &err)) { - return unexpected(errorToString(err)); - } - return okStatus(); + return callTail<&PJ_plot_tab_host_vtable_t::attach_topic>( + "attach_topic", toAbiString(id), toAbiString(topic), toAbiString(dataset_source)); } - /// Take one topic back out, resolved by the same rule as attachTopic. A - /// topic that is not there is an error. + /// Take one topic back out of a scene tab, resolved by the same rule as attachTopic. + /// A topic that is not attached is an error. [[nodiscard]] Status detachTopic( std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { - if (!valid() || host_.vtable->detach_topic == nullptr) { - return unexpected("scene views host is not bound"); - } - PJ_error_t err{}; - if (!host_.vtable->detach_topic( - host_.ctx, toAbiString(id), toAbiString(topic), toAbiString(dataset_source), &err)) { - return unexpected(errorToString(err)); - } - return okStatus(); + return callTail<&PJ_plot_tab_host_vtable_t::detach_topic>( + "detach_topic", toAbiString(id), toAbiString(topic), toAbiString(dataset_source)); + } + + /// Bring one of this plugin's tabs (any kind) to the front. + [[nodiscard]] Status focus(std::string_view id) const { + return callTail<&PJ_plot_tab_host_vtable_t::focus_tab>("focus_tab", toAbiString(id)); } - /// Raise the view's tab. - [[nodiscard]] Status focusView(std::string_view id) const { - if (!valid() || host_.vtable->focus_view == nullptr) { - return unexpected("scene views host is not bound"); + private: + // Shared body of the tail-slot wrappers: bound check, PJ_HAS_TAIL_SLOT gate, call, + // error conversion. `Slot` is the vtable member pointer of the slot. + template + [[nodiscard]] Status callTail(const char* slot_name, Args... args) const { + if (!valid()) { + return unexpected("plot tab host is not bound"); + } + const auto* vtable = host_.vtable; + // PJ_HAS_TAIL_SLOT needs the field name, so check size and null through the member pointer. + const auto slot_end = + reinterpret_cast(&(vtable->*Slot)) + sizeof(vtable->*Slot) - reinterpret_cast(vtable); + if (vtable->struct_size < static_cast(slot_end) || vtable->*Slot == nullptr) { + return unexpected(std::string("plot tab host does not support ") + slot_name); } PJ_error_t err{}; - if (!host_.vtable->focus_view(host_.ctx, toAbiString(id), &err)) { + if (!(vtable->*Slot)(host_.ctx, args..., &err)) { return unexpected(errorToString(err)); } return okStatus(); } - private: - PJ_scene_view_host_t host_{}; + PJ_plot_tab_host_t host_{}; }; // --------------------------------------------------------------------------- diff --git a/pj_base/include/pj_base/sdk/service_traits.hpp b/pj_base/include/pj_base/sdk/service_traits.hpp index c88b652d..56e28cf7 100644 --- a/pj_base/include/pj_base/sdk/service_traits.hpp +++ b/pj_base/include/pj_base/sdk/service_traits.hpp @@ -217,6 +217,7 @@ struct ViewportHostService { /// and remove curves, read back what they hold, close. Scoped by the host to /// this plugin's tabs, which is also what bounds "pj.viewport.v1". Hosts with /// no plot workspace (headless) simply do not register it. +/// Scene (3D/2D) tabs are tail slots of this service since 0.36.0. struct PlotTabHostService { static constexpr const char* kName = "pj.plot_tabs.v1"; static constexpr uint32_t kMinVersion = 1; @@ -226,19 +227,6 @@ struct PlotTabHostService { static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); }; -/// "pj.scene_views.v1" — compose 3D/2D scene views of the plugin's own: create, -/// attach and detach object topics, read back what they hold, close. Scoped by -/// the host to this plugin's views, the same discipline as "pj.plot_tabs.v1". -/// Hosts with no scene workspace (headless) simply do not register it. -struct SceneViewHostService { - static constexpr const char* kName = "pj.scene_views.v1"; - static constexpr uint32_t kMinVersion = 1; - using Raw = PJ_scene_view_host_t; - using Vtable = PJ_scene_view_host_vtable_t; - using View = SceneViewHostView; - static_assert(detail::isValidServiceName(kName), "kName must match the pj naming rule"); -}; - /// Optional QSettings-like key/value persistence exposed to any plugin family. /// Host-backed (QSettings in the GUI app, JSON in a headless host); keys are /// namespaced per plugin by the host. diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 070c2f07..36e2087a 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -391,18 +391,24 @@ static_assert(offsetof(PJ_evaluation_budget_t, struct_size) == 0, "PJ_evaluation static_assert( offsetof(PJ_evaluation_budget_t, max_report_bytes) == 32, "PJ_evaluation_budget_t.max_report_bytes offset pinned"); -// --- Scene-view host vtable ("pj.scene_views.v1", ABI-APPENDABLE) ----------- -static_assert(offsetof(PJ_scene_view_host_vtable_t, protocol_version) == 0, "scene view host prefix pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, struct_size) == 4, "scene view host prefix pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, create_view) == 8, "scene view host create_view slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, close_view) == 16, "scene view host close_view slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, list_view_ids) == 24, "scene view host list_view_ids slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, view_config) == 32, "scene view host view_config slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, attach_topic) == 40, "scene view host attach_topic slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, detach_topic) == 48, "scene view host detach_topic slot pinned"); -static_assert(offsetof(PJ_scene_view_host_vtable_t, focus_view) == 56, "scene view host focus_view slot pinned"); -static_assert(sizeof(PJ_scene_view_host_vtable_t) == 64, "Scene view host vtable size (update deliberately on append)"); -static_assert(sizeof(PJ_scene_view_host_t) == 16, "Scene view host fat pointer pinned"); +// --- Plot-tab host vtable ("pj.plot_tabs.v1", ABI-APPENDABLE) --------------- +// Seven v1 slots are released and frozen; create_tab_v2 onward are tail slots (0.36.0). +static_assert(offsetof(PJ_plot_tab_host_vtable_t, protocol_version) == 0, "plot tab host prefix pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, struct_size) == 4, "plot tab host prefix pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, create_tab) == 8, "plot tab host create_tab slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, close_tab) == 16, "plot tab host close_tab slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, list_tab_ids) == 24, "plot tab host list_tab_ids slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, tab_config) == 32, "plot tab host tab_config slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, add_curve) == 40, "plot tab host add_curve slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, remove_curve) == 48, "plot tab host remove_curve slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, clear_tab) == 56, "plot tab host clear_tab slot pinned"); +static_assert(PJ_PLOT_TAB_HOST_MIN_VTABLE_SIZE == 64, "plot tab host min vtable size pinned at the v1 layout"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, create_tab_v2) == 64, "plot tab host create_tab_v2 tail slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, attach_topic) == 72, "plot tab host attach_topic tail slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, detach_topic) == 80, "plot tab host detach_topic tail slot pinned"); +static_assert(offsetof(PJ_plot_tab_host_vtable_t, focus_tab) == 88, "plot tab host focus_tab tail slot pinned"); +static_assert(sizeof(PJ_plot_tab_host_vtable_t) == 96, "Plot tab host vtable size (update deliberately on append)"); +static_assert(sizeof(PJ_plot_tab_host_t) == 16, "Plot tab host fat pointer pinned"); // --- Toolbox runtime host vtable (ABI-APPENDABLE within v4) ------------------ // The vtable the host exposes to plugins under "pj.toolbox_runtime.v1". diff --git a/pj_base/tests/plot_tabs_api_test.cpp b/pj_base/tests/plot_tabs_api_test.cpp index 15bdffd0..eb7f1613 100644 --- a/pj_base/tests/plot_tabs_api_test.cpp +++ b/pj_base/tests/plot_tabs_api_test.cpp @@ -15,8 +15,8 @@ namespace PJ { namespace { -// Fake host for pj.plot_tabs.v1: a small model of tabs-with-curves, not a bare -// recorder, so the round-trip tests below can assert on what a read-back +// Fake host for pj.plot_tabs.v1 (v1 slots and scene tail slots): a small model of +// tabs-with-curves-or-topics, not a bare recorder, so the round-trip tests below can assert on what a read-back // actually contains rather than merely that a call was forwarded. struct FakePlotTabHost { struct Curve { @@ -24,15 +24,23 @@ struct FakePlotTabHost { std::string field; std::string dataset; }; + struct Topic { + std::string topic; + std::string dataset; + }; struct Tab { std::string id; + std::string kind = "plot"; std::string title; std::vector curves; + std::vector topics; }; std::vector tabs; bool should_fail = false; std::string last_config_json; // storage backing the borrowed tab_config out-string + int focus_calls = 0; + std::string last_focused_id; Tab* find(std::string_view id) { auto it = std::find_if(tabs.begin(), tabs.end(), [&](const Tab& t) { return t.id == id; }); @@ -48,23 +56,38 @@ bool ptFail(FakePlotTabHost* self, PJ_error_t* out_error) noexcept { return false; } -bool ptCreateTab(void* ctx, PJ_string_view_t id, PJ_string_view_t title, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); +// Same id and kind: a plot tab is replaced by one empty plot, a scene tab only gets +// its title updated. A different kind resets the tab to empty. +bool createOfKind( + FakePlotTabHost* self, PJ_string_view_t id, std::string_view kind, PJ_string_view_t title, PJ_error_t* out_error) { if (ptFail(self, out_error)) { return false; } const auto id_sv = sdk::toStringView(id); + const std::string title_str(sdk::toStringView(title)); auto* existing = self->find(id_sv); - if (existing != nullptr) { - existing->curves.clear(); - existing->title = std::string(sdk::toStringView(title)); + if (existing == nullptr) { + self->tabs.push_back(FakePlotTabHost::Tab{.id = std::string(id_sv), .kind = std::string(kind), .title = title_str}); return true; } - self->tabs.push_back( - FakePlotTabHost::Tab{.id = std::string(id_sv), .title = std::string(sdk::toStringView(title)), .curves = {}}); + if (existing->kind != kind || kind == "plot") { + existing->kind = std::string(kind); + existing->curves.clear(); + existing->topics.clear(); + } + existing->title = title_str; return true; } +bool ptCreateTab(void* ctx, PJ_string_view_t id, PJ_string_view_t title, PJ_error_t* out_error) noexcept { + return createOfKind(static_cast(ctx), id, "plot", title, out_error); +} + +bool ptCreateTabV2( + void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) noexcept { + return createOfKind(static_cast(ctx), id, sdk::toStringView(kind), title, out_error); +} + bool ptCloseTab(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { auto* self = static_cast(ctx); if (ptFail(self, out_error)) { @@ -109,16 +132,30 @@ bool ptTabConfig(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_js sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); return false; } - std::string json = "{\"title\":\"" + tab->title + "\",\"curves\":["; - for (size_t i = 0; i < tab->curves.size(); ++i) { - if (i != 0) { - json += ","; + std::string json; + if (tab->kind == "plot") { + json = "{\"title\":\"" + tab->title + "\",\"curves\":["; + for (size_t i = 0; i < tab->curves.size(); ++i) { + if (i != 0) { + json += ","; + } + const auto& curve = tab->curves[i]; + json += + "{\"topic\":\"" + curve.topic + "\",\"field\":\"" + curve.field + "\",\"dataset\":\"" + curve.dataset + "\"}"; + } + json += "]}"; + } else { + json = "{\"kind\":\"" + tab->kind + "\",\"title\":\"" + tab->title + "\",\"topics\":["; + for (size_t i = 0; i < tab->topics.size(); ++i) { + if (i != 0) { + json += ","; + } + const auto& topic = tab->topics[i]; + json += "{\"topic\":\"" + topic.topic + "\",\"dataset\":\"" + topic.dataset + + "\",\"type\":\"kPointCloud\",\"visible\":true}"; } - const auto& curve = tab->curves[i]; - json += - "{\"topic\":\"" + curve.topic + "\",\"field\":\"" + curve.field + "\",\"dataset\":\"" + curve.dataset + "\"}"; + json += "]}"; } - json += "]}"; self->last_config_json = std::move(json); *out_config_json = sdk::toAbiString(self->last_config_json); return true; @@ -136,6 +173,12 @@ bool ptAddCurve( sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); return false; } + if (tab->kind != "plot") { + sdk::fillError( + out_error, 4, "plot_tabs", + "tab '" + tab->id + "' is a " + tab->kind + " scene tab: use attach_topic/detach_topic"); + return false; + } tab->curves.push_back( FakePlotTabHost::Curve{ .topic = std::string(sdk::toStringView(topic)), @@ -181,9 +224,76 @@ bool ptClearTab(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept return false; } tab->curves.clear(); + tab->topics.clear(); + return true; +} + +bool ptAttachTopic( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + const auto topic_sv = sdk::toStringView(topic); + const auto dataset_sv = sdk::toStringView(dataset_source); + auto it = std::find_if(tab->topics.begin(), tab->topics.end(), [&](const auto& t) { + return t.topic == topic_sv && t.dataset == dataset_sv; + }); + if (it != tab->topics.end()) { + return true; // already attached: success + } + tab->topics.push_back(FakePlotTabHost::Topic{.topic = std::string(topic_sv), .dataset = std::string(dataset_sv)}); + return true; +} + +bool ptDetachTopic( + void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, + PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + auto* tab = self->find(sdk::toStringView(id)); + if (tab == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + const auto topic_sv = sdk::toStringView(topic); + const auto dataset_sv = sdk::toStringView(dataset_source); + auto it = std::find_if(tab->topics.begin(), tab->topics.end(), [&](const auto& t) { + return t.topic == topic_sv && t.dataset == dataset_sv; + }); + if (it == tab->topics.end()) { + sdk::fillError(out_error, 3, "plot_tabs", "topic not present"); + return false; + } + tab->topics.erase(it); return true; } +bool ptFocusTab(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { + auto* self = static_cast(ctx); + if (ptFail(self, out_error)) { + return false; + } + const auto id_sv = sdk::toStringView(id); + if (self->find(id_sv) == nullptr) { + sdk::fillError(out_error, 2, "plot_tabs", "unknown tab id"); + return false; + } + ++self->focus_calls; + self->last_focused_id = std::string(id_sv); + return true; +} + +// Full vtable (v1 slots + the four tail slots). Tests that need a released v1 host +// shrink struct_size to PJ_PLOT_TAB_HOST_MIN_VTABLE_SIZE or null a tail slot. PJ_plot_tab_host_vtable_t makePlotTabVtable() { return PJ_plot_tab_host_vtable_t{ .protocol_version = 1, @@ -195,6 +305,10 @@ PJ_plot_tab_host_vtable_t makePlotTabVtable() { .add_curve = ptAddCurve, .remove_curve = ptRemoveCurve, .clear_tab = ptClearTab, + .create_tab_v2 = ptCreateTabV2, + .attach_topic = ptAttachTopic, + .detach_topic = ptDetachTopic, + .focus_tab = ptFocusTab, }; } @@ -391,5 +505,182 @@ TEST(PlotTabApiTest, DatasetQualifierReachesTheHost) { EXPECT_TRUE(tab->curves[1].dataset.empty()); } +// --- Scene tabs (tail slots) --------------------------------------------------- + +TEST(PlotTabSceneApiTest, CreateAndListRoundTrip) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createV2("view-a", "3d", "First")); + ASSERT_TRUE(view.createV2("view-b", "2d", "Second")); + + auto ids = view.list(); + ASSERT_TRUE(ids) << ids.error(); + ASSERT_EQ(ids->size(), 2u); + EXPECT_EQ((*ids)[0], "view-a"); + EXPECT_EQ((*ids)[1], "view-b"); +} + +TEST(PlotTabSceneApiTest, ConfigReadsBackWhatWasAttached) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createV2("view-a", "3d", "My View")); + ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); + ASSERT_TRUE(view.attachTopic("view-a", "camera/image", "bag1")); + + auto config = view.configOf("view-a"); + ASSERT_TRUE(config) << config.error(); + EXPECT_NE(config->find("My View"), std::string::npos); + EXPECT_NE(config->find("lidar/points"), std::string::npos); + EXPECT_NE(config->find("camera/image"), std::string::npos); + + const auto grown_size = config->size(); + ASSERT_TRUE(view.attachTopic("view-a", "imu/data", "bag1")); + auto config2 = view.configOf("view-a"); + ASSERT_TRUE(config2) << config2.error(); + EXPECT_NE(config2->find("imu/data"), std::string::npos); + EXPECT_GT(config2->size(), grown_size); +} + +TEST(PlotTabSceneApiTest, DetachMissingTopicIsAnError) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createV2("view-a", "3d")); + auto status = view.detachTopic("view-a", "lidar/points", "bag1"); + EXPECT_FALSE(status); +} + +TEST(PlotTabSceneApiTest, UnknownIdIsAnError) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + EXPECT_FALSE(view.attachTopic("nope", "lidar/points")); + EXPECT_FALSE(view.configOf("nope")); + EXPECT_FALSE(view.close("nope")); + EXPECT_FALSE(view.focus("nope")); +} + +TEST(PlotTabSceneApiTest, HostFailureSurfacesTheMessage) { + FakePlotTabHost host; + host.should_fail = true; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + auto create_status = view.createV2("view-a", "3d"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("tab boom"), std::string::npos); + + auto attach_status = view.attachTopic("view-a", "lidar/points"); + EXPECT_FALSE(attach_status); + EXPECT_NE(attach_status.error().find("tab boom"), std::string::npos); + + auto list_status = view.list(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("tab boom"), std::string::npos); +} + +TEST(PlotTabSceneApiTest, UnboundViewReportsNotBound) { + sdk::PlotTabHostView view; // default-constructed = not bound + EXPECT_FALSE(view.valid()); + + auto create_status = view.createV2("view-a", "3d"); + EXPECT_FALSE(create_status); + EXPECT_NE(create_status.error().find("not bound"), std::string::npos); + + auto close_status = view.close("view-a"); + EXPECT_FALSE(close_status); + EXPECT_NE(close_status.error().find("not bound"), std::string::npos); + + auto list_status = view.list(); + EXPECT_FALSE(list_status); + EXPECT_NE(list_status.error().find("not bound"), std::string::npos); + + auto config_status = view.configOf("view-a"); + EXPECT_FALSE(config_status); + EXPECT_NE(config_status.error().find("not bound"), std::string::npos); + + auto attach_status = view.attachTopic("view-a", "lidar/points"); + EXPECT_FALSE(attach_status); + EXPECT_NE(attach_status.error().find("not bound"), std::string::npos); + + auto detach_status = view.detachTopic("view-a", "lidar/points"); + EXPECT_FALSE(detach_status); + EXPECT_NE(detach_status.error().find("not bound"), std::string::npos); + + auto focus_status = view.focus("view-a"); + EXPECT_FALSE(focus_status); + EXPECT_NE(focus_status.error().find("not bound"), std::string::npos); +} + +TEST(PlotTabSceneApiTest, DatasetQualifierReachesTheHost) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createV2("view-a", "3d")); + ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); + ASSERT_TRUE(view.attachTopic("view-a", "camera/image")); + + auto* found = host.find("view-a"); + ASSERT_NE(found, nullptr); + ASSERT_EQ(found->topics.size(), 2u); + EXPECT_EQ(found->topics[0].dataset, "bag1"); + EXPECT_TRUE(found->topics[1].dataset.empty()); +} + +TEST(PlotTabSceneApiTest, HasSceneTabsWhenAllTailSlotsPresent) { + FakePlotTabHost host; + const auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + EXPECT_TRUE(view.hasSceneTabs()); + EXPECT_FALSE(sdk::PlotTabHostView{}.hasSceneTabs()); +} + +TEST(PlotTabSceneApiTest, V1HostWithStructSize64ReportsNoSceneTabs) { + FakePlotTabHost host; + auto vtable = makePlotTabVtable(); + vtable.struct_size = PJ_PLOT_TAB_HOST_MIN_VTABLE_SIZE; // a released v1 host: 64 bytes + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + EXPECT_FALSE(view.hasSceneTabs()); + auto status = view.createV2("tab-a", "3d"); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("does not support"), std::string::npos); + EXPECT_FALSE(view.attachTopic("tab-a", "t")); + EXPECT_FALSE(view.detachTopic("tab-a", "t")); + EXPECT_FALSE(view.focus("tab-a")); + EXPECT_TRUE(host.tabs.empty()); +} + +TEST(PlotTabSceneApiTest, NullTailSlotOnLargeStructReportsNoSceneTabs) { + FakePlotTabHost host; + auto vtable = makePlotTabVtable(); + ASSERT_EQ(vtable.struct_size, sizeof(PJ_plot_tab_host_vtable_t)); + vtable.create_tab_v2 = nullptr; // headless host: struct_size is 96 but the slot is NULL + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + EXPECT_FALSE(view.hasSceneTabs()); + auto status = view.createV2("tab-a", "3d"); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("does not support"), std::string::npos); +} + +TEST(PlotTabSceneApiTest, AddCurveOnSceneTabSurfacesHostError) { + FakePlotTabHost host; + auto vtable = makePlotTabVtable(); + sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); + + ASSERT_TRUE(view.createV2("tab-a", "3d")); + auto status = view.addCurve("tab-a", "imu/accel", "x"); + EXPECT_FALSE(status); + EXPECT_NE(status.error().find("tab 'tab-a' is a 3d scene tab: use attach_topic/detach_topic"), std::string::npos); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/scene_views_api_test.cpp b/pj_base/tests/scene_views_api_test.cpp deleted file mode 100644 index e4d1978a..00000000 --- a/pj_base/tests/scene_views_api_test.cpp +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright 2026 Davide Faconti -// SPDX-License-Identifier: Apache-2.0 - -#include - -#include -#include -#include -#include -#include - -#include "pj_base/plugin_data_api.h" -#include "pj_base/sdk/plugin_data_api.hpp" - -namespace PJ { -namespace { - -// Fake host for pj.scene_views.v1: a small model of views-with-topics, not a bare -// recorder, so the round-trip tests below can assert on what a read-back -// actually contains rather than merely that a call was forwarded. -struct FakeSceneViewHost { - struct Topic { - std::string topic; - std::string dataset; - }; - struct View { - std::string id; - std::string kind; - std::string title; - std::vector topics; - }; - - std::vector views; - bool should_fail = false; - std::string last_config_json; // storage backing the borrowed view_config out-string - int focus_calls = 0; - std::string last_focused_id; - - View* find(std::string_view id) { - auto it = std::find_if(views.begin(), views.end(), [&](const View& v) { return v.id == id; }); - return it == views.end() ? nullptr : &(*it); - } -}; - -bool svFail(FakeSceneViewHost* self, PJ_error_t* out_error) noexcept { - if (self->should_fail) { - sdk::fillError(out_error, 1, "scene_views", "view boom"); - return true; - } - return false; -} - -bool svCreateView( - void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - const auto id_sv = sdk::toStringView(id); - const auto kind_sv = sdk::toStringView(kind); - auto* existing = self->find(id_sv); - if (existing != nullptr) { - if (existing->kind != kind_sv) { - existing->kind = std::string(kind_sv); - existing->topics.clear(); - } - existing->title = std::string(sdk::toStringView(title)); - return true; - } - self->views.push_back( - FakeSceneViewHost::View{ - .id = std::string(id_sv), - .kind = std::string(kind_sv), - .title = std::string(sdk::toStringView(title)), - .topics = {}}); - return true; -} - -bool svCloseView(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - const auto id_sv = sdk::toStringView(id); - auto it = std::find_if(self->views.begin(), self->views.end(), [&](const auto& v) { return v.id == id_sv; }); - if (it == self->views.end()) { - sdk::fillError(out_error, 2, "scene_views", "unknown view id"); - return false; - } - self->views.erase(it); - return true; -} - -bool svListViewIds( - void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - const auto total = static_cast(self->views.size()); - if (capacity == 0) { - *out_count = total; - return true; - } - const uint64_t filled = std::min(capacity, total); - for (uint64_t i = 0; i < filled; ++i) { - out_ids[i] = sdk::toAbiString(self->views[i].id); - } - *out_count = total; - return true; -} - -bool svViewConfig(void* ctx, PJ_string_view_t id, PJ_string_view_t* out_config_json, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - auto* view = self->find(sdk::toStringView(id)); - if (view == nullptr) { - sdk::fillError(out_error, 2, "scene_views", "unknown view id"); - return false; - } - std::string json = "{\"kind\":\"" + view->kind + "\",\"title\":\"" + view->title + "\",\"topics\":["; - for (size_t i = 0; i < view->topics.size(); ++i) { - if (i != 0) { - json += ","; - } - const auto& topic = view->topics[i]; - json += "{\"topic\":\"" + topic.topic + "\",\"dataset\":\"" + topic.dataset + - "\",\"type\":\"kPointCloud\",\"visible\":true}"; - } - json += "]}"; - self->last_config_json = std::move(json); - *out_config_json = sdk::toAbiString(self->last_config_json); - return true; -} - -bool svAttachTopic( - void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, - PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - auto* view = self->find(sdk::toStringView(id)); - if (view == nullptr) { - sdk::fillError(out_error, 2, "scene_views", "unknown view id"); - return false; - } - const auto topic_sv = sdk::toStringView(topic); - const auto dataset_sv = sdk::toStringView(dataset_source); - auto it = std::find_if(view->topics.begin(), view->topics.end(), [&](const auto& t) { - return t.topic == topic_sv && t.dataset == dataset_sv; - }); - if (it != view->topics.end()) { - return true; // already attached: success - } - view->topics.push_back(FakeSceneViewHost::Topic{.topic = std::string(topic_sv), .dataset = std::string(dataset_sv)}); - return true; -} - -bool svDetachTopic( - void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, - PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - auto* view = self->find(sdk::toStringView(id)); - if (view == nullptr) { - sdk::fillError(out_error, 2, "scene_views", "unknown view id"); - return false; - } - const auto topic_sv = sdk::toStringView(topic); - const auto dataset_sv = sdk::toStringView(dataset_source); - auto it = std::find_if(view->topics.begin(), view->topics.end(), [&](const auto& t) { - return t.topic == topic_sv && t.dataset == dataset_sv; - }); - if (it == view->topics.end()) { - sdk::fillError(out_error, 3, "scene_views", "topic not present"); - return false; - } - view->topics.erase(it); - return true; -} - -bool svFocusView(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) noexcept { - auto* self = static_cast(ctx); - if (svFail(self, out_error)) { - return false; - } - const auto id_sv = sdk::toStringView(id); - auto* view = self->find(id_sv); - if (view == nullptr) { - sdk::fillError(out_error, 2, "scene_views", "unknown view id"); - return false; - } - ++self->focus_calls; - self->last_focused_id = std::string(id_sv); - return true; -} - -PJ_scene_view_host_vtable_t makeSceneViewVtable() { - return PJ_scene_view_host_vtable_t{ - .protocol_version = 1, - .struct_size = sizeof(PJ_scene_view_host_vtable_t), - .create_view = svCreateView, - .close_view = svCloseView, - .list_view_ids = svListViewIds, - .view_config = svViewConfig, - .attach_topic = svAttachTopic, - .detach_topic = svDetachTopic, - .focus_view = svFocusView, - }; -} - -// --- SceneViewHostView ------------------------------------------------------- - -TEST(SceneViewsApiTest, CreateAndListRoundTrip) { - FakeSceneViewHost host; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - ASSERT_TRUE(view.createView("view-a", "3d", "First")); - ASSERT_TRUE(view.createView("view-b", "2d", "Second")); - - auto ids = view.listViews(); - ASSERT_TRUE(ids) << ids.error(); - ASSERT_EQ(ids->size(), 2u); - EXPECT_EQ((*ids)[0], "view-a"); - EXPECT_EQ((*ids)[1], "view-b"); -} - -TEST(SceneViewsApiTest, ConfigReadsBackWhatWasAttached) { - FakeSceneViewHost host; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - ASSERT_TRUE(view.createView("view-a", "3d", "My View")); - ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); - ASSERT_TRUE(view.attachTopic("view-a", "camera/image", "bag1")); - - auto config = view.configOf("view-a"); - ASSERT_TRUE(config) << config.error(); - EXPECT_NE(config->find("My View"), std::string::npos); - EXPECT_NE(config->find("lidar/points"), std::string::npos); - EXPECT_NE(config->find("camera/image"), std::string::npos); - - const auto grown_size = config->size(); - ASSERT_TRUE(view.attachTopic("view-a", "imu/data", "bag1")); - auto config2 = view.configOf("view-a"); - ASSERT_TRUE(config2) << config2.error(); - EXPECT_NE(config2->find("imu/data"), std::string::npos); - EXPECT_GT(config2->size(), grown_size); -} - -TEST(SceneViewsApiTest, DetachMissingTopicIsAnError) { - FakeSceneViewHost host; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - ASSERT_TRUE(view.createView("view-a", "3d")); - auto status = view.detachTopic("view-a", "lidar/points", "bag1"); - EXPECT_FALSE(status); -} - -TEST(SceneViewsApiTest, UnknownIdIsAnError) { - FakeSceneViewHost host; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - EXPECT_FALSE(view.attachTopic("nope", "lidar/points")); - EXPECT_FALSE(view.configOf("nope")); - EXPECT_FALSE(view.closeView("nope")); - EXPECT_FALSE(view.focusView("nope")); -} - -TEST(SceneViewsApiTest, HostFailureSurfacesTheMessage) { - FakeSceneViewHost host; - host.should_fail = true; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - auto create_status = view.createView("view-a", "3d"); - EXPECT_FALSE(create_status); - EXPECT_NE(create_status.error().find("view boom"), std::string::npos); - - auto attach_status = view.attachTopic("view-a", "lidar/points"); - EXPECT_FALSE(attach_status); - EXPECT_NE(attach_status.error().find("view boom"), std::string::npos); - - auto list_status = view.listViews(); - EXPECT_FALSE(list_status); - EXPECT_NE(list_status.error().find("view boom"), std::string::npos); -} - -TEST(SceneViewsApiTest, UnboundViewReportsNotBound) { - sdk::SceneViewHostView view; // default-constructed = not bound - EXPECT_FALSE(view.valid()); - - auto create_status = view.createView("view-a", "3d"); - EXPECT_FALSE(create_status); - EXPECT_NE(create_status.error().find("not bound"), std::string::npos); - - auto close_status = view.closeView("view-a"); - EXPECT_FALSE(close_status); - EXPECT_NE(close_status.error().find("not bound"), std::string::npos); - - auto list_status = view.listViews(); - EXPECT_FALSE(list_status); - EXPECT_NE(list_status.error().find("not bound"), std::string::npos); - - auto config_status = view.configOf("view-a"); - EXPECT_FALSE(config_status); - EXPECT_NE(config_status.error().find("not bound"), std::string::npos); - - auto attach_status = view.attachTopic("view-a", "lidar/points"); - EXPECT_FALSE(attach_status); - EXPECT_NE(attach_status.error().find("not bound"), std::string::npos); - - auto detach_status = view.detachTopic("view-a", "lidar/points"); - EXPECT_FALSE(detach_status); - EXPECT_NE(detach_status.error().find("not bound"), std::string::npos); - - auto focus_status = view.focusView("view-a"); - EXPECT_FALSE(focus_status); - EXPECT_NE(focus_status.error().find("not bound"), std::string::npos); -} - -TEST(SceneViewsApiTest, DatasetQualifierReachesTheHost) { - FakeSceneViewHost host; - const auto vtable = makeSceneViewVtable(); - sdk::SceneViewHostView view(PJ_scene_view_host_t{.ctx = &host, .vtable = &vtable}); - - ASSERT_TRUE(view.createView("view-a", "3d")); - ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); - ASSERT_TRUE(view.attachTopic("view-a", "camera/image")); - - auto* found = host.find("view-a"); - ASSERT_NE(found, nullptr); - ASSERT_EQ(found->topics.size(), 2u); - EXPECT_EQ(found->topics[0].dataset, "bag1"); - EXPECT_TRUE(found->topics[1].dataset.empty()); -} - -} // namespace -} // namespace PJ diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 48200f93..425db841 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -222,9 +222,8 @@ Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: | Service trait | Methods and scope | |---|---| | `PJ::sdk::PlaybackHostService` | `play`, `pause`, `seek`, `setPlaybackRate`, `state`: the global playback cursor. `toDisplayTime` and `toDisplayTimeForSource` convert absolute nanoseconds to display-axis seconds. | -| `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. | +| `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. Since 0.36.0 the same service also serves scene tabs through tail slots, absent on a host with no scene workspace (check `hasSceneTabs()`): `createV2(id, kind, title)` with `kind` `"plot"`, `"3d"` or `"2d"` (one id namespace across kinds; re-creating an id with a different kind closes and recreates it empty), `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focus(id)`. On a scene tab `addCurve`/`removeCurve` are errors; `clear` detaches every topic; `configOf` reports what the tab actually holds (scene tabs: `kind`, topics with dataset, type, visibility; plot tabs: unchanged, no `kind`). | | `PJ::sdk::ViewportHostService` | `zoomToTimeRange`, `zoomReset`: all eligible plots in the calling plugin's tabs. | -| `PJ::sdk::SceneViewHostService` (`pj.scene_views.v1`, 0.36.0) | `createView(id, kind, title)`, `closeView`, `list`, `configOf`, `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focusView`: only the calling plugin's 3D/2D scene views. `kind` is `"3d"` or `"2d"`; re-creating an id with a different kind closes and recreates it empty. `configOf` reports what a view actually holds as JSON (topics with dataset, type, visibility). | All calls run on the main thread. Services are optional. Check acquisition and each operation's result. A host offering viewport control also offers diff --git a/tools/feature_floors/extract_host_surfaces.py b/tools/feature_floors/extract_host_surfaces.py index 36082a70..5c2f0530 100644 --- a/tools/feature_floors/extract_host_surfaces.py +++ b/tools/feature_floors/extract_host_surfaces.py @@ -57,7 +57,6 @@ "pj.playback.v1", "pj.plot_tabs.v1", "pj.runtime.v1", - "pj.scene_views.v1", "pj.settings.v1", "pj.source_object_write.v1", "pj.source_promotion.v1", diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index 14e1d495..af85a7ea 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -64,19 +64,16 @@ PJ_playback_host_vtable_t::set_playback_rate PJ_playback_host_vtable_t::to_display_time PJ_playback_host_vtable_t::to_display_time_for_source PJ_plot_tab_host_vtable_t::add_curve +PJ_plot_tab_host_vtable_t::attach_topic PJ_plot_tab_host_vtable_t::clear_tab PJ_plot_tab_host_vtable_t::close_tab PJ_plot_tab_host_vtable_t::create_tab +PJ_plot_tab_host_vtable_t::create_tab_v2 +PJ_plot_tab_host_vtable_t::detach_topic +PJ_plot_tab_host_vtable_t::focus_tab PJ_plot_tab_host_vtable_t::list_tab_ids PJ_plot_tab_host_vtable_t::remove_curve PJ_plot_tab_host_vtable_t::tab_config -PJ_scene_view_host_vtable_t::attach_topic -PJ_scene_view_host_vtable_t::close_view -PJ_scene_view_host_vtable_t::create_view -PJ_scene_view_host_vtable_t::detach_topic -PJ_scene_view_host_vtable_t::focus_view -PJ_scene_view_host_vtable_t::list_view_ids -PJ_scene_view_host_vtable_t::view_config PJ_service_registry_vtable_t::get_service PJ_settings_store_vtable_t::contains PJ_settings_store_vtable_t::get_string @@ -118,7 +115,6 @@ pj.parser_write.v1 pj.playback.v1 pj.plot_tabs.v1 pj.runtime.v1 -pj.scene_views.v1 pj.settings.v1 pj.source_object_write.v1 pj.source_promotion.v1 From 67631b743aa5c19323ce14b4bbb5639e7edf0e63 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 1 Oct 2026 14:36:16 +0200 Subject: [PATCH 10/22] feat(sdk): DataProcessorsHostView::hasTypedRequests Lets a plugin ask whether the host offers the typed request surface (create_data_processor_v2 and the evaluation handles) before offering features that need it, instead of probing a call and parsing its error. --- CHANGELOG.md | 3 +++ .../include/pj_base/sdk/plugin_data_api.hpp | 12 +++++++++++- pj_base/tests/data_processors_api_test.cpp | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ee7655..5d157dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, to read the result either way, as a JSON report (`{"coverage":{...},"bundles":[...]}` — one bundle per requested instant, one entry per output, `*_ns` values as raw int64 dataset nanoseconds). +- Add `DataProcessorsHostView::hasTypedRequests()`: true iff the host serves + `create_data_processor_v2` and `submit_evaluation`/`poll_evaluation`/ + `release_evaluation`, so a plugin can gate typed-request UI without probing. - Scene 3D/2D tabs are kinds of `pj.plot_tabs.v1` tabs: tail slots `create_tab_v2`, `attach_topic`, `detach_topic`, `focus_tab` (C++: `PlotTabHostView::createV2/attachTopic/detachTopic/focus/hasSceneTabs`). The diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 13041564..6170c1ab 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1691,7 +1691,17 @@ class DataProcessorsHostView { return host_.vtable != nullptr && host_.ctx != nullptr; } - /// Create or replace (upsert by id) a data processor of `kind` ("transform" or + /// True iff the host serves the typed-request surface: createV2() and the + /// submitEvaluation()/pollEvaluation()/releaseEvaluation() trio (four tail slots, all + /// covered by the vtable's struct_size). Cheap capability check; no host call is made. + [[nodiscard]] bool hasTypedRequests() const noexcept { + return valid() && PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2) && + PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, submit_evaluation) && + PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, poll_evaluation) && + PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, release_evaluation); + } + + /// Create or replace (upsert by id) a data processor of `kind' ("transform" or /// "markers"). `outputs` may be empty for an ephemeral preview (flags & /// PJ_DATA_PROCESSOR_FLAG_EPHEMERAL), in which case the host names the sink(s); /// flags & PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT instead marks a persisted node the diff --git a/pj_base/tests/data_processors_api_test.cpp b/pj_base/tests/data_processors_api_test.cpp index 357985a2..fe67f642 100644 --- a/pj_base/tests/data_processors_api_test.cpp +++ b/pj_base/tests/data_processors_api_test.cpp @@ -555,6 +555,24 @@ TEST(DataProcessorsApiTest, CreateV2OnOldHostReportsNotSupported) { EXPECT_FALSE(host.create_v2_called); } +TEST(DataProcessorsApiTest, HasTypedRequestsReflectsTailSlots) { + FakeDataProcessorsHost host; + auto vtable = makeVtable(); + sdk::DataProcessorsHostView full(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + EXPECT_TRUE(full.hasTypedRequests()); + EXPECT_FALSE(sdk::DataProcessorsHostView{}.hasTypedRequests()); + + // A host whose struct_size ends before the typed-request tail. + vtable.struct_size = offsetof(PJ_data_processors_host_vtable_t, create_data_processor_v2); + sdk::DataProcessorsHostView old_host(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + EXPECT_FALSE(old_host.hasTypedRequests()); + + // A host that covers the tail but stops short of the last slot. + vtable.struct_size = offsetof(PJ_data_processors_host_vtable_t, release_evaluation); + sdk::DataProcessorsHostView partial(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + EXPECT_FALSE(partial.hasTypedRequests()); +} + TEST(DataProcessorsApiTest, SubmitPollReleaseRoundTrip) { FakeDataProcessorsHost host; const auto vtable = makeVtable(); From f480b4d332413496288e1f57934615fef3b33887 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 1 Oct 2026 16:57:44 +0200 Subject: [PATCH 11/22] feat(plugins): optional short badge in the plugin manifest A plugin can declare "badge" (e.g. "AI"), a short label the host shows next to what the plugin created. Absent, the host falls back to the plugin's name. --- CHANGELOG.md | 3 +++ pj_plugins/docs/toolbox-guide.md | 1 + .../include/pj_plugins/host/plugin_catalog.hpp | 3 +++ pj_plugins/src/plugin_catalog.cpp | 5 +++++ pj_plugins/tests/plugin_catalog_test.cpp | 17 +++++++++++++++++ 5 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d157dca..8bf94671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, ImageAnnotations topic whose every entry is a complete clear-and-replace snapshot, so a stateless consumer may render each entry alone without replaying the topic's history. +- Add the optional manifest string `badge` (`PluginDescriptor::badge`, "" when + absent): a short label a host may show next to objects the plugin creates. + Older manifests and hosts are unaffected. - ImageAnnotations wire carries the top-level timestamp and image_topic; additive, old readers skip them. diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 425db841..2c4ec8e2 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -433,6 +433,7 @@ it without instantiating the plugin. | `name` | string | yes | Human-readable plugin name. | | `version` | string | yes | Semver version string. | | `description` | string | no | Short description of the plugin. | +| `badge` | string | no | Short label (e.g. `AI`) the host may show next to objects this plugin creates. Absent means empty; the host then falls back to the plugin name. | Example: ```json diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 86a02e9c..b788f7b5 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -61,6 +61,9 @@ struct PluginDescriptor { /// runs the plugin with some optional features inactive. Informational, for /// host degradation display — never an admission criterion. std::string suggested_sdk_version; + /// Optional short label (e.g. "AI") a host may show next to objects this + /// plugin creates. "" when the manifest does not declare it. + std::string badge; }; /// Diagnostic for a candidate DSO that could not produce a valid descriptor. diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 7bff9483..26c8e3ec 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -261,6 +261,10 @@ Expected decodeManifest( if (!suggested_sdk_version) { return unexpected(suggested_sdk_version.error()); } + auto badge = optional_string("badge"); + if (!badge) { + return unexpected(badge.error()); + } auto min_sdk_required = optional_string("min_sdk_required"); if (!min_sdk_required) { return unexpected(min_sdk_required.error()); @@ -288,6 +292,7 @@ Expected decodeManifest( d.min_plotjuggler_version = *min_plotjuggler_version; d.min_sdk_required = *min_sdk_required; d.suggested_sdk_version = *suggested_sdk_version; + d.badge = *badge; d.file_extensions = *file_extensions; d.capabilities = *capabilities; diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index 65a72929..e3e01b8a 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -295,6 +295,23 @@ TEST_F(PluginCatalogTest, SuggestedSdkVersionRoundTripsAndDefaultsEmpty) { EXPECT_TRUE(without_field->suggested_sdk_version.empty()); } +TEST_F(PluginCatalogTest, BadgeRoundTripsAndDefaultsEmpty) { + auto with_field = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","badge":"AI"})"); + ASSERT_TRUE(with_field.has_value()) << with_field.error(); + EXPECT_EQ(with_field->badge, "AI"); + + auto without_field = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0"})"); + ASSERT_TRUE(without_field.has_value()) << without_field.error(); + EXPECT_TRUE(without_field->badge.empty()); + + auto wrong_type = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","badge":3})"); + EXPECT_FALSE(wrong_type.has_value()); +} + TEST_F(PluginCatalogTest, MinSdkRequiredAcceptsEmptyString) { auto descriptor = decodeManifest( "static:sdk-test", PluginFamily::kDataSource, From 6cf6d7619bc58ec02e0d9a5d7a8422854a6366d6 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 1 Oct 2026 18:05:11 +0200 Subject: [PATCH 12/22] feat(data-processors): infer on-demand outputs from what the script returns PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS lets a transient evaluation run with no declared outputs: the host names and types each returned value and lists them under "outputs" in the report. On create, the declared outputs are the ones a trial run learned. Available since 0.36.0. --- CHANGELOG.md | 7 ++++++- pj_base/feature_floors.json | 1 + pj_base/include/pj_base/plugin_data_api.h | 21 +++++++++++++++++++-- tools/feature_floors/host_surfaces.snapshot | 1 + 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf94671..38ca6716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,13 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is ## [0.36.0] — Unreleased -Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT (floor 0.36.0) +Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT, PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (floor 0.36.0) +- Add `PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS` (on_demand): a transient evaluation + with no declared outputs infers each output's name and type from the script's + returned value and reports them in a root `"outputs"` array of the + `poll_evaluation` report; on create/create_v2 the declared outputs are a + binding hint learned from such a trial. - Add compile-time field tables (`pj_base/builtin/field_table.hpp`) 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 diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index dbf92643..30f41a67 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -16,6 +16,7 @@ "PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT": "" }, "0.36.0": { + "PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS": "", "PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT": "", "PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW": "", "attachTopic": "PJ_plot_tab_host_vtable_t::attach_topic", diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index cbc100d3..75d08609 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -999,7 +999,8 @@ typedef struct { * persisted, dropped on remove). PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT * marks a node the host's undo/redo history has no authority over * (still persisted like any other node; history never restores, - * recreates or removes it). Reserved bits must be 0. + * recreates or removes it). PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS: see + * its definition below. Reserved bits must be 0. * * DATASET-QUALIFIED NAMES — a series' full identity is (dataset, topic, field); a * bare "topic/field" name is an abbreviation that stops being unique the moment @@ -1042,6 +1043,19 @@ typedef struct { * read data_processor_config after creation: only history_exempt=true confirms * the property. A missing/false property or failed read does not confirm it. */ #define PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT (1u << 1) +/* on_demand only. Output names and types are learned from what the script returns + * instead of being declared. + * - Transient evaluation (submit_evaluation with a non-empty script and no declared + * outputs): the host runs the script and infers each output's name and type from + * the returned value. A non-table value gives one output "value"; a keyed table + * gives one output per key (sorted by name); a positional table gives outputs + * named by type ("value", "value_2", ..., "text", "cloud", "scene", "annotations", + * "image", "transforms", "object"). Mixed keyed+positional or empty returns are + * errors. The inferred list is returned in the report ("outputs"). + * - create/create_v2: the declared outputs are a binding hint learned from such a + * trial. The host still validates the returned types at runtime. + * A host that does not know this bit rejects it as an unknown flag. */ +#define PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (1u << 2) /* PJ_data_processor_request_t.time_flags */ #define PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW (1u << 0) /* window_start_ns..window_end_ns are meaningful */ @@ -1184,7 +1198,10 @@ typedef struct PJ_data_processors_host_vtable_t { * with one bundle for INSTANT. A bundle is {"requested_ns","stamp_ns","from_cache", * "revision","inputs":[{"alias","resolved_ns","is_object"}],"outputs":{name:{"status": * "ok"|"unavailable"|"empty"|"error","value"?,"topic"?,"summary"?,"reason"?}}}. - * Objects never appear as bytes, only as a "summary" object. Every *_ns value is a + * Objects never appear as bytes, only as a "summary" object. When the request + * carried PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS the report root also has + * "outputs":[{"name":"...","type":"number"|"string"|""| + * "unknown"}], the inferred outputs in report order. Every *_ns value is a * raw dataset nanosecond count as a JSON integer (int64; do not round-trip through * a double). On FAILED *out_json is {"error":"..."}. *out_json is borrowed until * release_evaluation(handle). An unknown handle is an error. ABI-APPENDED slot. */ diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index af85a7ea..7851996e 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -1,5 +1,6 @@ PJ_DATA_PROCESSOR_FLAG_EPHEMERAL PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT +PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW PJ_DESCRIPTOR_IMPORT_START_FLAGS_V1_MASK From 0dcd0afec756d08935ec22c7f0d6aeb5245aeb28 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 1 Oct 2026 22:57:44 +0200 Subject: [PATCH 13/22] feat(dialog): embedded object views on a plugin QFrame WidgetData::setSceneView(name, "3d"|"2d") and setSceneTopics turn a plugin QFrame into a live scene view bound to object topics; clearSceneView removes it. Hosts without support leave the frame empty. Available since 0.36.0. --- CHANGELOG.md | 5 +++ .../pj_plugins/host/widget_data_view.hpp | 44 ++++++++++++++++++- .../include/pj_plugins/sdk/widget_data.hpp | 39 ++++++++++++++++ .../tests/widget_data_view_test.cpp | 31 +++++++++++++ pj_plugins/docs/dialog-plugin-guide.md | 1 + 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ca6716..61b11180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT, PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (floor 0.36.0) +- Add `WidgetData::setSceneView` / `setSceneTopics` / `clearSceneView` (keys + `scene_view`, `scene_topics`) and the matching `WidgetDataView::sceneView` / + `sceneTopics`: a QFrame carrying `scene_view` becomes an embedded 3D/2D object + view bound to object topics and following the playback cursor. Hosts without + support leave the frame empty. Dialog protocol only: no ABI change. - Add `PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS` (on_demand): a transient evaluation with no declared outputs infers each output's name and type from the script's returned value and reports them in a root `"outputs"` array of the diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index ddcc6e51..4402a945 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -17,7 +17,7 @@ #include #include "pj_base/number_parse.hpp" -#include "pj_plugins/sdk/widget_data.hpp" // TimelineMark +#include "pj_plugins/sdk/widget_data.hpp" // TimelineMark, SceneTopic namespace PJ { @@ -657,6 +657,48 @@ class WidgetDataView { return result; } + // --- Embedded scene view (QFrame used as a 3D/2D object view container) --- + + /// Kind of the embedded view ("3d"/"2d"). nullopt: key absent (leave the frame + /// alone). Empty string: the key is null (clearSceneView), delete the view. + [[nodiscard]] std::optional sceneView(std::string_view name) const { + const nlohmann::json* w = widget(name); + if (!w) { + return std::nullopt; + } + auto it = w->find("scene_view"); + if (it == w->end()) { + return std::nullopt; + } + if (it->is_string()) { + return it->get(); + } + if (it->is_null()) { + return std::string(); + } + return std::nullopt; + } + + [[nodiscard]] std::optional> sceneTopics(std::string_view name) const { + const nlohmann::json* w = widget(name); + if (!w) { + return std::nullopt; + } + auto it = w->find("scene_topics"); + if (it == w->end() || !it->is_array()) { + return std::nullopt; + } + std::vector result; + result.reserve(it->size()); + for (const auto& t : *it) { + if (!t.is_object()) { + continue; + } + result.push_back({t.value("topic", std::string()), t.value("dataset", std::string())}); + } + return result; + } + /// A marker overlaid on the chart (see PJ::ChartMarker / setChartMarkers). /// Interpret by `kind`: "event" (point at x0,y0 if has_value, else vline at x0), /// "region" (x-span [x0,x1]), "value_band" (y-band [y0,y1]; line when y0==y1), "label". diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 65bf5d78..3c1c21a7 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -78,6 +78,14 @@ struct ChartSeries { bool dashed = false; // draw with a dashed line (e.g. a faded "before" ghost curve) }; +/// One object topic shown by an embedded scene view (used by setSceneTopics). +/// An empty `dataset` means "resolve the topic by name". +/// @since 0.36.0 +struct SceneTopic { + std::string topic; + std::string dataset; +}; + /// One marker overlaid on a chart preview (used by setChartMarkers). Interpret by /// `kind`, mirroring the PlotMarkers vocabulary: /// "event" → a point at (x0, y0) when `has_value`, else a vertical line at x0; @@ -627,6 +635,37 @@ class WidgetData { return *this; } + // --- Embedded scene view (QFrame used as a 3D/2D object view container) --- + + /// Turn the named QFrame into an embedded object view of the given kind + /// ("3d" or "2d"). The host creates the view once inside the frame; a kind + /// change recreates it. Hosts without embedded-view support leave the frame empty. + /// @since 0.36.0 + WidgetData& setSceneView(std::string_view name, std::string_view kind) { + entry(name)["scene_view"] = std::string(kind); + return *this; + } + + /// Set the object topics shown by the embedded view in the named QFrame. The + /// host attaches the added topics and detaches the removed ones; the view + /// follows the playback cursor. An empty `dataset` resolves the topic by name. + /// @since 0.36.0 + WidgetData& setSceneTopics(std::string_view name, const std::vector& topics) { + nlohmann::json arr = nlohmann::json::array(); + for (const auto& t : topics) { + arr.push_back({{"topic", t.topic}, {"dataset", t.dataset}}); + } + entry(name)["scene_topics"] = std::move(arr); + return *this; + } + + /// Remove the embedded object view (and its topics) from the named QFrame. + /// @since 0.36.0 + WidgetData& clearSceneView(std::string_view name) { + entry(name)["scene_view"] = nullptr; + return *this; + } + // --- QPlainTextEdit --- WidgetData& setPlainText(std::string_view name, std::string_view text) { entry(name)["plain_text"] = text; diff --git a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp index 9ca7c551..20dc8dff 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp @@ -635,6 +635,37 @@ TEST(WidgetDataViewTest, ChartPlaceholderRoundTrip) { EXPECT_EQ(*v.chartPlaceholder("chart"), "No data yet"); } +TEST(WidgetDataViewTest, SceneViewRoundTrip) { + PJ::WidgetData wd; + wd.setSceneView("view", "3d"); + wd.setSceneTopics("view", {{"/a", "ds"}, {"/b", ""}}); + PJ::WidgetDataView v(wd.toJson()); + ASSERT_TRUE(v.sceneView("view").has_value()); + EXPECT_EQ(*v.sceneView("view"), "3d"); + auto topics = v.sceneTopics("view"); + ASSERT_TRUE(topics.has_value()); + ASSERT_EQ(topics->size(), 2U); + EXPECT_EQ((*topics)[0].topic, "/a"); + EXPECT_EQ((*topics)[0].dataset, "ds"); + EXPECT_EQ((*topics)[1].topic, "/b"); + EXPECT_TRUE((*topics)[1].dataset.empty()); +} + +TEST(WidgetDataViewTest, SceneViewClearAndAbsent) { + PJ::WidgetData wd; + wd.clearSceneView("view"); + PJ::WidgetDataView v(wd.toJson()); + ASSERT_TRUE(v.sceneView("view").has_value()); + EXPECT_TRUE(v.sceneView("view")->empty()); + EXPECT_FALSE(v.sceneTopics("view").has_value()); + + PJ::WidgetData other; + other.setChartPlaceholder("chart", "x"); + PJ::WidgetDataView v2(other.toJson()); + EXPECT_FALSE(v2.sceneView("chart").has_value()); + EXPECT_FALSE(v2.sceneTopics("chart").has_value()); +} + TEST(WidgetDataViewTest, TableDeltaRoundTrip) { PJ::WidgetData wd; wd.appendTableRows("tbl", 9, std::vector>{{"r1c1", "r1c2"}}); diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 7a7cee81..fdd9548a 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -498,6 +498,7 @@ work like polling a server for available topics. | QTableWidget | `setTableHeaders`, `setTableRows` (strings, or `TableItem` for sortable columns), `setTableSortIndicator`, `setSelectedRows`, `setVisibleRows`, `setRowColor`, `setCellTooltip` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | | QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage`, `setCodeCursor`, `setCodeCaretTracking` | `onCodeChanged(name, code)`, or `onCodeChangedWithCursor(name, code, cursor)` when the editor opts into caret tracking | | QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | +| QFrame (embedded object view, since 0.36.0) | `setSceneView(name, "3d"\|"2d")`, `setSceneTopics`, `clearSceneView` | (none) | | QDateTimeEdit (incl. QDateEdit/QTimeEdit) | `setDateTime`, `setDateTimeRange` | `onDateTimeChanged(name, iso8601)` | | RangeSlider (two-handle) | `setRangeSliderBounds`, `setRangeSliderValues`, `setRangeSliderTimeSpan` | `onRangeChanged(name, lower, upper)` | | DateRangePicker (date range) | `setDateRangePlaceholder` | `onDateRangeChanged(name, from_iso, to_iso)` | From e0827da991f9ac96611b9431c1de4cbd7bd15203 Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 2 Oct 2026 10:10:10 +0200 Subject: [PATCH 14/22] fix(data-processors): read-prefix struct_size rule, number out_topics, unknown poll state is an error - struct_size of the typed request and the evaluation budget: the host reads the prefix it knows and accepts a larger size; a later field is announced by a flag bit, never by the size. - on_demand out_topics returns // for number outputs too. - pollEvaluation reports an unknown state as an error instead of pending. - document coverage.error, coverage.gaps and the poll limits. --- CHANGELOG.md | 19 ++++ pj_base/include/pj_base/plugin_data_api.h | 87 +++++++++++++++---- .../include/pj_base/sdk/plugin_data_api.hpp | 30 +++++-- pj_base/tests/data_processors_api_test.cpp | 35 +++++++- 4 files changed, 149 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b11180..888436ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,25 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, - Add the optional manifest string `badge` (`PluginDescriptor::badge`, "" when absent): a short label a host may show next to objects the plugin creates. Older manifests and hosts are unaffected. +- Record the `struct_size` rule of the typed data-processor request and of + `PJ_evaluation_budget_t` as READ-PREFIX: a host reads the prefix it knows, + rejects a `struct_size` below the v1 layout and ACCEPTS a larger one; a field + appended later is zero-defaultable and announced by a new `flags`/`time_flags` + bit, so an older host rejects the bit, never the size. The previous text + (reject any `struct_size` larger than the host's own) would have made the + first appended field break every newer plugin on a 0.36 host. The C++ + wrapper keeps sending `sizeof`, which is correct under this rule. +- `create_data_processor_v2` / `create_data_processor` (`kind="on_demand"`): + `out_topics` now returns `//` for number outputs as well as + object outputs (the series key of series mode; absent from the catalog when + the recipe cannot run in series mode); string outputs stay the bare name. + A reader must use the returned string, not rebuild it from the output name. +- `DataProcessorsHostView::pollEvaluation` returns an error for an unknown + evaluation state ("unknown evaluation state N") instead of reading it as + pending, which made a caller poll forever. +- Document `coverage.error` (present only when `coverage.stopped == "error"`) + and `coverage.gaps` in the `poll_evaluation` report, plus the 64-handle / + 64 MiB limits and that polling needs the host event loop to turn. - ImageAnnotations wire carries the top-level timestamp and image_topic; additive, old readers skip them. diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 75d08609..b042fc83 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -79,6 +79,29 @@ extern "C" { ((vtable_ptr)->struct_size >= (offsetof(vtable_type, field) + sizeof((vtable_ptr)->field)) && \ (vtable_ptr)->field != NULL) +/* + * CAPABILITY-DETECTION RULE (stated once; the toolbox guide points here). + * A plugin learns what a host can do in exactly one of five ways, by the kind of feature: + * + * 1. ABI service feature (new tail slot(s)): present iff the slot(s) are covered by + * the vtable's struct_size and non-NULL. Read it through ONE named hasX() on the + * C++ view, never through a version string: DataProcessorsHostView::hasTypedRequests, + * PlotTabHostView::hasSceneTabs, ToolboxHostView::hasCatalogSnapshotV2. + * 2. Flag-bit feature (e.g. PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS, a future request + * field announced by a bit): NO probe. A host that does not know the bit REJECTS + * it, so the call fails loudly instead of being half-honoured. The floor of such + * a feature is the hasX() of the slot that carries the bit. + * 3. Build-dependent behaviour (e.g. the Python backend of on_demand, which a WASM + * host lacks): probe by doing it, e.g. validateScript("on_demand", "python", + * "return {}"), and keep the UI honest when it fails. + * 4. Dialog-protocol feature (scene_view / scene_topics widget keys): a bit in + * PJ_dialog_host_info_t::capabilities (dialog_protocol.h), read through + * DialogPluginTyped::hostCapabilities(). A host that never calls set_host_info + * reports 0 bits. + * 5. Manifest metadata (badge, custom_topics_editor): declarative, no probe; hosts + * that do not know a key ignore it. + */ + typedef enum { PJ_PRIMITIVE_TYPE_FLOAT32 = 0, PJ_PRIMITIVE_TYPE_FLOAT64 = 1, @@ -961,8 +984,14 @@ typedef struct { * ":", where is "number", "string", or a BuiltinObjectType * name (e.g. "kPointCloud", "kSceneEntities") — the host needs the declared shape * up front to route a later on-demand evaluation without re-running the script. - * out_topics returns, 1:1 with outputs, the resolved physical topic name for an - * object-typed output and the bare (untyped) name for a number/string output. + * out_topics returns, 1:1 with outputs, the catalog path of each output: + * - object output: "//", the object topic the host publishes; + * - number output: also "//", the series key the host writes + * in series mode. It is ABSENT from the catalog when the recipe cannot run in + * series mode (pinned, or no object input), so a reader must tolerate a miss; + * - string output: the bare name; a string is never a topic. + * A consumer reads a number output's series by exactly this string; it must not + * rebuild the path from the output name (a different topic may share the leaf). * `language` must be "luau"; the same "unknown kind is rejected" rule as any * other kind applies to a host that predates this one. * Future kinds (e.g. a host-owned engine backend) are added the same way — a new @@ -1070,13 +1099,21 @@ typedef struct { PJ_string_view_t type; } PJ_data_processor_output_t; -/* Typed request for create_data_processor_v2 and submit_evaluation. struct_size gates - * the readable prefix: the v1 minimum is offsetof(time_ns) + sizeof(time_ns). A host - * REJECTS (never ignores) a request it cannot honour: unknown flags or time_flags bits, - * nonzero reserved, a count > 0 with a NULL pointer, struct_size below the v1 minimum, - * or struct_size larger than the host's own sizeof (a field added later must be - * zero-defaultable AND announced by a new flag bit, so an old host that does not know - * the bit rejects it). Inputs use the same grammar as create_data_processor (topic +/* Typed request for create_data_processor_v2 and submit_evaluation. + * + * struct_size RULE (read-prefix): the host reads the PREFIX of the struct that it + * knows and ACCEPTS a struct_size larger than its own sizeof. The v1 minimum is + * offsetof(time_ns) + sizeof(time_ns); a smaller struct_size is rejected. A field + * appended in a later SDK release must be zero-defaultable AND announced by a new + * flags/time_flags bit, so an older host rejects the unknown BIT -- never the size. + * A request that uses no new field therefore keeps working on every host. + * The C++ wrapper (detail::toAbiRequest) keeps sending sizeof(PJ_data_processor_request_t) + * of the header it was compiled with; under this rule that is correct on any host + * that knows the prefix. + * + * A host REJECTS (never ignores) a request it cannot honour: unknown flags or + * time_flags bits, nonzero reserved, a count > 0 with a NULL pointer, or struct_size + * below the v1 minimum. Inputs use the same grammar as create_data_processor (topic * names, optionally dataset-qualified); the script reads each input under its literal * name. All strings are borrowed for the duration of the call. */ typedef struct { @@ -1101,7 +1138,11 @@ typedef struct { /* Cooperative computation budget of one submit_evaluation. Checked between * evaluations and inside the host's native operations, NOT a wall-clock guarantee: - * one script or native call may overrun it. 0 = host default. */ + * one script or native call may overrun it. 0 = host default. + * struct_size follows the same read-prefix rule as PJ_data_processor_request_t: the + * host rejects a struct_size below the v1 layout (offsetof(max_report_bytes) + + * sizeof(max_report_bytes)), accepts a larger one and reads only the prefix it knows. + * A budget field added later must be zero-defaultable (0 = host default). */ typedef struct { uint32_t struct_size; /* = sizeof(PJ_evaluation_budget_t) */ uint32_t reserved; /* 0 */ @@ -1129,7 +1170,8 @@ typedef struct PJ_data_processors_host_vtable_t { * name(s) are written to out_topics using the count-then-fill convention: pass * out_topics=NULL/capacity=0 to read *out_topics_count, or a buffer to receive * min(capacity,*out_topics_count) entries (borrowed, valid only until the next call - * on this vtable); pass out_topics_count=NULL to ignore them. Transactional: on + * on this vtable); pass out_topics_count=NULL to ignore them. For kind="on_demand" + * see the out_topics contract in the service comment above. Transactional: on * failure no partial state is left AND any previously published output for this id * is preserved. All string arguments are borrowed for the duration of the call. */ bool (*create_data_processor)( @@ -1173,7 +1215,10 @@ typedef struct PJ_data_processors_host_vtable_t { /* [main-thread] create_data_processor with a typed request: typed outputs, a label, * and (INSTANT) a pinned evaluation time for an on_demand finding. Same upsert, * transactional and out_topics (count-then-fill, borrowed until the next call on - * this host object) contract as create_data_processor. ABI-APPENDED slot. */ + * this host object) contract as create_data_processor, including the on_demand + * out_topics contract (object AND number outputs return "//"). + * request->struct_size follows the read-prefix rule on PJ_data_processor_request_t. + * ABI-APPENDED slot. */ bool (*create_data_processor_v2)( void* ctx, const PJ_data_processor_request_t* request, PJ_string_view_t* out_topics, uint64_t out_topics_capacity, uint64_t* out_topics_count, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1194,8 +1239,13 @@ typedef struct PJ_data_processors_host_vtable_t { /* [main-thread] Read an evaluation's state. On COMPLETED *out_json is the report: * {"coverage":{"start_ns","end_ns","evaluated_until_ns"|null,"candidates","evaluated", * "cache_hits","complete","stopped":"complete"|"budget_time"|"budget_evaluations"| - * "budget_bytes"|"budget_report"|"cancelled"|"error"},"bundles":[...]} - * with one bundle for INSTANT. A bundle is {"requested_ns","stamp_ns","from_cache", + * "budget_bytes"|"budget_report"|"cancelled"|"error","gaps":[{"from_ns","to_ns"}...], + * "error"?},"bundles":[...]} + * with one bundle for INSTANT. coverage.gaps lists the retention gaps (raw ns) the + * evaluated range fell into. coverage.error is present ONLY when stopped == "error" + * and carries the reason (truncated by the host); a COMPLETED report with an empty + * "bundles" list is therefore NOT enough to infer "no sample": read coverage.stopped + * and coverage.error. A bundle is {"requested_ns","stamp_ns","from_cache", * "revision","inputs":[{"alias","resolved_ns","is_object"}],"outputs":{name:{"status": * "ok"|"unavailable"|"empty"|"error","value"?,"topic"?,"summary"?,"reason"?}}}. * Objects never appear as bytes, only as a "summary" object. When the request @@ -1204,7 +1254,14 @@ typedef struct PJ_data_processors_host_vtable_t { * "unknown"}], the inferred outputs in report order. Every *_ns value is a * raw dataset nanosecond count as a JSON integer (int64; do not round-trip through * a double). On FAILED *out_json is {"error":"..."}. *out_json is borrowed until - * release_evaluation(handle). An unknown handle is an error. ABI-APPENDED slot. */ + * release_evaluation(handle). An unknown handle is an error. ABI-APPENDED slot. + * Limits: a host keeps at most 64 live handles and at most 64 MiB of reserved + * report bytes across them; a submit beyond either is an error until completed + * handles are released. The host completes a background evaluation from its own + * event loop: a caller must return to that loop between polls (poll from a timer, + * never a busy loop on the calling thread) or the evaluation never finishes. A state + * value outside PJ_EVALUATION_STATE_* is not "pending": the C++ wrapper reports it + * as an error. */ bool (*poll_evaluation)( void* ctx, uint64_t handle, uint32_t* out_state, PJ_string_view_t* out_json, PJ_error_t* out_error) PJ_NOEXCEPT; diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 6170c1ab..9d8cbb84 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1630,6 +1630,9 @@ namespace detail { /// Builds the ABI request for a DataProcessorRequest, filling `in_abi`/`out_abi` /// with borrowed views into `request`'s owned strings — the caller must keep /// `request`, `in_abi`, and `out_abi` alive for the duration of the ABI call. +/// `struct_size` is the sizeof of the header this plugin was compiled with: correct +/// under the read-prefix rule (the host reads the prefix it knows and accepts a +/// larger struct_size; see PJ_data_processor_request_t). [[nodiscard]] inline PJ_data_processor_request_t toAbiRequest( const DataProcessorRequest& request, std::vector& in_abi, std::vector& out_abi) { @@ -1811,8 +1814,10 @@ class DataProcessorsHostView { /// ":" ("number", "string", or a BuiltinObjectType name such as /// "kPointCloud"/"kSceneEntities") the host needs to route a later on-demand /// evaluation without re-running the script. Returns the resolved output - /// identifiers 1:1 with `typed_outputs`: the physical topic name for an - /// object-typed output, the bare name for a number/string output. Inputs MAY + /// identifiers 1:1 with `typed_outputs`: the catalog path "//" + /// for an object-typed output AND for a number output (the series key of series + /// mode; absent from the catalog when the recipe cannot run in series mode), the + /// bare name for a string output. Inputs MAY /// be dataset-qualified (see create()). For typed outputs, a label, or a /// pinned evaluation time, use createV2() instead. [[nodiscard]] Expected> createOnDemand( @@ -1892,7 +1897,16 @@ class DataProcessorsHostView { /// create_data_processor with a typed request: typed outputs, a label, and /// (with `request.instant_ns` set) a pinned evaluation time for an on_demand /// finding. Same upsert, transactional, and resolved-topic-names contract as - /// create(). Errors if the host predates this slot. + /// create(); for kind="on_demand" the names are 1:1 with `request.outputs`: + /// "//" for object AND number outputs (a number output's series + /// key in series mode, absent from the catalog when the recipe cannot run in + /// series mode), the bare name for a string output. Read a number output's + /// series by exactly the returned string. Errors if the host predates this slot. + /// + /// struct_size: this wrapper sends sizeof(PJ_data_processor_request_t) of the + /// header it was compiled with. Under the read-prefix rule (the host reads the + /// prefix it knows and accepts a larger struct_size; see + /// PJ_data_processor_request_t) that is correct against any host. [[nodiscard]] Expected> createV2(const DataProcessorRequest& request) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2)) { return unexpected("data processors host does not support create_data_processor_v2"); @@ -1957,8 +1971,9 @@ class DataProcessorsHostView { /// Read an evaluation's state. On kCompleted, `EvaluationPoll::json` is the /// coverage+bundles report (owned copy); on kFailed it is `{"error":"..."}`. /// See poll_evaluation's doc-comment in plugin_data_api.h for the full - /// report schema. Errors if the host predates this slot or `handle` is - /// unknown. + /// report schema. Errors if the host predates this slot, `handle` is + /// unknown, or the host reports a state this SDK does not know ("unknown + /// evaluation state N") -- never treated as pending. [[nodiscard]] Expected pollEvaluation(uint64_t handle) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, poll_evaluation)) { return unexpected("data processors host does not support poll_evaluation"); @@ -1981,9 +1996,12 @@ class DataProcessorsHostView { case PJ_EVALUATION_STATE_CANCELLED: result.state = EvaluationState::kCancelled; break; - default: + case PJ_EVALUATION_STATE_PENDING: result.state = EvaluationState::kPending; break; + default: + // Never read an unknown state as "pending": the caller would poll forever. + return unexpected("unknown evaluation state " + std::to_string(state)); } return result; } diff --git a/pj_base/tests/data_processors_api_test.cpp b/pj_base/tests/data_processors_api_test.cpp index fe67f642..7792f04a 100644 --- a/pj_base/tests/data_processors_api_test.cpp +++ b/pj_base/tests/data_processors_api_test.cpp @@ -51,6 +51,7 @@ struct FakeDataProcessorsHost { // --- create_data_processor_v2 / submit_evaluation / poll_evaluation / release_evaluation --- bool create_v2_called = false; + uint32_t poll_state = PJ_EVALUATION_STATE_COMPLETED; // what poll_evaluation reports bool submit_called = false; uint32_t last_request_struct_size = 0; uint32_t last_request_flags = 0; @@ -247,7 +248,7 @@ bool dpPollEvaluation( } return false; } - *out_state = PJ_EVALUATION_STATE_COMPLETED; + *out_state = self->poll_state; *out_json = sdk::toAbiString(it->second.json); return true; } @@ -606,6 +607,38 @@ TEST(DataProcessorsApiTest, PollUnknownHandleIsAnError) { EXPECT_FALSE(poll); } +TEST(DataProcessorsApiTest, PollMapsEveryKnownStateAndRejectsAnUnknownOne) { + FakeDataProcessorsHost host; + const auto vtable = makeVtable(); + sdk::DataProcessorsHostView view(PJ_data_processors_host_t{.ctx = &host, .vtable = &vtable}); + + sdk::DataProcessorRequest request; + request.id = "finding"; + request.kind = "on_demand"; + request.instant_ns = 10; + auto handle = view.submitEvaluation(request); + ASSERT_TRUE(handle) << handle.error(); + + const std::pair known[] = { + {PJ_EVALUATION_STATE_PENDING, sdk::EvaluationState::kPending}, + {PJ_EVALUATION_STATE_COMPLETED, sdk::EvaluationState::kCompleted}, + {PJ_EVALUATION_STATE_FAILED, sdk::EvaluationState::kFailed}, + {PJ_EVALUATION_STATE_CANCELLED, sdk::EvaluationState::kCancelled}, + }; + for (const auto& [raw, expected] : known) { + host.poll_state = raw; + auto poll = view.pollEvaluation(*handle); + ASSERT_TRUE(poll) << poll.error(); + EXPECT_EQ(poll->state, expected); + } + + // An unknown state must not read as "pending": a caller would poll forever. + host.poll_state = 99; + auto poll = view.pollEvaluation(*handle); + ASSERT_FALSE(poll); + EXPECT_NE(poll.error().find("unknown evaluation state 99"), std::string::npos); +} + TEST(DataProcessorsApiTest, RequestStructSizeAndFlagsAreSet) { FakeDataProcessorsHost host; const auto vtable = makeVtable(); From 2415930886d1a9b81431d569245355aab36533c5 Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 2 Oct 2026 10:13:02 +0200 Subject: [PATCH 15/22] feat(sdk): capability rule, EMBEDS_SCENE_VIEWS dialog bit, custom_topics_editor, plot-tab renames - ToolboxHostView::hasCatalogSnapshotV2 and the capability-detection rule, stated once in plugin_data_api.h. - PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS (bit 5) plus DialogPluginBase hostCapabilities()/hostHas() over the existing set_host_info slot. - PluginDescriptor::custom_topics_editor manifest flag. - sdk::kDerivedMetadataKey / kDerivedOnDemandValue. - PlotTabHostView::createV2 -> createTabV2, focus -> focusTab. - @since 0.36.0 on every slot, struct and wrapper added on this line. - floors keys follow the renames; kTabledTypeCount text fixed. --- CHANGELOG.md | 35 ++++++++++++- docs/builtin_type.md | 2 +- pj_base/feature_floors.json | 4 +- pj_base/include/pj_base/plugin_data_api.h | 51 ++++++++++++------- .../pj_base/sdk/object_topic_metadata.hpp | 14 +++++ .../include/pj_base/sdk/plugin_data_api.hpp | 37 ++++++++++++-- pj_base/tests/object_topic_metadata_test.cpp | 11 ++++ pj_base/tests/plot_tabs_api_test.cpp | 26 +++++----- pj_base/tests/plugin_data_api_test.cpp | 22 ++++++++ .../include/pj_plugins/dialog_protocol.h | 7 +++ .../pj_plugins/host/widget_data_view.hpp | 3 ++ .../pj_plugins/sdk/dialog_plugin_base.hpp | 19 +++++++ .../include/pj_plugins/sdk/widget_data.hpp | 4 +- .../tests/dialog_plugin_base_test.cpp | 33 ++++++++++++ pj_plugins/docs/toolbox-guide.md | 3 +- .../pj_plugins/host/plugin_catalog.hpp | 7 +++ pj_plugins/src/plugin_catalog.cpp | 16 ++++++ pj_plugins/tests/plugin_catalog_test.cpp | 24 +++++++++ 18 files changed, 277 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 888436ca..29fcace1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,16 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, `scene_entities_fields.hpp`; `field_table_registry.hpp` exposes `describe(BuiltinObjectType)` to look one up from the runtime tag carried by a `BuiltinObject`. `FrameTransforms`, `ImageAnnotations`, `PointCloud`, - and `SceneEntities` are tabled today (`kTabledTypeCount == 4`); `PointCloud` + and `SceneEntities` were the first four tabled types (the next entry adds four + more); `PointCloud` exposes its packed-bytes `data` field through a `kBuffer` descriptor (`buffer<>()`) resolving a `BufferLayout` view rather than a plain get/set pair. Client-side only: no ABI or wire-format change. - Add field tables for `Image`, `DepthImage`, `CameraInfo`, and `VideoFrame` (`image_fields.hpp`, `depth_image_fields.hpp`, `camera_info_fields.hpp`, `video_frame_fields.hpp`); `describe(BuiltinObjectType)` now covers 8 - types. `Image::data`/`DepthImage::data`/`VideoFrame::data` each get a + types (there is no count constant: `FieldTableTest.DescribeCoversExactlyTheTabledTypes` + pins the set). `Image::data`/`DepthImage::data`/`VideoFrame::data` each get a `kBuffer` descriptor whose layout is derived from the encoding/format string: a raw `Image` encoding (e.g. "rgb8") or a recognized `DepthImage` encoding ("16UC1"/"32FC1") resolves a static per-pixel `record_step`; @@ -108,6 +110,35 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, - Document `coverage.error` (present only when `coverage.stopped == "error"`) and `coverage.gaps` in the `poll_evaluation` report, plus the 64-handle / 64 MiB limits and that polling needs the host event loop to turn. +- Add `ToolboxHostView::hasCatalogSnapshotV2()`: true iff the host serves + `acquire_catalog_snapshot_v2`. One `hasX()` per ABI service feature is the + capability rule, stated once in the `plugin_data_api.h` header comment + (ABI feature = tail slot behind a `hasX()`; flag-bit feature = no probe, an + older host rejects the bit; build-dependent behaviour = probe by doing it; + dialog feature = `PJ_dialog_host_info_t::capabilities`; manifest metadata = + no probe). +- Add the dialog host capability `PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS` + (`1 << 5`, C++ `DialogHostCapability::kEmbedsSceneViews`): the host embeds + `scene_view` / `scene_topics` frames. `DialogPluginBase` (and so + `DialogPluginTyped`) gains `hostCapabilities()` and `hostHas(capability)` + over the host info already delivered by the existing `set_host_info` slot; + a host that never delivers it reports 0. A dialog gates its scene UI on the + bit instead of probing a different service. No ABI change. +- Add the optional manifest flag `custom_topics_editor` + (`PluginDescriptor::custom_topics_editor`, false when absent): the toolbox + that edits the host's user-defined topics. A host keys the "+" button and + the ownership of those rows on it instead of a hard-coded plugin id. +- Add `sdk::kDerivedMetadataKey` (`"pj_derived"`) and `sdk::kDerivedOnDemandValue` + (`"on_demand"`) in `object_topic_metadata.hpp`: the topic metadata key a host + sets on object topics derived by an on_demand data processor. +- Add `sdk::unprojectPixel` (`depth_image_utils.hpp`): unprojects a rectified + pixel with positive metric depth through a pinhole `K`; rejects singular or + non-finite intrinsics and skew/projective terms. Client-side only. +- RENAME (the 0.36 line is unreleased, so nothing shipped breaks): + `PlotTabHostView::createV2` -> `createTabV2` and `focus` -> `focusTab`, so + they no longer read like `DataProcessorsHostView::createV2` or a UI focus. + `feature_floors.json` keys follow. +- Every slot, struct and wrapper added in 0.36 now carries `@since 0.36.0`. - ImageAnnotations wire carries the top-level timestamp and image_topic; additive, old readers skip them. diff --git a/docs/builtin_type.md b/docs/builtin_type.md index ab0621ee..09cf0331 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -692,7 +692,7 @@ with one generic recursive routine. `field_table_registry.hpp` exposes (`frame_transforms_fields.hpp`, `image_annotations_fields.hpp`, `point_cloud_fields.hpp`, `scene_entities_fields.hpp`, `image_fields.hpp`, `depth_image_fields.hpp`, `camera_info_fields.hpp`, `video_frame_fields.hpp`; -`kTabledTypeCount == 8` in `field_table_registry.hpp`). `Image::data`, +`describe()` in `field_table_registry.hpp` returns a table for exactly these eight). `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 diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 30f41a67..0f76123c 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -21,10 +21,10 @@ "PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW": "", "attachTopic": "PJ_plot_tab_host_vtable_t::attach_topic", "catalogSnapshotV2": "PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2", + "createTabV2": "PJ_plot_tab_host_vtable_t::create_tab_v2", "createV2": "PJ_data_processors_host_vtable_t::create_data_processor_v2", - "create_tab_v2": "PJ_plot_tab_host_vtable_t::create_tab_v2", "detachTopic": "PJ_plot_tab_host_vtable_t::detach_topic", - "focus": "PJ_plot_tab_host_vtable_t::focus_tab", + "focusTab": "PJ_plot_tab_host_vtable_t::focus_tab", "pollEvaluation": "PJ_data_processors_host_vtable_t::poll_evaluation", "releaseEvaluation": "PJ_data_processors_host_vtable_t::release_evaluation", "submitEvaluation": "PJ_data_processors_host_vtable_t::submit_evaluation" diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index b042fc83..9f2353de 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -388,7 +388,8 @@ typedef struct { /* One object topic of the catalog (snapshot v2). ARRAY ELEMENT with a FIXED * STRIDE: a field that cannot be zero-defaulted needs a new struct + slot, never - * a change of this layout. `reserved` must be 0. */ + * a change of this layout. `reserved` must be 0. + * @since 0.36.0 */ typedef struct { PJ_object_topic_handle_t handle; PJ_data_source_handle_t source; /* the dataset the topic lives on */ @@ -404,7 +405,8 @@ typedef struct { /* ABI-VERSIONED (not appendable): a later shape is a new struct + new slot. The * scalar arrays have exactly the content of PJ_catalog_snapshot_t. Object topics * include derived and marker topics; clients filter by metadata_json (key - * "pj_derived") or by the "__markers__/" name prefix. */ + * "pj_derived") or by the "__markers__/" name prefix. + * @since 0.36.0 */ typedef struct { uint32_t struct_size; /* = sizeof(PJ_catalog_snapshot_v2_t), set by the host */ uint32_t reserved; /* 0 */ @@ -715,7 +717,8 @@ typedef struct PJ_toolbox_host_vtable_t { /* [stream-thread] Catalog snapshot v2: the scalar catalog of acquire_catalog_snapshot * PLUS every object topic with its dataset, builtin type, entry count and raw time * range, in one deep copy. Release with out_snapshot->release(out_snapshot->release_ctx). - * ABI-APPENDED slot: gate via struct_size before calling. */ + * ABI-APPENDED slot: gate via struct_size before calling. + * @since 0.36.0 */ bool (*acquire_catalog_snapshot_v2)(void* ctx, PJ_catalog_snapshot_v2_t* out_snapshot, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_toolbox_host_vtable_t; @@ -1083,17 +1086,20 @@ typedef struct { * errors. The inferred list is returned in the report ("outputs"). * - create/create_v2: the declared outputs are a binding hint learned from such a * trial. The host still validates the returned types at runtime. - * A host that does not know this bit rejects it as an unknown flag. */ + * A host that does not know this bit rejects it as an unknown flag. + * @since 0.36.0 */ #define PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (1u << 2) -/* PJ_data_processor_request_t.time_flags */ +/* PJ_data_processor_request_t.time_flags + * @since 0.36.0 */ #define PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW (1u << 0) /* window_start_ns..window_end_ns are meaningful */ #define PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT \ (1u << 1) /* time_ns is meaningful: a pinned finding on create, the instant to evaluate on submit */ /* A declared output. ARRAY ELEMENT with a FIXED STRIDE (see PJ_object_topic_info_t). * type: "number" | "string" | a builtin object type name ("kPointCloud") | "" (untyped, - * legacy transform/markers output). */ + * legacy transform/markers output). + * @since 0.36.0 */ typedef struct { PJ_string_view_t name; PJ_string_view_t type; @@ -1115,7 +1121,8 @@ typedef struct { * time_flags bits, nonzero reserved, a count > 0 with a NULL pointer, or struct_size * below the v1 minimum. Inputs use the same grammar as create_data_processor (topic * names, optionally dataset-qualified); the script reads each input under its literal - * name. All strings are borrowed for the duration of the call. */ + * name. All strings are borrowed for the duration of the call. + * @since 0.36.0 */ typedef struct { uint32_t struct_size; /* = sizeof(PJ_data_processor_request_t) */ uint32_t flags; /* PJ_DATA_PROCESSOR_FLAG_* */ @@ -1142,7 +1149,8 @@ typedef struct { * struct_size follows the same read-prefix rule as PJ_data_processor_request_t: the * host rejects a struct_size below the v1 layout (offsetof(max_report_bytes) + * sizeof(max_report_bytes)), accepts a larger one and reads only the prefix it knows. - * A budget field added later must be zero-defaultable (0 = host default). */ + * A budget field added later must be zero-defaultable (0 = host default). + * @since 0.36.0 */ typedef struct { uint32_t struct_size; /* = sizeof(PJ_evaluation_budget_t) */ uint32_t reserved; /* 0 */ @@ -1152,7 +1160,8 @@ typedef struct { uint64_t max_report_bytes; /* whole report; default 1 MiB, host cap 8 MiB */ } PJ_evaluation_budget_t; -/* poll_evaluation states */ +/* poll_evaluation states + * @since 0.36.0 */ #define PJ_EVALUATION_STATE_PENDING 0u #define PJ_EVALUATION_STATE_COMPLETED 1u #define PJ_EVALUATION_STATE_FAILED 2u @@ -1218,7 +1227,8 @@ typedef struct PJ_data_processors_host_vtable_t { * this host object) contract as create_data_processor, including the on_demand * out_topics contract (object AND number outputs return "//"). * request->struct_size follows the read-prefix rule on PJ_data_processor_request_t. - * ABI-APPENDED slot. */ + * ABI-APPENDED slot. + * @since 0.36.0 */ bool (*create_data_processor_v2)( void* ctx, const PJ_data_processor_request_t* request, PJ_string_view_t* out_topics, uint64_t out_topics_capacity, uint64_t* out_topics_count, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1231,7 +1241,8 @@ typedef struct PJ_data_processors_host_vtable_t { * the window, in time order, until the budget stops it). A host may complete the * work before returning (phase 0) or in the background; poll_evaluation is the only * way to read the result either way. Handles are per host object, increasing, never - * reused. Kinds other than on_demand are an error. ABI-APPENDED slot. */ + * reused. Kinds other than on_demand are an error. ABI-APPENDED slot. + * @since 0.36.0 */ bool (*submit_evaluation)( void* ctx, const PJ_data_processor_request_t* request, const PJ_evaluation_budget_t* budget, uint64_t* out_handle, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1261,12 +1272,14 @@ typedef struct PJ_data_processors_host_vtable_t { * event loop: a caller must return to that loop between polls (poll from a timer, * never a busy loop on the calling thread) or the evaluation never finishes. A state * value outside PJ_EVALUATION_STATE_* is not "pending": the C++ wrapper reports it - * as an error. */ + * as an error. + * @since 0.36.0 */ bool (*poll_evaluation)( void* ctx, uint64_t handle, uint32_t* out_state, PJ_string_view_t* out_json, PJ_error_t* out_error) PJ_NOEXCEPT; /* [main-thread] Cancel a pending evaluation (cooperative) and free its result. - * Releasing an unknown handle is an error; releasing twice is an error. ABI-APPENDED slot. */ + * Releasing an unknown handle is an error; releasing twice is an error. ABI-APPENDED slot. + * @since 0.36.0 */ bool (*release_evaluation)(void* ctx, uint64_t handle, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_data_processors_host_vtable_t; @@ -1548,7 +1561,8 @@ typedef struct PJ_plot_tab_host_vtable_t { * behaviour); on a scene tab it only updates the title. The same id with a DIFFERENT * kind closes the old tab and creates a new empty one. `create_tab` (v1) is this call * with kind "plot". An empty `id`, or an id containing '/', is an error here (v1 - * keeps its released id rules). An empty `title` lets the host name the tab. */ + * keeps its released id rules). An empty `title` lets the host name the tab. + * @since 0.36.0 */ bool (*create_tab_v2)( void* ctx, PJ_string_view_t id, PJ_string_view_t kind, PJ_string_view_t title, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1558,7 +1572,8 @@ typedef struct PJ_plot_tab_host_vtable_t { * and an ambiguous one is refused with the qualified candidates. A "3d" tab accepts * every object type the 3D view renders; a "2d" tab accepts Image/DepthImage/ * VideoFrame (replacing the background) and ImageAnnotations (an overlay). On a plot - * tab this is an error: "tab '' is a plot tab: use add_curve". */ + * tab this is an error: "tab '' is a plot tab: use add_curve". + * @since 0.36.0 */ bool (*attach_topic)( void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1566,13 +1581,15 @@ typedef struct PJ_plot_tab_host_vtable_t { /* [main-thread] Take one topic back out of a scene tab (same `dataset_source` rule). * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. A topic that is not * attached is an error. On a plot tab this is an error: "tab '' is a plot tab: - * use remove_curve". */ + * use remove_curve". + * @since 0.36.0 */ bool (*detach_topic)( void* ctx, PJ_string_view_t id, PJ_string_view_t topic, PJ_string_view_t dataset_source, PJ_error_t* out_error) PJ_NOEXCEPT; /* [main-thread] Bring one of this plugin's tabs (any kind) to the front. - * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. */ + * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. + * @since 0.36.0 */ bool (*focus_tab)(void* ctx, PJ_string_view_t id, PJ_error_t* out_error) PJ_NOEXCEPT; } PJ_plot_tab_host_vtable_t; 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 a2659e9f..2431c0ed 100644 --- a/pj_base/include/pj_base/sdk/object_topic_metadata.hpp +++ b/pj_base/include/pj_base/sdk/object_topic_metadata.hpp @@ -27,6 +27,20 @@ inline constexpr std::string_view kBuiltinObjectTypeMetadataKey = "builtin_objec /// @since 0.36.0 inline constexpr std::string_view kSnapshotMetadataKey = "pj_snapshot"; +/// Canonical metadata key marking a DERIVED object topic: one a host's on_demand +/// data-processor re-evaluates at a consumer-requested time (kind="on_demand"), as +/// opposed to a topic a data source ingested. A host that sets it uses the value +/// `kDerivedOnDemandValue`. A consumer (a viewer, the assistant, a script author) +/// may read it to tell derived results from recorded data; it is set by the HOST, +/// never by the plugin that created the processor. Absent or any other value means +/// "not derived". Match the key by parsing the metadata JSON, never by substring. +/// @since 0.36.0 +inline constexpr std::string_view kDerivedMetadataKey = "pj_derived"; + +/// The value `kDerivedMetadataKey` takes for an on_demand-derived topic. +/// @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 diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 9d8cbb84..e5de4891 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -177,6 +177,7 @@ class CatalogSnapshot { /// RAII wrapper around PJ_catalog_snapshot_v2_t — the scalar catalog plus /// every object topic (dataset, builtin type, entry count, raw time range). /// See ToolboxHostView::catalogSnapshotV2(). +/// @since 0.36.0 class CatalogSnapshotV2 { public: CatalogSnapshotV2() = default; @@ -1320,14 +1321,26 @@ class ToolboxHostView { return CatalogSnapshot(raw); } + /// True iff the host serves catalog snapshot v2 (the acquire_catalog_snapshot_v2 + /// tail slot). Cheap capability check, no host call; see the capability-detection + /// rule in plugin_data_api.h. Without it, catalogSnapshot() (scalars only) is all + /// the host offers: object topics are not enumerable. + /// @since 0.36.0 + [[nodiscard]] bool hasCatalogSnapshotV2() const noexcept { + return valid() && PJ_HAS_TAIL_SLOT(PJ_toolbox_host_vtable_t, host_.vtable, acquire_catalog_snapshot_v2); + } + /// Snapshot v2: the scalar catalog of catalogSnapshot() PLUS every object /// topic with its dataset, builtin type, entry count and raw time range, - /// in one deep copy. + /// in one deep copy. The two halves are NOT read atomically: the scalar + /// catalog and the object-topic list are each consistent on their own, but a + /// topic created between the two reads may appear in one and not the other. + /// @since 0.36.0 [[nodiscard]] Expected catalogSnapshotV2() const { if (!valid()) { return unexpected("toolbox host is not bound"); } - if (!PJ_HAS_TAIL_SLOT(PJ_toolbox_host_vtable_t, host_.vtable, acquire_catalog_snapshot_v2)) { + if (!hasCatalogSnapshotV2()) { return unexpected("toolbox host does not support acquire_catalog_snapshot_v2"); } PJ_catalog_snapshot_v2_t raw{}; @@ -1581,6 +1594,7 @@ class ColorMapRegistryView { /// A declared output for the typed create/evaluate request surface. `type` is /// "number", "string", a builtin object type name ("kPointCloud"), or "" /// (untyped, legacy transform/markers output). Mirrors PJ_data_processor_output_t. +/// @since 0.36.0 struct DataProcessorOutput { std::string name; std::string type; @@ -1591,6 +1605,7 @@ struct DataProcessorOutput { /// full contract). `window`/`instant_ns` set time_flags: a `window` selects /// PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, an `instant_ns` selects /// PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT; both may be set together. +/// @since 0.36.0 struct DataProcessorRequest { std::string id; std::string kind; @@ -1607,6 +1622,7 @@ struct DataProcessorRequest { /// Cooperative computation budget of one submit_evaluation. Mirrors /// PJ_evaluation_budget_t; 0 fields mean "host default". +/// @since 0.36.0 struct EvaluationBudget { uint64_t max_millis = 0; uint64_t max_bytes = 0; @@ -1615,11 +1631,13 @@ struct EvaluationBudget { }; /// Mirrors the poll_evaluation states (PJ_EVALUATION_STATE_*). +/// @since 0.36.0 enum class EvaluationState { kPending, kCompleted, kFailed, kCancelled }; /// Result of DataProcessorsHostView::pollEvaluation(): the evaluation's state /// plus its report JSON (owned copy), see poll_evaluation's doc-comment in /// plugin_data_api.h for the report schema. +/// @since 0.36.0 struct EvaluationPoll { EvaluationState state = EvaluationState::kPending; std::string json; @@ -1697,6 +1715,7 @@ class DataProcessorsHostView { /// True iff the host serves the typed-request surface: createV2() and the /// submitEvaluation()/pollEvaluation()/releaseEvaluation() trio (four tail slots, all /// covered by the vtable's struct_size). Cheap capability check; no host call is made. + /// @since 0.36.0 [[nodiscard]] bool hasTypedRequests() const noexcept { return valid() && PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2) && PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, submit_evaluation) && @@ -1820,6 +1839,7 @@ class DataProcessorsHostView { /// bare name for a string output. Inputs MAY /// be dataset-qualified (see create()). For typed outputs, a label, or a /// pinned evaluation time, use createV2() instead. + /// @since 0.36.0 [[nodiscard]] Expected> createOnDemand( std::string_view id, Span inputs, Span typed_outputs, std::string_view script, std::string_view params_json, uint32_t flags = 0) const { @@ -1907,6 +1927,7 @@ class DataProcessorsHostView { /// header it was compiled with. Under the read-prefix rule (the host reads the /// prefix it knows and accepts a larger struct_size; see /// PJ_data_processor_request_t) that is correct against any host. + /// @since 0.36.0 [[nodiscard]] Expected> createV2(const DataProcessorRequest& request) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2)) { return unexpected("data processors host does not support create_data_processor_v2"); @@ -1945,6 +1966,7 @@ class DataProcessorsHostView { /// host may complete the work before returning or in the background; /// pollEvaluation() is the only way to read the result either way. Errors /// if the host predates this slot. + /// @since 0.36.0 [[nodiscard]] Expected submitEvaluation( const DataProcessorRequest& request, const EvaluationBudget& budget = {}) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, submit_evaluation)) { @@ -1974,6 +1996,7 @@ class DataProcessorsHostView { /// report schema. Errors if the host predates this slot, `handle` is /// unknown, or the host reports a state this SDK does not know ("unknown /// evaluation state N") -- never treated as pending. + /// @since 0.36.0 [[nodiscard]] Expected pollEvaluation(uint64_t handle) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, poll_evaluation)) { return unexpected("data processors host does not support poll_evaluation"); @@ -2009,6 +2032,7 @@ class DataProcessorsHostView { /// Cancel a pending evaluation (cooperative) and free its result. Errors if /// the host predates this slot, `handle` is unknown, or it was already /// released. + /// @since 0.36.0 [[nodiscard]] Status releaseEvaluation(uint64_t handle) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, release_evaluation)) { return unexpected("data processors host does not support release_evaluation"); @@ -2314,6 +2338,7 @@ class PlotTabHostView { /// True iff the host serves scene (3D/2D) tabs: all four tail slots /// (create_tab_v2, attach_topic, detach_topic, focus_tab) are present. A host with /// no scene workspace leaves them NULL; check this before calling them. + /// @since 0.36.0 [[nodiscard]] bool hasSceneTabs() const noexcept { return valid() && PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, create_tab_v2) && PJ_HAS_TAIL_SLOT(PJ_plot_tab_host_vtable_t, host_.vtable, attach_topic) && @@ -2325,7 +2350,8 @@ class PlotTabHostView { /// tab is replaced by one empty plot, a scene tab only gets its title updated; same id /// with a different kind closes the old tab and creates a new empty one. An empty /// `title` lets the host name it. - [[nodiscard]] Status createV2(std::string_view id, std::string_view kind, std::string_view title = {}) const { + /// @since 0.36.0 + [[nodiscard]] Status createTabV2(std::string_view id, std::string_view kind, std::string_view title = {}) const { return callTail<&PJ_plot_tab_host_vtable_t::create_tab_v2>( "create_tab_v2", toAbiString(id), toAbiString(kind), toAbiString(title)); } @@ -2333,6 +2359,7 @@ class PlotTabHostView { /// Attach an object topic to a scene tab. An empty `dataset_source` requires the topic /// to be unique across loaded datasets; an ambiguous one is refused by the host with /// the qualified candidates. Attaching a topic already there is success. + /// @since 0.36.0 [[nodiscard]] Status attachTopic( std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { return callTail<&PJ_plot_tab_host_vtable_t::attach_topic>( @@ -2341,6 +2368,7 @@ class PlotTabHostView { /// Take one topic back out of a scene tab, resolved by the same rule as attachTopic. /// A topic that is not attached is an error. + /// @since 0.36.0 [[nodiscard]] Status detachTopic( std::string_view id, std::string_view topic, std::string_view dataset_source = {}) const { return callTail<&PJ_plot_tab_host_vtable_t::detach_topic>( @@ -2348,7 +2376,8 @@ class PlotTabHostView { } /// Bring one of this plugin's tabs (any kind) to the front. - [[nodiscard]] Status focus(std::string_view id) const { + /// @since 0.36.0 + [[nodiscard]] Status focusTab(std::string_view id) const { return callTail<&PJ_plot_tab_host_vtable_t::focus_tab>("focus_tab", toAbiString(id)); } diff --git a/pj_base/tests/object_topic_metadata_test.cpp b/pj_base/tests/object_topic_metadata_test.cpp index fa557c28..dcd40bfe 100644 --- a/pj_base/tests/object_topic_metadata_test.cpp +++ b/pj_base/tests/object_topic_metadata_test.cpp @@ -223,6 +223,17 @@ TEST(ObjectTopicMetadataBuilderTest, SnapshotFlagRoundTrips) { 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 = { diff --git a/pj_base/tests/plot_tabs_api_test.cpp b/pj_base/tests/plot_tabs_api_test.cpp index eb7f1613..d52f696d 100644 --- a/pj_base/tests/plot_tabs_api_test.cpp +++ b/pj_base/tests/plot_tabs_api_test.cpp @@ -512,8 +512,8 @@ TEST(PlotTabSceneApiTest, CreateAndListRoundTrip) { const auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - ASSERT_TRUE(view.createV2("view-a", "3d", "First")); - ASSERT_TRUE(view.createV2("view-b", "2d", "Second")); + ASSERT_TRUE(view.createTabV2("view-a", "3d", "First")); + ASSERT_TRUE(view.createTabV2("view-b", "2d", "Second")); auto ids = view.list(); ASSERT_TRUE(ids) << ids.error(); @@ -527,7 +527,7 @@ TEST(PlotTabSceneApiTest, ConfigReadsBackWhatWasAttached) { const auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - ASSERT_TRUE(view.createV2("view-a", "3d", "My View")); + ASSERT_TRUE(view.createTabV2("view-a", "3d", "My View")); ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); ASSERT_TRUE(view.attachTopic("view-a", "camera/image", "bag1")); @@ -550,7 +550,7 @@ TEST(PlotTabSceneApiTest, DetachMissingTopicIsAnError) { const auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - ASSERT_TRUE(view.createV2("view-a", "3d")); + ASSERT_TRUE(view.createTabV2("view-a", "3d")); auto status = view.detachTopic("view-a", "lidar/points", "bag1"); EXPECT_FALSE(status); } @@ -563,7 +563,7 @@ TEST(PlotTabSceneApiTest, UnknownIdIsAnError) { EXPECT_FALSE(view.attachTopic("nope", "lidar/points")); EXPECT_FALSE(view.configOf("nope")); EXPECT_FALSE(view.close("nope")); - EXPECT_FALSE(view.focus("nope")); + EXPECT_FALSE(view.focusTab("nope")); } TEST(PlotTabSceneApiTest, HostFailureSurfacesTheMessage) { @@ -572,7 +572,7 @@ TEST(PlotTabSceneApiTest, HostFailureSurfacesTheMessage) { const auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - auto create_status = view.createV2("view-a", "3d"); + auto create_status = view.createTabV2("view-a", "3d"); EXPECT_FALSE(create_status); EXPECT_NE(create_status.error().find("tab boom"), std::string::npos); @@ -589,7 +589,7 @@ TEST(PlotTabSceneApiTest, UnboundViewReportsNotBound) { sdk::PlotTabHostView view; // default-constructed = not bound EXPECT_FALSE(view.valid()); - auto create_status = view.createV2("view-a", "3d"); + auto create_status = view.createTabV2("view-a", "3d"); EXPECT_FALSE(create_status); EXPECT_NE(create_status.error().find("not bound"), std::string::npos); @@ -613,7 +613,7 @@ TEST(PlotTabSceneApiTest, UnboundViewReportsNotBound) { EXPECT_FALSE(detach_status); EXPECT_NE(detach_status.error().find("not bound"), std::string::npos); - auto focus_status = view.focus("view-a"); + auto focus_status = view.focusTab("view-a"); EXPECT_FALSE(focus_status); EXPECT_NE(focus_status.error().find("not bound"), std::string::npos); } @@ -623,7 +623,7 @@ TEST(PlotTabSceneApiTest, DatasetQualifierReachesTheHost) { const auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - ASSERT_TRUE(view.createV2("view-a", "3d")); + ASSERT_TRUE(view.createTabV2("view-a", "3d")); ASSERT_TRUE(view.attachTopic("view-a", "lidar/points", "bag1")); ASSERT_TRUE(view.attachTopic("view-a", "camera/image")); @@ -649,12 +649,12 @@ TEST(PlotTabSceneApiTest, V1HostWithStructSize64ReportsNoSceneTabs) { sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); EXPECT_FALSE(view.hasSceneTabs()); - auto status = view.createV2("tab-a", "3d"); + auto status = view.createTabV2("tab-a", "3d"); EXPECT_FALSE(status); EXPECT_NE(status.error().find("does not support"), std::string::npos); EXPECT_FALSE(view.attachTopic("tab-a", "t")); EXPECT_FALSE(view.detachTopic("tab-a", "t")); - EXPECT_FALSE(view.focus("tab-a")); + EXPECT_FALSE(view.focusTab("tab-a")); EXPECT_TRUE(host.tabs.empty()); } @@ -666,7 +666,7 @@ TEST(PlotTabSceneApiTest, NullTailSlotOnLargeStructReportsNoSceneTabs) { sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); EXPECT_FALSE(view.hasSceneTabs()); - auto status = view.createV2("tab-a", "3d"); + auto status = view.createTabV2("tab-a", "3d"); EXPECT_FALSE(status); EXPECT_NE(status.error().find("does not support"), std::string::npos); } @@ -676,7 +676,7 @@ TEST(PlotTabSceneApiTest, AddCurveOnSceneTabSurfacesHostError) { auto vtable = makePlotTabVtable(); sdk::PlotTabHostView view(PJ_plot_tab_host_t{.ctx = &host, .vtable = &vtable}); - ASSERT_TRUE(view.createV2("tab-a", "3d")); + ASSERT_TRUE(view.createTabV2("tab-a", "3d")); auto status = view.addCurve("tab-a", "imu/accel", "x"); EXPECT_FALSE(status); EXPECT_NE(status.error().find("tab 'tab-a' is a 3d scene tab: use attach_topic/detach_topic"), std::string::npos); diff --git a/pj_base/tests/plugin_data_api_test.cpp b/pj_base/tests/plugin_data_api_test.cpp index e0ab9630..99929f94 100644 --- a/pj_base/tests/plugin_data_api_test.cpp +++ b/pj_base/tests/plugin_data_api_test.cpp @@ -282,6 +282,28 @@ TEST(PluginDataApiTest, ToolboxHostViewRejectsMissingReadSeriesTailSlot) { EXPECT_NE(status.error().find("read_series_arrow"), std::string::npos); } +TEST(PluginDataApiTest, HasCatalogSnapshotV2ReflectsTheTailSlot) { + TailSlotRecorder recorder; + PJ_toolbox_host_vtable_t vtable = { + .abi_version = PJ_PLUGIN_DATA_API_VERSION, + .struct_size = sizeof(PJ_toolbox_host_vtable_t), + .acquire_catalog_snapshot_v2 = toolboxAcquireCatalogSnapshotV2, + }; + EXPECT_TRUE(sdk::ToolboxHostView(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}).hasCatalogSnapshotV2()); + + // Slot present but not covered by struct_size: an old host. + vtable.struct_size = offsetof(PJ_toolbox_host_vtable_t, acquire_catalog_snapshot_v2); + EXPECT_FALSE(sdk::ToolboxHostView(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}).hasCatalogSnapshotV2()); + + // Covered but NULL. + vtable.struct_size = sizeof(PJ_toolbox_host_vtable_t); + vtable.acquire_catalog_snapshot_v2 = nullptr; + EXPECT_FALSE(sdk::ToolboxHostView(PJ_toolbox_host_t{.ctx = &recorder, .vtable = &vtable}).hasCatalogSnapshotV2()); + + // Unbound view. + EXPECT_FALSE(sdk::ToolboxHostView().hasCatalogSnapshotV2()); +} + TEST(PluginDataApiTest, CatalogSnapshotV2ReleasesOnce) { TailSlotRecorder recorder; const PJ_toolbox_host_vtable_t vtable = { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/dialog_protocol.h b/pj_plugins/dialog_protocol/include/pj_plugins/dialog_protocol.h index 87984304..b263b009 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/dialog_protocol.h +++ b/pj_plugins/dialog_protocol/include/pj_plugins/dialog_protocol.h @@ -56,6 +56,13 @@ typedef enum PJ_dialog_host_capability_t { PJ_DIALOG_HOST_CAN_SAVE_FILE_PATH = 1ull << 2, PJ_DIALOG_HOST_CAN_SELECT_FOLDER = 1ull << 3, PJ_DIALOG_HOST_STAGES_BROWSER_FILE = 1ull << 4, + /* The host embeds scene views: a QFrame carrying the `scene_view` / `scene_topics` + * widget keys becomes an embedded 3D/2D object view. A host without the bit leaves + * the frame empty; a dialog gates its scene UI on this bit (capability-detection + * rule 4 in plugin_data_api.h). Announced by a bit, so an older host that does not + * set it simply reports 0 for it. + * @since 0.36.0 */ + PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS = 1ull << 5, /* Forces a stable 4-byte width across compilers. Not a real capability. * New enum members are therefore limited to bits 0-30. Future capabilities * using bits 31 or higher must be UINT64_C(...) #define constants instead. */ diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index 4402a945..a02a0207 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -661,6 +661,7 @@ class WidgetDataView { /// Kind of the embedded view ("3d"/"2d"). nullopt: key absent (leave the frame /// alone). Empty string: the key is null (clearSceneView), delete the view. + /// @since 0.36.0 [[nodiscard]] std::optional sceneView(std::string_view name) const { const nlohmann::json* w = widget(name); if (!w) { @@ -679,6 +680,8 @@ class WidgetDataView { return std::nullopt; } + /// Object topics of the embedded view; nullopt when the key is absent or not an array. + /// @since 0.36.0 [[nodiscard]] std::optional> sceneTopics(std::string_view name) const { const nlohmann::json* w = widget(name); if (!w) { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp index 29d1aedb..9eb4db25 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp @@ -23,6 +23,8 @@ enum class DialogHostCapability : uint64_t { kCanSaveFilePath = PJ_DIALOG_HOST_CAN_SAVE_FILE_PATH, kCanSelectFolder = PJ_DIALOG_HOST_CAN_SELECT_FOLDER, kStagesBrowserFile = PJ_DIALOG_HOST_STAGES_BROWSER_FILE, + /// @since 0.36.0 + kEmbedsSceneViews = PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS, }; /// Owned copy of the runtime information supplied by the embedding host. @@ -88,6 +90,23 @@ class DialogPluginBase { return host_info_; } + /// Capability bits the host announced through set_host_info (a + /// PJ_DIALOG_HOST_* mask), or 0 when no host info was delivered (pre-0.21 host + /// or non-conforming embedding host). Never a probe of host version: a bit absent + /// here means "do not use that feature". + /// + /// @since 0.36.0 + [[nodiscard]] uint64_t hostCapabilities() const noexcept { + return host_info_.has_value() ? host_info_->capabilities : 0; + } + + /// True iff the host announced `capability`. Same as hostCapabilities() & bit. + /// + /// @since 0.36.0 + [[nodiscard]] bool hostHas(DialogHostCapability capability) const noexcept { + return (hostCapabilities() & static_cast(capability)) != 0; + } + public: template static const PJ_dialog_vtable_t* vtableWithCreate(CreateFn create_fn, const char* manifest_json = nullptr) { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 3c1c21a7..7dcfef5a 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -639,7 +639,9 @@ class WidgetData { /// Turn the named QFrame into an embedded object view of the given kind /// ("3d" or "2d"). The host creates the view once inside the frame; a kind - /// change recreates it. Hosts without embedded-view support leave the frame empty. + /// change recreates it. Hosts without embedded-view support leave the frame empty: + /// gate the scene UI on DialogPluginBase::hostHas(DialogHostCapability::kEmbedsSceneViews) + /// (PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS), not on a version string. /// @since 0.36.0 WidgetData& setSceneView(std::string_view name, std::string_view kind) { entry(name)["scene_view"] = std::string(kind); diff --git a/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp index 1c4d6e2e..6f9358f7 100644 --- a/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp +++ b/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp @@ -28,6 +28,17 @@ static_assert( static_cast(PJ::DialogHostCapability::kStagesBrowserFile) == PJ_DIALOG_HOST_STAGES_BROWSER_FILE, "C++ capability mirrors the C ABI"); +static_assert( + static_cast(PJ::DialogHostCapability::kEmbedsSceneViews) == PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS, + "C++ capability mirrors the C ABI"); +// ABI: capability bits are never renumbered or reused. Bit 5 is the first one added after 0.21. +static_assert(PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS == (1ull << 5), "capability bit values are frozen"); +static_assert( + (PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS & + (PJ_DIALOG_HOST_CAN_OPEN_FILE | PJ_DIALOG_HOST_CAN_OPEN_FILES | PJ_DIALOG_HOST_CAN_SAVE_FILE_PATH | + PJ_DIALOG_HOST_CAN_SELECT_FOLDER | PJ_DIALOG_HOST_STAGES_BROWSER_FILE)) == 0, + "each capability owns its own bit"); + class HostInfoDialog final : public PJ::DialogPluginBase { public: std::string manifest() const override { @@ -49,6 +60,14 @@ class HostInfoDialog final : public PJ::DialogPluginBase { [[nodiscard]] const std::optional& observedHostInfo() const noexcept { return hostInfo(); } + + [[nodiscard]] uint64_t observedHostCapabilities() const noexcept { + return hostCapabilities(); + } + + [[nodiscard]] bool observedHostHas(PJ::DialogHostCapability capability) const noexcept { + return hostHas(capability); + } }; class DialogPluginBaseHostInfoTest : public ::testing::Test { @@ -116,6 +135,20 @@ TEST_F(DialogPluginBaseHostInfoTest, CopiesStringsAndCapabilitiesDuringDelivery) EXPECT_FALSE(info->has(PJ::DialogHostCapability::kCanSelectFolder)); } +TEST_F(DialogPluginBaseHostInfoTest, HostCapabilitiesReportsTheEmbedsSceneViewsBit) { + // No delivery (pre-0.21 host, or one that never calls set_host_info): no bits. + EXPECT_EQ(plugin().observedHostCapabilities(), 0u); + EXPECT_FALSE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); + + ASSERT_TRUE(deliver("0.36.0", "4.2.0", PJ_DIALOG_HOST_CAN_OPEN_FILE)); + EXPECT_EQ(plugin().observedHostCapabilities(), static_cast(PJ_DIALOG_HOST_CAN_OPEN_FILE)); + EXPECT_FALSE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); + + ASSERT_TRUE(deliver("0.36.0", "4.2.0", PJ_DIALOG_HOST_CAN_OPEN_FILE | PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS)); + EXPECT_TRUE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); + EXPECT_TRUE(plugin().observedHostHas(PJ::DialogHostCapability::kCanOpenFile)); +} + TEST_F(DialogPluginBaseHostInfoTest, AcceptsOnlyFieldsCoveredByStructSize) { static constexpr char kSdkVersion[] = "0.21.0"; static constexpr char kIgnoredPlotJugglerVersion[] = "must-not-be-read"; diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 2c4ec8e2..3df6b892 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -222,7 +222,7 @@ Include `pj_base/sdk/service_traits.hpp` and acquire the services you need: | Service trait | Methods and scope | |---|---| | `PJ::sdk::PlaybackHostService` | `play`, `pause`, `seek`, `setPlaybackRate`, `state`: the global playback cursor. `toDisplayTime` and `toDisplayTimeForSource` convert absolute nanoseconds to display-axis seconds. | -| `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. Since 0.36.0 the same service also serves scene tabs through tail slots, absent on a host with no scene workspace (check `hasSceneTabs()`): `createV2(id, kind, title)` with `kind` `"plot"`, `"3d"` or `"2d"` (one id namespace across kinds; re-creating an id with a different kind closes and recreates it empty), `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focus(id)`. On a scene tab `addCurve`/`removeCurve` are errors; `clear` detaches every topic; `configOf` reports what the tab actually holds (scene tabs: `kind`, topics with dataset, type, visibility; plot tabs: unchanged, no `kind`). | +| `PJ::sdk::PlotTabHostService` | `create`, `close`, `list`, `configOf`, `addCurve`, `removeCurve`, `clear`: only the calling plugin's tabs. Since 0.36.0 the same service also serves scene tabs through tail slots, absent on a host with no scene workspace (check `hasSceneTabs()`): `createTabV2(id, kind, title)` with `kind` `"plot"`, `"3d"` or `"2d"` (one id namespace across kinds; re-creating an id with a different kind closes and recreates it empty), `attachTopic(id, topic, dataset_source)`, `detachTopic`, `focusTab(id)`. On a scene tab `addCurve`/`removeCurve` are errors; `clear` detaches every topic; `configOf` reports what the tab actually holds (scene tabs: `kind`, topics with dataset, type, visibility; plot tabs: unchanged, no `kind`). | | `PJ::sdk::ViewportHostService` | `zoomToTimeRange`, `zoomReset`: all eligible plots in the calling plugin's tabs. | All calls run on the main thread. Services are optional. Check acquisition @@ -434,6 +434,7 @@ it without instantiating the plugin. | `version` | string | yes | Semver version string. | | `description` | string | no | Short description of the plugin. | | `badge` | string | no | Short label (e.g. `AI`) the host may show next to objects this plugin creates. Absent means empty; the host then falls back to the plugin name. | +| `custom_topics_editor` | bool | no | `true` declares this toolbox as the editor of the host's user-defined (Custom) topics. The host shows the "+" button for it and lets only this plugin edit or delete those rows; absent or `false` means no. Declarative, never probed. | Example: ```json diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index b788f7b5..8461cf18 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -64,6 +64,13 @@ struct PluginDescriptor { /// Optional short label (e.g. "AI") a host may show next to objects this /// plugin creates. "" when the manifest does not declare it. std::string badge; + /// Optional manifest flag `"custom_topics_editor": true`: this toolbox is the + /// editor of the host's user-defined (Custom) topics. A host keys the "+" button + /// and the edit/delete ownership of those rows on this declaration instead of a + /// hard-coded plugin id. false when absent. Declarative: no probe (capability + /// rule 5 in plugin_data_api.h). + /// @since 0.36.0 + bool custom_topics_editor = false; }; /// Diagnostic for a candidate DSO that could not produce a valid descriptor. diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 26c8e3ec..d1d308be 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -223,6 +223,17 @@ Expected decodeManifest( return it->get(); }; + auto optional_bool = [&](std::string_view key) -> Expected { + const auto it = j.find(std::string(key)); + if (it == j.end()) { + return false; + } + if (!it->is_boolean()) { + return unexpected(fmt::format("plugin embedded manifest key must be a boolean: {}", key)); + } + return it->get(); + }; + PluginDescriptor d; d.dso_path = source_path; d.abi_major = PJ_ABI_VERSION; @@ -265,6 +276,10 @@ Expected decodeManifest( if (!badge) { return unexpected(badge.error()); } + auto custom_topics_editor = optional_bool("custom_topics_editor"); + if (!custom_topics_editor) { + return unexpected(custom_topics_editor.error()); + } auto min_sdk_required = optional_string("min_sdk_required"); if (!min_sdk_required) { return unexpected(min_sdk_required.error()); @@ -293,6 +308,7 @@ Expected decodeManifest( d.min_sdk_required = *min_sdk_required; d.suggested_sdk_version = *suggested_sdk_version; d.badge = *badge; + d.custom_topics_editor = *custom_topics_editor; d.file_extensions = *file_extensions; d.capabilities = *capabilities; diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index e3e01b8a..290de63b 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -312,6 +312,30 @@ TEST_F(PluginCatalogTest, BadgeRoundTripsAndDefaultsEmpty) { EXPECT_FALSE(wrong_type.has_value()); } +TEST_F(PluginCatalogTest, CustomTopicsEditorRoundTripsAndDefaultsFalse) { + auto declared = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","custom_topics_editor":true})"); + ASSERT_TRUE(declared.has_value()) << declared.error(); + EXPECT_TRUE(declared->custom_topics_editor); + + auto explicit_false = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","custom_topics_editor":false})"); + ASSERT_TRUE(explicit_false.has_value()) << explicit_false.error(); + EXPECT_FALSE(explicit_false->custom_topics_editor); + + auto absent = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0"})"); + ASSERT_TRUE(absent.has_value()) << absent.error(); + EXPECT_FALSE(absent->custom_topics_editor); + + auto wrong_type = decodeManifest( + "static:sdk-test", PluginFamily::kToolbox, + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","custom_topics_editor":"yes"})"); + EXPECT_FALSE(wrong_type.has_value()); +} + TEST_F(PluginCatalogTest, MinSdkRequiredAcceptsEmptyString) { auto descriptor = decodeManifest( "static:sdk-test", PluginFamily::kDataSource, From 3e6b5bbcff43c230b798e23acb8a1d1f8e7e3e7d Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 2 Oct 2026 10:15:23 +0200 Subject: [PATCH 16/22] docs(sdk): processor lifetime, per-kind fields, capability rule, Python probe Document EPHEMERAL / HISTORY_EXEMPT / pinned lifetime, what each kind does with label and WINDOW, Python as an optional per-host on_demand language, plot-tab replace semantics, attach idempotency, the non-atomic snapshot v2, scene_view limits and the badge fallback. Update the toolbox guide, the architecture data-processors section, the dialog guide and the plugin skill reference; fix a stray backtick in plugin_data_api.hpp. --- .../plotjuggler-plugin/references/toolbox.md | 23 +++++++ CHANGELOG.md | 10 +++ pj_base/include/pj_base/plugin_data_api.h | 58 +++++++++++++--- .../include/pj_base/sdk/plugin_data_api.hpp | 6 +- .../include/pj_plugins/sdk/widget_data.hpp | 5 +- pj_plugins/docs/ARCHITECTURE.md | 23 +++++-- pj_plugins/docs/dialog-plugin-guide.md | 10 ++- pj_plugins/docs/toolbox-guide.md | 66 +++++++++++++++++-- .../pj_plugins/host/plugin_catalog.hpp | 4 +- 9 files changed, 182 insertions(+), 23 deletions(-) diff --git a/.claude/skills/plotjuggler-plugin/references/toolbox.md b/.claude/skills/plotjuggler-plugin/references/toolbox.md index 66bf3c66..6d271c35 100644 --- a/.claude/skills/plotjuggler-plugin/references/toolbox.md +++ b/.claude/skills/plotjuggler-plugin/references/toolbox.md @@ -78,6 +78,29 @@ PJ_TOOLBOX_PLUGIN(MyToolbox, `dataset_source:topic/field` lookup rules are in `pj_plugins/docs/toolbox-guide.md` → "Playback, viewport, and owned tabs". +## Detecting host features (SDK 0.36.0) + +One rule, in the header comment of `pj_base/plugin_data_api.h`; never test a +version string: + +- ABI service feature: a named `hasX()` on the view + (`DataProcessorsHostView::hasTypedRequests()`, `PlotTabHostView::hasSceneTabs()`, + `ToolboxHostView::hasCatalogSnapshotV2()`). +- Flag-bit feature (`PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS`): do not probe; an older + host rejects the bit. Its floor is the `hasX()` of the slot carrying it. +- Build-dependent behaviour (Python for on_demand): probe by doing it, + `validateScript("on_demand", "python", "return {}")`. +- Dialog feature (`scene_view`): `hostHas(DialogHostCapability::kEmbedsSceneViews)` + inside the dialog. +- Manifest metadata (`badge`, `custom_topics_editor`): declarative, no probe. + +On-demand requests: `createV2` returns `//` for object and number +outputs (read a number series by exactly that string) and the bare name for a +string output. `submitEvaluation` + `pollEvaluation`: check `coverage.stopped` and +`coverage.error`; an empty `bundles` list is not "no sample". Lifetime: persistent +by default, `EPHEMERAL` = preview that undo/redo does not end, `HISTORY_EXEMPT` = +persisted but outside history. See `toolbox-guide.md` for the full text. + ## Reading a series (Arrow) ```cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fcace1..bb0c9342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,16 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, they no longer read like `DataProcessorsHostView::createV2` or a UI focus. `feature_floors.json` keys follow. - Every slot, struct and wrapper added in 0.36 now carries `@since 0.36.0`. +- Docs: the contract text now states, once each, the lifetime of a processor by + flag and kind (persistent / EPHEMERAL / HISTORY_EXEMPT / pinned), what each + kind does with `label`, WINDOW and INSTANT, that Python is an optional + per-host on_demand language probed with `validateScript`, that a plot-tab + re-create keeps the host's layout and clears the curves, that `attach_topic` + is idempotent, that the two halves of catalog snapshot v2 are not atomic, + that a `scene_view` frame exists in panels only and a failed attach is not + retried until the topic set changes, and that the empty-badge fallback is host + policy (at most 8 characters advised). `data_processor_config` reports + `history_exempt` for every kind, not only transforms and markers. - ImageAnnotations wire carries the top-level timestamp and image_topic; additive, old readers skip them. diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 9f2353de..cf9b81e5 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -717,6 +717,9 @@ typedef struct PJ_toolbox_host_vtable_t { /* [stream-thread] Catalog snapshot v2: the scalar catalog of acquire_catalog_snapshot * PLUS every object topic with its dataset, builtin type, entry count and raw time * range, in one deep copy. Release with out_snapshot->release(out_snapshot->release_ctx). + * The two halves are NOT atomic: the scalar catalog and the object-topic list are + * each consistent on their own, but a topic created between the two reads may show + * in one half only. A consumer must not assume a topic named in one appears in the other. * ABI-APPENDED slot: gate via struct_size before calling. * @since 0.36.0 */ bool (*acquire_catalog_snapshot_v2)(void* ctx, PJ_catalog_snapshot_v2_t* out_snapshot, PJ_error_t* out_error) @@ -995,8 +998,11 @@ typedef struct { * - string output: the bare name; a string is never a topic. * A consumer reads a number output's series by exactly this string; it must not * rebuild the path from the output name (a different topic may share the leaf). - * `language` must be "luau"; the same "unknown kind is rejected" rule as any - * other kind applies to a host that predates this one. + * `language` is "luau" on every host; "python" is OPTIONAL and per host (a + * host built without it, e.g. WASM, rejects it): never assume it, probe it by + * calling validate_data_processor_script("on_demand","python","return {}") and + * keep the UI honest when that fails. The same "unknown kind is rejected" rule as + * any other kind applies to a host that predates this one. * Future kinds (e.g. a host-owned engine backend) are added the same way — a new * `kind` string plus host routing, no ABI change. * @@ -1008,7 +1014,9 @@ typedef struct { * calling plugin (per-plugin isolation): one plugin can neither * enumerate nor remove another's. * - kind : output discriminator, see above ("transform", "markers", "on_demand"). - * - language : script backend, "luau" today; the host rejects anything else. + * - language : script backend. "luau" everywhere; "python" only for kind + * "on_demand" and only on hosts that ship it (probe with + * validate_data_processor_script). The host rejects any other value. * - inputs : topic OR topic-field names ("pose/orientation" or * "pose/orientation/x") the script reads; the host resolves them * and exact-joins co-timestamped inputs. A name MAY carry the @@ -1034,6 +1042,35 @@ typedef struct { * recreates or removes it). PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS: see * its definition below. Reserved bits must be 0. * + * LIFETIME — a node is exactly one of three things, by flag and kind: + * - PERSISTENT (default): saved in the layout; the host's undo/redo and layout load + * may replace, recreate or remove it like any other node. It survives plugin + * unload and a session reload. + * - EPHEMERAL (PJ_DATA_PROCESSOR_FLAG_EPHEMERAL): a preview owned by the plugin + * instance that created it. Never persisted; undo/redo and layout load do not + * end it; only remove_data_processor(id) or the owning plugin's teardown does. + * Hosts hide previews from list_data_processor_ids and data_processor_config; the + * catalog snapshot may still show their output topics. kinds: all three. + * - HISTORY_EXEMPT (PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT): PERSISTED, but the host's + * history has no authority over it (never restored, recreated or removed by + * undo/redo). Meaningless on an EPHEMERAL node. kinds: all three; every kind's + * data_processor_config reports the boolean "history_exempt". + * - PINNED (kind "on_demand" only: PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT on create): a + * persisted finding fixed at one evaluation time (config key "pinned_t_ns"). It + * cannot run in series mode, so its number outputs have no catalog series. + * Transforms and markers REJECT INSTANT. + * + * PER-KIND REQUEST FIELDS (create_data_processor_v2 / submit_evaluation): + * kind label WINDOW (time_flags) INSTANT + * transform accepted, ignored (advisory) rejected ("not supported yet") rejected + * markers accepted, ignored (advisory) accepted on create: the span the rejected + * markers are computed over + * on_demand stored and reported rejected on create ("use accepted on create + * submit_evaluation"); valid only (pinned); valid on + * on submit_evaluation submit_evaluation + * `label` is advisory text for a UI: a host never keys behaviour on it, and a kind + * that ignores it still accepts it (the request is not rejected for carrying one). + * * DATASET-QUALIFIED NAMES — a series' full identity is (dataset, topic, field); a * bare "topic/field" name is an abbreviation that stops being unique the moment * two loaded datasets share topic names. An input MAY therefore carry the @@ -1205,8 +1242,8 @@ typedef struct PJ_data_processors_host_vtable_t { * re-edit (e.g. after a session reload). *out_recipe_json is borrowed, valid only * until the next call on this vtable. An unknown id is an error. * Hosts supporting PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT include the boolean - * "history_exempt" for transforms and markers, reflecting the node's actual - * property. Callers must not infer support from a successful create alone. */ + * "history_exempt" for EVERY kind (transform, markers, on_demand), reflecting the + * node's actual property. Callers must not infer support from a successful create alone. */ bool (*data_processor_config)( void* ctx, PJ_string_view_t id, PJ_string_view_t* out_recipe_json, PJ_error_t* out_error) PJ_NOEXCEPT; @@ -1556,9 +1593,11 @@ typedef struct PJ_plot_tab_host_vtable_t { /* [main-thread] Create (or update) a tab of the given `kind`: "plot", "3d" or "2d". * ABI-APPENDED slot (0.36.0): gate with PJ_HAS_TAIL_SLOT. One id namespace per - * plugin across kinds. The same id with the same kind: on a plot tab it replaces - * the tab with one empty plot (the released `create_tab` "create or replace" - * behaviour); on a scene tab it only updates the title. The same id with a DIFFERENT + * plugin across kinds. The same id with the same kind: on a plot tab it REPLACES + * the tab's contents (the released `create_tab` "create or replace" behaviour: the + * curves are cleared; a host may keep the tab's existing split layout, as PlotJuggler + * does, so do not assume exactly one plot afterwards: read tab_config); on a scene + * tab it only updates the title. The same id with a DIFFERENT * kind closes the old tab and creates a new empty one. `create_tab` (v1) is this call * with kind "plot". An empty `id`, or an id containing '/', is an error here (v1 * keeps its released id rules). An empty `title` lets the host name the tab. @@ -1569,7 +1608,8 @@ typedef struct PJ_plot_tab_host_vtable_t { /* [main-thread] Attach an object topic to a scene tab. ABI-APPENDED slot (0.36.0): * gate with PJ_HAS_TAIL_SLOT. Scene tabs only. `dataset_source` follows the same * rule as add_curve: empty means the topic must be unique across loaded datasets, - * and an ambiguous one is refused with the qualified candidates. A "3d" tab accepts + * and an ambiguous one is refused with the qualified candidates. IDEMPOTENT: + * attaching a topic that is already attached is success and changes nothing. A "3d" tab accepts * every object type the 3D view renders; a "2d" tab accepts Image/DepthImage/ * VideoFrame (replacing the background) and ImageAnnotations (an overlay). On a plot * tab this is an error: "tab '' is a plot tab: use add_curve". diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index e5de4891..5a571fcc 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1723,7 +1723,7 @@ class DataProcessorsHostView { PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, release_evaluation); } - /// Create or replace (upsert by id) a data processor of `kind' ("transform" or + /// Create or replace (upsert by id) a data processor of `kind` ("transform" or /// "markers"). `outputs` may be empty for an ephemeral preview (flags & /// PJ_DATA_PROCESSOR_FLAG_EPHEMERAL), in which case the host names the sink(s); /// flags & PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT instead marks a persisted node the @@ -1898,7 +1898,9 @@ class DataProcessorsHostView { /// Validate a script WITHOUT installing anything: compile + module-load only (no /// inputs, no run, no side effects) for the given `kind`. Cheap enough to drive a /// live red/green editor semaphore. Runtime/empty-output errors are NOT caught here — - /// use an ephemeral create for that. `language` selects the backend ("luau" today). + /// use an ephemeral create for that. `language` selects the backend: "luau" everywhere, + /// "python" only for kind "on_demand" and only on hosts that ship it (probe with + /// validateScript("on_demand", "python", "return {}"), never assume it). /// Errors if the host predates this slot or the language/kind is unknown. [[nodiscard]] Status validateScript( std::string_view kind, std::string_view language, std::string_view script, diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 7dcfef5a..4c86f554 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -641,7 +641,10 @@ class WidgetData { /// ("3d" or "2d"). The host creates the view once inside the frame; a kind /// change recreates it. Hosts without embedded-view support leave the frame empty: /// gate the scene UI on DialogPluginBase::hostHas(DialogHostCapability::kEmbedsSceneViews) - /// (PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS), not on a version string. + /// (PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS), not on a version string. Embedded scene + /// views exist in PANELS (non-modal toolbox dialogs) only; a modal dialog never + /// embeds one. If the host fails to attach a requested topic it does NOT retry: + /// the attach is attempted again only when the requested topic set changes. /// @since 0.36.0 WidgetData& setSceneView(std::string_view name, std::string_view kind) { entry(name)["scene_view"] = std::string(kind); diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index ce8a5c3e..70ee1911 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -248,9 +248,22 @@ service registry, error out-params, and typed borrowed-dialog patterns): a materialized artifact to the host for promotion to a stock file-backed source — see "Plugin extension query" above and `pj_base/descriptor_import_protocol.h`. `"pj.data_processors.v1"` (optional) lets a toolbox create - catalog-resident transform nodes in the host by data — a script plus - input/output names and a params JSON blob; nothing executable crosses the - boundary (the host owns execution). Input names may carry the dataset + nodes in the host by data — a script plus input/output names and a params + JSON blob; nothing executable crosses the boundary (the host owns execution). + One polymorphic surface serves three `kind`s: `"transform"` (catalog-resident + numeric series), `"markers"` (a PlotMarkers set) and `"on_demand"` (objects or + numbers evaluated at a consumer-requested time). The typed request + (`create_data_processor_v2`, `submit_evaluation`/`poll_evaluation`/ + `release_evaluation`, tail slots detected by + `DataProcessorsHostView::hasTypedRequests()`) carries typed outputs, a label + and an instant or window; flags are `EPHEMERAL` (preview, never persisted), + `HISTORY_EXEMPT` (persisted, outside undo/redo) and `INFER_OUTPUTS` + (outputs learned from the script). Python is an optional on_demand language + of some hosts, probed with `validateScript`. The request's `struct_size` + follows a read-prefix rule: a host accepts a larger size and a later field is + announced by a flag bit. The capability-detection rule (tail slot behind a + `hasX()`; flag bit with no probe; probe by doing; dialog capability bit; + manifest metadata) is stated once in `plugin_data_api.h`. Input names may carry the dataset qualifier `dataset_source:topic/field`, so a name several loaded datasets share is addressed rather than guessed — rules are normative in `plugin_data_api.h` (DATASET-QUALIFIED NAMES), shared parser/composer in @@ -259,7 +272,9 @@ service registry, error out-params, and typed borrowed-dialog patterns): this same data-only surface (a future host-owned WASM/Python backend is purely additive and survives plugin unload) — deliberately *not* a C++ kernel vtable that would dangle on unload. The plugin sees a Qt-free - `sdk::DataProcessorsHostView` (`createTransform`/`remove`/`list`/`recipeOf`). + `sdk::DataProcessorsHostView` (`createTransform`/`createMarkers`/ + `createOnDemand`/`createV2`/`submitEvaluation`/`pollEvaluation`/`remove`/ + `list`/`recipeOf`). `"pj.settings.v1"` (optional) is a QSettings-like key/value store any plugin family can use for persistent state — the plugin sees a Qt-free `sdk::SettingsView` (`setValue(key, v)` returns a `Status`; reads return an diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index fdd9548a..f3c3e810 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -188,6 +188,14 @@ Available `DialogHostCapability` values are: | `kCanSaveFilePath` | The host can choose a destination file path. | | `kCanSelectFolder` | The host can select a folder. | | `kStagesBrowserFile` | A browser-selected file is staged to a host-accessible path before delivery. | +| `kEmbedsSceneViews` (0.36.0) | The host turns a QFrame carrying `scene_view` / `scene_topics` into an embedded 3D/2D object view. | + +`hostCapabilities()` returns the raw mask (0 when nothing was delivered) and +`hostHas(DialogHostCapability)` tests one bit; both are protected, like +`hostInfo()`. This is the way to detect a dialog-protocol feature: a bit you do +not see means "do not use it" (capability rule 4 in `pj_base/plugin_data_api.h`). +Embedded scene views exist in panels (non-modal toolbox dialogs) only, and a +failed attach of a topic is not retried until the requested topic set changes. Delivery is last-writer-wins: a later successful host-info call replaces both stored strings and the complete capability mask. Plugin code should normally @@ -498,7 +506,7 @@ work like polling a server for available topics. | QTableWidget | `setTableHeaders`, `setTableRows` (strings, or `TableItem` for sortable columns), `setTableSortIndicator`, `setSelectedRows`, `setVisibleRows`, `setRowColor`, `setCellTooltip` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | | QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage`, `setCodeCursor`, `setCodeCaretTracking` | `onCodeChanged(name, code)`, or `onCodeChangedWithCursor(name, code, cursor)` when the editor opts into caret tracking | | QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | -| QFrame (embedded object view, since 0.36.0) | `setSceneView(name, "3d"\|"2d")`, `setSceneTopics`, `clearSceneView` | (none) | +| QFrame (embedded object view, since 0.36.0) | `setSceneView(name, "3d"\|"2d")`, `setSceneTopics`, `clearSceneView`; gate on `hostHas(kEmbedsSceneViews)` | (none) | | QDateTimeEdit (incl. QDateEdit/QTimeEdit) | `setDateTime`, `setDateTimeRange` | `onDateTimeChanged(name, iso8601)` | | RangeSlider (two-handle) | `setRangeSliderBounds`, `setRangeSliderValues`, `setRangeSliderTimeSpan` | `onRangeChanged(name, lower, upper)` | | DateRangePicker (date range) | `setDateRangePlaceholder` | `onDateRangeChanged(name, from_iso, to_iso)` | diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 3df6b892..a52e9ebe 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -200,7 +200,8 @@ data store. | `appendBoundRecord(topic, timestamp, fields)` | Write using pre-resolved field handles (faster). | | `appendArrowStream(topic, stream, ts_col)` | Hand an `ArrowArrayStream*` (Arrow C Data Interface) to the host for bulk ingest. Same ownership rule as the source write path: success transfers, failure retains. | | `catalogSnapshot()` | Acquire a read-only snapshot of all data sources, topics, and fields. | -| `catalogSnapshotV2()` (0.36.0) | Like `catalogSnapshot()`, plus every object topic (`objectTopics()`) with its dataset, builtin type, entry count and raw time range, in one deep copy. | +| `hasCatalogSnapshotV2()` (0.36.0) | True iff the host serves snapshot v2. Check it before `catalogSnapshotV2()`; without it object topics are not enumerable. | +| `catalogSnapshotV2()` (0.36.0) | Like `catalogSnapshot()`, plus every object topic (`objectTopics()`) with its dataset, builtin type, entry count and raw time range, in one deep copy. The two halves are not read atomically: a topic created between the reads may appear in one half only. | | `readSeriesArrow(field, schema*, array*)` | Read one field's full time series into host-owned `ArrowSchema` + `ArrowArray` out-params (two columns: `timestamp` int64 ns, then the typed field value). | | `registerObjectTopic(source, name, type[, extra_metadata])` | Register a built-in media/object topic under a data source. The typed overload emits the canonical `builtin_object_type` renderer metadata and returns an `ObjectTopicHandle`. | | `registerObjectTopic(source, name, metadata_json)` | Raw-metadata overload for custom or untyped object topics. The store retains the JSON verbatim. | @@ -266,7 +267,9 @@ recomputed after user edits to source offsets or the time reference. Tab `id` and visible `title` are separate. `create("run-a", "Temperature")` and `create("run-b", "Temperature")` create two independently addressable tabs. Renaming or reordering tabs preserves their IDs. Recreating an ID replaces its -contents. +contents: PlotJuggler keeps the tab's split layout and clears the curves, so do +not assume a single plot afterwards and read `configOf`. `attachTopic` is +idempotent (attaching an attached topic succeeds). IDs are scoped to the plugin binding and live only while `list()` returns them. Re-read after workspace changes. `configOf(id)` reports the resolved curves @@ -291,8 +294,29 @@ and with both sources loaded the parser chooses `a:b`. Composition round-trips only when the intended source is the longest matching prefix. Do not treat this string as a persistent dataset identity. Hosts still validate the split result. +### Detecting what the host can do (capability rule) + +One rule, stated once in the header comment of `pj_base/plugin_data_api.h`. A +plugin finds out what a host offers in exactly one of five ways, by the kind of +feature: + +| Feature kind | How to detect | Examples | +|---|---|---| +| ABI service feature (tail slots) | One named `hasX()` on the C++ view; never a version string | `DataProcessorsHostView::hasTypedRequests()`, `PlotTabHostView::hasSceneTabs()`, `ToolboxHostView::hasCatalogSnapshotV2()` | +| Flag-bit feature | No probe: an older host REJECTS an unknown bit, the call fails loudly. The floor is the `hasX()` of the slot that carries it | `PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS` (floor: `hasTypedRequests()`) | +| Build-dependent behaviour | Probe by doing it | Python for on_demand: `validateScript("on_demand", "python", "return {}")`; a WASM host rejects it | +| Dialog-protocol feature | A bit of `PJ_dialog_host_info_t::capabilities`, read with `hostHas()` / `hostCapabilities()` | `kEmbedsSceneViews` for `scene_view` / `scene_topics` | +| Manifest metadata | Declarative, no probe; hosts ignore keys they do not know | `badge`, `custom_topics_editor` | + +When a plugin cannot find an input's type because the host has no snapshot v2, +treat it as unknown and say the host is too old; do not guess a scalar. + ### Typed requests and on-demand evaluation (SDK 0.36.0) +`DataProcessorsHostView::hasTypedRequests()` is true iff the host serves +`createV2` and the `submitEvaluation` / `pollEvaluation` / `releaseEvaluation` +trio; gate typed-request UI on it, never on a version. + `DataProcessorsHostView::createV2(request)` upserts a `kind="on_demand"` node like `createOnDemand`, but the request is typed (`DataProcessorRequest`): outputs carry a `DataProcessorOutput{name, type}` pair instead of a `":"` string @@ -300,6 +324,33 @@ suffix, plus an optional human-readable `label` and, with `instant_ns` set, a pinned evaluation time. `create_data_processor` (v1) remains valid for the untyped suffix grammar. +`createV2` returns, 1:1 with `outputs`, the catalog path of each output: +`//` for an object output AND for a number output (the series +key of series mode; it is absent from the catalog when the recipe cannot run in +series mode: pinned, or no object input), and the bare name for a string +output. Read a number output's series by exactly the returned string; never +rebuild it from the output name, because another topic may share the leaf. + +`PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS` (on_demand): a transient evaluation +(`submitEvaluation` with a script and no declared outputs) infers each output's +name and type from the returned value, and the report carries them in a root +`"outputs"` array `[{"name","type"}]` (type `number`, `string`, a builtin +object type name, or `unknown`). On `createV2` the declared outputs are then a +binding hint learned from such a trial. An older host rejects the unknown flag +bit, so there is nothing to probe. + +Lifetime, by flag: PERSISTENT (the default) is saved in the layout and is +subject to the host's undo/redo; EPHEMERAL is a preview owned by the plugin +instance, never persisted and untouched by undo/redo or layout load, ended only +by `remove` or the plugin's teardown; HISTORY_EXEMPT is persisted but history +never restores, recreates or removes it (`recipeOf` reports `history_exempt` for +every kind); a pinned on_demand node (`instant_ns` on `createV2`) is a persisted +finding fixed at one time. The per-kind table of `label`, WINDOW and INSTANT +is in the `plugin_data_api.h` comment: transforms and markers accept and ignore +`label`, transforms reject WINDOW, and WINDOW belongs to `submitEvaluation` for +on_demand. Python is an optional on_demand language of some hosts: probe it with +`validateScript("on_demand", "python", ...)`. + `submitEvaluation(request, budget)` starts an evaluation and returns a handle. `request.id` naming an installed on_demand node with an empty `script` evaluates that node; a non-empty `script` is an ephemeral recipe (`flags` must carry @@ -313,8 +364,13 @@ window, in time order, until the `EvaluationBudget` (`max_millis`, `max_bytes`, completed the work before returning or in the background: on `kCompleted` its `json` is `{"coverage":{...},"bundles":[...]}` (one bundle for INSTANT, one per evaluated instant for WINDOW; every `*_ns` value is a raw dataset nanosecond -count as a JSON integer). On `kFailed` it is `{"error":"..."}`. Handles are per -host object, increasing, never reused. `releaseEvaluation(handle)` cancels a +count as a JSON integer). `coverage.stopped` says why the evaluation ended and +`coverage.error` carries the reason only when it is `"error"`; an empty `bundles` +list alone does not mean "no sample". `coverage.gaps` lists retention gaps. On +`kFailed` it is `{"error":"..."}`; a state the SDK does not know is returned as +an error, never as pending. A host keeps at most 64 live handles and 64 MiB of +reserved report bytes: release finished handles, and poll from a timer so the +host's event loop can finish the work. Handles are per host object, increasing, never reused. `releaseEvaluation(handle)` cancels a pending evaluation and frees its result; releasing an unknown or already-released handle is an error. @@ -433,7 +489,7 @@ it without instantiating the plugin. | `name` | string | yes | Human-readable plugin name. | | `version` | string | yes | Semver version string. | | `description` | string | no | Short description of the plugin. | -| `badge` | string | no | Short label (e.g. `AI`) the host may show next to objects this plugin creates. Absent means empty; the host then falls back to the plugin name. | +| `badge` | string | no | Short label (e.g. `AI`) the host may show next to objects this plugin creates; at most 8 characters are advised. Absent means empty, and what the host shows then (the plugin name, nothing) is host policy, not contract. | | `custom_topics_editor` | bool | no | `true` declares this toolbox as the editor of the host's user-defined (Custom) topics. The host shows the "+" button for it and lets only this plugin edit or delete those rows; absent or `false` means no. Declarative, never probed. | Example: diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 8461cf18..048d2849 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -62,7 +62,9 @@ struct PluginDescriptor { /// host degradation display — never an admission criterion. std::string suggested_sdk_version; /// Optional short label (e.g. "AI") a host may show next to objects this - /// plugin creates. "" when the manifest does not declare it. + /// plugin creates. "" when the manifest does not declare it. What a host shows + /// for an empty badge (the plugin name, nothing, ...) is HOST POLICY, not part of + /// the contract; at most 8 characters are advised, longer text may be truncated. std::string badge; /// Optional manifest flag `"custom_topics_editor": true`: this toolbox is the /// editor of the host's user-defined (Custom) topics. A host keys the "+" button From f30e67791ddfe150d32abbc8350e68543a8d5ef9 Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 2 Oct 2026 11:03:38 +0200 Subject: [PATCH 17/22] refactor(sdk): simplify the 0.36 alignment hostHas reads DialogHostInfo::has and hostCapabilities() goes. The on_demand out_topics rule, the read-prefix rule and the Python probe are stated once in plugin_data_api.h; the C++ wrappers point to it. --- pj_base/include/pj_base/plugin_data_api.h | 10 +++---- .../include/pj_base/sdk/plugin_data_api.hpp | 27 +++++++------------ .../pj_plugins/sdk/dialog_plugin_base.hpp | 16 +++-------- .../tests/dialog_plugin_base_test.cpp | 9 ++----- 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index cf9b81e5..94ce6a23 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -96,7 +96,7 @@ extern "C" { * "return {}"), and keep the UI honest when it fails. * 4. Dialog-protocol feature (scene_view / scene_topics widget keys): a bit in * PJ_dialog_host_info_t::capabilities (dialog_protocol.h), read through - * DialogPluginTyped::hostCapabilities(). A host that never calls set_host_info + * DialogPluginBase::hostHas(). A host that never calls set_host_info * reports 0 bits. * 5. Manifest metadata (badge, custom_topics_editor): declarative, no probe; hosts * that do not know a key ignore it. @@ -1014,9 +1014,9 @@ typedef struct { * calling plugin (per-plugin isolation): one plugin can neither * enumerate nor remove another's. * - kind : output discriminator, see above ("transform", "markers", "on_demand"). - * - language : script backend. "luau" everywhere; "python" only for kind - * "on_demand" and only on hosts that ship it (probe with - * validate_data_processor_script). The host rejects any other value. + * - language : script backend. "luau" everywhere; "python" is optional (see the + * Python note in the on_demand paragraph above). The host rejects any + * other value. * - inputs : topic OR topic-field names ("pose/orientation" or * "pose/orientation/x") the script reads; the host resolves them * and exact-joins co-timestamped inputs. A name MAY carry the @@ -1262,7 +1262,7 @@ typedef struct PJ_data_processors_host_vtable_t { * and (INSTANT) a pinned evaluation time for an on_demand finding. Same upsert, * transactional and out_topics (count-then-fill, borrowed until the next call on * this host object) contract as create_data_processor, including the on_demand - * out_topics contract (object AND number outputs return "//"). + * out_topics contract (see the on_demand paragraph of the service comment). * request->struct_size follows the read-prefix rule on PJ_data_processor_request_t. * ABI-APPENDED slot. * @since 0.36.0 */ diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 5a571fcc..92048a59 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1648,9 +1648,8 @@ namespace detail { /// Builds the ABI request for a DataProcessorRequest, filling `in_abi`/`out_abi` /// with borrowed views into `request`'s owned strings — the caller must keep /// `request`, `in_abi`, and `out_abi` alive for the duration of the ABI call. -/// `struct_size` is the sizeof of the header this plugin was compiled with: correct -/// under the read-prefix rule (the host reads the prefix it knows and accepts a -/// larger struct_size; see PJ_data_processor_request_t). +/// `struct_size` is the sizeof of the header this plugin was compiled with (see the +/// read-prefix rule on PJ_data_processor_request_t). [[nodiscard]] inline PJ_data_processor_request_t toAbiRequest( const DataProcessorRequest& request, std::vector& in_abi, std::vector& out_abi) { @@ -1833,10 +1832,8 @@ class DataProcessorsHostView { /// ":" ("number", "string", or a BuiltinObjectType name such as /// "kPointCloud"/"kSceneEntities") the host needs to route a later on-demand /// evaluation without re-running the script. Returns the resolved output - /// identifiers 1:1 with `typed_outputs`: the catalog path "//" - /// for an object-typed output AND for a number output (the series key of series - /// mode; absent from the catalog when the recipe cannot run in series mode), the - /// bare name for a string output. Inputs MAY + /// identifiers 1:1 with `typed_outputs` (the out_topics contract, see + /// plugin_data_api.h). Inputs MAY /// be dataset-qualified (see create()). For typed outputs, a label, or a /// pinned evaluation time, use createV2() instead. /// @since 0.36.0 @@ -1899,8 +1896,7 @@ class DataProcessorsHostView { /// inputs, no run, no side effects) for the given `kind`. Cheap enough to drive a /// live red/green editor semaphore. Runtime/empty-output errors are NOT caught here — /// use an ephemeral create for that. `language` selects the backend: "luau" everywhere, - /// "python" only for kind "on_demand" and only on hosts that ship it (probe with - /// validateScript("on_demand", "python", "return {}"), never assume it). + /// "python" is optional per host (see the Python note in plugin_data_api.h). /// Errors if the host predates this slot or the language/kind is unknown. [[nodiscard]] Status validateScript( std::string_view kind, std::string_view language, std::string_view script, @@ -1919,16 +1915,11 @@ class DataProcessorsHostView { /// create_data_processor with a typed request: typed outputs, a label, and /// (with `request.instant_ns` set) a pinned evaluation time for an on_demand /// finding. Same upsert, transactional, and resolved-topic-names contract as - /// create(); for kind="on_demand" the names are 1:1 with `request.outputs`: - /// "//" for object AND number outputs (a number output's series - /// key in series mode, absent from the catalog when the recipe cannot run in - /// series mode), the bare name for a string output. Read a number output's - /// series by exactly the returned string. Errors if the host predates this slot. + /// create(); for kind="on_demand" the names are 1:1 with `request.outputs` + /// (the out_topics contract, see plugin_data_api.h). Errors if the host predates this slot. /// - /// struct_size: this wrapper sends sizeof(PJ_data_processor_request_t) of the - /// header it was compiled with. Under the read-prefix rule (the host reads the - /// prefix it knows and accepts a larger struct_size; see - /// PJ_data_processor_request_t) that is correct against any host. + /// struct_size: sizeof(PJ_data_processor_request_t) of the compiled header; correct + /// against any host (read-prefix rule, see PJ_data_processor_request_t). /// @since 0.36.0 [[nodiscard]] Expected> createV2(const DataProcessorRequest& request) const { if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_data_processors_host_vtable_t, host_.vtable, create_data_processor_v2)) { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp index 9eb4db25..36336744 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp @@ -90,21 +90,13 @@ class DialogPluginBase { return host_info_; } - /// Capability bits the host announced through set_host_info (a - /// PJ_DIALOG_HOST_* mask), or 0 when no host info was delivered (pre-0.21 host - /// or non-conforming embedding host). Never a probe of host version: a bit absent - /// here means "do not use that feature". - /// - /// @since 0.36.0 - [[nodiscard]] uint64_t hostCapabilities() const noexcept { - return host_info_.has_value() ? host_info_->capabilities : 0; - } - - /// True iff the host announced `capability`. Same as hostCapabilities() & bit. + /// True iff the host announced `capability` through set_host_info. False when no + /// host info was delivered (pre-0.21 host or non-conforming embedding host). Never + /// a probe of host version: a bit absent here means "do not use that feature". /// /// @since 0.36.0 [[nodiscard]] bool hostHas(DialogHostCapability capability) const noexcept { - return (hostCapabilities() & static_cast(capability)) != 0; + return host_info_.has_value() && host_info_->has(capability); } public: diff --git a/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp index 6f9358f7..633f9dda 100644 --- a/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp +++ b/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp @@ -61,10 +61,6 @@ class HostInfoDialog final : public PJ::DialogPluginBase { return hostInfo(); } - [[nodiscard]] uint64_t observedHostCapabilities() const noexcept { - return hostCapabilities(); - } - [[nodiscard]] bool observedHostHas(PJ::DialogHostCapability capability) const noexcept { return hostHas(capability); } @@ -135,13 +131,12 @@ TEST_F(DialogPluginBaseHostInfoTest, CopiesStringsAndCapabilitiesDuringDelivery) EXPECT_FALSE(info->has(PJ::DialogHostCapability::kCanSelectFolder)); } -TEST_F(DialogPluginBaseHostInfoTest, HostCapabilitiesReportsTheEmbedsSceneViewsBit) { +TEST_F(DialogPluginBaseHostInfoTest, HostHasReportsTheEmbedsSceneViewsBit) { // No delivery (pre-0.21 host, or one that never calls set_host_info): no bits. - EXPECT_EQ(plugin().observedHostCapabilities(), 0u); EXPECT_FALSE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); ASSERT_TRUE(deliver("0.36.0", "4.2.0", PJ_DIALOG_HOST_CAN_OPEN_FILE)); - EXPECT_EQ(plugin().observedHostCapabilities(), static_cast(PJ_DIALOG_HOST_CAN_OPEN_FILE)); + EXPECT_TRUE(plugin().observedHostHas(PJ::DialogHostCapability::kCanOpenFile)); EXPECT_FALSE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); ASSERT_TRUE(deliver("0.36.0", "4.2.0", PJ_DIALOG_HOST_CAN_OPEN_FILE | PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS)); From 9e28198e67b89617f8bb69c431af72a1ab10bea7 Mon Sep 17 00:00:00 2001 From: alvvm Date: Fri, 2 Oct 2026 13:11:56 +0200 Subject: [PATCH 18/22] docs(sdk): ephemeral config visibility rule and chart auto-zoom semantics --- CHANGELOG.md | 7 +++++++ pj_base/include/pj_base/plugin_data_api.h | 10 +++++++--- .../include/pj_plugins/sdk/widget_data.hpp | 10 +++++++--- pj_plugins/docs/dialog-plugin-guide.md | 8 +++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0c9342..575defca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT, PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (floor 0.36.0) +- Document the visibility rule for ephemeral data processors: `list_data_processor_ids` + hides them, but `data_processor_config` by exact id answers the owning plugin + (hosts no longer reject it), so a plugin can read its preview's `series` progress. + Docs only: no ABI change. +- Document the `chart_auto_zoom` semantics (`setChartAutoZoom`): omitted fits until + the user zooms or pans, `true` fits now and resumes auto-fit, `false` keeps the + user's view. Docs only: no ABI change. - Add `WidgetData::setSceneView` / `setSceneTopics` / `clearSceneView` (keys `scene_view`, `scene_topics`) and the matching `WidgetDataView::sceneView` / `sceneTopics`: a QFrame carrying `scene_view` becomes an embedded 3D/2D object diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 94ce6a23..575717ed 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1049,8 +1049,11 @@ typedef struct { * - EPHEMERAL (PJ_DATA_PROCESSOR_FLAG_EPHEMERAL): a preview owned by the plugin * instance that created it. Never persisted; undo/redo and layout load do not * end it; only remove_data_processor(id) or the owning plugin's teardown does. - * Hosts hide previews from list_data_processor_ids and data_processor_config; the - * catalog snapshot may still show their output topics. kinds: all three. + * VISIBILITY: list_data_processor_ids hides previews, but + * data_processor_config by exact id answers the OWNING plugin, ephemeral or not + * (ids are namespaced per plugin), so a plugin can read its own preview's + * progress (e.g. the on_demand "series" block). The catalog snapshot may still + * show their output topics. kinds: all three. * - HISTORY_EXEMPT (PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT): PERSISTED, but the host's * history has no authority over it (never restored, recreated or removed by * undo/redo). Meaningless on an EPHEMERAL node. kinds: all three; every kind's @@ -1233,7 +1236,8 @@ typedef struct PJ_data_processors_host_vtable_t { * Count-then-fill: pass capacity 0 to read *out_count, then call again with a * buffer of that size. On success the first min(capacity, *out_count) entries * of out_ids are filled and point into host storage valid only until the next - * call on this vtable. Ephemeral previews are excluded. */ + * call on this vtable. Ephemeral previews are excluded (data_processor_config + * still answers for them by exact id). */ bool (*list_data_processor_ids)( void* ctx, PJ_string_view_t* out_ids, uint64_t capacity, uint64_t* out_count, PJ_error_t* out_error) PJ_NOEXCEPT; diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index 4c86f554..784a3f2f 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -619,9 +619,13 @@ class WidgetData { return *this; } - /// Auto-fit (zoom-to-extents) the chart inside the named QFrame on every series - /// update when `enabled` is true; when false, preserve the user's current zoom. - /// Mirrors the Transform/Filter editor "AutoZoom" checkbox. + /// Auto-fit semantics for the chart inside the named QFrame: + /// - key omitted: fit on every series update until the user zooms or pans, and + /// refit when the series set is new; + /// - `true`: fit now and resume auto-fitting (discards the user's view); + /// - `false`: keep the user's view; fit only when the series set is new. + /// Send `true` for one update only (e.g. a "Fit" button), not on every tick: that + /// would wipe the user's zoom each time. Mirrors the "AutoZoom" checkbox. WidgetData& setChartAutoZoom(std::string_view name, bool enabled) { entry(name)["chart_auto_zoom"] = enabled; return *this; diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index f3c3e810..e7e66c76 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -505,7 +505,7 @@ work like polling a server for available topics. | QListWidget | `setListItems`, `setSelectedItems` | `onSelectionChanged(name, items)`, `onItemDoubleClicked(name, index)` | | QTableWidget | `setTableHeaders`, `setTableRows` (strings, or `TableItem` for sortable columns), `setTableSortIndicator`, `setSelectedRows`, `setVisibleRows`, `setRowColor`, `setCellTooltip` | `onSelectionChanged(name, items)`, `onHeaderClicked(name, section)` | | QPlainTextEdit | `setPlainText`, `setCodeContent`, `setCodeLanguage`, `setCodeCursor`, `setCodeCaretTracking` | `onCodeChanged(name, code)`, or `onCodeChangedWithCursor(name, code, cursor)` when the editor opts into caret tracking | -| QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | +| QFrame (chart container) | `setChartSeries`, `clearChart`, `setChartZoomEnabled`, `setChartAutoZoom` | `onChartViewChanged(name, x_min, x_max, y_min, y_max)` | | QFrame (embedded object view, since 0.36.0) | `setSceneView(name, "3d"\|"2d")`, `setSceneTopics`, `clearSceneView`; gate on `hostHas(kEmbedsSceneViews)` | (none) | | QDateTimeEdit (incl. QDateEdit/QTimeEdit) | `setDateTime`, `setDateTimeRange` | `onDateTimeChanged(name, iso8601)` | | RangeSlider (two-handle) | `setRangeSliderBounds`, `setRangeSliderValues`, `setRangeSliderTimeSpan` | `onRangeChanged(name, lower, upper)` | @@ -515,6 +515,12 @@ work like polling a server for available topics. | QTreeWidget | `setTreeHeaders`, `setTreeItems`, `setTreeSelectedIds`, `setTreeExpandedIds`, `setTreeVisibleIds`, `clearTreeVisibleIds`, `setTreeSelectionMode` | `onTreeSelectionChanged`, `onTreeItemActivated`, `onTreeExpansionChanged`, `onTreeCheckStateChanged` | | QDialogButtonBox | `setOkEnabled` | (none — host handles OK/Cancel) | +Chart auto-fit: when `setChartAutoZoom` is never called the chart fits on every +series update until the user zooms or pans, and refits when the series set is +new. `setChartAutoZoom(name, true)` fits now and resumes auto-fit, so send it +for one update only (for example from a "Fit" button), never on every tick. +`false` keeps the user's view and fits only on a new series set. + All widgets also support `setEnabled(name, bool)`, `setVisible(name, bool)`, `setDropTarget(name, bool)`, and `setFieldValid(name, ok, tooltip)` (a generic inline valid/invalid indicator the plugin drives). Drop targets receive From 8feaffbc110cc01cd6f118d3795132557978cac8 Mon Sep 17 00:00:00 2001 From: alvvm Date: Mon, 5 Oct 2026 12:25:58 +0200 Subject: [PATCH 19/22] feat(sdk): reserve 16 bytes in PJ_data_processor_output_t --- pj_base/include/pj_base/plugin_data_api.h | 7 +++++-- pj_base/include/pj_base/sdk/plugin_data_api.hpp | 2 +- pj_base/tests/abi_layout_sentinels_test.cpp | 3 ++- pj_base/tests/data_processors_api_test.cpp | 6 ++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 575717ed..19aae69c 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1136,13 +1136,16 @@ typedef struct { #define PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT \ (1u << 1) /* time_ns is meaningful: a pinned finding on create, the instant to evaluate on submit */ -/* A declared output. ARRAY ELEMENT with a FIXED STRIDE (see PJ_object_topic_info_t). +/* A declared output. ARRAY ELEMENT with a FIXED STRIDE: a field that cannot be + * zero-defaulted needs a new struct + slot, never a change of this layout (see + * PJ_object_topic_info_t). `reserved` must be 0. * type: "number" | "string" | a builtin object type name ("kPointCloud") | "" (untyped, * legacy transform/markers output). * @since 0.36.0 */ typedef struct { PJ_string_view_t name; PJ_string_view_t type; + uint64_t reserved[2]; /* 0 */ } PJ_data_processor_output_t; /* Typed request for create_data_processor_v2 and submit_evaluation. @@ -1158,7 +1161,7 @@ typedef struct { * that knows the prefix. * * A host REJECTS (never ignores) a request it cannot honour: unknown flags or - * time_flags bits, nonzero reserved, a count > 0 with a NULL pointer, or struct_size + * time_flags bits, nonzero reserved (in the request or in any output), a count > 0 with a NULL pointer, or struct_size * below the v1 minimum. Inputs use the same grammar as create_data_processor (topic * names, optionally dataset-qualified); the script reads each input under its literal * name. All strings are borrowed for the duration of the call. diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index 92048a59..e8a49477 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -1661,7 +1661,7 @@ namespace detail { out_abi.clear(); out_abi.reserve(request.outputs.size()); for (const auto& output : request.outputs) { - out_abi.push_back(PJ_data_processor_output_t{toAbiString(output.name), toAbiString(output.type)}); + out_abi.push_back(PJ_data_processor_output_t{toAbiString(output.name), toAbiString(output.type), {0, 0}}); } uint32_t time_flags = 0; if (request.window.has_value()) { diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 36e2087a..ca165f16 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -369,9 +369,10 @@ static_assert( static_assert(offsetof(PJ_catalog_snapshot_v2_t, release) == 80, "PJ_catalog_snapshot_v2_t.release offset pinned"); // --- Data-processor typed request vocabulary (introduced 0.36.0) ------------ -static_assert(sizeof(PJ_data_processor_output_t) == 32, "PJ_data_processor_output_t size pinned (fixed stride)"); +static_assert(sizeof(PJ_data_processor_output_t) == 48, "PJ_data_processor_output_t size pinned (fixed stride)"); static_assert(offsetof(PJ_data_processor_output_t, name) == 0, "PJ_data_processor_output_t.name offset pinned"); static_assert(offsetof(PJ_data_processor_output_t, type) == 16, "PJ_data_processor_output_t.type offset pinned"); +static_assert(offsetof(PJ_data_processor_output_t, reserved) == 32, "PJ_data_processor_output_t.reserved offset pinned"); static_assert( sizeof(PJ_data_processor_request_t) == 168, "PJ_data_processor_request_t size pinned at introduction (0.36.0)"); diff --git a/pj_base/tests/data_processors_api_test.cpp b/pj_base/tests/data_processors_api_test.cpp index 7792f04a..3e08ec6c 100644 --- a/pj_base/tests/data_processors_api_test.cpp +++ b/pj_base/tests/data_processors_api_test.cpp @@ -68,6 +68,7 @@ struct FakeDataProcessorsHost { std::string last_request_label; std::vector last_request_inputs; std::vector> last_request_outputs; // (name, type) + bool last_request_outputs_reserved_zero = true; std::vector v2_resolved_storage; // host storage out_topics point into std::string v2_auto_topic = "__on_demand__/__preview__/finding"; @@ -191,7 +192,11 @@ bool dpCreateV2( self->last_request_inputs.emplace_back(sdk::toStringView(request->inputs[i])); } self->last_request_outputs.clear(); + self->last_request_outputs_reserved_zero = true; for (uint64_t i = 0; i < request->output_count; ++i) { + if (request->outputs[i].reserved[0] != 0 || request->outputs[i].reserved[1] != 0) { + self->last_request_outputs_reserved_zero = false; + } self->last_request_outputs.emplace_back( std::string(sdk::toStringView(request->outputs[i].name)), std::string(sdk::toStringView(request->outputs[i].type))); @@ -534,6 +539,7 @@ TEST(DataProcessorsApiTest, CreateV2ForwardsTypedOutputsAndInstant) { ASSERT_EQ(host.last_request_outputs.size(), 1u); EXPECT_EQ(host.last_request_outputs[0].first, "cloud"); EXPECT_EQ(host.last_request_outputs[0].second, "kPointCloud"); + EXPECT_TRUE(host.last_request_outputs_reserved_zero); EXPECT_EQ(host.last_request_time_flags, static_cast(PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT)); EXPECT_EQ(host.last_request_time_ns, 123456789); ASSERT_EQ(topics->size(), 1u); From 24f66e332804a6e18bfdb1efdd67ccffbfaad1ea Mon Sep 17 00:00:00 2001 From: alvvm Date: Mon, 5 Oct 2026 12:26:00 +0200 Subject: [PATCH 20/22] docs(sdk): host-policy limits, deprecate output suffix, unknown-value rule --- CHANGELOG.md | 20 ++++++++++++- pj_base/include/pj_base/plugin_data_api.h | 36 ++++++++++++++++------- pj_plugins/docs/dialog-plugin-guide.md | 5 ++-- pj_plugins/docs/toolbox-guide.md | 2 +- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 575defca..48460b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, PJ_data_processors_host_vtable_t::create_data_processor_v2, PJ_data_processors_host_vtable_t::submit_evaluation, PJ_data_processors_host_vtable_t::poll_evaluation, PJ_data_processors_host_vtable_t::release_evaluation, pj.plot_tabs.v1 tail slots (create_tab_v2, attach_topic, detach_topic, focus_tab), PJ_DATA_PROCESSOR_TIME_FLAG_WINDOW, PJ_DATA_PROCESSOR_TIME_FLAG_INSTANT, PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (floor 0.36.0) +- Add `uint64_t reserved[2]` (must be 0) to `PJ_data_processor_output_t`, which is + an array element with a fixed stride: the struct grows from 32 to 48 bytes. The + C++ wrapper zeroes it; a host rejects an output with nonzero `reserved`. +- Deprecate the `":"` output suffix of `create_data_processor`: it + still works, new clients use `create_data_processor_v2` with + `PJ_data_processor_output_t`; it will be removed in a future version. +- Evaluation budgets and live-handle / report-byte limits are host policy: `0` = + host default, each field is clamped to a host-defined maximum, and a submit is + never rejected for asking more (`coverage.stopped` says which budget ended the + run). The documented default and cap numbers are removed. Docs only. +- Document the unknown-value rule of the evaluation report: an unrecognised string + value in a key the client knows (e.g. `coverage.stopped`, `outputs[].status`) + is an error, never success; `outputs[].type` `"unknown"` is a defined value. + The names inferred by `INFER_OUTPUTS` are chosen by the host and must be read + from the report. Docs only. +- Document the optional `done` / `total` keys of the on_demand `series` block of + `data_processor_config` (instants evaluated / known total); a client tolerates + their absence. Docs only. - Document the visibility rule for ephemeral data processors: `list_data_processor_ids` hides them, but `data_processor_config` by exact id answers the owning plugin (hosts no longer reject it), so a plugin can read its preview's `series` progress. @@ -127,7 +145,7 @@ Host contract: extended: PJ_toolbox_host_vtable_t::acquire_catalog_snapshot_v2, - Add the dialog host capability `PJ_DIALOG_HOST_EMBEDS_SCENE_VIEWS` (`1 << 5`, C++ `DialogHostCapability::kEmbedsSceneViews`): the host embeds `scene_view` / `scene_topics` frames. `DialogPluginBase` (and so - `DialogPluginTyped`) gains `hostCapabilities()` and `hostHas(capability)` + `DialogPluginTyped`) gains `hostHas(capability)` over the host info already delivered by the existing `set_host_info` slot; a host that never delivers it reports 0. A dialog gates its scene UI on the bit instead of probing a different service. No ABI change. diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 19aae69c..c41e5e03 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -984,10 +984,12 @@ typedef struct { * materialize for the whole series. The request's ABI surface is * create_data_processor_v2 / submit_evaluation / poll_evaluation / * release_evaluation (PJ_data_processor_request_t, PJ_evaluation_budget_t): - * create_data_processor (v1) stays valid to install an on_demand node using the + * create_data_processor (v1) can still install an on_demand node using the * ":" output-suffix grammar below, but reading a result requires the - * v2 evaluation surface. Each `outputs` entry carries a type suffix - * ":", where is "number", "string", or a BuiltinObjectType + * v2 evaluation surface. DEPRECATED: the ":" suffix still works, but + * new clients use create_data_processor_v2 with PJ_data_processor_output_t; the + * suffix will be removed in a future version. In that grammar each `outputs` + * entry carries a type suffix ":", where is "number", "string", or a BuiltinObjectType * name (e.g. "kPointCloud", "kSceneEntities") — the host needs the declared shape * up front to route a later on-demand evaluation without re-running the script. * out_topics returns, 1:1 with outputs, the catalog path of each output: @@ -1124,6 +1126,8 @@ typedef struct { * named by type ("value", "value_2", ..., "text", "cloud", "scene", "annotations", * "image", "transforms", "object"). Mixed keyed+positional or empty returns are * errors. The inferred list is returned in the report ("outputs"). + * The names are chosen by the host; read them from the report's outputs, never + * assume them (the list above is the current host behaviour, not a contract). * - create/create_v2: the declared outputs are a binding hint learned from such a * trial. The host still validates the returned types at runtime. * A host that does not know this bit rejects it as an unknown flag. @@ -1188,7 +1192,9 @@ typedef struct { /* Cooperative computation budget of one submit_evaluation. Checked between * evaluations and inside the host's native operations, NOT a wall-clock guarantee: - * one script or native call may overrun it. 0 = host default. + * one script or native call may overrun it. 0 = host default. Each field is clamped + * to a host-defined maximum (host policy); a submit is never rejected for asking + * more. coverage.stopped reports which budget ended an evaluation. * struct_size follows the same read-prefix rule as PJ_data_processor_request_t: the * host rejects a struct_size below the v1 layout (offsetof(max_report_bytes) + * sizeof(max_report_bytes)), accepts a larger one and reads only the prefix it knows. @@ -1197,10 +1203,10 @@ typedef struct { typedef struct { uint32_t struct_size; /* = sizeof(PJ_evaluation_budget_t) */ uint32_t reserved; /* 0 */ - uint64_t max_millis; /* default 1000, host cap 5000 */ + uint64_t max_millis; /* cooperative time budget, ms */ uint64_t max_bytes; /* per-evaluation VM + native ceiling */ - uint64_t max_evaluations; /* WINDOW: instants evaluated; default 200, host cap 2000 */ - uint64_t max_report_bytes; /* whole report; default 1 MiB, host cap 8 MiB */ + uint64_t max_evaluations; /* WINDOW: instants evaluated */ + uint64_t max_report_bytes; /* whole report */ } PJ_evaluation_budget_t; /* poll_evaluation states @@ -1246,7 +1252,11 @@ typedef struct PJ_data_processors_host_vtable_t { /* [main-thread] Read a node's full recipe as JSON * {"kind":"...","language":"...","inputs":[...],"outputs":[...],"params":{...}} for - * re-edit (e.g. after a session reload). *out_recipe_json is borrowed, valid only + * re-edit (e.g. after a session reload). An on_demand node that runs in series + * mode also reports "series":{"topic","rows","failed","first_error","complete", + * "done","total"}; "done" (instants already evaluated) and "total" (instants known + * to be evaluated; may grow with live data) are OPTIONAL: absent before the first + * step, and a client must tolerate their absence. *out_recipe_json is borrowed, valid only * until the next call on this vtable. An unknown id is an error. * Hosts supporting PJ_DATA_PROCESSOR_FLAG_HISTORY_EXEMPT include the boolean * "history_exempt" for EVERY kind (transform, markers, on_demand), reflecting the @@ -1308,10 +1318,14 @@ typedef struct PJ_data_processors_host_vtable_t { * "outputs":[{"name":"...","type":"number"|"string"|""| * "unknown"}], the inferred outputs in report order. Every *_ns value is a * raw dataset nanosecond count as a JSON integer (int64; do not round-trip through - * a double). On FAILED *out_json is {"error":"..."}. *out_json is borrowed until + * a double). Unknown keys are ignored, but a string value a client does not + * recognise in a key it knows (e.g. coverage.stopped, outputs[].status) must be + * treated as an error, never as success; a host emits a new value only for a + * request that opted into it. Exception: outputs[].type "unknown" is a defined + * value. On FAILED *out_json is {"error":"..."}. *out_json is borrowed until * release_evaluation(handle). An unknown handle is an error. ABI-APPENDED slot. - * Limits: a host keeps at most 64 live handles and at most 64 MiB of reserved - * report bytes across them; a submit beyond either is an error until completed + * Limits: a host bounds the number of live handles and the reserved report + * bytes (host policy); a submit beyond either bound is an error until completed * handles are released. The host completes a background evaluation from its own * event loop: a caller must return to that loop between polls (poll from a timer, * never a busy loop on the calling thread) or the evaluation never finishes. A state diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index e7e66c76..90614c7b 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -190,9 +190,8 @@ Available `DialogHostCapability` values are: | `kStagesBrowserFile` | A browser-selected file is staged to a host-accessible path before delivery. | | `kEmbedsSceneViews` (0.36.0) | The host turns a QFrame carrying `scene_view` / `scene_topics` into an embedded 3D/2D object view. | -`hostCapabilities()` returns the raw mask (0 when nothing was delivered) and -`hostHas(DialogHostCapability)` tests one bit; both are protected, like -`hostInfo()`. This is the way to detect a dialog-protocol feature: a bit you do +`hostHas(DialogHostCapability)` tests one bit (false when nothing was +delivered); it is protected, like `hostInfo()`. This is the way to detect a dialog-protocol feature: a bit you do not see means "do not use it" (capability rule 4 in `pj_base/plugin_data_api.h`). Embedded scene views exist in panels (non-modal toolbox dialogs) only, and a failed attach of a topic is not retried until the requested topic set changes. diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index a52e9ebe..615ad36d 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -305,7 +305,7 @@ feature: | ABI service feature (tail slots) | One named `hasX()` on the C++ view; never a version string | `DataProcessorsHostView::hasTypedRequests()`, `PlotTabHostView::hasSceneTabs()`, `ToolboxHostView::hasCatalogSnapshotV2()` | | Flag-bit feature | No probe: an older host REJECTS an unknown bit, the call fails loudly. The floor is the `hasX()` of the slot that carries it | `PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS` (floor: `hasTypedRequests()`) | | Build-dependent behaviour | Probe by doing it | Python for on_demand: `validateScript("on_demand", "python", "return {}")`; a WASM host rejects it | -| Dialog-protocol feature | A bit of `PJ_dialog_host_info_t::capabilities`, read with `hostHas()` / `hostCapabilities()` | `kEmbedsSceneViews` for `scene_view` / `scene_topics` | +| Dialog-protocol feature | A bit of `PJ_dialog_host_info_t::capabilities`, read with `hostHas()` | `kEmbedsSceneViews` for `scene_view` / `scene_topics` | | Manifest metadata | Declarative, no probe; hosts ignore keys they do not know | `badge`, `custom_topics_editor` | When a plugin cannot find an input's type because the host has no snapshot v2, From 9a389bdcd883420e363b7ececd84d9f7861a4be9 Mon Sep 17 00:00:00 2001 From: alvvm Date: Mon, 5 Oct 2026 14:36:27 +0200 Subject: [PATCH 21/22] refactor(sdk): name the v1 minimum struct_size of the data-processor request and evaluation budget --- pj_base/include/pj_base/plugin_data_api.h | 10 +++++++--- pj_base/tests/abi_layout_sentinels_test.cpp | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index c41e5e03..aee09f1f 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -1156,7 +1156,7 @@ typedef struct { * * struct_size RULE (read-prefix): the host reads the PREFIX of the struct that it * knows and ACCEPTS a struct_size larger than its own sizeof. The v1 minimum is - * offsetof(time_ns) + sizeof(time_ns); a smaller struct_size is rejected. A field + * PJ_DATA_PROCESSOR_REQUEST_V1_MIN_SIZE; a smaller struct_size is rejected. A field * appended in a later SDK release must be zero-defaultable AND announced by a new * flags/time_flags bit, so an older host rejects the unknown BIT -- never the size. * A request that uses no new field therefore keeps working on every host. @@ -1190,14 +1190,16 @@ typedef struct { int64_t time_ns; /* raw ns; INSTANT */ } PJ_data_processor_request_t; +#define PJ_DATA_PROCESSOR_REQUEST_V1_MIN_SIZE (offsetof(PJ_data_processor_request_t, time_ns) + sizeof(int64_t)) + /* Cooperative computation budget of one submit_evaluation. Checked between * evaluations and inside the host's native operations, NOT a wall-clock guarantee: * one script or native call may overrun it. 0 = host default. Each field is clamped * to a host-defined maximum (host policy); a submit is never rejected for asking * more. coverage.stopped reports which budget ended an evaluation. * struct_size follows the same read-prefix rule as PJ_data_processor_request_t: the - * host rejects a struct_size below the v1 layout (offsetof(max_report_bytes) + - * sizeof(max_report_bytes)), accepts a larger one and reads only the prefix it knows. + * host rejects a struct_size below PJ_EVALUATION_BUDGET_V1_MIN_SIZE, accepts a larger + * one and reads only the prefix it knows. * A budget field added later must be zero-defaultable (0 = host default). * @since 0.36.0 */ typedef struct { @@ -1209,6 +1211,8 @@ typedef struct { uint64_t max_report_bytes; /* whole report */ } PJ_evaluation_budget_t; +#define PJ_EVALUATION_BUDGET_V1_MIN_SIZE (offsetof(PJ_evaluation_budget_t, max_report_bytes) + sizeof(uint64_t)) + /* poll_evaluation states * @since 0.36.0 */ #define PJ_EVALUATION_STATE_PENDING 0u diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index ca165f16..0803a1a6 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -391,6 +391,8 @@ static_assert(sizeof(PJ_evaluation_budget_t) == 40, "PJ_evaluation_budget_t size static_assert(offsetof(PJ_evaluation_budget_t, struct_size) == 0, "PJ_evaluation_budget_t.struct_size offset pinned"); static_assert( offsetof(PJ_evaluation_budget_t, max_report_bytes) == 32, "PJ_evaluation_budget_t.max_report_bytes offset pinned"); +static_assert(PJ_DATA_PROCESSOR_REQUEST_V1_MIN_SIZE == 168, "request v1 minimum size pinned"); +static_assert(PJ_EVALUATION_BUDGET_V1_MIN_SIZE == 40, "evaluation budget v1 minimum size pinned"); // --- Plot-tab host vtable ("pj.plot_tabs.v1", ABI-APPENDABLE) --------------- // Seven v1 slots are released and frozen; create_tab_v2 onward are tail slots (0.36.0). From b40bf629bcb3d7f518406def39f9fa8519a0fb00 Mon Sep 17 00:00:00 2001 From: alvvm Date: Thu, 8 Oct 2026 18:26:41 +0200 Subject: [PATCH 22/22] style(tests): wrap the PJ_data_processor_output_t reserved-offset sentinel to 120 columns --- pj_base/tests/abi_layout_sentinels_test.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 0803a1a6..f2c97186 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -372,7 +372,8 @@ static_assert(offsetof(PJ_catalog_snapshot_v2_t, release) == 80, "PJ_catalog_sna static_assert(sizeof(PJ_data_processor_output_t) == 48, "PJ_data_processor_output_t size pinned (fixed stride)"); static_assert(offsetof(PJ_data_processor_output_t, name) == 0, "PJ_data_processor_output_t.name offset pinned"); static_assert(offsetof(PJ_data_processor_output_t, type) == 16, "PJ_data_processor_output_t.type offset pinned"); -static_assert(offsetof(PJ_data_processor_output_t, reserved) == 32, "PJ_data_processor_output_t.reserved offset pinned"); +static_assert( + offsetof(PJ_data_processor_output_t, reserved) == 32, "PJ_data_processor_output_t.reserved offset pinned"); static_assert( sizeof(PJ_data_processor_request_t) == 168, "PJ_data_processor_request_t size pinned at introduction (0.36.0)");