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 7e97156e..48460b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,180 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [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, 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. + 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 + 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 + `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 + 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` 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 (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`; + 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 + `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 `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 + 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 + 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. +- 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. +- 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 `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`. +- 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. + ## [0.35.0] Host contract: unchanged (no floor impact) — a client-side codec helper, no new diff --git a/VERSION b/VERSION index 7b52f5e5..93d4c1ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.0 +0.36.0 diff --git a/docs/builtin_type.md b/docs/builtin_type.md index 7c212713..09cf0331 100644 --- a/docs/builtin_type.md +++ b/docs/builtin_type.md @@ -673,6 +673,56 @@ 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`, `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`; +`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 +rather than a channel list (see each header's doc comment for how a raw vs. +compressed encoding differs). `CameraInfo`'s `K`/`R`/`P` (and `DepthImage`'s +`K`) are `std::array` members, described as a fixed-size `kList` +written through `list_replace` rather than `list_emplace`/`list_clear` (the +element count never changes); `Image::compressed_depth_min`/ +`compressed_depth_max` are `kOptionalNumber` — a nullable number read/written +like `kNumber` but guarded by `has_value()`. A field table is hand-maintained +alongside its struct: adding, renaming, or retyping a member requires updating the matching +`FieldTable` specialization in the same change, or the table silently +drifts from the struct it claims to describe. `pj_base/tests/field_table_test.cpp` +enforces this — its generic copy-through-table round trip is compared against +the struct's own `operator==` and its canonical wire-codec bytes, so a member +the table forgot to list shows up as a round-trip mismatch rather than a +silent gap. + +An object topic carrying a field-tabled type can mark itself with the +`pj_snapshot` object-topic metadata key (`PJ::sdk::kSnapshotMetadataKey`, +`ObjectTopicMetadataBuilder::snapshot()`, SDK 0.36.0). Value `"true"` means +every entry on the topic is a complete clear-and-replace snapshot — a +generic field-table consumer may render each entry alone, without replaying +the topic's history to reconstruct state first. This matters for +SceneEntities/ImageAnnotations producers in particular, whose entries can +otherwise be incremental (add/update/delete deltas); a producer that instead +republishes its whole set each time sets this flag so a stateless consumer +(and `set_object_topic_retention(topic, 1)`, which keeps only the latest +snapshot) can treat each entry as the full picture. + ## Conversion Examples | Source type | Canonical builtin type | Conversion intent | diff --git a/docs/image_annotations_format.md b/docs/image_annotations_format.md index 0fd54634..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/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/feature_floors.json b/pj_base/feature_floors.json index 70d5dd84..0f76123c 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -14,6 +14,20 @@ }, "0.34.0": { "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", + "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", + "detachTopic": "PJ_plot_tab_host_vtable_t::detach_topic", + "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" } }, "baseline": [ 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/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/include/pj_base/builtin/field_table.hpp b/pj_base/include/pj_base/builtin/field_table.hpp new file mode 100644 index 00000000..9c0a7d69 --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table.hpp @@ -0,0 +1,445 @@ +/** + * @file field_table.hpp + * @brief Compile-time field tables describing the members of builtin object + * structs, so a generic binder (e.g. a script engine) can read/write + * any described field by name without per-type glue code. + * + * `FieldTable` is a template specialized once per describable struct + * (see `frame_transforms_fields.hpp`, `image_annotations_fields.hpp`). Each + * specialization lists its members as `FieldDescriptor`s built with + * `field<&T::member>("member")`, which deduces the member's `FieldKind` and + * generates non-capturing-lambda accessors from the member pointer alone — + * no per-field boilerplate at the call site. `field_table_registry.hpp` + * exposes `describe(BuiltinObjectType)` to look up a table from the runtime + * tag carried by a `BuiltinObject`. + * + * A table is a flat `Span`; nested structs and lists of + * described structs link to their own table through `FieldDescriptor::nested`, + * so a consumer walks an arbitrarily deep struct tree with one generic + * recursive routine (see `field_table_test.cpp` for the pattern). A packed + * record buffer (e.g. `PointCloud::data`) does not fit `field<>()`'s + * one-member-at-a-time shape — it is derived from several sibling members + * (bytes, stride, dimensions, channel layout) — so a buffer-bearing type + * instead provides its own `kBuffer` descriptor factory built directly + * against `FieldDescriptor` (e.g. `PointCloud`'s `pointCloudDataField()` in + * `point_cloud_fields.hpp`), resolving a `BufferLayout` on demand. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/buffer_anchor.hpp" +#include "pj_base/span.hpp" + +namespace PJ::sdk { + +/// Runtime-visible shape of one field, driving which `FieldDescriptor` +/// accessors are populated. +enum class FieldKind : uint8_t { + kNumber, ///< Arithmetic member (not bool/int64/enum) — read/written as `double`. + kBool, ///< `bool` member — read/written as `double` (0.0/1.0). + kInt64, ///< `int64_t`/`uint64_t`/`Timestamp` member — read/written as `int64_t`, never `double` + ///< (a 53+ bit timestamp silently loses precision as a double). + kString, ///< `std::string` member. + kEnum, ///< `enum`/`enum class` member — read/written as its underlying integer via `double`. + kStruct, ///< Member whose type has its own `FieldTable` specialization; see `nested`. + kList, ///< `std::vector` (growable; see `list_emplace`/`list_clear`) or `std::array` + ///< (fixed-size; see `list_replace`) member; see `element_kind`/`nested` for the element shape. + kBuffer, ///< Raw record buffer (e.g. `PointCloud::data`), resolved through `BufferLayout`. A concrete + ///< describable type provides its own `kBuffer` descriptor factory (see `BufferLayout`'s doc comment). + kOptionalNumber, ///< `std::optional` member (e.g. `Image::compressed_depth_min`) — a + ///< nullable number. Presence is read through `has_value()`; the value itself, when + ///< present, is read/written as `double` through `get_number`/`set_number`, exactly + ///< like `kNumber`. Calling `get_number`/`set_number` when absent is only meaningful + ///< after a caller has checked `has_value()`; `set_number` also makes the field present. +}; + +/// Resolved view of a `kBuffer` field: a byte-packed record buffer plus a +/// PointField-style channel layout for interpreting each record. Built +/// on-demand by `FieldDescriptor::buffer()` — a fresh value each call, not a +/// pointer into a stored layout — so `channels` owns a small vector rather +/// than viewing one (`Channel`'s shape deliberately differs from the owner's +/// own channel-description type, e.g. `PointField`, so the two are not +/// safely alias-able through a reinterpreted span). Resolve once per object +/// and iterate its records from that one `BufferLayout`; `buffer()` +/// allocates the channel vector on every call, so calling it per-record is +/// wasteful. +struct BufferLayout { + /// Raw record bytes: `record_count` records of `record_step` bytes each + /// (or row-strided by `row_step` when `row_step != 0`). + Span bytes; + /// Byte length of one record. + uint32_t record_step = 0; + /// Number of records packed into `bytes`. + uint64_t record_count = 0; + /// Byte length of one row when records are organized in rows; 0 when flat. + uint32_t row_step = 0; + /// True if multi-byte values inside `bytes` are big-endian. + bool is_bigendian = false; + + /// One named, typed channel packed at a fixed offset within every record. + struct Channel { + /// Channel name (e.g. "x", "intensity"). Views into the owner's own + /// channel-description member (e.g. `PointCloud::fields[i].name`), so it + /// is valid exactly as long as that owner is. + std::string_view name; + /// Byte offset of this channel within one record. + uint32_t offset = 0; + /// Wire datatype tag; codec-defined (mirrors PointField datatype values). + uint8_t datatype = 0; + /// Number of consecutive `datatype` values packed at `offset`. + uint32_t count = 1; + }; + /// Layout of every channel packed into one record of `bytes`, freshly + /// built by this call (owned, not a view — resolving it does one small + /// allocation, proportional to channel count, e.g. 4 for an XYZI cloud). + std::vector channels; +}; + +/// One field of a `FieldTable`: its name, shape, and type-erased accessors. +/// Every accessor not applicable to `kind` is `nullptr` — a consumer +/// switches on `kind` (or `element_kind` inside a `kList`) and calls only +/// the accessor group that kind documents. +struct FieldDescriptor { + /// Field name as declared in the owning struct (e.g. "translation"). + std::string_view name; + /// Shape of this field; selects which accessor group below is non-null. + FieldKind kind = FieldKind::kNumber; + /// For `kind == kList`: shape of one element. When `nested != nullptr` the + /// element is a struct and this is always `kStruct` — a consumer that + /// needs to know "struct element or scalar element" may branch on + /// `nested` first and only consult `element_kind` for the scalar case. + /// Unused (left at its default) for every other `kind`. + FieldKind element_kind = FieldKind::kNumber; + /// For `kind == kStruct`: the member type's own table. + /// For `kind == kList`: the element type's table, or `nullptr` for a scalar list. + const struct FieldTableView* nested = nullptr; + + /// Read/write a `kNumber`, `kBool`, `kEnum`, or `kOptionalNumber` field as + /// `double`. For `kOptionalNumber`, meaningful only when `has_value()` is + /// true; `set_number` also makes the field present. + double (*get_number)(const void*) = nullptr; + void (*set_number)(void*, double) = nullptr; + + /// For `kind == kOptionalNumber`: true iff the field currently holds a + /// value. `nullptr` for every other `kind`. + bool (*has_value)(const void*) = nullptr; + + /// Read/write a `kInt64` field. An underlying `uint64_t` round-trips through + /// `int64_t` via `std::bit_cast` (same 8 bytes, reinterpreted) rather than a + /// narrowing numeric conversion, so every bit pattern survives exactly. + int64_t (*get_int64)(const void*) = nullptr; + void (*set_int64)(void*, int64_t) = nullptr; + + /// Read/write a `kString` field. + std::string_view (*get_string)(const void*) = nullptr; + void (*set_string)(void*, std::string_view) = nullptr; + + /// For `kind == kStruct`: address the nested struct within the owner. + const void* (*struct_ptr)(const void*) = nullptr; + void* (*struct_ptr_mut)(void*) = nullptr; + + /// For `kind == kList`: element count and element address — populated for + /// both a growable list (`std::vector`) and a fixed-size one + /// (`std::array`). + size_t (*list_size)(const void*) = nullptr; + const void* (*list_at)(const void*, size_t) = nullptr; + + /// For `kind == kList` backed by a growable `std::vector`: append-one + /// (returns the address of the newly appended element) and + /// truncate-to-empty. `nullptr` for a fixed-size list — use `list_replace` + /// instead. + void* (*list_emplace)(void*) = nullptr; + void (*list_clear)(void*) = nullptr; + + /// For `kind == kList` backed by a fixed-size `std::array` (e.g. + /// `CameraInfo::K`): overwrites element `i` in place and returns its + /// address, `i` in `[0, list_size)`. `nullptr` for a growable list — use + /// `list_emplace`/`list_clear` instead. The element count never changes + /// for such a field, so "append" and "truncate" don't apply; a generic + /// consumer branches on which of the two accessor pairs is non-null to + /// choose how to write the list (see `field_table_test.cpp`'s + /// `copyThroughTable` for the pattern). + void* (*list_replace)(void*, size_t) = nullptr; + + /// For `kind == kBuffer`: resolve the current buffer, or replace it + /// (taking ownership of the bytes and re-anchoring them). See + /// `BufferLayout`'s doc comment and a concrete factory such as + /// `pointCloudDataField()` (`point_cloud_fields.hpp`). + BufferLayout (*buffer)(const void*) = nullptr; + void (*buffer_assign)(void*, std::vector) = nullptr; +}; + +/// A described struct's field list plus its name, for diagnostics. +struct FieldTableView { + /// The described struct's type name (e.g. "FrameTransform"). + std::string_view type_name; + /// Every described member, in declaration order. + Span fields; +}; + +/// Per-type registry of field descriptors. Specialized once per describable +/// struct as: +/// ```cpp +/// template <> struct FieldTable { +/// static constexpr std::array fields{ +/// field<&Vector2::x>("x"), field<&Vector2::y>("y")}; +/// static constexpr FieldTableView view{"Vector2", Span(fields)}; +/// }; +/// ``` +/// The primary template is declared only, never defined: `HasFieldTable` +/// detects "no specialization exists for T" through that incompleteness. +template +struct FieldTable; + +namespace detail { + +/// Splits a data-member-pointer type `T C::*` into its owner (`C`) and +/// value (`T`) types, for deducing both from a `field<&C::member>` NTTP. +template +struct MemberPointerTraits; + +template +struct MemberPointerTraits { + using Owner = C; + using Value = T; +}; + +template +struct IsVector : std::false_type {}; +template +struct IsVector> : std::true_type {}; +template +inline constexpr bool kIsVector = IsVector::value; + +/// Detects `std::array` (any `E`, any `N`), for `field<>()`'s +/// fixed-size-list branch (e.g. `CameraInfo::K`). +template +struct IsArray : std::false_type {}; +template +struct IsArray> : std::true_type {}; +template +inline constexpr bool kIsArray = IsArray::value; + +/// Detects `std::optional`, for `field<>()`'s `kOptionalNumber` branch. +template +struct IsOptional : std::false_type {}; +template +struct IsOptional> : std::true_type {}; +template +inline constexpr bool kIsOptional = IsOptional::value; + +/// Always-false, but dependent on `T` — lets a `static_assert` inside the +/// final branch of an `if constexpr` chain fire only when instantiated, +/// instead of unconditionally. +template +inline constexpr bool kAlwaysFalse = false; + +/// True for every scalar type `setScalarAccessors` below knows how to +/// describe: `bool`/other arithmetic (covers `int64_t`/`uint64_t` too, +/// handled as a distinct `FieldKind` inside `setScalarAccessors`), +/// `enum`/`enum class`, and `std::string`. +template +inline constexpr bool kIsSupportedScalar = + std::is_arithmetic_v || std::is_enum_v || std::is_same_v; + +/// 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 (growable; see `list_emplace`/`list_clear`), +/// with `nested`/`element_kind` set from `E` the same way (kStruct + +/// nested table when `HasFieldTable`, otherwise the scalar +/// `FieldKind` of `E`). For a scalar `E`, the matching get_*/set_* pair +/// is ALSO populated, but operating on an ELEMENT address (as returned +/// by `list_at`/`list_emplace`), not on `owner->*Member` — the same +/// accessor a struct field of kind `E` would carry, repurposed for the +/// list's element type. +/// - `std::array` -> kList (fixed-size; see `list_replace`), `E` +/// restricted to a scalar (no struct-element fixed arrays today). Reads +/// the same way as a `std::vector` list (`list_size`/`list_at`); the +/// element count never changes, so writes go through `list_replace` +/// instead of `list_emplace`/`list_clear`. +/// - `std::optional` (`E` a non-bool arithmetic type) -> kOptionalNumber, +/// a nullable number: `has_value()` reports presence, `get_number`/ +/// `set_number` read/write the value like `kNumber` when present. +/// Any other member type fails to compile with a `static_assert`. +template +constexpr FieldDescriptor field(std::string_view name) { + using MemberPtr = decltype(Member); + using Owner = typename detail::MemberPointerTraits::Owner; + using Value = typename detail::MemberPointerTraits::Value; + + FieldDescriptor d{}; + d.name = name; + + if constexpr (detail::kIsSupportedScalar) { + d.kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsVector) { + using Element = typename Value::value_type; + d.kind = FieldKind::kList; + d.list_size = [](const void* p) -> size_t { return (static_cast(p)->*Member).size(); }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_emplace = [](void* p) -> void* { + auto& vec = static_cast(p)->*Member; + vec.emplace_back(); + return &vec.back(); + }; + d.list_clear = [](void* p) { (static_cast(p)->*Member).clear(); }; + + // A struct element links to its own table; a scalar element instead gets + // the matching get_*/set_* pair (see setScalarAccessors's doc comment), + // operating on an ELEMENT address (as returned by list_at/list_emplace) + // rather than on `owner->*Member`. Either way a generic consumer copies + // the list the same way: list_clear + list_emplace, then either recurse + // through `nested` or call the matching set_* on the element address. + if constexpr (HasFieldTable) { + d.element_kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + } else if constexpr (detail::kIsSupportedScalar) { + d.element_kind = detail::setScalarAccessors>(d); + } else { + static_assert(detail::kAlwaysFalse, "field: unsupported std::vector element type"); + } + } else if constexpr (detail::kIsArray) { + using Element = typename Value::value_type; + static_assert(detail::kIsSupportedScalar, "field: unsupported std::array element type"); + constexpr size_t kSize = std::tuple_size_v; + d.kind = FieldKind::kList; + d.list_size = [](const void*) -> size_t { return kSize; }; + d.list_at = [](const void* p, size_t i) -> const void* { return &(static_cast(p)->*Member)[i]; }; + d.list_replace = [](void* p, size_t i) -> void* { return &(static_cast(p)->*Member)[i]; }; + // Same scalar accessor pair a std::vector list would carry + // (see field<>()'s std::vector branch), operating on an element address. + d.element_kind = detail::setScalarAccessors>(d); + } else if constexpr (detail::kIsOptional) { + using Inner = typename Value::value_type; + static_assert( + std::is_arithmetic_v && !std::is_same_v, + "field: unsupported std::optional value type (must be non-bool arithmetic)"); + d.kind = FieldKind::kOptionalNumber; + d.has_value = [](const void* p) -> bool { return (static_cast(p)->*Member).has_value(); }; + d.get_number = [](const void* p) -> double { + return static_cast(*(static_cast(p)->*Member)); + }; + d.set_number = [](void* p, double v) { (static_cast(p)->*Member) = static_cast(v); }; + } else if constexpr (HasFieldTable) { + d.kind = FieldKind::kStruct; + d.nested = &FieldTable::view; + d.struct_ptr = [](const void* p) -> const void* { return &(static_cast(p)->*Member); }; + d.struct_ptr_mut = [](void* p) -> void* { return &(static_cast(p)->*Member); }; + } else { + static_assert( + detail::kAlwaysFalse, + "field: unsupported member type (no FieldTable and not a recognized scalar/vector/array/optional)"); + } + + return d; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/field_table_registry.hpp b/pj_base/include/pj_base/builtin/field_table_registry.hpp new file mode 100644 index 00000000..81d034ec --- /dev/null +++ b/pj_base/include/pj_base/builtin/field_table_registry.hpp @@ -0,0 +1,66 @@ +/** + * @file field_table_registry.hpp + * @brief Looks up a builtin struct's `FieldTable` from its runtime + * `BuiltinObjectType` tag, so a generic binder can go straight from + * a `BuiltinObject` to its field descriptors without a `switch` of + * its own. + */ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include "pj_base/builtin/builtin_object.hpp" +#include "pj_base/builtin/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace PJ::sdk { + +/// Returns the field table for @p type, or `nullptr` for a type that has none +/// (buffer-only or not yet described) and for `kNone`. Every enumerator is +/// listed without a `default:` so `-Wswitch` flags a new `BuiltinObjectType` +/// until this function decides what it describes. +[[nodiscard]] inline const FieldTableView* describe(BuiltinObjectType type) noexcept { + switch (type) { + case BuiltinObjectType::kFrameTransforms: + return &FieldTable::view; + case BuiltinObjectType::kImageAnnotations: + return &FieldTable::view; + case BuiltinObjectType::kPointCloud: + return &FieldTable::view; + case BuiltinObjectType::kSceneEntities: + return &FieldTable::view; + case BuiltinObjectType::kImage: + return &FieldTable::view; + case BuiltinObjectType::kDepthImage: + return &FieldTable::view; + case BuiltinObjectType::kCameraInfo: + return &FieldTable::view; + case BuiltinObjectType::kVideoFrame: + return &FieldTable::view; + case BuiltinObjectType::kNone: + case BuiltinObjectType::kOccupancyGrid: + case BuiltinObjectType::kCompressedPointCloud: + case BuiltinObjectType::kMesh3D: + case BuiltinObjectType::kRobotDescription: + case BuiltinObjectType::kOccupancyGridUpdate: + case BuiltinObjectType::kLog: + case BuiltinObjectType::kPosesInFrame: + case BuiltinObjectType::kVoxelGrid: + case BuiltinObjectType::kPlotMarkers: + case BuiltinObjectType::kGridMap: + return nullptr; + } + return nullptr; +} + +} // namespace PJ::sdk diff --git a/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp b/pj_base/include/pj_base/builtin/frame_transforms_fields.hpp new file mode 100644 index 00000000..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/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/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/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/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 5ee65d51..aee09f1f 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 + * 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. + */ + typedef enum { PJ_PRIMITIVE_TYPE_FLOAT32 = 0, PJ_PRIMITIVE_TYPE_FLOAT64 = 1, @@ -363,6 +386,42 @@ 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. + * @since 0.36.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. + * @since 0.36.0 */ +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 +713,17 @@ 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). + * 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) + PJ_NOEXCEPT; } PJ_toolbox_host_vtable_t; typedef struct { @@ -908,6 +978,33 @@ 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. 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) can still install an on_demand node using the + * ":" output-suffix grammar below, but reading a result requires the + * 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: + * - 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` 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. * @@ -918,8 +1015,10 @@ 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"). - * - language : script backend, "luau" today; the host rejects anything else. + * - kind : output discriminator, see above ("transform", "markers", "on_demand"). + * - 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 @@ -942,7 +1041,40 @@ 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. + * + * 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. + * 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 + * 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 @@ -985,6 +1117,108 @@ 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"). + * 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. + * @since 0.36.0 */ +#define PJ_DATA_PROCESSOR_FLAG_INFER_OUTPUTS (1u << 2) + +/* 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: 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. + * + * 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 + * 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. + * 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 (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. + * @since 0.36.0 */ +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; + +#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 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 { + uint32_t struct_size; /* = sizeof(PJ_evaluation_budget_t) */ + uint32_t reserved; /* 0 */ + uint64_t max_millis; /* cooperative time budget, ms */ + uint64_t max_bytes; /* per-evaluation VM + native ceiling */ + uint64_t max_evaluations; /* WINDOW: instants evaluated */ + 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 +#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 @@ -998,7 +1232,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)( @@ -1014,17 +1249,22 @@ 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; /* [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 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; @@ -1038,6 +1278,71 @@ 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, including the on_demand + * 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 */ + 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. + * @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; + + /* [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","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 + * 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). 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 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 + * value outside PJ_EVALUATION_STATE_* is not "pending": the C++ wrapper reports it + * 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. + * @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; typedef struct { @@ -1232,6 +1537,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. */ @@ -1291,8 +1615,53 @@ 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; + + /* [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'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. + * @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; + + /* [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. 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". + * @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; + + /* [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". + * @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. + * @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; +/* 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_plot_tab_host_vtable_t* vtable; 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..2431c0ed 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,29 @@ 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.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 @@ -64,6 +87,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.36.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 a1df513c..e8a49477 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,66 @@ 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; + 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 +1321,40 @@ 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. 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 (!hasCatalogSnapshotV2()) { + 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,15 +1591,117 @@ 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. +/// @since 0.36.0 +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. +/// @since 0.36.0 +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". +/// @since 0.36.0 +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_*). +/// @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; +}; + +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 (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) { + 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), {0, 0}}); + } + 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 /// 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; @@ -1515,6 +1711,17 @@ class DataProcessorsHostView { return host_.vtable != nullptr && host_.ctx != nullptr; } + /// 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) && + 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); @@ -1618,6 +1825,24 @@ 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). 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 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 + [[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) { @@ -1670,7 +1895,8 @@ 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" 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, @@ -1686,6 +1912,131 @@ 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(); 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: 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)) { + 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. + /// @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)) { + 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, `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"); + } + 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; + 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; + } + + /// 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"); + } + 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_{}; }; @@ -1977,7 +2328,74 @@ class PlotTabHostView { return okStatus(); } + /// 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) && + 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. + /// @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)); + } + + /// 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>( + "attach_topic", toAbiString(id), toAbiString(topic), toAbiString(dataset_source)); + } + + /// 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>( + "detach_topic", toAbiString(id), toAbiString(topic), toAbiString(dataset_source)); + } + + /// Bring one of this plugin's tabs (any kind) to the front. + /// @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)); + } + 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 (!(vtable->*Slot)(host_.ctx, args..., &err)) { + return unexpected(errorToString(err)); + } + return okStatus(); + } + 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 75d1d669..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; 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/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index 7d332c72..f2c97186 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -316,7 +316,103 @@ 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"); +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( + 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.36.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.36.0) ------------ +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)"); +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.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"); +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). +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/data_processors_api_test.cpp b/pj_base/tests/data_processors_api_test.cpp index 3c7cdfb9..3e08ec6c 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,37 @@ 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; + 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; + 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) + 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"; + + struct Evaluation { + std::string json; + }; + std::unordered_map evaluations; // handle -> stored report + uint64_t next_handle = 1; }; bool dpCreate( @@ -133,6 +167,110 @@ 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(); + 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))); + } + + // 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 = self->poll_state; + *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 +280,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 +512,155 @@ 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_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); + 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, 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(); + 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, 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(); + 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/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)); +} diff --git a/pj_base/tests/field_table_test.cpp b/pj_base/tests/field_table_test.cpp new file mode 100644 index 00000000..eadd3f70 --- /dev/null +++ b/pj_base/tests/field_table_test.cpp @@ -0,0 +1,929 @@ +// 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/camera_info_fields.hpp" +#include "pj_base/builtin/depth_image_fields.hpp" +#include "pj_base/builtin/field_table_registry.hpp" +#include "pj_base/builtin/frame_transforms_fields.hpp" +#include "pj_base/builtin/image_annotations_fields.hpp" +#include "pj_base/builtin/image_fields.hpp" +#include "pj_base/builtin/point_cloud_fields.hpp" +#include "pj_base/builtin/scene_entities_fields.hpp" +#include "pj_base/builtin/video_frame_fields.hpp" + +namespace { + +using PJ::sdk::AnnotationTopology; +using PJ::sdk::ArrowPrimitive; +using PJ::sdk::AxesPrimitive; +using PJ::sdk::BufferLayout; +using PJ::sdk::BuiltinObjectType; +using PJ::sdk::CameraInfo; +using PJ::sdk::CircleAnnotation; +using PJ::sdk::CubePrimitive; +using PJ::sdk::CylinderPrimitive; +using PJ::sdk::DepthImage; +using PJ::sdk::FieldDescriptor; +using PJ::sdk::FieldKind; +using PJ::sdk::FieldTable; +using PJ::sdk::FieldTableView; +using PJ::sdk::FrameTransform; +using PJ::sdk::FrameTransforms; +using PJ::sdk::Image; +using PJ::sdk::ImageAnnotations; +using PJ::sdk::KeyValuePair; +using PJ::sdk::LinePrimitive; +using PJ::sdk::LineType; +using PJ::sdk::ModelPrimitive; +using PJ::sdk::PointCloud; +using PJ::sdk::PointField; +using PJ::sdk::PointsAnnotation; +using PJ::sdk::Pose; +using PJ::sdk::SceneEntities; +using PJ::sdk::SceneEntity; +using PJ::sdk::SceneEntityDeletion; +using PJ::sdk::SpherePrimitive; +using PJ::sdk::TextAnnotation; +using PJ::sdk::TextPrimitive; +using PJ::sdk::TrianglePrimitive; +using PJ::sdk::Vector2; +using PJ::sdk::VideoFrame; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const FieldDescriptor* findField(const FieldTableView& table, std::string_view name) { + for (const auto& f : table.fields) { + if (f.name == name) { + return &f; + } + } + return nullptr; +} + +// Walks `table` and every table reachable through `nested`, depth-first, +// visiting each distinct type name once (guards against re-visiting a type +// reachable through multiple paths, e.g. Vector3 via both Pose and FrameTransform). +void collectTables( + const FieldTableView& table, std::set& visited, std::vector& out) { + if (!visited.insert(table.type_name).second) { + return; + } + out.push_back(&table); + for (const auto& f : table.fields) { + if (f.nested != nullptr) { + collectTables(*f.nested, visited, out); + } + } +} + +// Recursively copies every described field from `src` to `dst` using only +// FieldDescriptor accessors — the same access pattern a generic script +// binder would use. A kList field either holds structs (nested != nullptr, +// recurse into the nested table) or scalars (nested == nullptr; copy via the +// get_*/set_* pair matching element_kind, applied to the element address +// returned by list_at/list_emplace for a growable list, or list_replace for +// a fixed-size one — see field_table.hpp's kList doc comment). A kBuffer +// field resolves its current bytes and re-assigns them onto dst, taking a +// fresh copy/anchor. A kOptionalNumber field copies the value only when +// present, leaving a freshly-constructed dst's absent default untouched +// otherwise. +void copyThroughTable(const FieldTableView& table, const void* src, void* dst) { + for (const auto& f : table.fields) { + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst, f.get_number(src)); + break; + case FieldKind::kInt64: + f.set_int64(dst, f.get_int64(src)); + break; + case FieldKind::kString: + f.set_string(dst, f.get_string(src)); + break; + case FieldKind::kOptionalNumber: + if (f.has_value(src)) { + f.set_number(dst, f.get_number(src)); + } + break; + case FieldKind::kStruct: + ASSERT_NE(f.nested, nullptr); + copyThroughTable(*f.nested, f.struct_ptr(src), f.struct_ptr_mut(dst)); + break; + case FieldKind::kList: { + const size_t n = f.list_size(src); + const bool fixed_size = f.list_replace != nullptr; + if (!fixed_size) { + f.list_clear(dst); + } else { + ASSERT_EQ(n, f.list_size(dst)) << "fixed-size list: src/dst element counts must match"; + } + for (size_t i = 0; i < n; ++i) { + void* dst_elem = fixed_size ? f.list_replace(dst, i) : f.list_emplace(dst); + const void* src_elem = f.list_at(src, i); + if (f.nested != nullptr) { + copyThroughTable(*f.nested, src_elem, dst_elem); + continue; + } + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + f.set_number(dst_elem, f.get_number(src_elem)); + break; + case FieldKind::kInt64: + f.set_int64(dst_elem, f.get_int64(src_elem)); + break; + case FieldKind::kString: + f.set_string(dst_elem, f.get_string(src_elem)); + break; + default: + FAIL() << "unsupported scalar element_kind in copyThroughTable"; + } + } + break; + } + case FieldKind::kBuffer: { + const BufferLayout layout = f.buffer(src); + std::vector bytes(layout.bytes.begin(), layout.bytes.end()); + f.buffer_assign(dst, std::move(bytes)); + break; + } + } + } +} + +// --------------------------------------------------------------------------- +// Sample data — populated, so every accessor path is exercised. +// --------------------------------------------------------------------------- + +FrameTransforms makeSampleFrameTransforms() { + FrameTransforms ft; + ft.transforms.push_back( + FrameTransform{ + .timestamp = 1'234'567'890'123'456, + .parent_frame_id = "map", + .child_frame_id = "base_link", + .translation = {.x = 1.0, .y = 2.0, .z = 3.0}, + .rotation = {.x = 0.1, .y = 0.2, .z = 0.3, .w = 0.9}, + }); + ft.transforms.push_back( + FrameTransform{ + .timestamp = -42, + .parent_frame_id = "odom", + .child_frame_id = "map", + .translation = {.x = -1.5, .y = 2.5, .z = -3.5}, + .rotation = {.x = 0.0, .y = 0.0, .z = 0.707, .w = 0.707}, + }); + return ft; +} + +ImageAnnotations makeSampleImageAnnotations() { + ImageAnnotations ia; + ia.timestamp = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + ia.image_topic = "/camera/image"; + + PointsAnnotation pa; + pa.topology = AnnotationTopology::kLineStrip; // non-default (kPoints == 0) + pa.points = {{.x = 1.0, .y = 2.0}, {.x = 3.0, .y = 4.0}, {.x = 5.0, .y = 6.0}}; + pa.thickness = 3.5; + pa.color = {.r = 10, .g = 20, .b = 30, .a = 40}; + pa.colors = {{.r = 1, .g = 2, .b = 3, .a = 4}, {.r = 5, .g = 6, .b = 7, .a = 8}}; + pa.fill_color = {.r = 50, .g = 60, .b = 70, .a = 80}; + ia.points.push_back(pa); + + CircleAnnotation ca; + ca.center = {.x = 7.0, .y = 8.0}; + ca.radius = 4.0; + ca.thickness = 1.5; + ca.color = {.r = 100, .g = 110, .b = 120, .a = 130}; + ca.fill_color = {.r = 140, .g = 150, .b = 160, .a = 170}; + ia.circles.push_back(ca); + + TextAnnotation ta; + ta.position = {.x = 9.0, .y = 10.0}; + ta.font_size = 18.0; + ta.color = {.r = 200, .g = 210, .b = 220, .a = 230}; + ta.text = "hello"; + ia.texts.push_back(ta); + + return ia; +} + +// One entity carrying at least one of every SceneEntity primitive kind, plus +// metadata; the batch also carries one deletion. +SceneEntities makeSampleSceneEntities() { + SceneEntity e; + e.timestamp = 1'000'000'000'000'123; // > 2^53. + e.frame_id = "map"; + e.id = "entity-1"; + e.lifetime_ns = 5'000'000'000; + e.frame_locked = true; + e.metadata = { + KeyValuePair{.key = "k1", .value = "v1"}, + KeyValuePair{.key = "k2", .value = "v2"}, + }; + + e.arrows.push_back( + ArrowPrimitive{ + .pose = {.position = {.x = 1, .y = 2, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .shaft_length = 1.0, + .shaft_diameter = 0.1, + .head_length = 0.3, + .head_diameter = 0.2, + .color = {.r = 255, .g = 0, .b = 0, .a = 255}, + }); + + e.cubes.push_back( + CubePrimitive{ + .pose = {.position = {.x = 4, .y = 5, .z = 6}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 0, .g = 255, .b = 0, .a = 255}, + }); + + e.spheres.push_back( + SpherePrimitive{ + .pose = {.position = {.x = 7, .y = 8, .z = 9}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 2, .y = 2, .z = 2}, + .color = {.r = 0, .g = 0, .b = 255, .a = 255}, + }); + + e.cylinders.push_back( + CylinderPrimitive{ + .pose = {.position = {.x = 1, .y = 1, .z = 1}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .size = {.x = 1, .y = 1, .z = 2}, + .bottom_scale = 0.5, + .top_scale = 0.8, + .color = {.r = 10, .g = 20, .b = 30, .a = 255}, + }); + + e.lines.push_back( + LinePrimitive{ + .type = LineType::kLineLoop, // non-default (kLineStrip == 0) + .pose = {.position = {.x = 0, .y = 0, .z = 0}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .thickness = 0.05, + .scale_invariant = true, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 1, .y = 1, .z = 0}}, + .color = {.r = 1, .g = 2, .b = 3, .a = 4}, + .colors = + {{.r = 5, .g = 6, .b = 7, .a = 8}, + {.r = 9, .g = 10, .b = 11, .a = 12}, + {.r = 13, .g = 14, .b = 15, .a = 16}}, + .indices = {0, 1, 2}, + }); + + e.triangles.push_back( + TrianglePrimitive{ + .pose = {.position = {.x = 2, .y = 2, .z = 2}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .points = {{.x = 0, .y = 0, .z = 0}, {.x = 1, .y = 0, .z = 0}, {.x = 0, .y = 1, .z = 0}}, + .color = {.r = 20, .g = 21, .b = 22, .a = 23}, + .colors = + {{.r = 24, .g = 25, .b = 26, .a = 27}, + {.r = 28, .g = 29, .b = 30, .a = 31}, + {.r = 32, .g = 33, .b = 34, .a = 35}}, + .indices = {0, 1, 2}, + }); + + e.texts.push_back( + TextPrimitive{ + .pose = {.position = {.x = 3, .y = 3, .z = 3}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .billboard = true, + .font_size = 12.0, + .scale_invariant = true, + .color = {.r = 40, .g = 41, .b = 42, .a = 43}, + .text = "hello scene", + }); + + e.models.push_back( + ModelPrimitive{ + .pose = {.position = {.x = 4, .y = 4, .z = 4}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .scale = {.x = 1, .y = 1, .z = 1}, + .color = {.r = 50, .g = 51, .b = 52, .a = 53}, + .override_color = true, + .url = "", + .media_type = "model/gltf-binary", + .data = {0x01, 0x02, 0x03, 0x04}, + }); + + e.axes.push_back( + AxesPrimitive{ + .pose = {.position = {.x = 5, .y = 5, .z = 5}, .orientation = {.x = 0, .y = 0, .z = 0, .w = 1}}, + .length = 0.5, + .thickness = 0.02, + .scale_invariant = false, + }); + + SceneEntities entities; + entities.entities.push_back(e); + entities.deletions.push_back( + SceneEntityDeletion{ + .type = SceneEntityDeletion::Type::kAll, // non-default (kMatchingId == 0) + .timestamp = 999, + .id = "", + }); + return entities; +} + +// Packs `points` (each {x, y, z, intensity} float32) at `point_step`-byte +// stride into a fresh owned buffer via `buffer_assign()`. +PointCloud makeSamplePointCloudUnorganized() { + PointCloud cloud; + cloud.width = 3; + cloud.height = 1; + cloud.point_step = 16; + cloud.row_step = 48; // 3 * 16, no padding. + cloud.is_bigendian = false; + cloud.is_dense = true; + cloud.frame_id = "lidar"; + cloud.fields = { + PointField{.name = "x", .offset = 0, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "y", .offset = 4, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "z", .offset = 8, .datatype = PointField::Datatype::kFloat32, .count = 1}, + PointField{.name = "intensity", .offset = 12, .datatype = PointField::Datatype::kFloat32, .count = 1}, + }; + cloud.timestamp_ns = 1'234'567'890'123'456; + + std::vector bytes(static_cast(cloud.point_step) * cloud.width); + const float points[3][4] = { + {1.0f, 2.0f, 3.0f, 0.5f}, + {4.0f, 5.0f, 6.0f, 0.6f}, + {7.0f, 8.0f, 9.0f, 0.7f}, + }; + for (size_t i = 0; i < 3; ++i) { + std::memcpy(bytes.data() + i * cloud.point_step, points[i], sizeof(points[i])); + } + + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&cloud, std::move(bytes)); + return cloud; +} + +// A 4x3 rgb8 image (36 bytes, no row padding), both optionals set even +// though they are only semantically meaningful for "compressedDepth" — this +// exercises the kOptionalNumber accessors on every populated fixture. +Image makeSampleImage() { + Image image; + image.width = 4; + image.height = 3; + image.encoding = "rgb8"; + image.row_step = 12; // 4 * 3, no padding. + image.is_bigendian = false; + image.compressed_depth_min = 0.1f; + image.compressed_depth_max = 6.4f; + image.timestamp_ns = 1'234'567'890'123'456; + image.frame_id = "camera_optical_frame"; + + std::vector bytes(static_cast(image.row_step) * image.height); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +// A 2x2 32FC1 depth image (16 bytes) with a populated K and a non-empty D. +DepthImage makeSampleDepthImage() { + DepthImage image; + image.width = 2; + image.height = 2; + image.encoding = "32FC1"; + image.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + image.distortion_model = "plumb_bob"; + image.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + image.timestamp_ns = -42; + + std::vector bytes(static_cast(image.width) * image.height * 4); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(i + 1); + } + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&image, std::move(bytes)); + return image; +} + +CameraInfo makeSampleCameraInfo() { + CameraInfo info; + info.timestamp_ns = 9'007'199'254'740'993; // > 2^53: would lose precision as a double. + info.frame_id = "camera_optical_frame"; + info.width = 640; + info.height = 480; + info.distortion_model = "plumb_bob"; + info.D = {0.1, -0.2, 0.001, -0.002, 0.05}; + info.K = {525.0, 0.0, 319.5, 0.0, 525.0, 239.5, 0.0, 0.0, 1.0}; + info.R = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + info.P = {525.0, 0.0, 319.5, 0.0, 0.0, 525.0, 239.5, 0.0, 0.0, 0.0, 1.0, 0.0}; + return info; +} + +VideoFrame makeSampleVideoFrame() { + VideoFrame frame; + frame.timestamp_ns = 1'000'000'000'000'123; // > 2^53. + frame.frame_id = "cam0"; + frame.format = "h264"; + + std::vector bytes = {0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xC0, 0x1E}; + const FieldDescriptor* data_field = findField(FieldTable::view, "data"); + data_field->buffer_assign(&frame, std::move(bytes)); + return frame; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +TEST(FieldTableTest, NamesUniqueAndKindsConsistent) { + // Explicit roots so every specialization is exercised even when a struct + // (Vector2, Pose) is not reachable from any other root. + const std::array roots{ + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + &FieldTable::view, &FieldTable::view, + }; + + std::set visited; + std::vector tables; + for (const auto* root : roots) { + collectTables(*root, visited, tables); + } + ASSERT_EQ(tables.size(), 32u) << "expected all 32 field_table specializations added through SDK block 5.1"; + + for (const auto* table : tables) { + SCOPED_TRACE(table->type_name); + std::set names; + for (const auto& f : table->fields) { + EXPECT_FALSE(f.name.empty()); + EXPECT_TRUE(names.insert(f.name).second) << "duplicate field name: " << f.name; + + switch (f.kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.set_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.set_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.set_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kStruct: + EXPECT_NE(f.struct_ptr, nullptr); + EXPECT_NE(f.struct_ptr_mut, nullptr); + EXPECT_NE(f.nested, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.list_size, nullptr); + break; + case FieldKind::kOptionalNumber: + EXPECT_NE(f.has_value, nullptr); + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.nested, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.buffer, nullptr); + break; + case FieldKind::kList: + EXPECT_NE(f.list_size, nullptr); + EXPECT_NE(f.list_at, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.struct_ptr_mut, nullptr); + EXPECT_EQ(f.buffer, nullptr); + // Exactly one of the two write-accessor pairs is set: growable + // (list_emplace/list_clear, e.g. std::vector) or fixed-size + // (list_replace, e.g. std::array) — see field_table.hpp's kList + // doc comment. + if (f.list_replace != nullptr) { + EXPECT_EQ(f.list_emplace, nullptr); + EXPECT_EQ(f.list_clear, nullptr); + } else { + EXPECT_NE(f.list_emplace, nullptr); + EXPECT_NE(f.list_clear, nullptr); + } + if (f.nested != nullptr) { + // Struct-element list: nested table set, element_kind == kStruct, + // no scalar accessor (the elements are read via `nested`, not get_*/set_*). + EXPECT_EQ(f.element_kind, FieldKind::kStruct); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + } else { + // Scalar-element list: exactly one get_*/set_* pair set, matching element_kind. + EXPECT_NE(f.element_kind, FieldKind::kStruct); + switch (f.element_kind) { + case FieldKind::kNumber: + case FieldKind::kBool: + case FieldKind::kEnum: + EXPECT_NE(f.get_number, nullptr); + EXPECT_NE(f.set_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kInt64: + EXPECT_NE(f.get_int64, nullptr); + EXPECT_NE(f.set_int64, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_string, nullptr); + break; + case FieldKind::kString: + EXPECT_NE(f.get_string, nullptr); + EXPECT_NE(f.set_string, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + break; + default: + ADD_FAILURE() << "unexpected scalar element_kind"; + } + } + break; + case FieldKind::kBuffer: + EXPECT_NE(f.buffer, nullptr); + EXPECT_NE(f.buffer_assign, nullptr); + EXPECT_EQ(f.get_number, nullptr); + EXPECT_EQ(f.get_int64, nullptr); + EXPECT_EQ(f.get_string, nullptr); + EXPECT_EQ(f.struct_ptr, nullptr); + EXPECT_EQ(f.list_size, nullptr); + EXPECT_EQ(f.nested, nullptr); + break; + } + } + } +} + +TEST(FieldTableTest, GenericCopyMatchesCodecRoundTrip) { + { + const FrameTransforms src = makeSampleFrameTransforms(); + ASSERT_FALSE(src.transforms.empty()); + FrameTransforms dst; + copyThroughTable(FieldTable::view, &src, &dst); + EXPECT_EQ(dst, src); + + 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); + } +} + +// 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. + + 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); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kDepthImage), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kCameraInfo), nullptr); + EXPECT_NE(PJ::sdk::describe(BuiltinObjectType::kVideoFrame), nullptr); + + // Every other stable BuiltinObjectType value (mirrors the array in + // builtin_object_codec_test.cpp) must resolve to nullptr. + const std::array other_types{ + BuiltinObjectType::kOccupancyGrid, + BuiltinObjectType::kCompressedPointCloud, + BuiltinObjectType::kMesh3D, + BuiltinObjectType::kRobotDescription, + BuiltinObjectType::kOccupancyGridUpdate, + BuiltinObjectType::kLog, + BuiltinObjectType::kPosesInFrame, + BuiltinObjectType::kVoxelGrid, + BuiltinObjectType::kGridMap, + BuiltinObjectType::kPlotMarkers, + }; + for (auto type : other_types) { + EXPECT_EQ(PJ::sdk::describe(type), nullptr) << PJ::sdk::name(type); + } + EXPECT_EQ(PJ::sdk::describe(BuiltinObjectType::kNone), nullptr); +} + +} // namespace diff --git a/pj_base/tests/image_annotations_codec_test.cpp b/pj_base/tests/image_annotations_codec_test.cpp index 88d68b6c..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 diff --git a/pj_base/tests/object_topic_metadata_test.cpp b/pj_base/tests/object_topic_metadata_test.cpp index 92ce68a2..dcd40bfe 100644 --- a/pj_base/tests/object_topic_metadata_test.cpp +++ b/pj_base/tests/object_topic_metadata_test.cpp @@ -202,6 +202,38 @@ 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(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 15bdffd0..d52f696d 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.createTabV2("view-a", "3d", "First")); + ASSERT_TRUE(view.createTabV2("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.createTabV2("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.createTabV2("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.focusTab("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.createTabV2("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.createTabV2("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.focusTab("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.createTabV2("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.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.focusTab("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.createTabV2("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.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); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/plugin_data_api_test.cpp b/pj_base/tests/plugin_data_api_test.cpp index 155d4bc1..99929f94 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,77 @@ 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 = { + .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_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/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 ddcc6e51..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 @@ -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,51 @@ 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. + /// @since 0.36.0 + [[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; + } + + /// 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) { + 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/dialog_plugin_base.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp index 29d1aedb..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 @@ -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,15 @@ class DialogPluginBase { return host_info_; } + /// 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 host_info_.has_value() && host_info_->has(capability); + } + 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 65bf5d78..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 @@ -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; @@ -611,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; @@ -627,6 +639,42 @@ 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: + /// gate the scene UI on DialogPluginBase::hostHas(DialogHostCapability::kEmbedsSceneViews) + /// (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); + 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/dialog_plugin_base_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_plugin_base_test.cpp index 1c4d6e2e..633f9dda 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,10 @@ class HostInfoDialog final : public PJ::DialogPluginBase { [[nodiscard]] const std::optional& observedHostInfo() const noexcept { return hostInfo(); } + + [[nodiscard]] bool observedHostHas(PJ::DialogHostCapability capability) const noexcept { + return hostHas(capability); + } }; class DialogPluginBaseHostInfoTest : public ::testing::Test { @@ -116,6 +131,19 @@ TEST_F(DialogPluginBaseHostInfoTest, CopiesStringsAndCapabilitiesDuringDelivery) EXPECT_FALSE(info->has(PJ::DialogHostCapability::kCanSelectFolder)); } +TEST_F(DialogPluginBaseHostInfoTest, HostHasReportsTheEmbedsSceneViewsBit) { + // No delivery (pre-0.21 host, or one that never calls set_host_info): no bits. + EXPECT_FALSE(plugin().observedHostHas(PJ::DialogHostCapability::kEmbedsSceneViews)); + + ASSERT_TRUE(deliver("0.36.0", "4.2.0", 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)); + 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/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/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 2e511cdc..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 @@ -512,6 +527,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/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 7a7cee81..90614c7b 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -188,6 +188,13 @@ 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. | + +`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. Delivery is last-writer-wins: a later successful host-info call replaces both stored strings and the complete capability mask. Plugin code should normally @@ -497,7 +504,8 @@ 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)` | | DateRangePicker (date range) | `setDateRangePlaceholder` | `onDateRangeChanged(name, from_iso, to_iso)` | @@ -506,6 +514,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 diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index 9d71fa18..615ad36d 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -200,6 +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. | +| `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. | @@ -214,14 +216,14 @@ 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.36.0) 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()`): `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 @@ -265,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 @@ -290,6 +294,86 @@ 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()` | `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 +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 +`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). `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. + ### Reading a series via Arrow `readSeriesArrow()` is the only read path in v4 — it returns @@ -405,6 +489,8 @@ 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; 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: ```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..048d2849 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -61,6 +61,18 @@ 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. 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 + /// 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 7bff9483..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; @@ -261,6 +272,14 @@ Expected decodeManifest( if (!suggested_sdk_version) { return unexpected(suggested_sdk_version.error()); } + auto badge = optional_string("badge"); + 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()); @@ -288,6 +307,8 @@ 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.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 65a72929..290de63b 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -295,6 +295,47 @@ 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, 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, 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() 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/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index 9834fc27..7851996e 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -1,5 +1,8 @@ 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 PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE PJ_INGEST_COMPLETION_FLAGS_V1_MASK @@ -11,9 +14,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 @@ -58,9 +65,13 @@ 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 @@ -78,6 +89,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