From 05e24dd3df81a27d3a6c91326abb2eaeeb245091 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Fri, 22 May 2026 14:23:51 +0100 Subject: [PATCH 1/4] feat(trackerobjects): tracker-to-prop binding package Adds com.basis.trackerobjects, a runtime package for binding a VR tracker to a user-spawned prop. The binding drives the prop's transform from the tracker's pose each frame. Public surface (BasisTrackerObjectManager): - TryCreateBinding(BasisInput, Transform, string netID, out BasisTrackerBinding) - TryRemoveBinding(string id) - TryGetBindingByLoadedNetID(string netID, out BasisTrackerBinding) Each BasisTrackerBinding holds the device + target and re-asserts isKinematic = true each frame on the bound rigidbody so BasisObjectSyncNetworking can't reset it back to dynamic mid-session. v1 spec at Packages/com.basis.trackerobjects/REQUIREMENTS.md. --- .../com.basis.trackerobjects/LICENSE.md | 21 ++ .../com.basis.trackerobjects/LICENSE.md.meta | 7 + .../com.basis.trackerobjects/REQUIREMENTS.md | 244 ++++++++++++++++++ .../REQUIREMENTS.md.meta | 7 + .../com.basis.trackerobjects/Runtime.meta | 8 + .../Runtime/BasisTrackerBinding.cs | 22 ++ .../Runtime/BasisTrackerBinding.cs.meta | 2 + .../Runtime/BasisTrackerObjectManager.cs | 201 +++++++++++++++ .../Runtime/BasisTrackerObjectManager.cs.meta | 2 + .../Runtime/BasisTrackerObjects.asmdef | 19 ++ .../Runtime/BasisTrackerObjects.asmdef.meta | 7 + .../com.basis.trackerobjects/package.json | 12 + .../package.json.meta | 7 + 13 files changed, 559 insertions(+) create mode 100644 Basis/Packages/com.basis.trackerobjects/LICENSE.md create mode 100644 Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md create mode 100644 Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef create mode 100644 Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta create mode 100644 Basis/Packages/com.basis.trackerobjects/package.json create mode 100644 Basis/Packages/com.basis.trackerobjects/package.json.meta diff --git a/Basis/Packages/com.basis.trackerobjects/LICENSE.md b/Basis/Packages/com.basis.trackerobjects/LICENSE.md new file mode 100644 index 0000000000..4253a56423 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BasisVR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta b/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta new file mode 100644 index 0000000000..3c3bf378fd --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8f674a0ab242d304db0a81442e8b4c2c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md new file mode 100644 index 0000000000..086d321e38 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md @@ -0,0 +1,244 @@ +# Basis Tracker Objects — v1 requirements + +## Purpose + +Bind a SteamVR / OpenXR tracker's pose to an arbitrary GameObject so the GameObject follows the tracker in real time, locally, with remote players seeing the motion via the existing networked object sync pipeline. Example use cases: physical prop tracking (e.g. a tracker chip stuck to a juggling ball), and assigning a tracker to a real-life dolly system that can drive the handheld camera. + +## v1 scope in one breath + +A single MonoBehaviour-free runtime (`BasisTrackerObjectManager`) maintains a list of `BasisTrackerBinding` records and writes each binding's tracker pose to its target transform every render frame. A "Assign Tracker" button on each instantiated-object row in the library menu opens a tracker picker; confirming captures the prop's current relative pose to the tracker as a fixed offset and locks the binding in. Remote players see the motion because the existing `BasisObjectSyncNetworking` on the spawned instance already replicates transform updates. Pickup is vetoed while a binding is active, including for the binder. Bindings auto-clear when the underlying spawn instance is removed by anyone (local user, server admin, session cleanup) and are not persisted across sessions. + +## Package split + +| Package | Owns | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `com.basis.trackerobjects` | Binding data type, manager, per-frame pose drive, offset capture, pickup-veto registration, registry-removal subscription, events. | +| `com.basis.integration.trackerobjects` | Library row subscriber and tracker picker dialog — the bridge that calls into the manager when the user clicks Assign/Unbind on an instance. | +| `com.basis.framework` | `LibraryProvider.OnInstanceRowCreated` event the bridge subscribes to, plus the localization keys (`library.assignTracker`, `library.unbindTracker`, `library.trackerPicker.*`) in `Localization/Languages/*.json`. | + +A three-package split because `com.basis.trackerobjects` references `Basis Framework` for `BasisInput`/`BasisLocalPlayer`/`BasisRuntimeSpawnRegistry`, which means framework can't reference back into trackerobjects without a circular asmdef ref. `com.basis.integration.trackerobjects` references both and is the only place that can wire UI clicks into manager calls. Pattern mirrors `com.basis.integration.audiolink`. No framework-side type leaks into `BasisTrackerBinding` or the manager's public API. + +## Public API + +### `BasisTrackerBinding` + +```csharp +public class BasisTrackerBinding +{ + public int Id; + public BasisInput Tracker; + public Transform Target; + public string UniqueDeviceIdentifier; + public string LoadedNetID; + public Vector3 LocalPositionOffset; + public Quaternion LocalRotationOffset; +} +``` + +`LoadedNetID` is the `BasisRuntimeSpawnRegistry.SpawnInstance.LoadedNetID` of the target's spawn instance. Used to match registry-removal events by ID (the Target transform may already be Unity-null by the time the event fires). + +Offsets are expressed in tracker-local space so the per-frame drive is `target.SetPositionAndRotation(trackerPos + trackerRot * LocalPositionOffset, trackerRot * LocalRotationOffset)`. + +### `BasisTrackerObjectManager` + +```csharp +public static class BasisTrackerObjectManager +{ + public const int RenderPriority = 99; + + public static readonly List Bindings; + + public static event Action OnBindingCreated; + public static event Action OnBindingRemoved; + + public static bool TryCreateBinding(BasisInput tracker, Transform target, string loadedNetID, out int id); + public static bool TryRemoveBinding(int id); + public static bool TryGetBindingByLoadedNetID(string loadedNetID, out BasisTrackerBinding binding); +} +``` + +`TryCreateBinding` extends the existing signature with `loadedNetID` (required for registry-removal cleanup). It captures the current relative pose of `target` to `tracker` as the offset, registers a pickup veto on the target's `BasisPickupInteractable` if present, and stores the pre-bind `Rigidbody.isKinematic` value internally so it can be restored on unbind. Returns false if either argument is null, if `loadedNetID` is null/empty, or if a binding for that `LoadedNetID` already exists. + +`TryRemoveBinding` unregisters the pickup veto, restores the prior `isKinematic`, and removes the binding from the list. + +`OnBindingCreated` / `OnBindingRemoved` exist so the UI can refresh row labels without polling. The events fire on the main thread, synchronously, after the binding list mutation. + +## Pose drive + +The manager subscribes to `BasisLocalPlayer.AfterSimulateOnRender` at priority `99` (existing). The handler walks `Bindings`, skips entries where `Tracker == null` or `Target == null` (handles destroyed-during-frame races), reads the tracker's world pose, applies the per-binding offset, and writes to the target. + +The handler MUST be allocation-free on the hot path (per `STYLE.md` rules). Use cached locals; no LINQ; no per-frame closure capture. + +## UI integration (framework side) + +### Entry point + +A new `PanelButton` is added to each instantiated-object row in `LibraryProvider.CreateListEntry`, placed between the existing "Select" and "Teleport" buttons. + +| Binding state for this `InstanceId` | Button style | Button label (localization key) | +| ----------------------------------- | ------------------ | -------------------------------------- | +| No binding | `StandardButton` | `library.assignTracker` ("Assign") | +| Binding exists | `StandardButton` | `library.unbindTracker` ("Unbind") | + +Labels are deliberately one word — the prop-row context already makes "what's being assigned" obvious, and the row's equal-share horizontal layout means a longer label would squeeze Select/Teleport/Remove visibly. + +The style stays fixed; only the label flips. This matches the existing Select/Deselect pattern on the same row and avoids signalling unbind as destructive (it isn't — no data loss, the binding just lifts). + +The button is skipped entirely (not added to the row) when the instance's `SpawnMode == Scene` or `SpawnMethod == Embedded`. A disabled fourth button would push Select/Teleport/Remove off the right of the row, and those instances can't host a tracker binding regardless — a scene spawn isn't user-owned and an embedded one has no pickup/rigid surface to drive. + +### Tracker picker dialog + +Clicking "Assign Tracker" opens a modal dialog listing currently-connected `BasisInput` devices whose role is `GenericTracker` (or whatever the equivalent enum value resolves to for SteamVR/OpenXR pucks and chips). The list further excludes: + +- Trackers with `BasisInput.IsLinked == true` — currently fused into a virtual midpoint pair and committed to an avatar body role via the partner pairing. +- Trackers whose `UniqueDeviceIdentifier` matches a `BasisTrackerRoleOverride.TryGetOverride` hit — calibration will claim them as a body joint, so binding them to a prop would race with the calibration assignment. +- `BasisVirtualMidpointInput` instances — caught by the `GenericTracker` role filter today (midpoints take a body role), but excluded explicitly so a future shift in the role taxonomy can't accidentally surface a midpoint here. + +Each row shows the tracker's role / display name and its `UniqueDeviceIdentifier`. Selecting a row calls `BasisTrackerObjectManager.TryCreateBinding(tracker, instanceGo.transform, instance.LoadedNetID, out _)` and closes the dialog. + +Clicking "Unbind Tracker" calls `TryGetBindingByLoadedNetID` then `TryRemoveBinding(binding.Id)` directly, no confirmation dialog. + +The picker is user-initiated, so the host must always show it — if the dialogue helper exposes a `divertible` flag (as `BasisMenuDialoguePanel.CreateNew` does), it stays `false`. Closing the picker without a selection is equivalent to cancel: no re-prompt, no notification-center pending entry (i.e. don't pass a `reopen` callback to `DialogBox.Create`). + +Localization strings land in every `Localization/Languages/*.json` file. English values: + +- `library.assignTracker` — "Assign" +- `library.unbindTracker` — "Unbind" +- `library.trackerPicker.title` — "Choose Tracker" +- `library.trackerPicker.empty` — "No trackers are currently connected." +- `library.trackerPicker.confirm` — "Bind" +- `library.trackerPicker.cancel` — "Cancel" + +### Calibration model (offset capture) + +The offset is snapshot-on-bind: at the moment `TryCreateBinding` runs, the relative pose of `target` to `tracker` is captured and stored on the binding. This means the user's workflow is: + +1. Spawn the prop. +2. Position the prop where it should sit relative to the tracker chip (e.g. velcro the chip to a juggling ball, or hold the prop against the tracker in the desired orientation). +3. Open the library menu, find the instance, click "Assign Tracker", pick the tracker, confirm. + +The captured offset is whatever the relative pose was at confirm time. No numeric input UI; no recalibration affordance in v1 (see "out of v1"). + +### Refusing release while bound + +When a binding is created against a target that has a `BasisPickupInteractable`, the manager registers two predicates that always return `false`: + +- `BasisPickupInteractable.CanHoverInjected.Add(...)` — prevents the pickup-hover highlight from appearing. +- `BasisPickupInteractable.CanInteractInjected.Add(...)` — prevents any input from initiating a grab. + +These are removed in `TryRemoveBinding`. The veto applies to the binder's own inputs too — for v1 this is acceptable because the natural unbind path is the library menu, and the juggling-balls / dolly-camera cases don't want the binder fighting the tracker by accidentally grabbing the prop. A future iteration can refine this if a use case emerges. + +Existing `BasisObjectSyncNetworking.CanNetworkSteal` is unchanged; the veto sits in front of it, so steal attempts from remote players are blocked at the local `CanInteract` check before any ownership transfer is initiated. + +### Kinematic capture and restore + +`BasisObjectSyncNetworking.ControlState` sets `Rigidbody.isKinematic = false` when the object is locally owned, so physics drives the rigidbody. With a tracker also writing the transform every render frame, the two compete and the prop jitters. To prevent this: + +- `TryCreateBinding` captures `pickupInteractable.RigidRef.isKinematic` and stores it on the binding. +- It then sets `isKinematic = true`, and the per-frame pose drive re-asserts `isKinematic = true` each frame. Re-assertion (rather than one-shot) is required because `BasisObjectSyncNetworking.Awake` and `ControlState` both set `isKinematic = false` for locally-owned props, and `ControlState` can fire after bind on ownership-transfer events. Physics moving the rigidbody between our writes shows up as Scene-view flicker even when Game view (which renders right after `onBeforeRender`) looks clean. +- `TryRemoveBinding` restores the captured value. + +If the target has no `BasisPickupInteractable` or no `Rigidbody`, the kinematic toggle is skipped. + +## Removal handling + +The manager subscribes to `BasisRuntimeSpawnRegistry.OnRegistryChanged` at init and handles all four change types: + +| `RegistryChangeType` | `instance` payload | Action | +| -------------------- | ------------------ | ----------------------------------------------------------------------------------- | +| `Added` | non-null | Ignore. | +| `Removed` | non-null | If a binding exists for `instance.LoadedNetID`, call `TryRemoveBinding(binding.Id)`. | +| `ClearedUrl` | non-null | Same as `Removed`. | +| `ClearedAll` | null | Clear all bindings. | + +By the time the event fires, `SpawnedGameobjects` has already been cleared and the GameObject may be mid-destroy; the binding's stored `LoadedNetID` is the only reliable identifier. The pose-drive loop tolerates a Unity-null `Target` between the destroy and the event firing. + +This same path covers: + +- Local user removing their own prop via the library menu. +- Server admin removing someone else's prop (network unload broadcast → `BasisNetworkSpawnItem.DestroyGameobject` → `BasisRuntimeSpawnRegistry.RemoveByLoadedNetId` → event fires on the binder's client too). +- Session cleanup via `BasisRuntimeSpawnRegistry.ClearAllNetworking`. + +There is no separate per-source removal plumbing; the registry funnel is sufficient. + +The no-arg `BasisRuntimeSpawnRegistry.ClearAll()` overload does not raise any event. If it is called, existing bindings will not be cleared by this mechanism. The pose-drive loop's `Target == null` guard prevents this from causing a hard error; the binding entries leak until process exit. This is acceptable for v1. + +## Network sync + +The binding does not introduce a new network channel. Spawned game objects already carry `BasisObjectSyncNetworking`, which replicates their transform via `SendCustomNetworkEvent(... DeliveryMethod.Sequenced)` while ownership is local. Because the manager writes the local `target.transform` every render frame, the next outbound sync sample includes the tracker-driven pose, and remote players interpolate to it through the existing `BasisObjectSyncDriver` path. + +Two consequences: + +- Remote update rate is whatever `BasisObjectSyncDriver` sends at — not the avatar bone rate. If this turns out to be visibly too low for hand-bone-rate motion (the juggling case), it's a follow-up question for the object-sync subsystem rather than a tracker-objects problem. +- Network compression is the existing `BasisCompression.QuaternionCompressor` path. No special handling for tracker-driven motion. + +A peer-to-peer transport (`BasisP2PManager`) carries voice and avatar transforms at configurable rates (20–250 Hz, default 60 Hz). Object sync is not currently routed over this transport — `BasisObjectSyncNetworking` continues to send through the server peer. If an object-sync-over-P2P path lands later, tracker-bound props inherit the new rate for free because this package writes to the local transform only and does not own the sync surface. + +If the target has no `BasisNetworkContentBase` / `BasisObjectSyncNetworking` (i.e. it's a local-only object), binding still works for the local player but no remote replication occurs. This is expected; a warning is logged via `BasisDebug.Log` with `BasisDebug.LogTag.TrackerObjects` so the user can see why their bound object doesn't appear to move for others. + +## Logging + +All logging uses `BasisDebug.Log*` with `BasisDebug.LogTag.TrackerObjects`. No `UnityEngine.Debug.Log*` calls in committed code. If `LogTag.TrackerObjects` is not yet present in `BasisDebug`, it is added there as part of the integration. + +## Lifecycle and threading + +- All operations are main-thread only. The pose-drive loop runs in `AfterSimulateOnRender` (main thread by construction). +- The manager initializes via `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` and is idempotent under repeated registration (`_subscribed` flag). +- No `MonoBehaviour`; no scene discovery via `FindObjectsByType` / `FindFirstObjectByType`; no allocations in the per-frame path. + +## Out of scope — and why + +### Persistence across sessions + +A binding does not survive a disconnect/reconnect or a session restart. The user re-binds on next session. + +**Why**: Persistence requires a stable identifier per binding (likely tracker `UniqueDeviceIdentifier` plus a stable instance reference), a serialization format, and a restore-on-spawn handshake that handles the case where the tracker isn't connected at restore time. None of this is needed for the v1 use cases (juggling demo, dolly camera) which are both per-session activities. Adding it post-v1 is purely additive. + +### Manual offset editing UI + +The offset is captured at bind time and cannot be tweaked numerically. + +**Why**: A numeric XYZ + Euler editor in VR is clunky enough that snapshot-on-bind covers the practical workflow better. If the snapshot is wrong, the user unbinds, repositions, and rebinds. A "Recalibrate" button that re-snapshots without going through unbind/rebind is a trivial follow-up if the friction shows up in practice. + +### Multiple trackers per object + +A single binding maps one tracker to one transform's root pose. Multi-point rigging (e.g. two trackers driving an articulated prop with IK) is out. + +**Why**: Single-tracker covers all v1 use cases. Multi-point rigging is a much larger design surface (which tracker drives which joint, conflict resolution between trackers, calibration UX for multi-anchor poses) and shouldn't be smuggled into the first slice. + +### Higher-rate or custom sync path + +The binding does not introduce its own network sync. It relies on whatever `BasisObjectSyncNetworking` provides. + +**Why**: Reinventing sync inside this package would duplicate compression, ownership, and delivery code that already exists and is already tested. If the existing rate proves insufficient for hand-bone-rate motion, the fix belongs in `BasisObjectSyncDriver` (raise the cadence or expose a per-instance high-rate flag), not here. + +### Allow binder to grab through the veto + +The pickup veto blocks the binder's own inputs too. + +**Why**: For the provided use cases there's no reason the binder needs to grab the prop while it's bound — the tracker is the driver. If a future use case wants "bound but still hand-grabbable for fine adjustment", the predicate can be made input-aware. + +### Calibration affordances beyond snapshot-on-bind + +No "Recalibrate" button, no "Reset offset to zero" button, no pose-preview during the picker dialog. + +**Why**: Snapshot-on-bind plus unbind/rebind covers the workflow. Each of these affordances is independently small to add once a real need surfaces; bundling them speculatively into the current scope stretches the surface unnecessarily. + +## Future outlook + +Follow-ups likely to be promoted into a later release once the version 1 surface is validated in real use: + +- Recalibrate button on the library row when a binding exists (re-snapshots offset without unbind/rebind). +- Per-binding "allow binder to grab" toggle for fine-adjustment workflows. +- Visible binding indicator in-world (small icon above the prop showing it's tracker-driven, and which tracker). +- Object sync routed over the `BasisP2PManager` transport. Not a change in this package — `BasisObjectSyncNetworking` would gain a P2P send path, and tracker-bound props would inherit the higher remote rate automatically. Tracked here so a future reader knows where the rate ceiling moves once that lands. + +These are listed for visibility, not committed. + +## Integration points to respect + +- **Logging**: `BasisDebug.Log*` with `BasisDebug.LogTag.TrackerObjects`. Never `UnityEngine.Debug.Log*` in committed code. +- **STYLE.md compliance**: no allocations on the per-frame path; prefer `TryGetComponent` over `GetComponent`; no `FindObjectsByType` for scene discovery; events driven through `BasisEventDriver` patterns where applicable; Burst-jobbable hot paths considered where the work justifies it. +- **Pickup integration**: use `BasisPickupInteractable.CanHoverInjected` / `CanInteractInjected` for veto. Do not introduce new predicate lists on the pickup component. +- **Registry integration**: subscribe to `BasisRuntimeSpawnRegistry.OnRegistryChanged`. Do not subscribe to per-source removal paths (the registry funnel is exhaustive). +- **Render order**: keep `AfterSimulateOnRender` priority at the existing value (`99`). Other systems may depend on this slot's relative ordering. diff --git a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta new file mode 100644 index 0000000000..ee8bad011c --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 51c2ddb969b7b094f81cf4ac803d2bd3 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime.meta b/Basis/Packages/com.basis.trackerobjects/Runtime.meta new file mode 100644 index 0000000000..6d8fd9ff05 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b44537b3dcedc2645bd3156ecf9c6db3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs new file mode 100644 index 0000000000..ccf9d18a81 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs @@ -0,0 +1,22 @@ +using Basis.Scripts.BasisSdk.Interactions; +using Basis.Scripts.Device_Management.Devices; +using UnityEngine; + +namespace Basis.TrackerObjects +{ + public class BasisTrackerBinding + { + public int Id; + public BasisInput Tracker; + public Transform Target; + public string UniqueDeviceIdentifier; + public string LoadedNetID; + public Vector3 LocalPositionOffset; + public Quaternion LocalRotationOffset; + + public BasisPickupInteractable PickupRef; + public Rigidbody RigidRef; + public bool PreBindKinematic; + public bool HasKinematicCaptured; + } +} diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta new file mode 100644 index 0000000000..ad5ccdd21f --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerBinding.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 967b93cccbc1a3846a11dcec83ef1d7d \ No newline at end of file diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs new file mode 100644 index 0000000000..e583adbf9e --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using Basis.Scripts.BasisSdk.Interactions; +using Basis.Scripts.BasisSdk.Players; +using Basis.Scripts.Device_Management.Devices; +using UnityEngine; + +namespace Basis.TrackerObjects +{ + public static class BasisTrackerObjectManager + { + public const int RenderPriority = 99; + + public static readonly List Bindings = new List(); + + public static event Action OnBindingCreated; + public static event Action OnBindingRemoved; + + private static int _nextID = 1; + private static bool _subscribed; + + // Single shared deny predicates — each binding lives on a distinct + // BasisPickupInteractable (enforced by the LoadedNetID dedup), so the same + // delegate instance is added once per pickup list and removed once on unbind. + private static readonly Func _denyHover = static _ => false; + private static readonly Func _denyInteract = static _ => false; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Initialize() + { + if (_subscribed) + { + return; + } + BasisLocalPlayer.AfterSimulateOnRender.AddAction(RenderPriority, OnAfterSimulateOnRender); + BasisRuntimeSpawnRegistry.OnRegistryChanged += OnRegistryChanged; + _subscribed = true; + BasisDebug.Log("BasisTrackerObjectManager subscribed", BasisDebug.LogTag.TrackerObjects); + } + + public static bool TryCreateBinding(BasisInput tracker, Transform target, string loadedNetID, out int id) + { + id = 0; + if (tracker == null || target == null) + { + BasisDebug.LogError("TryCreateBinding: tracker or target was null", BasisDebug.LogTag.TrackerObjects); + return false; + } + if (string.IsNullOrEmpty(loadedNetID)) + { + BasisDebug.LogError("TryCreateBinding: loadedNetID was null/empty", BasisDebug.LogTag.TrackerObjects); + return false; + } + if (TryGetBindingByLoadedNetID(loadedNetID, out _)) + { + BasisDebug.LogWarning($"TryCreateBinding: a binding for LoadedNetID {loadedNetID} already exists", BasisDebug.LogTag.TrackerObjects); + return false; + } + + tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot); + target.GetPositionAndRotation(out Vector3 targetPos, out Quaternion targetRot); + Quaternion invRot = Quaternion.Inverse(trackerRot); + + id = _nextID++; + BasisTrackerBinding binding = new BasisTrackerBinding + { + Id = id, + Tracker = tracker, + Target = target, + UniqueDeviceIdentifier = tracker.UniqueDeviceIdentifier, + LoadedNetID = loadedNetID, + LocalPositionOffset = invRot * (targetPos - trackerPos), + LocalRotationOffset = invRot * targetRot, + }; + + if (target.TryGetComponent(out BasisPickupInteractable pickup)) + { + binding.PickupRef = pickup; + pickup.CanHoverInjected.Add(_denyHover); + pickup.CanInteractInjected.Add(_denyInteract); + + if (pickup.RigidRef != null) + { + binding.RigidRef = pickup.RigidRef; + binding.PreBindKinematic = pickup.RigidRef.isKinematic; + binding.HasKinematicCaptured = true; + pickup.RigidRef.isKinematic = true; + } + } + + if (!target.TryGetComponent(out _)) + { + BasisDebug.LogWarning($"TryCreateBinding: target {target.name} has no BasisNetworkContentBase — local-only motion, remote players will not see the binding move", BasisDebug.LogTag.TrackerObjects); + } + + Bindings.Add(binding); + BasisDebug.Log($"Created tracker binding {id} for {tracker.UniqueDeviceIdentifier} -> {target.name} (netID {loadedNetID})", BasisDebug.LogTag.TrackerObjects); + OnBindingCreated?.Invoke(binding); + return true; + } + + public static bool TryRemoveBinding(int id) + { + int count = Bindings.Count; + for (int index = 0; index < count; index++) + { + if (Bindings[index].Id == id) + { + RemoveAt(index); + return true; + } + } + return false; + } + + public static bool TryGetBindingByLoadedNetID(string loadedNetID, out BasisTrackerBinding binding) + { + binding = null; + if (string.IsNullOrEmpty(loadedNetID)) + { + return false; + } + int count = Bindings.Count; + for (int index = 0; index < count; index++) + { + BasisTrackerBinding b = Bindings[index]; + if (b.LoadedNetID == loadedNetID) + { + binding = b; + return true; + } + } + return false; + } + + private static void RemoveAt(int index) + { + BasisTrackerBinding binding = Bindings[index]; + if (binding.PickupRef != null) + { + binding.PickupRef.CanHoverInjected.Remove(_denyHover); + binding.PickupRef.CanInteractInjected.Remove(_denyInteract); + } + if (binding.HasKinematicCaptured && binding.RigidRef != null) + { + binding.RigidRef.isKinematic = binding.PreBindKinematic; + } + Bindings.RemoveAt(index); + BasisDebug.Log($"Removed tracker binding {binding.Id}", BasisDebug.LogTag.TrackerObjects); + OnBindingRemoved?.Invoke(binding); + } + + private static void OnAfterSimulateOnRender() + { + int count = Bindings.Count; + for (int index = 0; index < count; index++) + { + BasisTrackerBinding binding = Bindings[index]; + if (binding.Tracker == null || binding.Target == null) + { + continue; + } + // BasisObjectSyncNetworking.Awake and ControlState both flip + // isKinematic = false on locally-owned props, and ControlState can + // re-fire on ownership-transfer events long after bind. If physics + // touches the rigidbody between our writes, Scene view samples those + // intermediate frames (out of step with onBeforeRender) and flickers + // even when Game view stays clean. Re-asserting kinematic each frame + // is cheap and avoids playing whack-a-mole with every external setter. + if (binding.HasKinematicCaptured && binding.RigidRef != null) + { + binding.RigidRef.isKinematic = true; + } + binding.Tracker.transform.GetPositionAndRotation(out Vector3 trackerPos, out Quaternion trackerRot); + binding.Target.SetPositionAndRotation( + trackerPos + trackerRot * binding.LocalPositionOffset, + trackerRot * binding.LocalRotationOffset); + } + } + + private static void OnRegistryChanged(BasisRuntimeSpawnRegistry.RegistryChangeType type, BasisRuntimeSpawnRegistry.SpawnInstance instance) + { + switch (type) + { + case BasisRuntimeSpawnRegistry.RegistryChangeType.Removed: + case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedUrl: + if (instance != null && TryGetBindingByLoadedNetID(instance.LoadedNetID, out BasisTrackerBinding binding)) + { + TryRemoveBinding(binding.Id); + } + break; + case BasisRuntimeSpawnRegistry.RegistryChangeType.ClearedAll: + for (int index = Bindings.Count - 1; index >= 0; index--) + { + RemoveAt(index); + } + break; + } + } + } +} diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta new file mode 100644 index 0000000000..e1ae0497e6 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjectManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e1b6649bd24ed042bd5ca4b44a671cf \ No newline at end of file diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef new file mode 100644 index 0000000000..65d894baa6 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef @@ -0,0 +1,19 @@ +{ + "name": "BasisTrackerObjects", + "rootNamespace": "Basis.TrackerObjects", + "references": [ + "Basis Framework", + "BasisSDK", + "BasisCommon", + "BasisDebug" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta new file mode 100644 index 0000000000..e43c139d42 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/Runtime/BasisTrackerObjects.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2be0cf59cd21f8b4bb8bdcf097744459 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.trackerobjects/package.json b/Basis/Packages/com.basis.trackerobjects/package.json new file mode 100644 index 0000000000..fba46259c1 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/package.json @@ -0,0 +1,12 @@ +{ + "name": "com.basis.trackerobjects", + "displayName": "Basis Tracker Objects", + "version": "0.0.1", + "description": "Bind a SteamVR/OpenXR tracker to a GameObject so its pose drives the transform locally and syncs to remote players over a custom network behaviour. Selection UI + persistence + per-tick pose drive + sync.", + "unity": "6000.0", + "author": { + "name": "BasisVR", + "url": "https://github.com/BasisVR" + }, + "license": "MIT" +} diff --git a/Basis/Packages/com.basis.trackerobjects/package.json.meta b/Basis/Packages/com.basis.trackerobjects/package.json.meta new file mode 100644 index 0000000000..55412da0c6 --- /dev/null +++ b/Basis/Packages/com.basis.trackerobjects/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a45415a816141ff4096977b85f13038c +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 3fbd3a742fb4357afbf4b2f922ff27be15993d59 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Fri, 22 May 2026 14:23:51 +0100 Subject: [PATCH 2/4] feat(integration.trackerobjects): library-row Assign/Unbind button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds com.basis.integration.trackerobjects, a UI integration package that wires com.basis.trackerobjects into the Library window's Instantiated tab. Each prop or avatar row gains a fourth blue StandardButton with a link/unlink icon — click opens a tracker picker dialog, click again on a bound row removes the binding. Framework hook: adds the static LibraryProvider.OnInstanceRowCreated event so the integration package can append the button mid-row without LibraryProvider needing to know about the trackerobjects package directly. Scoped to user-owned spawns: scene-mode and embedded instances are skipped (no rigidbody to drive, not user-owned). --- .../BasisUI/Localization/Languages/en.json | 24 ++++ .../BasisUI/Menus/Library/LibraryProvider.cs | 11 +- .../LICENSE.md | 21 +++ .../LICENSE.md.meta | 7 + .../README.md | 34 +++++ .../README.md.meta | 7 + .../Runtime.meta | 8 ++ .../Basis.Integration.TrackerObjects.asmdef | 34 +++++ ...sis.Integration.TrackerObjects.asmdef.meta | 7 + .../Runtime/BasisTrackerObjectsLibraryHook.cs | 136 ++++++++++++++++++ .../BasisTrackerObjectsLibraryHook.cs.meta | 2 + .../package.json | 10 ++ .../package.json.meta | 7 + .../Scripts/Basis Logger/BasisDebug.cs | 1 + 14 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/README.md create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/README.md.meta create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/package.json create mode 100644 Basis/Packages/com.basis.integration.trackerobjects/package.json.meta diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json index ec390c932a..ca7ff6610a 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json @@ -5221,6 +5221,30 @@ { "key": "settings.ra.title.voiceBuffer", "value": "Voice Buffer" + }, + { + "key": "library.assignTracker", + "value": "Assign" + }, + { + "key": "library.unbindTracker", + "value": "Unbind" + }, + { + "key": "library.trackerPicker.title", + "value": "Choose Tracker" + }, + { + "key": "library.trackerPicker.empty", + "value": "No trackers are currently connected." + }, + { + "key": "library.trackerPicker.confirm", + "value": "Bind" + }, + { + "key": "library.trackerPicker.cancel", + "value": "Cancel" } ] } diff --git a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs index 158927e49e..d47051e2ed 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs +++ b/Basis/Packages/com.basis.framework/BasisUI/Menus/Library/LibraryProvider.cs @@ -86,6 +86,13 @@ public override void OnReleaseEvent() private static protected bool IsProtected = false; // we use this to determine if the user is admin for admin related queries on the library provider public static BasisMenuPanel panel; + /// + /// Fires per instantiated-object row right after the Select button is built, + /// before Teleport/Remove. Subscribers can append buttons to the supplied row + /// container — they land between Select and Teleport. + /// + public static event Action OnInstanceRowCreated; + // references to the search query elements private static PanelTextField searchField; // reference to the search field private static PanelDropdown dateSorting; // reference to the date sorting dropdown @@ -1975,9 +1982,11 @@ private static void CreateListEntry(BasisRuntimeSpawnRegistry.SpawnInstance item // close the menu BasisMainMenu.Close(); } - + }; + OnInstanceRowCreated?.Invoke(itemListPanel.TabButtonParent, itemKey); + PanelButton TeleportToItem = PanelButton.CreateNew(ButtonStyles.StandardButton, itemListPanel.TabButtonParent); TeleportToItem.Descriptor.SetTitle(string.Empty); TeleportToItem.SetIcon(AddressableAssets.Sprites.TeleportTo); diff --git a/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md new file mode 100644 index 0000000000..4253a56423 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BasisVR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta new file mode 100644 index 0000000000..f25ec175b2 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/LICENSE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 12563aaadbb712e49a94036afb0c2d06 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.integration.trackerobjects/README.md b/Basis/Packages/com.basis.integration.trackerobjects/README.md new file mode 100644 index 0000000000..880f2fbac7 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/README.md @@ -0,0 +1,34 @@ +# Basis Tracker Objects Integration + +Bridges [`com.basis.trackerobjects`](../com.basis.trackerobjects/REQUIREMENTS.md) into the Basis library menu. When a prop has been instantiated and shows up in the library's instantiated-items tab, this package adds an **Assign** button to the row. Clicking it opens a tracker picker; confirming a tracker binds the prop's GameObject to that tracker via `BasisTrackerObjectManager`. Clicking **Unbind** removes the binding. + +## Why a separate package + +`com.basis.trackerobjects` references `Basis Framework` for the types it needs to drive a transform (`BasisInput`, `BasisLocalPlayer`, `BasisRuntimeSpawnRegistry`). That means `Basis Framework` can't reference `com.basis.trackerobjects` back — the asmdef graph would cycle. This integration package references both and is the only place that can wire a library-menu button into a `BasisTrackerObjectManager.TryCreateBinding` call. Same pattern as `com.basis.integration.audiolink`. + +## What it adds + +- A `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` subscriber on `LibraryProvider.OnInstanceRowCreated`. For every instantiated-object row that `LibraryProvider` builds, this appends a `StandardButton` between the existing Select and Teleport buttons. +- The button is non-interactable for `SpawnMode.Scene` and `SpawnMethod.Embedded` items — same rule the Select button applies. +- Clicking **Assign** opens a `DialogBox` modal listing the currently-connected trackers eligible for prop binding. Confirming a row calls `BasisTrackerObjectManager.TryCreateBinding` with the spawn instance's `LoadedNetID` and `GameObject` transform. The picker excludes: + - `BasisVirtualMidpointInput` instances (the virtual half of an active pair). + - Trackers with `BasisInput.IsLinked == true` (one half of an active pair). + - Trackers whose `UniqueDeviceIdentifier` has a `BasisTrackerRoleOverride.TryGetOverride` hit. + - Devices the input matcher has pinned to a fixed role (HMD, named controllers, etc.). + - Trackers currently driving an avatar bone via calibration. Decalibrate first if you want to reuse a calibrated tracker for a prop. +- Clicking **Unbind** calls `BasisTrackerObjectManager.TryRemoveBinding` directly — no confirmation dialog. Unbind isn't destructive; the binding just lifts. + +## Compile guards + +The assembly defines two version constraints: + +- `com.basis.framework` → `BASIS_FRAMEWORK_EXISTS` +- `com.basis.trackerobjects` → `BASIS_TRACKEROBJECTS_EXISTS` + +Both must be present for this package to compile. If either is removed from the project, this assembly drops out silently. + +## See also + +- [`com.basis.trackerobjects/REQUIREMENTS.md`](../com.basis.trackerobjects/REQUIREMENTS.md) — full v1 spec for the binding manager, pose drive, pickup veto, and registry-cleanup contract. +- `LibraryProvider.OnInstanceRowCreated` — the event this package subscribes to. Lives in `com.basis.framework`. +- `com.basis.integration.audiolink` — sibling integration package that follows the same bridge pattern. diff --git a/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta b/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta new file mode 100644 index 0000000000..ae4a1a4da2 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: af43d869252215f42a6e073975019f90 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta new file mode 100644 index 0000000000..b7852df074 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d3f6f5a03cdabb849a41d489b099507e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef new file mode 100644 index 0000000000..0cbf7124f9 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef @@ -0,0 +1,34 @@ +{ + "name": "Basis.Integration.TrackerObjects", + "rootNamespace": "Basis.Integration.TrackerObjects", + "references": [ + "Basis Framework", + "BasisSDK", + "BasisDebug", + "BasisTrackerObjects", + "Unity.TextMeshPro" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "BASIS_FRAMEWORK_EXISTS", + "BASIS_TRACKEROBJECTS_EXISTS" + ], + "versionDefines": [ + { + "name": "com.basis.framework", + "expression": "", + "define": "BASIS_FRAMEWORK_EXISTS" + }, + { + "name": "com.basis.trackerobjects", + "expression": "", + "define": "BASIS_TRACKEROBJECTS_EXISTS" + } + ], + "noEngineReferences": false +} diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta new file mode 100644 index 0000000000..23ba62f3be --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/Basis.Integration.TrackerObjects.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 14e7fa5f04af2d848a51f1e909da387f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs new file mode 100644 index 0000000000..6e2c3501ba --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Basis.BasisUI; +using Basis.Scripts.Avatar; +using Basis.Scripts.Device_Management; +using Basis.Scripts.Device_Management.Devices; +using Basis.Scripts.Device_Management.Devices.Pairing; +using Basis.Scripts.TransformBinders.BoneControl; +using Basis.TrackerObjects; +using UnityEngine; + +namespace Basis.Integration.TrackerObjects +{ + internal static class BasisTrackerObjectsLibraryHook + { + private static readonly Vector2 PickerSize = new Vector2(900, 720); + private static readonly Vector2 RowSize = new Vector2(80, 80); + private static readonly Vector2 PickerRowSize = new Vector2(700, 60); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Subscribe() + { + LibraryProvider.OnInstanceRowCreated -= OnRowCreated; + LibraryProvider.OnInstanceRowCreated += OnRowCreated; + } + + private static void OnRowCreated(RectTransform parent, BasisRuntimeSpawnRegistry.SpawnInstance instance) + { + if (instance == null) return; + string netID = instance.LoadedNetID; + if (string.IsNullOrEmpty(netID)) return; + + // Scene-mode and embedded instances can't host a tracker binding (no + // pickup/rigid surface to drive, and they're not user-owned spawns), so + // skip adding the button at all — a disabled fourth button just pushes + // the Select/Teleport/Remove row over. + if (instance.SpawnMode == BasisRuntimeSpawnRegistry.SpawnMode.Scene) return; + if (instance.SpawnMethod == BasisRuntimeSpawnRegistry.SpawnMethod.Embedded) return; + + bool hasBinding = BasisTrackerObjectManager.TryGetBindingByLoadedNetID(netID, out _); + PanelButton button = PanelButton.CreateNew(PanelButton.ButtonStyles.StandardButton, parent); + button.Descriptor.SetTitle(string.Empty); + button.SetIcon(hasBinding ? AddressableAssets.Sprites.Unlink : AddressableAssets.Sprites.Link); + button.SetSize(RowSize); + + button.OnClicked += async () => + { + if (BasisTrackerObjectManager.TryGetBindingByLoadedNetID(netID, out BasisTrackerBinding existing)) + { + BasisTrackerObjectManager.TryRemoveBinding(existing.Id); + button.SetIcon(AddressableAssets.Sprites.Link); + return; + } + + if (!BasisRuntimeSpawnRegistry.SpawnedGameobjects.TryGetValue(netID, out GameObject go) || go == null) + { + BasisDebug.LogWarning($"AssignTracker: spawn instance {netID} has no resolved GameObject", BasisDebug.LogTag.TrackerObjects); + return; + } + + BasisInput chosen = await OpenPickerAsync(); + if (chosen == null) return; + + if (BasisTrackerObjectManager.TryCreateBinding(chosen, go.transform, netID, out _)) + { + button.SetIcon(AddressableAssets.Sprites.Unlink); + } + }; + } + + private static async Task OpenPickerAsync() + { + DialogBox picker = DialogBox.Create( + LibraryProvider.panel, + PickerSize, + BasisLocalization.Get("library.trackerPicker.title"), + description: null, + icon: AddressableAssets.Sprites.Information); + + PanelButton cancel = PanelButton.CreateNew(PanelButton.ButtonStyles.ExitButton, picker.Descriptor.Header); + cancel.Descriptor.SetTitle(BasisLocalization.Get("library.trackerPicker.cancel")); + cancel.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 125); + cancel.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, 50); + cancel.OnClicked += () => picker.Cancel(null); + + List candidates = CollectBindableTrackers(); + if (candidates.Count == 0) + { + PanelTextField empty = PanelTextField.CreateNew(PanelTextField.TextFieldStyles.Entry, picker.Descriptor.ContentParent); + empty._inputField.gameObject.SetActive(false); + empty.Descriptor.SetTitle(BasisLocalization.Get("library.trackerPicker.empty")); + } + else + { + for (int index = 0; index < candidates.Count; index++) + { + BasisInput tracker = candidates[index]; + string roleLabel = tracker.TryGetRole(out BasisBoneTrackedRole role) + ? role.ToString() + : "Tracker"; + PanelButton row = PanelButton.CreateNew(PanelButton.ButtonStyles.StandardButton, picker.Descriptor.ContentParent); + row.Descriptor.SetTitle($"{roleLabel} — {tracker.UniqueDeviceIdentifier}"); + row.SetSize(PickerRowSize); + row.OnClicked += () => picker.CloseWithResult(tracker); + } + } + + return await picker.WaitAsync(); + } + + private static List CollectBindableTrackers() + { + List result = new List(); + BasisObservableList devices = BasisDeviceManagement.Instance?.AllInputDevices; + if (devices == null) return result; + + for (int i = 0; i < devices.Count; i++) + { + BasisInput input = devices[i]; + if (input == null) continue; + if (string.IsNullOrEmpty(input.UniqueDeviceIdentifier)) continue; + if (input is BasisVirtualMidpointInput) continue; + if (input.IsLinked) continue; + if (BasisTrackerRoleOverride.TryGetOverride(input.UniqueDeviceIdentifier, out _)) continue; + if (input.DeviceMatchSettings != null && input.DeviceMatchSettings.HasTrackedRole) continue; + // A tracker already driving a body bone (post-calibration) is excluded so + // calibration and prop binding can't fight over the same device. To reuse + // a calibrated tracker, decalibrate first. + if (input.TryGetRole(out _)) continue; + + result.Add(input); + } + return result; + } + } +} diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta new file mode 100644 index 0000000000..599af464e2 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a9316928431e2d34baf18718780b977c \ No newline at end of file diff --git a/Basis/Packages/com.basis.integration.trackerobjects/package.json b/Basis/Packages/com.basis.integration.trackerobjects/package.json new file mode 100644 index 0000000000..03510b5382 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/package.json @@ -0,0 +1,10 @@ +{ + "name": "com.basis.integration.trackerobjects", + "displayName": "Basis Tracker Objects Integration", + "version": "0.0.1", + "description": "Wires com.basis.trackerobjects into the Basis library menu. Adds the Assign/Unbind Tracker button and the tracker-picker dialog. Compiles only when both com.basis.framework and com.basis.trackerobjects are present.", + "author": { + "name": "BasisVR", + "url": "https://github.com/BasisVR" + } +} diff --git a/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta b/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta new file mode 100644 index 0000000000..dc701f2f78 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b2cab1f2262ce9e45a4f029194039de9 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs index 6159cc8802..2a645e3011 100644 --- a/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs +++ b/Basis/Packages/com.basis.sdk/Scripts/Basis Logger/BasisDebug.cs @@ -141,6 +141,7 @@ public enum LogTag Shims, Props, LocalNetwork, + TrackerObjects, } public enum MessageType From b5ce61cb9b11abba99fec8d41e067e8adcd647c4 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Fri, 22 May 2026 17:07:08 +0100 Subject: [PATCH 3/4] fix(integration.trackerobjects): inset Assign/Unbind icon to match library row padding Mirrors the inset applied to Select / Teleport / Remove in LibraryProvider.cs (PR landing alongside this in fix/library-icon-padding): sizeDelta -30 on the icon RectTransform so the link/unlink strokes sit comfortably inside the bevel, matching the row's status-icon column. --- .../Runtime/BasisTrackerObjectsLibraryHook.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs index 6e2c3501ba..0b5321d804 100644 --- a/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs @@ -42,6 +42,8 @@ private static void OnRowCreated(RectTransform parent, BasisRuntimeSpawnRegistry button.Descriptor.SetTitle(string.Empty); button.SetIcon(hasBinding ? AddressableAssets.Sprites.Unlink : AddressableAssets.Sprites.Link); button.SetSize(RowSize); + // Match the row's left-side status-icon padding (PE Image Simple Square inset). + button.Descriptor.IconImage.rectTransform.sizeDelta = new Vector2(-30, -30); button.OnClicked += async () => { From a726206b78717cbe93414f29e01d02b57a6aab38 Mon Sep 17 00:00:00 2001 From: towneh <25694892+towneh@users.noreply.github.com> Date: Sat, 23 May 2026 01:01:02 +0100 Subject: [PATCH 4/4] docs(trackerobjects): remove REQUIREMENTS.md The spec was implementation-only scaffolding and is no longer needed now that the package is in place. --- .../com.basis.trackerobjects/REQUIREMENTS.md | 244 ------------------ .../REQUIREMENTS.md.meta | 7 - 2 files changed, 251 deletions(-) delete mode 100644 Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md delete mode 100644 Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta diff --git a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md deleted file mode 100644 index 086d321e38..0000000000 --- a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md +++ /dev/null @@ -1,244 +0,0 @@ -# Basis Tracker Objects — v1 requirements - -## Purpose - -Bind a SteamVR / OpenXR tracker's pose to an arbitrary GameObject so the GameObject follows the tracker in real time, locally, with remote players seeing the motion via the existing networked object sync pipeline. Example use cases: physical prop tracking (e.g. a tracker chip stuck to a juggling ball), and assigning a tracker to a real-life dolly system that can drive the handheld camera. - -## v1 scope in one breath - -A single MonoBehaviour-free runtime (`BasisTrackerObjectManager`) maintains a list of `BasisTrackerBinding` records and writes each binding's tracker pose to its target transform every render frame. A "Assign Tracker" button on each instantiated-object row in the library menu opens a tracker picker; confirming captures the prop's current relative pose to the tracker as a fixed offset and locks the binding in. Remote players see the motion because the existing `BasisObjectSyncNetworking` on the spawned instance already replicates transform updates. Pickup is vetoed while a binding is active, including for the binder. Bindings auto-clear when the underlying spawn instance is removed by anyone (local user, server admin, session cleanup) and are not persisted across sessions. - -## Package split - -| Package | Owns | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `com.basis.trackerobjects` | Binding data type, manager, per-frame pose drive, offset capture, pickup-veto registration, registry-removal subscription, events. | -| `com.basis.integration.trackerobjects` | Library row subscriber and tracker picker dialog — the bridge that calls into the manager when the user clicks Assign/Unbind on an instance. | -| `com.basis.framework` | `LibraryProvider.OnInstanceRowCreated` event the bridge subscribes to, plus the localization keys (`library.assignTracker`, `library.unbindTracker`, `library.trackerPicker.*`) in `Localization/Languages/*.json`. | - -A three-package split because `com.basis.trackerobjects` references `Basis Framework` for `BasisInput`/`BasisLocalPlayer`/`BasisRuntimeSpawnRegistry`, which means framework can't reference back into trackerobjects without a circular asmdef ref. `com.basis.integration.trackerobjects` references both and is the only place that can wire UI clicks into manager calls. Pattern mirrors `com.basis.integration.audiolink`. No framework-side type leaks into `BasisTrackerBinding` or the manager's public API. - -## Public API - -### `BasisTrackerBinding` - -```csharp -public class BasisTrackerBinding -{ - public int Id; - public BasisInput Tracker; - public Transform Target; - public string UniqueDeviceIdentifier; - public string LoadedNetID; - public Vector3 LocalPositionOffset; - public Quaternion LocalRotationOffset; -} -``` - -`LoadedNetID` is the `BasisRuntimeSpawnRegistry.SpawnInstance.LoadedNetID` of the target's spawn instance. Used to match registry-removal events by ID (the Target transform may already be Unity-null by the time the event fires). - -Offsets are expressed in tracker-local space so the per-frame drive is `target.SetPositionAndRotation(trackerPos + trackerRot * LocalPositionOffset, trackerRot * LocalRotationOffset)`. - -### `BasisTrackerObjectManager` - -```csharp -public static class BasisTrackerObjectManager -{ - public const int RenderPriority = 99; - - public static readonly List Bindings; - - public static event Action OnBindingCreated; - public static event Action OnBindingRemoved; - - public static bool TryCreateBinding(BasisInput tracker, Transform target, string loadedNetID, out int id); - public static bool TryRemoveBinding(int id); - public static bool TryGetBindingByLoadedNetID(string loadedNetID, out BasisTrackerBinding binding); -} -``` - -`TryCreateBinding` extends the existing signature with `loadedNetID` (required for registry-removal cleanup). It captures the current relative pose of `target` to `tracker` as the offset, registers a pickup veto on the target's `BasisPickupInteractable` if present, and stores the pre-bind `Rigidbody.isKinematic` value internally so it can be restored on unbind. Returns false if either argument is null, if `loadedNetID` is null/empty, or if a binding for that `LoadedNetID` already exists. - -`TryRemoveBinding` unregisters the pickup veto, restores the prior `isKinematic`, and removes the binding from the list. - -`OnBindingCreated` / `OnBindingRemoved` exist so the UI can refresh row labels without polling. The events fire on the main thread, synchronously, after the binding list mutation. - -## Pose drive - -The manager subscribes to `BasisLocalPlayer.AfterSimulateOnRender` at priority `99` (existing). The handler walks `Bindings`, skips entries where `Tracker == null` or `Target == null` (handles destroyed-during-frame races), reads the tracker's world pose, applies the per-binding offset, and writes to the target. - -The handler MUST be allocation-free on the hot path (per `STYLE.md` rules). Use cached locals; no LINQ; no per-frame closure capture. - -## UI integration (framework side) - -### Entry point - -A new `PanelButton` is added to each instantiated-object row in `LibraryProvider.CreateListEntry`, placed between the existing "Select" and "Teleport" buttons. - -| Binding state for this `InstanceId` | Button style | Button label (localization key) | -| ----------------------------------- | ------------------ | -------------------------------------- | -| No binding | `StandardButton` | `library.assignTracker` ("Assign") | -| Binding exists | `StandardButton` | `library.unbindTracker` ("Unbind") | - -Labels are deliberately one word — the prop-row context already makes "what's being assigned" obvious, and the row's equal-share horizontal layout means a longer label would squeeze Select/Teleport/Remove visibly. - -The style stays fixed; only the label flips. This matches the existing Select/Deselect pattern on the same row and avoids signalling unbind as destructive (it isn't — no data loss, the binding just lifts). - -The button is skipped entirely (not added to the row) when the instance's `SpawnMode == Scene` or `SpawnMethod == Embedded`. A disabled fourth button would push Select/Teleport/Remove off the right of the row, and those instances can't host a tracker binding regardless — a scene spawn isn't user-owned and an embedded one has no pickup/rigid surface to drive. - -### Tracker picker dialog - -Clicking "Assign Tracker" opens a modal dialog listing currently-connected `BasisInput` devices whose role is `GenericTracker` (or whatever the equivalent enum value resolves to for SteamVR/OpenXR pucks and chips). The list further excludes: - -- Trackers with `BasisInput.IsLinked == true` — currently fused into a virtual midpoint pair and committed to an avatar body role via the partner pairing. -- Trackers whose `UniqueDeviceIdentifier` matches a `BasisTrackerRoleOverride.TryGetOverride` hit — calibration will claim them as a body joint, so binding them to a prop would race with the calibration assignment. -- `BasisVirtualMidpointInput` instances — caught by the `GenericTracker` role filter today (midpoints take a body role), but excluded explicitly so a future shift in the role taxonomy can't accidentally surface a midpoint here. - -Each row shows the tracker's role / display name and its `UniqueDeviceIdentifier`. Selecting a row calls `BasisTrackerObjectManager.TryCreateBinding(tracker, instanceGo.transform, instance.LoadedNetID, out _)` and closes the dialog. - -Clicking "Unbind Tracker" calls `TryGetBindingByLoadedNetID` then `TryRemoveBinding(binding.Id)` directly, no confirmation dialog. - -The picker is user-initiated, so the host must always show it — if the dialogue helper exposes a `divertible` flag (as `BasisMenuDialoguePanel.CreateNew` does), it stays `false`. Closing the picker without a selection is equivalent to cancel: no re-prompt, no notification-center pending entry (i.e. don't pass a `reopen` callback to `DialogBox.Create`). - -Localization strings land in every `Localization/Languages/*.json` file. English values: - -- `library.assignTracker` — "Assign" -- `library.unbindTracker` — "Unbind" -- `library.trackerPicker.title` — "Choose Tracker" -- `library.trackerPicker.empty` — "No trackers are currently connected." -- `library.trackerPicker.confirm` — "Bind" -- `library.trackerPicker.cancel` — "Cancel" - -### Calibration model (offset capture) - -The offset is snapshot-on-bind: at the moment `TryCreateBinding` runs, the relative pose of `target` to `tracker` is captured and stored on the binding. This means the user's workflow is: - -1. Spawn the prop. -2. Position the prop where it should sit relative to the tracker chip (e.g. velcro the chip to a juggling ball, or hold the prop against the tracker in the desired orientation). -3. Open the library menu, find the instance, click "Assign Tracker", pick the tracker, confirm. - -The captured offset is whatever the relative pose was at confirm time. No numeric input UI; no recalibration affordance in v1 (see "out of v1"). - -### Refusing release while bound - -When a binding is created against a target that has a `BasisPickupInteractable`, the manager registers two predicates that always return `false`: - -- `BasisPickupInteractable.CanHoverInjected.Add(...)` — prevents the pickup-hover highlight from appearing. -- `BasisPickupInteractable.CanInteractInjected.Add(...)` — prevents any input from initiating a grab. - -These are removed in `TryRemoveBinding`. The veto applies to the binder's own inputs too — for v1 this is acceptable because the natural unbind path is the library menu, and the juggling-balls / dolly-camera cases don't want the binder fighting the tracker by accidentally grabbing the prop. A future iteration can refine this if a use case emerges. - -Existing `BasisObjectSyncNetworking.CanNetworkSteal` is unchanged; the veto sits in front of it, so steal attempts from remote players are blocked at the local `CanInteract` check before any ownership transfer is initiated. - -### Kinematic capture and restore - -`BasisObjectSyncNetworking.ControlState` sets `Rigidbody.isKinematic = false` when the object is locally owned, so physics drives the rigidbody. With a tracker also writing the transform every render frame, the two compete and the prop jitters. To prevent this: - -- `TryCreateBinding` captures `pickupInteractable.RigidRef.isKinematic` and stores it on the binding. -- It then sets `isKinematic = true`, and the per-frame pose drive re-asserts `isKinematic = true` each frame. Re-assertion (rather than one-shot) is required because `BasisObjectSyncNetworking.Awake` and `ControlState` both set `isKinematic = false` for locally-owned props, and `ControlState` can fire after bind on ownership-transfer events. Physics moving the rigidbody between our writes shows up as Scene-view flicker even when Game view (which renders right after `onBeforeRender`) looks clean. -- `TryRemoveBinding` restores the captured value. - -If the target has no `BasisPickupInteractable` or no `Rigidbody`, the kinematic toggle is skipped. - -## Removal handling - -The manager subscribes to `BasisRuntimeSpawnRegistry.OnRegistryChanged` at init and handles all four change types: - -| `RegistryChangeType` | `instance` payload | Action | -| -------------------- | ------------------ | ----------------------------------------------------------------------------------- | -| `Added` | non-null | Ignore. | -| `Removed` | non-null | If a binding exists for `instance.LoadedNetID`, call `TryRemoveBinding(binding.Id)`. | -| `ClearedUrl` | non-null | Same as `Removed`. | -| `ClearedAll` | null | Clear all bindings. | - -By the time the event fires, `SpawnedGameobjects` has already been cleared and the GameObject may be mid-destroy; the binding's stored `LoadedNetID` is the only reliable identifier. The pose-drive loop tolerates a Unity-null `Target` between the destroy and the event firing. - -This same path covers: - -- Local user removing their own prop via the library menu. -- Server admin removing someone else's prop (network unload broadcast → `BasisNetworkSpawnItem.DestroyGameobject` → `BasisRuntimeSpawnRegistry.RemoveByLoadedNetId` → event fires on the binder's client too). -- Session cleanup via `BasisRuntimeSpawnRegistry.ClearAllNetworking`. - -There is no separate per-source removal plumbing; the registry funnel is sufficient. - -The no-arg `BasisRuntimeSpawnRegistry.ClearAll()` overload does not raise any event. If it is called, existing bindings will not be cleared by this mechanism. The pose-drive loop's `Target == null` guard prevents this from causing a hard error; the binding entries leak until process exit. This is acceptable for v1. - -## Network sync - -The binding does not introduce a new network channel. Spawned game objects already carry `BasisObjectSyncNetworking`, which replicates their transform via `SendCustomNetworkEvent(... DeliveryMethod.Sequenced)` while ownership is local. Because the manager writes the local `target.transform` every render frame, the next outbound sync sample includes the tracker-driven pose, and remote players interpolate to it through the existing `BasisObjectSyncDriver` path. - -Two consequences: - -- Remote update rate is whatever `BasisObjectSyncDriver` sends at — not the avatar bone rate. If this turns out to be visibly too low for hand-bone-rate motion (the juggling case), it's a follow-up question for the object-sync subsystem rather than a tracker-objects problem. -- Network compression is the existing `BasisCompression.QuaternionCompressor` path. No special handling for tracker-driven motion. - -A peer-to-peer transport (`BasisP2PManager`) carries voice and avatar transforms at configurable rates (20–250 Hz, default 60 Hz). Object sync is not currently routed over this transport — `BasisObjectSyncNetworking` continues to send through the server peer. If an object-sync-over-P2P path lands later, tracker-bound props inherit the new rate for free because this package writes to the local transform only and does not own the sync surface. - -If the target has no `BasisNetworkContentBase` / `BasisObjectSyncNetworking` (i.e. it's a local-only object), binding still works for the local player but no remote replication occurs. This is expected; a warning is logged via `BasisDebug.Log` with `BasisDebug.LogTag.TrackerObjects` so the user can see why their bound object doesn't appear to move for others. - -## Logging - -All logging uses `BasisDebug.Log*` with `BasisDebug.LogTag.TrackerObjects`. No `UnityEngine.Debug.Log*` calls in committed code. If `LogTag.TrackerObjects` is not yet present in `BasisDebug`, it is added there as part of the integration. - -## Lifecycle and threading - -- All operations are main-thread only. The pose-drive loop runs in `AfterSimulateOnRender` (main thread by construction). -- The manager initializes via `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` and is idempotent under repeated registration (`_subscribed` flag). -- No `MonoBehaviour`; no scene discovery via `FindObjectsByType` / `FindFirstObjectByType`; no allocations in the per-frame path. - -## Out of scope — and why - -### Persistence across sessions - -A binding does not survive a disconnect/reconnect or a session restart. The user re-binds on next session. - -**Why**: Persistence requires a stable identifier per binding (likely tracker `UniqueDeviceIdentifier` plus a stable instance reference), a serialization format, and a restore-on-spawn handshake that handles the case where the tracker isn't connected at restore time. None of this is needed for the v1 use cases (juggling demo, dolly camera) which are both per-session activities. Adding it post-v1 is purely additive. - -### Manual offset editing UI - -The offset is captured at bind time and cannot be tweaked numerically. - -**Why**: A numeric XYZ + Euler editor in VR is clunky enough that snapshot-on-bind covers the practical workflow better. If the snapshot is wrong, the user unbinds, repositions, and rebinds. A "Recalibrate" button that re-snapshots without going through unbind/rebind is a trivial follow-up if the friction shows up in practice. - -### Multiple trackers per object - -A single binding maps one tracker to one transform's root pose. Multi-point rigging (e.g. two trackers driving an articulated prop with IK) is out. - -**Why**: Single-tracker covers all v1 use cases. Multi-point rigging is a much larger design surface (which tracker drives which joint, conflict resolution between trackers, calibration UX for multi-anchor poses) and shouldn't be smuggled into the first slice. - -### Higher-rate or custom sync path - -The binding does not introduce its own network sync. It relies on whatever `BasisObjectSyncNetworking` provides. - -**Why**: Reinventing sync inside this package would duplicate compression, ownership, and delivery code that already exists and is already tested. If the existing rate proves insufficient for hand-bone-rate motion, the fix belongs in `BasisObjectSyncDriver` (raise the cadence or expose a per-instance high-rate flag), not here. - -### Allow binder to grab through the veto - -The pickup veto blocks the binder's own inputs too. - -**Why**: For the provided use cases there's no reason the binder needs to grab the prop while it's bound — the tracker is the driver. If a future use case wants "bound but still hand-grabbable for fine adjustment", the predicate can be made input-aware. - -### Calibration affordances beyond snapshot-on-bind - -No "Recalibrate" button, no "Reset offset to zero" button, no pose-preview during the picker dialog. - -**Why**: Snapshot-on-bind plus unbind/rebind covers the workflow. Each of these affordances is independently small to add once a real need surfaces; bundling them speculatively into the current scope stretches the surface unnecessarily. - -## Future outlook - -Follow-ups likely to be promoted into a later release once the version 1 surface is validated in real use: - -- Recalibrate button on the library row when a binding exists (re-snapshots offset without unbind/rebind). -- Per-binding "allow binder to grab" toggle for fine-adjustment workflows. -- Visible binding indicator in-world (small icon above the prop showing it's tracker-driven, and which tracker). -- Object sync routed over the `BasisP2PManager` transport. Not a change in this package — `BasisObjectSyncNetworking` would gain a P2P send path, and tracker-bound props would inherit the higher remote rate automatically. Tracked here so a future reader knows where the rate ceiling moves once that lands. - -These are listed for visibility, not committed. - -## Integration points to respect - -- **Logging**: `BasisDebug.Log*` with `BasisDebug.LogTag.TrackerObjects`. Never `UnityEngine.Debug.Log*` in committed code. -- **STYLE.md compliance**: no allocations on the per-frame path; prefer `TryGetComponent` over `GetComponent`; no `FindObjectsByType` for scene discovery; events driven through `BasisEventDriver` patterns where applicable; Burst-jobbable hot paths considered where the work justifies it. -- **Pickup integration**: use `BasisPickupInteractable.CanHoverInjected` / `CanInteractInjected` for veto. Do not introduce new predicate lists on the pickup component. -- **Registry integration**: subscribe to `BasisRuntimeSpawnRegistry.OnRegistryChanged`. Do not subscribe to per-source removal paths (the registry funnel is exhaustive). -- **Render order**: keep `AfterSimulateOnRender` priority at the existing value (`99`). Other systems may depend on this slot's relative ordering. diff --git a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta b/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta deleted file mode 100644 index ee8bad011c..0000000000 --- a/Basis/Packages/com.basis.trackerobjects/REQUIREMENTS.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 51c2ddb969b7b094f81cf4ac803d2bd3 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: