diff --git a/devtool/EmulatorDevtools.tsx b/devtool/EmulatorDevtools.tsx
new file mode 100644
index 0000000..f5518a5
--- /dev/null
+++ b/devtool/EmulatorDevtools.tsx
@@ -0,0 +1,154 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/ir-engine/ir-engine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Infinite Reality Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Infinite Reality Engine team.
+
+All portions of the code written by the Infinite Reality Engine team are Copyright © 2021-2023
+Infinite Reality Engine. All Rights Reserved.
+*/
+
+import { useHookstate, useImmediateEffect, useMutableState } from '@ir-engine/hyperflux'
+import { endXRSession, requestXRSession } from '@ir-engine/spatial/src/xr/XRSessionFunctions'
+import Button from '@ir-engine/ui/src/primitives/tailwind/Button'
+import React from 'react'
+
+import EmulatedDevice from './js/emulatedDevice'
+import { EmulatorSettings, emulatorStates } from './js/emulatorStates'
+import { syncDevicePose } from './js/messenger'
+import Devtool from './jsx/app'
+import devtoolCSS from './styles/index.css?inline'
+
+import { XRState } from '@ir-engine/spatial/src/xr/XRState'
+import 'bootstrap'
+import 'bootstrap/dist/css/bootstrap.min.css'
+import { WebXREventDispatcher } from '@ir-engine/spatial/tests/webxr/emulator/WebXREventDispatcher'
+import { POLYFILL_ACTIONS } from '@ir-engine/spatial/tests/webxr/emulator/actions'
+
+export async function overrideXR(args: { mode: 'immersive-vr' | 'immersive-ar' }) {
+ // inject the webxr polyfill from the webxr emulator source - this is a script added by the bot
+ // globalThis.WebXRPolyfillInjection()
+
+ const { CustomWebXRPolyfill } = await import('@ir-engine/spatial/tests/webxr/emulator/CustomWebXRPolyfill')
+ new CustomWebXRPolyfill()
+ // override session supported request, it hangs indefinitely for some reason
+ ;(navigator as any).xr.isSessionSupported = () => {
+ return true
+ }
+
+ const deviceDefinition = {
+ id: 'Oculus Quest',
+ name: 'Oculus Quest',
+ modes: ['inline', 'immersive-vr', 'immersive-ar'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true
+ },
+ controllers: [
+ {
+ id: 'Oculus Touch (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 0,
+ primarySqueezeButtonIndex: 1,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ isComplex: true
+ },
+ {
+ id: 'Oculus Touch (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 0,
+ primarySqueezeButtonIndex: 1,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ isComplex: true
+ }
+ ],
+ environmentBlendMode: args.mode === 'immersive-vr' ? 'opaque' : 'additive'
+ }
+
+ // send our device info to the polyfill API so it knows our capabilities
+ WebXREventDispatcher.instance.dispatchEvent({
+ type: POLYFILL_ACTIONS.DEVICE_INIT,
+ detail: { stereoEffect: false, deviceDefinition }
+ })
+}
+
+const setup = async (mode: 'immersive-vr' | 'immersive-ar') => {
+ await overrideXR({ mode })
+ await EmulatorSettings.instance.load()
+ const device = new EmulatedDevice()
+ device.on('pose', syncDevicePose)
+ ;(emulatorStates as any).emulatedDevice = device
+
+ return device
+}
+
+export const EmulatorDevtools = (props: { mode: 'immersive-vr' | 'immersive-ar' }) => {
+ const xrState = useMutableState(XRState)
+ const xrActive = xrState.sessionActive.value && !xrState.requestingSession.value
+
+ const deviceState = useHookstate(null as null | EmulatedDevice)
+ useImmediateEffect(() => {
+ setup(props.mode).then((device) => {
+ deviceState.set(device)
+ })
+ }, [])
+
+ const toggleXR = async () => {
+ if (xrActive) {
+ endXRSession()
+ } else {
+ requestXRSession({ mode: props.mode })
+ }
+ }
+
+ const togglePlacement = () => {
+ if (xrState.scenePlacementMode.value !== 'placing') {
+ xrState.scenePlacementMode.set('placing')
+ xrState.sceneScaleAutoMode.set(false)
+ xrState.sceneScaleTarget.set(0.1)
+ } else {
+ xrState.scenePlacementMode.set('placed')
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/devtool/assets/headset.glb b/devtool/assets/headset.glb
new file mode 100644
index 0000000..485e243
Binary files /dev/null and b/devtool/assets/headset.glb differ
diff --git a/devtool/assets/images/auto-return.png b/devtool/assets/images/auto-return.png
new file mode 100644
index 0000000..ac294f8
Binary files /dev/null and b/devtool/assets/images/auto-return.png differ
diff --git a/devtool/assets/images/button1-left.png b/devtool/assets/images/button1-left.png
new file mode 100644
index 0000000..ac16bca
Binary files /dev/null and b/devtool/assets/images/button1-left.png differ
diff --git a/devtool/assets/images/button1-right.png b/devtool/assets/images/button1-right.png
new file mode 100644
index 0000000..4343c70
Binary files /dev/null and b/devtool/assets/images/button1-right.png differ
diff --git a/devtool/assets/images/button2-left.png b/devtool/assets/images/button2-left.png
new file mode 100644
index 0000000..c2222d7
Binary files /dev/null and b/devtool/assets/images/button2-left.png differ
diff --git a/devtool/assets/images/button2-right.png b/devtool/assets/images/button2-right.png
new file mode 100644
index 0000000..88d7136
Binary files /dev/null and b/devtool/assets/images/button2-right.png differ
diff --git a/devtool/assets/images/delete.png b/devtool/assets/images/delete.png
new file mode 100644
index 0000000..efe5c73
Binary files /dev/null and b/devtool/assets/images/delete.png differ
diff --git a/devtool/assets/images/exit.png b/devtool/assets/images/exit.png
new file mode 100644
index 0000000..f7a1a72
Binary files /dev/null and b/devtool/assets/images/exit.png differ
diff --git a/devtool/assets/images/gamepad.png b/devtool/assets/images/gamepad.png
new file mode 100644
index 0000000..97cb605
Binary files /dev/null and b/devtool/assets/images/gamepad.png differ
diff --git a/devtool/assets/images/grip-left.png b/devtool/assets/images/grip-left.png
new file mode 100644
index 0000000..926f292
Binary files /dev/null and b/devtool/assets/images/grip-left.png differ
diff --git a/devtool/assets/images/grip-right.png b/devtool/assets/images/grip-right.png
new file mode 100644
index 0000000..7f3ad8f
Binary files /dev/null and b/devtool/assets/images/grip-right.png differ
diff --git a/devtool/assets/images/hand-pose.png b/devtool/assets/images/hand-pose.png
new file mode 100644
index 0000000..28d7f3b
Binary files /dev/null and b/devtool/assets/images/hand-pose.png differ
diff --git a/devtool/assets/images/hand-tracking.png b/devtool/assets/images/hand-tracking.png
new file mode 100644
index 0000000..10fadea
Binary files /dev/null and b/devtool/assets/images/hand-tracking.png differ
diff --git a/devtool/assets/images/headset-type.png b/devtool/assets/images/headset-type.png
new file mode 100644
index 0000000..83358d2
Binary files /dev/null and b/devtool/assets/images/headset-type.png differ
diff --git a/devtool/assets/images/headset.png b/devtool/assets/images/headset.png
new file mode 100644
index 0000000..cc47029
Binary files /dev/null and b/devtool/assets/images/headset.png differ
diff --git a/devtool/assets/images/horizontal.png b/devtool/assets/images/horizontal.png
new file mode 100644
index 0000000..07e1db7
Binary files /dev/null and b/devtool/assets/images/horizontal.png differ
diff --git a/devtool/assets/images/info.png b/devtool/assets/images/info.png
new file mode 100644
index 0000000..bf27215
Binary files /dev/null and b/devtool/assets/images/info.png differ
diff --git a/devtool/assets/images/joystick.png b/devtool/assets/images/joystick.png
new file mode 100644
index 0000000..b248180
Binary files /dev/null and b/devtool/assets/images/joystick.png differ
diff --git a/devtool/assets/images/keyboard.png b/devtool/assets/images/keyboard.png
new file mode 100644
index 0000000..ac58759
Binary files /dev/null and b/devtool/assets/images/keyboard.png differ
diff --git a/devtool/assets/images/left-controller.png b/devtool/assets/images/left-controller.png
new file mode 100644
index 0000000..eac798c
Binary files /dev/null and b/devtool/assets/images/left-controller.png differ
diff --git a/devtool/assets/images/left-hand.png b/devtool/assets/images/left-hand.png
new file mode 100644
index 0000000..cee6edd
Binary files /dev/null and b/devtool/assets/images/left-hand.png differ
diff --git a/devtool/assets/images/lock.png b/devtool/assets/images/lock.png
new file mode 100644
index 0000000..5f79253
Binary files /dev/null and b/devtool/assets/images/lock.png differ
diff --git a/devtool/assets/images/move.png b/devtool/assets/images/move.png
new file mode 100644
index 0000000..d12b091
Binary files /dev/null and b/devtool/assets/images/move.png differ
diff --git a/devtool/assets/images/play.png b/devtool/assets/images/play.png
new file mode 100644
index 0000000..e512702
Binary files /dev/null and b/devtool/assets/images/play.png differ
diff --git a/devtool/assets/images/polyfill-on.png b/devtool/assets/images/polyfill-on.png
new file mode 100644
index 0000000..c4fe0b0
Binary files /dev/null and b/devtool/assets/images/polyfill-on.png differ
diff --git a/devtool/assets/images/pose.png b/devtool/assets/images/pose.png
new file mode 100644
index 0000000..6551a82
Binary files /dev/null and b/devtool/assets/images/pose.png differ
diff --git a/devtool/assets/images/press.png b/devtool/assets/images/press.png
new file mode 100644
index 0000000..443404b
Binary files /dev/null and b/devtool/assets/images/press.png differ
diff --git a/devtool/assets/images/reset.png b/devtool/assets/images/reset.png
new file mode 100644
index 0000000..476b652
Binary files /dev/null and b/devtool/assets/images/reset.png differ
diff --git a/devtool/assets/images/revert.png b/devtool/assets/images/revert.png
new file mode 100644
index 0000000..0974e7d
Binary files /dev/null and b/devtool/assets/images/revert.png differ
diff --git a/devtool/assets/images/right-controller.png b/devtool/assets/images/right-controller.png
new file mode 100644
index 0000000..27888ab
Binary files /dev/null and b/devtool/assets/images/right-controller.png differ
diff --git a/devtool/assets/images/right-hand.png b/devtool/assets/images/right-hand.png
new file mode 100644
index 0000000..0047730
Binary files /dev/null and b/devtool/assets/images/right-hand.png differ
diff --git a/devtool/assets/images/roomscale.png b/devtool/assets/images/roomscale.png
new file mode 100644
index 0000000..24fbf51
Binary files /dev/null and b/devtool/assets/images/roomscale.png differ
diff --git a/devtool/assets/images/rotation.png b/devtool/assets/images/rotation.png
new file mode 100644
index 0000000..4aea38e
Binary files /dev/null and b/devtool/assets/images/rotation.png differ
diff --git a/devtool/assets/images/save-copy.png b/devtool/assets/images/save-copy.png
new file mode 100644
index 0000000..5509eab
Binary files /dev/null and b/devtool/assets/images/save-copy.png differ
diff --git a/devtool/assets/images/save.png b/devtool/assets/images/save.png
new file mode 100644
index 0000000..3629043
Binary files /dev/null and b/devtool/assets/images/save.png differ
diff --git a/devtool/assets/images/send.png b/devtool/assets/images/send.png
new file mode 100644
index 0000000..7908702
Binary files /dev/null and b/devtool/assets/images/send.png differ
diff --git a/devtool/assets/images/settings.png b/devtool/assets/images/settings.png
new file mode 100644
index 0000000..1e83b62
Binary files /dev/null and b/devtool/assets/images/settings.png differ
diff --git a/devtool/assets/images/stereo.png b/devtool/assets/images/stereo.png
new file mode 100644
index 0000000..0bd7d89
Binary files /dev/null and b/devtool/assets/images/stereo.png differ
diff --git a/devtool/assets/images/sticky.png b/devtool/assets/images/sticky.png
new file mode 100644
index 0000000..ae1a79c
Binary files /dev/null and b/devtool/assets/images/sticky.png differ
diff --git a/devtool/assets/images/trash.png b/devtool/assets/images/trash.png
new file mode 100644
index 0000000..5ce87e0
Binary files /dev/null and b/devtool/assets/images/trash.png differ
diff --git a/devtool/assets/images/trigger-left.png b/devtool/assets/images/trigger-left.png
new file mode 100644
index 0000000..02929f9
Binary files /dev/null and b/devtool/assets/images/trigger-left.png differ
diff --git a/devtool/assets/images/trigger-right.png b/devtool/assets/images/trigger-right.png
new file mode 100644
index 0000000..52b9113
Binary files /dev/null and b/devtool/assets/images/trigger-right.png differ
diff --git a/devtool/assets/images/undo.png b/devtool/assets/images/undo.png
new file mode 100644
index 0000000..ce84255
Binary files /dev/null and b/devtool/assets/images/undo.png differ
diff --git a/devtool/assets/images/vertical.png b/devtool/assets/images/vertical.png
new file mode 100644
index 0000000..d6ef3fb
Binary files /dev/null and b/devtool/assets/images/vertical.png differ
diff --git a/devtool/assets/left-controller.glb b/devtool/assets/left-controller.glb
new file mode 100644
index 0000000..56d914d
Binary files /dev/null and b/devtool/assets/left-controller.glb differ
diff --git a/devtool/assets/left-hand.glb b/devtool/assets/left-hand.glb
new file mode 100644
index 0000000..9feae89
Binary files /dev/null and b/devtool/assets/left-hand.glb differ
diff --git a/devtool/assets/right-controller.glb b/devtool/assets/right-controller.glb
new file mode 100644
index 0000000..bb3d9df
Binary files /dev/null and b/devtool/assets/right-controller.glb differ
diff --git a/devtool/assets/right-hand.glb b/devtool/assets/right-hand.glb
new file mode 100644
index 0000000..2c29a7d
Binary files /dev/null and b/devtool/assets/right-hand.glb differ
diff --git a/devtool/js/actions.js b/devtool/js/actions.js
new file mode 100644
index 0000000..b7a9911
--- /dev/null
+++ b/devtool/js/actions.js
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Events triggered by the emulator UI and sent to the content script
+ */
+export const EMULATOR_ACTIONS = {
+ HEADSET_POSE_CHANGE: 'ea-headset-pose-change',
+ CONTROLLER_POSE_CHANGE: 'ea-controller-pose-change',
+ CONTROLLER_VISIBILITY_CHANGE: 'ea-controller-visibility-change',
+ BUTTON_STATE_CHANGE: 'ea-button-state-change',
+ ANALOG_VALUE_CHANGE: 'ea-analog-value-change',
+ DEVICE_TYPE_CHANGE: 'ea-device-type-change',
+ STEREO_TOGGLE: 'ea-stereo-toggle',
+ KEYBOARD_EVENT: 'ea-keyboard-event',
+ EXIT_IMMERSIVE: 'ea-exit-immersive',
+ ROOM_DIMENSION_CHANGE: 'ea-room-dimension-change',
+ EXCLUDE_POLYFILL: 'ea-exclude-polyfill',
+ INPUT_MODE_CHANGE: 'ea-input-mode-change',
+ HAND_POSE_CHANGE: 'ea-hand-pose-change',
+ HAND_VISIBILITY_CHANGE: 'ea-hand-visibility-change',
+ PINCH_VALUE_CHANGE: 'ea-pinch-value-change',
+ USER_OBJECTS_CHANGE: 'ea-user-objects-change',
+};
+
+/**
+ * Events triggered by the content script and caught and processed by the custom WebXR Polyfill
+ */
+export const POLYFILL_ACTIONS = EMULATOR_ACTIONS
+// {
+// HEADSET_POSE_CHANGE: 'pa-headset-pose-change',
+// CONTROLLER_POSE_CHANGE: 'pa-controller-pose-change',
+// CONTROLLER_VISIBILITY_CHANGE: 'pa-controller-visibility-change',
+// BUTTON_STATE_CHANGE: 'pa-button-state-change',
+// ANALOG_VALUE_CHANGE: 'pa-analog-value-change',
+// DEVICE_TYPE_CHANGE: 'pa-device-type-change',
+// STEREO_TOGGLE: 'pa-stereo-toggle',
+// KEYBOARD_EVENT: 'pa-keyboard-event',
+// EXIT_IMMERSIVE: 'pa-exit-immersive',
+// DEVICE_INIT: 'pa-device-init',
+// ROOM_DIMENSION_CHANGE: 'pa-room-dimension-change',
+// INPUT_MODE_CHANGE: 'pa-input-mode-change',
+// HAND_POSE_CHANGE: 'pa-hand-pose-change',
+// HAND_VISIBILITY_CHANGE: 'pa-hand-visibility-change',
+// PINCH_VALUE_CHANGE: 'pa-pinch-value-change',
+// USER_OBJECTS_CHANGE: 'pa-user-objects-change',
+// };
+
+/**
+ * Events triggered from the client side that are caught by the content script and then relayed back to the emulator side
+ */
+export const CLIENT_ACTIONS = {
+ ENTER_IMMERSIVE: 'ca-enter-immersive',
+ EXIT_IMMERSIVE: 'ca-exit-immersive',
+ PING: 'ca-ping',
+};
diff --git a/devtool/js/constants.js b/devtool/js/constants.js
new file mode 100644
index 0000000..3eeee5c
--- /dev/null
+++ b/devtool/js/constants.js
@@ -0,0 +1,141 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+export const PRESS_AND_RELEASE_DURATION = 250;
+
+export const BUTTON_POLYFILL_INDEX_MAPPING = {
+ joystick: 0,
+ trigger: 1,
+ grip: 2,
+ button1: 3,
+ button2: 4,
+};
+
+export const DEVICE = {
+ HEADSET: '0',
+ INPUT_RIGHT: '2',
+ INPUT_LEFT: '3',
+};
+
+export const OBJECT_NAME = {};
+OBJECT_NAME[DEVICE.HEADSET] = 'headset';
+OBJECT_NAME[DEVICE.INPUT_LEFT] = 'left-controller';
+OBJECT_NAME[DEVICE.INPUT_RIGHT] = 'right-controller';
+
+export const DEFAULT_TRANSFORMS = {};
+DEFAULT_TRANSFORMS[DEVICE.HEADSET] = {
+ position: [0, 1.7, 0],
+ rotation: [0, 0, 0, 'XYZ'],
+};
+DEFAULT_TRANSFORMS[DEVICE.INPUT_RIGHT] = {
+ position: [0.25, 1.5, -0.4],
+ rotation: [0, 0, 0, 'XYZ'],
+};
+DEFAULT_TRANSFORMS[DEVICE.INPUT_LEFT] = {
+ position: [-0.25, 1.5, -0.4],
+ rotation: [0, 0, 0, 'XYZ'],
+};
+
+export const CONTROLLER_STRINGS = {};
+CONTROLLER_STRINGS[DEVICE.INPUT_LEFT] = {
+ name: 'left-controller',
+ displayName: 'Left Controller',
+ handedness: 'left',
+ button1: 'ButtonX',
+ button2: 'ButtonY',
+};
+CONTROLLER_STRINGS[DEVICE.INPUT_RIGHT] = {
+ name: 'right-controller',
+ displayName: 'Right Controller',
+ handedness: 'right',
+ button1: 'ButtonA',
+ button2: 'ButtonB',
+};
+
+export const HAND_STRINGS = {};
+HAND_STRINGS[DEVICE.INPUT_LEFT] = {
+ name: 'left-hand',
+ displayName: 'Left Hand',
+ handedness: 'left',
+};
+HAND_STRINGS[DEVICE.INPUT_RIGHT] = {
+ name: 'right-hand',
+ displayName: 'Right Hand',
+ handedness: 'right',
+};
+
+export const KEYBOARD_CONTROL_MAPPING = {
+ left: {
+ joystickLeft: 'a',
+ joystickRight: 'd',
+ joystickForward: 'w',
+ joystickBackward: 's',
+ trigger: 'e',
+ grip: 'q',
+ button1: 'x',
+ button2: 'z',
+ joystick: 'c',
+ },
+ right: {
+ joystickLeft: 'ArrowLeft',
+ joystickRight: 'ArrowRight',
+ joystickForward: 'ArrowUp',
+ joystickBackward: 'ArrowDown',
+ trigger: 'Enter',
+ grip: 'Shift',
+ button1: "'",
+ button2: '/',
+ joystick: '.',
+ },
+};
+
+export const GAMEPAD_ID_TO_INPUT_ID_MAPPING = {
+ 3: 'joystick',
+ 5: 'button2',
+ 4: 'button1',
+ 0: 'trigger',
+ 1: 'grip',
+};
+
+export const SEMANTIC_LABELS = {
+ Desk: 'desk',
+ Couch: 'couch',
+ Floor: 'floor',
+ Ceiling: 'ceiling',
+ Wall: 'wall',
+ Door: 'door',
+ Window: 'window',
+ Table: 'table',
+ Shelf: 'shelf',
+ Bed: 'bed',
+ Screen: 'screen',
+ Lamp: 'lamp',
+ Plant: 'plant',
+ WallArt: 'wall art',
+ Other: 'other',
+};
+
+export const TRIGGER_MODES = ['slow', 'normal', 'fast', 'turbo'];
+
+export const TRIGGER_CONFIG = {
+ slow: {
+ interval: 20,
+ holdTime: 100,
+ },
+ normal: {
+ interval: 10,
+ holdTime: 50,
+ },
+ fast: {
+ interval: 5,
+ holdTime: 10,
+ },
+ turbo: {
+ interval: 1,
+ holdTime: 1,
+ },
+};
diff --git a/devtool/js/devices.js b/devtool/js/devices.js
new file mode 100644
index 0000000..468698a
--- /dev/null
+++ b/devtool/js/devices.js
@@ -0,0 +1,235 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+export const DEVICE_DEFINITIONS = {
+ 'Oculus Rift CV1': {
+ id: 'Oculus Rift CV1',
+ name: 'Oculus Rift CV1',
+ shortName: 'Rift CV1',
+ profile: 'oculus-touch',
+ modes: ['inline', 'immersive-vr'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Oculus Touch (Left)',
+ buttonNum: 7,
+ /**
+ * this is not the index in gamepad.buttons, but the index used for input remapping in WebXR Polyfill
+ * @see https://github.com/immersive-web/webxr-polyfill/blob/main/src/devices/GamepadMappings.js
+ */
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Oculus Touch (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+ 'Oculus Rift S': {
+ id: 'Oculus Rift S',
+ name: 'Oculus Rift S',
+ shortName: 'Rift S',
+ profile: 'oculus-touch-v2',
+ modes: ['inline', 'immersive-vr'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Oculus Touch V2 (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Oculus Touch V2 (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+ 'Oculus Quest': {
+ id: 'Oculus Quest',
+ name: 'Oculus Quest',
+ shortName: 'Quest 1',
+ profile: 'oculus-touch-v2',
+ modes: ['inline', 'immersive-vr'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Oculus Touch V2 (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Oculus Touch V2 (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+ 'Oculus Quest 2': {
+ id: 'Oculus Quest 2',
+ name: 'Oculus Quest 2',
+ shortName: 'Quest 2',
+ profile: 'oculus-touch-v3',
+ modes: ['inline', 'immersive-vr', 'immersive-ar'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Oculus Touch V3 (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Oculus Touch V3 (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+ 'Meta Quest Pro': {
+ id: 'Meta Quest Pro',
+ name: 'Meta Quest Pro',
+ shortName: 'Quest Pro',
+ profile: 'meta-quest-touch-pro',
+ modes: ['inline', 'immersive-vr', 'immersive-ar'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Meta Quest Touch Pro (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Meta Quest Touch Pro (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+ 'Meta Quest 3': {
+ id: 'Meta Quest 3',
+ name: 'Meta Quest 3',
+ shortName: 'Quest 3',
+ profile: 'meta-quest-touch-plus',
+ modes: ['inline', 'immersive-vr', 'immersive-ar'],
+ headset: {
+ hasPosition: true,
+ hasRotation: true,
+ },
+ controllers: [
+ {
+ id: 'Meta Quest Touch Plus (Left)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'left',
+ },
+ {
+ id: 'Meta Quest Touch Plus (Right)',
+ buttonNum: 7,
+ primaryButtonIndex: 1,
+ primarySqueezeButtonIndex: 2,
+ hasPosition: true,
+ hasRotation: true,
+ hasSqueezeButton: true,
+ handedness: 'right',
+ },
+ ],
+ polyfillInputMapping: {
+ axes: [2, 3, 0, 1],
+ buttons: [1, 2, null, 0, 3, 4, null],
+ },
+ },
+};
diff --git a/devtool/js/emulatedDevice.js b/devtool/js/emulatedDevice.js
new file mode 100644
index 0000000..8f7cde2
--- /dev/null
+++ b/devtool/js/emulatedDevice.js
@@ -0,0 +1,537 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import * as THREE from 'three';
+
+import {
+ CONTROLLER_STRINGS,
+ DEVICE,
+ HAND_STRINGS,
+ OBJECT_NAME,
+} from './constants';
+import { EmulatorSettings, emulatorStates } from './emulatorStates';
+
+import { BoxLineGeometry } from 'three/examples/jsm/geometries/BoxLineGeometry.js';
+import { EventEmitter } from 'events';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
+import { generateUUID } from 'three/src/math/MathUtils.js';
+import { updateUserObjects } from './messenger';
+
+import config from '@ir-engine/common/src/config';
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+const SELECTION_MOUSE_DOWN_THRESHOLD = 300;
+
+const isNumber = function isNumber(value) {
+ return typeof value === 'number' && isFinite(value);
+};
+
+export default class EmulatedDevice extends EventEmitter {
+ constructor() {
+ super();
+ this._renderer = new THREE.WebGLRenderer({ antialias: true });
+ this._renderer.setPixelRatio(window.devicePixelRatio);
+ this._renderer.setSize(1, 1);
+ this._renderer.domElement.style.position = 'absolute';
+
+ this._scene = new THREE.Scene();
+ this._scene.background = new THREE.Color(0x505050);
+ this._scene.add(new THREE.DirectionalLight(0xffffff, 1));
+ this._scene.add(new THREE.AmbientLight(0x404040, 2));
+
+ this._camera = new THREE.PerspectiveCamera(45, 1 / 1, 0.1, 100);
+ this._camera.position.set(-1.5, 1.7, 2);
+ this._camera.lookAt(new THREE.Vector3(0, 1.6, 0));
+
+ this._controllerMeshes = {};
+ this._controllerMeshesHidden = {};
+ this._handMeshes = {};
+ this._handMeshesHidden = {};
+
+ this._labelContainer = document.createElement('div');
+
+ const oc = new OrbitControls(this._camera, this.canvas);
+ oc.addEventListener('change', this.render.bind(this));
+ oc.target.set(0, 1.6, 0);
+ oc.update();
+ this._orbitControls = oc;
+
+ const loader = new GLTFLoader();
+ this._transformControls = {};
+ Object.values(DEVICE).forEach((deviceKey) => {
+ const node = new THREE.Group();
+ node.position.fromArray(
+ EmulatorSettings.instance.defaultPose[deviceKey].position,
+ );
+ node.rotation.fromArray(
+ EmulatorSettings.instance.defaultPose[deviceKey].rotation,
+ );
+ emulatorStates.assetNodes[deviceKey] = node;
+ this._scene.add(node);
+
+ // add device node mesh to parent
+ loader.load(`${assetURL}/assets/${OBJECT_NAME[deviceKey]}.glb`, (gltf) => {
+ const mesh = gltf.scene;
+ mesh.scale.setScalar(2);
+ mesh.rotateY(Math.PI);
+ mesh.traverse((child) => {
+ child.userData['deviceKey'] = deviceKey;
+ });
+ node.add(mesh);
+ if (CONTROLLER_STRINGS[deviceKey]) {
+ this._controllerMeshes[deviceKey] = mesh;
+ mesh.visible = EmulatorSettings.instance.inputMode === 'controllers';
+ this._controllerMeshesHidden[deviceKey] = false;
+ }
+ this.render();
+ });
+
+ if (HAND_STRINGS[deviceKey]) {
+ loader.load(`${assetURL}/assets/${HAND_STRINGS[deviceKey].name}.glb`, (gltf) => {
+ const mesh = gltf.scene;
+ mesh.scale.setScalar(2);
+ mesh.rotateY(Math.PI);
+ mesh.traverse((child) => {
+ child.userData['deviceKey'] = deviceKey;
+ });
+ node.add(mesh);
+ this._handMeshes[deviceKey] = mesh;
+ mesh.visible = EmulatorSettings.instance.inputMode === 'hands';
+ this._handMeshesHidden[deviceKey] = false;
+ this.render();
+ });
+ }
+
+ // setup transform control
+ const controls = new TransformControls(this._camera, this.canvas);
+ controls.attach(node);
+ controls.enabled = false;
+ controls.visible = false;
+ controls.addEventListener('mouseDown', () => (oc.enabled = false));
+ controls.addEventListener('mouseUp', () => (oc.enabled = true));
+ controls.addEventListener('change', () => {
+ this._emitPoseEvent(deviceKey);
+ this.render();
+ });
+ this._transformControls[deviceKey] = controls;
+ this._scene.add(controls);
+ });
+
+ this._userObjects = {};
+ this._recoverObjects();
+
+ // check device node selection by raycast
+ this._raycaster = new THREE.Raycaster();
+ this._mouseVec2 = new THREE.Vector2();
+ this._mouseDownTime = null;
+ this._selectedDeviceKey = null;
+ this.canvas.addEventListener('mousedown', (event) => {
+ this._selectedDeviceKey = this._findSelectedDeviceNode(event);
+ this._mouseDownTime = performance.now();
+ });
+ this.canvas.addEventListener('mouseup', () => {
+ if (this._selectedDeviceKey != null) {
+ const currentTime = performance.now();
+ if (
+ currentTime - this._mouseDownTime <
+ SELECTION_MOUSE_DOWN_THRESHOLD
+ ) {
+ this.toggleControlMode(this._selectedDeviceKey);
+ oc.enabled = true;
+ }
+ }
+ });
+
+ this.updateRoom();
+ }
+
+ _emitPoseEvent(deviceKey) {
+ const node = this.getDeviceNode(deviceKey);
+ this.emit('pose', {
+ deviceKey,
+ position: node.position.toArray(),
+ rotation: node.rotation.toArray(),
+ quaternion: node.quaternion.toArray(),
+ });
+ }
+
+ _findSelectedDeviceNode(mouseEvent) {
+ const rect = this.canvas.getBoundingClientRect();
+ const point = {
+ x: (mouseEvent.clientX - rect.left) / rect.width,
+ y: (mouseEvent.clientY - rect.top) / rect.height,
+ };
+ this._mouseVec2.set(point.x * 2 - 1, -(point.y * 2) + 1);
+ this._raycaster.setFromCamera(this._mouseVec2, this._camera);
+ const intersect = this._raycaster.intersectObjects(
+ [
+ ...Object.values(emulatorStates.assetNodes),
+ ...Object.values(this._userObjects),
+ ],
+ true,
+ )[0];
+
+ return (
+ intersect?.object.userData['deviceKey'] ??
+ intersect?.object.userData['userObjectId']
+ );
+ }
+
+ updateRoom() {
+ const dimension = EmulatorSettings.instance.roomDimension;
+ if (this._roomObject) {
+ this._scene.remove(this._roomObject);
+ }
+ this._roomObject = new THREE.LineSegments(
+ new BoxLineGeometry(
+ dimension.x,
+ dimension.y,
+ dimension.z,
+ Math.ceil(dimension.x * 2),
+ Math.ceil(dimension.y * 2),
+ Math.ceil(dimension.z * 2),
+ ),
+ new THREE.LineBasicMaterial({ color: 0x808080 }),
+ );
+ this._roomObject.geometry.translate(0, dimension.y / 2, 0);
+ this._scene.add(this._roomObject);
+ this.render();
+ }
+
+ addObject(object, semanticLabel, idOverride = null) {
+ this._scene.add(object);
+ const controls = new TransformControls(this._camera, this.canvas);
+ controls.attach(object);
+ controls.enabled = false;
+ controls.visible = false;
+ controls.addEventListener(
+ 'mouseDown',
+ () => (this._orbitControls.enabled = false),
+ );
+ controls.addEventListener('mouseUp', () => {
+ this._orbitControls.enabled = true;
+ this._updateObjects();
+ });
+ controls.addEventListener('change', () => {
+ this.render();
+ });
+ this._scene.add(controls);
+ const userObjectId = idOverride ?? generateUUID();
+ this._transformControls[userObjectId] = controls;
+ this._userObjects[userObjectId] = object;
+ const label = document.createElement('div');
+ label.classList.add('semantic-label');
+ label.innerHTML = semanticLabel;
+ this._labelContainer.appendChild(label);
+ object.userData = { userObjectId, controls, semanticLabel, label };
+ if (idOverride == null) {
+ this.render();
+ }
+ return userObjectId;
+ }
+
+ addMesh(
+ width,
+ height,
+ depth,
+ semanticLabel,
+ idOverride = null,
+ active = true,
+ ) {
+ if (
+ !isNumber(width) ||
+ !isNumber(height) ||
+ !isNumber(depth) ||
+ width * height * depth == 0
+ ) {
+ return;
+ }
+ const object = new THREE.Mesh(
+ new THREE.BoxGeometry(width, height, depth),
+ new THREE.MeshPhongMaterial({
+ color: 0xffffff * Math.random(),
+ transparent: true,
+ }),
+ );
+ const userObjectId = this.addObject(object, semanticLabel, idOverride);
+ EmulatorSettings.instance.userObjects[userObjectId] = {
+ type: 'mesh',
+ active: true,
+ width,
+ height,
+ depth,
+ semanticLabel,
+ position: object.position.toArray(),
+ quaternion: object.quaternion.toArray(),
+ };
+ this._toggleObjectVisibility(userObjectId, active);
+ EmulatorSettings.instance.write().then(updateUserObjects);
+ return object;
+ }
+
+ addPlane(
+ width,
+ height,
+ isVertical,
+ semanticLabel,
+ idOverride = null,
+ active = true,
+ ) {
+ if (!isNumber(width) || !isNumber(height) || width * height == 0) {
+ return;
+ }
+ const planeGeometry = new THREE.PlaneGeometry(width, height);
+ planeGeometry.rotateX(Math.PI / 2);
+ const object = new THREE.Mesh(
+ planeGeometry,
+ new THREE.MeshPhongMaterial({
+ color: 0xffffff * Math.random(),
+ side: THREE.DoubleSide,
+ transparent: true,
+ }),
+ );
+ if (isVertical) {
+ object.rotateX(Math.PI / 2);
+ }
+ const userObjectId = this.addObject(object, semanticLabel, idOverride);
+ EmulatorSettings.instance.userObjects[userObjectId] = {
+ type: 'plane',
+ active: true,
+ width,
+ height,
+ isVertical,
+ semanticLabel,
+ position: object.position.toArray(),
+ quaternion: object.quaternion.toArray(),
+ };
+ this._toggleObjectVisibility(userObjectId, active);
+ EmulatorSettings.instance.write().then(updateUserObjects);
+ return object;
+ }
+
+ deleteSelectedObject() {
+ Object.entries(this._transformControls).forEach(([key, controls]) => {
+ if (controls.enabled) {
+ const object = this._userObjects[key];
+ if (object) {
+ const { label } = object.userData;
+ this._labelContainer.removeChild(label);
+ controls.detach();
+ this._scene.remove(object);
+ delete this._userObjects[key];
+ controls.dispose();
+ delete this._transformControls[key];
+ this.render();
+ delete EmulatorSettings.instance.userObjects[key];
+ EmulatorSettings.instance.write().then(updateUserObjects);
+ }
+ }
+ });
+ }
+
+ _toggleObjectVisibility(objectId, active = undefined) {
+ const object = this._userObjects[objectId];
+ if (object) {
+ const isActive =
+ active ?? !EmulatorSettings.instance.userObjects[objectId].active;
+ EmulatorSettings.instance.userObjects[objectId].active = isActive;
+ const { label, semanticLabel } = object.userData;
+ if (isActive) {
+ object.material.opacity = 1;
+ label.innerHTML = semanticLabel;
+ } else {
+ object.material.opacity = 0.4;
+ label.innerHTML = '[hidden] ' + semanticLabel;
+ }
+ }
+ }
+
+ toggleSelectedObjectVisibility() {
+ Object.entries(this._transformControls).forEach(([objectId, controls]) => {
+ if (controls.enabled) {
+ this._toggleObjectVisibility(objectId);
+ this.render();
+ EmulatorSettings.instance.write().then(updateUserObjects);
+ }
+ });
+ }
+
+ _updateObjects() {
+ Object.entries(this._transformControls).forEach(
+ ([userObjectId, controls]) => {
+ if (controls.enabled) {
+ const object = this._userObjects[userObjectId];
+ if (object) {
+ EmulatorSettings.instance.userObjects[userObjectId].position =
+ object.position.toArray();
+ EmulatorSettings.instance.userObjects[userObjectId].quaternion =
+ object.quaternion.toArray();
+ }
+ }
+ },
+ );
+ EmulatorSettings.instance.write().then(updateUserObjects);
+ }
+
+ _recoverObjects() {
+ Object.entries(EmulatorSettings.instance.userObjects).forEach(
+ ([userObjectId, objectData]) => {
+ const {
+ type,
+ active,
+ width,
+ height,
+ depth,
+ isVertical,
+ semanticLabel,
+ position,
+ quaternion,
+ } = objectData;
+ let object;
+ if (type === 'mesh') {
+ object = this.addMesh(
+ width,
+ height,
+ depth,
+ semanticLabel,
+ userObjectId,
+ active,
+ );
+ } else if (type === 'plane') {
+ object = this.addPlane(
+ width,
+ height,
+ isVertical,
+ semanticLabel,
+ userObjectId,
+ active,
+ );
+ }
+ if (object) {
+ object.position.fromArray(position);
+ object.quaternion.fromArray(quaternion);
+ }
+ },
+ );
+ }
+
+ get canvas() {
+ return this._renderer.domElement;
+ }
+
+ get labels() {
+ return this._labelContainer;
+ }
+
+ getDeviceNode(deviceKey) {
+ return emulatorStates.assetNodes[deviceKey];
+ }
+
+ forceEmitPose() {
+ Object.values(DEVICE).forEach((deviceKey) => {
+ this._emitPoseEvent(deviceKey);
+ });
+ }
+
+ toggleControlMode(deviceKey, clearOthers = true) {
+ if (clearOthers) {
+ Object.entries(this._transformControls).forEach(([key, controls]) => {
+ if (key != deviceKey) {
+ controls.enabled = false;
+ controls.visible = false;
+ }
+ });
+ }
+ const controls = this._transformControls[deviceKey];
+ if (!controls.enabled) {
+ controls.enabled = true;
+ controls.visible = true;
+ controls.setMode('translate');
+ } else if (controls.getMode() === 'translate') {
+ controls.setMode('rotate');
+ } else {
+ controls.enabled = false;
+ controls.visible = false;
+ }
+ this.render();
+ }
+
+ toggleControllerVisibility(deviceKey, visible) {
+ this._controllerMeshesHidden[deviceKey] = !visible;
+ this.render();
+ }
+
+ toggleHandVisibility(deviceKey, visible) {
+ this._handMeshesHidden[deviceKey] = !visible;
+ this.render();
+ }
+
+ setDeviceTransform(deviceKey, position, rotation) {
+ const deviceNode = this.getDeviceNode(deviceKey);
+ if (deviceNode) {
+ deviceNode.position.fromArray(position);
+ deviceNode.rotation.fromArray(rotation);
+ this._emitPoseEvent(deviceKey);
+ this.render();
+ }
+ }
+
+ resetPose() {
+ Object.values(DEVICE).forEach((deviceKey) => {
+ const deviceNode = this.getDeviceNode(deviceKey);
+ deviceNode.position.fromArray(
+ EmulatorSettings.instance.defaultPose[deviceKey].position,
+ );
+ deviceNode.rotation.fromArray(
+ EmulatorSettings.instance.defaultPose[deviceKey].rotation,
+ );
+ this._emitPoseEvent(deviceKey);
+ });
+ this.render();
+ }
+
+ render() {
+ Object.entries(this._handMeshes).forEach(([deviceKey, mesh]) => {
+ mesh.visible =
+ EmulatorSettings.instance.inputMode === 'hands' &&
+ !this._handMeshesHidden[deviceKey];
+ });
+ Object.entries(this._controllerMeshes).forEach(([deviceKey, mesh]) => {
+ mesh.visible =
+ EmulatorSettings.instance.inputMode === 'controllers' &&
+ !this._controllerMeshesHidden[deviceKey];
+ });
+ const parent = this.canvas.parentElement;
+ if (!parent) return;
+ const width = parent.offsetWidth;
+ const height = parent.offsetHeight;
+ if (width != this._lastWidth || height != this._lastHeight) {
+ this._camera.aspect = width / height;
+ this._camera.updateProjectionMatrix();
+ this._renderer.setSize(width, height);
+ this._lastWidth = width;
+ this._lastHeight = height;
+ }
+ this._renderer.render(this._scene, this._camera);
+
+ const sceneContainer = this._renderer.domElement.parentElement;
+ if (!sceneContainer) return;
+
+ Object.values(this._userObjects).forEach((object) => {
+ const { label } = object.userData;
+ if (label) {
+ const screenVec = object.position.clone().project(this._camera);
+ screenVec.x = ((screenVec.x + 1) * sceneContainer.offsetWidth) / 2;
+ screenVec.y = (-(screenVec.y - 1) * sceneContainer.offsetHeight) / 2;
+ label.style.top = `${screenVec.y}px`;
+ label.style.left = `${screenVec.x}px`;
+ }
+ });
+ }
+}
diff --git a/devtool/js/emulatorStates.js b/devtool/js/emulatorStates.js
new file mode 100644
index 0000000..837e81c
--- /dev/null
+++ b/devtool/js/emulatorStates.js
@@ -0,0 +1,172 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { DEFAULT_TRANSFORMS } from './constants';
+
+// eslint-disable-next-line no-undef
+// const localStorage = chrome.storage.local;
+const originalLocalStorage = window.localStorage;
+const localStorage = {
+ get: (key, callback) => {
+ callback(originalLocalStorage.getItem(key))
+ },
+ set: (data, callback) => {
+ Object.entries(data).forEach(([key, value]) => {
+ originalLocalStorage.setItem(key, value)
+ })
+ callback()
+ }
+}
+
+
+const STORAGE_KEY = 'immersive-web-emulator-settings';
+localStorage.set({ [STORAGE_KEY]: null }, () => {});
+
+export const emulatorStates = {
+ inImmersive: false,
+ actionMappingOn: true,
+ assetNodes: {},
+ controllers: {
+ 'left-controller': {
+ joystick: {
+ touched: false,
+ pressed: false,
+ valueX: 0,
+ valueY: 0,
+ },
+ trigger: {
+ touched: false,
+ value: 0,
+ },
+ grip: {
+ touched: false,
+ value: 0,
+ },
+ button1: {
+ touched: false,
+ pressed: false,
+ },
+ button2: {
+ touched: false,
+ pressed: false,
+ },
+ },
+ 'right-controller': {
+ joystick: {
+ touched: false,
+ pressed: false,
+ valueX: 0,
+ valueY: 0,
+ },
+ trigger: {
+ touched: false,
+ value: 0,
+ },
+ grip: {
+ touched: false,
+ value: 0,
+ },
+ button1: {
+ touched: false,
+ pressed: false,
+ },
+ button2: {
+ touched: false,
+ pressed: false,
+ },
+ },
+ },
+ playbackInProgress: false,
+ pinchValues: {
+ 'left-hand': 0,
+ 'right-hand': 0,
+ },
+ joysticks: {},
+ buttons: {},
+ sliders: {},
+ emulatedDevice: null,
+};
+
+export class EmulatorSettings {
+ static get instance() {
+ if (!EmulatorSettings._instance) {
+ EmulatorSettings._instance = new EmulatorSettings();
+ }
+ return EmulatorSettings._instance;
+ }
+
+ constructor() {
+ this.stereoOn = false;
+ this.actionMappingOn = true;
+ this.defaultPose = DEFAULT_TRANSFORMS;
+ this.deviceKey = 'Meta Quest 3';
+ this.keyboardMappingOn = true;
+ this.roomDimension = { x: 6, y: 3, z: 6 };
+ this.polyfillExcludes = new Set();
+ this.inputMode = 'controllers';
+ this.handPoses = {
+ 'left-hand': 'relaxed',
+ 'right-hand': 'relaxed',
+ };
+ this.userObjects = {};
+ this.triggerMode = 'normal';
+ }
+
+ load() {
+ return new Promise((resolve) => {
+ localStorage.get(STORAGE_KEY, (result) => {
+ const settings = result[STORAGE_KEY]
+ ? JSON.parse(result[STORAGE_KEY])
+ : null;
+ this.stereoOn = settings?.stereoOn ?? false;
+ this.actionMappingOn = settings?.actionMappingOn ?? true;
+ this.defaultPose = settings?.defaultPose ?? DEFAULT_TRANSFORMS;
+ this.deviceKey = settings?.deviceKey ?? 'Meta Quest 3';
+ this.keyboardMappingOn = settings?.keyboardMappingOn ?? true;
+ this.roomDimension = settings?.roomDimension ?? { x: 6, y: 3, z: 6 };
+ this.polyfillExcludes = new Set(settings?.polyfillExcludes ?? []);
+ this.inputMode = settings?.inputMode ?? 'controllers';
+ this.handPoses = settings?.handPoses ?? this.handPoses;
+ this.userObjects = settings?.userObjects ?? {};
+ this.triggerMode = settings?.triggerMode ?? 'normal';
+ resolve(result);
+ });
+ });
+ }
+
+ write() {
+ const settings = {};
+ settings[STORAGE_KEY] = JSON.stringify({
+ stereoOn: this.stereoOn,
+ actionMappingOn: this.actionMappingOn,
+ defaultPose: this.defaultPose,
+ deviceKey: this.deviceKey,
+ keyboardMappingOn: this.keyboardMappingOn,
+ roomDimension: this.roomDimension,
+ polyfillExcludes: Array.from(this.polyfillExcludes),
+ inputMode: this.inputMode,
+ handPoses: this.handPoses,
+ userObjects: this.userObjects,
+ triggerMode: this.triggerMode,
+ });
+ return new Promise((resolve) => {
+ localStorage.set(settings, () => {
+ resolve(settings);
+ });
+ });
+ }
+
+ clear() {
+ const settings = {};
+ settings[STORAGE_KEY] = null;
+ return new Promise((resolve) => {
+ localStorage.set(settings, () => {
+ resolve(settings);
+ });
+ });
+ }
+}
diff --git a/devtool/js/joystick.js b/devtool/js/joystick.js
new file mode 100644
index 0000000..0c1dac8
--- /dev/null
+++ b/devtool/js/joystick.js
@@ -0,0 +1,172 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { EventEmitter } from 'events';
+
+const CIRCUMFERENCE = 2 * Math.PI;
+const LINEWIDTH = 4;
+const OUTER_STROKE_COLOR = '#e4e6eb';
+const INNER_FILL_COLOR = '#317BEF';
+
+export class Joystick extends EventEmitter {
+ constructor(size, autoReturn, renderScale = 2) {
+ super();
+ this._renderScale = renderScale;
+ this._autoReturn = autoReturn;
+
+ const canvas = document.createElement('canvas');
+ canvas.id = 'Joystick';
+ canvas.width = size * renderScale;
+ canvas.height = size * renderScale;
+ this._radius = (size / 2) * renderScale;
+ canvas.style.width = 75;
+ canvas.style.height = 75;
+ this._canvas = canvas;
+
+ this._pressed = false;
+ this._innerRadius = this._radius / 2;
+ this._outerRadius = this._innerRadius * 1.5;
+ this._maxStickDelta = this._outerRadius - this._innerRadius / 2;
+
+ this._centerX = this._radius;
+ this._centerY = this._radius;
+ this._refX = 0;
+ this._refY = 0;
+ this._deltaX = 0;
+ this._deltaY = 0;
+
+ canvas.addEventListener('mousedown', this._onMouseDown.bind(this), false);
+ document.addEventListener('mousemove', this._onMouseMove.bind(this), false);
+ document.addEventListener('mouseup', this._onMouseUp.bind(this), false);
+
+ this._drawOuterCircle();
+ this._drawInnerCircle();
+ }
+
+ _drawOuterCircle() {
+ const context = this._canvas.getContext('2d');
+ context.imageSmoothingQuality = 'high';
+ context.beginPath();
+ context.arc(
+ this._centerX,
+ this._centerY,
+ this._outerRadius,
+ 0,
+ CIRCUMFERENCE,
+ false,
+ );
+
+ context.lineWidth = LINEWIDTH * this._renderScale;
+ context.strokeStyle = OUTER_STROKE_COLOR;
+ context.stroke();
+ }
+
+ _drawInnerCircle() {
+ const context = this._canvas.getContext('2d');
+ context.beginPath();
+ const deltaDistance = Math.sqrt(
+ this._deltaX * this._deltaX + this._deltaY * this._deltaY,
+ );
+ const scaleFactor = deltaDistance / this._maxStickDelta;
+ if (scaleFactor > 1) {
+ this._deltaX /= scaleFactor;
+ this._deltaY /= scaleFactor;
+ }
+ context.arc(
+ this._deltaX + this._centerX,
+ this._deltaY + this._centerY,
+ this._innerRadius,
+ 0,
+ CIRCUMFERENCE,
+ false,
+ );
+
+ context.fillStyle = INNER_FILL_COLOR;
+ context.fill();
+ context.lineWidth = 0;
+ }
+
+ _onMouseDown(event) {
+ this._refX = event.pageX;
+ this._refY = event.pageY;
+ this._pressed = true;
+ this._dispatchEvent();
+ }
+
+ _onMouseUp(_event) {
+ const context = this._canvas.getContext('2d');
+ this._pressed = false;
+
+ if (this._autoReturn) {
+ this._deltaX = 0;
+ this._deltaY = 0;
+ }
+
+ context.clearRect(0, 0, this._canvas.width, this._canvas.height);
+
+ this._drawOuterCircle();
+ this._drawInnerCircle();
+ this._dispatchEvent();
+ }
+
+ _onMouseMove(event) {
+ if (this._pressed) {
+ const context = this._canvas.getContext('2d');
+ this._deltaX = (event.pageX - this._refX) * this._renderScale;
+ this._deltaY = (event.pageY - this._refY) * this._renderScale;
+
+ context.clearRect(0, 0, this._canvas.width, this._canvas.height);
+
+ this._drawOuterCircle();
+ this._drawInnerCircle();
+ this._dispatchEvent();
+ }
+ }
+
+ _dispatchEvent() {
+ this.emit('joystickmove');
+ }
+
+ overrideMove(x, y) {
+ const context = this._canvas.getContext('2d');
+ this._deltaX = x * this._maxStickDelta;
+ this._deltaY = y * this._maxStickDelta;
+
+ context.clearRect(0, 0, this._canvas.width, this._canvas.height);
+
+ this._drawOuterCircle();
+ this._drawInnerCircle();
+ this._dispatchEvent();
+ }
+
+ addToParent(parent) {
+ parent.appendChild(this._canvas);
+ }
+
+ getX() {
+ return (100 * (this._deltaX / this._maxStickDelta)).toFixed() / 100;
+ }
+
+ getY() {
+ return (100 * (this._deltaY / this._maxStickDelta)).toFixed() / 100;
+ }
+
+ reset() {
+ this._deltaX = 0;
+ this._deltaY = 0;
+ this._onMouseUp();
+ }
+
+ get sticky() {
+ return !this._autoReturn;
+ }
+
+ setSticky(sticky) {
+ this._autoReturn = !sticky;
+ this._onMouseUp();
+ }
+}
diff --git a/devtool/js/keyboard.js b/devtool/js/keyboard.js
new file mode 100644
index 0000000..6ac9fe8
--- /dev/null
+++ b/devtool/js/keyboard.js
@@ -0,0 +1,185 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { DEVICE, KEYBOARD_CONTROL_MAPPING, OBJECT_NAME } from './constants';
+import { EmulatorSettings, emulatorStates } from './emulatorStates';
+
+import { relayKeyboardEvent } from './messenger';
+
+const emulatedJoysticks = {};
+const JOYSTICKS = emulatorStates.joysticks;
+
+const resetEmulatedJoysticks = () => {
+ emulatedJoysticks.left = {
+ left: false,
+ right: false,
+ forward: false,
+ backward: false,
+ };
+ emulatedJoysticks.right = {
+ left: false,
+ right: false,
+ forward: false,
+ backward: false,
+ };
+};
+
+const getReservedKeyAction = (key) => {
+ let result = null;
+ Object.entries(KEYBOARD_CONTROL_MAPPING).forEach(([handKey, mapping]) => {
+ Object.entries(mapping).forEach(([action, mappedKey]) => {
+ if (mappedKey == key) {
+ result = [handKey, action];
+ }
+ });
+ });
+ return result;
+};
+
+const onReservedKeyDown = (handKey, action) => {
+ switch (action) {
+ case 'joystickLeft':
+ emulatedJoysticks[handKey].left = true;
+ break;
+ case 'joystickRight':
+ emulatedJoysticks[handKey].right = true;
+ break;
+ case 'joystickForward':
+ emulatedJoysticks[handKey].forward = true;
+ break;
+ case 'joystickBackward':
+ emulatedJoysticks[handKey].backward = true;
+ break;
+ case 'trigger':
+ case 'grip':
+ emulatorStates.sliders[handKey][action].value = 100;
+ emulatorStates.buttons[handKey][action].disabled = true;
+ emulatorStates.sliders[handKey][action].onInputFunc();
+ break;
+ default:
+ emulatorStates.buttons[handKey][action].click();
+ }
+};
+
+const onReservedKeyUp = (handKey, action) => {
+ switch (action) {
+ case 'joystickLeft':
+ emulatedJoysticks[handKey].left = false;
+ break;
+ case 'joystickRight':
+ emulatedJoysticks[handKey].right = false;
+ break;
+ case 'joystickForward':
+ emulatedJoysticks[handKey].forward = false;
+ break;
+ case 'joystickBackward':
+ emulatedJoysticks[handKey].backward = false;
+ break;
+ case 'trigger':
+ case 'grip':
+ emulatorStates.sliders[handKey][action].value = 0;
+ emulatorStates.buttons[handKey][action].disabled = false;
+ emulatorStates.sliders[handKey][action].onInputFunc();
+ break;
+ default:
+ emulatorStates.buttons[handKey][action].click();
+ }
+};
+
+/**
+ *
+ * @param {KeyboardEvent} event
+ */
+const passThroughKeyboardEvent = (event) => {
+ const options = {
+ key: event.key,
+ code: event.code,
+ location: event.location,
+ repeat: event.repeat,
+ isComposing: event.isComposing,
+ ctrlKey: event.ctrlKey,
+ shiftKey: event.shiftKey,
+ altKey: event.altKey,
+ metaKey: event.metaKey,
+ };
+
+ relayKeyboardEvent(event.type, options);
+};
+
+const moveJoysticks = () => {
+ Object.entries(emulatedJoysticks).forEach(([handKey, directions]) => {
+ const deviceId = handKey == 'left' ? DEVICE.INPUT_LEFT : DEVICE.INPUT_RIGHT;
+ const deviceName = OBJECT_NAME[deviceId];
+ if (
+ directions.left ||
+ directions.right ||
+ directions.forward ||
+ directions.backward
+ ) {
+ const axisX = directions.left ? -1 : 0 + directions.right ? 1 : 0;
+ const axisY = directions.forward ? -1 : 0 + directions.backward ? 1 : 0;
+ const normalizeScale = Math.sqrt(axisX * axisX + axisY * axisY);
+
+ if (JOYSTICKS[deviceName]) {
+ JOYSTICKS[deviceName].overrideMove(
+ axisX / normalizeScale,
+ axisY / normalizeScale,
+ );
+ }
+ } else {
+ if (JOYSTICKS[deviceName]) {
+ JOYSTICKS[deviceName].overrideMove(0, 0);
+ }
+ }
+ });
+};
+
+export default function initKeyboardControl() {
+ resetEmulatedJoysticks();
+ window.addEventListener('blur', resetEmulatedJoysticks);
+
+ document.addEventListener(
+ 'keydown',
+ (event) => {
+ const result = getReservedKeyAction(event.key);
+ if (EmulatorSettings.instance.actionMappingOn && result) {
+ const [handKey, action] = result;
+ onReservedKeyDown(handKey, action);
+ moveJoysticks();
+ } else {
+ passThroughKeyboardEvent(event);
+ }
+ },
+ false,
+ );
+
+ document.addEventListener(
+ 'keyup',
+ (event) => {
+ const result = getReservedKeyAction(event.key);
+ if (result) {
+ const [handKey, action] = result;
+ onReservedKeyUp(handKey, action);
+ moveJoysticks();
+ } else if (EmulatorSettings.instance.actionMappingOn) {
+ passThroughKeyboardEvent(event);
+ }
+ },
+ false,
+ );
+
+ document.addEventListener(
+ 'keypress',
+ (event) => {
+ const result = getReservedKeyAction(event.key);
+ if (!result && EmulatorSettings.instance.actionMappingOn) {
+ passThroughKeyboardEvent(event);
+ }
+ },
+ false,
+ );
+}
diff --git a/devtool/js/messenger.js b/devtool/js/messenger.js
new file mode 100644
index 0000000..5864e72
--- /dev/null
+++ b/devtool/js/messenger.js
@@ -0,0 +1,178 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { WebXREventDispatcher } from '@ir-engine/spatial/tests/webxr/emulator/WebXREventDispatcher';
+import { CLIENT_ACTIONS, EMULATOR_ACTIONS } from './actions';
+import { DEVICE, HAND_STRINGS, OBJECT_NAME } from './constants';
+import { EmulatorSettings, emulatorStates } from './emulatorStates';
+
+const tabId = 0// chrome.devtools.inspectedWindow.tabId;
+
+const connection = {
+ port: null,
+ connect: () => {
+ connection.port = chrome.runtime.connect(null, { name: 'iwe_devtool' });
+ connection.port.onMessage.addListener((payload) => {
+ switch (payload.action) {
+ case CLIENT_ACTIONS.ENTER_IMMERSIVE:
+ emulatorStates.inImmersive = true;
+ emulatorStates.emulatedDevice?.forceEmitPose();
+ break;
+ case CLIENT_ACTIONS.EXIT_IMMERSIVE:
+ emulatorStates.inImmersive = false;
+ break;
+ }
+ });
+ connection.port.onDisconnect.addListener(connection.connect);
+ },
+};
+
+const executeAction = (action, detail = {}) => {
+ const payload = { detail };
+ payload.tabId = tabId;
+ payload.action = action;
+ payload.type = action
+ WebXREventDispatcher.instance.dispatchEvent(payload);
+ // try {
+ // connection.port.postMessage(payload);
+ // } catch (_e) {
+ // connection.connect();
+ // connection.port.postMessage(payload);
+ // }
+};
+
+export const syncDevicePose = (event) => {
+ const { deviceKey, position, quaternion } = event;
+ if (deviceKey === DEVICE.HEADSET) {
+ executeAction(EMULATOR_ACTIONS.HEADSET_POSE_CHANGE, {
+ position,
+ quaternion,
+ });
+ } else {
+ executeAction(EMULATOR_ACTIONS.CONTROLLER_POSE_CHANGE, {
+ objectName: OBJECT_NAME[deviceKey],
+ position,
+ quaternion,
+ });
+ }
+};
+
+export const applyControllerButtonPressed = (key, buttonIndex, pressed) => {
+ executeAction(EMULATOR_ACTIONS.BUTTON_STATE_CHANGE, {
+ objectName: OBJECT_NAME[key],
+ buttonIndex,
+ pressed,
+ });
+};
+
+export const applyControllerButtonChanged = (
+ key,
+ buttonIndex,
+ pressed,
+ touched,
+ value,
+) => {
+ executeAction(EMULATOR_ACTIONS.BUTTON_STATE_CHANGE, {
+ objectName: OBJECT_NAME[key],
+ buttonIndex,
+ pressed,
+ touched,
+ value,
+ });
+};
+
+export const applyControllerAnalogValue = (key, axisIndex, value) => {
+ executeAction(EMULATOR_ACTIONS.ANALOG_VALUE_CHANGE, {
+ objectName: OBJECT_NAME[key],
+ axisIndex,
+ value,
+ });
+};
+
+export const changeEmulatedDeviceType = (deviceDefinition) => {
+ executeAction(EMULATOR_ACTIONS.DEVICE_TYPE_CHANGE, { deviceDefinition });
+};
+
+export const toggleStereoMode = (enabled) => {
+ executeAction(EMULATOR_ACTIONS.STEREO_TOGGLE, { enabled });
+};
+
+export const relayKeyboardEvent = (eventType, eventOptions) => {
+ executeAction(EMULATOR_ACTIONS.KEYBOARD_EVENT, {
+ eventType,
+ eventOptions,
+ });
+};
+
+export const notifyExitImmersive = () => {
+ executeAction(EMULATOR_ACTIONS.EXIT_IMMERSIVE);
+};
+
+export const changeRoomDimension = () => {
+ executeAction(EMULATOR_ACTIONS.ROOM_DIMENSION_CHANGE, {
+ dimension: EmulatorSettings.instance.roomDimension,
+ });
+};
+
+export const changeInputMode = () => {
+ executeAction(EMULATOR_ACTIONS.INPUT_MODE_CHANGE, {
+ inputMode: EmulatorSettings.instance.inputMode,
+ });
+};
+
+export const changeHandPose = (deviceId) => {
+ const handName = HAND_STRINGS[deviceId].name;
+ executeAction(EMULATOR_ACTIONS.HAND_POSE_CHANGE, {
+ handedness: deviceId === DEVICE.INPUT_LEFT ? 'left' : 'right',
+ pose: EmulatorSettings.instance.handPoses[handName],
+ });
+};
+
+export const updatePinchValue = (deviceId) => {
+ const handName = HAND_STRINGS[deviceId].name;
+ executeAction(EMULATOR_ACTIONS.PINCH_VALUE_CHANGE, {
+ handedness: deviceId === DEVICE.INPUT_LEFT ? 'left' : 'right',
+ value: emulatorStates.pinchValues[handName],
+ });
+};
+
+export const togglePolyfill = () => {
+ chrome.tabs.get(tabId, (tab) => {
+ const url = new URL(tab.url);
+ const urlMatchPattern = url.origin + '/*';
+ if (EmulatorSettings.instance.polyfillExcludes.has(urlMatchPattern)) {
+ EmulatorSettings.instance.polyfillExcludes.delete(urlMatchPattern);
+ } else {
+ EmulatorSettings.instance.polyfillExcludes.add(urlMatchPattern);
+ }
+ EmulatorSettings.instance.write().then(() => {
+ executeAction(EMULATOR_ACTIONS.EXCLUDE_POLYFILL);
+ });
+ });
+};
+
+export const toggleControllerVisibility = (deviceKey, visible) => {
+ executeAction(EMULATOR_ACTIONS.CONTROLLER_VISIBILITY_CHANGE, {
+ objectName: OBJECT_NAME[deviceKey],
+ visible,
+ });
+};
+
+export const toggleHandVisibility = (deviceId, visible) => {
+ executeAction(EMULATOR_ACTIONS.HAND_VISIBILITY_CHANGE, {
+ handedness: HAND_STRINGS[deviceId].handedness,
+ visible,
+ });
+};
+
+export const reloadInspectedTab = () => {
+ executeAction(EMULATOR_ACTIONS.EXCLUDE_POLYFILL);
+};
+
+export const updateUserObjects = () => {
+ executeAction(EMULATOR_ACTIONS.USER_OBJECTS_CHANGE);
+};
diff --git a/devtool/jsx/app.jsx b/devtool/jsx/app.jsx
new file mode 100644
index 0000000..454e3fc
--- /dev/null
+++ b/devtool/jsx/app.jsx
@@ -0,0 +1,123 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import ControllerPanel from './controllers.jsx';
+import { DEVICE } from '../js/constants';
+import { EmulatorSettings } from '../js/emulatorStates.js';
+import HandPanel from './hands.jsx';
+import HeadsetBar from './headset.jsx';
+import Inspector from './inspector.jsx';
+import PoseBar from './pose.jsx';
+import React from 'react';
+
+const MIN_PANEL_WIDTH = 327;
+const MIN_PANEL_HEIGHT = 256;
+const MIN_INPUT_PANEL_WIDTH = 420;
+const MIN_INPUT_PANEL_HEIGHTS = {
+ controllers: 452,
+ hands: 327,
+};
+
+export default function App({ device }) {
+ const [inputMode, setInputMode] = React.useState(
+ EmulatorSettings.instance.inputMode,
+ );
+ const [showInspector, setShowInspector] = React.useState(true);
+ const [showControls, setShowControls] = React.useState(true);
+ const sizeWarningRef = React.useRef();
+ React.useEffect(onResize, [inputMode]);
+
+ React.useEffect(() => {
+ window.addEventListener('resize', function () {
+ onResize();
+ });
+ });
+
+ function onResize() {
+ const body = document.getElementById('devtools')
+ if (body.offsetHeight < MIN_PANEL_HEIGHT) {
+ if (showInspector) setShowInspector(false);
+ if (showControls) setShowControls(false);
+ sizeWarningRef.current.innerHTML = 'Not Enough Vertical Space';
+ } else if (body.offsetWidth < MIN_PANEL_WIDTH) {
+ if (showInspector) setShowInspector(false);
+ if (showControls) setShowControls(false);
+ sizeWarningRef.current.innerHTML = 'Not Enough Horizontal Space';
+ } else {
+ if (!showInspector) setShowInspector(true);
+ const inputMode = EmulatorSettings.instance.inputMode;
+ if (body.offsetWidth < MIN_INPUT_PANEL_WIDTH) {
+ if (showControls) setShowControls(false);
+ sizeWarningRef.current.innerHTML = 'Not Enough Horizontal Space';
+ } else if (
+ body.offsetHeight < MIN_INPUT_PANEL_HEIGHTS[inputMode]
+ ) {
+ if (showControls) setShowControls(false);
+ sizeWarningRef.current.innerHTML = 'Not Enough Vertical Space';
+ } else {
+ if (!showControls) setShowControls(true);
+ }
+ }
+ device.render();
+ }
+
+ return (
+ <>
+
+
+
+ {[DEVICE.INPUT_LEFT, DEVICE.INPUT_RIGHT].map((deviceKey) => (
+
+ ))}
+
+
+ {[DEVICE.INPUT_LEFT, DEVICE.INPUT_RIGHT].map((deviceKey) => (
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/devtool/jsx/controllers.jsx b/devtool/jsx/controllers.jsx
new file mode 100644
index 0000000..3e1a60d
--- /dev/null
+++ b/devtool/jsx/controllers.jsx
@@ -0,0 +1,339 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {
+ BUTTON_POLYFILL_INDEX_MAPPING,
+ CONTROLLER_STRINGS,
+ PRESS_AND_RELEASE_DURATION,
+ TRIGGER_CONFIG,
+} from '../js/constants';
+import { EmulatorSettings, emulatorStates } from '../js/emulatorStates';
+import {
+ applyControllerAnalogValue,
+ applyControllerButtonChanged,
+ applyControllerButtonPressed,
+ toggleControllerVisibility,
+} from '../js/messenger';
+import config from '@ir-engine/common/src/config';
+
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+import { Joystick } from '../js/joystick';
+import React from 'react';
+
+function ControlButtonGroup({ isAnalog, deviceKey, buttonKey }) {
+ const touchRef = React.useRef();
+ const pressRef = React.useRef();
+ const holdRef = React.useRef();
+ const rangeRef = React.useRef();
+ const deviceName = CONTROLLER_STRINGS[deviceKey].name;
+ const buttonState = emulatorStates.controllers[deviceName][buttonKey];
+
+ function onTouchToggle() {
+ buttonState.touched = !buttonState.touched;
+ buttonState.touched ||= buttonState.pressed;
+ touchRef.current.classList.toggle('button-pressed', buttonState.touched);
+ applyControllerButtonChanged(
+ deviceKey,
+ BUTTON_POLYFILL_INDEX_MAPPING[buttonKey],
+ buttonState.pressed,
+ buttonState.touched,
+ buttonState.value,
+ );
+ }
+
+ function onHoldToggle() {
+ buttonState.pressed = !buttonState.pressed;
+ buttonState.touched ||= buttonState.pressed;
+ pressRef.current.disabled = buttonState.pressed;
+ holdRef.current.classList.toggle('button-pressed', buttonState.pressed);
+ applyControllerButtonPressed(
+ deviceKey,
+ BUTTON_POLYFILL_INDEX_MAPPING[buttonKey],
+ buttonState.pressed,
+ );
+ }
+
+ function onPressBinary() {
+ if (buttonState.pressed) return;
+ onHoldToggle();
+ pressRef.current.disabled = true;
+ holdRef.current.disabled = true;
+ setTimeout(() => {
+ onHoldToggle();
+ pressRef.current.disabled = false;
+ holdRef.current.disabled = false;
+ }, PRESS_AND_RELEASE_DURATION);
+ }
+
+ function onRangeInput() {
+ const inputValue = rangeRef.current.value / 100;
+ applyControllerButtonChanged(
+ deviceKey,
+ BUTTON_POLYFILL_INDEX_MAPPING[buttonKey],
+ inputValue != 0,
+ buttonState.touched,
+ inputValue,
+ );
+ }
+
+ const onPressAnalog = createAnalogPressFunction(
+ pressRef,
+ rangeRef,
+ onRangeInput,
+ );
+
+ React.useEffect(() => {
+ const handedness = CONTROLLER_STRINGS[deviceKey].handedness;
+ if (isAnalog) {
+ rangeRef.current.value = 0;
+ onRangeInput();
+ if (!emulatorStates.sliders[handedness]) {
+ emulatorStates.sliders[handedness] = {};
+ }
+ rangeRef.current.onInputFunc = onRangeInput;
+ emulatorStates.sliders[handedness][buttonKey] = rangeRef.current;
+ }
+ if (!emulatorStates.buttons[handedness]) {
+ emulatorStates.buttons[handedness] = {};
+ }
+ emulatorStates.buttons[handedness][buttonKey] = pressRef.current;
+ });
+
+ return (
+
+
+
+ {isAnalog ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default function ControllerPanel({ deviceKey, device }) {
+ const strings = CONTROLLER_STRINGS[deviceKey];
+ const joystickContainerRef = React.useRef();
+ const joystickResetRef = React.useRef();
+ const joystickStickyRef = React.useRef();
+
+ const [showController, setShowController] = React.useState(true);
+
+ const joystick = new Joystick(100, true, 1);
+ emulatorStates.joysticks[strings.name] = joystick;
+ joystick.on('joystickmove', () => {
+ // update joystick
+ applyControllerAnalogValue(deviceKey, 0, joystick.getX());
+ applyControllerAnalogValue(deviceKey, 1, joystick.getY());
+
+ joystickResetRef.current.disabled = !(
+ joystick.sticky &&
+ joystick.getX() != 0 &&
+ joystick.getY() != 0
+ );
+ });
+
+ function onStickyToggle() {
+ joystick.setSticky(!joystick.sticky);
+ joystickStickyRef.current.classList.toggle(
+ 'button-pressed',
+ joystick.sticky,
+ );
+ }
+
+ function toggleDeviceVisibility(event) {
+ setShowController(!showController); // React state only applies to the next rendering frame
+ device.toggleControllerVisibility(deviceKey, !showController);
+ toggleControllerVisibility(deviceKey, !showController);
+ event.target.classList.toggle('button-pressed', showController);
+ }
+
+ React.useEffect(() => {
+ joystick.addToParent(joystickContainerRef.current);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
{strings.displayName}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {['trigger', 'grip'].map((controlName) => (
+
+
+
+
{controlName}
+
+
+
+
+
+ ))}
+ {['button2', 'button1'].map((controlName) => (
+
+
+
+
{strings[controlName]}
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export function createAnalogPressFunction(pressRef, rangeRef, onRangeInput) {
+ return function () {
+ const step = 10;
+ const { interval, holdTime } =
+ TRIGGER_CONFIG[EmulatorSettings.instance.triggerMode];
+ pressRef.current.disabled = true;
+ let rangeValue = 0;
+ const pressIntervalId = setInterval(() => {
+ if (rangeRef.current.value >= 100) {
+ rangeRef.current.value = 100;
+ clearInterval(pressIntervalId);
+ setTimeout(() => {
+ const depressIntervalId = setInterval(() => {
+ if (rangeRef.current.value <= 0) {
+ rangeRef.current.value = 0;
+ clearInterval(depressIntervalId);
+ pressRef.current.disabled = false;
+ } else {
+ rangeRef.current.value -= step;
+ }
+ onRangeInput();
+ }, interval);
+ }, holdTime);
+ } else {
+ rangeValue += step;
+ rangeRef.current.value = rangeValue;
+ }
+ onRangeInput();
+ }, interval);
+ };
+}
diff --git a/devtool/jsx/hands.jsx b/devtool/jsx/hands.jsx
new file mode 100644
index 0000000..af4e8dc
--- /dev/null
+++ b/devtool/jsx/hands.jsx
@@ -0,0 +1,138 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { EmulatorSettings, emulatorStates } from '../js/emulatorStates';
+import {
+ changeHandPose,
+ updatePinchValue,
+ toggleHandVisibility,
+} from '../js/messenger';
+
+import { HAND_STRINGS } from '../js/constants';
+import React from 'react';
+import { createAnalogPressFunction } from './controllers.jsx';
+
+import config from '@ir-engine/common/src/config';
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+
+export default function HandPanel({ deviceKey, device }) {
+ const strings = HAND_STRINGS[deviceKey];
+ const pressRef = React.createRef();
+ const rangeRef = React.createRef();
+ const poseSelectRef = React.createRef();
+
+ const [showHand, setShowHand] = React.useState(true);
+
+ function onHandPoseChange() {
+ EmulatorSettings.instance.handPoses[strings.name] =
+ poseSelectRef.current.value;
+ EmulatorSettings.instance.write();
+ changeHandPose(deviceKey);
+ }
+
+ function onRangeInput() {
+ emulatorStates.pinchValues[strings.name] = rangeRef.current.value / 100;
+ updatePinchValue(deviceKey);
+ }
+
+ function toggleDeviceVisibility(event) {
+ setShowHand(!showHand); // React state only applies to the next rendering frame
+ device.toggleHandVisibility(deviceKey, !showHand);
+ toggleHandVisibility(deviceKey, !showHand);
+ event.target.classList.toggle('button-pressed', showHand);
+ }
+
+ const onPressAnalog = createAnalogPressFunction(
+ pressRef,
+ rangeRef,
+ onRangeInput,
+ );
+
+ React.useEffect(onRangeInput, []);
+
+ return (
+
+
+
+
+
+
+
{strings.displayName}
+
+
+
+
+
+
+
+ Pose
+
+
+
+
+
+
+
+
+
+ Pinch
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/devtool/jsx/headset.jsx b/devtool/jsx/headset.jsx
new file mode 100644
index 0000000..9636d83
--- /dev/null
+++ b/devtool/jsx/headset.jsx
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { DEFAULT_TRANSFORMS, TRIGGER_MODES } from '../js/constants';
+import {
+ changeEmulatedDeviceType,
+ notifyExitImmersive,
+ reloadInspectedTab,
+ togglePolyfill,
+ toggleStereoMode,
+} from '../js/messenger';
+import config from '@ir-engine/common/src/config';
+
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+import { DEVICE_DEFINITIONS } from '../js/devices';
+import { EmulatorSettings } from '../js/emulatorStates';
+import React from 'react';
+
+export default function HeadsetBar({ device }) {
+ const headsetSelectRef = React.useRef();
+ const polyfillToggleRef = React.useRef();
+ const stereoToggleRef = React.useRef();
+ const [polyfillOn, setPolyfillOn] = React.useState(true);
+ const [showDropDown, setShowDropDown] = React.useState(false);
+ const [triggerMode, setTriggerMode] = React.useState(
+ EmulatorSettings.instance.triggerMode,
+ );
+
+ function onChangeDevice() {
+ const deviceId = headsetSelectRef.current.value;
+ if (DEVICE_DEFINITIONS[deviceId]) {
+ EmulatorSettings.instance.deviceKey = deviceId;
+ changeEmulatedDeviceType(DEVICE_DEFINITIONS[deviceId]);
+ EmulatorSettings.instance.write();
+ }
+ }
+
+ function onToggleStereo() {
+ EmulatorSettings.instance.stereoOn = !EmulatorSettings.instance.stereoOn;
+ toggleStereoMode(EmulatorSettings.instance.stereoOn);
+ stereoToggleRef.current.classList.toggle(
+ 'button-pressed',
+ EmulatorSettings.instance.stereoOn,
+ );
+ EmulatorSettings.instance.write();
+ }
+
+ const updatePolyfillState = (tab) => {
+ const url = new URL(tab.url);
+ const urlMatchPattern = url.origin + '/*';
+ setPolyfillOn(
+ !EmulatorSettings.instance.polyfillExcludes.has(urlMatchPattern),
+ );
+ };
+
+ React.useEffect(() => {
+ // check every time navigation happens on the tab
+ // chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ // if (
+ // tabId === chrome.devtools.inspectedWindow.tabId &&
+ // changeInfo.status === 'complete'
+ // ) {
+ // updatePolyfillState(tab);
+ // }
+ // });
+
+ // // check on start up
+ // chrome.tabs.get(chrome.devtools.inspectedWindow.tabId, (tab) => {
+ // updatePolyfillState(tab);
+ // });
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showDropDown && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/devtool/jsx/inspector.jsx b/devtool/jsx/inspector.jsx
new file mode 100644
index 0000000..3932266
--- /dev/null
+++ b/devtool/jsx/inspector.jsx
@@ -0,0 +1,381 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {
+ CONTROLLER_STRINGS,
+ DEVICE,
+ HAND_STRINGS,
+ OBJECT_NAME,
+ SEMANTIC_LABELS,
+} from '../js/constants';
+
+import { EmulatorSettings } from '../js/emulatorStates';
+import React from 'react';
+import { changeRoomDimension } from '../js/messenger';
+
+import config from '@ir-engine/common/src/config';
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+export default function Inspector({ device, inputMode }) {
+ const sceneContainerRef = React.useRef();
+ // plane setting refs
+ const planeWidthRef = React.useRef();
+ const planeHeightRef = React.useRef();
+ const [planeVertical, setPlaneVertical] = React.useState(true);
+ const planeSemanticLabelRef = React.useRef();
+ // mesh setting refs
+ const meshWidthRef = React.useRef();
+ const meshHeightRef = React.useRef();
+ const meshDepthRef = React.useRef();
+ const meshSemanticLabelRef = React.useRef();
+
+ const [showTransforms, setShowTransforms] = React.useState(true);
+ const [showRoomSettings, setShowRoomSettings] = React.useState(false);
+ const [showPlaneSettings, setShowPlaneSettings] = React.useState(false);
+ const [showMeshSettings, setShowMeshSettings] = React.useState(false);
+ const transformData = {};
+ Object.values(DEVICE).forEach((deviceKey) => {
+ const deviceName = OBJECT_NAME[deviceKey];
+ transformData[deviceName] = React.useState({
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ });
+ });
+
+ const [inputValues, setInputValues] = React.useState(
+ Object.values(DEVICE).reduce((acc, deviceKey) => {
+ const deviceName = OBJECT_NAME[deviceKey];
+ for (let i = 0; i < 3; i++) {
+ acc[`${deviceName}-position-${i}`] =
+ transformData[deviceName][0].position[i];
+ acc[`${deviceName}-rotation-${i}`] =
+ transformData[deviceName][0].rotation[i];
+ }
+ return acc;
+ }, {}),
+ );
+
+ function handleInputChange(key, event) {
+ const value = parseFloat(event.target.value);
+ if (!isNaN(value)) {
+ const clampedValue = roundAndClamp(value);
+ setInputValues((prevValues) => ({
+ ...prevValues,
+ [key]: clampedValue,
+ }));
+ // Split the key into its components
+ const [deviceName, type, index] = key.split('-');
+ const deviceKey = Object.keys(OBJECT_NAME).find(
+ (key) => OBJECT_NAME[key] === deviceName,
+ );
+ // Update the device transform
+ if (deviceKey) {
+ const position = [...transformData[deviceName][0].position];
+ const rotation = [...transformData[deviceName][0].rotation];
+ if (type === 'position') {
+ position[index] = clampedValue;
+ } else if (type === 'rotation') {
+ rotation[index] = clampedValue;
+ }
+ device.setDeviceTransform(deviceKey, position, rotation);
+ }
+ }
+ }
+
+ function roundAndClamp(number) {
+ const rounded = Math.round(number * 100) / 100;
+ return Math.min(Math.max(rounded, -99.99), 99.99);
+ }
+
+ React.useEffect(() => {
+ sceneContainerRef.current.appendChild(device.canvas);
+ sceneContainerRef.current.appendChild(device.labels);
+ device.on('pose', (event) => {
+ const { deviceKey, position, rotation } = event;
+ const deviceName = OBJECT_NAME[deviceKey];
+ const transform = transformData[deviceName];
+ const setTransform = transform[1];
+ setTransform({ position, rotation });
+ setInputValues((prevValues) => ({
+ ...prevValues,
+ [`${deviceName}-position-0`]: roundAndClamp(position[0]),
+ [`${deviceName}-position-1`]: roundAndClamp(position[1]),
+ [`${deviceName}-position-2`]: roundAndClamp(position[2]),
+ [`${deviceName}-rotation-0`]: roundAndClamp(rotation[0]),
+ [`${deviceName}-rotation-1`]: roundAndClamp(rotation[1]),
+ [`${deviceName}-rotation-2`]: roundAndClamp(rotation[2]),
+ }));
+ });
+ device.forceEmitPose();
+ }, []);
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/devtool/jsx/pose.jsx b/devtool/jsx/pose.jsx
new file mode 100644
index 0000000..87bd12d
--- /dev/null
+++ b/devtool/jsx/pose.jsx
@@ -0,0 +1,157 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { EmulatorSettings, emulatorStates } from '../js/emulatorStates';
+
+import { DEVICE } from '../js/constants';
+import React from 'react';
+import { changeInputMode } from '../js/messenger';
+import initKeyboardControl from '../js/keyboard';
+
+
+import config from '@ir-engine/common/src/config';
+const assetURL = config.client.fileServer + '/projects/ir-engine/ir-bot/devtool'
+
+export default function PoseBar({ device, setInputMode }) {
+ const saveDefaultPoseRef = React.useRef();
+ const resetPoseRef = React.useRef();
+ const actionMappingToggleRef = React.useRef();
+ const handModeToggleRef = React.useRef();
+ const controllerModeToggleRef = React.useRef();
+
+ function onSaveDefaultPose() {
+ const deviceTransform = {};
+ Object.values(DEVICE).forEach((device) => {
+ deviceTransform[device] = {};
+ deviceTransform[device].position =
+ emulatorStates.assetNodes[device].position.toArray();
+ deviceTransform[device].rotation =
+ emulatorStates.assetNodes[device].rotation.toArray();
+ });
+ EmulatorSettings.instance.defaultPose = deviceTransform;
+ EmulatorSettings.instance.write();
+ }
+
+ function onActionMappingToggle() {
+ EmulatorSettings.instance.actionMappingOn =
+ !EmulatorSettings.instance.actionMappingOn;
+ actionMappingToggleRef.current.classList.toggle(
+ 'button-pressed',
+ EmulatorSettings.instance.actionMappingOn,
+ );
+ EmulatorSettings.instance.write();
+ }
+
+ function onInputModeChange(inputMode) {
+ EmulatorSettings.instance.inputMode = inputMode;
+ EmulatorSettings.instance.write();
+ changeInputMode();
+ controllerModeToggleRef.current.classList.toggle(
+ 'button-pressed',
+ inputMode === 'controllers',
+ );
+ handModeToggleRef.current.classList.toggle(
+ 'button-pressed',
+ inputMode === 'hands',
+ );
+ setInputMode(inputMode);
+ }
+
+ React.useEffect(() => {
+ changeInputMode();
+ initKeyboardControl();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/devtool/styles/index.css b/devtool/styles/index.css
new file mode 100644
index 0000000..7536ec7
--- /dev/null
+++ b/devtool/styles/index.css
@@ -0,0 +1,600 @@
+/*
+ Copyright (c) Meta Platforms, Inc. and affiliates.
+
+ This source code is licensed under the MIT license found in the
+ LICENSE file in the root directory of this source tree.
+*/
+
+.row {
+ margin: 0px;
+}
+
+.row > * {
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.component {
+ display: flex;
+ flex-direction: column;
+}
+
+.card {
+ border: 0px;
+}
+
+.component-container {
+ width: 100%;
+ padding: 0;
+}
+
+.root-panel {
+ border-radius: 10px;
+ color: #a7a7a7;
+}
+
+.inspector-panel {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.controls-panel {
+ flex: 0 1 auto;
+}
+
+.inspector-panel .row {
+ flex: 1 1 auto;
+}
+
+.inspector-panel .row:first-child,
+.inspector-panel .row:last-child {
+ flex: 0 1 auto;
+}
+
+#scene-container {
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+}
+
+.headset-action-button {
+ height: 24px;
+ border-radius: 6px;
+ border-style: none;
+ padding: 4px 6px 4px 6px;
+ margin: 0px 0px 0px 10px;
+ font-size: small;
+ background-color: #3b3b3c;
+ color: #e4e6eb;
+ text-align: center;
+}
+
+.headset-select {
+ height: 2.1em;
+ width: 70px;
+ appearance: none;
+ border-radius: 6px;
+ border-style: none;
+ padding: 4px;
+ margin: 0px 0px 0px 10px;
+ font-size: small;
+ background-color: #252526;
+ color: #e4e6eb;
+ text-align: center;
+}
+
+.headset-select:hover {
+ background-color: #3b3b3c;
+}
+
+.headset-select:before {
+ content: 'Device: ';
+ color: #e4e6eb;
+}
+
+.special-button {
+ height: 2.1em;
+ border-radius: 6px;
+ padding: 4px;
+ margin: 0px 0px 0px 10px;
+ font-size: small;
+ background-color: #3b3b3c;
+ color: #e4e6eb;
+}
+
+.special-button img {
+ width: 1.2em;
+}
+
+.pose-action-button {
+ height: 2em;
+ display: inline-block;
+ border-radius: 6px;
+ border-style: none;
+ padding: 2px 6px;
+ margin: 0px;
+ font-size: small;
+ background-color: #3b3b3c;
+ color: #e4e6eb;
+ text-align: center;
+ vertical-align: middle;
+}
+
+.pose-action-button:hover,
+.headset-action-button:hover,
+.special-button:hover {
+ background-color: #676767;
+ color: #e4e6eb;
+}
+
+.pose-action-button:disabled,
+.special-button:disabled {
+ background-color: #2a2a2b;
+ /* color: #656568; */
+ color: #b0b0b0;
+}
+
+.button-pressed {
+ background-color: #317bef;
+}
+
+.button-pressed:hover {
+ background-color: #679bec;
+}
+
+.button-rightmost {
+ border-radius: 2px 6px 6px 2px !important;
+ margin-left: 0px;
+}
+
+.button-leftmost {
+ border-radius: 6px 2px 2px 6px !important;
+}
+
+.button-middle {
+ border-radius: 2px !important;
+ margin-left: 0px;
+}
+
+.joystick-button {
+ top: 0px;
+ right: 12px;
+ position: absolute;
+ width: 70px;
+ margin: 2px;
+}
+
+.action-icon {
+ width: 1.25em;
+ height: 1.25em;
+}
+
+.control-icon {
+ width: 2em;
+ height: 2em;
+}
+
+.control-label {
+ text-transform: capitalize;
+ padding-left: 5px;
+}
+
+.control-button-group {
+ margin-left: 5px;
+}
+
+.control-button-group * {
+ border-radius: 2px !important;
+ margin: 0px 2px 0px 0px;
+}
+
+.control-button-group *:first-child {
+ border-radius: 6px 2px 2px 6px !important;
+}
+
+.control-button-group *:last-child {
+ border-radius: 2px 6px 6px 2px !important;
+ margin: 0px;
+}
+
+.controller-card {
+ border-radius: 10px;
+ background-color: #252526;
+ color: #a7a7a7;
+}
+
+.headset-card {
+ border-radius: 10px 10px 0 0;
+ background-color: #252526;
+ color: #a7a7a7;
+ margin: 5px 5px 0px 5px;
+}
+
+.headset-card .card-body,
+.pose-card .card-body {
+ padding: 5px 0px 5px 0px;
+}
+
+.headset-card .row > *,
+.pose-card .row > * {
+ padding-right: 0px;
+}
+
+.controller-panel {
+ width: 100%;
+ padding: 5px;
+}
+
+.controller-panel .row {
+ margin: 4px 0px !important;
+}
+
+.controller-panel .col {
+ padding: 0;
+}
+
+.controller-panel .col:first-child {
+ margin-right: 2.5px;
+}
+
+.controller-panel .col:last-child {
+ margin-left: 2.5px;
+}
+
+.controller-card .card-header {
+ border-bottom-width: 2px;
+ padding: 2px 5px;
+}
+
+.controller-card .card-body {
+ padding: 0 0 1px 0;
+}
+
+.joystick-panel {
+ width: 80px;
+ position: relative;
+ top: 30px;
+ left: 0px;
+}
+
+#render-component {
+ padding-left: 5px;
+ padding-right: 5px;
+ width: 100%;
+}
+
+#transform-component {
+ position: absolute;
+ padding: 0;
+ pointer-events: none;
+}
+
+#transform-component button {
+ border: none;
+ pointer-events: all;
+}
+
+#transform-component > button {
+ background-color: #252526;
+ color: white;
+ opacity: 50%;
+ height: 18px;
+ padding: 0px;
+ width: 50px;
+ border-radius: 4px;
+ margin: 2px 0px 1px 2px;
+}
+
+#transform-component button:hover {
+ opacity: 1;
+}
+
+#transform-component button.active {
+ font-weight: bold;
+ opacity: 1;
+}
+
+#transform-component input {
+ pointer-events: all;
+}
+
+.transform-card {
+ color: white;
+ margin: 2px !important;
+ width: 155px;
+ font-size: 10px;
+ line-height: 1.2em;
+}
+
+.transform-icon {
+ background-color: #252526;
+ opacity: 50%;
+ border-radius: 6px 2px 2px 6px;
+}
+
+.transform-body {
+ background-color: #252526;
+ width: 127px;
+ opacity: 50%;
+ color: white;
+ border-radius: 2px 6px 6px 2px;
+ padding: 0px;
+ margin-left: 2px;
+}
+
+.transform-body .row {
+ margin: 1%;
+}
+
+.value {
+ font-family: 'Roboto Mono', monospace;
+ padding: 0;
+}
+
+.value > input[type='number'] {
+ border: none;
+ background-color: black;
+ border-radius: 5px;
+ width: 40px;
+ -webkit-appearance: none;
+ -moz-appearance: textfield;
+ appearance: textfield;
+ margin-left: 2px;
+ color: white;
+ text-align: center;
+}
+
+.value > input[type='number']:first-child {
+ margin-left: 0;
+}
+
+.value > input[type='number']:focus {
+ outline: none;
+}
+
+.value-changed {
+ color: #679bec;
+}
+
+.form-range {
+ max-width: 50px;
+ vertical-align: middle;
+}
+
+.form-range:hover {
+ background-color: #3b3b3c;
+}
+
+.form-range::-webkit-slider-runnable-track {
+ height: 1.4em;
+ border-radius: 5px;
+}
+
+.form-range::-webkit-slider-thumb {
+ -webkit-appearance: none; /* Override default look */
+ appearance: none;
+ margin-top: -0.35em; /* Centers thumb on the track */
+ border-radius: 2px;
+ height: 2.1em;
+ width: 8px;
+}
+
+.pose-left-full {
+ width: 110px;
+}
+
+.pose-right-full {
+ width: 85px;
+ margin-left: 3px;
+}
+
+.pose-left-long {
+ width: 78px;
+ border-radius: 6px 2px 2px 6px;
+}
+
+.pose-left-short {
+ width: 30px;
+ margin-left: 2px;
+ border-radius: 2px 6px 6px 2px;
+}
+
+.pose-card {
+ border-radius: 0 0 10px 10px;
+ background-color: #252526;
+ color: #a7a7a7;
+ margin: 0px 5px 0px 5px;
+}
+
+#mask {
+ background-color: #3b3b3c;
+ color: #e4e6eb;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ z-index: 1000;
+}
+
+.prompt {
+ width: calc(100% - 10px);
+ height: calc(100% - 10px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 5px;
+ border-radius: 3px;
+ border-width: 3px;
+ border-style: dashed;
+}
+
+.alternative-control-card {
+ border-radius: 10px;
+ background-color: #252526;
+ color: #a7a7a7;
+}
+
+.alternative-control-card .card-body {
+ padding: 5px 0px 5px 0px;
+}
+
+.resize-warning-card {
+ background-color: #252526;
+ color: #a7a7a7;
+ align-items: center;
+ margin: 5px 5px 0px 5px;
+ border: 3px dashed;
+ border-bottom: none;
+ border-radius: 10px 10px 0px 0px;
+ height: 100%;
+ justify-content: center;
+}
+
+.session-progress-bar {
+ display: inline-block;
+ width: calc(100% - 32px - 6em);
+ margin: 10px 5px 0px 5px;
+}
+
+#session-file {
+ margin: 8px 0px 0px 16px;
+}
+
+#save-default-pose {
+ margin-left: 10px;
+}
+
+.playback-action-button {
+ height: 2.1em;
+ width: 2.1em;
+ border-radius: 6px;
+ border-style: none;
+ padding: 4px 6px 4px 6px;
+ font-size: small;
+ background-color: #3b3b3c;
+ color: #e4e6eb;
+ text-align: center;
+}
+
+#playback-control {
+ margin: 5px 0px 0px 16px;
+}
+
+#room-dimension-settings span {
+ height: 20px;
+ padding-left: 5px;
+}
+
+#room-dimension-settings input {
+ width: 35px;
+ height: 15px;
+ padding: 1px 5px;
+ position: absolute;
+ border-radius: 8px;
+ border: 0px;
+ left: 100px;
+ background-color: #a7a7a7;
+}
+
+#room-dimension-settings .row {
+ height: 15px;
+}
+
+.controller-card .form-select {
+ width: 105px;
+}
+
+.controller-card .form-select:focus {
+ color: #fff;
+ background-color: #3b3b3c;
+}
+
+.controller-card .card-header .title {
+ padding-bottom: auto;
+}
+
+.controller-card .card-body .row {
+ margin: 2%;
+}
+
+.hand-card .form-range {
+ max-width: 78px;
+}
+
+.component-container.row {
+ width: calc(100% - 10px);
+}
+
+.drop-down-container {
+ background-color: #3b3b3c;
+ position: absolute;
+ width: 126px;
+ height: auto;
+ top: 41px;
+ right: 5px;
+ border-radius: 10px 2px 10px 10px;
+ z-index: 1;
+ padding: 5px;
+}
+
+.drop-down-container button {
+ width: 100%;
+ margin: 0;
+ text-align: start;
+}
+
+.mesh-menu {
+ margin: 5px;
+ width: 141px;
+}
+
+.mesh-menu > div {
+ margin-bottom: 2px;
+}
+
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.mesh-menu input[type='number'],
+.mesh-menu select,
+.mesh-menu button {
+ height: 18px;
+ border: none;
+ border-radius: 2px;
+ margin-right: 2px;
+ pointer-events: all;
+ text-align: center;
+ background-color: #252526;
+ color: white;
+ opacity: 50%;
+ vertical-align: middle;
+}
+
+.mesh-menu input[type='number'],
+.mesh-menu button {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 40px;
+ padding: 0;
+}
+
+.mesh-menu select {
+ width: 82px;
+}
+
+.semantic-label {
+ position: absolute;
+ color: white;
+ pointer-events: none;
+ z-index: 20;
+ width: 50px;
+ height: 20px;
+ text-align: center;
+ margin-left: -25px;
+ margin-top: -10px;
+}