diff --git a/examples/hit-test-anchor/app.tsx b/examples/hit-test-anchor/app.tsx index 05c8b315..5c4df960 100644 --- a/examples/hit-test-anchor/app.tsx +++ b/examples/hit-test-anchor/app.tsx @@ -38,7 +38,7 @@ const store = createXRStore({ return ( <> - + diff --git a/examples/room-with-shadows/src/App.jsx b/examples/room-with-shadows/src/App.jsx index 50aae7f8..75085032 100644 --- a/examples/room-with-shadows/src/App.jsx +++ b/examples/room-with-shadows/src/App.jsx @@ -25,7 +25,21 @@ function Light() { ) } -const store = createXRStore() +const store = createXRStore({ + emulate: { + headset: { + position: [0, 1, 0], + }, + controller: { + left: { + position: [-0.2, 1, -0.3], + }, + right: { + position: [0.2, 1, -0.3], + }, + }, + }, +}) export default function App() { return ( @@ -66,7 +80,6 @@ export default function App() { - diff --git a/examples/secondary-input-sources/app.tsx b/examples/secondary-input-sources/app.tsx index f85d4dc2..89b68e3b 100644 --- a/examples/secondary-input-sources/app.tsx +++ b/examples/secondary-input-sources/app.tsx @@ -11,11 +11,9 @@ const store = createXRStore({ controller: () => { // eslint-disable-next-line react-hooks/rules-of-hooks const hasHands = useXR((xr) => xr.inputSourceStates.find((state) => state.type === 'hand') != null) - // eslint-disable-next-line react-hooks/rules-of-hooks - const controllerState = useXRInputSourceStateContext() return ( <> - + ( [model, state.layout, state.gamepad], ) useFrame(update) - return + return ( + + + + ) }) const LoadXRControllerLayoutSymbol = Symbol('loadXRControllerLayout') diff --git a/packages/react/xr/src/default.tsx b/packages/react/xr/src/default.tsx index 36912394..41cb0e36 100644 --- a/packages/react/xr/src/default.tsx +++ b/packages/react/xr/src/default.tsx @@ -29,7 +29,7 @@ import { useRayPointer, useTouchPointer, } from './pointer.js' -import { XRSpace as XRSpaceImpl } from './space.js' +import { XRSpace as XRSpaceImpl, XRSpaceType } from './space.js' import { xrInputSourceStateContext } from './contexts.js' import { TeleportPointerRayModel } from './teleport.js' import { createPortal, useFrame, useThree } from '@react-three/fiber' @@ -51,7 +51,7 @@ export { function DefaultXRInputSourceGrabPointer( event: 'select' | 'squeeze', - getSpace: (source: XRInputSource) => XRSpace, + spaceType: XRSpaceType, options: DefaultXRInputSourceGrabPointerOptions, ) { const state = useContext(xrInputSourceStateContext) @@ -63,7 +63,7 @@ function DefaultXRInputSourceGrabPointer( usePointerXRInputSourceEvents(pointer, state.inputSource, event, state.events) const cursorModelOptions = options.cursorModel return ( - + {cursorModelOptions !== false && ( )} @@ -82,11 +82,7 @@ function DefaultXRInputSourceGrabPointer( * - `cursorModel` properties for configuring how the cursor should look * - `radius` the size of the intersection sphere */ -export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind( - null, - 'select', - (inputSource) => inputSource.hand!.get('index-finger-tip')!, -) +export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind(null, 'select', 'index-finger-tip') /** * grab pointer for the XRController @@ -99,11 +95,7 @@ export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind( * - `cursorModel` properties for configuring how the cursor should look * - `radius` the size of the intersection sphere */ -export const DefaultXRControllerGrabPointer = DefaultXRInputSourceGrabPointer.bind( - null, - 'squeeze', - (inputSource) => inputSource.gripSpace!, -) +export const DefaultXRControllerGrabPointer = DefaultXRInputSourceGrabPointer.bind(null, 'squeeze', 'grip-space') /** * ray pointer for the XRInputSource @@ -128,7 +120,7 @@ export function DefaultXRInputSourceRayPointer(options: DefaultXRInputSourceRayP const rayModelOptions = options.rayModel const cursorModelOptions = options.cursorModel return ( - + {rayModelOptions !== false && ( )} @@ -345,7 +337,7 @@ export function DefaultXRInputSourceTeleportPointer(options: DefaultXRInputSourc }) return ( <> - + {createPortal( {rayModelOptions !== false && ( diff --git a/packages/react/xr/src/elements.tsx b/packages/react/xr/src/elements.tsx index 671285ca..7b6afc97 100644 --- a/packages/react/xr/src/elements.tsx +++ b/packages/react/xr/src/elements.tsx @@ -68,13 +68,13 @@ function XRControllers() { return null } return ( - - + + {typeof ResolvedImpl === 'function' ? : } - - + + ) })} @@ -95,13 +95,13 @@ function XRHands() { return null } return ( - - + + {typeof ResolvedImpl === 'function' ? : } - - + + ) })} @@ -125,7 +125,7 @@ function XRTransientPointers() { return null } return ( - + {typeof ResolvedImpl === 'function' ? ( @@ -152,7 +152,7 @@ function XRGazes() { <> {gazeStates.map((state) => { return ( - + {typeof Implementation === 'function' ? ( @@ -179,7 +179,7 @@ function XRScreenInputs() { <> {screenInputStates.map((state) => { return ( - + {typeof Implementation === 'function' ? ( diff --git a/packages/xr/package.json b/packages/xr/package.json index 359a0761..b42a1437 100644 --- a/packages/xr/package.json +++ b/packages/xr/package.json @@ -30,7 +30,7 @@ "three": "*" }, "dependencies": { - "@iwer/devui": "^0.1.0", + "@iwer/devui": "^0.2.0", "@pmndrs/pointer-events": "workspace:^", "iwer": "^1.0.3", "meshline": "^3.3.1", diff --git a/packages/xr/src/emulate.ts b/packages/xr/src/emulate.ts index cb499898..42510119 100644 --- a/packages/xr/src/emulate.ts +++ b/packages/xr/src/emulate.ts @@ -1,13 +1,110 @@ import { XRDevice, metaQuest3, metaQuest2, metaQuestPro, oculusQuest1 } from 'iwer' import { DevUI } from '@iwer/devui' +import type { XRDeviceOptions } from 'iwer/lib/device/XRDevice' +import { Euler, Quaternion, Vector3, Vector3Tuple, Vector4Tuple } from 'three' const configurations = { metaQuest3, metaQuest2, metaQuestPro, oculusQuest1 } export type EmulatorType = keyof typeof configurations -export function emulate(type: EmulatorType) { - const xrdevice = new XRDevice(configurations[type]) - xrdevice.ipd = 0 +export type EmulatorTransformationOptions = { + position?: Vector3 | Vector3Tuple + rotation?: Euler | Vector3Tuple + quaternion?: Quaternion | Vector4Tuple +} + +export type EmulatorOptions = + | EmulatorType + | ({ + type?: EmulatorType + primaryInputMode?: XRDevice['primaryInputMode'] + headset?: EmulatorTransformationOptions + controller?: Partial> + hand?: Partial> + } & Partial>) + +const handednessList: Array = ['left', 'none', 'right'] + +export function emulate(options: EmulatorOptions) { + const type = typeof options === 'string' ? options : (options.type ?? 'metaQuest3') + const xrdevice = new XRDevice(configurations[type], typeof options === 'string' ? undefined : options) + if (typeof options != 'string') { + applyEmulatorTransformOptions(xrdevice, options.headset) + applyEmulatorInputSourcesOptions(xrdevice.hands, options.hand) + applyEmulatorInputSourcesOptions(xrdevice.controllers, options.controller) + xrdevice.primaryInputMode = options.primaryInputMode ?? 'controller' + } + xrdevice.ipd = typeof options === 'string' ? 0 : (options.ipd ?? 0) xrdevice.installRuntime() new DevUI(xrdevice) + return xrdevice +} + +const eulerHelper = new Euler() +const quaternionHelper = new Quaternion() + +function applyEmulatorInputSourcesOptions( + xrInputSources: XRDevice['controllers'] | XRDevice['hands'], + options: Partial> | undefined, +) { + if (options == null) { + return + } + for (const handedness of handednessList) { + applyEmulatorTransformOptions(xrInputSources[handedness], options[handedness]) + } +} + +function applyEmulatorTransformOptions( + target: XRDevice['controllers']['left'] | XRDevice['hands']['left'] | XRDevice, + options: EmulatorTransformationOptions | undefined, +) { + if (target == null || options == null) { + return + } + setVector(target.position, options.position) + setVector(eulerHelper, options.rotation) + setQuaternion(target.quaternion, quaternionHelper.setFromEuler(eulerHelper)) + setQuaternion(target.quaternion, options.quaternion) +} + +function setVector( + target: { x: number; y: number; z: number } | Euler, + value: Euler | Vector3 | Vector3Tuple | undefined, +) { + if (value == null) { + return + } + if (value instanceof Euler && target instanceof Euler) { + target.copy(value) + } + if (Array.isArray(value)) { + target.x = value[0] + target.y = value[1] + target.z = value[2] + return + } + target.x = value.x + target.y = value.y + target.z = value.z +} + +function setQuaternion( + target: { x: number; y: number; z: number; w: number }, + value: Quaternion | Vector4Tuple | undefined, +) { + if (value == null) { + return + } + if (Array.isArray(value)) { + target.x = value[0] + target.y = value[1] + target.z = value[2] + target.w = value[3] + return + } + target.x = value.x + target.y = value.y + target.z = value.z + target.w = value.w } diff --git a/packages/xr/src/store.ts b/packages/xr/src/store.ts index 4228b6a8..3a70e3e0 100644 --- a/packages/xr/src/store.ts +++ b/packages/xr/src/store.ts @@ -5,8 +5,9 @@ import { XRControllerLayoutLoaderOptions, updateXRControllerState } from './cont import { XRHandLoaderOptions } from './hand/index.js' import { XRInputSourceState, XRInputSourceStateMap, createSyncXRInputSourceStates } from './input.js' import { XRSessionInitOptions, buildXRSessionInit } from './init.js' -import type { EmulatorType } from './emulate.js' +import type { EmulatorOptions } from './emulate.js' import { XRLayerEntry } from './layer.js' +import { XRDevice } from 'iwer' declare global { export interface XRSessionEventMap { @@ -68,6 +69,10 @@ export type XRState = Readonly< * active additional webxr layers */ layerEntries: ReadonlyArray + /** + * access to the emulator values to change the emulated input device imperatively + */ + emulator?: XRDevice } & WithRecord > @@ -166,7 +171,7 @@ export type XRStoreOptions = { * emulates a device if WebXR not supported and on localhost * @default "metaQuest3" */ - emulate?: EmulatorType | boolean + emulate?: EmulatorOptions | boolean /** * sets the WebXR foveation between 0 and 1 * undefined refers to the default foveation provided by the device/browser @@ -283,35 +288,30 @@ const baseInitialState: Omit< layerEntries: [], } -function startEmulate(emulate: EmulatorType | true, alert: boolean) { +function startEmulate(store: StoreApi>, emulate: EmulatorOptions | true, alert: boolean) { if (typeof navigator === 'undefined') { return } Promise.all([navigator.xr?.isSessionSupported('immersive-vr'), navigator.xr?.isSessionSupported('immersive-ar')]) .then(([vr, ar]) => (!ar && !vr ? import('./emulate.js') : undefined)) .then((pkg) => { + if (pkg == null) { + return + } if (alert) { window.alert(`emulator started`) } - pkg?.emulate(emulate === true ? 'metaQuest3' : emulate) + const emulator = pkg.emulate(emulate === true ? 'metaQuest3' : emulate) + if (emulator == null) { + return + } + store.setState({ + emulator, + }) }) } export function createXRStore(options?: XRStoreOptions): XRStore { - const emulate = options?.emulate ?? 'metaQuest3' - let cleanupEmulate: (() => void) | undefined - if (typeof window !== 'undefined' && emulate != false) { - if (window.location.hostname === 'localhost') { - startEmulate(emulate, false) - } - const keydownListener = (e: KeyboardEvent) => { - if (e.altKey && e.metaKey && e.code === 'KeyE') { - startEmulate(emulate, true) - } - } - window.addEventListener('keydown', keydownListener) - cleanupEmulate = () => window.removeEventListener('keydown', keydownListener) - } const domOverlayRoot = typeof HTMLElement === 'undefined' ? undefined @@ -328,6 +328,22 @@ export function createXRStore(options?: XRSt domOverlayRoot, })) + //setup emulate + const emulate = options?.emulate ?? 'metaQuest3' + let cleanupEmulate: (() => void) | undefined + if (typeof window !== 'undefined' && emulate != false) { + if (window.location.hostname === 'localhost') { + startEmulate(store, emulate, false) + } + const keydownListener = (e: KeyboardEvent) => { + if (e.altKey && e.metaKey && e.code === 'KeyE') { + startEmulate(store, emulate, true) + } + } + window.addEventListener('keydown', keydownListener) + cleanupEmulate = () => window.removeEventListener('keydown', keydownListener) + } + let cleanupDomOverlayRoot: (() => void) | undefined if (domOverlayRoot != null) { if (domOverlayRoot.parentNode == null) { @@ -677,7 +693,10 @@ function createBindToSession( const onEnd = () => { cleanupSession?.() cleanupSession = undefined - store.setState(baseInitialState) + store.setState({ + emulator: store.getState().emulator, + ...baseInitialState, + }) } session.addEventListener('end', onEnd) diff --git a/packages/xr/src/vanilla/elements.ts b/packages/xr/src/vanilla/elements.ts index 07b53b79..f8f48ca0 100644 --- a/packages/xr/src/vanilla/elements.ts +++ b/packages/xr/src/vanilla/elements.ts @@ -135,7 +135,7 @@ function setupSyncInputSourceElements( if (implementation === false) { return } - const spaceObject = new XRSpace(getSpace(key, state.inputSource)) + const spaceObject = new XRSpace(state.inputSource.targetRaySpace) target.add(spaceObject) const customCleanup = typeof implementation === 'object' @@ -195,19 +195,6 @@ function cleanup(map: Map void) | undefined>) { map.clear() } -function getSpace(type: keyof XRInputSourceStateMap, inputSource: XRInputSource): XRSpaceType { - switch (type) { - case 'controller': - return inputSource.gripSpace! - case 'hand': - return inputSource.hand!.get('wrist')! - case 'gaze': - case 'screenInput': - case 'transientPointer': - return inputSource.targetRaySpace - } -} - export let xrUpdatesListContext: XRUpdatesList | undefined function runInXRUpdatesListContext(updatesList: XRUpdatesList, fn: () => (() => void) | undefined | void) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e9f3d1c..f7be344b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,8 +367,8 @@ importers: packages/xr: dependencies: '@iwer/devui': - specifier: ^0.1.0 - version: 0.1.1(iwer@1.0.4) + specifier: ^0.2.0 + version: 0.2.0(iwer@1.0.4) '@pmndrs/pointer-events': specifier: workspace:^ version: link:../pointer-events @@ -974,8 +974,8 @@ packages: wrap-ansi-cjs: /wrap-ansi@7.0.0 dev: false - /@iwer/devui@0.1.1(iwer@1.0.4): - resolution: {integrity: sha512-kZnoWc7KeKsdhRM7n7KORRlNCtk5Y2NmCsDUnwRvFyY1Cv0y0bnf3QMN6+rnHtRH88PtNxqKpN+syN8byrCAgw==} + /@iwer/devui@0.2.0(iwer@1.0.4): + resolution: {integrity: sha512-/D9QlbC+JGco0NuG2WiyVWo7SJatrNZ8kotS2rV1r/z4A+bcDbDfiU+PhOwz7oktwIzdr83cFK/V7IDUe0iSSg==} peerDependencies: iwer: ^1.0.3 dependencies: @@ -986,7 +986,7 @@ packages: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-components: 6.1.13(react-dom@18.3.1)(react@18.3.1) - three: 0.166.1 + three: 0.169.0 dev: false /@jridgewell/gen-mapping@0.3.5: @@ -4597,29 +4597,29 @@ packages: postcss: 8.4.31 dev: false - /postcss-import@15.1.0(postcss@8.4.31): + /postcss-import@15.1.0(postcss@8.4.43): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.31 + postcss: 8.4.43 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 dev: false - /postcss-js@4.0.1(postcss@8.4.31): + /postcss-js@4.0.1(postcss@8.4.43): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.31 + postcss: 8.4.43 dev: false - /postcss-load-config@4.0.2(postcss@8.4.31): + /postcss-load-config@4.0.2(postcss@8.4.43): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} peerDependencies: @@ -4632,17 +4632,17 @@ packages: optional: true dependencies: lilconfig: 3.1.2 - postcss: 8.4.31 + postcss: 8.4.43 yaml: 2.5.0 dev: false - /postcss-nested@6.2.0(postcss@8.4.31): + /postcss-nested@6.2.0(postcss@8.4.43): resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.31 + postcss: 8.4.43 postcss-selector-parser: 6.1.2 dev: false @@ -4683,7 +4683,6 @@ packages: nanoid: 3.3.7 picocolors: 1.0.1 source-map-js: 1.2.0 - dev: true /potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} @@ -5273,11 +5272,11 @@ packages: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.1 - postcss: 8.4.31 - postcss-import: 15.1.0(postcss@8.4.31) - postcss-js: 4.0.1(postcss@8.4.31) - postcss-load-config: 4.0.2(postcss@8.4.31) - postcss-nested: 6.2.0(postcss@8.4.31) + postcss: 8.4.43 + postcss-import: 15.1.0(postcss@8.4.43) + postcss-js: 4.0.1(postcss@8.4.43) + postcss-load-config: 4.0.2(postcss@8.4.43) + postcss-nested: 6.2.0(postcss@8.4.43) postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 resolve: 1.22.8 @@ -5348,13 +5347,13 @@ packages: resolution: {integrity: sha512-iC/hUBbl1vzFny7f5GtqzVXYjMJKaTPxiCxXfrvVdBi1Sf+jhd1CAkitiFwC7mIBFCo3MrDLJG97yisoaWig0w==} dev: true - /three@0.166.1: - resolution: {integrity: sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==} - dev: false - /three@0.167.1: resolution: {integrity: sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==} + /three@0.169.0: + resolution: {integrity: sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==} + dev: false + /tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} dev: false