├── .editorconfig
├── .eslintrc.js
├── .github
├── dependabot.yml
└── workflows
│ ├── docs.yml
│ └── test.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── devui
├── README.md
├── example
│ ├── index.html
│ ├── index.js
│ ├── package-lock.json
│ ├── package.json
│ └── webpack.config.cjs
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
│ ├── components
│ │ ├── analog.tsx
│ │ ├── binary.tsx
│ │ ├── controller.tsx
│ │ ├── controls.tsx
│ │ ├── hand.tsx
│ │ ├── header.tsx
│ │ ├── headset.tsx
│ │ ├── icons.tsx
│ │ ├── joystick.tsx
│ │ ├── keys.tsx
│ │ ├── mapper.tsx
│ │ ├── pinch.tsx
│ │ ├── pose.tsx
│ │ ├── styled.tsx
│ │ └── vec3.tsx
│ ├── index.tsx
│ └── scene.ts
└── tsconfig.json
├── docs
├── .vitepress
│ ├── config.ts
│ └── theme
│ │ ├── index.ts
│ │ └── style.css
├── about.md
├── action.md
├── api
│ ├── config-interfaces.md
│ ├── xr-controller.md
│ ├── xr-device.md
│ └── xr-hand-input.md
├── getting-started.md
├── index.md
├── public
│ ├── cap.mp4
│ ├── iwer-dark.svg
│ ├── iwer-locomotion.gif
│ ├── iwer-open-menu.gif
│ ├── iwer-photo-drop.gif
│ ├── iwer-seed-selection.gif
│ ├── iwer-text.svg
│ ├── iwer.png
│ └── iwer.svg
└── xplat.md
├── example
├── cap.min.js
├── index.html
├── index.js
├── package-lock.json
├── package.json
└── webpack.config.js
├── jest.config.cjs
├── package-lock.json
├── package.json
├── rollup.config.js
├── sem
├── .gitignore
├── README.md
├── captures
│ ├── living_room.json
│ ├── meeting_room.json
│ ├── music_room.json
│ ├── office_large.json
│ └── office_small.json
├── package-lock.json
├── package.json
├── rollup.config.js
├── scripts
│ └── prebuild.cjs
├── src
│ ├── generated
│ │ ├── google
│ │ │ └── protobuf
│ │ │ │ ├── descriptor.ts
│ │ │ │ ├── duration.ts
│ │ │ │ └── timestamp.ts
│ │ └── protos
│ │ │ ├── openxr_core.ts
│ │ │ ├── openxr_scene.ts
│ │ │ └── validate.ts
│ ├── globals.d.ts
│ ├── index.ts
│ ├── native
│ │ ├── components
│ │ │ ├── bounded2d.ts
│ │ │ ├── bounded3d.ts
│ │ │ ├── component.ts
│ │ │ ├── locatable.ts
│ │ │ ├── semanticlabel.ts
│ │ │ └── trianglemesh.ts
│ │ └── entity.ts
│ └── sem.ts
└── tsconfig.json
├── src
├── action
│ ├── ActionPlayer.ts
│ └── ActionRecorder.ts
├── anchors
│ └── XRAnchor.ts
├── device
│ ├── XRController.ts
│ ├── XRDevice.ts
│ ├── XRHandInput.ts
│ ├── XRTrackedInput.ts
│ └── configs
│ │ ├── controller
│ │ └── meta.ts
│ │ ├── hand
│ │ ├── pinch.ts
│ │ ├── point.ts
│ │ └── relaxed.ts
│ │ └── headset
│ │ └── meta.ts
├── events
│ ├── XRInputSourceEvent.ts
│ ├── XRInputSourcesChangeEvent.ts
│ ├── XRReferenceSpaceEvent.ts
│ └── XRSessionEvent.ts
├── frameloop
│ └── XRFrame.ts
├── gamepad
│ └── Gamepad.ts
├── hittest
│ ├── XRHitTest.ts
│ └── XRRay.ts
├── index.ts
├── initialization
│ └── XRSystem.ts
├── input
│ ├── XRHand.ts
│ └── XRInputSource.ts
├── labels
│ └── labels.ts
├── layers
│ └── XRWebGLLayer.ts
├── meshes
│ └── XRMesh.ts
├── planes
│ └── XRPlane.ts
├── pose
│ ├── XRJointPose.ts
│ ├── XRPose.ts
│ └── XRViewerPose.ts
├── primitives
│ └── XRRigidTransform.ts
├── private.ts
├── session
│ ├── XRRenderState.ts
│ └── XRSession.ts
├── spaces
│ ├── XRJointSpace.ts
│ ├── XRReferenceSpace.ts
│ └── XRSpace.ts
├── utils
│ └── Math.ts
└── views
│ ├── XRView.ts
│ └── XRViewport.ts
├── tests
├── anchors
│ └── XRAnchor.test.ts
├── device
│ ├── XRController.test.ts
│ ├── XRDevice.test.ts
│ ├── XRHandInput.test.ts
│ └── XRTrackedInput.test.ts
├── events
│ ├── XRInputSourceChangeEvent.test.ts
│ ├── XRInputSourceEvent.test.ts
│ ├── XRReferenceSpaceEvent.test.ts
│ └── XRSessionEvent.test.ts
├── frameloop
│ └── XRFrame.test.ts
├── gamepad
│ └── Gamepad.test.ts
├── hittest
│ ├── XRHitTest.test.ts
│ └── XRRay.test.ts
├── initialization
│ └── XRSystem.test.ts
├── input
│ └── XRInputSource.test.ts
├── jsconfig.json
├── polyfill.ts
├── primitives
│ └── XRRigidTransform.test.ts
├── session
│ └── XRSession.test.ts
└── spaces
│ ├── XRReferenceSpace.test.ts
│ └── XRSpace.test.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'prettier',
11 | ],
12 | parser: '@typescript-eslint/parser',
13 | plugins: ['@typescript-eslint', 'prettier'],
14 | parserOptions: {
15 | ecmaVersion: 12,
16 | sourceType: 'module',
17 | },
18 | rules: {
19 | 'sort-imports': [
20 | 'error',
21 | {
22 | ignoreCase: false,
23 | ignoreDeclarationSort: false,
24 | ignoreMemberSort: false,
25 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
26 | allowSeparatedGroups: false,
27 | },
28 | ],
29 | 'no-unused-vars': [
30 | 'error',
31 | { vars: 'all', args: 'all', argsIgnorePattern: '^_' },
32 | ],
33 | 'lines-between-class-members': ['warn', 'always'],
34 | 'prettier/prettier': 'error',
35 | },
36 | root: true,
37 | };
38 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directory: '/'
5 | schedule:
6 | interval: 'weekly'
7 | open-pull-requests-limit: 10
8 | commit-message:
9 | prefix: 'chore'
10 | prefix-development: 'chore'
11 | include: 'scope'
12 | - package-ecosystem: 'npm'
13 | directory: '/devui'
14 | schedule:
15 | interval: 'weekly'
16 | open-pull-requests-limit: 10
17 | commit-message:
18 | prefix: 'chore'
19 | prefix-development: 'chore'
20 | include: 'scope'
21 | - package-ecosystem: 'npm'
22 | directory: '/sem'
23 | schedule:
24 | interval: 'weekly'
25 | open-pull-requests-limit: 10
26 | commit-message:
27 | prefix: 'chore'
28 | prefix-development: 'chore'
29 | include: 'scope'
30 | - package-ecosystem: 'github-actions'
31 | directory: '/'
32 | schedule:
33 | interval: 'weekly'
34 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Build and deploy docs to Pages
2 |
3 | on:
4 | push:
5 | branches: ['main']
6 | workflow_dispatch:
7 |
8 | permissions:
9 | contents: read
10 | pages: write
11 | id-token: write
12 |
13 | concurrency:
14 | group: 'pages'
15 | cancel-in-progress: true
16 |
17 | jobs:
18 | deploy:
19 | environment:
20 | name: github-pages
21 | url: ${{ steps.deployment.outputs.page_url }}
22 |
23 | runs-on: ubuntu-latest
24 |
25 | strategy:
26 | matrix:
27 | node-version: [20.x]
28 |
29 | steps:
30 | - uses: actions/checkout@v4
31 |
32 | - name: Use Node.js ${{ matrix.node-version }}
33 | uses: actions/setup-node@v4
34 | with:
35 | node-version: ${{ matrix.node-version }}
36 |
37 | - name: Install dependencies
38 | run: npm ci
39 |
40 | - name: Build library
41 | run: npm run build
42 |
43 | - name: Build doc site
44 | run: npm run docs:build
45 |
46 | - name: Upload artifact
47 | uses: actions/upload-pages-artifact@v3
48 | with:
49 | path: ./docs/.vitepress/dist
50 |
51 | - name: Deploy to GitHub Pages
52 | id: deployment
53 | uses: actions/deploy-pages@v4
54 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [20.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v4
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - run: npm ci
20 | - run: npm run build
21 | - run: npm test
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.yarn/*
2 | !/.yarn/releases
3 | !/.yarn/plugins
4 | !/.yarn/sdks
5 |
6 | # Swap the comments on the following lines if you don't wish to use zero-installs
7 | # Documentation here: https://yarnpkg.com/features/zero-installs
8 | !/.yarn/cache
9 | #/.pnp.*
10 |
11 | lib/
12 | build/
13 | dist/
14 | node_modules/
15 |
16 | /.vscode/
17 |
18 | # blender backups
19 | *.blend?
20 |
21 | .DS_Store
22 |
23 | docs/.vitepress/dist/
24 | docs/.vitepress/cache/
25 |
26 | **/src/version.ts
27 |
28 | *.tgz
29 |
30 | /coverage/
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /src/assets/*
2 | /src/style/*
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "endOfLine": "lf",
4 | "useTabs": true,
5 | "trailingComma": "all",
6 | "arrowParens": "always",
7 | "printWidth": 80,
8 | "tabWidth": 2,
9 | "singleQuote": true,
10 | "jsxSingleQuote": false
11 | }
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | This Code of Conduct also applies outside the project spaces when there is a
56 | reasonable belief that an individual's behavior may have a negative impact on
57 | the project or its community.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported by contacting the project team at . All
63 | complaints will be reviewed and investigated and will result in a response that
64 | is deemed necessary and appropriate to the circumstances. The project team is
65 | obligated to maintain confidentiality with regard to the reporter of an incident.
66 | Further details of specific enforcement policies may be posted separately.
67 |
68 | Project maintainers who do not follow or enforce the Code of Conduct in good
69 | faith may face temporary or permanent repercussions as determined by other
70 | members of the project's leadership.
71 |
72 | ## Attribution
73 |
74 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
76 |
77 | [homepage]: https://www.contributor-covenant.org
78 |
79 | For answers to common questions about this code of conduct, see
80 | https://www.contributor-covenant.org/faq
81 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Immersive Web Emulation Runtime
2 |
3 | We want to make contributing to this project as easy and transparent as
4 | possible.
5 |
6 | ## Pull Requests
7 |
8 | We actively welcome your pull requests.
9 |
10 | 1. Fork the repo and create your branch from `main`.
11 | 2. If you've added code that should be tested, add tests.
12 | 3. Make sure your code lints.
13 | 4. If you haven't already, complete the Contributor License Agreement ("CLA").
14 |
15 | ## Contributor License Agreement ("CLA")
16 |
17 | In order to accept your pull request, we need you to submit a CLA. You only need
18 | to do this once to work on any of Meta's open source projects.
19 |
20 | Complete your CLA here:
21 |
22 | ## Issues
23 |
24 | We use GitHub issues to track public bugs. Please ensure your description is
25 | clear and has sufficient instructions to be able to reproduce the issue.
26 |
27 | Meta has a [bounty program](https://www.facebook.com/whitehat/) for the safe
28 | disclosure of security bugs. In those cases, please go through the process
29 | outlined on that page and do not file a public issue.
30 |
31 | ## Coding Style
32 |
33 | Immersive Web Emulation Runtime uses `eslint` and `prettier` to lint and format code.
34 |
35 | You can format manually by running:
36 |
37 | ```
38 | $ npm run format
39 | ```
40 |
41 | There are also VSCode extensions that can run those linters / formatters for you. Prettier has a [VSCode Plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), which allows you to format on save.
42 |
43 | If you don't want to format on save, and want to format a single file, you can use `Ctrl+Shift+P` in VS Code (`Cmd+Shift+P` on macs) to bring up the command palette, then type `Format Document` to format the currently active file. Note that you should have the Prettier VS code plugin installed to make sure it formats according to the project's guidelines.
44 |
45 | ## Testing
46 |
47 | Immersive Web Emulation Runtime uses `jest` to conduct unit testing for all its classes. Before contributing, please make sure your change does not fail any of the existing jest tests. If you are adding new classes or new features, we request that you add relevant tests to ensure a high test coverage of the Immersive Web Emulation Runtime.
48 |
49 | You can run tests manually by running:
50 |
51 | ```
52 | $ npm run test
53 | ```
54 |
55 | ## License
56 |
57 | By contributing to Immersive Web Emulation Runtime, you agree that your contributions will be licensed
58 | under the [LICENSE](./LICENSE) file in the root directory of this source tree.
59 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Meta Platforms, Inc. and affiliates.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Immersive Web Emulation Runtime
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | The Immersive Web Emulation Runtime (IWER) is a TypeScript-based tool designed to enable the running of WebXR applications in modern browsers without requiring native WebXR support. By emulating the WebXR Device API, IWER provides developers with the ability to test and run WebXR projects across a wide range of devices, ensuring compatibility and enhancing the development process.
14 |
15 | ## Documentation
16 |
17 | For detailed information about using IWER, including concepts, guides, and API references, please visit our documentation site:
18 |
19 | - [IWER Documentation](https://meta-quest.github.io/immersive-web-emulation-runtime)
20 |
21 | ## License
22 |
23 | IWER is licensed under the MIT License. For more details, see the [LICENSE](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/LICENSE) file in this repository.
24 |
25 | ## Contributing
26 |
27 | Your contributions are welcome! Please feel free to submit issues and pull requests. Before contributing, make sure to review our [Contributing Guidelines](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/CONTRIBUTING.md) and [Code of Conduct](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/CODE_OF_CONDUCT.md).
28 |
--------------------------------------------------------------------------------
/devui/example/index.html:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | IWER DevUI Showcase
11 |
12 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/devui/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webxr-first-steps",
3 | "private": true,
4 | "devDependencies": {
5 | "@types/three": "0.165.0",
6 | "copy-webpack-plugin": "12.0.2",
7 | "eslint": "9.10.0",
8 | "eslint-config-prettier": "9.1.0",
9 | "eslint-webpack-plugin": "4.2.0",
10 | "html-webpack-plugin": "5.6.0",
11 | "prettier": "3.3.3",
12 | "webpack": "5.94.0",
13 | "webpack-cli": "5.1.4",
14 | "webpack-dev-server": "5.1.0"
15 | },
16 | "scripts": {
17 | "build": "webpack",
18 | "dev": "webpack serve",
19 | "format": "prettier --write ./src/**/*"
20 | },
21 | "dependencies": {
22 | "@iwer/devui": "file:..",
23 | "@iwer/sem": "^0.2.4",
24 | "iwer": "^2.0.1",
25 | "three": "0.165.0",
26 | "troika-three-text": "^0.52.3"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/devui/example/webpack.config.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | const path = require('path');
9 | const HtmlWebpackPlugin = require('html-webpack-plugin');
10 |
11 | module.exports = {
12 | mode: 'development',
13 | entry: './index.js',
14 | output: {
15 | filename: '[name].[contenthash].js',
16 | path: path.resolve(__dirname, 'dist'),
17 | clean: true,
18 | },
19 | module: {
20 | rules: [
21 | {
22 | test: /\.css$/i,
23 | use: ['style-loader', 'css-loader'],
24 | },
25 | ],
26 | },
27 | devServer: {
28 | static: {
29 | directory: path.join(__dirname, 'dist'),
30 | },
31 | host: '0.0.0.0',
32 | server: 'https',
33 | port: 8081,
34 | client: {
35 | overlay: { warnings: false, errors: true },
36 | },
37 | },
38 | plugins: [
39 | new HtmlWebpackPlugin({
40 | template: './index.html',
41 | }),
42 | ],
43 | };
44 |
--------------------------------------------------------------------------------
/devui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@iwer/devui",
3 | "version": "1.1.2",
4 | "description": "Dev UI for IWER",
5 | "type": "module",
6 | "main": "lib/index.js",
7 | "types": "lib/index.d.ts",
8 | "files": [
9 | "build",
10 | "lib"
11 | ],
12 | "scripts": {
13 | "prebuild": "node -p \"'export const VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
14 | "build": "tsc && rollup -c"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/meta-quest/immersive-web-emulation-runtime.git"
19 | },
20 | "keywords": [],
21 | "author": "Felix Zhang ",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/meta-quest/immersive-web-emulation-runtime/issues"
25 | },
26 | "homepage": "https://github.com/meta-quest/immersive-web-emulation-runtime#readme",
27 | "dependencies": {
28 | "@fortawesome/fontawesome-svg-core": "6.6.0",
29 | "@fortawesome/free-solid-svg-icons": "6.6.0",
30 | "@fortawesome/react-fontawesome": "0.2.2",
31 | "@pmndrs/handle": "^6.6.17",
32 | "@pmndrs/pointer-events": "^6.6.17",
33 | "react": ">=18.3.1",
34 | "react-dom": ">=18.3.1",
35 | "styled-components": "^6.1.13",
36 | "three": "^0.165.0"
37 | },
38 | "peerDependencies": {
39 | "iwer": "^2.0.1"
40 | },
41 | "devDependencies": {
42 | "@rollup/plugin-babel": "^6.0.4",
43 | "@rollup/plugin-commonjs": "^28.0.1",
44 | "@rollup/plugin-node-resolve": "^15.3.0",
45 | "@rollup/plugin-replace": "^6.0.1",
46 | "@rollup/plugin-terser": "^0.4.4",
47 | "@types/react": "^18.3.12",
48 | "@types/react-dom": "^18.3.1",
49 | "rollup": "^4.24.3",
50 | "rollup-plugin-peer-deps-external": "^2.2.4",
51 | "typescript": "^5.6.3"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/devui/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import peerDepsExternal from 'rollup-plugin-peer-deps-external';
3 | import replace from '@rollup/plugin-replace';
4 | import resolve from '@rollup/plugin-node-resolve';
5 | import terser from '@rollup/plugin-terser';
6 |
7 | const globals = {
8 | iwer: 'IWER', // Change this to the global variable name for the iwer module
9 | };
10 |
11 | export default {
12 | input: 'lib/index.js',
13 | external: ['iwer'],
14 | plugins: [
15 | peerDepsExternal(),
16 | resolve(),
17 | commonjs(),
18 | replace({
19 | 'process.env.NODE_ENV': JSON.stringify('production'),
20 | preventAssignment: true,
21 | }),
22 | ],
23 | output: [
24 | // UMD build
25 | {
26 | file: 'build/iwer-devui.js',
27 | format: 'umd',
28 | name: 'IWER_DevUI',
29 | globals,
30 | },
31 | // Minified UMD build
32 | {
33 | file: 'build/iwer-devui.min.js',
34 | format: 'umd',
35 | name: 'IWER_DevUI',
36 | globals,
37 | plugins: [terser()],
38 | },
39 | // ES module build
40 | {
41 | file: 'build/iwer-devui.module.js',
42 | format: 'es',
43 | globals,
44 | },
45 | // Minified ES module build
46 | {
47 | file: 'build/iwer-devui.module.min.js',
48 | format: 'es',
49 | globals,
50 | plugins: [terser()],
51 | },
52 | ],
53 | };
54 |
--------------------------------------------------------------------------------
/devui/src/components/binary.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | Button,
10 | ButtonContainer,
11 | ButtonGroup,
12 | Colors,
13 | ControlButtonStyles,
14 | FAIcon,
15 | MappedKeyBlock,
16 | } from './styled.js';
17 | import React, { useEffect, useState } from 'react';
18 |
19 | import { GamepadIcon } from './icons.js';
20 | import { MappedKeyDisplay } from './keys.js';
21 | import { XRController } from 'iwer/lib/device/XRController';
22 | import { faFingerprint } from '@fortawesome/free-solid-svg-icons';
23 |
24 | interface BinaryButtonProps {
25 | xrController: XRController;
26 | buttonId: string;
27 | pointerLocked: boolean;
28 | mappedKey: string;
29 | }
30 |
31 | export const BinaryButton: React.FC = ({
32 | xrController,
33 | buttonId,
34 | pointerLocked,
35 | mappedKey,
36 | }) => {
37 | const [isTouched, setIsTouched] = useState(false);
38 | const [isOnHold, setIsOnHold] = useState(false);
39 | const [isPressed, setIsPressed] = useState(false);
40 | const [isKeyPressed, setIsKeyPressed] = useState(false);
41 |
42 | const handedness = xrController.inputSource.handedness;
43 |
44 | useEffect(() => {
45 | const handleKeyDown = (event: KeyboardEvent) => {
46 | if (event.code === mappedKey) {
47 | xrController.updateButtonValue(buttonId, 1);
48 | setIsKeyPressed(true);
49 | }
50 | };
51 |
52 | const handleKeyUp = (event: KeyboardEvent) => {
53 | if (event.code === mappedKey) {
54 | xrController.updateButtonValue(buttonId, 0);
55 | setIsKeyPressed(false);
56 | }
57 | };
58 |
59 | if (pointerLocked) {
60 | window.addEventListener('keydown', handleKeyDown);
61 | window.addEventListener('keyup', handleKeyUp);
62 | } else {
63 | window.removeEventListener('keydown', handleKeyDown);
64 | window.removeEventListener('keyup', handleKeyUp);
65 | }
66 |
67 | return () => {
68 | window.removeEventListener('keydown', handleKeyDown);
69 | window.removeEventListener('keyup', handleKeyUp);
70 | };
71 | }, [mappedKey, pointerLocked, buttonId, xrController]);
72 |
73 | return (
74 |
75 |
76 |
77 | {pointerLocked ? (
78 |
79 | {MappedKeyDisplay[mappedKey]}
80 |
81 | ) : (
82 | <>
83 | {
92 | setIsPressed(true);
93 | xrController.updateButtonValue(buttonId, 1);
94 | setTimeout(() => {
95 | setIsPressed(false);
96 | xrController.updateButtonValue(buttonId, 0);
97 | }, 250);
98 | }}
99 | >
100 | Press
101 |
102 | {
113 | setIsTouched(!isTouched);
114 | xrController.updateButtonTouch(buttonId, !isTouched);
115 | }}
116 | >
117 |
118 |
119 | {
128 | setIsOnHold(!isOnHold);
129 | xrController.updateButtonValue(buttonId, isOnHold ? 0 : 1);
130 | }}
131 | >
132 | Hold
133 |
134 | >
135 | )}
136 |
137 |
138 | );
139 | };
140 |
--------------------------------------------------------------------------------
/devui/src/components/controls.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { ControllerUI } from './controller.js';
9 | import { HandUI } from './hand.js';
10 | import { InputLayer } from '../scene.js';
11 | import React from 'react';
12 | import { XRDevice } from 'iwer';
13 | import { create } from 'zustand';
14 |
15 | interface ControlsProps {
16 | xrDevice: XRDevice;
17 | inputLayer: InputLayer;
18 | pointerLocked: boolean;
19 | }
20 |
21 | type InputModeStore = {
22 | inputMode: string;
23 | setInputMode: (mode: 'controller' | 'hand') => void;
24 | };
25 |
26 | export const useInputModeStore = create((set) => ({
27 | inputMode: 'controller',
28 | setInputMode: (mode: 'controller' | 'hand') =>
29 | set(() => ({
30 | inputMode: mode,
31 | })),
32 | }));
33 |
34 | export const ControlsUI: React.FC = ({
35 | xrDevice,
36 | inputLayer,
37 | pointerLocked,
38 | }) => {
39 | const { inputMode } = useInputModeStore();
40 | return (
41 | <>
42 | {inputMode === 'controller'
43 | ? Object.entries(xrDevice.controllers).map(
44 | ([handedness, controller]) => (
45 |
56 | ),
57 | )
58 | : Object.entries(xrDevice.hands).map(([handedness, hand]) => (
59 |
68 | ))}
69 | >
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/devui/src/components/hand.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | ControlButtonStyles,
10 | ControlPanel,
11 | FAIcon,
12 | PanelHeaderButton,
13 | SectionBreak,
14 | } from './styled.js';
15 | import { ControlsMapper, useKeyMapStore } from './mapper.js';
16 | import {
17 | faCircleXmark,
18 | faGear,
19 | faHand,
20 | faPlug,
21 | } from '@fortawesome/free-solid-svg-icons';
22 |
23 | import { PinchControl } from './pinch.js';
24 | import { PoseSelector } from './pose.js';
25 | import React from 'react';
26 | import { TransformHandles } from '@pmndrs/handle';
27 | import { Vector3Input } from './vec3.js';
28 | import type { XRHandInput } from 'iwer/lib/device/XRHandInput.js';
29 |
30 | interface HandProps {
31 | hand: XRHandInput;
32 | handle: TransformHandles;
33 | handedness: string;
34 | pointerLocked: boolean;
35 | }
36 |
37 | export const HandUI: React.FC = ({
38 | hand,
39 | handle,
40 | handedness,
41 | pointerLocked,
42 | }) => {
43 | const { keyMap } = useKeyMapStore();
44 | const [connected, setConnected] = React.useState(hand.connected);
45 | const [settingsOpen, setSettingsOpen] = React.useState(false);
46 | React.useEffect(() => {
47 | if (pointerLocked) {
48 | setSettingsOpen(false);
49 | }
50 | }, [pointerLocked]);
51 | return (
52 |
60 | {!pointerLocked && (
61 | <>
62 |
70 |
78 |
83 | Hand
84 |
85 | [{handedness === 'left' ? 'L' : 'R'}]
86 |
87 |
88 |
95 | {connected ? (
96 | <>
97 |
{
102 | setSettingsOpen(!settingsOpen);
103 | }}
104 | >
105 |
106 |
107 |
{
111 | hand.connected = false;
112 | setConnected(false);
113 | }}
114 | >
115 |
116 |
117 | >
118 | ) : (
119 |
{
122 | hand.connected = true;
123 | setConnected(true);
124 | }}
125 | style={{ marginLeft: '5px' }}
126 | >
127 |
128 |
129 | )}
130 |
131 |
132 | >
133 | )}
134 | {connected && !pointerLocked && (
135 | <>
136 | {!settingsOpen && (
137 | <>
138 |
139 |
144 |
145 | >
146 | )}
147 |
148 | >
149 | )}
150 | {connected &&
151 | (settingsOpen ? (
152 |
156 | ) : (
157 | <>
158 |
163 |
168 | >
169 | ))}
170 |
171 | );
172 | };
173 |
--------------------------------------------------------------------------------
/devui/src/components/headset.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | Colors,
10 | ControlButtonStyles,
11 | ControlPanel,
12 | FAIcon,
13 | InputSuffix,
14 | SectionBreak,
15 | ValueInput,
16 | ValuesContainer,
17 | } from './styled.js';
18 | import {
19 | faStreetView,
20 | faVideo,
21 | faVrCardboard,
22 | } from '@fortawesome/free-solid-svg-icons';
23 |
24 | import { InputLayer } from '../scene.js';
25 | import React from 'react';
26 | import { Vector3Input } from './vec3.js';
27 | import { XRDevice } from 'iwer';
28 | import { styled } from 'styled-components';
29 |
30 | interface HeadsetProps {
31 | xrDevice: XRDevice;
32 | inputLayer: InputLayer;
33 | pointerLocked: boolean;
34 | }
35 |
36 | const HeadsetOptionContainer = styled.div`
37 | width: 100%;
38 | display: flex;
39 | flex-direction: row;
40 | justify-content: space-between;
41 | align-items: center;
42 | margin-top: ${ControlButtonStyles.gap};
43 | font-size: 12px;
44 | `;
45 |
46 | const RangeSelector = styled.input.attrs({ type: 'range' })`
47 | -webkit-appearance: none;
48 | appearance: none;
49 | background: ${Colors.gradientGrey};
50 | border: 1px solid transparent;
51 | height: 25px;
52 | color: ${Colors.textWhite};
53 | width: ${ControlButtonStyles.widthLong};
54 | cursor: pointer;
55 | margin: 0;
56 | border-radius: 5px;
57 | padding: 0 10px 0 5px;
58 | box-sizing: border-box;
59 | font-size: 10px;
60 |
61 | &::-webkit-slider-thumb {
62 | -webkit-appearance: none;
63 | appearance: none;
64 | width: 8px;
65 | height: 25px;
66 | background-color: ${Colors.textWhite};
67 | border-radius: ${ControlButtonStyles.radiusMiddle};
68 | }
69 |
70 | &::-moz-range-thumb {
71 | width: 8px;
72 | height: 25px;
73 | background-color: ${Colors.textWhite};
74 | border-radius: ${ControlButtonStyles.radiusMiddle};
75 | }
76 |
77 | &::-ms-thumb {
78 | width: 8px;
79 | height: 25px;
80 | background-color: ${Colors.textWhite};
81 | border-radius: ${ControlButtonStyles.radiusMiddle};
82 | }
83 | `;
84 |
85 | export const HeadsetUI: React.FC = ({
86 | xrDevice,
87 | inputLayer,
88 | pointerLocked,
89 | }) => {
90 | const [fovy, setFovy] = React.useState(xrDevice.fovy);
91 | return (
92 |
93 |
102 |
111 |
112 |
{xrDevice.name}
113 |
114 |
121 |
122 |
123 |
127 |
128 | {!pointerLocked && (
129 |
130 |
131 |
132 |
139 |
145 | FOV-Y
146 |
147 |
153 | {
156 | const value = Number(e.target.value);
157 | setFovy(value);
158 | xrDevice.fovy = value;
159 | }}
160 | min={Math.PI / 6}
161 | max={Math.PI / 1.5}
162 | step={Math.PI / 48}
163 | style={{ width: '80px' }}
164 | />
165 |
166 |
167 |
168 | )}
169 |
170 | );
171 | };
172 |
--------------------------------------------------------------------------------
/devui/src/components/keys.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { MouseLeft, MouseRight } from './icons.js';
9 | import {
10 | faAngleUp,
11 | faArrowRightToBracket,
12 | faArrowTurnDown,
13 | faCaretDown,
14 | faCaretLeft,
15 | faCaretRight,
16 | faCaretUp,
17 | faDeleteLeft,
18 | } from '@fortawesome/free-solid-svg-icons';
19 |
20 | import { FAIcon } from './styled.js';
21 |
22 | export const MappedKeyDisplay: {
23 | [keyCode: string]: string | JSX.Element;
24 | } = {
25 | KeyA: 'A',
26 | KeyB: 'B',
27 | KeyC: 'C',
28 | KeyD: 'D',
29 | KeyE: 'E',
30 | KeyF: 'F',
31 | KeyG: 'G',
32 | KeyH: 'H',
33 | KeyI: 'I',
34 | KeyJ: 'J',
35 | KeyK: 'K',
36 | KeyL: 'L',
37 | KeyM: 'M',
38 | KeyN: 'N',
39 | KeyO: 'O',
40 | KeyP: 'P',
41 | KeyQ: 'Q',
42 | KeyR: 'R',
43 | KeyS: 'S',
44 | KeyT: 'T',
45 | KeyU: 'U',
46 | KeyV: 'V',
47 | KeyW: 'W',
48 | KeyX: 'X',
49 | KeyY: 'Y',
50 | KeyZ: 'Z',
51 | Digit0: '0',
52 | Digit1: '1',
53 | Digit2: '2',
54 | Digit3: '3',
55 | Digit4: '4',
56 | Digit5: '5',
57 | Digit6: '6',
58 | Digit7: '7',
59 | Digit8: '8',
60 | Digit9: '9',
61 | Tab: ,
62 | Backspace: ,
63 | Enter: (
64 |
70 | ),
71 | ShiftLeft: ,
72 | ShiftRight: ,
73 | Space: ' ',
74 | ArrowUp: ,
75 | ArrowDown: ,
76 | ArrowLeft: ,
77 | ArrowRight: ,
78 | Semicolon: ';',
79 | Equal: '=',
80 | Comma: ',',
81 | Minus: '-',
82 | Period: '.',
83 | Slash: '/',
84 | Backquote: '`',
85 | BracketLeft: '[',
86 | Backslash: '\\',
87 | BracketRight: ']',
88 | Quote: "'",
89 | MouseLeft: ,
90 | MouseRight: ,
91 | };
92 |
--------------------------------------------------------------------------------
/devui/src/components/pinch.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | Button,
10 | ButtonContainer,
11 | ButtonGroup,
12 | Colors,
13 | ControlButtonStyles,
14 | FAControlIcon,
15 | MappedKeyBlock,
16 | RangeSelector,
17 | } from './styled.js';
18 | import React, { useEffect, useState } from 'react';
19 |
20 | import { MappedKeyDisplay } from './keys.js';
21 | import { XRHandInput } from 'iwer/lib/device/XRHandInput.js';
22 | import { faHandLizard } from '@fortawesome/free-solid-svg-icons';
23 |
24 | interface PinchControlProps {
25 | hand: XRHandInput;
26 | pointerLocked: boolean;
27 | mappedKey: string;
28 | }
29 |
30 | const pinchSliderWidth = `calc(${ControlButtonStyles.widthLong} + ${ControlButtonStyles.widthShort} + ${ControlButtonStyles.gap})`;
31 |
32 | export const PinchControl: React.FC = ({
33 | hand,
34 | pointerLocked,
35 | mappedKey,
36 | }) => {
37 | const [isPressed, setIsPressed] = useState(false);
38 | const [isKeyPressed, setIsKeyPressed] = useState(false);
39 | const [analogValue, setAnalogValue] = useState(0);
40 |
41 | const handedness = hand.inputSource.handedness;
42 |
43 | useEffect(() => {
44 | const handleKeyDown = (event: KeyboardEvent) => {
45 | if (event.code === mappedKey) {
46 | hand.updatePinchValue(1);
47 | setIsKeyPressed(true);
48 | }
49 | };
50 |
51 | const handleKeyUp = (event: KeyboardEvent) => {
52 | if (event.code === mappedKey) {
53 | hand.updatePinchValue(0);
54 | setIsKeyPressed(false);
55 | }
56 | };
57 |
58 | const handleMouseDown = (event: MouseEvent) => {
59 | if (
60 | (mappedKey === 'MouseLeft' && event.button === 0) ||
61 | (mappedKey === 'MouseRight' && event.button === 2)
62 | ) {
63 | hand.updatePinchValue(1);
64 | setIsKeyPressed(true);
65 | }
66 | };
67 |
68 | const handleMouseUp = (event: MouseEvent) => {
69 | if (
70 | (mappedKey === 'MouseLeft' && event.button === 0) ||
71 | (mappedKey === 'MouseRight' && event.button === 2)
72 | ) {
73 | hand.updatePinchValue(0);
74 | setIsKeyPressed(false);
75 | }
76 | };
77 |
78 | if (pointerLocked) {
79 | if (mappedKey === 'MouseLeft' || mappedKey === 'MouseRight') {
80 | window.addEventListener('mousedown', handleMouseDown);
81 | window.addEventListener('mouseup', handleMouseUp);
82 | } else {
83 | window.addEventListener('keydown', handleKeyDown);
84 | window.addEventListener('keyup', handleKeyUp);
85 | }
86 | } else {
87 | if (mappedKey === 'MouseLeft' || mappedKey === 'MouseRight') {
88 | window.removeEventListener('mousedown', handleMouseDown);
89 | window.removeEventListener('mouseup', handleMouseUp);
90 | } else {
91 | window.removeEventListener('keydown', handleKeyDown);
92 | window.removeEventListener('keyup', handleKeyUp);
93 | }
94 | }
95 |
96 | return () => {
97 | if (mappedKey === 'MouseLeft' || mappedKey === 'MouseRight') {
98 | window.removeEventListener('mousedown', handleMouseDown);
99 | window.removeEventListener('mouseup', handleMouseUp);
100 | } else {
101 | window.removeEventListener('keydown', handleKeyDown);
102 | window.removeEventListener('keyup', handleKeyUp);
103 | }
104 | };
105 | }, [mappedKey, pointerLocked, hand]);
106 |
107 | return (
108 |
109 |
110 |
111 | {pointerLocked ? (
112 |
113 | {MappedKeyDisplay[mappedKey]}
114 |
115 | ) : (
116 | <>
117 | {
126 | setIsPressed(true);
127 | hand.updatePinchValue(1);
128 | setTimeout(() => {
129 | setIsPressed(false);
130 | hand.updatePinchValue(0);
131 | }, 250);
132 | }}
133 | >
134 | Pinch
135 |
136 | {
140 | const value = Number(e.target.value);
141 | setAnalogValue(value);
142 | hand.updatePinchValue(value / 100);
143 | }}
144 | style={{ width: pinchSliderWidth }}
145 | min="0"
146 | max="100"
147 | />
148 | >
149 | )}
150 |
151 |
152 | );
153 | };
154 |
--------------------------------------------------------------------------------
/devui/src/components/pose.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | Button,
10 | ButtonContainer,
11 | ButtonGroup,
12 | ControlButtonStyles,
13 | FAControlIcon,
14 | FAIcon,
15 | MappedKeyBlock,
16 | } from './styled.js';
17 | import React, { useEffect, useState } from 'react';
18 | import {
19 | faChevronLeft,
20 | faChevronRight,
21 | faHandScissors,
22 | } from '@fortawesome/free-solid-svg-icons';
23 |
24 | import { MappedKeyDisplay } from './keys.js';
25 | import { XRHandInput } from 'iwer/lib/device/XRHandInput.js';
26 |
27 | interface PoseSelectorProps {
28 | hand: XRHandInput;
29 | pointerLocked: boolean;
30 | mappedKey: string;
31 | }
32 |
33 | const poses = ['default', 'point'];
34 |
35 | const poseButtonWidth = `calc(2 * ${ControlButtonStyles.widthLong} - ${ControlButtonStyles.widthShort})`;
36 |
37 | export const PoseSelector: React.FC = ({
38 | hand,
39 | pointerLocked,
40 | mappedKey,
41 | }) => {
42 | const [poseId, setPoseId] = useState(hand.poseId);
43 | const [isKeyPressed, setIsKeyPressed] = useState(false);
44 |
45 | const handedness = hand.inputSource.handedness;
46 | const cyclePose = (delta: number) => {
47 | const poseIdx = poses.indexOf(hand.poseId);
48 | const newPoseIdx = (poseIdx + poses.length + delta) % poses.length;
49 | setPoseId(poses[newPoseIdx]);
50 | hand.poseId = poses[newPoseIdx];
51 | };
52 | const layoutReverse = handedness === 'right';
53 |
54 | useEffect(() => {
55 | const handleKeyDown = (event: KeyboardEvent) => {
56 | if (event.code === mappedKey) {
57 | cyclePose(1);
58 | setIsKeyPressed(true);
59 | }
60 | };
61 |
62 | const handleKeyUp = (event: KeyboardEvent) => {
63 | if (event.code === mappedKey) {
64 | setIsKeyPressed(false);
65 | }
66 | };
67 |
68 | if (pointerLocked) {
69 | window.addEventListener('keydown', handleKeyDown);
70 | window.addEventListener('keyup', handleKeyUp);
71 | } else {
72 | window.removeEventListener('keydown', handleKeyDown);
73 | window.removeEventListener('keyup', handleKeyUp);
74 | }
75 |
76 | return () => {
77 | window.removeEventListener('keydown', handleKeyDown);
78 | window.removeEventListener('keyup', handleKeyUp);
79 | };
80 | }, [mappedKey, pointerLocked, hand]);
81 |
82 | return (
83 |
84 |
85 |
86 | {pointerLocked ? (
87 |
88 | {MappedKeyDisplay[mappedKey]}
89 |
90 | ) : (
91 | <>
92 | {
98 | cyclePose(layoutReverse ? 1 : -1);
99 | }}
100 | >
101 |
102 |
103 |
110 | Pose: {poseId}
111 |
112 | {
118 | cyclePose(layoutReverse ? -1 : 1);
119 | }}
120 | >
121 |
122 |
123 | >
124 | )}
125 |
126 |
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/devui/src/components/vec3.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { FAIcon, InputSuffix, ValueInput, ValuesContainer } from './styled.js';
9 | import { useEffect, useRef, useState } from 'react';
10 |
11 | import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
12 | import { styled } from 'styled-components';
13 |
14 | export function round(number: number, decimalPlaces: number) {
15 | const factor = Math.pow(10, decimalPlaces);
16 | return Math.round(number * factor) / factor;
17 | }
18 |
19 | type Axis = 'x' | 'y' | 'z';
20 |
21 | type Vector3Like = {
22 | x: number;
23 | y: number;
24 | z: number;
25 | };
26 |
27 | interface Vector3InputProps {
28 | vector: Vector3Like;
29 | label?: string;
30 | icon?: IconDefinition;
31 | multiplier?: number;
32 | precision?: number;
33 | onValidInput?: () => void;
34 | marginBottom?: string;
35 | }
36 |
37 | const Vector3Container = styled.div`
38 | width: 100%;
39 | display: flex;
40 | flex-direction: row;
41 | justify-content: space-between;
42 | align-items: center;
43 | margin: 0;
44 | font-size: 12px;
45 | `;
46 |
47 | export const Vector3Input = ({
48 | vector,
49 | label = '',
50 | icon,
51 | multiplier = 1,
52 | precision = 2,
53 | onValidInput = () => {},
54 | marginBottom = '0',
55 | }: Vector3InputProps) => {
56 | const [displayValues, setDisplayValues] = useState({
57 | x: (vector.x / multiplier).toFixed(precision),
58 | y: (vector.y / multiplier).toFixed(precision),
59 | z: (vector.z / multiplier).toFixed(precision),
60 | });
61 |
62 | const actualValuesRef = useRef({
63 | x: round(vector.x / multiplier, precision),
64 | y: round(vector.y / multiplier, precision),
65 | z: round(vector.z / multiplier, precision),
66 | });
67 |
68 | const animationFrameId = useRef(null);
69 |
70 | // Sync display values with actual values (optimized)
71 | const syncValues = () => {
72 | const currentActualValues = {
73 | x: round(vector.x / multiplier, precision),
74 | y: round(vector.y / multiplier, precision),
75 | z: round(vector.z / multiplier, precision),
76 | };
77 |
78 | const { x, y, z } = actualValuesRef.current;
79 |
80 | // Only update state if actual values have changed
81 | if (
82 | currentActualValues.x !== x ||
83 | currentActualValues.y !== y ||
84 | currentActualValues.z !== z
85 | ) {
86 | actualValuesRef.current = currentActualValues;
87 | setDisplayValues({
88 | x: currentActualValues.x.toFixed(precision),
89 | y: currentActualValues.y.toFixed(precision),
90 | z: currentActualValues.z.toFixed(precision),
91 | });
92 | }
93 |
94 | // Schedule the next frame
95 | animationFrameId.current = requestAnimationFrame(syncValues);
96 | };
97 |
98 | useEffect(() => {
99 | // Start the synchronization loop
100 | animationFrameId.current = requestAnimationFrame(syncValues);
101 |
102 | return () => {
103 | // Cleanup the animation frame on unmount
104 | if (animationFrameId.current) {
105 | cancelAnimationFrame(animationFrameId.current);
106 | }
107 | };
108 | }, [vector, multiplier, precision]);
109 |
110 | // Handle user input changes
111 | const handleInputChange =
112 | (axis: Axis) => (event: React.ChangeEvent) => {
113 | const newValue = event.target.value;
114 | const parsedValue = parseFloat(newValue);
115 |
116 | // Update display values immediately
117 | setDisplayValues((prev) => ({ ...prev, [axis]: newValue }));
118 |
119 | // If valid, update the actual values and the vector
120 | if (!isNaN(parsedValue)) {
121 | actualValuesRef.current[axis] = parsedValue;
122 | vector[axis] = parsedValue * multiplier;
123 | onValidInput();
124 | }
125 | };
126 |
127 | return (
128 |
129 | {icon ? (
130 |
131 | ) : (
132 | {label}
133 | )}
134 |
135 | {['x', 'y', 'z'].map((axis) => (
136 |
144 |
154 | {axis.toUpperCase()}
155 |
156 | ))}
157 |
158 |
159 | );
160 | };
161 |
--------------------------------------------------------------------------------
/devui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "outDir": "./lib",
6 | },
7 | "include": ["src/**/*"]
8 | }
--------------------------------------------------------------------------------
/docs/.vitepress/config.ts:
--------------------------------------------------------------------------------
1 | import { VERSION } from '../../src/version';
2 | import { defineConfig } from 'vitepress';
3 |
4 | // https://vitepress.dev/reference/site-config
5 | export default defineConfig({
6 | title: 'IWER',
7 | description: 'Javascript WebXR Runtime Library for Emulation',
8 | head: [
9 | [
10 | 'link',
11 | { rel: 'icon', href: '/immersive-web-emulation-runtime/iwer.png' },
12 | ],
13 | ],
14 | base: '/immersive-web-emulation-runtime/',
15 | themeConfig: {
16 | // https://vitepress.dev/reference/default-theme-config
17 | logo: { light: '/iwer-dark.svg', dark: '/iwer.svg' },
18 | nav: [
19 | { text: 'About', link: '/about' },
20 | { text: 'Guide', link: '/getting-started' },
21 | {
22 | text: 'Terms',
23 | link: 'https://opensource.fb.com/legal/terms/',
24 | },
25 | {
26 | text: 'Privacy',
27 | link: 'https://opensource.fb.com/legal/privacy/',
28 | },
29 | {
30 | text: `v${VERSION}`,
31 | items: [
32 | { text: 'NPM', link: 'https://www.npmjs.com/package/iwer' },
33 | {
34 | text: 'License',
35 | link: 'https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/LICENSE',
36 | },
37 | ],
38 | },
39 | ],
40 |
41 | sidebar: [
42 | {
43 | text: 'Getting Started',
44 | items: [
45 | { text: 'About IWER', link: '/about' },
46 | {
47 | text: 'Installation',
48 | link: '/getting-started#adding-iwer-to-your-project',
49 | },
50 | {
51 | text: 'Runtime Injection',
52 | link: '/getting-started#creating-an-xrdevice-and-installing-the-runtime',
53 | },
54 | {
55 | text: '🥽 Emulated Headset',
56 | link: '/getting-started#emulated-headset',
57 | },
58 | {
59 | text: '🎮 Emulated Controllers',
60 | link: '/getting-started#emulated-controllers',
61 | },
62 | {
63 | text: '🖐️ Emulated Hands',
64 | link: '/getting-started#emulated-hands',
65 | },
66 | {
67 | text: 'Platform Features',
68 | link: '/getting-started#platform-features',
69 | },
70 | {
71 | text: 'XPlat Controls',
72 | link: '/xplat',
73 | },
74 | ],
75 | },
76 | {
77 | text: 'IWER in Action',
78 | link: '/action#action-recording-playback',
79 | items: [
80 | { text: 'Live Demo', link: '/action#live-webxr-demo' },
81 | { text: 'Action Recording', link: '/action#how-does-recording-work' },
82 | { text: 'Action Playback', link: '/action#how-does-playback-work' },
83 | ],
84 | },
85 | {
86 | text: 'API Reference',
87 | items: [
88 | { text: 'XRDevice Class', link: '/api/xr-device' },
89 | { text: 'XRController Class', link: '/api/xr-controller' },
90 | { text: 'XRHandInput Class', link: '/api/xr-hand-input' },
91 | { text: 'Config Interfaces', link: '/api/config-interfaces' },
92 | ],
93 | },
94 | ],
95 |
96 | socialLinks: [
97 | {
98 | icon: 'github',
99 | link: 'https://github.com/meta-quest/immersive-web-emulation-runtime/',
100 | },
101 | ],
102 |
103 | search: {
104 | provider: 'local',
105 | },
106 |
107 | footer: {
108 | copyright: 'MIT License | Copyright © Meta Platforms, Inc',
109 | },
110 | },
111 | });
112 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | // https://vitepress.dev/guide/custom-theme
2 | import { h } from 'vue'
3 | import type { Theme } from 'vitepress'
4 | import DefaultTheme from 'vitepress/theme'
5 | import './style.css'
6 |
7 | export default {
8 | extends: DefaultTheme,
9 | Layout: () => {
10 | return h(DefaultTheme.Layout, null, {
11 | // https://vitepress.dev/guide/extending-default-theme#layout-slots
12 | })
13 | },
14 | enhanceApp({ app, router, siteData }) {
15 | // ...
16 | }
17 | } satisfies Theme
18 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/style.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Customize default theme styling by overriding CSS variables:
3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
4 | */
5 |
6 | /**
7 | * Colors
8 | *
9 | * Each colors have exact same color scale system with 3 levels of solid
10 | * colors with different brightness, and 1 soft color.
11 | *
12 | * - `XXX-1`: The most solid color used mainly for colored text. It must
13 | * satisfy the contrast ratio against when used on top of `XXX-soft`.
14 | *
15 | * - `XXX-2`: The color used mainly for hover state of the button.
16 | *
17 | * - `XXX-3`: The color for solid background, such as bg color of the button.
18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on
19 | * top of it.
20 | *
21 | * - `XXX-soft`: The color used for subtle background such as custom container
22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
23 | * on top of it.
24 | *
25 | * The soft color must be semi transparent alpha channel. This is crucial
26 | * because it allows adding multiple "soft" colors on top of each other
27 | * to create a accent, such as when having inline code block inside
28 | * custom containers.
29 | *
30 | * - `default`: The color used purely for subtle indication without any
31 | * special meanings attched to it such as bg color for menu hover state.
32 | *
33 | * - `brand`: Used for primary brand colors, such as link text, button with
34 | * brand theme, etc.
35 | *
36 | * - `tip`: Used to indicate useful information. The default theme uses the
37 | * brand color for this by default.
38 | *
39 | * - `warning`: Used to indicate warning to the users. Used in custom
40 | * container, badges, etc.
41 | *
42 | * - `danger`: Used to show error, or dangerous message to the users. Used
43 | * in custom container, badges, etc.
44 | * -------------------------------------------------------------------------- */
45 |
46 | :root {
47 | --vp-c-default-1: var(--vp-c-gray-1);
48 | --vp-c-default-2: var(--vp-c-gray-2);
49 | --vp-c-default-3: var(--vp-c-gray-3);
50 | --vp-c-default-soft: var(--vp-c-gray-soft);
51 |
52 | --vp-c-brand-1: var(--vp-c-indigo-1);
53 | --vp-c-brand-2: var(--vp-c-indigo-2);
54 | --vp-c-brand-3: var(--vp-c-indigo-3);
55 | --vp-c-brand-soft: var(--vp-c-indigo-soft);
56 |
57 | --vp-c-tip-1: var(--vp-c-brand-1);
58 | --vp-c-tip-2: var(--vp-c-brand-2);
59 | --vp-c-tip-3: var(--vp-c-brand-3);
60 | --vp-c-tip-soft: var(--vp-c-brand-soft);
61 |
62 | --vp-c-warning-1: var(--vp-c-yellow-1);
63 | --vp-c-warning-2: var(--vp-c-yellow-2);
64 | --vp-c-warning-3: var(--vp-c-yellow-3);
65 | --vp-c-warning-soft: var(--vp-c-yellow-soft);
66 |
67 | --vp-c-danger-1: var(--vp-c-red-1);
68 | --vp-c-danger-2: var(--vp-c-red-2);
69 | --vp-c-danger-3: var(--vp-c-red-3);
70 | --vp-c-danger-soft: var(--vp-c-red-soft);
71 | }
72 |
73 | /**
74 | * Component: Button
75 | * -------------------------------------------------------------------------- */
76 |
77 | :root {
78 | --vp-button-brand-border: transparent;
79 | --vp-button-brand-text: var(--vp-c-white);
80 | --vp-button-brand-bg: var(--vp-c-brand-3);
81 | --vp-button-brand-hover-border: transparent;
82 | --vp-button-brand-hover-text: var(--vp-c-white);
83 | --vp-button-brand-hover-bg: var(--vp-c-brand-2);
84 | --vp-button-brand-active-border: transparent;
85 | --vp-button-brand-active-text: var(--vp-c-white);
86 | --vp-button-brand-active-bg: var(--vp-c-brand-1);
87 | }
88 |
89 | /**
90 | * Component: Home
91 | * -------------------------------------------------------------------------- */
92 |
93 | :root {
94 | --vp-home-hero-name-color: transparent;
95 | --vp-home-hero-name-background: -webkit-linear-gradient(
96 | 120deg,
97 | #bd34fe 30%,
98 | #41d1ff
99 | );
100 |
101 | --vp-home-hero-image-background-image: linear-gradient(
102 | -45deg,
103 | #bd34fe 50%,
104 | #47caff 50%
105 | );
106 | --vp-home-hero-image-filter: blur(44px);
107 | }
108 |
109 | @media (min-width: 640px) {
110 | :root {
111 | --vp-home-hero-image-filter: blur(56px);
112 | }
113 | }
114 |
115 | @media (min-width: 960px) {
116 | :root {
117 | --vp-home-hero-image-filter: blur(68px);
118 | }
119 | }
120 |
121 | /**
122 | * Component: Custom Block
123 | * -------------------------------------------------------------------------- */
124 |
125 | :root {
126 | --vp-custom-block-tip-border: transparent;
127 | --vp-custom-block-tip-text: var(--vp-c-text-1);
128 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft);
129 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
130 | }
131 |
132 | /**
133 | * Component: Algolia
134 | * -------------------------------------------------------------------------- */
135 |
136 | .DocSearch {
137 | --docsearch-primary-color: var(--vp-c-brand-1) !important;
138 | }
139 |
140 |
--------------------------------------------------------------------------------
/docs/about.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | title: About
4 | ---
5 |
6 | # What is IWER?
7 |
8 | The Immersive Web Emulation Runtime **(IWER)** is a TypeScript-based tool designed to enable the running of WebXR applications in modern browsers without requiring native WebXR support. By emulating the WebXR Device API, IWER provides developers with the ability to test and run WebXR projects across a wide range of devices, ensuring compatibility and enhancing the development process.
9 |
10 | IWER utilizes a universal `XRDevice` interface, configurable to emulate the vast majority of XR hardware with WebXR support, to offer direct and comprehensive control over the XR device in the emulated WebXR experience; Its potential applications include:
11 |
12 | - **Custom Development Tools**: Empowers developers to craft specific WebXR tools tailored to their project requirements, bypassing the need for browser extensions.
13 | - **Cross-Platform Compatibility**: Functions as an input remapping layer over existing WebXR projects, facilitating seamless cross-platform functionality and allowing the recycling of XR interaction codes.
14 | - **Scalable Testing Solutions**: Offers action capture and playback features that unlocks automated testing of WebXR projects for the first time in environments without the necessity for physical headsets, enhancing testing accessibility and scalability.
15 |
--------------------------------------------------------------------------------
/docs/action.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | title: IWER in Action
4 | ---
5 |
6 | # IWER in Action
7 |
8 | The following section introduces the Immersive Web Emulation Runtime (IWER) implemented within a three.js-powered WebXR demo. This demo smartly detects whether you are accessing from a browser lacking native WebXR support and automatically installs IWER's WebXR runtime, allowing entry into XR in emulation mode. In this mode, a simple GUI at the top right corner lets you interact with a selection of IWER's features like toggling stereo rendering and managing XRVisibilityState, alongside exploring an **experimental input playback** feature.
9 |
10 | ## Live WebXR Demo
11 |
12 | Below is the live demo which uses three.js. It includes preloaded actions captured on a Meta Quest 3 device. For those with WebXR-ready browsers, it's possible to capture your own actions within this demo environment.
13 |
14 |
22 |
23 | ## Comparative Video
24 |
25 | Here's a screen recording from the same session captured for the demo, providing a visual reference of the actions included in the live demo.
26 |
27 |
28 |
29 | Your browser does not support the video tag.
30 |
31 |
32 | ## Action Recording & Playback
33 |
34 | Recording and replaying user actions in WebXR are some of the most requested features among the WebXR developer community for their potential to simplify debugging and automate testing. The primary use cases include:
35 |
36 | - **Debugging**: Capture user actions that lead to issues or crashes in your WebXR apps. Replay these actions in a developer-friendly environment to robustly reproduce and troubleshoot issues.
37 | - **Automated Testing**: Automate end-to-end testing, reducing the reliance on manual QA, which is traditionally labor-intensive for WebXR applications.
38 | - **Motion Capture**: Record brief sequences of user actions to generate motion capture data, enhancing the realism and interactivity of your WebXR content.
39 |
40 | ### How Does Recording Work?
41 |
42 | IWER’s `ActionRecorder` functions independently from its WebXR runtime and is typically utilized in environments where native WebXR support is available. During each animation frame, `ActionRecorder` captures data directly from the `XRSession` and `XRFrame`. This includes recording the transforms of the headset and input sources relative to the root `XRReferenceSpace`. It also captures the component states within the input sources, such as button presses and joystick movements from the associated `Gamepad` objects.
43 |
44 | #### Handling Input Source Switching
45 |
46 | To handle scenarios where input sources may change during a session, `ActionRecorder` listens for input source events. Upon detecting a new input source, it constructs an input schema that includes details like the profile ID and input type, and then it starts tracking data for that input source.
47 |
48 | ### How to Record an Input Session?
49 |
50 | 1. **Initialize the Recorder**:
51 |
52 | ```javascript
53 | import { ActionRecorder } from 'iwer';
54 |
55 | onSessionStarted(xrSession) {
56 | const refSpace = await xrSession.requestReferenceSpace('local-floor');
57 | recorder = new ActionRecorder(xrSession, refSpace);
58 | }
59 | ```
60 |
61 | 2. **Start / Stop the Recording**:
62 |
63 | ```javascript
64 | let recording = false;
65 |
66 | onButtonPress() {
67 | recording = !recording; // Toggle recording state
68 | }
69 |
70 | onFrame(xrFrame) {
71 | if (recording) {
72 | recorder.recordFrame(xrFrame);
73 | }
74 | }
75 | ```
76 |
77 | 3. **Access the Recorded Data**:
78 | ```javascript
79 | recorder.log(); // Outputs the recorded data as a JSON string in the console
80 | ```
81 |
82 | ### How does Playback Work?
83 |
84 | `ActionPlayer`, unlike `ActionRecorder`, integrates closely with IWER’s WebXR runtime and connects directly to the `XRDevice` class. It uses the root `XRReferenceSpace` from your WebXR app to accurately reconstruct input data for playback.
85 |
86 | #### Achieving Playback Accuracy
87 |
88 | Due to hardware and framerate variations, achieving perfect playback accuracy can be challenging. For example, discrepancies in frame rates between recording (e.g., 72 fps) and playback (e.g., 60 fps) devices can cause subtle speed variations in the playback of recorded sessions. `ActionPlayer` mitigates this by interpolating between frames based on timestamps to maintain consistent motion during playback.
89 |
90 | ### How to Play a Captured Session?
91 |
92 | 1. **Initialize the Player**:
93 |
94 | ```javascript
95 | let capture = JSON.parse(capturedJSONString);
96 | let player;
97 |
98 | onSessionStarted(xrSession) {
99 | player = xrdevice.createActionPlayer(refSpace, capture);
100 | }
101 | ```
102 |
103 | 2. **Control Playback**:
104 | ```javascript
105 | player.play(); // Starts playback from the beginning
106 | player.stop(); // Stops the playback
107 | ```
108 |
--------------------------------------------------------------------------------
/docs/api/config-interfaces.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | ---
4 |
5 | # Configuration Interfaces
6 |
7 | ## XRDeviceConfig
8 |
9 | - **`name`**: **string**
10 | The name of the XR device.
11 |
12 | - **`controllerConfig`**: **XRControllerConfig | undefined**
13 | Configuration for the device's controllers. It is `undefined` if no controllers are used.
14 |
15 | - **`supportedSessionModes`**: **XRSessionMode[]**
16 | Session modes the device supports (e.g., `inline`, `immersive-vr`, `immersive-ar`).
17 |
18 | - **`supportedFeatures`**: **WebXRFeatures[]**
19 | Features supported by the device, such as `hand-tracking`, `hit-test`, etc.
20 |
21 | - **`supportedFrameRates`**: **number[]**
22 | Frame rates supported by the device for rendering.
23 |
24 | - **`isSystemKeyboardSupported`**: **boolean**
25 | Indicates if system keyboard input is supported.
26 |
27 | - **`internalNominalFrameRate`**: **number**
28 | The device's nominal internal frame rate.
29 |
30 | ## XRDeviceOptions
31 |
32 | - **`ipd`**: **number**
33 | Interpupillary distance in meters, affecting stereo rendering.
34 |
35 | - **`fovy`**: **number**
36 | Field of view on the Y-axis in radians.
37 |
38 | - **`stereoEnabled`**: **boolean**
39 | Enables or disables stereo rendering.
40 |
41 | - **`headsetPosition`**: **Vector3**
42 | The initial position of the headset in 3D space.
43 |
44 | - **`headsetQuaternion`**: **Quaternion**
45 | The initial orientation of the headset.
46 |
47 | - **`canvasContainer`**: **HTMLDivElement**
48 | The HTML container for the rendering canvas.
49 |
50 | ## XRControllerConfig
51 |
52 | - **`profileId`**: **string**
53 | Identifier for the controller profile.
54 |
55 | - **`fallbackProfileIds`**: **string[]**
56 | Identifiers for fallback controller profiles.
57 |
58 | - **`layout`**: **{ [handedness in XRHandedness]?: { gamepad: GamepadConfig; gripOffsetMatrix?: mat4; numHapticActuators: number; } }**
59 | Maps handedness to controller configurations, including gamepad layout and optional grip offset.
60 |
61 | ## HandPose
62 |
63 | - **`jointTransforms`**: **{ [joint in XRHandJoint]: { offsetMatrix: mat4; radius: number; } }**
64 | Pose data for each joint in the hand, including transformation matrix and joint radius.
65 |
66 | - **`gripOffsetMatrix`**: **mat4?**
67 | Optional matrix to offset the grip position.
68 |
69 | ## XRHandInputConfig
70 |
71 | - **`profileId`**: **string**
72 | Identifier for the hand input profile.
73 |
74 | - **`fallbackProfileIds`**: **string[]**
75 | Identifiers for fallback hand input profiles.
76 |
77 | - **`poses`**: **{ default: HandPose; pinch: HandPose; [poseId: string]: HandPose; }**
78 | Contains hand pose configurations for default, pinch, and additional customizable poses.
79 |
--------------------------------------------------------------------------------
/docs/api/xr-controller.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | ---
4 |
5 | # XRController Class
6 |
7 | The `XRController` class represents an emulated XR input device, such as a handheld controller, used within WebXR projects.
8 |
9 | ## Properties
10 |
11 | Properties of the `XRController` class provide access to the controller's current state, including its connection status, position, orientation, and the associated input source.
12 |
13 | ### `connected`
14 |
15 | Indicates whether the controller is currently connected and active.
16 |
17 | - **Type**: `boolean`
18 |
19 | ### `position`
20 |
21 | Provides the current position of the controller in the 3D space, enabling position tracking within the virtual environment.
22 |
23 | - **Type**: `Vector3`
24 | - **Readonly**
25 |
26 | ### `quaternion`
27 |
28 | Describes the current orientation of the controller as a quaternion, essential for accurate directional tracking.
29 |
30 | - **Type**: `Quaternion`
31 | - **Readonly**
32 |
33 | ### `inputSource`
34 |
35 | Accesses the underlying `XRInputSource` object for this controller, linking it to the broader XR input system.
36 |
37 | - **Type**: `XRInputSource`
38 | - **Readonly**
39 |
40 | ## Methods
41 |
42 | Methods of the `XRController` class allow for dynamic updates to the controller's state, simulating user interactions such as button presses, touch, and movement along axes.
43 |
44 | ### `updateButtonValue`
45 |
46 | Simulates the press or release of a button on the controller by updating its value.
47 |
48 | ```typescript
49 | updateButtonValue(id: string, value: number): void
50 | ```
51 |
52 | - `id`: **string** - The identifier for the button to update.
53 | - `value`: **number** - The new value for the button, with `0` indicating unpressed and `1` indicating fully pressed.
54 |
55 | ### `updateButtonTouch`
56 |
57 | Simulates touching or releasing a touch-sensitive button on the controller.
58 |
59 | ```typescript
60 | updateButtonTouch(id: string, touched: boolean): void
61 | ```
62 |
63 | - `id`: **string** - The identifier for the button to update.
64 | - `touched`: **boolean** - Indicates whether the button is being touched.
65 |
66 | ### `updateAxis`
67 |
68 | Updates the value of a specific axis, such as a thumbstick or touchpad direction.
69 |
70 | ```typescript
71 | updateAxis(id: string, type: 'x-axis' | 'y-axis', value: number): void
72 | ```
73 |
74 | - `id`: **string** - The identifier for the axis group to update.
75 | - `type`: **'x-axis' | 'y-axis'** - Specifies which axis to update.
76 | - `value`: **number** - The new value for the axis, between `-1` (full negative) and `1` (full positive).
77 |
78 | ### `updateAxes`
79 |
80 | Simultaneously updates the values of both axes for a specific control element on the controller.
81 |
82 | ```typescript
83 | updateAxes(id: string, x: number, y: number): void
84 | ```
85 |
86 | - `id`: **string** - The identifier for the axis group to update.
87 | - `x`: **number** - The new value for the x-axis.
88 | - `y`: **number** - The new value for the y-axis.
89 |
--------------------------------------------------------------------------------
/docs/api/xr-hand-input.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | ---
4 |
5 | # XRHandInput Class
6 |
7 | The `XRHandInput` class emulates hand input within WebXR environments. It supports detailed hand pose emulation and gesture recognition, allowing for immersive interactions within the virtual space.
8 |
9 | ## Properties
10 |
11 | ### `poseId`
12 |
13 | Specifies the current hand pose being emulated, such as `default`, `pinch`, or other custom poses defined in the configuration.
14 |
15 | - **Type**: `string`
16 | - Allows dynamic update to the hand pose through a setter. Changing the `poseId` triggers an update in the hand's pose based on the configuration associated with the new pose ID.
17 |
18 | ### `pinchValue`
19 |
20 | Indicates the current level of the pinch gesture being emulated, with a range from `0` (no pinch) to `1` (full pinch).
21 |
22 | - **Type**: `number`
23 | - **Readonly**
24 |
25 | ### `connected`
26 |
27 | Reflects the connection status of the hand input, showing whether it's actively recognized and tracked within the XR session.
28 |
29 | - **Type**: `boolean`
30 |
31 | ### `position`
32 |
33 | The current 3D position of the hand input, allowing for movement tracking within the virtual environment.
34 |
35 | - **Type**: `Vector3`
36 | - **Readonly**
37 |
38 | ### `quaternion`
39 |
40 | The current orientation of the hand input, represented as a quaternion for accurate gesture and direction tracking.
41 |
42 | - **Type**: `Quaternion`
43 | - **Readonly**
44 |
45 | ### `inputSource`
46 |
47 | Provides access to the `XRInputSource` associated with this hand input, integrating it with the XR input system.
48 |
49 | - **Type**: `XRInputSource`
50 | - **Readonly**
51 |
52 | ## Methods
53 |
54 | ### `updateHandPose`
55 |
56 | Executes the pose update logic, adjusting the virtual hand's current pose based on the active `poseId` and `pinchValue`. This method facilitates the interpolation between different hand poses and pinch gestures for dynamic hand gesture emulation.
57 |
58 | ```typescript
59 | updateHandPose(): void
60 | ```
61 |
62 | ### `updatePinchValue`
63 |
64 | Modifies the emulation level of the pinch gesture. This method allows for the simulation of pinch gestures to various degrees, enhancing interaction realism within the virtual environment.
65 |
66 | ```typescript
67 | updatePinchValue(value: number): void
68 | ```
69 |
70 | - `value`: **number** - The new pinch intensity, where `0` indicates no pinch and `1` indicates a fully engaged pinch gesture.
71 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | hero:
6 | name: Immersive Web Emulation Runtime
7 | text: 🔓 Unlock WebXR Emulation Everywhere
8 | actions:
9 | - theme: brand
10 | text: Getting Started
11 | link: /getting-started
12 | - theme: alt
13 | text: API Reference
14 | link: /api/xr-device
15 |
16 | features:
17 | - title: 🌐 Emulate WebXR Anywhere
18 | details: IWER provides a comprehensive WebXR runtime that unlocks WebXR emulation on any browser, enabling developers to create bespoke WebXR development tools. Develop seamlessly across all browsers, no native WebXR needed.
19 | linkText: Get Started
20 | link: /getting-started
21 | - title: 🎮 Recycle XR Controls across Platforms
22 | details: IWER is a lightweight, high-performance layer capable of input remapping directly atop your WebXR projects. It enables seamless recycling of your XR input stack, enhancing cross-platform compatibility without the overhead.
23 | linkText: Read Story
24 | link: /xplat
25 | - title: 🎥 Action Capture and Playback
26 | details: With IWER, automated testing in WebXR becomes a breeze. Capture user interactions within XR environments and replay them across various devices effortlessly, thanks to our action capture and playback feature.
27 | linkText: Live Demo
28 | link: /action
29 | ---
30 |
--------------------------------------------------------------------------------
/docs/public/cap.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/cap.mp4
--------------------------------------------------------------------------------
/docs/public/iwer-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docs/public/iwer-locomotion.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/iwer-locomotion.gif
--------------------------------------------------------------------------------
/docs/public/iwer-open-menu.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/iwer-open-menu.gif
--------------------------------------------------------------------------------
/docs/public/iwer-photo-drop.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/iwer-photo-drop.gif
--------------------------------------------------------------------------------
/docs/public/iwer-seed-selection.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/iwer-seed-selection.gif
--------------------------------------------------------------------------------
/docs/public/iwer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meta-quest/immersive-web-emulation-runtime/4b05359a07f9b92086bc09d3e5be0971b2e01dd1/docs/public/iwer.png
--------------------------------------------------------------------------------
/docs/public/iwer.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docs/xplat.md:
--------------------------------------------------------------------------------
1 | ---
2 | outline: deep
3 | title: Cross-Platform Controls with IWER
4 | ---
5 |
6 | # Bringing Project Flowerbed to PC: Cross-Platform Controls with IWER
7 |
8 | ## Motivation
9 |
10 | The web’s inherent cross-platform nature offers unique opportunities for developers. However, adapting WebXR experiences, which are rich in nuanced interactions, to non-VR platforms like PCs and mobile devices presents distinct challenges. Typically, developers might resort to creating multiple input mechanisms for different platforms, leading to complex and hard-to-maintain code. The Immersive Web Emulation Runtime (IWER) offers a streamlined solution by serving as an input remapping layer, simplifying the adaptation process. This article details my journey in implementing intuitive cross-platform controls for [Project Flowerbed](https://flowerbed.metademolab.com/), a WebXR showcase with intricate interactions, using IWER.
11 |
12 | ## Locomotion
13 |
14 | 
15 |
16 | One of the first challenges I faced was enabling players to move around in the gorgeous environment of Project Flowerbed. On PC, first-person movements are typically controlled using the WASD keys for movement and the mouse for camera direction. Project Flowerbed, originally designed for VR, utilizes sliding locomotion where movement is controlled by a joystick and the camera by the player’s head movement. To adapt this, I mapped the WASD keys to control the emulated joystick via IWER and the mouse movement to control the emulated headset's rotation. This setup allows PC players to navigate Project Flowerbed as smoothly as they would in any first-person PC game.
17 |
18 | ## Simple One-Handed Interactions
19 |
20 | 
21 |
22 | Most interactions in Project Flowerbed, as with many XR applications, involve simple one-handed interactions that consist of two basic steps: point and click. This includes indirect interactions such as raycasting or direct interactions like grabbing virtual objects. These interactions are straightforward to adapt to PC controls as well.
23 |
24 | ### Pointing:
25 |
26 | In VR, pointing is usually done with controllers. In Project Flowerbed, this translates to PC by coupling the mouse with the camera movement. By constructing a player rig that includes controllers and a headset, with controllers parented under the headset, pointing on a PC becomes intuitive. As the player rotates the headset view with the mouse, the controllers follow, allowing for accurate pointing.
27 |
28 | ### Clicking:
29 |
30 | I use "clicking" to refer broadly to actions like pressing, holding, and grabbing. On PC, these can be mapped to keyboard and mouse buttons in a way that is ergonomic and logical. For instance, in Project Flowerbed, I mapped the F key to trigger the main action wheel (originally the A button in VR), and the left mouse button to mimic shooting a seed, corresponding to the right trigger in VR.
31 |
32 | ### One-Handed Gestures:
33 |
34 | 
35 |
36 | Addressing straightforward point-and-click interactions laid a solid foundation for handling more complex one-handed gestures. For example, the drag-and-drop action to save or delete a photo in Project Flowerbed breaks down into a sequence of point-and-click actions. By leveraging IWER, implementing these gestures on PC became straightforward.
37 |
38 | ## Complex Gesture-Based Interactions
39 |
40 | 
41 |
42 | More intricate interactions that involve relative motion between two hands, like selecting a seedbag with one hand from a seedbox attached to the other, required a creative approach. On PC, since both hands are typically attached to the camera rig, achieving this relative motion means temporarily decoupling one hand. In planting mode, holding the right mouse button decouples the left hand (seedbox), allowing the player to select a seedbag with the other hand. Releasing the right mouse button then snaps the left hand back under the camera rig.
43 |
44 | For scenarios requiring complex gestures or patterns to trigger specific actions in XR, consider using the Action Recorder to pre-record these sequences. These can then be played back on PC by mapping them to keyboard or mouse events. For more details on how to use the Action Recorder, check out [this guide](/action).
45 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
16 | IWER Demo
17 |
46 |
47 |
48 |
49 |
50 |
Immersive Web Emulation Runtime Demo
51 |
52 |
53 |
54 | No Native WebXR Support Detected:
55 | IWER has created an emulated Meta Quest 3 device and injected its
56 | WebXR runtime, you can now enter WebXR in emulation mode and control
57 | the emulated device via the GUI.
58 |
59 |
60 |
61 | Action Playback Demo:
62 | This demo is loaded with a pre-recorded input session captured on a
63 | Meta Quest 3 device, you can view the playback by entering VR in
64 | emulation mode and click the "Play Action" button.
65 |
66 |
67 |
68 | Native WebXR Support Detected:
69 | Enter VR and record your input session by pressing trigger on your
70 | right controller to start/end recording
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iwer-example",
3 | "private": true,
4 | "devDependencies": {
5 | "css-loader": "^5.2.1",
6 | "html-webpack-plugin": "^5.3.1",
7 | "pre-commit": "^1.2.2",
8 | "style-loader": "3.3.4",
9 | "webpack": "^5.91.0",
10 | "webpack-cli": "5.1.4",
11 | "webpack-dev-server": "5.0.4"
12 | },
13 | "scripts": {
14 | "build": "webpack",
15 | "ci-build": "NODE_ENV=development npm install && NODE_ENV=production npm run build",
16 | "serve": "webpack serve"
17 | },
18 | "dependencies": {
19 | "@iwer/devui": "file:../devui",
20 | "dat.gui": "0.7.9",
21 | "iwer": "file:../",
22 | "three": "npm:super-three@0.158.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 |
4 | module.exports = {
5 | mode: process.env.NODE_ENV ?? 'development',
6 | entry: {
7 | index: './index.js',
8 | },
9 | module: {
10 | rules: [
11 | {
12 | test: /\.css$/i,
13 | use: ['style-loader', 'css-loader'],
14 | },
15 | ],
16 | },
17 | devServer: {
18 | static: {
19 | directory: path.join(__dirname, 'dist'),
20 | },
21 | host: '0.0.0.0',
22 | server: 'https',
23 | compress: true,
24 | port: 8081,
25 | },
26 | output: {
27 | filename: '[name].bundle.js',
28 | path: path.resolve(__dirname, 'dist'),
29 | clean: true,
30 | },
31 | plugins: [
32 | new HtmlWebpackPlugin({
33 | template: './index.html',
34 | }),
35 | ],
36 | devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
37 | };
38 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest/presets/default-esm',
4 | extensionsToTreatAsEsm: ['.ts'],
5 | setupFiles: ['/tests/polyfill.ts'],
6 | moduleNameMapper: {
7 | '^(\\.{1,2}/.*)\\.js$': '$1',
8 | },
9 | transform: {
10 | '^.+\\.ts$': ['ts-jest', { useESM: true }],
11 | },
12 | testEnvironment: 'jsdom',
13 | };
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iwer",
3 | "version": "2.0.1",
4 | "description": "Javascript WebXR Runtime for Emulation",
5 | "type": "module",
6 | "main": "lib/index.js",
7 | "types": "lib/index.d.ts",
8 | "files": [
9 | "build",
10 | "lib"
11 | ],
12 | "scripts": {
13 | "prebuild": "node -p \"'export const VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
14 | "build": "tsc && rollup -c",
15 | "doc": "typedoc",
16 | "format": "prettier --write ./src/**/*",
17 | "test": "jest --coverage",
18 | "prepublishOnly": "npm run build",
19 | "docs:dev": "vitepress dev docs",
20 | "docs:build": "vitepress build docs",
21 | "docs:preview": "vitepress preview docs"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/meta-quest/immersive-web-emulation-runtime.git"
26 | },
27 | "keywords": [],
28 | "author": "Felix Zhang ",
29 | "license": "MIT",
30 | "bugs": {
31 | "url": "https://github.com/meta-quest/immersive-web-emulation-runtime/issues"
32 | },
33 | "homepage": "https://github.com/meta-quest/immersive-web-emulation-runtime#readme",
34 | "devDependencies": {
35 | "@rollup/plugin-commonjs": "^25.0.7",
36 | "@rollup/plugin-node-resolve": "^15.2.3",
37 | "@rollup/plugin-terser": "^0.4.4",
38 | "@types/gl-matrix": "^3.2.0",
39 | "@types/jest": "^29.5.11",
40 | "@types/node": "^18.7.13",
41 | "@types/three": "^0.149.0",
42 | "@types/uuid": "^9.0.8",
43 | "@types/webxr": "^0.5.8",
44 | "@typescript-eslint/eslint-plugin": "^5.55.0",
45 | "@typescript-eslint/parser": "^5.55.0",
46 | "eslint": "^8.36.0",
47 | "eslint-config-prettier": "^8.10.0",
48 | "eslint-plugin-prettier": "^5.0.1",
49 | "jest": "^29.7.0",
50 | "jest-environment-jsdom": "^29.7.0",
51 | "prettier": "^3.0.3",
52 | "renamer": "^4.0.0",
53 | "rimraf": "^5.0.5",
54 | "rollup": "^2.79.1",
55 | "ts-jest": "^29.1.1",
56 | "typescript": "^4.9.5",
57 | "vitepress": "1.0.1"
58 | },
59 | "dependencies": {
60 | "gl-matrix": "^3.4.3"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import resolve from '@rollup/plugin-node-resolve';
3 | import terser from '@rollup/plugin-terser';
4 |
5 | export default {
6 | input: 'lib/index.js',
7 | plugins: [resolve(), commonjs()],
8 | output: [
9 | // UMD build
10 | {
11 | file: 'build/iwer.js',
12 | format: 'umd',
13 | name: 'IWER',
14 | },
15 | // Minified UMD build
16 | {
17 | file: 'build/iwer.min.js',
18 | format: 'umd',
19 | name: 'IWER',
20 | plugins: [terser()],
21 | },
22 | // ES module build
23 | {
24 | file: 'build/iwer.module.js',
25 | format: 'es',
26 | },
27 | // Minified ES module build
28 | {
29 | file: 'build/iwer.module.min.js',
30 | format: 'es',
31 | plugins: [terser()],
32 | },
33 | ],
34 | };
35 |
--------------------------------------------------------------------------------
/sem/.gitignore:
--------------------------------------------------------------------------------
1 | src/registry.ts
--------------------------------------------------------------------------------
/sem/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
@iwer/sem
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | `@iwer/sem` (**Synthetic Environment Module**) is an extension for the Immersive Web Emulation Runtime (**IWER**). It enables advanced Mixed Reality features in IWER by emulating real-world environments with high-fidelity, including video passthrough, plane and mesh detection, semantic labels, and hit testing.
14 |
15 | ## Key Features
16 |
17 | - **Video Passthrough Emulation:** Injects a video passthrough layer, rendering the captured scene behind the emulated app canvas.
18 | - **Plane & Mesh Detection:** Provides `XRPlane` and `XRMesh` information via the `plane-detection` and `mesh-detection` APIs in IWER.
19 | - **High Fidelity Scene Mesh:** Supports emulation of high-fidelity scene meshes exposed on Meta Quest 3 and 3S devices.
20 | - **Hit-Test:** Enables `hit-test` APIs through IWER by conducting real-time raycasting spatial queries with the loaded environment.
21 |
22 | ## Current Status
23 |
24 | `@iwer/sem` is currently in `0.x` status and is under active development. This is an early build, changes may occur before the official v1.0 release.
25 |
26 | ## Installation
27 |
28 | To install `@iwer/sem`, use the following npm command:
29 |
30 | ```bash
31 | npm install @iwer/sem
32 | ```
33 |
34 | ## Usage
35 |
36 | `@iwer/sem` requires an active IWER runtime. If you are new to IWER, refer to the [IWER Getting Started Guide](https://meta-quest.github.io/immersive-web-emulation-runtime/getting-started.html). Here is a quick example:
37 |
38 | ```javascript
39 | import { XRDevice, metaQuest3 } from 'iwer';
40 |
41 | // Initialize the XR device with a preset configuration (e.g., Meta Quest 3)
42 | const xrDevice = new XRDevice(metaQuest3);
43 |
44 | // Install the IWER runtime to enable WebXR emulation
45 | xrDevice.installRuntime();
46 | ```
47 |
48 | Integrate `@iwer/sem`:
49 |
50 | ```javascript
51 | import { SyntheticEnvironmentModule } from '@iwer/sem';
52 |
53 | const sem = new SyntheticEnvironmentModule();
54 | xrDevice.installSyntheticEnvironmentModule(sem);
55 | ```
56 |
57 | Load an environment using a JSON object:
58 |
59 | ```javascript
60 | sem.loadEnvironment(sceneJSON);
61 | ```
62 |
63 | Or fetch from an external source:
64 |
65 | ```javascript
66 | const url = 'path/to/your/scene.json';
67 | fetch(url)
68 | .then((response) => response.json())
69 | .then((data) => {
70 | sem.loadEnvironment(data);
71 | })
72 | .catch((error) => {
73 | console.error('Error loading JSON:', error);
74 | });
75 | ```
76 |
77 | ## License
78 |
79 | `@iwer/sem` is licensed under the MIT License. For more details, see the [LICENSE](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/LICENSE) file in this repository.
80 |
81 | ## Contributing
82 |
83 | Your contributions are welcome! Please feel free to submit issues and pull requests. Before contributing, make sure to review our [Contributing Guidelines](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/CONTRIBUTING.md) and [Code of Conduct](https://github.com/meta-quest/immersive-web-emulation-runtime/blob/main/CODE_OF_CONDUCT.md).
84 |
--------------------------------------------------------------------------------
/sem/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@iwer/sem",
3 | "version": "0.2.5",
4 | "description": "Synthetic Environment Module for IWER",
5 | "type": "module",
6 | "main": "lib/index.js",
7 | "types": "lib/index.d.ts",
8 | "files": [
9 | "build",
10 | "lib",
11 | "captures"
12 | ],
13 | "scripts": {
14 | "prebuild": "node scripts/prebuild.cjs",
15 | "build": "tsc && rollup -c"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git+https://github.com/meta-quest/immersive-web-emulation-runtime.git"
20 | },
21 | "keywords": [],
22 | "author": "Felix Zhang ",
23 | "license": "MIT",
24 | "bugs": {
25 | "url": "https://github.com/meta-quest/immersive-web-emulation-runtime/issues"
26 | },
27 | "homepage": "https://github.com/meta-quest/immersive-web-emulation-runtime#readme",
28 | "dependencies": {
29 | "three": "^0.165.0",
30 | "ts-proto": "^2.6.0"
31 | },
32 | "peerDependencies": {
33 | "iwer": "^2.0.0"
34 | },
35 | "devDependencies": {
36 | "@rollup/plugin-commonjs": "^28.0.1",
37 | "@rollup/plugin-json": "^6.1.0",
38 | "@rollup/plugin-node-resolve": "^15.3.0",
39 | "@rollup/plugin-replace": "^6.0.1",
40 | "@rollup/plugin-terser": "^0.4.4",
41 | "@types/three": "^0.170.0",
42 | "rollup": "^4.24.3",
43 | "rollup-plugin-peer-deps-external": "^2.2.4",
44 | "typescript": "^5.6.3"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/sem/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import json from '@rollup/plugin-json';
3 | import peerDepsExternal from 'rollup-plugin-peer-deps-external';
4 | import replace from '@rollup/plugin-replace';
5 | import resolve from '@rollup/plugin-node-resolve';
6 | import terser from '@rollup/plugin-terser';
7 |
8 | const globals = {
9 | iwer: 'IWER',
10 | };
11 |
12 | const basePlugins = [peerDepsExternal(), resolve(), commonjs(), json()];
13 |
14 | const esPlugins = [
15 | ...basePlugins,
16 | replace({
17 | __IS_UMD__: 'false', // Set to false for ES builds
18 | preventAssignment: true,
19 | }),
20 | ];
21 |
22 | const umdPlugins = [
23 | ...basePlugins,
24 | replace({
25 | __IS_UMD__: 'true', // Set to true for UMD builds
26 | preventAssignment: true,
27 | }),
28 | ];
29 |
30 | export default [
31 | // UMD builds
32 | {
33 | input: 'lib/index.js',
34 | external: ['iwer'],
35 | plugins: umdPlugins,
36 | output: [
37 | {
38 | file: 'build/iwer-sem.js',
39 | format: 'umd',
40 | name: 'IWER_SEM',
41 | globals,
42 | },
43 | {
44 | file: 'build/iwer-sem.min.js',
45 | format: 'umd',
46 | name: 'IWER_SEM',
47 | globals,
48 | plugins: [terser()],
49 | },
50 | ],
51 | },
52 | // ES module builds
53 | {
54 | input: 'lib/index.js',
55 | external: ['iwer'],
56 | plugins: esPlugins,
57 | output: [
58 | {
59 | dir: 'build/es',
60 | format: 'es',
61 | entryFileNames: '[name].js',
62 | chunkFileNames: '[name]-[hash].js',
63 | globals,
64 | },
65 | {
66 | dir: 'build/es-min',
67 | format: 'es',
68 | entryFileNames: '[name].min.js',
69 | chunkFileNames: '[name]-[hash].min.js',
70 | plugins: [terser()],
71 | globals,
72 | },
73 | ],
74 | },
75 | ];
76 |
--------------------------------------------------------------------------------
/sem/scripts/prebuild.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | const fs = require('fs');
9 | const path = require('path');
10 |
11 | const jsonDirectory = path.join(__dirname, '../captures');
12 | const outputFilePath = path.join(__dirname, '../src', 'registry.ts');
13 |
14 | // Read all JSON files in the directory
15 | const files = fs
16 | .readdirSync(jsonDirectory)
17 | .filter((file) => file.endsWith('.json'));
18 |
19 | // Generate the content for registry.ts
20 | const imports = files
21 | .map((file) => {
22 | const envId = path.basename(file, '.json');
23 | return ` ${envId}: () => import('../captures/${file}'),`;
24 | })
25 | .join('\n');
26 |
27 | const content = `export const Environments: { [envId: string]: () => Promise } = {\n${imports}\n};\n`;
28 |
29 | // Write the content to registry.ts
30 | fs.writeFileSync(outputFilePath, content, 'utf8');
31 |
32 | console.log('registry.ts has been generated successfully.');
33 | // Generate version.ts
34 | const packageJson = require('../package.json');
35 | const versionOutputFilePath = path.join(__dirname, '../src', 'version.ts');
36 | const versionContent = `export const VERSION = ${JSON.stringify(
37 | packageJson.version,
38 | )};\n`;
39 | // Write the content to version.ts
40 | fs.writeFileSync(versionOutputFilePath, versionContent, 'utf8');
41 | console.log('version.ts has been generated successfully.');
42 |
--------------------------------------------------------------------------------
/sem/src/globals.d.ts:
--------------------------------------------------------------------------------
1 | declare const __IS_UMD__: boolean;
2 |
--------------------------------------------------------------------------------
/sem/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | export { SpatialEntity, SpatialEntityType } from './native/entity.js';
9 | export { Bounded2DComponent } from './native/components/bounded2d.js';
10 | export { Bounded3DComponent } from './native/components/bounded3d.js';
11 | export {
12 | SpatialEntityComponent,
13 | SpatialEntityComponentType,
14 | } from './native/components/component.js';
15 | export { LocatableComponent } from './native/components/locatable.js';
16 | export { SemanticLabelComponent } from './native/components/semanticlabel.js';
17 | export { TriangleMeshComponent } from './native/components/trianglemesh.js';
18 | export { SyntheticEnvironmentModule } from './sem.js';
19 | export { VERSION } from './version.js';
20 |
--------------------------------------------------------------------------------
/sem/src/native/components/bounded2d.ts:
--------------------------------------------------------------------------------
1 | import { PlaneGeometry, Vector2 } from 'three';
2 | import {
3 | SpatialEntityComponent,
4 | SpatialEntityComponentType,
5 | } from './component.js';
6 |
7 | import { Rect2D } from '../../generated/protos/openxr_core.js';
8 | import { SpatialEntity } from '../entity.js';
9 |
10 | export class Bounded2DComponent extends SpatialEntityComponent {
11 | private _offset: Vector2 = new Vector2();
12 | private _extent: Vector2 = new Vector2();
13 | type = SpatialEntityComponentType.Bounded2D;
14 |
15 | constructor(spatialEntity: SpatialEntity, initData: Rect2D) {
16 | super(spatialEntity);
17 | const { offset, extent } = initData;
18 | this._offset.set(offset!.x, offset!.y);
19 | this._extent.set(extent!.width, extent!.height);
20 | this.buildGeometry();
21 | }
22 |
23 | buildGeometry() {
24 | const geometry = new PlaneGeometry(this._extent.x, this._extent.y);
25 | geometry.translate(
26 | this._offset.x + this._extent.x / 2,
27 | this._offset.y + this._extent.y / 2,
28 | 0,
29 | );
30 | geometry.rotateX(Math.PI / 2);
31 | this._spatialEntity.geometry?.dispose();
32 | this._spatialEntity.geometry = geometry;
33 | }
34 |
35 | get offset() {
36 | return this._offset;
37 | }
38 |
39 | get extent() {
40 | return this._extent;
41 | }
42 |
43 | get initData() {
44 | return {
45 | offset: this.offset,
46 | extent: this.extent,
47 | };
48 | }
49 |
50 | get pbData(): Rect2D {
51 | return {
52 | offset: { x: this._offset.x, y: this._offset.y },
53 | extent: { width: this._extent.x, height: this._extent.y },
54 | } as Rect2D;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/sem/src/native/components/bounded3d.ts:
--------------------------------------------------------------------------------
1 | import { BoxGeometry, Mesh, Vector3 } from 'three';
2 | import {
3 | SpatialEntityComponent,
4 | SpatialEntityComponentType,
5 | } from './component.js';
6 |
7 | import { Rect3D } from '../../generated/protos/openxr_core.js';
8 |
9 | export class Bounded3DComponent extends SpatialEntityComponent {
10 | private _offset: Vector3 = new Vector3();
11 | private _extent: Vector3 = new Vector3();
12 | type = SpatialEntityComponentType.Bounded3D;
13 |
14 | constructor(spatialEntity: Mesh, initData: Rect3D) {
15 | super(spatialEntity);
16 | const { offset, extent } = initData;
17 | this._offset.set(offset!.x, offset!.y, offset!.z);
18 | this._extent.set(extent!.width, extent!.height, extent!.depth);
19 | this.buildGeometry();
20 | }
21 |
22 | buildGeometry() {
23 | const geometry = new BoxGeometry(
24 | this._extent.x,
25 | this._extent.y,
26 | this._extent.z,
27 | );
28 | geometry.translate(
29 | this._offset.x + this._extent.x / 2,
30 | this._offset.y + this._extent.y / 2,
31 | this._offset.z + this._extent.z / 2,
32 | );
33 | this._spatialEntity.geometry?.dispose();
34 | this._spatialEntity.geometry = geometry;
35 | }
36 |
37 | get offset() {
38 | return this._offset;
39 | }
40 |
41 | get extent() {
42 | return this._extent;
43 | }
44 |
45 | get initData() {
46 | return {
47 | offset: this.offset,
48 | extent: this.extent,
49 | };
50 | }
51 |
52 | get pbData() {
53 | return {
54 | offset: { x: this._offset.x, y: this._offset.y, z: this._offset.z },
55 | extent: {
56 | width: this._extent.x,
57 | height: this._extent.y,
58 | depth: this._extent.z,
59 | },
60 | } as Rect3D;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/sem/src/native/components/component.ts:
--------------------------------------------------------------------------------
1 | import { Mesh } from 'three';
2 |
3 | export enum SpatialEntityComponentType {
4 | Locatable = 'locatable',
5 | Bounded3D = 'bounded3D',
6 | Bounded2D = 'bounded2D',
7 | TriangleMesh = 'triangleMesh',
8 | SemanticLabel = 'semanticLabel',
9 | }
10 |
11 | export abstract class SpatialEntityComponent extends EventTarget {
12 | constructor(protected _spatialEntity: Mesh) {
13 | super();
14 | }
15 | abstract get initData(): any;
16 | abstract get pbData(): any;
17 | abstract type: SpatialEntityComponentType;
18 | }
19 |
--------------------------------------------------------------------------------
/sem/src/native/components/locatable.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SpatialEntityComponent,
3 | SpatialEntityComponentType,
4 | } from './component.js';
5 |
6 | import { Mesh } from 'three';
7 | import { Pose } from '../../generated/protos/openxr_core.js';
8 |
9 | export class LocatableComponent extends SpatialEntityComponent {
10 | type = SpatialEntityComponentType.Locatable;
11 |
12 | constructor(spatialEntity: Mesh, initData: Pose) {
13 | super(spatialEntity);
14 | if (initData.position) {
15 | this.position.copy(initData.position);
16 | }
17 | if (initData.orientation) {
18 | this.orientation.copy(initData.orientation);
19 | }
20 | }
21 |
22 | get position() {
23 | return this._spatialEntity.position;
24 | }
25 |
26 | get rotation() {
27 | return this._spatialEntity.rotation;
28 | }
29 |
30 | get orientation() {
31 | return this._spatialEntity.quaternion;
32 | }
33 |
34 | get initData() {
35 | return {
36 | position: {
37 | x: this.position.x,
38 | y: this.position.y,
39 | z: this.position.z,
40 | },
41 | orientation: {
42 | x: this.orientation.x,
43 | y: this.orientation.y,
44 | z: this.orientation.z,
45 | w: this.orientation.w,
46 | },
47 | };
48 | }
49 |
50 | get pbData() {
51 | return {
52 | position: {
53 | x: this._spatialEntity.position.x,
54 | y: this._spatialEntity.position.y,
55 | z: this._spatialEntity.position.z,
56 | },
57 | orientation: {
58 | x: this._spatialEntity.quaternion.x,
59 | y: this._spatialEntity.quaternion.y,
60 | z: this._spatialEntity.quaternion.z,
61 | w: this._spatialEntity.quaternion.w,
62 | },
63 | } as Pose;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/sem/src/native/components/semanticlabel.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SemanticLabelMETA,
3 | semanticLabelMETAToJSON,
4 | } from '../../generated/protos/openxr_scene.js';
5 | import {
6 | SpatialEntityComponent,
7 | SpatialEntityComponentType,
8 | } from './component.js';
9 |
10 | import { Mesh } from 'three';
11 |
12 | function convertToReadableString(str: string): string {
13 | return str.toLowerCase().replace(/_/g, ' ').trim();
14 | }
15 |
16 | export class SemanticLabelComponent extends SpatialEntityComponent {
17 | type = SpatialEntityComponentType.SemanticLabel;
18 |
19 | constructor(
20 | spatialEntity: Mesh,
21 | private _semanticLabel: SemanticLabelMETA,
22 | ) {
23 | super(spatialEntity);
24 | this._spatialEntity.name = convertToReadableString(
25 | semanticLabelMETAToJSON(_semanticLabel),
26 | );
27 | }
28 |
29 | get semanticLabel(): SemanticLabelMETA {
30 | return this._semanticLabel;
31 | }
32 |
33 | set semanticLabel(value: SemanticLabelMETA) {
34 | if (Object.values(SemanticLabelMETA).includes(value)) {
35 | this._semanticLabel = value;
36 | } else {
37 | this._semanticLabel = SemanticLabelMETA.UNRECOGNIZED;
38 | }
39 | this._spatialEntity.name = convertToReadableString(
40 | semanticLabelMETAToJSON(this._semanticLabel),
41 | );
42 | }
43 |
44 | get initData() {
45 | return this._semanticLabel;
46 | }
47 |
48 | get pbData() {
49 | return this._semanticLabel;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/sem/src/native/components/trianglemesh.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BufferAttribute,
3 | BufferGeometry,
4 | Mesh,
5 | MeshBasicMaterial,
6 | } from 'three';
7 | import {
8 | SpatialEntityComponent,
9 | SpatialEntityComponentType,
10 | } from './component.js';
11 |
12 | import { TriangleMeshMETA } from '../../generated/protos/openxr_scene.js';
13 | import { Vector3 } from '../../generated/protos/openxr_core.js';
14 |
15 | function vec3ArrayToFloat32Array(arr: Vector3[]): Float32Array {
16 | const result = new Float32Array(arr.length * 3);
17 | let index = 0;
18 | for (const vec of arr) {
19 | result[index++] = vec.x;
20 | result[index++] = vec.y;
21 | result[index++] = vec.z;
22 | }
23 | return result;
24 | }
25 |
26 | export class TriangleMeshComponent extends SpatialEntityComponent {
27 | private _vertices: Vector3[];
28 | private _indices: number[];
29 | private _polygonCount: number = 0;
30 | private _vertexCount: number = 0;
31 | private _dimensions: Vector3 = { x: 0, y: 0, z: 0 };
32 | type = SpatialEntityComponentType.TriangleMesh;
33 |
34 | constructor(spatialEntity: Mesh, initData: TriangleMeshMETA) {
35 | super(spatialEntity);
36 | const { vertices, indices } = initData;
37 | const verticesArray = new Float32Array(vertices.buffer);
38 | const indicesArray = new Uint32Array(indices.buffer);
39 | const vec3Array = [];
40 | for (let i = 0; i < verticesArray.length / 3; i++) {
41 | vec3Array.push({
42 | x: verticesArray[3 * i],
43 | y: verticesArray[3 * i + 1],
44 | z: verticesArray[3 * i + 2],
45 | });
46 | }
47 | this._vertices = vec3Array;
48 | this._indices = [...indicesArray];
49 | this.buildGeometry();
50 | const material = spatialEntity.material as MeshBasicMaterial;
51 | material.polygonOffset = true;
52 | material.polygonOffsetFactor = 1;
53 | material.polygonOffsetUnits = 0.005;
54 | material.color.setHex(0xd4d4d4);
55 | spatialEntity.renderOrder = 999;
56 | }
57 |
58 | private buildGeometry() {
59 | const geometry = new BufferGeometry();
60 | const vertices = vec3ArrayToFloat32Array(this._vertices);
61 | geometry.setAttribute('position', new BufferAttribute(vertices, 3));
62 | geometry.setIndex(new BufferAttribute(new Uint16Array(this._indices), 1));
63 | this._spatialEntity.geometry?.dispose();
64 | this._spatialEntity.geometry = geometry;
65 | geometry.computeVertexNormals();
66 | this._vertexCount = geometry.attributes.position.count;
67 | this._polygonCount = geometry.index
68 | ? geometry.index.count / 3
69 | : this._vertexCount / 3;
70 | geometry.computeBoundingBox();
71 | const boundingBox = geometry.boundingBox!;
72 | this._dimensions = {
73 | x: boundingBox.max.x - boundingBox.min.x,
74 | y: boundingBox.max.y - boundingBox.min.y,
75 | z: boundingBox.max.z - boundingBox.min.z,
76 | };
77 | }
78 |
79 | get vertexCount() {
80 | return this._vertexCount;
81 | }
82 |
83 | get polygonCount() {
84 | return this._polygonCount;
85 | }
86 |
87 | get dimensions() {
88 | return this._dimensions;
89 | }
90 |
91 | get initData() {
92 | return {
93 | vertices: this._vertices,
94 | indices: this._indices,
95 | };
96 | }
97 |
98 | get pbData() {
99 | const verticesArray = vec3ArrayToFloat32Array(this._vertices);
100 | const indicesArray = new Uint32Array(this._indices);
101 | return {
102 | vertices: new Uint8Array(verticesArray.buffer),
103 | indices: new Uint8Array(indicesArray.buffer),
104 | } as TriangleMeshMETA;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/sem/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./lib"
5 | },
6 | "include": ["src/**/*"]
7 | }
8 |
--------------------------------------------------------------------------------
/src/anchors/XRAnchor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_ANCHOR, P_DEVICE, P_SESSION, P_SPACE } from '../private.js';
9 |
10 | import { XRSession } from '../session/XRSession.js';
11 | import { XRSpace } from '../spaces/XRSpace.js';
12 | import { mat4 } from 'gl-matrix';
13 |
14 | export class XRAnchor {
15 | [P_ANCHOR]: {
16 | anchorSpace: XRSpace | null;
17 | session: XRSession;
18 | deleted: boolean;
19 | };
20 |
21 | constructor(anchorSpace: XRSpace, session: XRSession) {
22 | this[P_ANCHOR] = {
23 | anchorSpace,
24 | session,
25 | deleted: false,
26 | };
27 | session[P_SESSION].trackedAnchors.add(this);
28 | }
29 |
30 | get anchorSpace() {
31 | if (this[P_ANCHOR].deleted) {
32 | throw new DOMException(
33 | 'XRAnchor has already been deleted.',
34 | 'InvalidStateError',
35 | );
36 | }
37 | return this[P_ANCHOR].anchorSpace!;
38 | }
39 |
40 | requestPersistentHandle() {
41 | return new Promise((resolve, reject) => {
42 | if (this[P_ANCHOR].deleted) {
43 | reject(
44 | new DOMException(
45 | 'XRAnchor has already been deleted.',
46 | 'InvalidStateError',
47 | ),
48 | );
49 | } else {
50 | const persistentAnchors =
51 | this[P_ANCHOR].session[P_SESSION].persistentAnchors;
52 | for (const [uuid, anchor] of persistentAnchors.entries()) {
53 | if (anchor === this) {
54 | resolve(uuid);
55 | return;
56 | }
57 | }
58 | const uuid = crypto.randomUUID();
59 | XRAnchorUtils.createPersistentAnchor(
60 | this[P_ANCHOR].session,
61 | this,
62 | uuid,
63 | );
64 | resolve(uuid);
65 | }
66 | });
67 | }
68 |
69 | delete() {
70 | if (this[P_ANCHOR].deleted) {
71 | return;
72 | }
73 | this[P_ANCHOR].anchorSpace = null;
74 | this[P_ANCHOR].deleted = true;
75 | this[P_ANCHOR].session[P_SESSION].trackedAnchors.delete(this);
76 | }
77 | }
78 |
79 | export class XRAnchorSet extends Set {}
80 |
81 | const PersistentAnchorsStorageKey =
82 | '@immersive-web-emulation-runtime/persistent-anchors';
83 |
84 | export class XRAnchorUtils {
85 | static recoverPersistentAnchorsFromStorage(session: XRSession) {
86 | const persistentAnchors = JSON.parse(
87 | localStorage.getItem(PersistentAnchorsStorageKey) || '{}',
88 | ) as { [uuid: string]: mat4 };
89 | Object.entries(persistentAnchors).forEach(([uuid, offsetMatrix]) => {
90 | const globalSpace = session[P_SESSION].device[P_DEVICE].globalSpace;
91 | const anchorSpace = new XRSpace(globalSpace, offsetMatrix);
92 | const anchor = new XRAnchor(anchorSpace, session);
93 | session[P_SESSION].persistentAnchors.set(uuid, anchor);
94 | });
95 | }
96 |
97 | static createPersistentAnchor(
98 | session: XRSession,
99 | anchor: XRAnchor,
100 | uuid: string,
101 | ) {
102 | session[P_SESSION].trackedAnchors.add(anchor);
103 | session[P_SESSION].persistentAnchors.set(uuid, anchor);
104 | const persistentAnchors = JSON.parse(
105 | localStorage.getItem(PersistentAnchorsStorageKey) || '{}',
106 | ) as { [uuid: string]: mat4 };
107 | persistentAnchors[uuid] = Array.from(
108 | anchor[P_ANCHOR].anchorSpace![P_SPACE].offsetMatrix,
109 | ) as mat4;
110 | localStorage.setItem(
111 | PersistentAnchorsStorageKey,
112 | JSON.stringify(persistentAnchors),
113 | );
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/device/XRController.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { Gamepad, GamepadConfig } from '../gamepad/Gamepad.js';
9 | import { GlobalSpace, XRSpace } from '../spaces/XRSpace.js';
10 | import { P_CONTROLLER, P_GAMEPAD, P_TRACKED_INPUT } from '../private.js';
11 | import {
12 | XRHandedness,
13 | XRInputSource,
14 | XRTargetRayMode,
15 | } from '../input/XRInputSource.js';
16 |
17 | import { XRTrackedInput } from './XRTrackedInput.js';
18 | import { mat4 } from 'gl-matrix';
19 |
20 | export interface XRControllerConfig {
21 | profileId: string;
22 | fallbackProfileIds: string[];
23 | layout: {
24 | [handedness in XRHandedness]?: {
25 | gamepad: GamepadConfig;
26 | gripOffsetMatrix?: mat4;
27 | numHapticActuators: number;
28 | };
29 | };
30 | }
31 |
32 | export class XRController extends XRTrackedInput {
33 | [P_CONTROLLER]: {
34 | profileId: string;
35 | gamepadConfig: GamepadConfig;
36 | };
37 |
38 | constructor(
39 | controllerConfig: XRControllerConfig,
40 | handedness: XRHandedness,
41 | globalSpace: GlobalSpace,
42 | ) {
43 | if (!controllerConfig.layout[handedness]) {
44 | throw new DOMException('Handedness not supported', 'InvalidStateError');
45 | }
46 | const targetRaySpace = new XRSpace(globalSpace);
47 | const gripSpace = controllerConfig.layout[handedness]!.gripOffsetMatrix
48 | ? new XRSpace(
49 | targetRaySpace,
50 | controllerConfig.layout[handedness]!.gripOffsetMatrix,
51 | )
52 | : undefined;
53 | const profiles = [
54 | controllerConfig.profileId,
55 | ...controllerConfig.fallbackProfileIds,
56 | ];
57 | const inputSource = new XRInputSource(
58 | handedness,
59 | XRTargetRayMode.TrackedPointer,
60 | profiles,
61 | targetRaySpace,
62 | new Gamepad(controllerConfig.layout[handedness]!.gamepad),
63 | gripSpace,
64 | );
65 |
66 | super(inputSource);
67 | this[P_CONTROLLER] = {
68 | profileId: controllerConfig.profileId,
69 | gamepadConfig: controllerConfig.layout[handedness]!.gamepad,
70 | };
71 | }
72 |
73 | get gamepadConfig() {
74 | return this[P_CONTROLLER].gamepadConfig;
75 | }
76 |
77 | get profileId() {
78 | return this[P_CONTROLLER].profileId;
79 | }
80 |
81 | updateButtonValue(id: string, value: number) {
82 | if (value > 1 || value < 0) {
83 | console.warn(`Out-of-range value ${value} provided for button ${id}.`);
84 | return;
85 | }
86 | const gamepadButton =
87 | this[P_TRACKED_INPUT].inputSource.gamepad![P_GAMEPAD].buttonsMap[id];
88 | if (gamepadButton) {
89 | if (
90 | gamepadButton[P_GAMEPAD].type === 'binary' &&
91 | value != 1 &&
92 | value != 0
93 | ) {
94 | console.warn(
95 | `Non-binary value ${value} provided for binary button ${id}.`,
96 | );
97 | return;
98 | }
99 | gamepadButton[P_GAMEPAD].pendingValue = value;
100 | } else {
101 | console.warn(`Current controller does not have button ${id}.`);
102 | }
103 | }
104 |
105 | updateButtonTouch(id: string, touched: boolean) {
106 | const gamepadButton =
107 | this[P_TRACKED_INPUT].inputSource.gamepad![P_GAMEPAD].buttonsMap[id];
108 | if (gamepadButton) {
109 | gamepadButton[P_GAMEPAD].touched = touched;
110 | } else {
111 | console.warn(`Current controller does not have button ${id}.`);
112 | }
113 | }
114 |
115 | updateAxis(id: string, type: 'x-axis' | 'y-axis', value: number) {
116 | if (value > 1 || value < -1) {
117 | console.warn(`Out-of-range value ${value} provided for ${id} axes.`);
118 | return;
119 | }
120 | const axesById =
121 | this[P_TRACKED_INPUT].inputSource.gamepad![P_GAMEPAD].axesMap[id];
122 | if (axesById) {
123 | if (type === 'x-axis') {
124 | axesById.x = value;
125 | } else if (type === 'y-axis') {
126 | axesById.y = value;
127 | }
128 | } else {
129 | console.warn(`Current controller does not have ${id} axes.`);
130 | }
131 | }
132 |
133 | updateAxes(id: string, x: number, y: number) {
134 | if (x > 1 || x < -1 || y > 1 || y < -1) {
135 | console.warn(
136 | `Out-of-range value x:${x}, y:${y} provided for ${id} axes.`,
137 | );
138 | return;
139 | }
140 | const axesById =
141 | this[P_TRACKED_INPUT].inputSource.gamepad![P_GAMEPAD].axesMap[id];
142 | if (axesById) {
143 | axesById.x = x;
144 | axesById.y = y;
145 | } else {
146 | console.warn(`Current controller does not have ${id} axes.`);
147 | }
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/device/XRTrackedInput.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_GAMEPAD, P_SPACE, P_TRACKED_INPUT } from '../private.js';
9 | import { Quaternion, Vector3 } from '../utils/Math.js';
10 | import { XRHandedness, XRInputSource } from '../input/XRInputSource.js';
11 |
12 | import { GamepadButton } from '../gamepad/Gamepad.js';
13 | import { XRFrame } from '../frameloop/XRFrame.js';
14 | import { XRInputSourceEvent } from '../events/XRInputSourceEvent.js';
15 | import { mat4 } from 'gl-matrix';
16 |
17 | const DEFAULT_TRANSFORM = {
18 | [XRHandedness.Left]: {
19 | position: new Vector3(-0.25, 1.5, -0.4),
20 | quaternion: new Quaternion(),
21 | },
22 | [XRHandedness.Right]: {
23 | position: new Vector3(0.25, 1.5, -0.4),
24 | quaternion: new Quaternion(),
25 | },
26 | [XRHandedness.None]: {
27 | position: new Vector3(0.25, 1.5, -0.4),
28 | quaternion: new Quaternion(),
29 | },
30 | };
31 |
32 | export class XRTrackedInput {
33 | [P_TRACKED_INPUT]: {
34 | inputSource: XRInputSource;
35 | // input state
36 | position: Vector3;
37 | quaternion: Quaternion;
38 | connected: boolean;
39 | lastFrameConnected: boolean;
40 | inputSourceChanged: boolean;
41 | };
42 |
43 | constructor(inputSource: XRInputSource) {
44 | this[P_TRACKED_INPUT] = {
45 | inputSource,
46 | position: DEFAULT_TRANSFORM[inputSource.handedness].position.clone(),
47 | quaternion: DEFAULT_TRANSFORM[inputSource.handedness].quaternion.clone(),
48 | connected: true,
49 | lastFrameConnected: false,
50 | inputSourceChanged: true,
51 | };
52 | }
53 |
54 | get position(): Vector3 {
55 | return this[P_TRACKED_INPUT].position;
56 | }
57 |
58 | get quaternion(): Quaternion {
59 | return this[P_TRACKED_INPUT].quaternion;
60 | }
61 |
62 | get inputSource(): XRInputSource {
63 | return this[P_TRACKED_INPUT].inputSource;
64 | }
65 |
66 | get connected() {
67 | return this[P_TRACKED_INPUT].connected;
68 | }
69 |
70 | set connected(value: boolean) {
71 | this[P_TRACKED_INPUT].connected = value;
72 | this[P_TRACKED_INPUT].inputSource.gamepad![P_GAMEPAD].connected = value;
73 | }
74 |
75 | onFrameStart(frame: XRFrame) {
76 | const targetRaySpace = this[P_TRACKED_INPUT].inputSource.targetRaySpace;
77 | mat4.fromRotationTranslation(
78 | targetRaySpace[P_SPACE].offsetMatrix,
79 | this[P_TRACKED_INPUT].quaternion.quat,
80 | this[P_TRACKED_INPUT].position.vec3,
81 | );
82 |
83 | const session = frame.session;
84 | this[P_TRACKED_INPUT].inputSource.gamepad!.buttons.forEach((button) => {
85 | if (button instanceof GamepadButton) {
86 | // apply pending values and record last frame values
87 | button[P_GAMEPAD].lastFrameValue = button[P_GAMEPAD].value;
88 | if (button[P_GAMEPAD].pendingValue != null) {
89 | button[P_GAMEPAD].value = button[P_GAMEPAD].pendingValue;
90 | button[P_GAMEPAD].pendingValue = null;
91 | }
92 | // trigger input source events
93 | if (button[P_GAMEPAD].eventTrigger != null) {
94 | if (
95 | button[P_GAMEPAD].lastFrameValue === 0 &&
96 | button[P_GAMEPAD].value > 0
97 | ) {
98 | session.dispatchEvent(
99 | new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, {
100 | frame,
101 | inputSource: this[P_TRACKED_INPUT].inputSource,
102 | }),
103 | );
104 | session.dispatchEvent(
105 | new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger + 'start', {
106 | frame,
107 | inputSource: this[P_TRACKED_INPUT].inputSource,
108 | }),
109 | );
110 | } else if (
111 | button[P_GAMEPAD].lastFrameValue > 0 &&
112 | button[P_GAMEPAD].value === 0
113 | ) {
114 | session.dispatchEvent(
115 | new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger + 'end', {
116 | frame,
117 | inputSource: this[P_TRACKED_INPUT].inputSource,
118 | }),
119 | );
120 | }
121 | }
122 | }
123 | });
124 |
125 | this[P_TRACKED_INPUT].inputSourceChanged =
126 | this.connected !== this[P_TRACKED_INPUT].lastFrameConnected;
127 | this[P_TRACKED_INPUT].lastFrameConnected = this.connected;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/device/configs/headset/meta.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | XREnvironmentBlendMode,
10 | XRInteractionMode,
11 | } from '../../../session/XRSession.js';
12 | import {
13 | metaQuestTouchPlus,
14 | metaQuestTouchPro,
15 | oculusTouchV2,
16 | oculusTouchV3,
17 | } from '../controller/meta.js';
18 |
19 | import { XRDeviceConfig } from '../../XRDevice.js';
20 |
21 | export const oculusQuest1: XRDeviceConfig = {
22 | name: 'Oculus Quest 1',
23 | controllerConfig: oculusTouchV2,
24 | supportedSessionModes: ['inline', 'immersive-vr', 'immersive-ar'],
25 | supportedFeatures: [
26 | 'viewer',
27 | 'local',
28 | 'local-floor',
29 | 'bounded-floor',
30 | 'unbounded',
31 | 'anchors',
32 | 'plane-detection',
33 | 'hand-tracking',
34 | ],
35 | supportedFrameRates: [72, 80, 90],
36 | isSystemKeyboardSupported: true,
37 | internalNominalFrameRate: 72,
38 | environmentBlendModes: {
39 | ['immersive-vr']: XREnvironmentBlendMode.Opaque,
40 | ['immersive-ar']: XREnvironmentBlendMode.AlphaBlend,
41 | },
42 | interactionMode: XRInteractionMode.WorldSpace,
43 | userAgent:
44 | 'Mozilla/5.0 (X11; Linux x86_64; Quest 1) AppleWebKit/537.36 (KHTML, like Gecko) OculusBrowser/33.0.0.x.x.x Chrome/126.0.6478.122 VR Safari/537.36',
45 | };
46 |
47 | export const metaQuest2: XRDeviceConfig = {
48 | name: 'Meta Quest 2',
49 | controllerConfig: oculusTouchV3,
50 | supportedSessionModes: ['inline', 'immersive-vr', 'immersive-ar'],
51 | supportedFeatures: [
52 | 'viewer',
53 | 'local',
54 | 'local-floor',
55 | 'bounded-floor',
56 | 'unbounded',
57 | 'anchors',
58 | 'plane-detection',
59 | 'mesh-detection',
60 | 'hit-test',
61 | 'hand-tracking',
62 | ],
63 | supportedFrameRates: [72, 80, 90, 120],
64 | isSystemKeyboardSupported: true,
65 | internalNominalFrameRate: 72,
66 | environmentBlendModes: {
67 | ['immersive-vr']: XREnvironmentBlendMode.Opaque,
68 | ['immersive-ar']: XREnvironmentBlendMode.AlphaBlend,
69 | },
70 | interactionMode: XRInteractionMode.WorldSpace,
71 | userAgent:
72 | 'Mozilla/5.0 (X11; Linux x86_64; Quest 2) AppleWebKit/537.36 (KHTML, like Gecko) OculusBrowser/33.0.0.x.x.x Chrome/126.0.6478.122 VR Safari/537.36',
73 | };
74 |
75 | export const metaQuestPro: XRDeviceConfig = {
76 | name: 'Meta Quest Pro',
77 | controllerConfig: metaQuestTouchPro,
78 | supportedSessionModes: ['inline', 'immersive-vr', 'immersive-ar'],
79 | supportedFeatures: [
80 | 'viewer',
81 | 'local',
82 | 'local-floor',
83 | 'bounded-floor',
84 | 'unbounded',
85 | 'anchors',
86 | 'plane-detection',
87 | 'mesh-detection',
88 | 'hit-test',
89 | 'hand-tracking',
90 | ],
91 | supportedFrameRates: [72, 80, 90, 120],
92 | isSystemKeyboardSupported: true,
93 | internalNominalFrameRate: 90,
94 | environmentBlendModes: {
95 | ['immersive-vr']: XREnvironmentBlendMode.Opaque,
96 | ['immersive-ar']: XREnvironmentBlendMode.AlphaBlend,
97 | },
98 | interactionMode: XRInteractionMode.WorldSpace,
99 | userAgent:
100 | 'Mozilla/5.0 (X11; Linux x86_64; Quest Pro) AppleWebKit/537.36 (KHTML, like Gecko) OculusBrowser/33.0.0.x.x.x Chrome/126.0.6478.122 VR Safari/537.36',
101 | };
102 |
103 | export const metaQuest3: XRDeviceConfig = {
104 | name: 'Meta Quest 3',
105 | controllerConfig: metaQuestTouchPlus,
106 | supportedSessionModes: ['inline', 'immersive-vr', 'immersive-ar'],
107 | supportedFeatures: [
108 | 'viewer',
109 | 'local',
110 | 'local-floor',
111 | 'bounded-floor',
112 | 'unbounded',
113 | 'anchors',
114 | 'plane-detection',
115 | 'mesh-detection',
116 | 'hit-test',
117 | 'hand-tracking',
118 | 'depth-sensing',
119 | ],
120 | supportedFrameRates: [72, 80, 90, 120],
121 | isSystemKeyboardSupported: true,
122 | internalNominalFrameRate: 90,
123 | environmentBlendModes: {
124 | ['immersive-vr']: XREnvironmentBlendMode.Opaque,
125 | ['immersive-ar']: XREnvironmentBlendMode.AlphaBlend,
126 | },
127 | interactionMode: XRInteractionMode.WorldSpace,
128 | userAgent:
129 | 'Mozilla/5.0 (X11; Linux x86_64; Quest 3) AppleWebKit/537.36 (KHTML, like Gecko) OculusBrowser/33.0.0.x.x.x Chrome/126.0.6478.122 VR Safari/537.36',
130 | };
131 |
--------------------------------------------------------------------------------
/src/events/XRInputSourceEvent.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRFrame } from '../frameloop/XRFrame.js';
9 | import { XRInputSource } from '../input/XRInputSource.js';
10 |
11 | interface XRInputSourceEventInit extends EventInit {
12 | frame: XRFrame;
13 | inputSource: XRInputSource;
14 | }
15 |
16 | export class XRInputSourceEvent extends Event {
17 | public readonly frame: XRFrame;
18 | public readonly inputSource: XRInputSource;
19 |
20 | constructor(type: string, eventInitDict: XRInputSourceEventInit) {
21 | super(type, eventInitDict);
22 | if (!eventInitDict.frame) {
23 | throw new Error('XRInputSourceEventInit.frame is required');
24 | }
25 | if (!eventInitDict.inputSource) {
26 | throw new Error('XRInputSourceEventInit.inputSource is required');
27 | }
28 | this.frame = eventInitDict.frame;
29 | this.inputSource = eventInitDict.inputSource;
30 | }
31 | }
32 |
33 | export interface XRInputSourceEventHandler {
34 | (evt: XRInputSourceEvent): any;
35 | }
36 |
--------------------------------------------------------------------------------
/src/events/XRInputSourcesChangeEvent.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRInputSource } from '../input/XRInputSource.js';
9 | import { XRSession } from '../session/XRSession.js';
10 |
11 | interface XRInputSourcesChangeEventInit extends EventInit {
12 | session: XRSession;
13 | added: XRInputSource[];
14 | removed: XRInputSource[];
15 | }
16 |
17 | export class XRInputSourcesChangeEvent extends Event {
18 | public readonly session: XRSession;
19 | public readonly added: XRInputSource[];
20 | public readonly removed: XRInputSource[];
21 |
22 | constructor(type: string, eventInitDict: XRInputSourcesChangeEventInit) {
23 | super(type, eventInitDict);
24 | if (!eventInitDict.session) {
25 | throw new Error('XRInputSourcesChangeEventInit.session is required');
26 | }
27 | if (!eventInitDict.added) {
28 | throw new Error('XRInputSourcesChangeEventInit.added is required');
29 | }
30 | if (!eventInitDict.removed) {
31 | throw new Error('XRInputSourcesChangeEventInit.removed is required');
32 | }
33 | this.session = eventInitDict.session;
34 | this.added = eventInitDict.added;
35 | this.removed = eventInitDict.removed;
36 | }
37 | }
38 |
39 | export interface XRInputSourcesChangeEventHandler {
40 | (evt: XRInputSourcesChangeEvent): any;
41 | }
42 |
--------------------------------------------------------------------------------
/src/events/XRReferenceSpaceEvent.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRReferenceSpace } from '../spaces/XRReferenceSpace.js';
9 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
10 |
11 | interface XRReferenceSpaceEventInit extends EventInit {
12 | referenceSpace: XRReferenceSpace;
13 | transform?: XRRigidTransform;
14 | }
15 |
16 | export class XRReferenceSpaceEvent extends Event {
17 | public readonly referenceSpace: XRReferenceSpace;
18 | public readonly transform?: XRRigidTransform;
19 |
20 | constructor(type: string, eventInitDict: XRReferenceSpaceEventInit) {
21 | super(type, eventInitDict);
22 | if (!eventInitDict.referenceSpace) {
23 | throw new Error('XRReferenceSpaceEventInit.referenceSpace is required');
24 | }
25 | this.referenceSpace = eventInitDict.referenceSpace;
26 | this.transform = eventInitDict.transform;
27 | }
28 | }
29 |
30 | export interface XRReferenceSpaceEventHandler {
31 | (evt: XRReferenceSpaceEvent): any;
32 | }
33 |
--------------------------------------------------------------------------------
/src/events/XRSessionEvent.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRSession } from '../session/XRSession.js';
9 |
10 | interface XRSessionEventInit extends EventInit {
11 | session: XRSession;
12 | }
13 |
14 | export class XRSessionEvent extends Event {
15 | public readonly session: XRSession;
16 |
17 | constructor(type: string, eventInitDict: XRSessionEventInit) {
18 | super(type, eventInitDict);
19 | if (!eventInitDict.session) {
20 | throw new Error('XRSessionEventInit.session is required');
21 | }
22 | this.session = eventInitDict.session;
23 | }
24 | }
25 |
26 | export interface XRSessionEventHandler {
27 | (evt: XRSessionEvent): any;
28 | }
29 |
--------------------------------------------------------------------------------
/src/gamepad/Gamepad.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_GAMEPAD } from '../private.js';
9 |
10 | export enum GamepadMappingType {
11 | None = '',
12 | Standard = 'standard',
13 | XRStandard = 'xr-standard',
14 | }
15 |
16 | export interface Button {
17 | id: string;
18 | type: 'binary' | 'analog' | 'manual';
19 | eventTrigger?: 'select' | 'squeeze';
20 | }
21 |
22 | export interface Axis {
23 | id: string;
24 | type: 'x-axis' | 'y-axis' | 'manual';
25 | }
26 |
27 | export interface GamepadConfig {
28 | mapping: GamepadMappingType;
29 | buttons: (Button | null)[];
30 | axes: (Axis | null)[];
31 | }
32 |
33 | export class GamepadButton {
34 | [P_GAMEPAD]: {
35 | type: 'analog' | 'binary' | 'manual';
36 | eventTrigger: 'select' | 'squeeze' | null;
37 | pressed: boolean;
38 | touched: boolean;
39 | value: number;
40 | lastFrameValue: number;
41 | pendingValue: number | null;
42 | };
43 |
44 | constructor(
45 | type: 'analog' | 'binary' | 'manual',
46 | eventTrigger: 'select' | 'squeeze' | null,
47 | ) {
48 | this[P_GAMEPAD] = {
49 | type,
50 | eventTrigger,
51 | pressed: false,
52 | touched: false,
53 | value: 0,
54 | lastFrameValue: 0,
55 | pendingValue: null,
56 | };
57 | }
58 |
59 | get pressed() {
60 | if (this[P_GAMEPAD].type === 'manual') {
61 | return this[P_GAMEPAD].pressed;
62 | } else {
63 | return this[P_GAMEPAD].value > 0;
64 | }
65 | }
66 |
67 | get touched() {
68 | if (this[P_GAMEPAD].type === 'manual') {
69 | return this[P_GAMEPAD].touched;
70 | } else {
71 | return this[P_GAMEPAD].touched || this.pressed;
72 | }
73 | }
74 |
75 | get value() {
76 | return this[P_GAMEPAD].value;
77 | }
78 | }
79 |
80 | export class EmptyGamepadButton {
81 | pressed = false;
82 | touched = false;
83 | value = 0;
84 | }
85 |
86 | export class Gamepad {
87 | [P_GAMEPAD]: {
88 | id: string;
89 | index: number;
90 | connected: boolean;
91 | timestamp: DOMHighResTimeStamp;
92 | mapping: GamepadMappingType;
93 | buttonsMap: {
94 | [id: string]: GamepadButton | null;
95 | };
96 | buttonsSequence: (string | null)[];
97 | axesMap: {
98 | [id: string]: { x: number; y: number };
99 | };
100 | axesSequence: (string | null)[];
101 | hapticActuators: GamepadHapticActuator[];
102 | };
103 |
104 | constructor(
105 | gamepadConfig: GamepadConfig,
106 | id: string = '',
107 | index: number = -1,
108 | ) {
109 | this[P_GAMEPAD] = {
110 | id,
111 | index,
112 | connected: false,
113 | timestamp: performance.now(),
114 | mapping: gamepadConfig.mapping,
115 | buttonsMap: {},
116 | buttonsSequence: [],
117 | axesMap: {},
118 | axesSequence: [],
119 | hapticActuators: [],
120 | };
121 | gamepadConfig.buttons.forEach((buttonConfig) => {
122 | if (buttonConfig === null) {
123 | this[P_GAMEPAD].buttonsSequence.push(null);
124 | } else {
125 | this[P_GAMEPAD].buttonsSequence.push(buttonConfig.id);
126 | this[P_GAMEPAD].buttonsMap[buttonConfig.id] = new GamepadButton(
127 | buttonConfig.type,
128 | buttonConfig.eventTrigger ?? null,
129 | );
130 | }
131 | });
132 | gamepadConfig.axes.forEach((axisConfig) => {
133 | if (axisConfig === null) {
134 | this[P_GAMEPAD].axesSequence.push(null);
135 | } else {
136 | this[P_GAMEPAD].axesSequence.push(axisConfig.id + axisConfig.type);
137 | if (!this[P_GAMEPAD].axesMap[axisConfig.id]) {
138 | this[P_GAMEPAD].axesMap[axisConfig.id] = { x: 0, y: 0 };
139 | }
140 | }
141 | });
142 | }
143 |
144 | get id() {
145 | return this[P_GAMEPAD].id;
146 | }
147 |
148 | get index() {
149 | return this[P_GAMEPAD].index;
150 | }
151 |
152 | get connected() {
153 | return this[P_GAMEPAD].connected;
154 | }
155 |
156 | get timestamp() {
157 | return this[P_GAMEPAD].timestamp;
158 | }
159 |
160 | get mapping() {
161 | return this[P_GAMEPAD].mapping;
162 | }
163 |
164 | get axes() {
165 | const axes: (number | null)[] = [];
166 | this[P_GAMEPAD].axesSequence.forEach((id) => {
167 | if (id === null) {
168 | axes.push(null);
169 | } else {
170 | const axisId = id.substring(0, id.length - 6);
171 | const axisType = id.substring(id.length - 6);
172 | axes.push(
173 | // if axis type is manual, then return the x value
174 | axisType === 'y-axis'
175 | ? this[P_GAMEPAD].axesMap[axisId].y
176 | : this[P_GAMEPAD].axesMap[axisId].x,
177 | );
178 | }
179 | });
180 | return axes;
181 | }
182 |
183 | get buttons() {
184 | return this[P_GAMEPAD].buttonsSequence.map((id) =>
185 | id === null ? new EmptyGamepadButton() : this[P_GAMEPAD].buttonsMap[id],
186 | );
187 | }
188 |
189 | get hapticActuators() {
190 | return this[P_GAMEPAD].hapticActuators;
191 | }
192 |
193 | get vibrationActuator() {
194 | return null;
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/hittest/XRHitTest.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_HIT_TEST, P_SESSION } from '../private.js';
9 |
10 | import { XRFrame } from '../frameloop/XRFrame.js';
11 | import { XRPose } from '../pose/XRPose.js';
12 | import { XRRay } from './XRRay.js';
13 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
14 | import { XRSession } from '../session/XRSession.js';
15 | import { XRSpace } from '../spaces/XRSpace.js';
16 |
17 | export interface XRHitTestOptionsInit {
18 | space: XRSpace;
19 | offsetRay: XRRay;
20 | }
21 |
22 | export class XRHitTestSource {
23 | [P_HIT_TEST]: {
24 | session: XRSession;
25 | space: XRSpace;
26 | offsetRay: XRRay;
27 | };
28 |
29 | constructor(session: XRSession, options: XRHitTestOptionsInit) {
30 | this[P_HIT_TEST] = {
31 | session,
32 | space: options.space,
33 | offsetRay: options.offsetRay ?? new XRRay(),
34 | };
35 | }
36 |
37 | cancel() {
38 | this[P_HIT_TEST].session[P_SESSION].hitTestSources.delete(this);
39 | }
40 | }
41 |
42 | export class XRHitTestResult {
43 | [P_HIT_TEST]: {
44 | frame: XRFrame;
45 | offsetSpace: XRSpace;
46 | };
47 |
48 | constructor(frame: XRFrame, offsetSpace: XRSpace) {
49 | this[P_HIT_TEST] = { frame, offsetSpace };
50 | }
51 |
52 | getPose(baseSpace: XRSpace): XRPose | undefined {
53 | return this[P_HIT_TEST].frame.getPose(
54 | this[P_HIT_TEST].offsetSpace,
55 | baseSpace,
56 | );
57 | }
58 |
59 | createAnchor() {
60 | return this[P_HIT_TEST].frame.createAnchor(
61 | new XRRigidTransform(),
62 | this[P_HIT_TEST].offsetSpace,
63 | );
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/hittest/XRRay.ts:
--------------------------------------------------------------------------------
1 | import { mat4, vec3, vec4 } from 'gl-matrix';
2 |
3 | import { P_RAY } from '../private.js';
4 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
5 |
6 | class DOMPointReadOnly {
7 | constructor(
8 | public x: number = 0,
9 | public y: number = 0,
10 | public z: number = 0,
11 | public w: number = 1,
12 | ) {}
13 | }
14 |
15 | export class XRRay {
16 | [P_RAY]: {
17 | origin: DOMPointReadOnly;
18 | direction: DOMPointReadOnly;
19 | matrix: Float32Array | null;
20 | };
21 |
22 | constructor(
23 | origin?: DOMPointInit | XRRigidTransform,
24 | direction?: DOMPointInit,
25 | ) {
26 | const _origin: DOMPointInit = { x: 0, y: 0, z: 0, w: 1 };
27 | const _direction: DOMPointInit = { x: 0, y: 0, z: -1, w: 0 };
28 |
29 | if (origin instanceof XRRigidTransform) {
30 | const transform = origin;
31 | const matrix = transform.matrix;
32 | const originVec4 = vec4.set(
33 | vec4.create(),
34 | _origin.x!,
35 | _origin.y!,
36 | _origin.z!,
37 | _origin.w!,
38 | );
39 | const directionVec4 = vec4.set(
40 | vec4.create(),
41 | _direction.x!,
42 | _direction.y!,
43 | _direction.z!,
44 | _direction.w!,
45 | );
46 | vec4.transformMat4(originVec4, originVec4, matrix);
47 | vec4.transformMat4(directionVec4, directionVec4, matrix);
48 | _origin.x = originVec4[0];
49 | _origin.y = originVec4[1];
50 | _origin.z = originVec4[2];
51 | _origin.w = originVec4[3];
52 | _direction.x = directionVec4[0];
53 | _direction.y = directionVec4[1];
54 | _direction.z = directionVec4[2];
55 | _direction.w = directionVec4[3];
56 | } else {
57 | if (origin) {
58 | _origin.x = origin.x;
59 | _origin.y = origin.y;
60 | _origin.z = origin.z;
61 | _origin.w = origin.w;
62 | }
63 | if (direction) {
64 | if (
65 | (direction.x === 0 && direction.y === 0 && direction.z === 0) ||
66 | direction.w !== 0
67 | ) {
68 | throw new DOMException(
69 | 'Invalid direction value to construct XRRay',
70 | 'TypeError',
71 | );
72 | }
73 | _direction.x = direction.x;
74 | _direction.y = direction.y;
75 | _direction.z = direction.z;
76 | _direction.w = direction.w;
77 | }
78 | }
79 |
80 | const length =
81 | Math.sqrt(
82 | _direction.x! * _direction.x! +
83 | _direction.y! * _direction.y! +
84 | _direction.z! * _direction.z!,
85 | ) || 1;
86 | _direction.x = _direction.x! / length;
87 | _direction.y = _direction.y! / length;
88 | _direction.z = _direction.z! / length;
89 |
90 | this[P_RAY] = {
91 | origin: new DOMPointReadOnly(
92 | _origin.x!,
93 | _origin.y!,
94 | _origin.z!,
95 | _origin.w!,
96 | ),
97 | direction: new DOMPointReadOnly(
98 | _direction.x!,
99 | _direction.y!,
100 | _direction.z!,
101 | _direction.w!,
102 | ),
103 | matrix: null,
104 | };
105 | }
106 |
107 | get origin(): DOMPointReadOnly {
108 | return this[P_RAY].origin;
109 | }
110 |
111 | get direction(): DOMPointReadOnly {
112 | return this[P_RAY].direction;
113 | }
114 |
115 | get matrix(): Float32Array {
116 | if (this[P_RAY].matrix) {
117 | return this[P_RAY].matrix;
118 | }
119 | const z = vec3.set(vec3.create(), 0, 0, -1);
120 | const origin = vec3.set(
121 | vec3.create(),
122 | this[P_RAY].origin.x,
123 | this[P_RAY].origin.y,
124 | this[P_RAY].origin.z,
125 | );
126 | const direction = vec3.set(
127 | vec3.create(),
128 | this[P_RAY].direction.x,
129 | this[P_RAY].direction.y,
130 | this[P_RAY].direction.z,
131 | );
132 | const axis = vec3.cross(vec3.create(), direction, z);
133 | const cosAngle = vec3.dot(direction, z);
134 | const rotation = mat4.create();
135 | if (cosAngle > -1 && cosAngle < 1) {
136 | mat4.fromRotation(rotation, Math.acos(cosAngle), axis);
137 | } else if (cosAngle === -1) {
138 | mat4.fromRotation(
139 | rotation,
140 | Math.acos(cosAngle),
141 | vec3.set(vec3.create(), 1, 0, 0),
142 | );
143 | } else {
144 | mat4.identity(rotation);
145 | }
146 |
147 | const translation = mat4.fromTranslation(mat4.create(), origin);
148 | const matrix = mat4.multiply(mat4.create(), translation, rotation);
149 | this[P_RAY].matrix = new Float32Array(matrix);
150 | return this[P_RAY].matrix;
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | // model
9 | export { XRDevice, XRDeviceConfig } from './device/XRDevice.js';
10 | export {
11 | metaQuest2,
12 | metaQuest3,
13 | metaQuestPro,
14 | oculusQuest1,
15 | } from './device/configs/headset/meta.js';
16 |
17 | // Initialization
18 | export { XRSystem } from './initialization/XRSystem.js';
19 |
20 | // Session
21 | export { XRRenderState } from './session/XRRenderState.js';
22 | export { XRSession } from './session/XRSession.js';
23 |
24 | // Frame Loop
25 | export { XRFrame } from './frameloop/XRFrame.js';
26 |
27 | // Spaces
28 | export { XRSpace } from './spaces/XRSpace.js';
29 | export { XRReferenceSpace } from './spaces/XRReferenceSpace.js';
30 | export { XRJointSpace } from './spaces/XRJointSpace.js';
31 |
32 | // Views
33 | export { XRView } from './views/XRView.js';
34 | export { XRViewport } from './views/XRViewport.js';
35 |
36 | // Primitives
37 | export { XRRigidTransform } from './primitives/XRRigidTransform.js';
38 |
39 | // Pose
40 | export { XRPose } from './pose/XRPose.js';
41 | export { XRViewerPose } from './pose/XRViewerPose.js';
42 | export { XRJointPose } from './pose/XRJointPose.js';
43 |
44 | // Input
45 | export { XRInputSource, XRInputSourceArray } from './input/XRInputSource.js';
46 | export { XRHand } from './input/XRHand.js';
47 |
48 | // Layers
49 | export { XRWebGLLayer, XRLayer } from './layers/XRWebGLLayer.js';
50 |
51 | // Planes
52 | export { XRPlane, XRPlaneSet, NativePlane } from './planes/XRPlane.js';
53 |
54 | // Meshes
55 | export { XRMesh, XRMeshSet, NativeMesh } from './meshes/XRMesh.js';
56 | export { XRSemanticLabels } from './labels/labels.js';
57 |
58 | // Anchors
59 | export { XRAnchor, XRAnchorSet } from './anchors/XRAnchor.js';
60 |
61 | // Hit Test
62 | export { XRRay } from './hittest/XRRay.js';
63 |
64 | // Events
65 | export { XRSessionEvent } from './events/XRSessionEvent.js';
66 | export { XRInputSourceEvent } from './events/XRInputSourceEvent.js';
67 | export { XRInputSourcesChangeEvent } from './events/XRInputSourcesChangeEvent.js';
68 | export { XRReferenceSpaceEvent } from './events/XRReferenceSpaceEvent.js';
69 |
70 | // Action Recording/Playback
71 | export { ActionRecorder } from './action/ActionRecorder.js';
72 |
73 | // Private Keys
74 | export * from './private.js';
75 |
--------------------------------------------------------------------------------
/src/initialization/XRSystem.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import type { WebXRFeature, XRDevice } from '../device/XRDevice.js';
9 | import {
10 | XRSession,
11 | XRSessionInit,
12 | XRSessionMode,
13 | } from '../session/XRSession.js';
14 |
15 | import { P_SYSTEM } from '../private.js';
16 |
17 | type SessionGrantConfig = {
18 | resolve: (value: XRSession) => void;
19 | reject: (reason?: any) => void;
20 | mode: XRSessionMode;
21 | options: XRSessionInit;
22 | };
23 |
24 | export class XRSystem extends EventTarget {
25 | [P_SYSTEM]: {
26 | device: XRDevice;
27 | activeSession?: XRSession;
28 | grantSession: (SessionGrantConfig: SessionGrantConfig) => void;
29 | offeredSessionConfig?: SessionGrantConfig;
30 | };
31 |
32 | constructor(device: XRDevice) {
33 | super();
34 | this[P_SYSTEM] = {
35 | device,
36 | grantSession: ({ resolve, reject, mode, options }) => {
37 | // Check for active sessions and other constraints here
38 | if (this[P_SYSTEM].activeSession) {
39 | reject(
40 | new DOMException(
41 | 'An active XRSession already exists.',
42 | 'InvalidStateError',
43 | ),
44 | );
45 | return;
46 | }
47 |
48 | // Handle required and optional features
49 | const { requiredFeatures = [], optionalFeatures = [] } = options;
50 | const { supportedFeatures } = this[P_SYSTEM].device;
51 |
52 | // Check if all required features are supported
53 | const allRequiredSupported = requiredFeatures.every((feature) =>
54 | supportedFeatures.includes(feature),
55 | );
56 | if (!allRequiredSupported) {
57 | reject(
58 | new Error(
59 | 'One or more required features are not supported by the device.',
60 | ),
61 | );
62 | return;
63 | }
64 |
65 | // Filter out unsupported optional features
66 | const supportedOptionalFeatures = optionalFeatures.filter((feature) =>
67 | supportedFeatures.includes(feature),
68 | );
69 |
70 | // Combine required and supported optional features into enabled features
71 | const enabledFeatures: WebXRFeature[] = Array.from(
72 | new Set([
73 | ...requiredFeatures,
74 | ...supportedOptionalFeatures,
75 | 'viewer',
76 | 'local',
77 | ]),
78 | );
79 |
80 | // Proceed with session creation
81 | const session = new XRSession(
82 | this[P_SYSTEM].device,
83 | mode,
84 | enabledFeatures,
85 | );
86 | this[P_SYSTEM].activeSession = session;
87 |
88 | // Listen for session end to clear the active session
89 | session.addEventListener('end', () => {
90 | this[P_SYSTEM].activeSession = undefined;
91 | });
92 |
93 | resolve(session);
94 | },
95 | };
96 | // Initialize device change monitoring here if applicable
97 | }
98 |
99 | isSessionSupported(mode: XRSessionMode): Promise {
100 | return new Promise((resolve, _reject) => {
101 | if (mode === 'inline') {
102 | resolve(true);
103 | } else {
104 | // Check for spatial tracking permission if necessary
105 | resolve(this[P_SYSTEM].device.supportedSessionModes.includes(mode));
106 | }
107 | });
108 | }
109 |
110 | requestSession(
111 | mode: XRSessionMode,
112 | options: XRSessionInit = {},
113 | ): Promise {
114 | return new Promise((resolve, reject) => {
115 | this.isSessionSupported(mode)
116 | .then((isSupported) => {
117 | if (!isSupported) {
118 | reject(
119 | new DOMException(
120 | 'The requested XRSession mode is not supported.',
121 | 'NotSupportedError',
122 | ),
123 | );
124 | return;
125 | }
126 |
127 | const sessionGrantConfig = {
128 | resolve,
129 | reject,
130 | mode,
131 | options,
132 | };
133 |
134 | this[P_SYSTEM].grantSession(sessionGrantConfig);
135 | })
136 | .catch(reject);
137 | });
138 | }
139 |
140 | offerSession(
141 | mode: XRSessionMode,
142 | options: XRSessionInit = {},
143 | ): Promise {
144 | return new Promise((resolve, reject) => {
145 | this.isSessionSupported(mode)
146 | .then((isSupported) => {
147 | if (!isSupported) {
148 | reject(
149 | new DOMException(
150 | 'The requested XRSession mode is not supported.',
151 | 'NotSupportedError',
152 | ),
153 | );
154 | return;
155 | }
156 |
157 | this[P_SYSTEM].offeredSessionConfig = {
158 | resolve,
159 | reject,
160 | mode,
161 | options,
162 | };
163 | })
164 | .catch(reject);
165 | });
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/input/XRHand.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRJointSpace } from '../spaces/XRJointSpace.js';
9 |
10 | export enum XRHandJoint {
11 | Wrist = 'wrist',
12 |
13 | ThumbMetacarpal = 'thumb-metacarpal',
14 | ThumbPhalanxProximal = 'thumb-phalanx-proximal',
15 | ThumbPhalanxDistal = 'thumb-phalanx-distal',
16 | ThumbTip = 'thumb-tip',
17 |
18 | IndexFingerMetacarpal = 'index-finger-metacarpal',
19 | IndexFingerPhalanxProximal = 'index-finger-phalanx-proximal',
20 | IndexFingerPhalanxIntermediate = 'index-finger-phalanx-intermediate',
21 | IndexFingerPhalanxDistal = 'index-finger-phalanx-distal',
22 | IndexFingerTip = 'index-finger-tip',
23 |
24 | MiddleFingerMetacarpal = 'middle-finger-metacarpal',
25 | MiddleFingerPhalanxProximal = 'middle-finger-phalanx-proximal',
26 | MiddleFingerPhalanxIntermediate = 'middle-finger-phalanx-intermediate',
27 | MiddleFingerPhalanxDistal = 'middle-finger-phalanx-distal',
28 | MiddleFingerTip = 'middle-finger-tip',
29 |
30 | RingFingerMetacarpal = 'ring-finger-metacarpal',
31 | RingFingerPhalanxProximal = 'ring-finger-phalanx-proximal',
32 | RingFingerPhalanxIntermediate = 'ring-finger-phalanx-intermediate',
33 | RingFingerPhalanxDistal = 'ring-finger-phalanx-distal',
34 | RingFingerTip = 'ring-finger-tip',
35 |
36 | PinkyFingerMetacarpal = 'pinky-finger-metacarpal',
37 | PinkyFingerPhalanxProximal = 'pinky-finger-phalanx-proximal',
38 | PinkyFingerPhalanxIntermediate = 'pinky-finger-phalanx-intermediate',
39 | PinkyFingerPhalanxDistal = 'pinky-finger-phalanx-distal',
40 | PinkyFingerTip = 'pinky-finger-tip',
41 | }
42 |
43 | export class XRHand extends Map {}
44 |
--------------------------------------------------------------------------------
/src/input/XRInputSource.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { Gamepad } from '../gamepad/Gamepad.js';
9 | import { P_INPUT_SOURCE } from '../private.js';
10 | import { XRHand } from './XRHand.js';
11 | import { XRSpace } from '../spaces/XRSpace.js';
12 |
13 | export enum XRHandedness {
14 | None = 'none',
15 | Left = 'left',
16 | Right = 'right',
17 | }
18 |
19 | export enum XRTargetRayMode {
20 | Gaze = 'gaze',
21 | TrackedPointer = 'tracked-pointer',
22 | Screen = 'screen',
23 | TransientPointer = 'transient-pointer',
24 | }
25 |
26 | export class XRInputSourceArray extends Array {}
27 |
28 | export class XRInputSource {
29 | [P_INPUT_SOURCE]: {
30 | handedness: XRHandedness;
31 | targetRayMode: XRTargetRayMode;
32 | targetRaySpace: XRSpace;
33 | gripSpace?: XRSpace;
34 | profiles: Array;
35 | gamepad?: Gamepad;
36 | hand?: XRHand;
37 | };
38 |
39 | constructor(
40 | handedness: XRHandedness,
41 | targetRayMode: XRTargetRayMode,
42 | profiles: string[],
43 | targetRaySpace: XRSpace,
44 | gamepad?: Gamepad,
45 | gripSpace?: XRSpace,
46 | hand?: XRHand,
47 | ) {
48 | this[P_INPUT_SOURCE] = {
49 | handedness,
50 | targetRayMode,
51 | targetRaySpace,
52 | gripSpace,
53 | profiles,
54 | gamepad,
55 | hand,
56 | };
57 | }
58 |
59 | get handedness() {
60 | return this[P_INPUT_SOURCE].handedness;
61 | }
62 |
63 | get targetRayMode() {
64 | return this[P_INPUT_SOURCE].targetRayMode;
65 | }
66 |
67 | get targetRaySpace() {
68 | return this[P_INPUT_SOURCE].targetRaySpace;
69 | }
70 |
71 | get gripSpace() {
72 | return this[P_INPUT_SOURCE].gripSpace;
73 | }
74 |
75 | get profiles() {
76 | return this[P_INPUT_SOURCE].profiles;
77 | }
78 |
79 | get gamepad() {
80 | return this[P_INPUT_SOURCE].gamepad;
81 | }
82 |
83 | get hand() {
84 | return this[P_INPUT_SOURCE].hand;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/labels/labels.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | // Source: https://github.com/immersive-web/semantic-labels/blob/master/labels.json
9 |
10 | /**
11 | * Enum for semantic labels.
12 | * For more details, see the {@link https://github.com/immersive-web/semantic-labels | Semantic Labels Documentation}.
13 | */
14 | export enum XRSemanticLabels {
15 | Desk = 'desk',
16 | Couch = 'couch',
17 | Floor = 'floor',
18 | Ceiling = 'ceiling',
19 | Wall = 'wall',
20 | Door = 'door',
21 | Window = 'window',
22 | Table = 'table',
23 | Shelf = 'shelf',
24 | Bed = 'bed',
25 | Screen = 'screen',
26 | Lamp = 'lamp',
27 | Plant = 'plant',
28 | WallArt = 'wall art',
29 | GlobalMesh = 'global mesh',
30 | Other = 'other',
31 | }
32 |
--------------------------------------------------------------------------------
/src/layers/XRWebGLLayer.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_DEVICE, P_SESSION, P_VIEW, P_WEBGL_LAYER } from '../private.js';
9 |
10 | import { XRSession } from '../session/XRSession.js';
11 | import { XRView } from '../views/XRView.js';
12 |
13 | export class XRLayer extends EventTarget {}
14 |
15 | type LayerInit = {
16 | antialias?: boolean;
17 | depth?: boolean;
18 | stencil?: boolean;
19 | alpha?: boolean;
20 | ignoreDepthValues?: boolean;
21 | framebufferScaleFactor?: number;
22 | };
23 |
24 | const defaultLayerInit: LayerInit = {
25 | antialias: true,
26 | depth: true,
27 | stencil: false,
28 | alpha: true,
29 | ignoreDepthValues: false,
30 | framebufferScaleFactor: 1.0,
31 | };
32 |
33 | export class XRWebGLLayer extends XRLayer {
34 | [P_WEBGL_LAYER]: {
35 | session: XRSession;
36 | context: WebGLRenderingContext | WebGL2RenderingContext;
37 | antialias: boolean;
38 | };
39 |
40 | constructor(
41 | session: XRSession,
42 | context: WebGLRenderingContext | WebGL2RenderingContext,
43 | layerInit: LayerInit = {},
44 | ) {
45 | super();
46 |
47 | if (session[P_SESSION].ended) {
48 | throw new DOMException('Session has ended', 'InvalidStateError');
49 | }
50 |
51 | // TO-DO: Check that the context attribute has xrCompatible set to true
52 | // may require polyfilling the context and perhaps canvas.getContext
53 |
54 | // Default values for XRWebGLLayerInit, can be overridden by layerInit
55 | const config = { ...defaultLayerInit, ...layerInit };
56 |
57 | this[P_WEBGL_LAYER] = {
58 | session,
59 | context,
60 | antialias: config.antialias!,
61 | };
62 | }
63 |
64 | get context() {
65 | return this[P_WEBGL_LAYER].context;
66 | }
67 |
68 | get antialias() {
69 | return this[P_WEBGL_LAYER].antialias;
70 | }
71 |
72 | get ignoreDepthValues() {
73 | return true;
74 | }
75 |
76 | get framebuffer() {
77 | return null;
78 | }
79 |
80 | get framebufferWidth() {
81 | return this[P_WEBGL_LAYER].context.drawingBufferWidth;
82 | }
83 |
84 | get framebufferHeight() {
85 | return this[P_WEBGL_LAYER].context.drawingBufferHeight;
86 | }
87 |
88 | getViewport(view: XRView) {
89 | if (view[P_VIEW].session !== this[P_WEBGL_LAYER].session) {
90 | throw new DOMException(
91 | "View's session differs from Layer's session",
92 | 'InvalidStateError',
93 | );
94 | }
95 | // TO-DO: check frame
96 | return this[P_WEBGL_LAYER].session[P_SESSION].device[P_DEVICE].getViewport(
97 | this,
98 | view,
99 | );
100 | }
101 |
102 | static getNativeFramebufferScaleFactor(session: XRSession): number {
103 | if (!(session instanceof XRSession)) {
104 | throw new TypeError(
105 | 'getNativeFramebufferScaleFactor must be passed a session.',
106 | );
107 | }
108 |
109 | if (session[P_SESSION].ended) {
110 | return 0.0;
111 | }
112 |
113 | // Return 1.0 for simplicity, actual implementation might vary based on the device capabilities
114 | return 1.0;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/meshes/XRMesh.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_MESH } from '../private.js';
9 | import type { XRFrame } from '../frameloop/XRFrame.js';
10 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
11 | import { XRSemanticLabels } from '../labels/labels.js';
12 | import { XRSpace } from '../spaces/XRSpace.js';
13 |
14 | export class XRMesh {
15 | [P_MESH]: {
16 | nativeMesh: NativeMesh;
17 | frame: XRFrame;
18 | meshSpace: XRSpace;
19 | vertices: Float32Array;
20 | indices: Uint32Array;
21 | lastChangedTime: DOMHighResTimeStamp;
22 | semanticLabel?: XRSemanticLabels;
23 | };
24 |
25 | constructor(
26 | nativeMesh: NativeMesh,
27 | meshSpace: XRSpace,
28 | vertices: Float32Array,
29 | indices: Uint32Array,
30 | semanticLabel?: XRSemanticLabels,
31 | ) {
32 | this[P_MESH] = {
33 | nativeMesh,
34 | frame: undefined!,
35 | meshSpace,
36 | vertices,
37 | indices,
38 | lastChangedTime: performance.now(),
39 | semanticLabel,
40 | };
41 | }
42 |
43 | get meshSpace() {
44 | return this[P_MESH].meshSpace;
45 | }
46 |
47 | get vertices(): Readonly {
48 | return this[P_MESH].vertices;
49 | }
50 |
51 | get indices(): Readonly {
52 | return this[P_MESH].indices;
53 | }
54 |
55 | get lastChangedTime() {
56 | return this[P_MESH].lastChangedTime;
57 | }
58 |
59 | get semanticLabel() {
60 | return this[P_MESH].semanticLabel;
61 | }
62 | }
63 |
64 | export class XRMeshSet extends Set {}
65 |
66 | export class NativeMesh {
67 | constructor(
68 | public transform: XRRigidTransform,
69 | public vertices: Float32Array,
70 | public indices: Uint32Array,
71 | public semanticLabel: XRSemanticLabels,
72 | ) {}
73 | }
74 |
--------------------------------------------------------------------------------
/src/planes/XRPlane.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_PLANE } from '../private.js';
9 | import type { XRFrame } from '../frameloop/XRFrame.js';
10 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
11 | import { XRSemanticLabels } from '../labels/labels.js';
12 | import { XRSpace } from '../spaces/XRSpace.js';
13 |
14 | export enum XRPlaneOrientation {
15 | Horizontal = 'horizontal',
16 | Vertical = 'vertical',
17 | }
18 |
19 | /**
20 | * XRPlane orientation mapping from semantic labels.
21 | * For more details, see the {@link https://github.com/immersive-web/semantic-labels | Semantic Labels Documentation}.
22 | */
23 | export const XREntityOrientation: Partial<
24 | Record
25 | > = {
26 | [XRSemanticLabels.Desk]: XRPlaneOrientation.Horizontal,
27 | [XRSemanticLabels.Couch]: XRPlaneOrientation.Horizontal,
28 | [XRSemanticLabels.Floor]: XRPlaneOrientation.Horizontal,
29 | [XRSemanticLabels.Ceiling]: XRPlaneOrientation.Horizontal,
30 | [XRSemanticLabels.Wall]: XRPlaneOrientation.Vertical,
31 | [XRSemanticLabels.Door]: XRPlaneOrientation.Vertical,
32 | [XRSemanticLabels.Window]: XRPlaneOrientation.Vertical,
33 | [XRSemanticLabels.Table]: XRPlaneOrientation.Horizontal,
34 | [XRSemanticLabels.Shelf]: XRPlaneOrientation.Horizontal,
35 | [XRSemanticLabels.Bed]: XRPlaneOrientation.Horizontal,
36 | [XRSemanticLabels.Screen]: XRPlaneOrientation.Horizontal,
37 | [XRSemanticLabels.Lamp]: XRPlaneOrientation.Horizontal,
38 | [XRSemanticLabels.Plant]: XRPlaneOrientation.Horizontal,
39 | [XRSemanticLabels.WallArt]: XRPlaneOrientation.Vertical,
40 | };
41 |
42 | export class XRPlane {
43 | [P_PLANE]: {
44 | nativePlane: NativePlane;
45 | frame: XRFrame;
46 | planeSpace: XRSpace;
47 | polygon: DOMPointReadOnly[];
48 | lastChangedTime: DOMHighResTimeStamp;
49 | semanticLabel?: XRSemanticLabels;
50 | orientation?: XRPlaneOrientation;
51 | };
52 |
53 | constructor(
54 | nativePlane: NativePlane,
55 | planeSpace: XRSpace,
56 | polygon: DOMPointReadOnly[],
57 | semanticLabel?: XRSemanticLabels,
58 | ) {
59 | this[P_PLANE] = {
60 | nativePlane,
61 | frame: undefined!,
62 | planeSpace,
63 | polygon,
64 | lastChangedTime: performance.now(),
65 | semanticLabel,
66 | orientation: semanticLabel
67 | ? XREntityOrientation[semanticLabel]
68 | : undefined,
69 | };
70 | }
71 |
72 | get planeSpace() {
73 | return this[P_PLANE].planeSpace;
74 | }
75 |
76 | get polygon(): ReadonlyArray {
77 | return this[P_PLANE].polygon;
78 | }
79 |
80 | get orientation() {
81 | return this[P_PLANE].orientation;
82 | }
83 |
84 | get lastChangedTime() {
85 | return this[P_PLANE].lastChangedTime;
86 | }
87 |
88 | get semanticLabel() {
89 | return this[P_PLANE].semanticLabel;
90 | }
91 | }
92 |
93 | export class XRPlaneSet extends Set {}
94 |
95 | export class NativePlane {
96 | constructor(
97 | public transform: XRRigidTransform,
98 | public polygon: DOMPointReadOnly[],
99 | public semanticLabel: XRSemanticLabels,
100 | ) {}
101 | }
102 |
--------------------------------------------------------------------------------
/src/pose/XRJointPose.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_JOINT_POSE } from '../private.js';
9 | import { XRPose } from './XRPose.js';
10 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
11 |
12 | export class XRJointPose extends XRPose {
13 | [P_JOINT_POSE]: {
14 | radius: number;
15 | };
16 |
17 | constructor(
18 | transform: XRRigidTransform,
19 | radius: number,
20 | emulatedPosition: boolean = false,
21 | linearVelocity: DOMPointReadOnly | undefined = undefined,
22 | angularVelocity: DOMPointReadOnly | undefined = undefined,
23 | ) {
24 | super(transform, emulatedPosition, linearVelocity, angularVelocity);
25 | this[P_JOINT_POSE] = { radius };
26 | }
27 |
28 | get radius() {
29 | return this[P_JOINT_POSE].radius;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/pose/XRPose.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_POSE } from '../private.js';
9 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
10 |
11 | export class XRPose {
12 | [P_POSE]: {
13 | transform: XRRigidTransform;
14 | emulatedPosition: boolean;
15 | linearVelocity?: DOMPointReadOnly;
16 | angularVelocity?: DOMPointReadOnly;
17 | };
18 |
19 | constructor(
20 | transform: XRRigidTransform,
21 | emulatedPosition = false,
22 | linearVelocity: DOMPointReadOnly | undefined = undefined,
23 | angularVelocity: DOMPointReadOnly | undefined = undefined,
24 | ) {
25 | this[P_POSE] = {
26 | transform,
27 | emulatedPosition,
28 | linearVelocity,
29 | angularVelocity,
30 | };
31 | }
32 |
33 | get transform() {
34 | return this[P_POSE].transform;
35 | }
36 |
37 | get emulatedPosition() {
38 | return this[P_POSE].emulatedPosition;
39 | }
40 |
41 | get linearVelocity() {
42 | return this[P_POSE].linearVelocity;
43 | }
44 |
45 | get angularVelocity() {
46 | return this[P_POSE].angularVelocity;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/pose/XRViewerPose.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_VIEWER_POSE } from '../private.js';
9 | import { XRPose } from './XRPose.js';
10 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
11 | import { XRView } from '../views/XRView.js';
12 |
13 | export class XRViewerPose extends XRPose {
14 | [P_VIEWER_POSE]: {
15 | views: readonly XRView[];
16 | };
17 |
18 | constructor(
19 | transform: XRRigidTransform,
20 | views: XRView[],
21 | emulatedPosition: boolean = false,
22 | linearVelocity: DOMPointReadOnly | undefined = undefined,
23 | angularVelocity: DOMPointReadOnly | undefined = undefined,
24 | ) {
25 | super(transform, emulatedPosition, linearVelocity, angularVelocity);
26 | this[P_VIEWER_POSE] = {
27 | views: Object.freeze(views),
28 | };
29 | }
30 |
31 | get views() {
32 | return this[P_VIEWER_POSE].views;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/primitives/XRRigidTransform.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { mat4, quat, vec3 } from 'gl-matrix';
9 |
10 | import { P_RIGID_TRANSFORM } from '../private.js';
11 |
12 | export class XRRigidTransform {
13 | [P_RIGID_TRANSFORM]: {
14 | matrix: mat4;
15 | position: vec3;
16 | orientation: quat;
17 | inverse: XRRigidTransform | null;
18 | };
19 |
20 | constructor(position?: DOMPointInit, orientation?: DOMPointInit) {
21 | // Default values
22 | const defaultPosition = vec3.fromValues(0, 0, 0);
23 | const defaultOrientation = quat.create();
24 |
25 | this[P_RIGID_TRANSFORM] = {
26 | matrix: mat4.create(),
27 | position: position
28 | ? vec3.fromValues(position.x!, position.y!, position.z!)
29 | : defaultPosition,
30 | orientation: orientation
31 | ? quat.normalize(
32 | quat.create(),
33 | quat.fromValues(
34 | orientation.x!,
35 | orientation.y!,
36 | orientation.z!,
37 | orientation.w!,
38 | ),
39 | )
40 | : defaultOrientation,
41 | inverse: null,
42 | };
43 |
44 | this.updateMatrix();
45 | }
46 |
47 | private updateMatrix(): void {
48 | mat4.fromRotationTranslation(
49 | this[P_RIGID_TRANSFORM].matrix,
50 | this[P_RIGID_TRANSFORM].orientation,
51 | this[P_RIGID_TRANSFORM].position,
52 | );
53 | }
54 |
55 | get matrix(): Float32Array {
56 | return this[P_RIGID_TRANSFORM].matrix as Float32Array;
57 | }
58 |
59 | get position(): DOMPointReadOnly {
60 | const pos = this[P_RIGID_TRANSFORM].position;
61 | return new DOMPointReadOnly(pos[0], pos[1], pos[2], 1);
62 | }
63 |
64 | get orientation(): DOMPointReadOnly {
65 | const ori = this[P_RIGID_TRANSFORM].orientation;
66 | return new DOMPointReadOnly(ori[0], ori[1], ori[2], ori[3]);
67 | }
68 |
69 | get inverse(): XRRigidTransform {
70 | if (!this[P_RIGID_TRANSFORM].inverse) {
71 | const invMatrix = mat4.create();
72 | if (!mat4.invert(invMatrix, this[P_RIGID_TRANSFORM].matrix)) {
73 | throw new Error('Matrix is not invertible.');
74 | }
75 |
76 | // Decomposing the inverse matrix into position and orientation
77 | let invPosition = vec3.create();
78 | mat4.getTranslation(invPosition, invMatrix);
79 |
80 | let invOrientation = quat.create();
81 | mat4.getRotation(invOrientation, invMatrix);
82 |
83 | // Creating a new XRRigidTransform for the inverse
84 | this[P_RIGID_TRANSFORM].inverse = new XRRigidTransform(
85 | new DOMPointReadOnly(invPosition[0], invPosition[1], invPosition[2], 1),
86 | new DOMPointReadOnly(
87 | invOrientation[0],
88 | invOrientation[1],
89 | invOrientation[2],
90 | invOrientation[3],
91 | ),
92 | );
93 |
94 | // Setting the inverse of the inverse to be this transform
95 | this[P_RIGID_TRANSFORM].inverse[P_RIGID_TRANSFORM].inverse = this;
96 | }
97 |
98 | return this[P_RIGID_TRANSFORM].inverse;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/private.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | export const P_ACTION_PLAYER = Symbol('@iwer/action-player');
9 | export const P_ACTION_RECORDER = Symbol('@iwer/action-recorder');
10 | export const P_ANCHOR = Symbol('@iwer/xr-anchor');
11 | export const P_CONTROLLER = Symbol('@iwer/xr-controller');
12 | export const P_DEVICE = Symbol('@iwer/xr-device');
13 | export const P_HAND_INPUT = Symbol('@iwer/xr-hand-input');
14 | export const P_TRACKED_INPUT = Symbol('@iwer/xr-tracked-input');
15 | export const P_FRAME = Symbol('@iwer/xr-frame');
16 | export const P_GAMEPAD = Symbol('@iwer/gamepad');
17 | export const P_SYSTEM = Symbol('@iwer/xr-system');
18 | export const P_INPUT_SOURCE = Symbol('@iwer/xr-input-source');
19 | export const P_WEBGL_LAYER = Symbol('@iwer/xr-webgl-layer');
20 | export const P_MESH = Symbol('@iwer/xr-mesh');
21 | export const P_PLANE = Symbol('@iwer/xr-plane');
22 | export const P_JOINT_POSE = Symbol('@iwer/xr-joint-pose');
23 | export const P_POSE = Symbol('@iwer/xr-pose');
24 | export const P_VIEWER_POSE = Symbol('@iwer/xr-viewer-pose');
25 | export const P_RIGID_TRANSFORM = Symbol('@iwer/xr-rigid-transform');
26 | export const P_RENDER_STATE = Symbol('@iwer/xr-render-state');
27 | export const P_SESSION = Symbol('@iwer/xr-session');
28 | export const P_JOINT_SPACE = Symbol('@iwer/xr-joint-space');
29 | export const P_REF_SPACE = Symbol('@iwer/xr-reference-space');
30 | export const P_SPACE = Symbol('@iwer/xr-space');
31 | export const P_VIEW = Symbol('@iwer/xr-view');
32 | export const P_VIEWPORT = Symbol('@iwer/xr-viewport');
33 | export const P_RAY = Symbol('@iwer/xr-ray');
34 | export const P_HIT_TEST = Symbol('@iwer/xr-hit-test');
35 |
--------------------------------------------------------------------------------
/src/session/XRRenderState.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_RENDER_STATE } from '../private.js';
9 | import { XRWebGLLayer } from '../layers/XRWebGLLayer.js';
10 |
11 | export class XRRenderState {
12 | [P_RENDER_STATE]: {
13 | depthNear: number;
14 | depthFar: number;
15 | inlineVerticalFieldOfView: number | null;
16 | baseLayer: XRWebGLLayer | null;
17 | };
18 |
19 | constructor(init: Partial = {}, oldState?: XRRenderState) {
20 | this[P_RENDER_STATE] = {
21 | depthNear: init.depthNear || oldState?.depthNear || 0.1,
22 | depthFar: init.depthFar || oldState?.depthFar || 1000.0,
23 | inlineVerticalFieldOfView:
24 | init.inlineVerticalFieldOfView ||
25 | oldState?.inlineVerticalFieldOfView ||
26 | null,
27 | baseLayer: init.baseLayer || oldState?.baseLayer || null,
28 | };
29 | }
30 |
31 | get depthNear(): number {
32 | return this[P_RENDER_STATE].depthNear;
33 | }
34 |
35 | get depthFar(): number {
36 | return this[P_RENDER_STATE].depthFar;
37 | }
38 |
39 | get inlineVerticalFieldOfView(): number | null {
40 | return this[P_RENDER_STATE].inlineVerticalFieldOfView;
41 | }
42 |
43 | get baseLayer(): XRWebGLLayer | null {
44 | return this[P_RENDER_STATE].baseLayer;
45 | }
46 | }
47 |
48 | // XRRenderStateInit interface definition for TypeScript
49 | export interface XRRenderStateInit {
50 | depthNear?: number;
51 | depthFar?: number;
52 | inlineVerticalFieldOfView?: number;
53 | baseLayer?: XRWebGLLayer;
54 | }
55 |
--------------------------------------------------------------------------------
/src/spaces/XRJointSpace.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_JOINT_SPACE } from '../private.js';
9 | import { XRHandJoint } from '../input/XRHand.js';
10 | import { XRSpace } from './XRSpace.js';
11 | import { mat4 } from 'gl-matrix';
12 |
13 | export class XRJointSpace extends XRSpace {
14 | [P_JOINT_SPACE]: {
15 | jointName: XRHandJoint;
16 | radius: number;
17 | };
18 |
19 | constructor(
20 | jointName: XRHandJoint,
21 | parentSpace?: XRSpace,
22 | offsetMatrix?: mat4,
23 | ) {
24 | super(parentSpace, offsetMatrix);
25 | this[P_JOINT_SPACE] = { jointName, radius: 0 };
26 | }
27 |
28 | get jointName() {
29 | return this[P_JOINT_SPACE].jointName;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/spaces/XRReferenceSpace.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_REF_SPACE } from '../private.js';
9 | import { XRReferenceSpaceEventHandler } from '../events/XRReferenceSpaceEvent.js';
10 | import { XRSpace } from './XRSpace.js';
11 | import { mat4 } from 'gl-matrix';
12 |
13 | export enum XRReferenceSpaceType {
14 | Viewer = 'viewer',
15 | Local = 'local',
16 | LocalFloor = 'local-floor',
17 | BoundedFloor = 'bounded-floor',
18 | Unbounded = 'unbounded',
19 | }
20 |
21 | export class XRReferenceSpace extends XRSpace {
22 | [P_REF_SPACE]: {
23 | type: XRReferenceSpaceType;
24 | onreset: XRReferenceSpaceEventHandler | null;
25 | } = {
26 | type: null as any,
27 | onreset: () => {},
28 | };
29 |
30 | constructor(
31 | type: XRReferenceSpaceType,
32 | parentSpace: XRSpace,
33 | offsetMatrix?: mat4,
34 | ) {
35 | super(parentSpace, offsetMatrix);
36 | this[P_REF_SPACE].type = type;
37 | }
38 |
39 | get onreset(): XRReferenceSpaceEventHandler {
40 | return this[P_REF_SPACE].onreset ?? (() => {});
41 | }
42 |
43 | set onreset(callback: XRReferenceSpaceEventHandler) {
44 | if (this[P_REF_SPACE].onreset) {
45 | this.removeEventListener(
46 | 'reset',
47 | this[P_REF_SPACE].onreset as EventListener,
48 | );
49 | }
50 | this[P_REF_SPACE].onreset = callback;
51 | if (callback) {
52 | this.addEventListener('reset', callback as EventListener);
53 | }
54 | }
55 |
56 | // Create a new XRReferenceSpace with an offset from the current space
57 | getOffsetReferenceSpace(originOffset: mat4): XRReferenceSpace {
58 | // Create a new XRReferenceSpace with the originOffset as its offsetMatrix
59 | // The new space's parent is set to 'this' (the current XRReferenceSpace)
60 | return new XRReferenceSpace(this[P_REF_SPACE].type, this, originOffset);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/spaces/XRSpace.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { mat4, quat, vec3 } from 'gl-matrix';
9 |
10 | import { P_SPACE } from '../private.js';
11 |
12 | export class XRSpace extends EventTarget {
13 | [P_SPACE]: {
14 | parentSpace: XRSpace | undefined;
15 | offsetMatrix: mat4;
16 | emulated: boolean;
17 | };
18 |
19 | constructor(parentSpace?: XRSpace, offsetMatrix?: mat4) {
20 | super();
21 | this[P_SPACE] = {
22 | parentSpace,
23 | offsetMatrix: offsetMatrix ? mat4.clone(offsetMatrix) : mat4.create(),
24 | emulated: true,
25 | };
26 | }
27 | }
28 |
29 | export class GlobalSpace extends XRSpace {
30 | constructor() {
31 | super(undefined, mat4.create()); // GlobalSpace has no parent
32 | }
33 | }
34 |
35 | export class XRSpaceUtils {
36 | // Update the position component of the offsetMatrix of a given XRSpace
37 | static updateOffsetPosition(space: XRSpace, position: vec3): void {
38 | const offsetMatrix = space[P_SPACE].offsetMatrix;
39 | mat4.fromTranslation(offsetMatrix, position);
40 | }
41 |
42 | // Update the rotation component of the offsetMatrix of a given XRSpace using a quaternion
43 | static updateOffsetQuaternion(space: XRSpace, quaternion: quat): void {
44 | const offsetMatrix = space[P_SPACE].offsetMatrix;
45 | const translation = vec3.create();
46 | mat4.getTranslation(translation, offsetMatrix);
47 | mat4.fromRotationTranslation(offsetMatrix, quaternion, translation);
48 | }
49 |
50 | // Update the offsetMatrix of a given XRSpace directly
51 | static updateOffsetMatrix(space: XRSpace, matrix: mat4): void {
52 | const offsetMatrix = space[P_SPACE].offsetMatrix;
53 | mat4.copy(offsetMatrix, matrix);
54 | }
55 |
56 | // Calculate the global offset matrix for a given XRSpace
57 | static calculateGlobalOffsetMatrix(
58 | space: XRSpace,
59 | globalOffset: mat4 = mat4.create(),
60 | ): mat4 {
61 | const parentOffset = space[P_SPACE].parentSpace
62 | ? XRSpaceUtils.calculateGlobalOffsetMatrix(space[P_SPACE].parentSpace)
63 | : mat4.create(); // Identity matrix for GlobalSpace
64 |
65 | mat4.multiply(globalOffset, parentOffset, space[P_SPACE].offsetMatrix);
66 | return globalOffset;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/utils/Math.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { quat, vec3 } from 'gl-matrix';
9 |
10 | /**
11 | * Wrapper class for gl-matrix vec3
12 | * Minimal interoperable interface to Vector3 in Three.js and Babylon.js
13 | */
14 | export class Vector3 {
15 | vec3: vec3;
16 | private tempVec3: vec3;
17 |
18 | constructor(x: number = 0, y: number = 0, z: number = 0) {
19 | this.vec3 = vec3.fromValues(x, y, z);
20 | this.tempVec3 = vec3.create();
21 | }
22 |
23 | get x() {
24 | return this.vec3[0];
25 | }
26 |
27 | set x(value: number) {
28 | this.vec3[0] = value;
29 | }
30 |
31 | get y() {
32 | return this.vec3[1];
33 | }
34 |
35 | set y(value: number) {
36 | this.vec3[1] = value;
37 | }
38 |
39 | get z() {
40 | return this.vec3[2];
41 | }
42 |
43 | set z(value: number) {
44 | this.vec3[2] = value;
45 | }
46 |
47 | set(x: number, y: number, z: number): this {
48 | vec3.set(this.vec3, x, y, z);
49 | return this;
50 | }
51 |
52 | clone(): Vector3 {
53 | return new Vector3(this.x, this.y, this.z);
54 | }
55 |
56 | copy(v: Vector3): this {
57 | this.x = v.x;
58 | this.y = v.y;
59 | this.z = v.z;
60 | return this;
61 | }
62 |
63 | round(): this {
64 | this.x = Math.round(this.x);
65 | this.y = Math.round(this.y);
66 | this.z = Math.round(this.z);
67 | return this;
68 | }
69 |
70 | normalize(): this {
71 | vec3.copy(this.tempVec3, this.vec3);
72 | vec3.normalize(this.vec3, this.tempVec3);
73 | return this;
74 | }
75 |
76 | add(v: Vector3): this {
77 | vec3.copy(this.tempVec3, this.vec3);
78 | vec3.add(this.vec3, this.tempVec3, v.vec3);
79 | return this;
80 | }
81 |
82 | applyQuaternion(q: Quaternion): this {
83 | vec3.copy(this.tempVec3, this.vec3);
84 | vec3.transformQuat(this.vec3, this.tempVec3, q.quat);
85 | return this;
86 | }
87 | }
88 |
89 | /**
90 | * Wrapper class for gl-matrix quat4
91 | * Minimal interoperable interface to Vector3 in Three.js and Babylon.js
92 | */
93 | export class Quaternion {
94 | quat: quat;
95 | private tempQuat: quat;
96 |
97 | constructor(x: number = 0, y: number = 0, z: number = 0, w: number = 1) {
98 | this.quat = quat.fromValues(x, y, z, w);
99 | this.tempQuat = quat.create();
100 | }
101 |
102 | get x() {
103 | return this.quat[0];
104 | }
105 |
106 | set x(value: number) {
107 | this.quat[0] = value;
108 | }
109 |
110 | get y() {
111 | return this.quat[1];
112 | }
113 |
114 | set y(value: number) {
115 | this.quat[1] = value;
116 | }
117 |
118 | get z() {
119 | return this.quat[2];
120 | }
121 |
122 | set z(value: number) {
123 | this.quat[2] = value;
124 | }
125 |
126 | get w() {
127 | return this.quat[3];
128 | }
129 |
130 | set w(value: number) {
131 | this.quat[3] = value;
132 | }
133 |
134 | set(x: number, y: number, z: number, w: number): this {
135 | quat.set(this.quat, x, y, z, w);
136 | return this;
137 | }
138 |
139 | clone(): Quaternion {
140 | return new Quaternion(this.x, this.y, this.z, this.w);
141 | }
142 |
143 | copy(q: Quaternion): this {
144 | quat.set(this.quat, q.x, q.y, q.z, q.w);
145 | return this;
146 | }
147 |
148 | normalize(): this {
149 | quat.copy(this.tempQuat, this.quat);
150 | quat.normalize(this.quat, this.tempQuat);
151 | return this;
152 | }
153 |
154 | invert(): this {
155 | quat.copy(this.tempQuat, this.quat);
156 | quat.conjugate(this.quat, this.tempQuat);
157 | return this;
158 | }
159 |
160 | multiply(q: Quaternion): this {
161 | quat.copy(this.tempQuat, this.quat);
162 | quat.multiply(this.quat, this.tempQuat, q.quat);
163 | return this;
164 | }
165 |
166 | setFromAxisAngle(axis: Vector3, angle: number): this {
167 | quat.setAxisAngle(this.quat, axis.vec3, angle);
168 | return this;
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/src/views/XRView.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_VIEW } from '../private.js';
9 | import { XRRigidTransform } from '../primitives/XRRigidTransform.js';
10 | import { XRSession } from '../session/XRSession.js';
11 |
12 | export enum XREye {
13 | None = 'none',
14 | Left = 'left',
15 | Right = 'right',
16 | }
17 |
18 | export class XRView {
19 | [P_VIEW]: {
20 | eye: XREye;
21 | projectionMatrix: Float32Array;
22 | transform: XRRigidTransform;
23 | recommendedViewportScale: number | null;
24 | requestedViewportScale: number;
25 | session: XRSession;
26 | };
27 |
28 | constructor(
29 | eye: XREye,
30 | projectionMatrix: Float32Array,
31 | transform: XRRigidTransform,
32 | session: XRSession,
33 | ) {
34 | this[P_VIEW] = {
35 | eye,
36 | projectionMatrix,
37 | transform,
38 | recommendedViewportScale: null,
39 | requestedViewportScale: 1.0,
40 | session,
41 | };
42 | }
43 |
44 | get eye(): XREye {
45 | return this[P_VIEW].eye;
46 | }
47 |
48 | get projectionMatrix(): Float32Array {
49 | return this[P_VIEW].projectionMatrix;
50 | }
51 |
52 | get transform(): XRRigidTransform {
53 | return this[P_VIEW].transform;
54 | }
55 |
56 | get recommendedViewportScale(): number | null {
57 | return this[P_VIEW].recommendedViewportScale;
58 | }
59 |
60 | requestViewportScale(scale: number | null): void {
61 | if (scale === null || scale <= 0 || scale > 1) {
62 | console.warn('Invalid scale value. Scale must be > 0 and <= 1.');
63 | return;
64 | }
65 | this[P_VIEW].requestedViewportScale = scale;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/views/XRViewport.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { P_VIEWPORT } from '../private.js';
9 |
10 | export class XRViewport {
11 | [P_VIEWPORT]: {
12 | x: number;
13 | y: number;
14 | width: number;
15 | height: number;
16 | };
17 |
18 | constructor(x: number, y: number, width: number, height: number) {
19 | this[P_VIEWPORT] = { x, y, width, height };
20 | }
21 |
22 | get x(): number {
23 | return this[P_VIEWPORT].x;
24 | }
25 |
26 | get y(): number {
27 | return this[P_VIEWPORT].y;
28 | }
29 |
30 | get width(): number {
31 | return this[P_VIEWPORT].width;
32 | }
33 |
34 | get height(): number {
35 | return this[P_VIEWPORT].height;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/tests/events/XRInputSourceChangeEvent.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRInputSourcesChangeEvent } from '../../src/events/XRInputSourcesChangeEvent.js';
9 | import { XRInputSource } from '../../src/input/XRInputSource.js';
10 | import { XRSession } from '../../src/session/XRSession.js';
11 |
12 | // Mock dependencies
13 | jest.mock('../../src/input/XRInputSource.js');
14 | jest.mock('../../src/session/XRSession.js');
15 |
16 | describe('XRInputSourcesChangeEvent', () => {
17 | let mockInputSource1: jest.Mocked;
18 | let mockInputSource2: jest.Mocked;
19 | let mockSession: jest.Mocked;
20 |
21 | beforeEach(() => {
22 | mockInputSource1 = {} as any;
23 | mockInputSource2 = {} as any;
24 | mockSession = {} as any;
25 | });
26 |
27 | describe('constructor', () => {
28 | it('should create event with type and event init', () => {
29 | const eventInit = {
30 | session: mockSession,
31 | added: [mockInputSource1],
32 | removed: [mockInputSource2],
33 | };
34 |
35 | const event = new XRInputSourcesChangeEvent(
36 | 'inputsourceschange',
37 | eventInit,
38 | );
39 |
40 | expect(event.type).toBe('inputsourceschange');
41 | expect(event.session).toBe(mockSession);
42 | expect(event.added).toEqual([mockInputSource1]);
43 | expect(event.removed).toEqual([mockInputSource2]);
44 | });
45 |
46 | it('should handle empty added and removed arrays', () => {
47 | const eventInit = {
48 | session: mockSession,
49 | added: [],
50 | removed: [],
51 | };
52 |
53 | const event = new XRInputSourcesChangeEvent(
54 | 'inputsourceschange',
55 | eventInit,
56 | );
57 |
58 | expect(event.added).toEqual([]);
59 | expect(event.removed).toEqual([]);
60 | });
61 |
62 | it('should handle multiple input sources', () => {
63 | const mockInputSource3 = {} as any;
64 | const eventInit = {
65 | session: mockSession,
66 | added: [mockInputSource1, mockInputSource2],
67 | removed: [mockInputSource3],
68 | };
69 |
70 | const event = new XRInputSourcesChangeEvent(
71 | 'inputsourceschange',
72 | eventInit,
73 | );
74 |
75 | expect(event.added).toHaveLength(2);
76 | expect(event.removed).toHaveLength(1);
77 | expect(event.added).toContain(mockInputSource1);
78 | expect(event.added).toContain(mockInputSource2);
79 | });
80 |
81 | it('should inherit from Event', () => {
82 | const eventInit = {
83 | session: mockSession,
84 | added: [],
85 | removed: [],
86 | };
87 |
88 | const event = new XRInputSourcesChangeEvent(
89 | 'inputsourceschange',
90 | eventInit,
91 | );
92 |
93 | expect(event).toBeInstanceOf(Event);
94 | });
95 |
96 | it('should handle event options', () => {
97 | const eventInit = {
98 | session: mockSession,
99 | added: [],
100 | removed: [],
101 | bubbles: true,
102 | cancelable: true,
103 | };
104 |
105 | const event = new XRInputSourcesChangeEvent(
106 | 'inputsourceschange',
107 | eventInit,
108 | );
109 |
110 | expect(event.bubbles).toBe(true);
111 | expect(event.cancelable).toBe(true);
112 | });
113 |
114 | it('should throw error when session is missing', () => {
115 | const eventInit = {
116 | added: [],
117 | removed: [],
118 | } as any;
119 |
120 | expect(() => {
121 | new XRInputSourcesChangeEvent('inputsourceschange', eventInit);
122 | }).toThrow('XRInputSourcesChangeEventInit.session is required');
123 | });
124 |
125 | it('should throw error when added is missing', () => {
126 | const eventInit = {
127 | session: mockSession,
128 | removed: [],
129 | } as any;
130 |
131 | expect(() => {
132 | new XRInputSourcesChangeEvent('inputsourceschange', eventInit);
133 | }).toThrow('XRInputSourcesChangeEventInit.added is required');
134 | });
135 |
136 | it('should throw error when removed is missing', () => {
137 | const eventInit = {
138 | session: mockSession,
139 | added: [],
140 | } as any;
141 |
142 | expect(() => {
143 | new XRInputSourcesChangeEvent('inputsourceschange', eventInit);
144 | }).toThrow('XRInputSourcesChangeEventInit.removed is required');
145 | });
146 | });
147 | });
148 |
--------------------------------------------------------------------------------
/tests/events/XRInputSourceEvent.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRInputSourceEvent } from '../../src/events/XRInputSourceEvent.js';
9 | import { XRInputSource } from '../../src/input/XRInputSource.js';
10 | import { XRFrame } from '../../src/frameloop/XRFrame.js';
11 |
12 | // Mock dependencies
13 | jest.mock('../../src/input/XRInputSource.js');
14 | jest.mock('../../src/frameloop/XRFrame.js');
15 |
16 | describe('XRInputSourceEvent', () => {
17 | let mockInputSource: jest.Mocked;
18 | let mockFrame: jest.Mocked;
19 |
20 | beforeEach(() => {
21 | mockInputSource = {} as any;
22 | mockFrame = {} as any;
23 | });
24 |
25 | describe('constructor', () => {
26 | it('should create event with type and event init', () => {
27 | const eventInit = {
28 | frame: mockFrame,
29 | inputSource: mockInputSource,
30 | };
31 |
32 | const event = new XRInputSourceEvent('select', eventInit);
33 |
34 | expect(event.type).toBe('select');
35 | expect(event.frame).toBe(mockFrame);
36 | expect(event.inputSource).toBe(mockInputSource);
37 | });
38 |
39 | it('should inherit from Event', () => {
40 | const eventInit = {
41 | frame: mockFrame,
42 | inputSource: mockInputSource,
43 | };
44 |
45 | const event = new XRInputSourceEvent('select', eventInit);
46 |
47 | expect(event).toBeInstanceOf(Event);
48 | });
49 |
50 | it('should handle event options', () => {
51 | const eventInit = {
52 | frame: mockFrame,
53 | inputSource: mockInputSource,
54 | bubbles: true,
55 | cancelable: true,
56 | };
57 |
58 | const event = new XRInputSourceEvent('select', eventInit);
59 |
60 | expect(event.bubbles).toBe(true);
61 | expect(event.cancelable).toBe(true);
62 | });
63 |
64 | it('should throw error when frame is missing', () => {
65 | const eventInit = {
66 | inputSource: mockInputSource,
67 | } as any;
68 |
69 | expect(() => {
70 | new XRInputSourceEvent('select', eventInit);
71 | }).toThrow('XRInputSourceEventInit.frame is required');
72 | });
73 |
74 | it('should throw error when inputSource is missing', () => {
75 | const eventInit = {
76 | frame: mockFrame,
77 | } as any;
78 |
79 | expect(() => {
80 | new XRInputSourceEvent('select', eventInit);
81 | }).toThrow('XRInputSourceEventInit.inputSource is required');
82 | });
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/tests/events/XRReferenceSpaceEvent.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRReferenceSpaceEvent } from '../../src/events/XRReferenceSpaceEvent.js';
9 | import { XRReferenceSpace } from '../../src/spaces/XRReferenceSpace.js';
10 | import { XRRigidTransform } from '../../src/primitives/XRRigidTransform.js';
11 |
12 | // Mock dependencies
13 | jest.mock('../../src/spaces/XRReferenceSpace.js');
14 | jest.mock('../../src/primitives/XRRigidTransform.js');
15 |
16 | describe('XRReferenceSpaceEvent', () => {
17 | let mockReferenceSpace: jest.Mocked;
18 | let mockTransform: jest.Mocked;
19 |
20 | beforeEach(() => {
21 | mockReferenceSpace = {} as any;
22 | mockTransform = {} as any;
23 | });
24 |
25 | describe('constructor', () => {
26 | it('should create event with type and event init', () => {
27 | const eventInit = {
28 | referenceSpace: mockReferenceSpace,
29 | transform: mockTransform,
30 | };
31 |
32 | const event = new XRReferenceSpaceEvent('reset', eventInit);
33 |
34 | expect(event.type).toBe('reset');
35 | expect(event.referenceSpace).toBe(mockReferenceSpace);
36 | expect(event.transform).toBe(mockTransform);
37 | });
38 |
39 | it('should inherit from Event', () => {
40 | const eventInit = {
41 | referenceSpace: mockReferenceSpace,
42 | transform: mockTransform,
43 | };
44 |
45 | const event = new XRReferenceSpaceEvent('reset', eventInit);
46 |
47 | expect(event).toBeInstanceOf(Event);
48 | });
49 |
50 | it('should handle event options', () => {
51 | const eventInit = {
52 | referenceSpace: mockReferenceSpace,
53 | transform: mockTransform,
54 | bubbles: true,
55 | cancelable: true,
56 | };
57 |
58 | const event = new XRReferenceSpaceEvent('reset', eventInit);
59 |
60 | expect(event.bubbles).toBe(true);
61 | expect(event.cancelable).toBe(true);
62 | });
63 |
64 | it('should throw error when referenceSpace is missing', () => {
65 | const eventInit = {
66 | transform: mockTransform,
67 | } as any;
68 |
69 | expect(() => {
70 | new XRReferenceSpaceEvent('reset', eventInit);
71 | }).toThrow('XRReferenceSpaceEventInit.referenceSpace is required');
72 | });
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/tests/events/XRSessionEvent.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRSessionEvent } from '../../src/events/XRSessionEvent.js';
9 | import { XRSession } from '../../src/session/XRSession.js';
10 |
11 | // Mock dependencies
12 | jest.mock('../../src/session/XRSession.js');
13 |
14 | describe('XRSessionEvent', () => {
15 | let mockSession: jest.Mocked;
16 |
17 | beforeEach(() => {
18 | mockSession = {} as any;
19 | });
20 |
21 | describe('constructor', () => {
22 | it('should create event with type and event init', () => {
23 | const eventInit = {
24 | session: mockSession,
25 | };
26 |
27 | const event = new XRSessionEvent('end', eventInit);
28 |
29 | expect(event.type).toBe('end');
30 | expect(event.session).toBe(mockSession);
31 | });
32 |
33 | it('should inherit from Event', () => {
34 | const eventInit = {
35 | session: mockSession,
36 | };
37 |
38 | const event = new XRSessionEvent('end', eventInit);
39 |
40 | expect(event).toBeInstanceOf(Event);
41 | });
42 |
43 | it('should handle event options', () => {
44 | const eventInit = {
45 | session: mockSession,
46 | bubbles: true,
47 | cancelable: true,
48 | };
49 |
50 | const event = new XRSessionEvent('end', eventInit);
51 |
52 | expect(event.bubbles).toBe(true);
53 | expect(event.cancelable).toBe(true);
54 | });
55 |
56 | it('should throw error when session is missing', () => {
57 | const eventInit = {} as any;
58 |
59 | expect(() => {
60 | new XRSessionEvent('end', eventInit);
61 | }).toThrow('XRSessionEventInit.session is required');
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/tests/hittest/XRHitTest.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { XRHitTestSource, XRHitTestResult } from '../../src/hittest/XRHitTest.js';
9 | import { XRRay } from '../../src/hittest/XRRay.js';
10 | import { XRSession } from '../../src/session/XRSession.js';
11 | import { XRFrame } from '../../src/frameloop/XRFrame.js';
12 | import { XRSpace } from '../../src/spaces/XRSpace.js';
13 | import { P_SESSION } from '../../src/private.js';
14 |
15 | // Mock dependencies
16 | jest.mock('../../src/session/XRSession.js');
17 | jest.mock('../../src/frameloop/XRFrame.js');
18 | jest.mock('../../src/spaces/XRSpace.js');
19 |
20 | describe('XRHitTestSource', () => {
21 | let mockSession: jest.Mocked;
22 | let mockSpace: jest.Mocked;
23 |
24 | beforeEach(() => {
25 | // Create a proper session mock with the private symbol
26 | mockSession = {
27 | [P_SESSION]: {
28 | hitTestSources: new Set()
29 | }
30 | } as any;
31 | mockSpace = {} as any;
32 | });
33 |
34 | describe('constructor', () => {
35 | it('should create a hit test source with required options', () => {
36 | const options = {
37 | space: mockSpace,
38 | offsetRay: new XRRay()
39 | };
40 |
41 | const hitTestSource = new XRHitTestSource(mockSession, options);
42 |
43 | expect(hitTestSource).toBeDefined();
44 | });
45 |
46 | it('should use default ray when offsetRay not provided', () => {
47 | const options = {
48 | space: mockSpace,
49 | offsetRay: undefined as any
50 | };
51 |
52 | const hitTestSource = new XRHitTestSource(mockSession, options);
53 |
54 | expect(hitTestSource).toBeDefined();
55 | });
56 | });
57 |
58 | describe('cancel method', () => {
59 | it('should remove itself from session hit test sources', () => {
60 | const options = {
61 | space: mockSpace,
62 | offsetRay: new XRRay()
63 | };
64 | const hitTestSource = new XRHitTestSource(mockSession, options);
65 |
66 | // Add to session first
67 | (mockSession as any)[P_SESSION].hitTestSources.add(hitTestSource);
68 | expect((mockSession as any)[P_SESSION].hitTestSources.has(hitTestSource)).toBe(true);
69 |
70 | hitTestSource.cancel();
71 |
72 | expect((mockSession as any)[P_SESSION].hitTestSources.has(hitTestSource)).toBe(false);
73 | });
74 | });
75 | });
76 |
77 | describe('XRHitTestResult', () => {
78 | let mockFrame: jest.Mocked;
79 | let mockOffsetSpace: jest.Mocked;
80 | let mockBaseSpace: jest.Mocked;
81 |
82 | beforeEach(() => {
83 | mockFrame = {
84 | getPose: jest.fn(),
85 | createAnchor: jest.fn()
86 | } as any;
87 | mockOffsetSpace = {} as any;
88 | mockBaseSpace = {} as any;
89 | });
90 |
91 | describe('constructor', () => {
92 | it('should create a hit test result', () => {
93 | const result = new XRHitTestResult(mockFrame, mockOffsetSpace);
94 |
95 | expect(result).toBeDefined();
96 | });
97 | });
98 |
99 | describe('getPose method', () => {
100 | it('should call frame.getPose with correct spaces', () => {
101 | const result = new XRHitTestResult(mockFrame, mockOffsetSpace);
102 | const mockPose = {} as any;
103 | mockFrame.getPose.mockReturnValue(mockPose);
104 |
105 | const pose = result.getPose(mockBaseSpace);
106 |
107 | expect(mockFrame.getPose).toHaveBeenCalledWith(mockOffsetSpace, mockBaseSpace);
108 | expect(pose).toBe(mockPose);
109 | });
110 |
111 | it('should return undefined when frame.getPose returns undefined', () => {
112 | const result = new XRHitTestResult(mockFrame, mockOffsetSpace);
113 | mockFrame.getPose.mockReturnValue(undefined as any);
114 |
115 | const pose = result.getPose(mockBaseSpace);
116 |
117 | expect(pose).toBeUndefined();
118 | });
119 | });
120 |
121 | describe('createAnchor method', () => {
122 | it('should call frame.createAnchor with rigid transform and offset space', () => {
123 | const result = new XRHitTestResult(mockFrame, mockOffsetSpace);
124 | const mockAnchor = {} as any;
125 | mockFrame.createAnchor.mockReturnValue(mockAnchor);
126 |
127 | const anchor = result.createAnchor();
128 |
129 | expect(mockFrame.createAnchor).toHaveBeenCalledWith(
130 | expect.any(Object), // XRRigidTransform
131 | mockOffsetSpace
132 | );
133 | expect(anchor).toBe(mockAnchor);
134 | });
135 | });
136 | });
--------------------------------------------------------------------------------
/tests/input/XRInputSource.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import {
9 | XRHandedness,
10 | XRInputSource,
11 | XRTargetRayMode,
12 | } from '../../src/input/XRInputSource';
13 |
14 | import { Gamepad } from '../../src/gamepad/Gamepad';
15 | import { XRSpace } from '../../src/spaces/XRSpace';
16 |
17 | describe('XRInputSource', () => {
18 | let targetRaySpace: XRSpace;
19 | let gripSpace: XRSpace;
20 | let gamepad: Gamepad;
21 |
22 | beforeEach(() => {
23 | targetRaySpace = new XRSpace();
24 | gripSpace = new XRSpace();
25 | gamepad = {} as Gamepad;
26 | });
27 |
28 | test('should properly initialize with given parameters', () => {
29 | const handedness = XRHandedness.Left;
30 | const targetRayMode = XRTargetRayMode.TrackedPointer;
31 | const profiles = ['profile1', 'profile2'];
32 |
33 | const inputSource = new XRInputSource(
34 | handedness,
35 | targetRayMode,
36 | profiles,
37 | targetRaySpace,
38 | gamepad,
39 | gripSpace,
40 | );
41 |
42 | expect(inputSource.handedness).toBe(handedness);
43 | expect(inputSource.targetRayMode).toBe(targetRayMode);
44 | expect(inputSource.targetRaySpace).toBe(targetRaySpace);
45 | expect(inputSource.gripSpace).toBe(gripSpace);
46 | expect(inputSource.profiles).toEqual(profiles);
47 | expect(inputSource.gamepad).toBe(gamepad);
48 | });
49 |
50 | // Test for different handedness
51 | test.each([[XRHandedness.None], [XRHandedness.Left], [XRHandedness.Right]])(
52 | 'should handle handedness: %s',
53 | (handedness) => {
54 | const inputSource = new XRInputSource(
55 | handedness,
56 | XRTargetRayMode.TrackedPointer,
57 | [],
58 | targetRaySpace,
59 | gamepad,
60 | gripSpace,
61 | );
62 | expect(inputSource.handedness).toBe(handedness);
63 | },
64 | );
65 |
66 | // Test for different target ray modes
67 | test.each([
68 | [XRTargetRayMode.Gaze],
69 | [XRTargetRayMode.TrackedPointer],
70 | [XRTargetRayMode.Screen],
71 | [XRTargetRayMode.TransientPointer],
72 | ])('should handle targetRayMode: %s', (targetRayMode) => {
73 | const inputSource = new XRInputSource(
74 | XRHandedness.None,
75 | targetRayMode,
76 | [],
77 | targetRaySpace,
78 | gamepad,
79 | gripSpace,
80 | );
81 | expect(inputSource.targetRayMode).toBe(targetRayMode);
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/tests/jsconfig.json:
--------------------------------------------------------------------------------
1 | { "typeAcquisition": { "include": [ "jest" ] } }
--------------------------------------------------------------------------------
/tests/polyfill.ts:
--------------------------------------------------------------------------------
1 | class PolyfillDOMPointReadOnly {
2 | readonly x: number;
3 | readonly y: number;
4 | readonly z: number;
5 | readonly w: number;
6 | constructor(x = 0, y = 0, z = 0, w = 1) {
7 | this.x = x;
8 | this.y = y;
9 | this.z = z;
10 | this.w = w;
11 | Object.freeze(this);
12 | }
13 | static fromPoint(other: DOMPointInit): DOMPointReadOnly {
14 | return new PolyfillDOMPointReadOnly(other.x, other.y, other.z, other.w);
15 | }
16 |
17 | matrixTransform(matrix: DOMMatrixReadOnly): DOMPointReadOnly {
18 | const x =
19 | matrix.m11 * this.x +
20 | matrix.m21 * this.y +
21 | matrix.m31 * this.z +
22 | matrix.m41 * this.w;
23 | const y =
24 | matrix.m12 * this.x +
25 | matrix.m22 * this.y +
26 | matrix.m32 * this.z +
27 | matrix.m42 * this.w;
28 | const z =
29 | matrix.m13 * this.x +
30 | matrix.m23 * this.y +
31 | matrix.m33 * this.z +
32 | matrix.m43 * this.w;
33 | const w =
34 | matrix.m14 * this.x +
35 | matrix.m24 * this.y +
36 | matrix.m34 * this.z +
37 | matrix.m44 * this.w;
38 | return new PolyfillDOMPointReadOnly(x, y, z, w);
39 | }
40 |
41 | toJSON(): any {
42 | return { x: this.x, y: this.y, z: this.z, w: this.w };
43 | }
44 | }
45 |
46 | if (typeof globalThis.DOMPointReadOnly === 'undefined') {
47 | // @ts-ignore
48 | globalThis.DOMPointReadOnly = PolyfillDOMPointReadOnly as any;
49 | }
50 |
--------------------------------------------------------------------------------
/tests/primitives/XRRigidTransform.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { quat, vec3 } from 'gl-matrix';
9 |
10 | import { XRRigidTransform } from '../../src/primitives/XRRigidTransform';
11 |
12 | describe('XRRigidTransform', () => {
13 | it('should create an identity transform with no arguments', () => {
14 | const transform = new XRRigidTransform();
15 | expect(transform.position.x).toBe(0);
16 | expect(transform.position.y).toBe(0);
17 | expect(transform.position.z).toBe(0);
18 | expect(transform.orientation.x).toBe(0);
19 | expect(transform.orientation.y).toBe(0);
20 | expect(transform.orientation.z).toBe(0);
21 | expect(transform.orientation.w).toBe(1);
22 | expect(transform.matrix).toEqual(expect.any(Float32Array));
23 | expect(transform.matrix.length).toBe(16);
24 | });
25 |
26 | it('should create a transform with given position and default orientation', () => {
27 | const position = { x: 1, y: 2, z: 3, w: 1 };
28 | const transform = new XRRigidTransform(position);
29 | expect(transform.position.x).toBe(1);
30 | expect(transform.position.y).toBe(2);
31 | expect(transform.position.z).toBe(3);
32 | expect(transform.orientation.x).toBe(0);
33 | expect(transform.orientation.y).toBe(0);
34 | expect(transform.orientation.z).toBe(0);
35 | expect(transform.orientation.w).toBe(1);
36 | });
37 |
38 | it('should create a transform with given position and orientation', () => {
39 | const position = { x: 1, y: 2, z: 3, w: 1 };
40 | const orientation = { x: 0, y: 0, z: 1, w: 0 };
41 | const transform = new XRRigidTransform(position, orientation);
42 | expect(transform.position.x).toBe(1);
43 | expect(transform.position.y).toBe(2);
44 | expect(transform.position.z).toBe(3);
45 | expect(transform.orientation.x).toBeCloseTo(0);
46 | expect(transform.orientation.y).toBeCloseTo(0);
47 | expect(transform.orientation.z).toBeCloseTo(1);
48 | expect(transform.orientation.w).toBeCloseTo(0);
49 | });
50 |
51 | it('should correctly calculate the inverse transform', () => {
52 | const position = { x: 1, y: 2, z: 3, w: 1 };
53 | const orientation = {
54 | x: 0.11109410971403122,
55 | y: 0.21208874881267548,
56 | z: 0.3130834102630615,
57 | w: 0.9190512895584106,
58 | }; // (normalized)
59 | const transform = new XRRigidTransform(position, orientation);
60 | const inverse = transform.inverse;
61 |
62 | // Test applying the transform and then the inverse transform to a point
63 | const point = vec3.fromValues(5, 5, 5); // Arbitrary point
64 | const transformedPoint = vec3.transformMat4(
65 | vec3.create(),
66 | point,
67 | transform.matrix,
68 | );
69 | const retransformedPoint = vec3.transformMat4(
70 | vec3.create(),
71 | transformedPoint,
72 | inverse.matrix,
73 | );
74 |
75 | // The retransformedPoint should be close to the original point
76 | expect(retransformedPoint[0]).toBeCloseTo(point[0]);
77 | expect(retransformedPoint[1]).toBeCloseTo(point[1]);
78 | expect(retransformedPoint[2]).toBeCloseTo(point[2]);
79 |
80 | const originalQuat = quat.fromValues(
81 | transform.orientation.x,
82 | transform.orientation.y,
83 | transform.orientation.z,
84 | transform.orientation.w,
85 | );
86 |
87 | const inverseQuat = quat.fromValues(
88 | inverse.orientation.x,
89 | inverse.orientation.y,
90 | inverse.orientation.z,
91 | inverse.orientation.w,
92 | );
93 |
94 | // Normalizing the quaternions (if not already normalized)
95 | quat.normalize(originalQuat, originalQuat);
96 | quat.normalize(inverseQuat, inverseQuat);
97 |
98 | // Combining the original and inverse quaternion
99 | const combinedQuat = quat.create();
100 | quat.multiply(combinedQuat, inverseQuat, originalQuat);
101 |
102 | // The combined quaternion should be close to a neutral quaternion (0, 0, 0, 1)
103 | expect(combinedQuat[0]).toBeCloseTo(0);
104 | expect(combinedQuat[1]).toBeCloseTo(0);
105 | expect(combinedQuat[2]).toBeCloseTo(0);
106 | expect(combinedQuat[3]).toBeCloseTo(1);
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/tests/spaces/XRReferenceSpace.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { GlobalSpace, XRSpaceUtils } from '../../src/spaces/XRSpace';
9 | import { P_REF_SPACE, P_SPACE } from '../../src/private';
10 | import {
11 | XRReferenceSpace,
12 | XRReferenceSpaceType,
13 | } from '../../src/spaces/XRReferenceSpace';
14 | import { mat4, vec3 } from 'gl-matrix';
15 |
16 | describe('XRReferenceSpace', () => {
17 | let globalSpace: GlobalSpace;
18 | let xrReferenceSpace: XRReferenceSpace;
19 |
20 | beforeEach(() => {
21 | globalSpace = new GlobalSpace();
22 | xrReferenceSpace = new XRReferenceSpace(
23 | XRReferenceSpaceType.Local,
24 | globalSpace,
25 | );
26 | });
27 |
28 | test('XRReferenceSpace should initialize correctly', () => {
29 | expect(xrReferenceSpace).toBeDefined();
30 | expect(xrReferenceSpace[P_REF_SPACE].type).toBe(XRReferenceSpaceType.Local);
31 | expect(xrReferenceSpace[P_SPACE].parentSpace).toBe(globalSpace);
32 | });
33 |
34 | test('getOffsetReferenceSpace should create a new XRReferenceSpace with correct offset', () => {
35 | const offsetMatrix = mat4.create();
36 | mat4.translate(offsetMatrix, offsetMatrix, vec3.fromValues(1, 2, 3)); // Some arbitrary offset
37 |
38 | const offsetSpace = xrReferenceSpace.getOffsetReferenceSpace(offsetMatrix);
39 |
40 | expect(offsetSpace).toBeInstanceOf(XRReferenceSpace);
41 | expect(offsetSpace[P_SPACE].parentSpace).toBe(xrReferenceSpace);
42 | expect(
43 | mat4.equals(offsetSpace[P_SPACE].offsetMatrix, offsetMatrix),
44 | ).toBeTruthy();
45 | });
46 |
47 | test('calculateGlobalOffsetMatrix should return correct global offset for nested XRReferenceSpaces created with getOffsetReferenceSpace', () => {
48 | // Create a parent XRReferenceSpace
49 | const parentSpace = new XRReferenceSpace(
50 | XRReferenceSpaceType.Local,
51 | globalSpace,
52 | );
53 | XRSpaceUtils.updateOffsetPosition(parentSpace, vec3.fromValues(0, 1, 0)); // 1 unit up
54 |
55 | // Create an offset XRReferenceSpace from parentSpace
56 | const offsetMatrix = mat4.create();
57 | mat4.fromTranslation(offsetMatrix, vec3.fromValues(1, 0, 0)); // 1 unit right
58 | const offsetSpace = parentSpace.getOffsetReferenceSpace(offsetMatrix);
59 |
60 | // Calculate the global offset matrix for offsetSpace
61 | const globalMatrix = XRSpaceUtils.calculateGlobalOffsetMatrix(offsetSpace);
62 |
63 | // Expected global matrix combines parent's and offsetSpace's transformations
64 | const expectedMatrix = mat4.create();
65 | mat4.translate(expectedMatrix, expectedMatrix, vec3.fromValues(1, 1, 0)); // Combined translation
66 |
67 | expect(mat4.equals(globalMatrix, expectedMatrix)).toBeTruthy();
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/tests/spaces/XRSpace.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | import { GlobalSpace, XRSpace, XRSpaceUtils } from '../../src/spaces/XRSpace';
9 | import { mat4, quat, vec3 } from 'gl-matrix';
10 |
11 | import { P_SPACE } from '../../src/private';
12 |
13 | describe('XRSpace and XRSpaceUtils', () => {
14 | let globalSpace: GlobalSpace;
15 | let xrSpace: XRSpace;
16 |
17 | beforeEach(() => {
18 | globalSpace = new GlobalSpace();
19 | xrSpace = new XRSpace();
20 | });
21 |
22 | test('XRSpace should initialize with default values', () => {
23 | expect(
24 | mat4.equals(xrSpace[P_SPACE].offsetMatrix, mat4.create()),
25 | ).toBeTruthy();
26 | expect(xrSpace[P_SPACE].parentSpace).toBeUndefined();
27 | });
28 |
29 | test('GlobalSpace should initialize as an XRSpace with no parent', () => {
30 | expect(globalSpace[P_SPACE].parentSpace).toBeUndefined();
31 | expect(
32 | mat4.equals(globalSpace[P_SPACE].offsetMatrix, mat4.create()),
33 | ).toBeTruthy();
34 | });
35 |
36 | test('updateOffsetPosition should update XRSpace offset matrix', () => {
37 | const position = vec3.fromValues(1, 2, 3);
38 | XRSpaceUtils.updateOffsetPosition(xrSpace, position);
39 |
40 | const expectedMatrix = mat4.create();
41 | mat4.fromTranslation(expectedMatrix, position);
42 | expect(
43 | mat4.equals(xrSpace[P_SPACE].offsetMatrix, expectedMatrix),
44 | ).toBeTruthy();
45 | });
46 |
47 | test('updateOffsetQuaternion should update XRSpace offset matrix with rotation', () => {
48 | const quaternion = quat.create();
49 | quat.rotateX(quaternion, quaternion, Math.PI / 2); // Rotate 90 degrees around X-axis
50 | XRSpaceUtils.updateOffsetQuaternion(xrSpace, quaternion);
51 |
52 | const expectedMatrix = mat4.create();
53 | mat4.fromQuat(expectedMatrix, quaternion);
54 | expect(
55 | mat4.equals(xrSpace[P_SPACE].offsetMatrix, expectedMatrix),
56 | ).toBeTruthy();
57 | });
58 |
59 | test('calculateGlobalOffsetMatrix should return global offset for XRSpace', () => {
60 | const position = vec3.fromValues(1, 2, 3);
61 | XRSpaceUtils.updateOffsetPosition(xrSpace, position);
62 |
63 | const globalMatrix = XRSpaceUtils.calculateGlobalOffsetMatrix(xrSpace);
64 |
65 | const expectedMatrix = mat4.create();
66 | mat4.fromTranslation(expectedMatrix, position);
67 | expect(mat4.equals(globalMatrix, expectedMatrix)).toBeTruthy();
68 | });
69 |
70 | test('calculateGlobalOffsetMatrix with nested XRSpaces', () => {
71 | // Create child and grandchild spaces with globalSpace as the root
72 | const childSpace = new XRSpace(globalSpace);
73 | const grandchildSpace = new XRSpace(childSpace);
74 |
75 | // Set position and orientation for each space
76 | const parentPosition = vec3.fromValues(1, 0, 0);
77 | const parentQuaternion = quat.create();
78 | quat.rotateY(parentQuaternion, parentQuaternion, Math.PI / 4); // Rotate 45 degrees around Y-axis
79 |
80 | const childPosition = vec3.fromValues(0, 2, 0);
81 | const childQuaternion = quat.create();
82 | quat.rotateX(childQuaternion, childQuaternion, Math.PI / 2); // Rotate 90 degrees around X-axis
83 |
84 | XRSpaceUtils.updateOffsetPosition(globalSpace, parentPosition);
85 | XRSpaceUtils.updateOffsetQuaternion(globalSpace, parentQuaternion);
86 | XRSpaceUtils.updateOffsetPosition(childSpace, childPosition);
87 | XRSpaceUtils.updateOffsetQuaternion(childSpace, childQuaternion);
88 |
89 | // Calculate the global offset matrix for the grandchild space
90 | const globalMatrix =
91 | XRSpaceUtils.calculateGlobalOffsetMatrix(grandchildSpace);
92 |
93 | // Expected global offset matrix
94 | const expectedMatrix = mat4.create();
95 | const parentMatrix = mat4.create();
96 | mat4.fromRotationTranslation(
97 | parentMatrix,
98 | parentQuaternion,
99 | parentPosition,
100 | );
101 | const childMatrix = mat4.create();
102 | mat4.fromRotationTranslation(childMatrix, childQuaternion, childPosition);
103 | mat4.multiply(expectedMatrix, parentMatrix, childMatrix);
104 |
105 | expect(mat4.equals(globalMatrix, expectedMatrix)).toBeTruthy();
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "ES2018", /* Good balance for modern browsers */
5 | "module": "esnext", /* Suitable for bundlers like Webpack or Rollup */
6 | "lib": ["dom", "es2018"], /* Standard library files for web development */
7 | "declaration": true, /* Generates corresponding '.d.ts' file */
8 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file */
9 | "sourceMap": true, /* Generates corresponding '.map' file */
10 | "outDir": "lib", /* Redirect output structure to the directory */
11 |
12 | /* Strict Type-Checking Options */
13 | "strict": true, /* Enable all strict type-checking options */
14 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type */
15 | "strictNullChecks": true, /* Enable strict null checks */
16 | "strictFunctionTypes": true, /* Enable strict checking of function types */
17 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions */
18 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes */
19 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type */
20 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file */
21 |
22 | /* Additional Checks */
23 | "noUnusedLocals": true, /* Report errors on unused locals */
24 | "noUnusedParameters": true, /* Report errors on unused parameters */
25 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value */
26 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement */
27 |
28 | /* Module Resolution Options */
29 | "moduleResolution": "node", /* Specify module resolution strategy */
30 | "resolveJsonModule": true, /* allows for importing JSON modules */
31 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules */
32 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators */
33 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators */
34 |
35 | /* Advanced Options */
36 | "skipLibCheck": true, /* Skip type checking of declaration files */
37 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file */
38 | },
39 | "include": ["src/**/*"]
40 | }
41 |
--------------------------------------------------------------------------------