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..0b5321d804 --- /dev/null +++ b/Basis/Packages/com.basis.integration.trackerobjects/Runtime/BasisTrackerObjectsLibraryHook.cs @@ -0,0 +1,138 @@ +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); + // 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 () => + { + 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 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/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: