diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 9b96fea7f9..fca676dff2 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -20,6 +20,7 @@ if(BUILD_EXAMPLES) add_subdirectory(soma_body_publisher) add_subdirectory(soma_hand_publisher) add_subdirectory(haptic_feedback) + add_subdirectory(tactile) add_subdirectory(sharpa_avatar) add_subdirectory(vehicle_teleop) elseif(BUILD_EXAMPLE_TELEOP_ROS2) diff --git a/examples/tactile/CMakeLists.txt b/examples/tactile/CMakeLists.txt new file mode 100644 index 0000000000..e3caba6501 --- /dev/null +++ b/examples/tactile/CMakeLists.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20) + +include(${CMAKE_SOURCE_DIR}/cmake/InstallPythonExample.cmake) +install_python_example(DESTINATION examples/tactile) diff --git a/examples/tactile/README.md b/examples/tactile/README.md new file mode 100644 index 0000000000..d6bf1aaa5e --- /dev/null +++ b/examples/tactile/README.md @@ -0,0 +1,28 @@ + + +# Run the tactile example + +1. From the IsaacCapture repository root, install the tactile example. + + ```bash + uv pip install -e ./examples/tactile + ``` + +2. In the first terminal, start the printer and the local CloudXR runtime. + + ```bash + python -m isaaccapture_examples.tactile.printer --accept-eula + ``` + +3. In the second terminal, start the feeder. + + ```bash + python -m isaaccapture_examples.tactile.feeder + ``` + +4. Confirm that the first terminal prints `TactileMetadata`, followed by the first and last taxel values once per second. + +5. Press `Ctrl+C` in both terminals. diff --git a/examples/tactile/pyproject.toml b/examples/tactile/pyproject.toml new file mode 100644 index 0000000000..ffefbb02d5 --- /dev/null +++ b/examples/tactile/pyproject.toml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "isaaccapture-examples-tactile" +version = "0.0.0" +description = "IsaacCapture tactile feeder and printer" +requires-python = ">=3.11,<3.14" +dependencies = ["isaaccapture[cloudxr]"] + +[tool.hatch.build.targets.wheel] +only-include = ["python/isaaccapture_examples/tactile"] +sources = ["python"] + +[tool.uv.sources] +isaaccapture = { path = "../..", editable = true } diff --git a/examples/tactile/python/isaaccapture_examples/tactile/__init__.py b/examples/tactile/python/isaaccapture_examples/tactile/__init__.py new file mode 100644 index 0000000000..4ba6bde466 --- /dev/null +++ b/examples/tactile/python/isaaccapture_examples/tactile/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Minimal tactile transport example.""" diff --git a/examples/tactile/python/isaaccapture_examples/tactile/common.py b/examples/tactile/python/isaaccapture_examples/tactile/common.py new file mode 100644 index 0000000000..f30cbad6eb --- /dev/null +++ b/examples/tactile/python/isaaccapture_examples/tactile/common.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +DEFAULT_COLLECTION_ID = "tactile/example" +TACTILE_MAX_SAMPLE_SIZE = 16_384 diff --git a/examples/tactile/python/isaaccapture_examples/tactile/feeder.py b/examples/tactile/python/isaaccapture_examples/tactile/feeder.py new file mode 100644 index 0000000000..0a893eddb0 --- /dev/null +++ b/examples/tactile/python/isaaccapture_examples/tactile/feeder.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Publish synthetic three-axis tactile data and immutable metadata.""" + +from __future__ import annotations + +import argparse +import math +import time + +from isaaccapture.deviceio_session import DeviceIOSession +from isaaccapture.deviceio_trackers import TensorPushTracker +from isaaccapture.oxr import OpenXRSession +from isaaccapture.schema import ( + TRACKER_METADATA_TENSOR_IDENTIFIER, + IsaacCapturePoseTactileBinding, + OpenXRHandTactileBinding, + Point, + Pose, + ProfileHandling, + Quaternion, + TactileBindingProfile, + TactileData, + TactileHandedness, + TactileMeasurement, + TactileMetadata, + TactileNode, + TactileQuantity, + TactileSkinWeight, + TactileTaxel, + TactileUnit, + TactileValueFrame, + make_tactile_binding_profile, + tracker_metadata_collection_id, +) + +from .common import DEFAULT_COLLECTION_ID, TACTILE_MAX_SAMPLE_SIZE + +TAXEL_COUNT = 8 +PUBLISH_RATE_HZ = 30.0 +METADATA_INTERVAL_S = 1.0 + + +def _pose(x: float, y: float) -> Pose: + return Pose(Point(x, y, 0.0), Quaternion(0.0, 0.0, 0.0, 1.0)) + + +def _metadata() -> TactileMetadata: + nodes = [ + TactileNode("thumb_distal", _pose(-0.02, 0.04)), + TactileNode("index_distal", _pose(0.02, 0.06)), + ] + taxels = [ + TactileTaxel( + f"taxel-{index}", + _pose(-0.035 + index * 0.01, 0.08), + [TactileSkinWeight(index % len(nodes), 1.0)], + ) + for index in range(TAXEL_COUNT) + ] + openxr_binding = TactileBindingProfile( + openxr_hand=OpenXRHandTactileBinding( + TactileHandedness.LEFT, + [4, 9], + ) + ) + pose_binding = TactileBindingProfile( + isaaccapture_pose=IsaacCapturePoseTactileBinding( + "core.HandPose", + tracker_name="hand", + channel="left", + subject_id="example-operator", + device_instance_id="example-glove-left", + skeleton_instance_id="example-left-hand", + node_pose_indices=[4, 9], + ) + ) + profiles = [ + make_tactile_binding_profile(openxr_binding, ProfileHandling.REQUIRED), + make_tactile_binding_profile(pose_binding, ProfileHandling.OPTIONAL), + ] + measurement = TactileMeasurement( + TactileQuantity.FORCE, + TactileUnit.NEWTON, + 3, + TactileValueFrame.TAXEL, + ) + return TactileMetadata("example-glove-left", measurement, taxels, nodes, profiles) + + +def _frame(elapsed_s: float) -> TactileData: + values: list[float] = [] + for taxel in range(TAXEL_COUNT): + normal_force = 1.0 + 0.75 * math.sin( + 2.0 * math.pi * 0.5 * elapsed_s + taxel * 0.3 + ) + values.extend((0.0, 0.0, normal_force)) + return TactileData(values) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--collection-id", default=DEFAULT_COLLECTION_ID) + args = parser.parse_args(argv) + + metadata = _metadata().to_bytes() + data_pusher = TensorPushTracker( + args.collection_id, "tactile", TACTILE_MAX_SAMPLE_SIZE + ) + metadata_pusher = TensorPushTracker( + tracker_metadata_collection_id(args.collection_id), + TRACKER_METADATA_TENSOR_IDENTIFIER, + len(metadata), + ) + trackers = [data_pusher, metadata_pusher] + + print( + f"Publishing {TAXEL_COUNT} taxels on {args.collection_id}; press Ctrl+C to stop." + ) + with ( + OpenXRSession( + "TactileExampleFeeder", + DeviceIOSession.get_required_extensions(trackers), + ) as oxr_session, + DeviceIOSession.run(trackers, oxr_session.get_handles()) as session, + ): + started = time.monotonic() + frame_index = 0 + last_metadata_push = -math.inf + try: + while True: + deadline = started + frame_index / PUBLISH_RATE_HZ + if (remaining := deadline - time.monotonic()) > 0: + time.sleep(remaining) + session.update() + elapsed_s = time.monotonic() - started + data_pusher.push(session, _frame(elapsed_s).to_bytes()) + if elapsed_s - last_metadata_push >= METADATA_INTERVAL_S: + metadata_pusher.push(session, metadata) + last_metadata_push = elapsed_s + frame_index += 1 + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/tactile/python/isaaccapture_examples/tactile/printer.py b/examples/tactile/python/isaaccapture_examples/tactile/printer.py new file mode 100644 index 0000000000..385b750361 --- /dev/null +++ b/examples/tactile/python/isaaccapture_examples/tactile/printer.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Receive tactile data through TactileTracker and print one update per second.""" + +from __future__ import annotations + +import argparse +import time + +from isaaccapture.cloudxr import CloudXRLauncher +from isaaccapture.deviceio_session import DeviceIOSession +from isaaccapture.deviceio_trackers import TactileTracker +from isaaccapture.oxr import OpenXRSession +from isaaccapture.schema import TactileMetadata + +from .common import DEFAULT_COLLECTION_ID + +PRINT_INTERVAL_S = 1.0 + + +def _print_metadata(metadata: TactileMetadata) -> None: + measurement = metadata.measurement + print("TactileMetadata") + print(f" schema_version: {metadata.schema_version}") + print(f" shape_frame_id: {metadata.shape_frame_id}") + print( + " measurement: " + f"{measurement.quantity.name}, {measurement.unit.name}, " + f"components={measurement.component_count}, frame={measurement.frame.name}" + ) + print(f" nodes: {[node.node_id for node in metadata.nodes]}") + print(f" taxels: {[taxel.taxel_id for taxel in metadata.taxels]}") + print( + " profiles: " + f"{[f'{profile.profile_id}@{profile.version_major}.{profile.version_minor}' for profile in metadata.profiles]}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--collection-id", default=DEFAULT_COLLECTION_ID) + CloudXRLauncher.add_launcher_arguments(parser) + args = parser.parse_args(argv) + + tracker = TactileTracker(args.collection_id) + trackers = [tracker] + print(f"Waiting for tactile data on {args.collection_id}; press Ctrl+C to stop.") + with ( + CloudXRLauncher.launch_context(args), + OpenXRSession( + "TactileExamplePrinter", + DeviceIOSession.get_required_extensions(trackers), + ) as oxr_session, + DeviceIOSession.run(trackers, oxr_session.get_handles()) as session, + ): + metadata = None + next_print = time.monotonic() + try: + while True: + session.update() + if ( + metadata is None + and (metadata := tracker.get_metadata(session)) is not None + ): + _print_metadata(metadata) + + data = tracker.get_data(session) + now = time.monotonic() + if metadata is not None and data is not None and now >= next_print: + component_count = metadata.measurement.component_count + values = list(data.values) + print( + f"first={values[:component_count]} " + f"last={values[-component_count:]}" + ) + next_print = now + PRINT_INTERVAL_S + time.sleep(0.005) + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/core/deviceio_trackers/trackers.toml b/src/core/deviceio_trackers/trackers.toml index d27518d43c..074ee673d0 100644 --- a/src/core/deviceio_trackers/trackers.toml +++ b/src/core/deviceio_trackers/trackers.toml @@ -111,3 +111,11 @@ python_accessor = "get_spacemouse_data" name = "gamepad" table = "GamepadSnapshot" python_accessor = "get_gamepad_data" + +# About 1300 three-axis taxels fit in the default data collection. Geometry, binding, +# and calibration are immutable tracker metadata and must fit TRACKER_METADATA_MAX_SIZE. +[[tracker]] +name = "tactile" +table = "TactileData" +metadata = "TactileMetadata" +max_flatbuffer_size = 16384 diff --git a/src/core/schema/cpp/inc/schema/tactile_profiles.hpp b/src/core/schema/cpp/inc/schema/tactile_profiles.hpp new file mode 100644 index 0000000000..1bfcdc4e10 --- /dev/null +++ b/src/core/schema/cpp/inc/schema/tactile_profiles.hpp @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +namespace core +{ + +// Provisional IsaacCapture registry identifiers. The payloads use external standards where +// named, but the profiles themselves are not Khronos or ROS standards. +inline constexpr std::string_view TACTILE_OPENXR_HAND_PROFILE_ID = "com.nvidia.isaaccapture.tactile.openxr-hand-binding"; +inline constexpr std::string_view TACTILE_URDF_PROFILE_ID = "com.nvidia.isaaccapture.tactile.urdf-binding"; +inline constexpr std::string_view TACTILE_ISAACCAPTURE_POSE_PROFILE_ID = + "com.nvidia.isaaccapture.tactile.pose-stream-binding"; +inline constexpr std::string_view TACTILE_BINDING_PROFILE_SCHEMA = "core.TactileBindingProfile"; + +} // namespace core diff --git a/src/core/schema/cpp/inc/schema/tactile_validation.hpp b/src/core/schema/cpp/inc/schema/tactile_validation.hpp new file mode 100644 index 0000000000..1bee7e4db9 --- /dev/null +++ b/src/core/schema/cpp/inc/schema/tactile_validation.hpp @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Semantic validation for tactile metadata and frames. FlatBuffers verification covers +// memory safety; these checks cover the cross-field invariants of the protocol. + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace core +{ + +inline void validate_tactile_measurement(const TactileMeasurement& measurement) +{ + switch (measurement.quantity()) + { + case TactileQuantity_FORCE: + if ((measurement.component_count() != 1 && measurement.component_count() != 3) || + measurement.unit() != TactileUnit_NEWTON || measurement.frame() != TactileValueFrame_TAXEL) + { + throw std::invalid_argument("FORCE requires 1 or 3 NEWTON components in the TAXEL frame"); + } + break; + case TactileQuantity_PRESSURE: + if (measurement.component_count() != 1 || measurement.unit() != TactileUnit_PASCAL || + measurement.frame() != TactileValueFrame_NONE) + { + throw std::invalid_argument("PRESSURE requires one PASCAL component with no vector frame"); + } + break; + case TactileQuantity_RAW: + if (measurement.component_count() != 1 || measurement.unit() != TactileUnit_COUNT || + measurement.frame() != TactileValueFrame_NONE) + { + throw std::invalid_argument("RAW requires one COUNT component with no vector frame"); + } + break; + default: + throw std::invalid_argument("measurement quantity must be a tactile schema v1 quantity"); + } +} + +inline bool equals(const flatbuffers::String* value, std::string_view expected) +{ + return value != nullptr && value->string_view() == expected; +} + +inline bool equals(const flatbuffers::String* left, const flatbuffers::String* right) +{ + return left != nullptr && right != nullptr && left->string_view() == right->string_view(); +} + +inline void validate_tactile_binding_profile(const SemanticProfile& profile, size_t node_count) +{ + const auto* payload = profile.payload(); + if (!equals(profile.payload_schema(), TACTILE_BINDING_PROFILE_SCHEMA) || payload == nullptr) + { + throw std::invalid_argument("known tactile binding profile has the wrong payload schema"); + } + flatbuffers::Verifier verifier(payload->data(), payload->size()); + if (!verifier.VerifyBuffer(nullptr)) + { + throw std::invalid_argument("tactile binding profile payload is not a valid FlatBuffer"); + } + const auto* binding = flatbuffers::GetRoot(payload->data()); + + switch (binding->kind()) + { + case TactileBindingKind_OPENXR_HAND: + { + if (!equals(profile.profile_id(), TACTILE_OPENXR_HAND_PROFILE_ID) || binding->openxr_hand() == nullptr || + binding->urdf() != nullptr || binding->isaaccapture_pose() != nullptr) + { + throw std::invalid_argument("OpenXR hand profile id, kind, and payload must agree"); + } + const auto* indices = binding->openxr_hand()->node_joint_indices(); + if (indices == nullptr || indices->size() != node_count) + { + throw std::invalid_argument("OpenXR hand profile must map every tactile node"); + } + std::unordered_set joints; + for (uint8_t joint : *indices) + { + if (joint >= 26 || !joints.insert(joint).second) + { + throw std::invalid_argument("OpenXR hand profile joint ordinals must be unique and less than 26"); + } + } + break; + } + case TactileBindingKind_URDF: + { + if (!equals(profile.profile_id(), TACTILE_URDF_PROFILE_ID) || binding->urdf() == nullptr || + binding->openxr_hand() != nullptr || binding->isaaccapture_pose() != nullptr) + { + throw std::invalid_argument("URDF profile id, kind, and payload must agree"); + } + const auto* mappings = binding->urdf()->nodes(); + if (mappings == nullptr || mappings->size() != node_count) + { + throw std::invalid_argument("URDF profile must map every tactile node"); + } + std::unordered_set mapped_nodes; + for (const auto* mapping : *mappings) + { + if (mapping == nullptr || mapping->node_index() >= node_count || mapping->entity_name() == nullptr || + mapping->entity_name()->size() == 0 || !mapped_nodes.insert(mapping->node_index()).second) + { + throw std::invalid_argument("URDF node mappings must be unique, in range, and named"); + } + } + bool resource_found = false; + if (const auto* resources = profile.resources(); resources != nullptr) + { + for (const auto* resource : *resources) + { + if (resource != nullptr && equals(resource->logical_id(), binding->urdf()->resource_id())) + { + resource_found = true; + if (resource->sha256() == nullptr || resource->sha256()->size() != 32) + { + throw std::invalid_argument("URDF content requires a 32-byte SHA-256 digest"); + } + if (resource->inline_data() != nullptr && resource->inline_data()->size() != 0 && + resource->inline_data()->size() != resource->byte_size()) + { + throw std::invalid_argument("embedded URDF size does not match its content descriptor"); + } + } + } + } + if (!resource_found) + { + throw std::invalid_argument("URDF profile resource_id does not resolve to profile content"); + } + break; + } + case TactileBindingKind_ISAACCAPTURE_POSE: + { + const auto* pose = binding->isaaccapture_pose(); + if (!equals(profile.profile_id(), TACTILE_ISAACCAPTURE_POSE_PROFILE_ID) || pose == nullptr || + binding->openxr_hand() != nullptr || binding->urdf() != nullptr) + { + throw std::invalid_argument("IsaacCapture pose profile id, kind, and payload must agree"); + } + if (pose->pose_schema() == nullptr || pose->pose_schema()->size() == 0 || + ((pose->tracker_name() == nullptr || pose->tracker_name()->size() == 0) && + (pose->collection_id() == nullptr || pose->collection_id()->size() == 0)) || + pose->node_pose_indices() == nullptr || pose->node_pose_indices()->size() != node_count) + { + throw std::invalid_argument("IsaacCapture pose profile must identify a stream and map every tactile node"); + } + break; + } + default: + throw std::invalid_argument("tactile binding profile kind must not be NONE"); + } +} + +inline void validate_tactile_metadata(const TactileMetadata& metadata) +{ + if (metadata.schema_version() == 0) + { + throw std::invalid_argument("schema_version must be positive"); + } + if (metadata.measurement() == nullptr) + { + throw std::invalid_argument("measurement is required"); + } + validate_tactile_measurement(*metadata.measurement()); + + const auto* nodes = metadata.nodes(); + const size_t node_count = nodes != nullptr ? nodes->size() : 0; + std::unordered_set node_ids; + if (nodes != nullptr) + { + for (const auto* node : *nodes) + { + if (node == nullptr || node->node_id() == nullptr || node->node_id()->size() == 0 || + node->bind_pose() == nullptr) + { + throw std::invalid_argument("every node requires a non-empty node_id and bind_pose"); + } + if (!node_ids.insert(node->node_id()->str()).second) + { + throw std::invalid_argument("node_id values must be unique"); + } + } + } + + const auto* taxels = metadata.taxels(); + if (taxels == nullptr || taxels->size() == 0) + { + throw std::invalid_argument("at least one taxel is required"); + } + std::unordered_set taxel_ids; + for (const auto* taxel : *taxels) + { + if (taxel == nullptr || taxel->taxel_id() == nullptr || taxel->taxel_id()->size() == 0 || taxel->pose() == nullptr) + { + throw std::invalid_argument("every taxel requires a non-empty taxel_id and pose"); + } + if (!taxel_ids.insert(taxel->taxel_id()->str()).second) + { + throw std::invalid_argument("taxel_id values must be unique"); + } + + const auto* skin = taxel->skin(); + if (skin == nullptr || skin->size() == 0) + { + continue; + } + float sum = 0.0F; + std::unordered_set influenced_nodes; + for (const auto* influence : *skin) + { + if (influence->node_index() >= node_count) + { + throw std::invalid_argument("taxel skin references an out-of-range node"); + } + if (!std::isfinite(influence->weight()) || influence->weight() <= 0.0F) + { + throw std::invalid_argument("taxel skin weights must be finite and positive"); + } + if (!influenced_nodes.insert(influence->node_index()).second) + { + throw std::invalid_argument("taxel skin must not repeat a node"); + } + sum += influence->weight(); + } + if (std::abs(sum - 1.0F) > 1.0e-5F) + { + throw std::invalid_argument("taxel skin weights must sum to 1"); + } + } + + std::unordered_set profile_ids; + if (const auto* profiles = metadata.profiles(); profiles != nullptr) + { + for (const auto* profile : *profiles) + { + if (profile == nullptr || profile->profile_id() == nullptr || profile->profile_id()->size() == 0) + { + throw std::invalid_argument("every semantic profile requires a profile_id"); + } + if (!profile_ids.insert(profile->profile_id()->str()).second) + { + throw std::invalid_argument("profile_id values must be unique"); + } + if (equals(profile->profile_id(), TACTILE_OPENXR_HAND_PROFILE_ID) || + equals(profile->profile_id(), TACTILE_URDF_PROFILE_ID) || + equals(profile->profile_id(), TACTILE_ISAACCAPTURE_POSE_PROFILE_ID)) + { + validate_tactile_binding_profile(*profile, node_count); + } + } + } +} + +inline void validate_tactile_data(const TactileMetadata& metadata, const TactileData& data) +{ + validate_tactile_metadata(metadata); + const size_t taxel_count = metadata.taxels()->size(); + const size_t expected_values = taxel_count * metadata.measurement()->component_count(); + if (data.values() == nullptr || data.values()->size() != expected_values) + { + throw std::invalid_argument("tactile value count does not match metadata"); + } + if (data.status() != nullptr && data.status()->size() != 0 && data.status()->size() != taxel_count) + { + throw std::invalid_argument("taxel status count must be zero or match metadata"); + } +} + +} // namespace core diff --git a/src/core/schema/fbs/profile.fbs b/src/core/schema/fbs/profile.fbs new file mode 100644 index 0000000000..5f5230ba2c --- /dev/null +++ b/src/core/schema/fbs/profile.fbs @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace core; + +// How a consumer handles a profile it does not understand. +enum ProfileHandling : ubyte { + // The core payload remains meaningful without this profile. + OPTIONAL = 0, + + // The profile is required for semantic interpretation. Unknown consumers may + // preserve and forward the bytes, but must not claim to understand the payload. + REQUIRED = 1, +} + +// A stable reference to content used by a profile, such as a URDF. +table ContentDescriptor { + // Profile-local stable identifier referenced by the typed payload. + logical_id: string (id: 0); + + // IANA media type when one exists, for example "application/xml". + media_type: string (id: 1); + + // Publisher-defined asset version. Integrity comes from sha256, not this label. + version: string (id: 2); + + // Ordered retrieval locations. A recording remains self-contained when inline_data + // is present; URIs are alternatives, not the asset identity. + uris: [string] (id: 3); + + // SHA-256 digest of the referenced bytes. Empty only when no bytes are referenced. + sha256: [ubyte] (id: 4); + + // Size of the referenced bytes, before transport encoding. + byte_size: uint64 (id: 5); + + // Optional embedded fallback. When present, it must match sha256 and byte_size. + inline_data: [ubyte] (id: 6); +} + +// Versioned, namespaced, typed extension data. +// +// profile_id identifies the semantics and version_major controls compatibility. +// payload_schema names the fully qualified FlatBuffers root stored in payload. +// Keeping the payload opaque lets an unaware relay preserve profiles losslessly. +table SemanticProfile { + profile_id: string (id: 0); + version_major: uint16 (id: 1); + version_minor: uint16 (id: 2); + handling: ProfileHandling = OPTIONAL (id: 3); + payload_schema: string (id: 4); + payload: [ubyte] (id: 5); + resources: [ContentDescriptor] (id: 6); +} + +root_type SemanticProfile; diff --git a/src/core/schema/fbs/tactile.fbs b/src/core/schema/fbs/tactile.fbs new file mode 100644 index 0000000000..40b55f6381 --- /dev/null +++ b/src/core/schema/fbs/tactile.fbs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +include "timestamp.fbs"; + +namespace core; + +// Per-taxel quality. An absent status vector means every taxel is VALID. +enum TaxelStatus : ubyte { + VALID = 0, + INVALID = 1, + SATURATED = 2, + UNAVAILABLE = 3, +} + +// One frame of tactile measurements. Values are taxel-major in the order declared by +// TactileMetadata. Each taxel contributes measurement.component_count consecutive floats. +table TactileData { + values: [float] (id: 0); + status: [TaxelStatus] (id: 1); +} + +// MCAP supplies the timestamp envelope; it is not duplicated in TactileData. +table TactileDataRecord { + data: TactileData (id: 0); + timestamp: DeviceDataTimestamp (id: 1); +} + +root_type TactileDataRecord; diff --git a/src/core/schema/fbs/tactile_binding_profile.fbs b/src/core/schema/fbs/tactile_binding_profile.fbs new file mode 100644 index 0000000000..e1a2dcb408 --- /dev/null +++ b/src/core/schema/fbs/tactile_binding_profile.fbs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace core; + +enum TactileHandedness : ubyte { + UNSPECIFIED = 0, + LEFT = 1, + RIGHT = 2, +} + +// Maps every TactileMetadata node to an XrHandJointEXT ordinal. +table OpenXRHandTactileBinding { + handedness: TactileHandedness (id: 0); + node_joint_indices: [ubyte] (id: 1); +} + +enum UrdfEntityKind : ubyte { + LINK = 0, + JOINT = 1, +} + +table UrdfNodeBinding { + node_index: uint32 (id: 0); + entity_kind: UrdfEntityKind (id: 1); + entity_name: string (id: 2); +} + +// resource_id selects a ContentDescriptor in the enclosing SemanticProfile. +table UrdfTactileBinding { + resource_id: string (id: 0); + nodes: [UrdfNodeBinding] (id: 1); +} + +// IsaacCapture session association. node_pose_indices maps tactile nodes to elements of +// pose_schema; the identifiers distinguish concurrent subjects, devices, and skeletons. +table IsaacCapturePoseTactileBinding { + pose_schema: string (id: 0); + tracker_name: string (id: 1); + collection_id: string (id: 2); + channel: string (id: 3); + subject_id: string (id: 4); + device_instance_id: string (id: 5); + skeleton_instance_id: string (id: 6); + node_pose_indices: [uint32] (id: 7); +} + +enum TactileBindingKind : ubyte { + NONE = 0, + OPENXR_HAND = 1, + URDF = 2, + ISAACCAPTURE_POSE = 3, +} + +// Payload of a SemanticProfile. Exactly the field selected by kind is present. +table TactileBindingProfile { + kind: TactileBindingKind (id: 0); + openxr_hand: OpenXRHandTactileBinding (id: 1); + urdf: UrdfTactileBinding (id: 2); + isaaccapture_pose: IsaacCapturePoseTactileBinding (id: 3); +} + +root_type TactileBindingProfile; diff --git a/src/core/schema/fbs/tactile_metadata.fbs b/src/core/schema/fbs/tactile_metadata.fbs new file mode 100644 index 0000000000..1346f60077 --- /dev/null +++ b/src/core/schema/fbs/tactile_metadata.fbs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +include "pose.fbs"; +include "profile.fbs"; + +namespace core; + +// Minimal quantities standardized by tactile schema v1. New modalities are profiles or +// appended enum values rather than unrelated fields in every sample. +enum TactileQuantity : ubyte { + UNKNOWN = 0, + FORCE = 1, + PRESSURE = 2, + RAW = 3, +} + +enum TactileUnit : ubyte { + UNKNOWN = 0, + NEWTON = 1, + PASCAL = 2, + COUNT = 3, +} + +enum TactileValueFrame : ubyte { + NONE = 0, + TAXEL = 1, +} + +// Semantics shared by every taxel value in one tracker. +table TactileMeasurement { + quantity: TactileQuantity (id: 0); + unit: TactileUnit (id: 1); + + // FORCE supports 1 (+Z normal component) or 3 (+X,+Y,+Z). PRESSURE and RAW use 1. + component_count: ubyte (id: 2); + + // FORCE is expressed in the taxel frame. Scalar quantities have no vector frame. + frame: TactileValueFrame (id: 3); +} + +// One deformation node at the reference state. Its stable node_id is local to this shape; +// profiles map node indices to OpenXR joints, URDF links/joints, or another pose source. +table TactileNode { + node_id: string (id: 0); + bind_pose: Pose (id: 1); +} + +// One non-zero linear-blend-skinning influence. +struct TactileSkinWeight { + node_index: uint32; + weight: float; +} + +// Taxel geometry at the reference state. pose maps the right-handed taxel frame into +// shape_frame_id: +Z is the outward surface normal N, +X is tangent T, and +Y is N x T. +table TactileTaxel { + taxel_id: string (id: 0); + pose: Pose (id: 1); + + // Empty means rigid in shape_frame_id. Otherwise weights are non-zero, refer to nodes, + // and sum to 1.0. Skinning transforms the position and basis from bind to current pose. + skin: [TactileSkinWeight] (id: 2); +} + +// Immutable description published through the tracker's standard metadata channel. +table TactileMetadata { + schema_version: uint16 = 1 (id: 0); + shape_frame_id: string (id: 1); + measurement: TactileMeasurement (id: 2); + nodes: [TactileNode] (id: 3); + taxels: [TactileTaxel] (id: 4); + + // Binding, calibration, and application integration live in versioned typed profiles. + // Unknown profiles remain byte-preserving and REQUIRED profiles fail closed semantically. + profiles: [SemanticProfile] (id: 5); +} + +root_type TactileMetadata; diff --git a/src/core/schema/golden/profile.bfbs b/src/core/schema/golden/profile.bfbs new file mode 100644 index 0000000000..2b9e6850a2 Binary files /dev/null and b/src/core/schema/golden/profile.bfbs differ diff --git a/src/core/schema/golden/tactile.bfbs b/src/core/schema/golden/tactile.bfbs new file mode 100644 index 0000000000..e3c1b744a2 Binary files /dev/null and b/src/core/schema/golden/tactile.bfbs differ diff --git a/src/core/schema/golden/tactile_binding_profile.bfbs b/src/core/schema/golden/tactile_binding_profile.bfbs new file mode 100644 index 0000000000..e28056bfff Binary files /dev/null and b/src/core/schema/golden/tactile_binding_profile.bfbs differ diff --git a/src/core/schema/golden/tactile_metadata.bfbs b/src/core/schema/golden/tactile_metadata.bfbs new file mode 100644 index 0000000000..0dd2144305 Binary files /dev/null and b/src/core/schema/golden/tactile_metadata.bfbs differ diff --git a/src/core/schema/python/CMakeLists.txt b/src/core/schema/python/CMakeLists.txt index b34cac9c90..bf9f7c9815 100644 --- a/src/core/schema/python/CMakeLists.txt +++ b/src/core/schema/python/CMakeLists.txt @@ -17,6 +17,7 @@ pybind11_add_module(schema_py pedals_bindings.h plugin_device_status_bindings.h pose_bindings.h + profile_bindings.h schema_array_views.h schema_serialized.h se3_tracker_bindings.h @@ -28,6 +29,9 @@ pybind11_add_module(schema_py schema_module.cpp spacemouse_bindings.h steering_wheel_bindings.h + tactile_binding_profile_bindings.h + tactile_bindings.h + tactile_metadata_bindings.h tracker_metadata_bindings.h ) diff --git a/src/core/schema/python/profile_bindings.h b/src/core/schema/python/profile_bindings.h new file mode 100644 index 0000000000..16d66c037c --- /dev/null +++ b/src/core/schema/python/profile_bindings.h @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "schema_serialized.h" + +#include +#include +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace core +{ + +inline void bind_profile(py::module& m) +{ + py::enum_(m, "ProfileHandling") + .value("OPTIONAL", ProfileHandling_OPTIONAL) + .value("REQUIRED", ProfileHandling_REQUIRED); + + serialized_class(m, "ContentDescriptor", "Versioned content referenced by a profile.") + .def(py::init( + [](const std::string& logical_id, const std::string& media_type, const std::string& version, + const std::vector& uris, py::bytes sha256, uint64_t byte_size, py::bytes inline_data) + { + const std::string digest = sha256; + const std::string embedded = inline_data; + if (!digest.empty() && digest.size() != 32) + { + throw py::value_error("sha256 must be empty or exactly 32 bytes"); + } + ContentDescriptorT native; + native.logical_id = logical_id; + native.media_type = media_type; + native.version = version; + native.uris = uris; + native.sha256.assign(digest.begin(), digest.end()); + native.byte_size = byte_size; + native.inline_data.assign(embedded.begin(), embedded.end()); + return pack(native); + }), + py::arg("logical_id"), py::arg("media_type") = std::string{}, py::arg("version") = std::string{}, + py::arg("uris") = std::vector{}, py::arg("sha256") = py::bytes(), py::arg("byte_size") = 0, + py::arg("inline_data") = py::bytes()) + .def_property_readonly("logical_id", string_field(&ContentDescriptor::logical_id)) + .def_property_readonly("media_type", string_field(&ContentDescriptor::media_type)) + .def_property_readonly("version", string_field(&ContentDescriptor::version)) + .def_property_readonly("uris", + [](const Serialized& self) + { + std::vector values; + const auto* uris = self->uris(); + if (uris != nullptr) + { + values.reserve(uris->size()); + for (const auto* uri : *uris) + { + values.push_back(uri != nullptr ? uri->str() : std::string{}); + } + } + return values; + }) + .def_property_readonly("sha256", bytes_field(&ContentDescriptor::sha256)) + .def_property_readonly("byte_size", field(&ContentDescriptor::byte_size)) + .def_property_readonly("inline_data", bytes_field(&ContentDescriptor::inline_data)); + + serialized_class(m, "SemanticProfile", "Versioned typed profile with an opaque payload.") + .def(py::init( + [](const std::string& profile_id, uint16_t version_major, uint16_t version_minor, + ProfileHandling handling, const std::string& payload_schema, py::bytes payload, + const std::vector>& resources) + { + if (profile_id.empty()) + { + throw py::value_error("profile_id must not be empty"); + } + const std::string data = payload; + if (!data.empty() && payload_schema.empty()) + { + throw py::value_error("payload_schema must name non-empty payload bytes"); + } + SemanticProfileT native; + native.profile_id = profile_id; + native.version_major = version_major; + native.version_minor = version_minor; + native.handling = handling; + native.payload_schema = payload_schema; + native.payload.assign(data.begin(), data.end()); + native.resources = to_native_vector(resources, "resources"); + return pack(native); + }), + py::arg("profile_id"), py::arg("version_major") = 1, py::arg("version_minor") = 0, + py::arg("handling") = ProfileHandling_OPTIONAL, py::arg("payload_schema") = std::string{}, + py::arg("payload") = py::bytes(), py::arg("resources") = std::vector>{}) + .def_property_readonly("profile_id", string_field(&SemanticProfile::profile_id)) + .def_property_readonly("version_major", field(&SemanticProfile::version_major)) + .def_property_readonly("version_minor", field(&SemanticProfile::version_minor)) + .def_property_readonly("handling", field(&SemanticProfile::handling)) + .def_property_readonly("payload_schema", string_field(&SemanticProfile::payload_schema)) + .def_property_readonly("payload", bytes_field(&SemanticProfile::payload)) + .def_property_readonly( + "resources", [](const Serialized& self) { return narrow_vector(self, self->resources()); }) + .def("__repr__", + [](const Serialized& self) + { + return "SemanticProfile(profile_id='" + + (self->profile_id() != nullptr ? self->profile_id()->str() : std::string{}) + + "', version=" + std::to_string(self->version_major()) + "." + + std::to_string(self->version_minor()) + ")"; + }); +} + +} // namespace core diff --git a/src/core/schema/python/schema_module.cpp b/src/core/schema/python/schema_module.cpp index 7d7a950be7..b37aad8e39 100644 --- a/src/core/schema/python/schema_module.cpp +++ b/src/core/schema/python/schema_module.cpp @@ -21,6 +21,7 @@ #include "pedals_bindings.h" #include "plugin_device_status_bindings.h" #include "pose_bindings.h" +#include "profile_bindings.h" #include "se3_tracker_bindings.h" #include "soma_body_joint_poses_bindings.h" #include "soma_body_joint_rotations_bindings.h" @@ -29,6 +30,9 @@ #include "soma_hand_joint_rotations_bindings.h" #include "spacemouse_bindings.h" #include "steering_wheel_bindings.h" +#include "tactile_binding_profile_bindings.h" +#include "tactile_bindings.h" +#include "tactile_metadata_bindings.h" #include "timestamp_bindings.h" #include "tracker_metadata_bindings.h" @@ -44,6 +48,14 @@ PYBIND11_MODULE(_schema, m) // Bind pose types (Point, Quaternion, Pose structs). core::bind_pose(m); + // Bind generic typed profiles before schemas that compose them. + core::bind_profile(m); + + // Bind tactile profile payloads and metadata before per-frame tactile data. + core::bind_tactile_binding_profile(m); + core::bind_tactile_metadata(m); + core::bind_tactile(m); + // Bind head types (HeadPose table). core::bind_head(m); diff --git a/src/core/schema/python/schema_serialized.h b/src/core/schema/python/schema_serialized.h index 3fcb8a5855..282acc41d0 100644 --- a/src/core/schema/python/schema_serialized.h +++ b/src/core/schema/python/schema_serialized.h @@ -26,6 +26,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,19 @@ template py::class_> serialized_class(py::module& m, const char* name, const char* doc) { return py::class_>(m, name, doc) + .def_static( + "from_bytes", + [name](py::bytes encoded) + { + const std::string bytes = encoded; + flatbuffers::Verifier verifier(reinterpret_cast(bytes.data()), bytes.size()); + if (!verifier.VerifyBuffer(nullptr)) + { + throw py::value_error(std::string(name) + ": invalid FlatBuffer"); + } + return Serialized::adopt(std::vector(bytes.begin(), bytes.end())); + }, + py::arg("encoded"), "Validate and adopt standalone FlatBuffer bytes rooted at this table.") .def( "to_bytes", [](const Serialized& self) @@ -58,6 +72,17 @@ py::class_> serialized_class(py::module& m, const char* name, cons "Copy this table into standalone FlatBuffer bytes, including when read from a record."); } +//! `field()` for a byte vector, copied into Python bytes. An absent vector is empty. +template +auto bytes_field(const flatbuffers::Vector* (T::*getter)() const) +{ + return [getter](const Serialized& self) + { + const auto* values = ((*self).*getter)(); + return values != nullptr ? py::bytes(reinterpret_cast(values->data()), values->size()) : py::bytes(); + }; +} + /*! * @brief Reads `getter` through the handle. * @@ -190,7 +215,8 @@ void bind_record(py::module& m, const char* name, const char* data_name) "timestamp", [](const Serialized& self) { return self->timestamp(); }, py::return_value_policy::reference_internal, "Capture timestamp, or None when absent.") .def("__repr__", - [name, data_name](const Serialized& self) { + [name, data_name](const Serialized& self) + { return std::string(name) + "(data=" + (self->data() != nullptr ? std::string(data_name) + "(...)" : "None") + ")"; }); diff --git a/src/core/schema/python/tactile_binding_profile_bindings.h b/src/core/schema/python/tactile_binding_profile_bindings.h new file mode 100644 index 0000000000..83c7ddb112 --- /dev/null +++ b/src/core/schema/python/tactile_binding_profile_bindings.h @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "schema_serialized.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace py = pybind11; + +namespace core +{ + +inline Serialized make_tactile_profile(const Serialized& binding, + ProfileHandling handling, + const std::vector>& resources) +{ + std::string_view profile_id; + switch (binding->kind()) + { + case TactileBindingKind_OPENXR_HAND: + profile_id = TACTILE_OPENXR_HAND_PROFILE_ID; + break; + case TactileBindingKind_URDF: + profile_id = TACTILE_URDF_PROFILE_ID; + break; + case TactileBindingKind_ISAACCAPTURE_POSE: + profile_id = TACTILE_ISAACCAPTURE_POSE_PROFILE_ID; + break; + default: + throw py::value_error("binding profile kind must not be NONE"); + } + + SemanticProfileT native; + native.profile_id = profile_id; + native.version_major = 1; + native.handling = handling; + native.payload_schema = TACTILE_BINDING_PROFILE_SCHEMA; + const auto payload = binding.buffer(); + native.payload.assign(payload.begin(), payload.end()); + native.resources = to_native_vector(resources, "resources"); + return pack(native); +} + +inline void bind_tactile_binding_profile(py::module& m) +{ + m.attr("TACTILE_OPENXR_HAND_PROFILE_ID") = std::string(TACTILE_OPENXR_HAND_PROFILE_ID); + m.attr("TACTILE_URDF_PROFILE_ID") = std::string(TACTILE_URDF_PROFILE_ID); + m.attr("TACTILE_ISAACCAPTURE_POSE_PROFILE_ID") = std::string(TACTILE_ISAACCAPTURE_POSE_PROFILE_ID); + m.attr("TACTILE_BINDING_PROFILE_SCHEMA") = std::string(TACTILE_BINDING_PROFILE_SCHEMA); + + py::enum_(m, "TactileHandedness") + .value("UNSPECIFIED", TactileHandedness_UNSPECIFIED) + .value("LEFT", TactileHandedness_LEFT) + .value("RIGHT", TactileHandedness_RIGHT); + + py::enum_(m, "UrdfEntityKind").value("LINK", UrdfEntityKind_LINK).value("JOINT", UrdfEntityKind_JOINT); + + py::enum_(m, "TactileBindingKind") + .value("NONE", TactileBindingKind_NONE) + .value("OPENXR_HAND", TactileBindingKind_OPENXR_HAND) + .value("URDF", TactileBindingKind_URDF) + .value("ISAACCAPTURE_POSE", TactileBindingKind_ISAACCAPTURE_POSE); + + serialized_class( + m, "OpenXRHandTactileBinding", "Map tactile nodes to XrHandJointEXT ordinals.") + .def(py::init( + [](TactileHandedness handedness, const std::vector& node_joint_indices) + { + if (handedness == TactileHandedness_UNSPECIFIED) + { + throw py::value_error("handedness must be LEFT or RIGHT"); + } + for (uint8_t index : node_joint_indices) + { + if (index >= 26) + { + throw py::value_error("OpenXR hand joint indices must be less than 26"); + } + } + OpenXRHandTactileBindingT native; + native.handedness = handedness; + native.node_joint_indices = node_joint_indices; + return pack(native); + }), + py::arg("handedness"), py::arg("node_joint_indices")) + .def_property_readonly("handedness", field(&OpenXRHandTactileBinding::handedness)) + .def_property_readonly("node_joint_indices", vector_field(&OpenXRHandTactileBinding::node_joint_indices)); + + serialized_class(m, "UrdfNodeBinding", "Map one tactile node to a URDF link or joint.") + .def(py::init( + [](uint32_t node_index, UrdfEntityKind entity_kind, const std::string& entity_name) + { + if (entity_name.empty()) + { + throw py::value_error("entity_name must not be empty"); + } + UrdfNodeBindingT native; + native.node_index = node_index; + native.entity_kind = entity_kind; + native.entity_name = entity_name; + return pack(native); + }), + py::arg("node_index"), py::arg("entity_kind"), py::arg("entity_name")) + .def_property_readonly("node_index", field(&UrdfNodeBinding::node_index)) + .def_property_readonly("entity_kind", field(&UrdfNodeBinding::entity_kind)) + .def_property_readonly("entity_name", string_field(&UrdfNodeBinding::entity_name)); + + serialized_class(m, "UrdfTactileBinding", "Bind tactile nodes to a referenced URDF.") + .def(py::init( + [](const std::string& resource_id, const std::vector>& nodes) + { + if (resource_id.empty()) + { + throw py::value_error("resource_id must not be empty"); + } + UrdfTactileBindingT native; + native.resource_id = resource_id; + native.nodes = to_native_vector(nodes, "nodes"); + return pack(native); + }), + py::arg("resource_id"), py::arg("nodes") = std::vector>{}) + .def_property_readonly("resource_id", string_field(&UrdfTactileBinding::resource_id)) + .def_property_readonly( + "nodes", [](const Serialized& self) { return narrow_vector(self, self->nodes()); }); + + serialized_class( + m, "IsaacCapturePoseTactileBinding", "Associate tactile nodes with an IsaacCapture pose stream.") + .def(py::init( + [](const std::string& pose_schema, const std::string& tracker_name, const std::string& collection_id, + const std::string& channel, const std::string& subject_id, const std::string& device_instance_id, + const std::string& skeleton_instance_id, const std::vector& node_pose_indices) + { + if (pose_schema.empty()) + { + throw py::value_error("pose_schema must not be empty"); + } + if (tracker_name.empty() && collection_id.empty()) + { + throw py::value_error("tracker_name or collection_id must identify the pose stream"); + } + IsaacCapturePoseTactileBindingT native; + native.pose_schema = pose_schema; + native.tracker_name = tracker_name; + native.collection_id = collection_id; + native.channel = channel; + native.subject_id = subject_id; + native.device_instance_id = device_instance_id; + native.skeleton_instance_id = skeleton_instance_id; + native.node_pose_indices = node_pose_indices; + return pack(native); + }), + py::arg("pose_schema"), py::arg("tracker_name") = std::string{}, py::arg("collection_id") = std::string{}, + py::arg("channel") = std::string{}, py::arg("subject_id") = std::string{}, + py::arg("device_instance_id") = std::string{}, py::arg("skeleton_instance_id") = std::string{}, + py::arg("node_pose_indices") = std::vector{}) + .def_property_readonly("pose_schema", string_field(&IsaacCapturePoseTactileBinding::pose_schema)) + .def_property_readonly("tracker_name", string_field(&IsaacCapturePoseTactileBinding::tracker_name)) + .def_property_readonly("collection_id", string_field(&IsaacCapturePoseTactileBinding::collection_id)) + .def_property_readonly("channel", string_field(&IsaacCapturePoseTactileBinding::channel)) + .def_property_readonly("subject_id", string_field(&IsaacCapturePoseTactileBinding::subject_id)) + .def_property_readonly("device_instance_id", string_field(&IsaacCapturePoseTactileBinding::device_instance_id)) + .def_property_readonly( + "skeleton_instance_id", string_field(&IsaacCapturePoseTactileBinding::skeleton_instance_id)) + .def_property_readonly("node_pose_indices", vector_field(&IsaacCapturePoseTactileBinding::node_pose_indices)); + + serialized_class(m, "TactileBindingProfile", "Typed tactile binding profile payload.") + .def(py::init( + [](const Serialized* openxr_hand, const Serialized* urdf, + const Serialized* isaaccapture_pose) + { + const int selected = (openxr_hand != nullptr) + (urdf != nullptr) + (isaaccapture_pose != nullptr); + if (selected != 1) + { + throw py::value_error("exactly one tactile binding must be supplied"); + } + TactileBindingProfileT native; + if (openxr_hand != nullptr) + { + native.kind = TactileBindingKind_OPENXR_HAND; + native.openxr_hand = std::make_shared(); + (*openxr_hand)->UnPackTo(native.openxr_hand.get()); + } + else if (urdf != nullptr) + { + native.kind = TactileBindingKind_URDF; + native.urdf = std::make_shared(); + (*urdf)->UnPackTo(native.urdf.get()); + } + else + { + native.kind = TactileBindingKind_ISAACCAPTURE_POSE; + native.isaaccapture_pose = std::make_shared(); + (*isaaccapture_pose)->UnPackTo(native.isaaccapture_pose.get()); + } + return pack(native); + }), + py::arg("openxr_hand").none(true) = nullptr, py::arg("urdf").none(true) = nullptr, + py::arg("isaaccapture_pose").none(true) = nullptr) + .def_property_readonly("kind", field(&TactileBindingProfile::kind)) + .def_property_readonly( + "openxr_hand", [](const Serialized& self) -> py::object + { return self->openxr_hand() != nullptr ? py::cast(self.narrow(self->openxr_hand())) : py::none(); }) + .def_property_readonly("urdf", [](const Serialized& self) -> py::object + { return self->urdf() != nullptr ? py::cast(self.narrow(self->urdf())) : py::none(); }) + .def_property_readonly("isaaccapture_pose", + [](const Serialized& self) -> py::object + { + return self->isaaccapture_pose() != nullptr ? + py::cast(self.narrow(self->isaaccapture_pose())) : + py::none(); + }); + + m.def("make_tactile_binding_profile", &make_tactile_profile, py::arg("binding"), + py::arg("handling") = ProfileHandling_REQUIRED, + py::arg("resources") = std::vector>{}, + "Wrap a typed tactile binding in its versioned SemanticProfile envelope."); +} + +} // namespace core diff --git a/src/core/schema/python/tactile_bindings.h b/src/core/schema/python/tactile_bindings.h new file mode 100644 index 0000000000..32251f3460 --- /dev/null +++ b/src/core/schema/python/tactile_bindings.h @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "schema_serialized.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace py = pybind11; + +namespace core +{ + +inline void bind_tactile(py::module& m) +{ + py::enum_(m, "TaxelStatus") + .value("VALID", TaxelStatus_VALID) + .value("INVALID", TaxelStatus_INVALID) + .value("SATURATED", TaxelStatus_SATURATED) + .value("UNAVAILABLE", TaxelStatus_UNAVAILABLE); + + serialized_class(m, "TactileData", "One taxel-major tactile measurement frame.") + .def(py::init( + [](const std::vector& values, const std::vector& status) + { + TactileDataT native; + native.values = values; + native.status = status; + return pack(native); + }), + py::arg("values") = std::vector{}, py::arg("status") = std::vector{}) + .def_property_readonly("values", vector_field(&TactileData::values)) + .def_property_readonly("status", + [](const Serialized& self) + { + std::vector status; + const auto* encoded = self->status(); + if (encoded != nullptr) + { + status.reserve(encoded->size()); + for (uint8_t value : *encoded) + { + status.push_back(static_cast(value)); + } + } + return status; + }) + .def("__repr__", + [](const Serialized& self) + { + return "TactileData(values=" + std::to_string(self->values() != nullptr ? self->values()->size() : 0) + + ")"; + }); + + m.def( + "validate_tactile_data", [](const Serialized& metadata, const Serialized& data) + { validate_tactile_data(*metadata, *data); }, py::arg("metadata"), py::arg("data"), + "Validate a tactile frame against its immutable metadata."); + + bind_record(m, "TactileDataRecord", "TactileData"); +} + +} // namespace core diff --git a/src/core/schema/python/tactile_metadata_bindings.h b/src/core/schema/python/tactile_metadata_bindings.h new file mode 100644 index 0000000000..2bf05e16f2 --- /dev/null +++ b/src/core/schema/python/tactile_metadata_bindings.h @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "schema_serialized.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace core +{ + +inline void bind_tactile_metadata(py::module& m) +{ + py::enum_(m, "TactileQuantity") + .value("UNKNOWN", TactileQuantity_UNKNOWN) + .value("FORCE", TactileQuantity_FORCE) + .value("PRESSURE", TactileQuantity_PRESSURE) + .value("RAW", TactileQuantity_RAW); + + py::enum_(m, "TactileUnit") + .value("UNKNOWN", TactileUnit_UNKNOWN) + .value("NEWTON", TactileUnit_NEWTON) + .value("PASCAL", TactileUnit_PASCAL) + .value("COUNT", TactileUnit_COUNT); + + py::enum_(m, "TactileValueFrame") + .value("NONE", TactileValueFrame_NONE) + .value("TAXEL", TactileValueFrame_TAXEL); + + py::class_(m, "TactileSkinWeight", "One tactile-node skinning influence.") + .def(py::init(), py::arg("node_index"), py::arg("weight")) + .def_property_readonly("node_index", &TactileSkinWeight::node_index) + .def_property_readonly("weight", &TactileSkinWeight::weight); + + serialized_class(m, "TactileMeasurement", "Semantics shared by each taxel value.") + .def(py::init( + [](TactileQuantity quantity, TactileUnit unit, uint8_t component_count, TactileValueFrame frame) + { + TactileMeasurementT native; + native.quantity = quantity; + native.unit = unit; + native.component_count = component_count; + native.frame = frame; + auto encoded = pack(native); + validate_tactile_measurement(*encoded); + return encoded; + }), + py::arg("quantity"), py::arg("unit"), py::arg("component_count"), py::arg("frame")) + .def_property_readonly("quantity", field(&TactileMeasurement::quantity)) + .def_property_readonly("unit", field(&TactileMeasurement::unit)) + .def_property_readonly("component_count", field(&TactileMeasurement::component_count)) + .def_property_readonly("frame", field(&TactileMeasurement::frame)); + + serialized_class(m, "TactileNode", "One deformation node in shape-frame coordinates.") + .def(py::init( + [](const std::string& node_id, const Pose& bind_pose) + { + TactileNodeT native; + native.node_id = node_id; + native.bind_pose = std::make_shared(bind_pose); + return pack(native); + }), + py::arg("node_id"), py::arg("bind_pose")) + .def_property_readonly("node_id", string_field(&TactileNode::node_id)) + .def_property_readonly("bind_pose", field(&TactileNode::bind_pose), py::return_value_policy::reference_internal); + + serialized_class(m, "TactileTaxel", "Taxel geometry and optional deformation binding.") + .def(py::init( + [](const std::string& taxel_id, const Pose& pose, const std::vector& skin) + { + TactileTaxelT native; + native.taxel_id = taxel_id; + native.pose = std::make_shared(pose); + native.skin = skin; + return pack(native); + }), + py::arg("taxel_id"), py::arg("pose"), py::arg("skin") = std::vector{}) + .def_property_readonly("taxel_id", string_field(&TactileTaxel::taxel_id)) + .def_property_readonly("pose", field(&TactileTaxel::pose), py::return_value_policy::reference_internal) + .def_property_readonly("skin", + [](const Serialized& self) + { + std::vector values; + const auto* skin = self->skin(); + if (skin != nullptr) + { + values.reserve(skin->size()); + for (const auto* influence : *skin) + { + values.push_back(*influence); + } + } + return values; + }); + + serialized_class(m, "TactileMetadata", "Immutable tactile geometry and semantics.") + .def(py::init( + [](const std::string& shape_frame_id, const Serialized& measurement, + const std::vector>& taxels, const std::vector>& nodes, + const std::vector>& profiles, uint16_t schema_version) + { + TactileMetadataT native; + native.schema_version = schema_version; + native.shape_frame_id = shape_frame_id; + native.measurement = std::make_shared(); + measurement->UnPackTo(native.measurement.get()); + native.nodes = to_native_vector(nodes, "nodes"); + native.taxels = to_native_vector(taxels, "taxels"); + native.profiles = to_native_vector(profiles, "profiles"); + auto encoded = pack(native); + validate_tactile_metadata(*encoded); + return encoded; + }), + py::arg("shape_frame_id"), py::arg("measurement"), py::arg("taxels"), + py::arg("nodes") = std::vector>{}, + py::arg("profiles") = std::vector>{}, py::arg("schema_version") = 1) + .def_property_readonly("schema_version", field(&TactileMetadata::schema_version)) + .def_property_readonly("shape_frame_id", string_field(&TactileMetadata::shape_frame_id)) + .def_property_readonly( + "measurement", [](const Serialized& self) { return self.narrow(self->measurement()); }) + .def_property_readonly( + "nodes", [](const Serialized& self) { return narrow_vector(self, self->nodes()); }) + .def_property_readonly( + "taxels", [](const Serialized& self) { return narrow_vector(self, self->taxels()); }) + .def_property_readonly( + "profiles", [](const Serialized& self) { return narrow_vector(self, self->profiles()); }) + .def("__repr__", + [](const Serialized& self) + { + return "TactileMetadata(shape_frame_id='" + + (self->shape_frame_id() != nullptr ? self->shape_frame_id()->str() : std::string{}) + + "', nodes=" + std::to_string(self->nodes() != nullptr ? self->nodes()->size() : 0) + + ", taxels=" + std::to_string(self->taxels() != nullptr ? self->taxels()->size() : 0) + ")"; + }); +} + +} // namespace core diff --git a/src/python/isaaccapture/schema/__init__.py b/src/python/isaaccapture/schema/__init__.py index 9b484fafb1..8f203a25b6 100644 --- a/src/python/isaaccapture/schema/__init__.py +++ b/src/python/isaaccapture/schema/__init__.py @@ -21,6 +21,37 @@ Point, Quaternion, Pose, + # Generic typed profiles. + ContentDescriptor, + ProfileHandling, + SemanticProfile, + # Tactile binding profiles. + TACTILE_BINDING_PROFILE_SCHEMA, + TACTILE_ISAACCAPTURE_POSE_PROFILE_ID, + TACTILE_OPENXR_HAND_PROFILE_ID, + TACTILE_URDF_PROFILE_ID, + IsaacCapturePoseTactileBinding, + OpenXRHandTactileBinding, + TactileBindingKind, + TactileBindingProfile, + TactileHandedness, + UrdfEntityKind, + UrdfNodeBinding, + UrdfTactileBinding, + make_tactile_binding_profile, + # Tactile metadata and samples. + TaxelStatus, + TactileData, + TactileDataRecord, + TactileMeasurement, + TactileMetadata, + TactileNode, + TactileQuantity, + TactileSkinWeight, + TactileTaxel, + TactileUnit, + TactileValueFrame, + validate_tactile_data, # Head-related types. HeadPose, HeadPoseRecord, @@ -157,6 +188,37 @@ def __getattr__(name: str): "Point", "Quaternion", "Pose", + # Generic typed profiles. + "ContentDescriptor", + "ProfileHandling", + "SemanticProfile", + # Tactile binding profiles. + "TACTILE_BINDING_PROFILE_SCHEMA", + "TACTILE_ISAACCAPTURE_POSE_PROFILE_ID", + "TACTILE_OPENXR_HAND_PROFILE_ID", + "TACTILE_URDF_PROFILE_ID", + "IsaacCapturePoseTactileBinding", + "OpenXRHandTactileBinding", + "TactileBindingKind", + "TactileBindingProfile", + "TactileHandedness", + "UrdfEntityKind", + "UrdfNodeBinding", + "UrdfTactileBinding", + "make_tactile_binding_profile", + # Tactile metadata and samples. + "TaxelStatus", + "TactileData", + "TactileDataRecord", + "TactileMeasurement", + "TactileMetadata", + "TactileNode", + "TactileQuantity", + "TactileSkinWeight", + "TactileTaxel", + "TactileUnit", + "TactileValueFrame", + "validate_tactile_data", # Head types. "HeadPose", "HeadPoseRecord", diff --git a/tests/cpp/core/schema/CMakeLists.txt b/tests/cpp/core/schema/CMakeLists.txt index 3fa2548b78..8e826650ad 100644 --- a/tests/cpp/core/schema/CMakeLists.txt +++ b/tests/cpp/core/schema/CMakeLists.txt @@ -6,12 +6,14 @@ cmake_minimum_required(VERSION 3.20) add_executable(schema_tests test_oak.cpp test_pose.cpp + test_profile.cpp test_head.cpp test_hand.cpp test_full_body.cpp test_controller.cpp test_pedals.cpp test_steering_wheel.cpp + test_tactile.cpp test_spacemouse.cpp test_gamepad.cpp test_plugin_device_status.cpp diff --git a/tests/cpp/core/schema/test_profile.cpp b/tests/cpp/core/schema/test_profile.cpp new file mode 100644 index 0000000000..b10689b41b --- /dev/null +++ b/tests/cpp/core/schema/test_profile.cpp @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include +#include + +TEST_CASE("Semantic profiles retain typed payloads and content references", "[unit][schema]") +{ + core::ContentDescriptorT resource; + resource.logical_id = "robot"; + resource.media_type = "application/xml"; + resource.version = "7"; + resource.uris = { "package://robot/model.urdf" }; + resource.sha256 = std::vector(32, 0x2a); + resource.byte_size = 128; + resource.inline_data = { '<', '/', '>' }; + + core::SemanticProfileT profile; + profile.profile_id = "org.example.robot.binding"; + profile.version_major = 2; + profile.version_minor = 3; + profile.handling = core::ProfileHandling_REQUIRED; + profile.payload_schema = "example.RobotBinding"; + profile.payload = { 1, 2, 3 }; + profile.resources.push_back(std::make_shared(resource)); + + flatbuffers::FlatBufferBuilder builder; + builder.Finish(core::SemanticProfile::Pack(builder, &profile)); + + flatbuffers::Verifier verifier(builder.GetBufferPointer(), builder.GetSize()); + REQUIRE(verifier.VerifyBuffer(nullptr)); + const auto* encoded = flatbuffers::GetRoot(builder.GetBufferPointer()); + CHECK(encoded->profile_id()->str() == "org.example.robot.binding"); + CHECK(encoded->handling() == core::ProfileHandling_REQUIRED); + REQUIRE(encoded->resources()->size() == 1); + CHECK(encoded->resources()->Get(0)->sha256()->size() == 32); +} diff --git a/tests/cpp/core/schema/test_tactile.cpp b/tests/cpp/core/schema/test_tactile.cpp new file mode 100644 index 0000000000..fb8b27fef1 --- /dev/null +++ b/tests/cpp/core/schema/test_tactile.cpp @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include +#include + +namespace +{ + +std::shared_ptr force_measurement() +{ + auto measurement = std::make_shared(); + measurement->quantity = core::TactileQuantity_FORCE; + measurement->unit = core::TactileUnit_NEWTON; + measurement->component_count = 3; + measurement->frame = core::TactileValueFrame_TAXEL; + return measurement; +} + +std::shared_ptr taxel(std::string id, uint32_t node_index) +{ + auto value = std::make_shared(); + value->taxel_id = std::move(id); + value->pose = std::make_shared(); + value->skin.emplace_back(node_index, 1.0F); + return value; +} + +core::TactileMetadataT metadata() +{ + core::TactileMetadataT value; + value.schema_version = 1; + value.shape_frame_id = "glove"; + value.measurement = force_measurement(); + auto node = std::make_shared(); + node->node_id = "index_distal"; + node->bind_pose = std::make_shared(); + value.nodes.push_back(std::move(node)); + value.taxels.push_back(taxel("taxel-0", 0)); + value.taxels.push_back(taxel("taxel-1", 0)); + return value; +} + +} // namespace + +TEST_CASE("Tactile metadata accepts a right-handed three-axis force layout", "[unit][schema]") +{ + auto native = metadata(); + flatbuffers::FlatBufferBuilder builder; + builder.Finish(core::TactileMetadata::Pack(builder, &native)); + const auto* encoded = flatbuffers::GetRoot(builder.GetBufferPointer()); + + CHECK_NOTHROW(core::validate_tactile_metadata(*encoded)); + CHECK(encoded->measurement()->component_count() == 3); + CHECK(encoded->taxels()->size() == 2); +} + +TEST_CASE("Tactile frames must match metadata dimensions", "[unit][schema]") +{ + auto metadata_native = metadata(); + flatbuffers::FlatBufferBuilder metadata_builder; + metadata_builder.Finish(core::TactileMetadata::Pack(metadata_builder, &metadata_native)); + const auto* encoded_metadata = flatbuffers::GetRoot(metadata_builder.GetBufferPointer()); + + core::TactileDataT data_native; + data_native.values = { 1.0F, 2.0F, 3.0F, 4.0F, 5.0F, 6.0F }; + flatbuffers::FlatBufferBuilder data_builder; + data_builder.Finish(core::TactileData::Pack(data_builder, &data_native)); + const auto* encoded_data = flatbuffers::GetRoot(data_builder.GetBufferPointer()); + CHECK_NOTHROW(core::validate_tactile_data(*encoded_metadata, *encoded_data)); + + data_native.values.pop_back(); + flatbuffers::FlatBufferBuilder short_builder; + short_builder.Finish(core::TactileData::Pack(short_builder, &data_native)); + const auto* short_data = flatbuffers::GetRoot(short_builder.GetBufferPointer()); + CHECK_THROWS_AS(core::validate_tactile_data(*encoded_metadata, *short_data), std::invalid_argument); +} + +TEST_CASE("Tactile skinning rejects invalid node references and weights", "[unit][schema]") +{ + auto native = metadata(); + native.taxels[0]->skin[0] = core::TactileSkinWeight(1, 1.0F); + flatbuffers::FlatBufferBuilder builder; + builder.Finish(core::TactileMetadata::Pack(builder, &native)); + const auto* encoded = flatbuffers::GetRoot(builder.GetBufferPointer()); + CHECK_THROWS_AS(core::validate_tactile_metadata(*encoded), std::invalid_argument); +} diff --git a/tests/python/core/schema/test_profile.py b/tests/python/core/schema/test_profile.py new file mode 100644 index 0000000000..78b34d4f34 --- /dev/null +++ b/tests/python/core/schema/test_profile.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from isaaccapture.schema import ( + ContentDescriptor, + ProfileHandling, + SemanticProfile, +) + + +def test_profile_round_trip_preserves_unknown_payload_and_resource() -> None: + resource = ContentDescriptor( + "robot", + media_type="application/xml", + version="7", + uris=["package://robot/model.urdf"], + sha256=b"x" * 32, + byte_size=3, + inline_data=b"", + ) + profile = SemanticProfile( + "org.example.robot.binding", + version_major=2, + version_minor=3, + handling=ProfileHandling.REQUIRED, + payload_schema="example.RobotBinding", + payload=b"opaque", + resources=[resource], + ) + + decoded = SemanticProfile.from_bytes(profile.to_bytes()) + assert decoded.profile_id == "org.example.robot.binding" + assert (decoded.version_major, decoded.version_minor) == (2, 3) + assert decoded.handling == ProfileHandling.REQUIRED + assert decoded.payload_schema == "example.RobotBinding" + assert decoded.payload == b"opaque" + assert decoded.resources[0].logical_id == "robot" + assert decoded.resources[0].inline_data == b"" + + +def test_profile_rejects_ambiguous_payload_and_invalid_digest() -> None: + with pytest.raises(ValueError, match="payload_schema"): + SemanticProfile("org.example.profile", payload=b"opaque") + with pytest.raises(ValueError, match="32 bytes"): + ContentDescriptor("robot", sha256=b"short") + + +def test_schema_views_reject_invalid_bytes() -> None: + with pytest.raises(ValueError, match="invalid FlatBuffer"): + SemanticProfile.from_bytes(b"not a flatbuffer") diff --git a/tests/python/core/schema/test_tactile.py b/tests/python/core/schema/test_tactile.py new file mode 100644 index 0000000000..f819500311 --- /dev/null +++ b/tests/python/core/schema/test_tactile.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from isaaccapture.schema import ( + ContentDescriptor, + IsaacCapturePoseTactileBinding, + OpenXRHandTactileBinding, + Point, + Pose, + ProfileHandling, + Quaternion, + TACTILE_ISAACCAPTURE_POSE_PROFILE_ID, + TACTILE_OPENXR_HAND_PROFILE_ID, + TACTILE_URDF_PROFILE_ID, + TactileBindingKind, + TactileBindingProfile, + TactileData, + TactileHandedness, + TactileMeasurement, + TactileMetadata, + TactileNode, + TactileQuantity, + TactileSkinWeight, + TactileTaxel, + TactileUnit, + TactileValueFrame, + UrdfEntityKind, + UrdfNodeBinding, + UrdfTactileBinding, + make_tactile_binding_profile, + validate_tactile_data, +) + + +def identity_pose(x: float = 0.0) -> Pose: + return Pose(Point(x, 0.0, 0.0), Quaternion(0.0, 0.0, 0.0, 1.0)) + + +def metadata(): + measurement = TactileMeasurement( + TactileQuantity.FORCE, + TactileUnit.NEWTON, + 3, + TactileValueFrame.TAXEL, + ) + nodes = [TactileNode("index_distal", identity_pose())] + taxels = [ + TactileTaxel( + f"taxel-{index}", + identity_pose(index * 0.01), + [TactileSkinWeight(0, 1.0)], + ) + for index in range(2) + ] + openxr = TactileBindingProfile( + openxr_hand=OpenXRHandTactileBinding(TactileHandedness.LEFT, [9]) + ) + pose_stream = TactileBindingProfile( + isaaccapture_pose=IsaacCapturePoseTactileBinding( + "core.HandPose", + tracker_name="hand", + channel="left", + subject_id="operator", + device_instance_id="glove-left", + skeleton_instance_id="operator-left-hand", + node_pose_indices=[9], + ) + ) + profiles = [ + make_tactile_binding_profile(openxr, ProfileHandling.REQUIRED), + make_tactile_binding_profile(pose_stream, ProfileHandling.OPTIONAL), + ] + return TactileMetadata("glove-left", measurement, taxels, nodes, profiles) + + +def test_tactile_metadata_and_profiles_round_trip() -> None: + value = TactileMetadata.from_bytes(metadata().to_bytes()) + assert value.schema_version == 1 + assert value.shape_frame_id == "glove-left" + assert [taxel.taxel_id for taxel in value.taxels] == ["taxel-0", "taxel-1"] + assert [profile.profile_id for profile in value.profiles] == [ + TACTILE_OPENXR_HAND_PROFILE_ID, + TACTILE_ISAACCAPTURE_POSE_PROFILE_ID, + ] + + binding = TactileBindingProfile.from_bytes(value.profiles[0].payload) + assert binding.kind == TactileBindingKind.OPENXR_HAND + assert binding.openxr_hand.node_joint_indices == [9] + assert binding.urdf is None + + +def test_tactile_frame_validation() -> None: + shape = metadata() + data = TactileData([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + validate_tactile_data(shape, data) + with pytest.raises(ValueError, match="value count"): + validate_tactile_data(shape, TactileData([1.0])) + + +def test_urdf_profile_is_typed_and_content_addressed() -> None: + resource = ContentDescriptor( + "hand-model", + media_type="application/xml", + version="1", + uris=["package://hand/model.urdf"], + sha256=b"d" * 32, + byte_size=7, + inline_data=b"", + ) + binding = TactileBindingProfile( + urdf=UrdfTactileBinding( + "hand-model", [UrdfNodeBinding(0, UrdfEntityKind.LINK, "finger")] + ) + ) + profile = make_tactile_binding_profile(binding, resources=[resource]) + measurement = TactileMeasurement( + TactileQuantity.PRESSURE, + TactileUnit.PASCAL, + 1, + TactileValueFrame.NONE, + ) + + value = TactileMetadata( + "hand/base", + measurement, + [TactileTaxel("pad", identity_pose(), [TactileSkinWeight(0, 1.0)])], + [TactileNode("finger", identity_pose())], + [profile], + ) + assert value.profiles[0].profile_id == TACTILE_URDF_PROFILE_ID + + +def test_tactile_metadata_rejects_invalid_skinning() -> None: + measurement = TactileMeasurement( + TactileQuantity.RAW, + TactileUnit.COUNT, + 1, + TactileValueFrame.NONE, + ) + with pytest.raises(ValueError, match="sum to 1"): + TactileMetadata( + "array", + measurement, + [TactileTaxel("taxel", identity_pose(), [TactileSkinWeight(0, 0.5)])], + [TactileNode("root", identity_pose())], + )