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 ( + <> + +
+
+ + {props.mode === 'immersive-ar' && ( + + )} +
+ {deviceState.value && } +
+ + ) +} 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 ( + <> +
+
+ + + + + {showTransforms && + Object.values(DEVICE).map((deviceKey) => { + const deviceName = OBJECT_NAME[deviceKey]; + return ( +
+ +
+ {['position', 'rotation'].map((type) => ( +
+
+ {[0, 1, 2].map((i) => ( + + handleInputChange( + `${deviceName}-${type}-${i}`, + event, + ) + } + /> + ))} +
+
+ ))} +
+
+ ); + })}{' '} + {showRoomSettings && ( +
+
+ +
+
+ {[ + ['x', 'width'], + ['y', 'height'], + ['z', 'depth'], + ].map(([key, name]) => ( +
+ Space {name}: + { + EmulatorSettings.instance.roomDimension[key] = parseFloat( + event.target.value, + ); + EmulatorSettings.instance.write(); + device.updateRoom(); + changeRoomDimension(); + }} + /> +
+ ))} +
+
+ )} + {showPlaneSettings && ( +
+
+ + + +
+
+ + +
+
+ +
+
+ +
+
+ )} + {showMeshSettings && ( +
+
+ + + +
+
+ + +
+
+ +
+
+ +
+
+ )} +
+ + ); +} 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; +}