commit 2e0fafcb4e2bf6d53cd24e5945fccdb6d5c8415b Author: wehub-resource-sync Date: Mon Jul 13 12:24:51 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bff08ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [ main ] + paths-ignore: ['README.md', 'LICENSE'] + pull_request: + branches: [ main ] + paths-ignore: ['README.md', 'LICENSE'] + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + timeout-minutes: 10 + + strategy: + matrix: + node-version: [22.x] + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install + run: npm ci + + - name: Build + run: npm run build + + lint: + name: Lint + runs-on: ubuntu-latest + + timeout-minutes: 10 + + strategy: + matrix: + node-version: [22.x] + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Lint locales + run: npm run lint:locales diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7491c5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.DS_Store +.npmrc +.vscode +.cursor +.idea diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d1cb904 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f478db --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011-2026 PlayCanvas Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..669d5b9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# SuperSplat Editor + +[![Github Release](https://img.shields.io/github/v/release/playcanvas/supersplat)](https://github.com/playcanvas/supersplat/releases) +[![License](https://img.shields.io/github/license/playcanvas/supersplat)](https://github.com/playcanvas/supersplat/blob/main/LICENSE) +[![Discord](https://img.shields.io/badge/Discord-5865F2?style=flat&logo=discord&logoColor=white&color=black)](https://discord.gg/RSaMRzg) +[![Reddit](https://img.shields.io/badge/Reddit-FF4500?style=flat&logo=reddit&logoColor=white&color=black)](https://www.reddit.com/r/PlayCanvas) +[![X](https://img.shields.io/badge/X-000000?style=flat&logo=x&logoColor=white&color=black)](https://x.com/intent/follow?screen_name=playcanvas) + +| [SuperSplat Editor](https://superspl.at/editor) | [User Guide](https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/) | [Blog](https://blog.playcanvas.com) | [Forum](https://forum.playcanvas.com) | + +The SuperSplat Editor is a free and open source tool for inspecting, editing, optimizing and publishing 3D Gaussian Splats. It is built on web technologies and runs in the browser, so there's nothing to download or install. + +A live version of this tool is available at: https://superspl.at/editor + +![image](https://github.com/user-attachments/assets/b6cbb5cc-d3cc-4385-8c71-ab2807fd4fba) + +To learn more about using SuperSplat, please refer to the [User Guide](https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/). + +## Local Development + +To initialize a local development environment for SuperSplat, ensure you have [Node.js](https://nodejs.org/) 18 or later installed. Follow these steps: + +1. Clone the repository: + + ```sh + git clone https://github.com/playcanvas/supersplat.git + cd supersplat + ``` + +2. Install dependencies: + + ```sh + npm install + ``` + +3. Build SuperSplat and start a local web server: + + ```sh + npm run develop + ``` + +4. Open a web browser tab and make sure network caching is disabled on the network tab and the other application caches are clear: + + - On Safari you can use `Cmd+Option+e` or Develop->Empty Caches. + - On Chrome ensure the options "Update on reload" and "Bypass for network" are enabled in the Application->Service workers tab: + + Screenshot 2025-04-25 at 16 53 37 + +5. Navigate to `http://localhost:3000` + +When changes to the source are detected, SuperSplat is rebuilt automatically. Simply refresh your browser to see your changes. + +## Localizing the SuperSplat Editor + +The currently supported languages are available here: + +https://github.com/playcanvas/supersplat/tree/main/static/locales + +### Adding a New Language + +1. Add a new `.json` file in the `static/locales` directory. + +2. Add the locale to the list here: + + https://github.com/playcanvas/supersplat/blob/main/src/ui/localization.ts + +### Testing Translations + +To test your translations: + +1. Run the development server: + + ```sh + npm run develop + ``` + +2. Open your browser and navigate to: + + ``` + http://localhost:3000/?lng= + ``` + + Replace `` with your language code (e.g., `fr`, `de`, `es`). + +## Contributors + +SuperSplat is made possible by our amazing open source community: + + + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..10458b3 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`playcanvas/supersplat` +- 原始仓库:https://github.com/playcanvas/supersplat +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/copy-and-watch.mjs b/copy-and-watch.mjs new file mode 100644 index 0000000..cdfe303 --- /dev/null +++ b/copy-and-watch.mjs @@ -0,0 +1,64 @@ +import fs from 'fs'; +import path from 'path'; + +// custom plugin to copy files and watch them +export default function copyAndWatch(config) { + const resolvedConfig = { + targets: [] + }; + + // resolve source directories into files + config.targets.forEach(target => { + const readRec = pathname => { + if (!fs.existsSync(pathname)) { + console.log(`skipping missing file ${target.src}`); + } else { + if (fs.lstatSync(pathname).isDirectory()) { + const children = fs.readdirSync(pathname); + children.forEach(childPath => { + readRec(path.join(pathname, childPath)); + }); + } else { + let dest; + if (fs.lstatSync(target.src).isDirectory()) { + dest = path.join( + target.dest || '', + path.basename(target.destFilename || target.src), + path.relative(target.src, pathname) + ); + } else { + dest = path.join(target.dest || '', path.basename(target.destFilename || target.src)); + } + resolvedConfig.targets.push({ + src: pathname, + dest: dest, + transform: target.transform + }); + } + } + }; + readRec(target.src); + }); + + return { + name: 'copy-and-watch', + async buildStart() { + // disable watching during production build + if (process.env.NODE_ENV !== 'production') { + resolvedConfig.targets.forEach(target => { + this.addWatchFile(target.src); + }); + } + }, + async generateBundle() { + resolvedConfig.targets.forEach(target => { + const contents = fs.readFileSync(target.src); + this.emitFile({ + type: 'asset', + fileName: target.dest, + source: target.transform ? target.transform(contents, target.src) : contents + }); + }); + } + }; +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..75ae25b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,90 @@ +# SuperSplat User Guide + +Welcome to the SuperSplat User Guide. + +SuperSplat is an open source, browser-based 3D Gaussian Splat Editor. You can use it to view, inspect, transform, combine, crop, clean up and optimize 3D Gaussian Splats. + +## Installing SuperSplat + +SuperSplat is a web app so you do not need to install it. Simply point your browser at: + +https://playcanvas.com/supersplat/editor + +However, for your convenience, you can also install SuperSplat as a PWA (Progressive Web App). This will make SuperSplat appear and behave more like a native application. An app icon for SuperSplat will be generated on your desktop or home screen. Furthermore, .ply files will be associated with the SuperSplat PWA, enabling you to launch SuperSplat more quickly. + +## Loading Splats + +SuperSplat loads splats from .ply files. Only .ply files containing 3D Gaussian Splat data can be loaded. If you attempt to load any other type of data from a .ply file, it will fail. + +There are three ways that you can load a .ply file: + +1. Drag and drop one or more .ply files from your file system into SuperSplat's client area. +2. Select the `Scene` > `Open` menu item and select one or more .ply files from your file system. +3. Use the `load` query parameter. This is in the form: `https://playcanvas.com/supersplat/editor?load=`. An example would be: + + https://playcanvas.com/supersplat/editor?load=https://raw.githubusercontent.com/willeastcott/assets/main/dragon.compressed.ply + + This is a useful mechanism for sharing splats with other people (say on social platforms like X and LinkedIn). + +## Saving Splats + +To save the currently loaded scene, select the `Scene` > `Save` or `Save As` menu items. This will save a `.ply` file to your file system. + +SuperSplat can also export to two additional formats via the `Scene` > `Export` sub-menu: + +* **Compressed Ply**: A lightweight, compressed format that is far smaller than the equivalent uncompressed .ply file. It quantizes splat data and drops spherical harmonics from the output file. See [this article](https://blog.playcanvas.com/compressing-gaussian-splats/) for more details on the format. +* **Splat File**: Another compressed format, although not as efficient as the compressed ply format. + +## Controlling the Camera + +The camera controls in SuperSplat are as follows: + +| Control | Description | +| ----------------------------------------------- | ------------------------------- | +| Left Mouse Button
Shift + Right Mouse Button | Orbit camera | +| Middle Mouse Button
Alt + Right Mouse Button | Dolly camera | +| Right Mouse Button | Pan camera | +| Left/Right Arrow Keys | Strafe camera left/right | +| Up/Down Arrow Keys | Dolly camera forwards/backwards | +| F Key | Frame selection | + +To set the target point for orbiting the camera, double click anywhere in the 3D view. + +## Visualizing Splats + +Splats can be rendered in two 'modes': + +* **Centers Mode**: A blue dot is rendered at the center of each Gaussian. +* **Rings Mode**: A ring is rendered at the outer boundary of each Gaussian. + +You can disable rendering of the centers or rings (depending on the active mode) by pressing Space. This allows you to view the scene as it would normally appear. + +You can control the pixel size of the center dots in the VIEW OPTIONS panel. + +## Selecting and Deleting Splats + +Cropping splats or deleting unwanted Gaussians is a key function of SuperSplat. To help with this, there are 3 selection tools available: + +* **Picker Select**: Click to select, or click + drag to rect select. +* **Brush Select**: Click and drag a selection circle. Change the brush size with the `[` and `]` keys. +* **Sphere Select**: Activate a sphere volume to add or remove splats from the current selection. Double click on any splat to reposition the sphere volume. + +Once you are happy with your selection, you can delete it with the Delete key. + +## Transforming Splats + +SuperSplat can translate, rotate and scale splats. To do this, select a splat in the Scene Manager and activate one of the gizmos via the horizontal icon bar. + +To achieve fine grain control over the transform of the selected splat, you can use the TRANSFORM panel (below the SCENE MANAGER panel). + +To set the origin of the currently active gizmo, double click anywhere in the 3D view. + +## Merging Splats + +It is possible to merge multiple .ply files together and output a single, combine .ply file. Simply load any number of .ply files into Scene Manager, perform whatever transformations and edits you require, and then save the result via the `Scene` > `Save` menu item. + +## Inspecting Splat Data + +The Data Panel can be used to analyze the contents of your splat scenes. Initially, it is collapsed at the bottom of the application's window. To open it, click on the panel's header or press the 'D' key. + +The Data Panel plots various scene properties on a histogram display. You can select splats directly by dragging on the histogram view. Use the Shift key to add to the current selection and the Ctrl key to remove from the current selection. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c4201ed --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,51 @@ +import playcanvasConfig from '@playcanvas/eslint-config'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; + +export default [ + ...playcanvasConfig, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + globals: { + ...globals.browser, + ...globals.serviceworker, + BlobPart: 'readonly' + } + }, + plugins: { + '@typescript-eslint': tsPlugin + }, + settings: { + 'import/resolver': { + typescript: {} + } + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-returns-type': 'off', + 'jsdoc/check-tag-names': 'off', + 'lines-between-class-members': 'off', + 'no-await-in-loop': 'off', + 'require-atomic-updates': 'off' + } + }, { + files: ['**/*.mjs'], + languageOptions: { + globals: { + ...globals.node + } + }, + rules: { + 'import/no-unresolved': 'off' + } + } +]; diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..061e20f --- /dev/null +++ b/global.d.ts @@ -0,0 +1,21 @@ +/// +/// + +interface FileSystemFileHandle { + remove(): Promise; +} + +declare module '*.png' { + const value: any; + export default value; +} + +declare module '*.svg' { + const value: any; + export default value; +} + +declare module '*.scss' { + const value: any; + export default value; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7bc5c1e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7510 @@ +{ + "name": "supersplat", + "version": "2.29.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "supersplat", + "version": "2.29.0", + "license": "MIT", + "devDependencies": { + "@playcanvas/eslint-config": "2.1.0", + "@playcanvas/pcui": "6.1.4", + "@playcanvas/splat-transform": "2.7.1", + "@rollup/plugin-alias": "6.0.0", + "@rollup/plugin-image": "3.0.3", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/plugin-strip": "3.0.4", + "@rollup/plugin-terser": "1.0.0", + "@rollup/plugin-typescript": "12.3.0", + "@types/wicg-file-system-access": "2023.10.7", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "autoprefixer": "10.5.2", + "concurrently": "9.2.3", + "cors": "2.8.6", + "cross-env": "10.1.0", + "eslint": "10.6.0", + "eslint-import-resolver-typescript": "4.4.5", + "globals": "17.7.0", + "i18next": "26.3.5", + "i18next-browser-languagedetector": "8.2.1", + "i18next-http-backend": "3.0.6", + "mediabunny": "1.50.7", + "playcanvas": "2.20.6", + "postcss": "8.5.16", + "rollup": "4.62.2", + "rollup-plugin-scss": "4.0.1", + "sass": "1.101.0", + "serve": "14.2.6", + "tslib": "2.8.1", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@adobe/spz": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@adobe/spz/-/spz-0.2.2.tgz", + "integrity": "sha512-KUCw5R52rCBnfyvOOIa9nUQGfR6TEcHjYzuWDYRGb1fpkpnkci6VXFqCx/xFJ1yEN7V5gc9YxU2zIY6sEjIbhw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.50.2", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", + "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6", + "@typescript-eslint/types": "^8.11.0", + "comment-parser": "1.4.1", + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@playcanvas/eslint-config": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@playcanvas/eslint-config/-/eslint-config-2.1.0.tgz", + "integrity": "sha512-m8IMRsdmxeSGvmfcl+wYk+dTF7I4wCbB+FozT//yMCUVCj7lHeaWWeHdouRUMgP5FWzEZ6q6u6bjXzOgUlbBeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.11", + "eslint-plugin-regexp": "^2.7.0" + }, + "peerDependencies": { + "eslint": ">= 8" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/eslint-plugin-jsdoc": { + "version": "50.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.8.0.tgz", + "integrity": "sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.50.2", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.4.1", + "escape-string-regexp": "^4.0.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@playcanvas/eslint-config/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@playcanvas/observer": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@playcanvas/observer/-/observer-1.6.6.tgz", + "integrity": "sha512-UMpiEG6VFmgDI2KkKE1I1swommG2AxBQA/zuCoUt+HeUzNOhSSdfED6tVTPluQ+nxp2ScUYSaBCBsjVC/goSiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@playcanvas/pcui": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@playcanvas/pcui/-/pcui-6.1.4.tgz", + "integrity": "sha512-4QEbbyWsbkD4z4LiMz2f5S0mEeyS1LXjZPAImf/uj8T+L6CGlHC/h2DibYppOWatuB4/BOl3uPpmTV/srTpRdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@playcanvas/observer": "^1.5.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/@playcanvas/splat-transform": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@playcanvas/splat-transform/-/splat-transform-2.7.1.tgz", + "integrity": "sha512-6hRUaVmn8shL/d7ymBsD5m6UbsT5KbHrniNhBxaJWQeZnN66LEUL8+AEXnYMBtIyQnub81JjZ4j9zgnKIehrpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/spz": "0.2.2", + "webgpu": "0.4.0" + }, + "bin": { + "splat-transform": "bin/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "playcanvas": "^2.0.0" + } + }, + "node_modules/@rollup/plugin-alias": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-6.0.0.tgz", + "integrity": "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "rollup": ">=4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-image": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-image/-/plugin-image-3.0.3.tgz", + "integrity": "sha512-qXWQwsXpvD4trSb8PeFPFajp8JLpRtqqOeNYRUKnEQNHm7e5UP7fuSRcbjQAJ7wDZBbnJvSdY5ujNBQd9B1iFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "mini-svg-data-uri": "^1.4.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-strip": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-strip/-/plugin-strip-3.0.4.tgz", + "integrity": "sha512-LDRV49ZaavxUo2YoKKMQjCxzCxugu1rCPQa0lDYBOWLj6vtzBMr8DcoJjsmg+s450RbKbe3qI9ZLaSO+O1oNbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/wicg-file-system-access": { + "version": "2023.10.7", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.7.tgz", + "integrity": "sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-regexp": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-2.10.0.tgz", + "integrity": "sha512-ovzQT8ESVn5oOe5a7gIDPD5v9bCSjIFJu57sVPDqgPRXicQzOnYfFN21WoQBQF18vrhT5o7UMKFwJQVVjyJ0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "comment-parser": "^1.4.0", + "jsdoc-type-pratt-parser": "^4.0.0", + "refa": "^0.12.1", + "regexp-ast-analysis": "^0.7.1", + "scslre": "^0.3.0" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "eslint": ">=8.44.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/i18next": { + "version": "26.3.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.5.tgz", + "integrity": "sha512-cd5lgkvjYQAPDfP3bbrAIE1wJmjTIlsrsjDqlvsHn4wFAerP5O0NfAe8RiDcXRfukN5ETe7Tlmfp6DkyuqDn6Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz", + "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "4.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mediabunny": { + "version": "1.50.7", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.50.7.tgz", + "integrity": "sha512-mv+FXkQNOobx4b43GJV1t0n8cx26d8nZpBMtCTaNKk6xzVpNPrwrbDq+dggm7Zpjvtx2ycgtYciFcySUsO5QGA==", + "dev": true, + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.78.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", + "integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playcanvas": { + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.20.6.tgz", + "integrity": "sha512-Dc0jlnjL782G9DhU+dT6UyxcnKA305xSCwZYTXpn2Ohx+6GXisCknYvog50b2FgMb+OPSY7SqBWMVrSsgvUtKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webxr": "^0.5.24", + "@webgpu/types": "^0.1.70" + }, + "engines": { + "node": ">=18.3.0" + }, + "optionalDependencies": { + "canvas": "3.2.3" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-scss": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-scss/-/rollup-plugin-scss-4.0.1.tgz", + "integrity": "sha512-3W3+3OzR+shkDl3hJ1XTAuGkP4AfiLgIjie2GtcoZ9pHfRiNqeDbtCu1EUnkjZ98EPIM6nnMIXkKlc7Sx5bRvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-pluginutils": "^2.3.3" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/serve/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/serve/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/serve/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smob": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webgpu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/webgpu/-/webgpu-0.4.0.tgz", + "integrity": "sha512-F5pimn3Aoi0zWjuRdiVs5TnrUwSzD2lESBohsIUsqyitWkGRQlXU2fhV6ycXlQTa1bvAf3sjqiUpBEpmSQ5ptA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@webgpu/types": "^0.1.69", + "debug": "^4.4.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d5b5e86 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "supersplat", + "version": "2.29.0", + "author": "PlayCanvas", + "homepage": "https://playcanvas.com/supersplat/editor", + "description": "3D Gaussian Splat Editor", + "keywords": [ + "playcanvas", + "ply", + "gaussian", + "splat", + "editor" + ], + "license": "MIT", + "main": "index.js", + "scripts": { + "build": "rollup -c", + "watch": "rollup -c -w", + "serve": "serve dist -C", + "develop": "cross-env BUILD_TYPE=debug concurrently --kill-others \"npm run watch\" \"npm run serve\"", + "develop:auto-launch": "concurrently --kill-others \"xdg-open 'http://localhost:3000/'\" \"npm run watch\" \"npm run serve\"", + "lint": "eslint src", + "lint:locales": "node scripts/check-locales.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "devDependencies": { + "@playcanvas/eslint-config": "2.1.0", + "@playcanvas/splat-transform": "2.7.1", + "@playcanvas/pcui": "6.1.4", + "@rollup/plugin-alias": "6.0.0", + "@rollup/plugin-image": "3.0.3", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/plugin-strip": "3.0.4", + "@rollup/plugin-terser": "1.0.0", + "@rollup/plugin-typescript": "12.3.0", + "@types/wicg-file-system-access": "2023.10.7", + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "autoprefixer": "10.5.2", + "concurrently": "9.2.3", + "cors": "2.8.6", + "cross-env": "10.1.0", + "eslint": "10.6.0", + "eslint-import-resolver-typescript": "4.4.5", + "globals": "17.7.0", + "i18next": "26.3.5", + "i18next-browser-languagedetector": "8.2.1", + "i18next-http-backend": "3.0.6", + "mediabunny": "1.50.7", + "playcanvas": "2.20.6", + "postcss": "8.5.16", + "rollup": "4.62.2", + "rollup-plugin-scss": "4.0.1", + "sass": "1.101.0", + "serve": "14.2.6", + "tslib": "2.8.1", + "typescript": "6.0.3" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..eeec671 --- /dev/null +++ b/renovate.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "matchManagers": [ + "npm" + ], + "groupName": "all npm dependencies", + "schedule": [ + "on monday at 10:00am" + ] + }, + { + "matchDepTypes": ["devDependencies"], + "rangeStrategy": "pin" + }, + { + "matchDepTypes": ["dependencies"], + "rangeStrategy": "widen" + } + ] +} diff --git a/rollup.config.mjs b/rollup.config.mjs new file mode 100644 index 0000000..e8be4a5 --- /dev/null +++ b/rollup.config.mjs @@ -0,0 +1,121 @@ +import path from 'path'; + +import alias from '@rollup/plugin-alias'; +import image from '@rollup/plugin-image'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import strip from '@rollup/plugin-strip'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import autoprefixer from 'autoprefixer'; +import postcss from 'postcss'; +import scss from 'rollup-plugin-scss'; +import sass from 'sass'; + +import copyAndWatch from './copy-and-watch.mjs'; + +// prod is release build +if (process.env.BUILD_TYPE === 'prod') { + process.env.BUILD_TYPE = 'release'; +} +// debug, profile, release +const BUILD_TYPE = process.env.BUILD_TYPE || 'release'; +const ENGINE_DIR = path.resolve(`node_modules/playcanvas/build/playcanvas${BUILD_TYPE === 'debug' ? '.dbg' : ''}/src/index.js`); +const PCUI_DIR = path.resolve('node_modules/@playcanvas/pcui'); +const HREF = process.env.BASE_HREF || ''; + +const outputHeader = () => { + const BLUE_OUT = '\x1b[34m'; + const BOLD_OUT = '\x1b[1m'; + const REGULAR_OUT = '\x1b[22m'; + const RESET_OUT = '\x1b[0m'; + + const title = [ + 'Building SuperSplat', + `type ${BOLD_OUT}${BUILD_TYPE}${REGULAR_OUT}` + ].map(l => `${BLUE_OUT}${l}`).join('\n'); + console.log(`${BLUE_OUT}${title}${RESET_OUT}\n`); +}; + +outputHeader(); + +const application = { + input: 'src/index.ts', + output: { + dir: 'dist', + format: 'esm', + sourcemap: true + }, + plugins: [ + copyAndWatch({ + targets: [ + { + src: 'src/index.html', + transform: (contents, filename) => { + return contents.toString().replace('__BASE_HREF__', HREF); + } + }, + { src: 'src/manifest.json' }, + { src: 'static/images', dest: 'static' }, + { src: 'static/icons', dest: 'static' }, + { src: 'static/lib', dest: 'static' }, + { src: 'static/locales', dest: 'static' }, + { src: 'static/env/VertebraeHDRI_v1_512.png', dest: 'static/env' } + ] + }), + alias({ + entries: { + 'playcanvas': ENGINE_DIR, + '@playcanvas/pcui': PCUI_DIR + } + }), + typescript({ + tsconfig: './tsconfig.json' + }), + resolve(), + image({ dom: false }), + json(), + scss({ + sourceMap: true, + runtime: sass, + processor: (css) => { + return postcss([autoprefixer]) + .process(css, { from: undefined }) + .then(result => result.css); + }, + fileName: 'index.css', + includePaths: [`${PCUI_DIR}/dist`], + watch: 'src/ui/scss' + }), + BUILD_TYPE === 'release' && + strip({ + include: ['**/*.ts'], + functions: ['Debug.exec'] + }), + BUILD_TYPE !== 'debug' && terser() + ], + treeshake: 'smallest', + cache: false +}; + +const serviceWorker = { + input: 'src/sw.ts', + output: { + dir: 'dist', + format: 'esm', + sourcemap: true + }, + plugins: [ + resolve(), + json(), + typescript() + // BUILD_TYPE !== 'debug' && terser() + ], + treeshake: 'smallest', + cache: false +}; + +export default [ + application, + serviceWorker +]; diff --git a/scripts/check-locales.mjs b/scripts/check-locales.mjs new file mode 100644 index 0000000..05db0bb --- /dev/null +++ b/scripts/check-locales.mjs @@ -0,0 +1,48 @@ +/** + * Locale consistency check. + * + * Verifies every translation in static/locales has exactly the same set of keys + * as the English reference (en.json, the i18next fallback language). Reports any + * key that is: + * - missing from a translation (the UI would silently fall back to English), or + * - stale (present in a translation but no longer in en.json). + * + * This is a pure JSON set comparison — it does not scan source, so it has no + * false positives and needs no dependencies. It deliberately does NOT check for + * unused or undefined keys, since that requires resolving dynamic localize() + * call sites and is not worth the complexity. Run via `npm run lint:locales`. + */ + +import { readFileSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const localesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'locales'); +const referenceFile = 'en.json'; + +const keysOf = file => new Set(Object.keys(JSON.parse(readFileSync(join(localesDir, file), 'utf8')))); + +const referenceKeys = keysOf(referenceFile); +const localeFiles = readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== referenceFile); + +let failed = false; + +for (const file of localeFiles) { + const keys = keysOf(file); + const missing = [...referenceKeys].filter(k => !keys.has(k)); + const stale = [...keys].filter(k => !referenceKeys.has(k)); + + if (missing.length || stale.length) { + failed = true; + console.error(`\n${file}:`); + missing.forEach(k => console.error(` missing (untranslated): ${k}`)); + stale.forEach(k => console.error(` stale (not in ${referenceFile}): ${k}`)); + } +} + +if (failed) { + console.error(`\n✖ Locale check failed. Update static/locales so every language has the same keys as ${referenceFile}.`); + process.exit(1); +} + +console.log(`✔ All ${localeFiles.length} locales are in sync with ${referenceFile} (${referenceKeys.size} keys).`); diff --git a/src/anim-track.ts b/src/anim-track.ts new file mode 100644 index 0000000..d29e6da --- /dev/null +++ b/src/anim-track.ts @@ -0,0 +1,52 @@ +/** + * Interface for animation tracks that can be attached to animatable targets. + * Each track owns its keyframes and handles capture, interpolation, and evaluation. + */ +interface AnimTrack { + /** Array of frame numbers where keyframes exist */ + readonly keys: readonly number[]; + + /** + * Add a keyframe at the specified frame, capturing current state. + * If a key already exists at this frame, it will be updated. + * @returns true if the track was modified, false if the operation was a no-op. + */ + addKey(frame: number): boolean; + + /** + * Remove the keyframe at the specified frame. + * @returns true if the track was modified, false if the operation was a no-op. + */ + removeKey(frame: number): boolean; + + /** + * Move a keyframe from one frame to another. + * @returns true if the track was modified, false if the operation was a no-op. + */ + moveKey(fromFrame: number, toFrame: number): boolean; + + /** + * Copy a keyframe from one frame to another. + * The original keyframe remains in place. + * @returns true if the track was modified, false if the operation was a no-op. + */ + copyKey(fromFrame: number, toFrame: number): boolean; + + /** + * Clear all keyframes. + */ + clear(): void; + + /** + * Return a deep copy of the track's internal state (for undo snapshots). + */ + snapshot(): unknown; + + /** + * Replace the track's internal state with a previously captured snapshot + * and fire appropriate change events. + */ + restore(snapshot: unknown): void; +} + +export { AnimTrack }; diff --git a/src/anim/spline.ts b/src/anim/spline.ts new file mode 100644 index 0000000..4e15600 --- /dev/null +++ b/src/anim/spline.ts @@ -0,0 +1,131 @@ +class CubicSpline { + // control times + times: number[]; + + // control data: in-tangent, point, out-tangent + knots: number[]; + + // dimension of the knot points + dim: number; + + constructor(times: number[], knots: number[]) { + this.times = times; + this.knots = knots; + this.dim = knots.length / times.length / 3; + } + + evaluate(time: number, result: number[]) { + const { times } = this; + const last = times.length - 1; + + if (time <= times[0]) { + this.getKnot(0, result); + } else if (time >= times[last]) { + this.getKnot(last, result); + } else { + let seg = 0; + while (time >= times[seg + 1]) { + seg++; + } + this.evaluateSegment(seg, (time - times[seg]) / (times[seg + 1] - times[seg]), result); + } + } + + getKnot(index: number, result: number[]) { + const { knots, dim } = this; + const idx = index * 3 * dim; + for (let i = 0; i < dim; ++i) { + result[i] = knots[idx + i * 3 + 1]; + } + } + + // evaluate the spline segment at the given normalized time t + evaluateSegment(segment: number, t: number, result: number[]) { + const { knots, dim } = this; + + const t2 = t * t; + const twot = t + t; + const omt = 1 - t; + const omt2 = omt * omt; + + let idx = segment * dim * 3; // each knot has 3 values: tangent in, value, tangent out + for (let i = 0; i < dim; ++i) { + const p0 = knots[idx + 1]; // p0 + const m0 = knots[idx + 2]; // outgoing tangent + const m1 = knots[idx + dim * 3]; // incoming tangent + const p1 = knots[idx + dim * 3 + 1]; // p1 + idx += 3; + + result[i] = + p0 * ((1 + twot) * omt2) + + m0 * (t * omt2) + + p1 * (t2 * (3 - twot)) + + m1 * (t2 * (t - 1)); + } + } + + // calculate cubic spline knots from points + // times: time values for each control point + // points: control point values to be interpolated (n dimensional) + // smoothness: 0 = linear, 1 = smooth + static calcKnots(times: number[], points: number[], smoothness: number) { + const n = times.length; + const dim = points.length / n; + const knots = new Array(n * dim * 3); + + for (let i = 0; i < n; i++) { + const t = times[i]; + + for (let j = 0; j < dim; j++) { + const idx = i * dim + j; + const p = points[idx]; + + let tangent; + if (i === 0) { + tangent = (points[idx + dim] - p) / (times[i + 1] - t); + } else if (i === n - 1) { + tangent = (p - points[idx - dim]) / (t - times[i - 1]); + } else { + tangent = (points[idx + dim] - points[idx - dim]) / (times[i + 1] - times[i - 1]); + } + + // convert to derivatives w.r.t normalized segment parameter + const inScale = i > 0 ? (times[i] - times[i - 1]) : (times[1] - times[0]); + const outScale = i < n - 1 ? (times[i + 1] - times[i]) : (times[i] - times[i - 1]); + + knots[idx * 3] = tangent * inScale * smoothness; + knots[idx * 3 + 1] = p; + knots[idx * 3 + 2] = tangent * outScale * smoothness; + } + } + + return knots; + } + + static fromPoints(times: number[], points: number[], smoothness = 1) { + return new CubicSpline(times, CubicSpline.calcKnots(times, points, smoothness)); + } + + // create a looping spline by duplicating animation points at the end and beginning + static fromPointsLooping(length: number, times: number[], points: number[], smoothness = 1) { + if (times.length < 2) { + return CubicSpline.fromPoints(times, points); + } + + const dim = points.length / times.length; + const newTimes = times.slice(); + const newPoints = points.slice(); + + // append first two points + newTimes.push(length + times[0], length + times[1]); + newPoints.push(...points.slice(0, dim * 2)); + + // prepend last two points + newTimes.splice(0, 0, times[times.length - 2] - length, times[times.length - 1] - length); + newPoints.splice(0, 0, ...points.slice(points.length - dim * 2)); + + return CubicSpline.fromPoints(newTimes, newPoints, smoothness); + } +} + +export { CubicSpline }; diff --git a/src/asset-loader.ts b/src/asset-loader.ts new file mode 100644 index 0000000..02f11d8 --- /dev/null +++ b/src/asset-loader.ts @@ -0,0 +1,49 @@ +import { ReadFileSystem } from '@playcanvas/splat-transform'; +import { AppBase, Asset, GSplatData, GSplatResource } from 'playcanvas'; + +import { Events } from './events'; +import { loadGSplatData, validateGSplatData } from './io'; +import { Splat } from './splat'; + +// handles loading gsplat assets using splat-transform +class AssetLoader { + app: AppBase; + events: Events; + + constructor(app: AppBase, events: Events) { + this.app = app; + this.events = events; + } + + // wrap in-memory GSplatData in a gsplat Asset + GSplatResource registered with + // the engine. shared by the splat-transform load path and the PLY sequence + // frame source, which already holds decoded GSplatData. + createGSplatAsset(gsplatData: GSplatData, filename: string): Asset { + const asset = new Asset(filename, 'gsplat', { url: `local-asset-${Date.now()}`, filename }); + this.app.assets.add(asset); + asset.resource = new GSplatResource(this.app.graphicsDevice, gsplatData); + return asset; + } + + async load(filename: string, fileSystem: ReadFileSystem, animationFrame?: boolean, skipReorder?: boolean) { + if (!animationFrame) { + this.events.fire('startSpinner'); + } + + try { + // Skip reordering for animation frames (speed) or when explicitly requested (already ordered) + const { gsplatData, transform } = await loadGSplatData(filename, fileSystem, skipReorder || animationFrame); + validateGSplatData(gsplatData); + + const asset = this.createGSplatAsset(gsplatData, filename); + + return new Splat(asset, transform.rotation); + } finally { + if (!animationFrame) { + this.events.fire('stopSpinner'); + } + } + } +} + +export { AssetLoader }; diff --git a/src/box-shape.ts b/src/box-shape.ts new file mode 100644 index 0000000..823fbd0 --- /dev/null +++ b/src/box-shape.ts @@ -0,0 +1,129 @@ +import { + BLENDEQUATION_ADD, + BLENDMODE_ONE, + BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDMODE_SRC_ALPHA, + CULLFACE_FRONT, + BlendState, + BoundingBox, + Entity, + ShaderMaterial, + Vec3 +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { Serializer } from './serializer'; +import { vertexShader, fragmentShader } from './shaders/box-shape-shader'; + +const v = new Vec3(); +const bound = new BoundingBox(); + +class BoxShape extends Element { + _lenX = 2; + _lenY = 2; + _lenZ = 2; + pivot: Entity; + material: ShaderMaterial; + + constructor() { + super(ElementType.debug); + + this.pivot = new Entity('boxPivot'); + this.pivot.addComponent('render', { + type: 'box' + }); + } + + add() { + const material = new ShaderMaterial({ + uniqueName: 'boxShape', + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + material.cull = CULLFACE_FRONT; + material.blendState = new BlendState( + true, + BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA + ); + material.update(); + + this.pivot.render.meshInstances[0].material = material; + this.pivot.render.layers = [this.scene.worldLayer.id]; + + this.material = material; + + this.scene.contentRoot.addChild(this.pivot); + + this.updateBound(); + } + + remove() { + this.scene.contentRoot.removeChild(this.pivot); + this.scene.boundDirty = true; + } + + destroy() { + + } + + serialize(serializer: Serializer): void { + serializer.packa(this.pivot.getWorldTransform().data); + serializer.pack(this.lenX); + serializer.pack(this.lenY); + serializer.pack(this.lenZ); + } + + onPreRender() { + this.pivot.setLocalScale(this._lenX, this._lenY, this._lenZ); + this.pivot.getWorldTransform().getTranslation(v); + this.material.setParameter('boxCen', [v.x, v.y, v.z]); + this.material.setParameter('boxLen', [this._lenX * 0.5, this._lenY * 0.5, this._lenZ * 0.5]); + + const device = this.scene.graphicsDevice; + device.scope.resolve('targetSize').setValue([device.width, device.height]); + } + + moved() { + this.updateBound(); + } + + updateBound() { + bound.center.copy(this.pivot.getPosition()); + bound.halfExtents.set(this._lenX, this._lenY, this._lenZ); + this.scene.boundDirty = true; + } + + get worldBound(): BoundingBox | null { + return bound; + } + + set lenX(lenX: number) { + this._lenX = lenX; + this.updateBound(); + } + + get lenX() { + return this._lenX; + } + + set lenY(lenY: number) { + this._lenY = lenY; + this.updateBound(); + } + + get lenY() { + return this._lenY; + } + + set lenZ(lenZ: number) { + this._lenZ = lenZ; + this.updateBound(); + } + + get lenZ() { + return this._lenZ; + } +} + +export { BoxShape }; diff --git a/src/camera-pose-gizmos.ts b/src/camera-pose-gizmos.ts new file mode 100644 index 0000000..f3855ba --- /dev/null +++ b/src/camera-pose-gizmos.ts @@ -0,0 +1,181 @@ +import { + PRIMITIVE_LINES, + Entity, + Mesh, + MeshInstance, + ShaderMaterial, + Vec3 +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { vertexShader, fragmentShader } from './shaders/debug-shader'; + +// temp vectors for frustum geometry calculation (module-scope to avoid allocations) +const tmpForward = new Vec3(); +const tmpRight = new Vec3(); +const tmpUp = new Vec3(); +const tmpBase = new Vec3(); +const tmpTL = new Vec3(); +const tmpTR = new Vec3(); +const tmpBL = new Vec3(); +const tmpBR = new Vec3(); +const tmpUpTip = new Vec3(); + +// lines per camera icon: 4 pyramid + 4 base rect + 2 up indicator = 10 +const LINES_PER_CAMERA = 10; +const VERTS_PER_CAMERA = LINES_PER_CAMERA * 2; + +class CameraPoseGizmos extends Element { + entity: Entity; + mesh: Mesh; + material: ShaderMaterial; + meshInstance: MeshInstance; + dirty = true; + + constructor() { + super(ElementType.debug); + } + + add() { + const scene = this.scene; + const device = scene.graphicsDevice; + + this.material = new ShaderMaterial({ + uniqueName: 'cameraPoseGizmoMaterial', + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + this.material.depthWrite = true; + this.material.depthTest = true; + this.material.update(); + + this.mesh = new Mesh(device); + this.mesh.primitive[0] = { + baseVertex: 0, + type: PRIMITIVE_LINES, + base: 0, + count: 0 + }; + + this.meshInstance = new MeshInstance(this.mesh, this.material, null); + this.meshInstance.cull = false; + + this.entity = new Entity('cameraPoseGizmos'); + this.entity.addComponent('render', { + meshInstances: [this.meshInstance], + layers: [scene.worldLayer.id] + }); + + scene.app.root.addChild(this.entity); + + // mark dirty when poses or scene bound change + const markDirty = () => { + this.dirty = true; + if (scene.events.invoke('camera.showPoses')) { + scene.forceRender = true; + } + }; + const { events } = scene; + events.on('track.keyAdded', markDirty); + events.on('track.keyRemoved', markDirty); + events.on('track.keyMoved', markDirty); + events.on('track.keyUpdated', markDirty); + events.on('track.keysCleared', markDirty); + events.on('track.keysLoaded', markDirty); + events.on('scene.boundChanged', markDirty); + } + + destroy() { + this.entity?.destroy(); + } + + onPreRender() { + const { scene } = this; + const visible = scene.events.invoke('camera.showPoses') && scene.camera.renderOverlays; + + this.entity.enabled = visible; + + if (visible && this.dirty) { + this.dirty = false; + this.rebuildMesh(); + } + } + + private rebuildMesh() { + const poses = this.scene.events.invoke('camera.poses') as { position: Vec3, target: Vec3 }[]; + if (!poses || poses.length === 0) { + this.mesh.primitive[0].count = 0; + return; + } + + const depth = 0.08; + const halfW = 0.06; + const halfH = 0.04; + + const numVerts = poses.length * VERTS_PER_CAMERA; + const positions: number[] = []; + const colors = new Uint8Array(numVerts * 4); + + const pushLine = (a: Vec3, b: Vec3) => { + positions.push(a.x, a.y, a.z, b.x, b.y, b.z); + }; + + for (const pose of poses) { + const { position, target } = pose; + + // forward direction + tmpForward.sub2(target, position).normalize(); + + // right direction (handle degenerate case when looking straight up/down) + if (Math.abs(tmpForward.y) > 0.999) { + tmpRight.cross(tmpForward, Vec3.BACK).normalize(); + } else { + tmpRight.cross(tmpForward, Vec3.UP).normalize(); + } + + // up direction + tmpUp.cross(tmpRight, tmpForward); + + // base center (in front of camera position) + tmpBase.copy(position).addScaled(tmpForward, depth); + + // frustum corners + tmpTL.copy(tmpBase).addScaled(tmpUp, halfH).addScaled(tmpRight, -halfW); + tmpTR.copy(tmpBase).addScaled(tmpUp, halfH).addScaled(tmpRight, halfW); + tmpBL.copy(tmpBase).addScaled(tmpUp, -halfH).addScaled(tmpRight, -halfW); + tmpBR.copy(tmpBase).addScaled(tmpUp, -halfH).addScaled(tmpRight, halfW); + + // pyramid edges from position to corners + pushLine(position, tmpTL); + pushLine(position, tmpTR); + pushLine(position, tmpBL); + pushLine(position, tmpBR); + + // base rectangle + pushLine(tmpTL, tmpTR); + pushLine(tmpTR, tmpBR); + pushLine(tmpBR, tmpBL); + pushLine(tmpBL, tmpTL); + + // up indicator triangle + tmpUpTip.copy(tmpBase).addScaled(tmpUp, halfH * 1.5); + pushLine(tmpTL, tmpUpTip); + pushLine(tmpTR, tmpUpTip); + } + + // fill vertex colors with cyan (0, 255, 255, 255) + for (let i = 0; i < numVerts; i++) { + const off = i * 4; + colors[off] = 0; + colors[off + 1] = 255; + colors[off + 2] = 255; + colors[off + 3] = 255; + } + + this.mesh.setPositions(positions); + this.mesh.setColors32(colors); + this.mesh.update(PRIMITIVE_LINES); + } +} + +export { CameraPoseGizmos }; diff --git a/src/camera-poses.ts b/src/camera-poses.ts new file mode 100644 index 0000000..50933b0 --- /dev/null +++ b/src/camera-poses.ts @@ -0,0 +1,336 @@ +import { Vec3 } from 'playcanvas'; + +import { CubicSpline } from './anim/spline'; +import { AnimTrack } from './anim-track'; +import { Events } from './events'; + +type Pose = { + name: string, + frame: number, + position: Vec3, + target: Vec3, + fov?: number +}; + +/** + * Camera animation track that manages camera keyframes and interpolation. + * Implements AnimTrack interface so it can be used with the timeline system. + * + * Fully self-contained: subscribes to timeline events internally for + * evaluation and spline rebuilding. + */ +class CameraAnimTrack implements AnimTrack { + private poses: Pose[] = []; + private events: Events; + private onTimelineChange: ((frame: number) => void) | null = null; + + constructor(events: Events) { + this.events = events; + + // Evaluate on timeline playback and scrub + events.on('timeline.time', (time: number) => { + this.evaluate(time); + }); + + events.on('timeline.frame', (frame: number) => { + this.evaluate(frame); + }); + + // Rebuild spline when timeline parameters change + events.on('timeline.frames', () => { + this.rebuildSpline(); + }); + + events.on('timeline.smoothness', () => { + this.rebuildSpline(); + }); + + events.on('timeline.loop', () => { + this.rebuildSpline(); + }); + + // Clear track when scene is cleared + events.on('scene.clear', () => { + this.clear(); + }); + } + + get keys(): readonly number[] { + return this.poses.map(p => p.frame); + } + + addKey(frame: number): boolean { + const pose = this.events.invoke('camera.getPose'); + if (!pose) return false; + + const existingIndex = this.poses.findIndex(p => p.frame === frame); + + const newPose: Pose = { + name: `camera_${this.poses.length}`, + frame, + position: new Vec3(pose.position.x, pose.position.y, pose.position.z), + target: new Vec3(pose.target.x, pose.target.y, pose.target.z), + fov: pose.fov + }; + + if (existingIndex === -1) { + this.poses.push(newPose); + this.rebuildSpline(); + this.events.fire('track.keyAdded', frame); + } else { + this.poses[existingIndex] = newPose; + this.rebuildSpline(); + this.events.fire('track.keyUpdated', frame); + } + return true; + } + + removeKey(frame: number): boolean { + const index = this.poses.findIndex(p => p.frame === frame); + if (index === -1) return false; + this.poses.splice(index, 1); + this.rebuildSpline(); + this.events.fire('track.keyRemoved', frame); + return true; + } + + moveKey(fromFrame: number, toFrame: number): boolean { + if (fromFrame === toFrame) return false; + + const index = this.poses.findIndex(p => p.frame === fromFrame); + if (index === -1) return false; + + // Remove any existing pose at the target frame + const toIndex = this.poses.findIndex(p => p.frame === toFrame); + if (toIndex !== -1) { + this.poses.splice(toIndex, 1); + } + + // Update the frame (re-find index since splice may have shifted it) + const movedIndex = this.poses.findIndex(p => p.frame === fromFrame); + this.poses[movedIndex].frame = toFrame; + this.rebuildSpline(); + this.events.fire('track.keyMoved', fromFrame, toFrame); + return true; + } + + copyKey(fromFrame: number, toFrame: number): boolean { + if (fromFrame === toFrame) return false; + + const source = this.poses.find(p => p.frame === fromFrame); + if (!source) return false; + + // Remove any existing pose at the target frame + const toIndex = this.poses.findIndex(p => p.frame === toFrame); + if (toIndex !== -1) { + this.poses.splice(toIndex, 1); + } + + this.poses.push({ + name: `camera_${this.poses.length}`, + frame: toFrame, + position: source.position.clone(), + target: source.target.clone(), + fov: source.fov + }); + + this.rebuildSpline(); + this.events.fire('track.keyAdded', toFrame); + return true; + } + + evaluate(frame: number): void { + this.onTimelineChange?.(frame); + } + + clear(): void { + this.poses.length = 0; + this.onTimelineChange = null; + this.events.fire('track.keysCleared'); + } + + snapshot(): Pose[] { + return this.poses.map(p => ({ + name: p.name, + frame: p.frame, + position: p.position.clone(), + target: p.target.clone(), + fov: p.fov + })); + } + + restore(snapshot: unknown): void { + this.poses = (snapshot as Pose[]).map(p => ({ + name: p.name, + frame: p.frame, + position: p.position.clone(), + target: p.target.clone(), + fov: p.fov + })); + this.rebuildSpline(); + this.events.fire('track.keysLoaded'); + } + + /** + * Add a pose directly (used for deserialization and legacy import). + */ + addPose(pose: Pose): void { + if (pose.frame === undefined) { + return; + } + + pose.fov ??= this.events.invoke('camera.fov') ?? 60; + + const idx = this.poses.findIndex(p => p.frame === pose.frame); + if (idx !== -1) { + this.poses[idx] = pose; + this.rebuildSpline(); + this.events.fire('track.keyUpdated', pose.frame); + } else { + this.poses.push(pose); + this.rebuildSpline(); + this.events.fire('track.keyAdded', pose.frame); + } + } + + /** + * Get all poses (used for serialization and legacy consumers). + */ + getPoses(): readonly Pose[] { + return this.poses; + } + + /** + * Load poses from serialized data. + */ + loadPoses(posesData: Pose[]): void { + this.poses.length = 0; + posesData.forEach((pose) => { + this.poses.push(pose); + }); + this.rebuildSpline(); + this.events.fire('track.keysLoaded'); + } + + private rebuildSpline(): void { + const duration = this.events.invoke('timeline.frames'); + const smoothness = this.events.invoke('timeline.smoothness'); + const loop = this.events.invoke('timeline.loop'); + + const orderedPoses = this.poses.slice() + .filter(a => a.frame < duration) + .sort((a, b) => a.frame - b.frame); + + const times = orderedPoses.map(p => p.frame); + const points: number[] = []; + for (let i = 0; i < orderedPoses.length; ++i) { + const p = orderedPoses[i]; + points.push(p.position.x, p.position.y, p.position.z); + points.push(p.target.x, p.target.y, p.target.z); + points.push(p.fov); + } + + if (orderedPoses.length > 1) { + // when not looping the spline clamps, holding the first/last pose + // beyond the outer keys instead of wrapping the end into the start + const spline = loop ? + CubicSpline.fromPointsLooping(duration, times, points, smoothness) : + CubicSpline.fromPoints(times, points, smoothness); + const result: number[] = []; + const pose = { position: new Vec3(), target: new Vec3(), fov: 0 }; + + this.onTimelineChange = (frame: number) => { + spline.evaluate(frame, result); + pose.position.set(result[0], result[1], result[2]); + pose.target.set(result[3], result[4], result[5]); + pose.fov = result[6]; + this.events.fire('camera.setPose', pose, 0); + }; + } else if (orderedPoses.length === 1) { + // a single key can't form a spline; hold its pose at every frame + const p = orderedPoses[0]; + const pose = { position: p.position.clone(), target: p.target.clone(), fov: p.fov }; + + this.onTimelineChange = () => { + this.events.fire('camera.setPose', pose, 0); + }; + } else { + this.onTimelineChange = null; + } + + // re-evaluate at the current frame so the camera updates immediately + this.evaluate(this.events.invoke('timeline.frame')); + } +} + +/** + * Register the camera animation track and expose it via events. + * The track is fully self-contained (subscribes to timeline events internally), + * so this function only needs to create it, expose it, and handle serialization. + */ +const registerCameraPosesEvents = (events: Events) => { + const track = new CameraAnimTrack(events); + + // Expose the camera animation track + events.function('camera.animTrack', () => { + return track; + }); + + // Legacy support: expose poses + events.function('camera.poses', () => { + return track.getPoses(); + }); + + // Legacy support: add pose directly + events.on('camera.addPose', (pose: Pose) => { + track.addPose(pose); + }); + + // Serialization + + events.function('docSerialize.poseSets', (): any[] => { + const pack3 = (v: Vec3) => [v.x, v.y, v.z]; + const poses = track.getPoses(); + + if (poses.length === 0) { + return []; + } + + return [{ + name: 'set0', + poses: poses.map((pose) => { + return { + name: pose.name, + frame: pose.frame, + position: pack3(pose.position), + target: pack3(pose.target), + fov: pose.fov + }; + }) + }]; + }); + + events.function('docDeserialize.poseSets', (poseSets: any[], documentCameraFov?: number) => { + if (!poseSets || poseSets.length === 0) { + return; + } + + const fps = events.invoke('timeline.frameRate'); + + const defaultFov = documentCameraFov ?? events.invoke('camera.fov') ?? 60; + + const loadedPoses: Pose[] = poseSets[0].poses.map((docPose: any, index: number) => { + return { + name: docPose.name, + frame: docPose.frame ?? (index * fps), + position: new Vec3(docPose.position), + target: new Vec3(docPose.target), + fov: docPose.fov ?? defaultFov + }; + }); + + track.loadPoses(loadedPoses); + }); +}; + +export { registerCameraPosesEvents, CameraAnimTrack, Pose }; diff --git a/src/camera.ts b/src/camera.ts new file mode 100644 index 0000000..2f797ba --- /dev/null +++ b/src/camera.ts @@ -0,0 +1,839 @@ +import { + math, + ADDRESS_CLAMP_TO_EDGE, + ASPECT_MANUAL, + FILTER_NEAREST, + PIXELFORMAT_RGBA8, + PIXELFORMAT_RGBA16F, + PIXELFORMAT_DEPTH, + PROJECTION_ORTHOGRAPHIC, + PROJECTION_PERSPECTIVE, + TONEMAP_ACES, + TONEMAP_ACES2, + TONEMAP_FILMIC, + TONEMAP_HEJL, + TONEMAP_LINEAR, + TONEMAP_NEUTRAL, + BoundingBox, + Color, + Entity, + Mat4, + Quat, + Ray, + RenderPass, + RenderPassForward, + RenderTarget, + Texture, + Vec3, + Vec4 +} from 'playcanvas'; + +import { PointerController } from './controllers'; +import { Element, ElementType } from './element'; +import { Picker } from './picker'; +import { Serializer } from './serializer'; +import { vertexShader, fragmentShader } from './shaders/blit-shader'; +import { Splat } from './splat'; +import { TweenValue } from './tween-value'; +import { ShaderQuad, SimpleRenderPass } from './utils/simple-render-pass'; + +// work globals +const forwardVec = new Vec3(); +const cameraPosition = new Vec3(); +const ray = new Ray(); +const vec = new Vec3(); +const vecb = new Vec3(); +const va = new Vec3(); +const m = new Mat4(); +const v4 = new Vec4(); + +// modulo dealing with negative numbers +const mod = (n: number, m: number) => ((n % m) + m) % m; + +class Camera extends Element { + /** + * Calculate the forward vector given azimuth and elevation angles. + * + * @param {Vec3} result - The Vec3 to store the result in. + * @param {number} azim - Azimuth angle in degrees. + * @param {number} elev - Elevation angle in degrees. + */ + static calcForwardVec(result: Vec3, azim: number, elev: number) { + const ex = elev * math.DEG_TO_RAD; + const ey = azim * math.DEG_TO_RAD; + const s1 = Math.sin(-ex); + const c1 = Math.cos(-ex); + const s2 = Math.sin(-ey); + const c2 = Math.cos(-ey); + result.set(-c1 * s2, s1, c1 * c2); + } + + controller: PointerController; + focalPointTween = new TweenValue({ x: 0, y: 0.5, z: 0 }); + azimElevTween = new TweenValue({ azim: 30, elev: -15 }); + distanceTween = new TweenValue({ distance: 1 }); + + minElev = -90; + maxElev = 90; + + sceneRadius = 1; + + flySpeed = 1; + + controlMode: 'orbit' | 'fly' = 'orbit'; + + // during fly-mode look, stores the camera position that must stay fixed + // while the azim/elev tween smoothly converges + lookCameraPos: Vec3 | null = null; + + picker: Picker; + + mainCamera: Entity; + + mainTarget: RenderTarget; + splatTarget: RenderTarget; + colorTarget: RenderTarget; + workTarget: RenderTarget; + + // Render passes + clearPass: RenderPass; + mainPass: RenderPassForward; + splatPass: RenderPassForward; + gizmoPass: RenderPassForward; + finalPass: SimpleRenderPass; + + // overridden target size + targetSizeOverride: { width: number, height: number } = null; + + // when set, overrides the tween-driven pose, fov and clipping planes each + // update (used by 360 capture to render arbitrary face orientations that + // the azim/elev pose system cannot express) + poseOverride: { position: Vec3, rotation: Quat, fov: number, near: number, far: number } | null = null; + + // world transform of the user-facing camera pose. while a pose override + // is active this holds the last tween-driven pose, so ui elements (view + // cube, overlays) don't track the internal capture poses + displayTransform = new Mat4(); + + renderOverlays = true; + + updateCameraUniforms: () => void; + + constructor() { + super(ElementType.camera); + + // create the camera entity + this.mainCamera = new Entity('Camera'); + this.mainCamera.addComponent('camera'); + } + + // ortho + set ortho(value: boolean) { + if (value !== this.ortho) { + this.camera.projection = value ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE; + this.scene.events.fire('camera.ortho', value); + } + } + + get ortho() { + return this.camera.projection === PROJECTION_ORTHOGRAPHIC; + } + + // fov + set fov(value: number) { + this.camera.fov = value; + } + + get fov() { + return this.camera.fov; + } + + // tonemapping + set tonemapping(value: string) { + const mapping: Record = { + linear: TONEMAP_LINEAR, + neutral: TONEMAP_NEUTRAL, + aces: TONEMAP_ACES, + aces2: TONEMAP_ACES2, + filmic: TONEMAP_FILMIC, + hejl: TONEMAP_HEJL + }; + + const tvalue = mapping[value]; + + if (tvalue !== undefined && tvalue !== this.camera.toneMapping) { + this.camera.toneMapping = tvalue; + this.scene.events.fire('camera.tonemapping', value); + } + } + + get tonemapping() { + switch (this.camera.toneMapping) { + case TONEMAP_LINEAR: return 'linear'; + case TONEMAP_NEUTRAL: return 'neutral'; + case TONEMAP_ACES: return 'aces'; + case TONEMAP_ACES2: return 'aces2'; + case TONEMAP_FILMIC: return 'filmic'; + case TONEMAP_HEJL: return 'hejl'; + } + return 'linear'; + } + + // near clip + set near(value: number) { + this.camera.nearClip = value; + } + + get near() { + return this.camera.nearClip; + } + + // far clip + set far(value: number) { + this.camera.farClip = value; + } + + get far() { + return this.camera.farClip; + } + + // focal point + get focalPoint() { + const t = this.focalPointTween.target; + return new Vec3(t.x, t.y, t.z); + } + + // azimuth, elevation + get azimElev() { + return this.azimElevTween.target; + } + + get azim() { + return this.azimElev.azim; + } + + get elevation() { + return this.azimElev.elev; + } + + get distance() { + return this.distanceTween.target.distance; + } + + setFocalPoint(point: Vec3, dampingFactorFactor: number = 1) { + this.lookCameraPos = null; + this.focalPointTween.goto(point, dampingFactorFactor * this.scene.config.controls.dampingFactor); + } + + // Fly mode: rotate camera around itself, keeping the camera position fixed + look(dx: number, dy: number) { + const sensitivity = this.scene.config.controls.orbitSensitivity; + const d = this.distance * this.sceneRadius / this.fovFactor; + + Camera.calcForwardVec(forwardVec, this.azim, this.elevation); + const cameraPos = this.focalPoint.add(forwardVec.clone().mulScalar(d)); + + const azim = this.azim - dx * sensitivity; + const elev = this.elevation - dy * sensitivity; + + Camera.calcForwardVec(forwardVec, azim, elev); + const focalPoint = cameraPos.clone().sub(forwardVec.clone().mulScalar(d)); + + this.setAzimElev(azim, elev); + this.focalPointTween.goto(focalPoint, this.scene.config.controls.dampingFactor); + this.lookCameraPos = cameraPos; + } + + setAzimElev(azim: number, elev: number, dampingFactorFactor: number = 1) { + // clamp + azim = mod(azim, 360); + elev = Math.max(this.minElev, Math.min(this.maxElev, elev)); + + const t = this.azimElevTween; + t.goto({ azim, elev }, dampingFactorFactor * this.scene.config.controls.dampingFactor); + + // handle wraparound + if (t.source.azim - azim < -180) { + t.source.azim += 360; + } else if (t.source.azim - azim > 180) { + t.source.azim -= 360; + } + + // return to perspective mode on rotation + this.ortho = false; + } + + setDistance(distance: number, dampingFactorFactor: number = 1) { + this.lookCameraPos = null; + + const controls = this.scene.config.controls; + + // clamp + distance = Math.max(controls.minZoom, Math.min(controls.maxZoom, distance)); + + const t = this.distanceTween; + t.goto({ distance }, dampingFactorFactor * controls.dampingFactor); + } + + setPose(position: Vec3, target: Vec3, dampingFactorFactor: number = 1) { + vec.sub2(target, position); + const l = vec.length(); + const azim = Math.atan2(-vec.x / l, -vec.z / l) * math.RAD_TO_DEG; + const elev = Math.asin(vec.y / l) * math.RAD_TO_DEG; + this.setFocalPoint(target, dampingFactorFactor); + this.setAzimElev(azim, elev, dampingFactorFactor); + this.setDistance(l / this.sceneRadius * this.fovFactor, dampingFactorFactor); + } + + // set or clear the pose override and apply it immediately so subsequent + // splat sorting and rendering see the new transform + setPoseOverride(override: Camera['poseOverride']) { + this.poseOverride = override; + this.onUpdate(0); + } + + // transform the world space coordinate to normalized screen coordinate + worldToScreen(world: Vec3, screen: Vec3) { + const { camera } = this; + m.mul2(camera.projectionMatrix, camera.viewMatrix); + + v4.set(world.x, world.y, world.z, 1); + m.transformVec4(v4, v4); + + screen.x = v4.x / v4.w * 0.5 + 0.5; + screen.y = 1.0 - (v4.y / v4.w * 0.5 + 0.5); + screen.z = v4.z / v4.w; + } + + add() { + const { camera, scene } = this; + + scene.cameraRoot.addChild(this.mainCamera); + + // configure camera to render all layers + this.mainCamera.camera.layers = [ + scene.worldLayer.id, + scene.splatLayer.id, + scene.gizmoLayer.id + ]; + + // use manual aspect ratio mode so we can set it based on targetSize + camera.aspectRatioMode = ASPECT_MANUAL; + + // create render passes + const device = scene.graphicsDevice; + const { app } = scene; + const renderer = app.renderer; + const composition = app.scene.layers; + + this.clearPass = new RenderPass(device); + this.mainPass = new RenderPassForward(device, composition, app.scene, renderer); + this.splatPass = new RenderPassForward(device, composition, app.scene, renderer); + this.gizmoPass = new RenderPassForward(device, composition, app.scene, renderer); + this.finalPass = new SimpleRenderPass(device, + new ShaderQuad(device, vertexShader, fragmentShader, 'final-blit'), { + vars: () => { + return { + srcTexture: this.mainTarget.colorBuffer + }; + } + }); + + const target = document.getElementById('canvas-container'); + this.controller = new PointerController(this, target); + + // apply scene config + const config = scene.config; + const controls = config.controls; + + this.minElev = (controls.minPolarAngle * 180) / Math.PI - 90; + this.maxElev = (controls.maxPolarAngle * 180) / Math.PI - 90; + + // tonemapping + camera.toneMapping = { + linear: TONEMAP_LINEAR, + filmic: TONEMAP_FILMIC, + hejl: TONEMAP_HEJL, + aces: TONEMAP_ACES, + aces2: TONEMAP_ACES2, + neutral: TONEMAP_NEUTRAL + }[config.camera.toneMapping]; + + // exposure + scene.app.scene.exposure = config.camera.exposure; + + this.fov = config.camera.fov; + + // initial camera position and orientation + this.setAzimElev(controls.initialAzim, controls.initialElev, 0); + this.setDistance(controls.initialZoom, 0); + + // picker + this.picker = new Picker(scene); + + scene.events.on('scene.boundChanged', this.onBoundChanged, this); + + // prepare camera-specific uniforms + this.updateCameraUniforms = () => { + const device = scene.graphicsDevice; + const entity = this.mainCamera; + const camera = entity.camera; + + const set = (name: string, vec: Vec3) => { + device.scope.resolve(name).setValue([vec.x, vec.y, vec.z]); + }; + + // get frustum corners in world space + const points = camera.camera.getFrustumCorners(-100); + const worldTransform = this.worldTransform; + for (let i = 0; i < points.length; i++) { + worldTransform.transformPoint(points[i], points[i]); + } + + // near + if (camera.projection === PROJECTION_PERSPECTIVE) { + // perspective + set('near_origin', worldTransform.getTranslation()); + set('near_x', Vec3.ZERO); + set('near_y', Vec3.ZERO); + } else { + // orthographic + set('near_origin', points[3]); + set('near_x', va.sub2(points[0], points[3])); + set('near_y', va.sub2(points[2], points[3])); + } + + // far + set('far_origin', points[7]); + set('far_x', va.sub2(points[4], points[7])); + set('far_y', va.sub2(points[6], points[7])); + }; + + // temp control of camera start + const url = new URL(location.href); + const focal = url.searchParams.get('focal'); + if (focal) { + const parts = focal.toString().split(','); + if (parts.length === 3) { + this.setFocalPoint(new Vec3(parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])), 0); + } + } + const angles = url.searchParams.get('angles'); + if (angles) { + const parts = angles.toString().split(','); + if (parts.length === 2) { + this.setAzimElev(parseFloat(parts[0]), parseFloat(parts[1]), 0); + } + } + const distance = url.searchParams.get('distance'); + if (distance) { + this.setDistance(parseFloat(distance), 0); + } + } + + remove() { + const { scene } = this; + + this.controller.destroy(); + this.controller = null; + + // cleanup render passes + this.clearPass?.destroy(); + this.mainPass?.destroy(); + this.splatPass?.destroy(); + this.gizmoPass?.destroy(); + this.finalPass?.destroy(); + this.camera.framePasses = null; + + scene.cameraRoot.removeChild(this.mainCamera); + + this.picker.destroy(); + this.picker = null; + + scene.events.off('scene.boundChanged', this.onBoundChanged, this); + } + + // handle the scene's bound changing. the camera must be configured to render + // the entire extents as well as possible. + // also update the existing camera distance to maintain the current view + onBoundChanged(bound: BoundingBox) { + const prevDistance = this.distanceTween.value.distance * this.sceneRadius; + this.sceneRadius = Math.max(1e-03, bound.halfExtents.length()); + this.setDistance(prevDistance / this.sceneRadius, 0); + } + + serialize(serializer: Serializer) { + serializer.packa(this.worldTransform.data); + serializer.pack( + this.fov, + this.tonemapping, + this.targetSize.width, + this.targetSize.height + ); + } + + // handle the viewer canvas resizing + rebuildRenderTargets() { + const { width, height } = this.targetSize; + const { mainTarget, scene } = this; + + // early out if size is unchanged + if (mainTarget && mainTarget.width === width && mainTarget.height === height) { + return; + } + + if (!mainTarget) { + // first time - construct render targets + const { graphicsDevice } = scene; + + const createTexture = (name: string, width: number, height: number, format: number) => { + return new Texture(graphicsDevice, { + name, + width, + height, + format, + mipmaps: false, + minFilter: FILTER_NEAREST, + magFilter: FILTER_NEAREST, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + }; + + const colorBuffer = createTexture('cameraColor', width, height, PIXELFORMAT_RGBA16F); + const workBuffer = createTexture('workColor', width, height, PIXELFORMAT_RGBA8); + const depthBuffer = createTexture('cameraDepth', width, height, PIXELFORMAT_DEPTH); + + // create main render target + this.mainTarget = new RenderTarget({ + colorBuffer, + depthBuffer, + flipY: false, + autoResolve: false + }); + + // create MRT render target for splat pass + this.splatTarget = new RenderTarget({ + colorBuffers: [ + colorBuffer, // RT0: main color (shared) + workBuffer // RT1: overlay output (shared with workTarget) + ], + depthBuffer, + flipY: false, + autoResolve: false + }); + + this.colorTarget = new RenderTarget({ + colorBuffer, + depth: false, + autoResolve: false + }); + + // create work buffer (used for picking, overlay output, and other operations) + this.workTarget = new RenderTarget({ + colorBuffer: workBuffer, + depth: false, + autoResolve: false + }); + + // set picker render targets + this.picker.setRenderTargets(this.colorTarget, this.workTarget); + + // clear all targets + this.clearPass.init(this.splatTarget); + this.clearPass.setClearColor(new Color(0, 0, 0, 0)); + this.clearPass.setClearDepth(1); + this.clearPass.setClearStencil(0); + + // configure main pass - world layer with clears + this.mainPass.init(this.mainTarget); + this.mainPass.addLayer(this.camera, scene.worldLayer, false, false); + this.mainPass.addLayer(this.camera, scene.worldLayer, true, false); + + // configure splat pass - MRT target, no clears + this.splatPass.init(this.splatTarget); + this.splatPass.addLayer(this.camera, scene.splatLayer, false, false); + this.splatPass.addLayer(this.camera, scene.splatLayer, true, false); + + // configure gizmo pass + this.gizmoPass.init(this.mainTarget); + this.gizmoPass.addLayer(this.camera, scene.gizmoLayer, false, false); + this.gizmoPass.addLayer(this.camera, scene.gizmoLayer, true, false); + this.gizmoPass.renderActions[0].clearDepth = true; + this.gizmoPass.renderActions[0].clearStencil = true; + + this.finalPass.init(null); + + // assign render passes to camera + this.camera.framePasses = [this.clearPass, this.mainPass, this.splatPass, this.gizmoPass, this.finalPass]; + } else { + // resize existing render targets + const { splatTarget, colorTarget, workTarget } = this; + + mainTarget.resize(width, height); + workTarget.resize(width, height); + colorTarget.resize(width, height); + splatTarget.resize(width, height); + } + + this.camera.horizontalFov = width > height; + this.camera.aspectRatio = width / height; + scene.events.fire('camera.resize', { width, height }); + } + + onUpdate(deltaTime: number) { + // controller update + this.controller.update(deltaTime); + + // update underlying values + this.focalPointTween.update(deltaTime); + this.azimElevTween.update(deltaTime); + this.distanceTween.update(deltaTime); + + const azimElev = this.azimElevTween.value; + const distance = this.distanceTween.value; + + Camera.calcForwardVec(forwardVec, azimElev.azim, azimElev.elev); + + if (this.lookCameraPos) { + cameraPosition.copy(this.lookCameraPos); + if (this.azimElevTween.timer >= this.azimElevTween.transitionTime) { + this.lookCameraPos = null; + } + } else { + cameraPosition.copy(forwardVec); + cameraPosition.mulScalar(distance.distance * this.sceneRadius / this.fovFactor); + cameraPosition.add(this.focalPointTween.value); + } + + if (this.poseOverride) { + // cameraRoot has identity transform, so local space is world space + const { position, rotation, fov, near, far } = this.poseOverride; + this.mainCamera.setLocalPosition(position); + this.mainCamera.setLocalRotation(rotation); + this.camera.fov = fov; + this.near = near; + this.far = far; + } else { + this.mainCamera.setLocalPosition(cameraPosition); + this.mainCamera.setLocalEulerAngles(azimElev.elev, azimElev.azim, 0); + + this.fitClippingPlanes(this.mainCamera.getLocalPosition(), this.mainCamera.forward); + + this.displayTransform.copy(this.mainCamera.getWorldTransform()); + } + + const { camera } = this.mainCamera; + const { targetSize } = this; + + // update ortho height + camera.orthoHeight = this.distanceTween.value.distance * this.sceneRadius / this.fovFactor * (this.fov / 90) * (camera.horizontalFov ? targetSize.height / targetSize.width : 1); + camera.camera._updateViewProjMat(); + } + + fitClippingPlanes(cameraPosition: Vec3, forwardVec: Vec3) { + const bound = this.scene.bound; + const boundRadius = bound.halfExtents.length(); + + vec.sub2(bound.center, cameraPosition); + const dist = vec.dot(forwardVec); + + if (dist > 0) { + this.far = dist + boundRadius; + // if camera is placed inside the sphere bound calculate near based far + this.near = Math.max(1e-6, dist < boundRadius ? this.far / (1024 * 16) : dist - boundRadius); + } else { + // if the scene is behind the camera + this.far = boundRadius * 2; + this.near = this.far / (1024 * 16); + } + } + + onPreRender() { + this.rebuildRenderTargets(); + this.updateCameraUniforms(); + } + + onPostRender() { + + } + + focus(options?: { focalPoint: Vec3, radius: number, speed: number }) { + const getSplatFocalPoint = () => { + for (const element of this.scene.elements) { + if (element.type === ElementType.splat) { + const focalPoint = (element as Splat).focalPoint?.(); + if (focalPoint) { + return focalPoint; + } + } + } + }; + + const focalPoint = options ? options.focalPoint : (getSplatFocalPoint() ?? this.scene.bound.center); + const focalRadius = options ? options.radius : this.scene.bound.halfExtents.length(); + + const fdist = focalRadius / this.sceneRadius; + + this.setDistance(isFinite(fdist) ? fdist : 1, options?.speed ?? 0); + this.setFocalPoint(focalPoint, options?.speed ?? 0); + } + + get fovFactor() { + // use the larger axis fov (which is always this.fov) so camera distance + // stays constant regardless of viewport aspect ratio. + return Math.sin(this.fov * math.DEG_TO_RAD * 0.5); + } + + getRay(screenX: number, screenY: number, ray: Ray) { + const { camera, ortho } = this; + const cameraPos = this.mainCamera.getPosition(); + + // create the pick ray in world space + if (ortho) { + camera.screenToWorld(screenX, screenY, -1.0, vec); + camera.screenToWorld(screenX, screenY, 1.0, vecb); + vecb.sub(vec).normalize(); + ray.set(vec, vecb); + } else { + camera.screenToWorld(screenX, screenY, 1.0, vec); + vec.sub(cameraPos).normalize(); + ray.set(cameraPos, vec); + } + } + + // intersect the scene at the given normalized screen coordinate (0-1 range) using depth picking + async intersect(x: number, y: number) { + const { scene } = this; + const splats = scene.getElementsByType(ElementType.splat); + + let closestDepth = Infinity; + let closestSplat: Splat | null = null; + + // Find the splat with the smallest depth at this screen position + for (let i = 0; i < splats.length; ++i) { + const splat = splats[i] as Splat; + + this.picker.prepareDepth(splat); + const normalizedDepth = await this.picker.readDepth(x, y); + + if (normalizedDepth !== null && normalizedDepth < closestDepth) { + closestDepth = normalizedDepth; + closestSplat = splat; + } + } + + if (!closestSplat) { + return null; + } + + // Convert normalized depth to linear depth + const linearDepth = closestDepth * (this.far - this.near) + this.near; + + // Convert normalized coordinates to screen pixels for getRay + const screenX = x * scene.canvas.clientWidth; + const screenY = y * scene.canvas.clientHeight; + + // Calculate world position from ray and depth + this.getRay(screenX, screenY, ray); + const t = linearDepth / ray.direction.dot(this.mainCamera.forward); + const position = new Vec3(); + position.copy(ray.origin).add(vec.copy(ray.direction).mulScalar(t)); + + return { + splat: closestSplat, + position: position, + distance: t + }; + } + + // intersect the scene at the normalized screen location (0-1 range) and focus the camera on this location + async pickFocalPoint(x: number, y: number) { + const result = await this.intersect(x, y); + if (result) { + const { scene } = this; + + this.setFocalPoint(result.position); + this.setDistance(result.distance / this.sceneRadius * this.fovFactor); + scene.events.fire('camera.focalPointPicked', { + camera: this, + splat: result.splat, + position: result.position + }); + } + } + + // pick mode + + // render picker contents + pickPrep(splat: Splat, mode: 'add' | 'remove' | 'set' | 'intersect') { + this.picker.prepareId(splat, mode); + } + + pick(x: number, y: number) { + return this.picker.readId(x, y); + } + + pickRect(x: number, y: number, width: number, height: number) { + return this.picker.readIds(x, y, width, height); + } + + docSerialize() { + const pack3 = (v: Vec3) => [v.x, v.y, v.z]; + + return { + focalPoint: pack3(this.focalPointTween.target), + azim: this.azim, + elev: this.elevation, + distance: this.distance, + fov: this.fov, + tonemapping: this.tonemapping + }; + } + + docDeserialize(settings: any) { + this.setFocalPoint(new Vec3(settings.focalPoint), 0); + this.setAzimElev(settings.azim, settings.elev, 0); + this.setDistance(settings.distance, 0); + this.fov = settings.fov; + this.tonemapping = settings.tonemapping; + } + + // offscreen render mode + + startOffscreenMode(width: number, height: number) { + this.targetSizeOverride = { width, height }; + this.finalPass.enabled = false; + this.rebuildRenderTargets(); + this.onUpdate(0); + } + + endOffscreenMode() { + this.targetSizeOverride = null; + this.finalPass.enabled = true; + this.rebuildRenderTargets(); + this.onUpdate(0); + } + + get targetSize() { + return this.targetSizeOverride ?? this.scene.targetSize; + } + + get camera() { + return this.mainCamera.camera; + } + + get worldTransform() { + return this.mainCamera.getWorldTransform(); + } + + get position() { + return this.mainCamera.getPosition(); + } + + get forward() { + return this.mainCamera.forward; + } +} + +export { Camera }; diff --git a/src/color-grade.ts b/src/color-grade.ts new file mode 100644 index 0000000..e2602fe --- /dev/null +++ b/src/color-grade.ts @@ -0,0 +1,81 @@ +import { Color } from 'playcanvas'; + +const SH_C0 = 0.28209479177387814; + +const dcDecode = (v: number) => v * SH_C0 + 0.5; +const dcEncode = (v: number) => (v - 0.5) / SH_C0; + +const sigmoid = (v: number) => 1 / (1 + Math.exp(-v)); +const invSigmoid = (v: number) => ((v <= 0) ? -400 : ((v >= 1) ? 400 : -Math.log(1 / v - 1))); + +type GradeParams = { + tintClr: Color, + temperature: number, + saturation: number, + brightness: number, + blackPoint: number, + whitePoint: number, + transparency: number +}; + +type RGB = { r: number, g: number, b: number }; + +class ColorGrade { + private s: RGB; + private offset: number; + private saturation: number; + private transparency: number; + + readonly hasTint: boolean; + + constructor(p: GradeParams) { + const scale = 1 / (p.whitePoint - p.blackPoint); + this.s = { + r: scale * p.tintClr.r * (1 + p.temperature), + g: scale * p.tintClr.g, + b: scale * p.tintClr.b * (1 - p.temperature) + }; + this.offset = -p.blackPoint + p.brightness; + this.saturation = p.saturation; + this.transparency = p.transparency; + + this.hasTint = ( + !p.tintClr.equals(Color.WHITE) || + p.temperature !== 0 || + p.saturation !== 1 || + p.brightness !== 0 || + p.blackPoint !== 0 || + p.whitePoint !== 1 + ); + } + + private apply(c: RGB, offset: number) { + c.r = offset + c.r * this.s.r; + c.g = offset + c.g * this.s.g; + c.b = offset + c.b * this.s.b; + + const grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114; + c.r = grey + (c.r - grey) * this.saturation; + c.g = grey + (c.g - grey) * this.saturation; + c.b = grey + (c.b - grey) * this.saturation; + } + + applyDC(c: RGB) { + this.apply(c, this.offset); + } + + applySH(c: RGB) { + this.apply(c, 0); + } + + applyOpacity(o: number): number { + return invSigmoid(sigmoid(o) * this.transparency); + } + + applyAlpha(o: number): number { + return sigmoid(o) * this.transparency; + } +} + +export { ColorGrade, dcDecode, dcEncode, sigmoid, invSigmoid, SH_C0 }; +export type { GradeParams, RGB }; diff --git a/src/command-queue.ts b/src/command-queue.ts new file mode 100644 index 0000000..1649d7b --- /dev/null +++ b/src/command-queue.ts @@ -0,0 +1,19 @@ +// unified FIFO queue for all async splat work (GPU readbacks + history mutations). +// every consumer that needs ordering relative to other commands enqueues a task +// here; the queue guarantees strict FIFO across the whole app, so neither +// dataProcessor nor edit-history needs its own private chain. +class CommandQueue { + private tail: Promise = Promise.resolve(); + + enqueue(fn: () => T | Promise): Promise { + const next = this.tail.then(fn); + // swallow errors on the chain itself so a failed task doesn't poison + // subsequent ones. the caller still sees the rejection on its own promise. + this.tail = next.then(() => {}, (err) => { + console.error('CommandQueue task failed', err); + }); + return next; + } +} + +export { CommandQueue }; diff --git a/src/controllers.ts b/src/controllers.ts new file mode 100644 index 0000000..a3cd7f2 --- /dev/null +++ b/src/controllers.ts @@ -0,0 +1,479 @@ +import { Vec3 } from 'playcanvas'; + +import { Camera } from './camera'; + +const fromWorldPoint = new Vec3(); +const toWorldPoint = new Vec3(); +const worldDiff = new Vec3(); +const moveVec = new Vec3(); + +// calculate the distance between two 2d points +const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); + +class PointerController { + update: (deltaTime: number) => void; + destroy: () => void; + + constructor(camera: Camera, target: HTMLElement) { + + // Orbit mode: rotate camera around the focal point + const orbit = (dx: number, dy: number) => { + const azim = camera.azim - dx * camera.scene.config.controls.orbitSensitivity; + const elev = camera.elevation - dy * camera.scene.config.controls.orbitSensitivity; + camera.setAzimElev(azim, elev); + }; + + const look = (dx: number, dy: number) => { + camera.look(dx, dy); + }; + + const pan = (x: number, y: number, dx: number, dy: number) => { + // For panning to work at any zoom level, we use screen point to world projection + // to work out how far we need to pan the pivotEntity in world space + const c = camera.camera; + const distance = camera.distanceTween.value.distance * camera.sceneRadius / camera.fovFactor; + + c.screenToWorld(x, y, distance, fromWorldPoint); + c.screenToWorld(x - dx, y - dy, distance, toWorldPoint); + + worldDiff.sub2(toWorldPoint, fromWorldPoint); + worldDiff.add(camera.focalPoint); + + camera.setFocalPoint(worldDiff); + }; + + const zoom = (amount: number) => { + camera.setDistance(camera.distance - (camera.distance * 0.999 + 0.001) * amount * camera.scene.config.controls.zoomSensitivity, 2); + }; + + // mouse state + let pressedButton = -1; // no button pressed, otherwise 0, 1, or 2 + let x: number, y: number; + + // middle-mouse click-vs-drag tracking (for MMB single-click to focus) + const CLICK_DRAG_THRESHOLD = 4; + let mmbStartX = 0, mmbStartY = 0, mmbDragged = false; + + // touch state + let touches: { id: number, x: number, y: number}[] = []; + let midx: number, midy: number, midlen: number; + + const pointerdown = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + // If a button is already pressed, ignore this press + if (pressedButton !== -1) { + return; + } + target.setPointerCapture(event.pointerId); + pressedButton = event.button; + x = event.offsetX; + y = event.offsetY; + if (pressedButton === 1) { + mmbStartX = x; + mmbStartY = y; + mmbDragged = false; + } + } else if (event.pointerType === 'touch') { + if (touches.length === 0) { + target.setPointerCapture(event.pointerId); + } + touches.push({ + x: event.offsetX, + y: event.offsetY, + id: event.pointerId + }); + + if (touches.length === 2) { + midx = (touches[0].x + touches[1].x) * 0.5; + midy = (touches[0].y + touches[1].y) * 0.5; + midlen = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); + } + } + }; + + const pointerup = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + // Only release if this is the button that was initially pressed + if (event.button === pressedButton) { + // MMB tap (no significant movement) -> focus on cursor point (orbit only; fly uses MMB for zoom) + if (pressedButton === 1 && camera.controlMode === 'orbit' && !mmbDragged) { + camera.pickFocalPoint(event.offsetX / target.clientWidth, event.offsetY / target.clientHeight); + } + pressedButton = -1; + target.releasePointerCapture(event.pointerId); + } + } else { + touches = touches.filter(touch => touch.id !== event.pointerId); + if (touches.length === 0) { + target.releasePointerCapture(event.pointerId); + } + } + }; + + const pointermove = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + // Only process if we're tracking a button + if (pressedButton === -1) { + return; + } + + // Verify the button we're tracking is still pressed + // 1 = left button, 4 = middle button, 2 = right button + const buttonMask = [1, 4, 2][pressedButton]; + if ((event.buttons & buttonMask) === 0) { + // Button is no longer pressed, clean up + pressedButton = -1; + return; + } + + const dx = event.offsetX - x; + const dy = event.offsetY - y; + x = event.offsetX; + y = event.offsetY; + + if (camera.controlMode === 'fly') { + // Fly mode: left-drag to look around, middle to zoom, right works same as orbit + if (pressedButton === 0) { + look(dx, dy); + } else if (pressedButton === 1) { + zoom(dy * -0.02); + } else if (pressedButton === 2) { + // Right button: same behavior as orbit mode + const mod = event.shiftKey || event.ctrlKey ? 'look' : + (event.altKey || event.metaKey ? 'zoom' : 'pan'); + + if (mod === 'look') { + look(dx, dy); + } else if (mod === 'zoom') { + zoom(dy * -0.02); + } else { + pan(x, y, dx, dy); + } + } + } else { + // Orbit mode: + // - left button: orbit + // - middle button (Blender-style): orbit, Shift -> pan, Ctrl -> zoom + // (gated on a small drag threshold so a tap can be used to focus on release) + // - right button: pan, Shift/Ctrl -> orbit, Alt/Meta -> zoom + if (pressedButton === 1 && !mmbDragged) { + if (dist(event.offsetX, event.offsetY, mmbStartX, mmbStartY) < CLICK_DRAG_THRESHOLD) { + return; + } + mmbDragged = true; + } + + let mod: 'orbit' | 'pan' | 'zoom'; + if (pressedButton === 2) { + mod = event.shiftKey || event.ctrlKey ? 'orbit' : + (event.altKey || event.metaKey ? 'zoom' : 'pan'); + } else if (pressedButton === 1) { + mod = event.shiftKey ? 'pan' : + (event.ctrlKey ? 'zoom' : 'orbit'); + } else { + mod = 'orbit'; + } + + if (mod === 'orbit') { + orbit(dx, dy); + } else if (mod === 'zoom') { + zoom(dy * -0.02); + } else { + pan(x, y, dx, dy); + } + } + } else { + if (touches.length === 1) { + const touch = touches[0]; + const dx = event.offsetX - touch.x; + const dy = event.offsetY - touch.y; + touch.x = event.offsetX; + touch.y = event.offsetY; + + if (camera.controlMode === 'fly') { + look(dx, dy); + } else { + orbit(dx, dy); + } + } else if (touches.length === 2) { + const touch = touches[touches.map(t => t.id).indexOf(event.pointerId)]; + touch.x = event.offsetX; + touch.y = event.offsetY; + + const mx = (touches[0].x + touches[1].x) * 0.5; + const my = (touches[0].y + touches[1].y) * 0.5; + const ml = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); + + if (camera.controlMode === 'fly') { + // In fly mode, pinch moves forward/backward by moving focal point + const zoomDelta = (ml - midlen) * 0.01; + const worldTransform = camera.mainCamera.getWorldTransform(); + const zAxis = worldTransform.getZ(); + moveVec.copy(zAxis).mulScalar(-zoomDelta * camera.flySpeed); + const p = camera.focalPoint.add(moveVec); + camera.setFocalPoint(p); + } else { + pan(mx, my, (mx - midx), (my - midy)); + zoom((ml - midlen) * 0.01); + } + + midx = mx; + midy = my; + midlen = ml; + } + } + }; + + // A physical mouse wheel and a trackpad two-finger swipe are + // indistinguishable from their wheel-event deltas alone - every + // per-event heuristic (wheelDelta % 120, deltaMode, fractional or + // diagonal deltas) misfires on hi-res mice and in momentum tails + // (see issue #919). The one reliable trackpad signal the platform + // gives us is the macOS-synthesized pinch: a wheel event with + // ctrlKey set while the physical Ctrl key is *not* held. So we no + // longer classify the device and instead drive everything from + // modifier keys, treating a bare scroll as zoom regardless of device: + // + // | Input | Action | + // |-----------------------------|--------| + // | Bare scroll (wheel / swipe) | zoom | + // | Pinch (synthetic Ctrl) | zoom | + // | Physical Ctrl + scroll | orbit | + // | Shift + scroll | pan | + + // Track the physical Ctrl key so we can tell a real Ctrl+scroll + // (orbit) from a macOS pinch (zoom). Listeners live on window so they + // fire regardless of which element currently has focus. + let ctrlDown = false; + const keydown = (event: KeyboardEvent) => { + if (event.key === 'Control') ctrlDown = true; + }; + const keyup = (event: KeyboardEvent) => { + if (event.key === 'Control') ctrlDown = false; + }; + + const wheel = (event: WheelEvent) => { + const { deltaX, deltaY } = event; + + // Some browsers (notably Safari/Firefox on macOS) remap a vertical + // mouse wheel to deltaX when Shift is held. Only fall back to + // deltaX for that remapped case so horizontal-only scrolling + // (tilt wheel, horizontal trackpad swipe) is not treated as + // zoom / fly movement. + const wheelDelta = event.shiftKey && deltaY === 0 ? deltaX : deltaY; + + // Synthetic Ctrl (macOS/Magic Mouse pinch) or Cmd: fine zoom. + // Physical Ctrl held down: orbit. + const isPinch = (event.ctrlKey && !ctrlDown) || event.metaKey; + const isOrbit = event.ctrlKey && ctrlDown; + + if (camera.controlMode === 'fly') { + if (isOrbit) { + look(deltaX, deltaY); + } else if (event.shiftKey) { + pan(event.offsetX, event.offsetY, deltaX, deltaY); + } else if (camera.ortho) { + // moving forward/backward has no visual effect in ortho + // (ortho height derives from distance), so zoom instead, + // with the same pinch/scroll factors as the orbit path + zoom(isPinch ? deltaY * -0.02 : wheelDelta * -0.002); + } else { + // Bare scroll / pinch: move focal point forward/backward + const factor = camera.flySpeed * 0.01; + const worldTransform = camera.mainCamera.getWorldTransform(); + const zAxis = worldTransform.getZ(); + moveVec.copy(zAxis).mulScalar(wheelDelta * factor); + const p = camera.focalPoint.add(moveVec); + camera.setFocalPoint(p); + } + } else if (isOrbit) { + orbit(deltaX, deltaY); + } else if (event.shiftKey) { + pan(event.offsetX, event.offsetY, deltaX, deltaY); + } else if (isPinch) { + zoom(deltaY * -0.02); + } else { + zoom(wheelDelta * -0.002); + } + + event.preventDefault(); + }; + + // FIXME: safari sends canvas as target of dblclick event but chrome sends the target element + const canvas = camera.scene.app.graphicsDevice.canvas; + + const dblclick = (event: globalThis.MouseEvent) => { + if (event.target === target || event.target === canvas) { + // Switch to orbit mode when double-clicking to focus + if (camera.controlMode === 'fly') { + camera.scene.events.fire('camera.setControlMode', 'orbit'); + } + camera.pickFocalPoint(event.offsetX / target.clientWidth, event.offsetY / target.clientHeight); + } + }; + + // fly movement state (updated via shortcut events) + let flyForward = false; + let flyBackward = false; + let flyLeft = false; + let flyRight = false; + let flyDown = false; + let flyUp = false; + + // track modifier keys for speed control (updated via shortcut events) + let fastDown = false; + let slowDown = false; + + // Clear all keys when window loses focus to prevent stuck keys + const clearAllKeys = () => { + flyForward = false; + flyBackward = false; + flyLeft = false; + flyRight = false; + flyDown = false; + flyUp = false; + fastDown = false; + slowDown = false; + ctrlDown = false; + }; + + // Helper to switch to fly mode when a fly key is pressed + const handleFlyKey = (down: boolean) => { + if (down && camera.controlMode !== 'fly') { + camera.scene.events.fire('camera.setControlMode', 'fly'); + } + }; + + // Listen for fly movement shortcut events + const events = camera.scene.events; + + const onFlyForward = (down: boolean) => { + flyForward = down; + handleFlyKey(down); + }; + const onFlyBackward = (down: boolean) => { + flyBackward = down; + handleFlyKey(down); + }; + const onFlyLeft = (down: boolean) => { + flyLeft = down; + handleFlyKey(down); + }; + const onFlyRight = (down: boolean) => { + flyRight = down; + handleFlyKey(down); + }; + const onFlyDown = (down: boolean) => { + flyDown = down; + handleFlyKey(down); + }; + const onFlyUp = (down: boolean) => { + flyUp = down; + handleFlyKey(down); + }; + const onModifierFast = (down: boolean) => { + fastDown = down; + }; + const onModifierSlow = (down: boolean) => { + slowDown = down; + }; + + events.on('camera.fly.forward', onFlyForward); + events.on('camera.fly.backward', onFlyBackward); + events.on('camera.fly.left', onFlyLeft); + events.on('camera.fly.right', onFlyRight); + events.on('camera.fly.down', onFlyDown); + events.on('camera.fly.up', onFlyUp); + events.on('camera.modifier.fast', onModifierFast); + events.on('camera.modifier.slow', onModifierSlow); + + this.update = (deltaTime: number) => { + if (camera.controlMode !== 'fly') return; + + // Fly mode: WASD for movement, Q/E for up/down - moves focal point + const forward = (flyForward ? 1 : 0) - (flyBackward ? 1 : 0); + const strafe = (flyRight ? 1 : 0) - (flyLeft ? 1 : 0); + const vertical = (flyUp ? 1 : 0) - (flyDown ? 1 : 0); + + if (forward || strafe || vertical) { + // Calculate speed modifier based on current modifier key state + const speedMod = fastDown ? 10 : (slowDown ? 0.1 : 1); + const factor = deltaTime * camera.flySpeed * speedMod; + const worldTransform = camera.worldTransform; + + moveVec.set(0, 0, 0); + + // Forward/backward along horizontal forward direction (fixed Y) + if (forward) { + const zAxis = worldTransform.getZ(); + zAxis.y = 0; + zAxis.normalize(); + moveVec.add(zAxis.mulScalar(-forward * factor)); + } + + // Strafe left/right (horizontal) + if (strafe) { + const xAxis = worldTransform.getX(); + xAxis.y = 0; + xAxis.normalize(); + moveVec.add(xAxis.mulScalar(strafe * factor)); + } + + // Up/down in world space + if (vertical) { + moveVec.y += vertical * factor; + } + + // Move the focal point (camera follows due to orbit calculation) + const p = camera.focalPoint.add(moveVec); + camera.setFocalPoint(p); + } + }; + + let destroy: () => void = null; + + const wrap = (target: any, name: string, fn: any, options?: any) => { + const callback = (event: any) => { + camera.scene.events.fire('camera.controller', name); + fn(event); + }; + target.addEventListener(name, callback, options); + destroy = () => { + destroy?.(); + target.removeEventListener(name, callback); + }; + }; + + wrap(target, 'pointerdown', pointerdown); + wrap(target, 'pointerup', pointerup); + wrap(target, 'pointermove', pointermove); + wrap(target, 'wheel', wheel, { passive: false }); + wrap(target, 'dblclick', dblclick); + wrap(window, 'blur', clearAllKeys); + + // Registered directly (not via wrap) so physical-Ctrl tracking + // doesn't fire camera.controller on every keystroke. Capture phase + // ensures we see Ctrl keydown/keyup even when a focused UI element + // (dialogs, popups) calls stopPropagation() on key events - otherwise + // ctrlDown could go stale and a real Ctrl+wheel would be misread as a + // synthetic-Ctrl pinch. + window.addEventListener('keydown', keydown, { capture: true }); + window.addEventListener('keyup', keyup, { capture: true }); + + this.destroy = () => { + destroy?.(); + window.removeEventListener('keydown', keydown, { capture: true }); + window.removeEventListener('keyup', keyup, { capture: true }); + events.off('camera.fly.forward', onFlyForward); + events.off('camera.fly.backward', onFlyBackward); + events.off('camera.fly.left', onFlyLeft); + events.off('camera.fly.right', onFlyRight); + events.off('camera.fly.down', onFlyDown); + events.off('camera.fly.up', onFlyUp); + events.off('camera.modifier.fast', onModifierFast); + events.off('camera.modifier.slow', onModifierSlow); + }; + } +} + +export { PointerController }; diff --git a/src/data-processor/buffer-pool.ts b/src/data-processor/buffer-pool.ts new file mode 100644 index 0000000..b864903 --- /dev/null +++ b/src/data-processor/buffer-pool.ts @@ -0,0 +1,27 @@ +// pool of Uint8Array readback buffers, keyed by byteLength. GPU passes acquire +// a buffer for the duration of a single run+consume cycle and release it back +// when done. avoids the shared-singleton foot-gun where a stale reference from +// a previous run could be overwritten under the caller, while keeping steady +// state memory parity with the old model (one buffer in flight, one parked). +class BufferPool { + private free = new Map(); + + acquire(byteLen: number): Uint8Array { + const list = this.free.get(byteLen); + if (list && list.length) { + return list.pop()!; + } + return new Uint8Array(byteLen); + } + + release(buf: Uint8Array): void { + const list = this.free.get(buf.byteLength); + if (list) { + list.push(buf); + } else { + this.free.set(buf.byteLength, [buf]); + } + } +} + +export { BufferPool }; diff --git a/src/data-processor/calc-bound.ts b/src/data-processor/calc-bound.ts new file mode 100644 index 0000000..2dc8254 --- /dev/null +++ b/src/data-processor/calc-bound.ts @@ -0,0 +1,244 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + PIXELFORMAT_RGBA32F, + SEMANTIC_POSITION, + drawQuadWithShader, + BoundingBox, + GraphicsDevice, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + Texture, + Vec3, + BlendState +} from 'playcanvas'; + +import { vertexShader, fragmentShader } from '../shaders/bound-shader'; +import { Splat } from '../splat'; + +const v1 = new Vec3(); +const v2 = new Vec3(); +const v3 = new Vec3(); +const v4 = new Vec3(); + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +class CalcBound { + private device: GraphicsDevice; + private splatParams = new Int32Array(3); + private shader: Shader = null; + private selectedMinTexture: Texture = null; + private selectedMaxTexture: Texture = null; + private visibleMinTexture: Texture = null; + private visibleMaxTexture: Texture = null; + private renderTarget: RenderTarget = null; + private selectedMinRenderTarget: RenderTarget = null; + private selectedMaxRenderTarget: RenderTarget = null; + private visibleMinRenderTarget: RenderTarget = null; + private visibleMaxRenderTarget: RenderTarget = null; + private selectedMinData: Float32Array = null; + private selectedMaxData: Float32Array = null; + private visibleMinData: Float32Array = null; + private visibleMaxData: Float32Array = null; + + constructor(device: GraphicsDevice) { + this.device = device; + } + + private getResources(width: number) { + const { device } = this; + + if (!this.shader) { + this.shader = ShaderUtils.createShader(device, { + uniqueName: 'calcBoundShader', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + } + + if (!this.selectedMinTexture || this.selectedMinTexture.width !== width) { + if (this.selectedMinTexture) { + this.selectedMinTexture.destroy(); + this.selectedMaxTexture.destroy(); + this.visibleMinTexture.destroy(); + this.visibleMaxTexture.destroy(); + this.renderTarget.destroy(); + this.selectedMinRenderTarget.destroy(); + this.selectedMaxRenderTarget.destroy(); + this.visibleMinRenderTarget.destroy(); + this.visibleMaxRenderTarget.destroy(); + } + + const createTexture = (name: string) => { + return new Texture(device, { + name, + width, + height: 1, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + }; + + this.selectedMinTexture = createTexture('calcBoundSelectedMin'); + this.selectedMaxTexture = createTexture('calcBoundSelectedMax'); + this.visibleMinTexture = createTexture('calcBoundVisibleMin'); + this.visibleMaxTexture = createTexture('calcBoundVisibleMax'); + + this.renderTarget = new RenderTarget({ + colorBuffers: [this.selectedMinTexture, this.selectedMaxTexture, this.visibleMinTexture, this.visibleMaxTexture], + depth: false + }); + + this.selectedMinRenderTarget = new RenderTarget({ + colorBuffer: this.selectedMinTexture, + depth: false + }); + + this.selectedMaxRenderTarget = new RenderTarget({ + colorBuffer: this.selectedMaxTexture, + depth: false + }); + + this.visibleMinRenderTarget = new RenderTarget({ + colorBuffer: this.visibleMinTexture, + depth: false + }); + + this.visibleMaxRenderTarget = new RenderTarget({ + colorBuffer: this.visibleMaxTexture, + depth: false + }); + + this.selectedMinData = new Float32Array(width * 4); + this.selectedMaxData = new Float32Array(width * 4); + this.visibleMinData = new Float32Array(width * 4); + this.visibleMaxData = new Float32Array(width * 4); + } + + return { + shader: this.shader, + selectedMinTexture: this.selectedMinTexture, + selectedMaxTexture: this.selectedMaxTexture, + visibleMinTexture: this.visibleMinTexture, + visibleMaxTexture: this.visibleMaxTexture, + renderTarget: this.renderTarget, + selectedMinRenderTarget: this.selectedMinRenderTarget, + selectedMaxRenderTarget: this.selectedMaxRenderTarget, + visibleMinRenderTarget: this.visibleMinRenderTarget, + visibleMaxRenderTarget: this.visibleMaxRenderTarget, + selectedMinData: this.selectedMinData, + selectedMaxData: this.selectedMaxData, + visibleMinData: this.visibleMinData, + visibleMaxData: this.visibleMaxData + }; + } + + async run(splat: Splat, selectionBound: BoundingBox, localBound: BoundingBox): Promise { + const device = splat.scene.graphicsDevice; + const { scope } = device; + + const numSplats = splat.splatData.numSplats; + const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA'); + const splatTransform = splat.transformTexture; + const transformPalette = splat.transformPalette.texture; + const splatState = splat.stateTexture; + + this.splatParams[0] = transformA.width; + this.splatParams[1] = transformA.height; + this.splatParams[2] = numSplats; + + // get resources + const resources = this.getResources(transformA.width); + + resolve(scope, { + transformA, + splatTransform, + transformPalette, + splatState, + splat_params: this.splatParams + }); + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, resources.renderTarget, resources.shader); + + // read all 4 textures asynchronously using the public texture.read() API + const [selectedMinData, selectedMaxData, visibleMinData, visibleMaxData] = await Promise.all([ + resources.selectedMinTexture.read(0, 0, transformA.width, 1, { + renderTarget: resources.selectedMinRenderTarget, + data: resources.selectedMinData, + immediate: false + }), + resources.selectedMaxTexture.read(0, 0, transformA.width, 1, { + renderTarget: resources.selectedMaxRenderTarget, + data: resources.selectedMaxData, + immediate: false + }), + resources.visibleMinTexture.read(0, 0, transformA.width, 1, { + renderTarget: resources.visibleMinRenderTarget, + data: resources.visibleMinData, + immediate: false + }), + resources.visibleMaxTexture.read(0, 0, transformA.width, 1, { + renderTarget: resources.visibleMaxRenderTarget, + data: resources.visibleMaxData, + immediate: false + }) + ]); + + // resolve selected bounds + v1.set(Infinity, Infinity, Infinity); + v2.set(-Infinity, -Infinity, -Infinity); + + for (let i = 0; i < transformA.width; i++) { + const a = selectedMinData[i * 4]; + const b = selectedMinData[i * 4 + 1]; + const c = selectedMinData[i * 4 + 2]; + if (isFinite(a)) v1.x = Math.min(v1.x, a); + if (isFinite(b)) v1.y = Math.min(v1.y, b); + if (isFinite(c)) v1.z = Math.min(v1.z, c); + + const d = selectedMaxData[i * 4]; + const e = selectedMaxData[i * 4 + 1]; + const f = selectedMaxData[i * 4 + 2]; + if (isFinite(d)) v2.x = Math.max(v2.x, d); + if (isFinite(e)) v2.y = Math.max(v2.y, e); + if (isFinite(f)) v2.z = Math.max(v2.z, f); + } + + selectionBound.setMinMax(v1, v2); + + // resolve visible bounds + v3.set(Infinity, Infinity, Infinity); + v4.set(-Infinity, -Infinity, -Infinity); + + for (let i = 0; i < transformA.width; i++) { + const a = visibleMinData[i * 4]; + const b = visibleMinData[i * 4 + 1]; + const c = visibleMinData[i * 4 + 2]; + if (isFinite(a)) v3.x = Math.min(v3.x, a); + if (isFinite(b)) v3.y = Math.min(v3.y, b); + if (isFinite(c)) v3.z = Math.min(v3.z, c); + + const d = visibleMaxData[i * 4]; + const e = visibleMaxData[i * 4 + 1]; + const f = visibleMaxData[i * 4 + 2]; + if (isFinite(d)) v4.x = Math.max(v4.x, d); + if (isFinite(e)) v4.y = Math.max(v4.y, e); + if (isFinite(f)) v4.z = Math.max(v4.z, f); + } + + localBound.setMinMax(v3, v4); + } +} + +export { CalcBound }; diff --git a/src/data-processor/calc-histogram.ts b/src/data-processor/calc-histogram.ts new file mode 100644 index 0000000..e899891 --- /dev/null +++ b/src/data-processor/calc-histogram.ts @@ -0,0 +1,353 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + BLENDEQUATION_ADD, + BLENDMODE_ONE, + PIXELFORMAT_RGBA32F, + SEMANTIC_POSITION, + drawQuadWithShader, + BlendState, + GraphicsDevice, + Mat4, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + Texture, + Vec3 +} from 'playcanvas'; + +import { drawPointsWithShader } from './draw-points'; +import { GRID_DIM, NUM_BINS } from './histogram-config'; +import { + fullscreenVS, + tileMinMaxFS, + finalReduceFS, + binVS, + binFS +} from '../shaders/histogram-shaders'; +import { Splat } from '../splat'; + +const identity = new Mat4(); +const zeroVec3 = new Vec3(); + +// number of SH coefficients per RGB band, indexed by GSplatResource.shBands. +const SH_NUM_COEFFS: { [k: number]: number } = { 0: 0, 1: 3, 2: 8, 3: 15 }; + +type CalcHistogramOptions = { + entityMatrix?: Mat4; + viewMatrix?: Mat4; + viewProjection?: Mat4; + cameraPos?: Vec3; + onScreenOnly?: boolean; +}; + +type CalcHistogramResult = { + selected: Float32Array; // length numBins + unselected: Float32Array; // length numBins + min: number; + max: number; + numValues: number; +}; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +const getShBands = (splat: Splat): number => { + return (splat.entity.gsplat.instance.resource as any).shBands ?? 0; +}; + +class CalcHistogram { + private device: GraphicsDevice; + + // shaders are compiled per SH_BANDS value so that each variant declares only + // the SH samplers it actually reads. reduceShader has no SH dependence. + private tileShaders: Map = new Map(); + private binShaders: Map = new Map(); + private reduceShader: Shader = null; + + private tileTex: Texture = null; + private tileRT: RenderTarget = null; + private minMaxTex: Texture = null; + private minMaxRT: RenderTarget = null; + private binTex: Texture = null; + private binRT: RenderTarget = null; + + private minMaxData = new Float32Array(4); + private binData = new Float32Array(NUM_BINS * 4); + + private additiveBlend: BlendState; + + constructor(device: GraphicsDevice) { + this.device = device; + + this.additiveBlend = new BlendState( + true, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE + ); + } + + private ensureSharedResources() { + const { device } = this; + + if (!this.reduceShader) { + this.reduceShader = ShaderUtils.createShader(device, { + uniqueName: 'histFinalReduce', + attributes: { vertex_position: SEMANTIC_POSITION }, + vertexGLSL: fullscreenVS, + fragmentGLSL: finalReduceFS + }); + } + + if (!this.tileTex) { + this.tileTex = new Texture(device, { + name: 'histTile', + width: GRID_DIM, + height: GRID_DIM, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + this.tileRT = new RenderTarget({ colorBuffer: this.tileTex, depth: false }); + + this.minMaxTex = new Texture(device, { + name: 'histMinMax', + width: 1, + height: 1, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + this.minMaxRT = new RenderTarget({ colorBuffer: this.minMaxTex, depth: false }); + + this.binTex = new Texture(device, { + name: 'histBins', + width: NUM_BINS, + height: 1, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + this.binRT = new RenderTarget({ colorBuffer: this.binTex, depth: false }); + } + } + + private getTileShader(shBands: number): Shader { + let shader = this.tileShaders.get(shBands); + if (!shader) { + const defines = new Map(); + defines.set('SH_BANDS', `${shBands}`); + shader = ShaderUtils.createShader(this.device, { + uniqueName: `histTileMinMax_SH${shBands}`, + attributes: { vertex_position: SEMANTIC_POSITION }, + vertexGLSL: fullscreenVS, + fragmentGLSL: tileMinMaxFS, + fragmentDefines: defines + }); + this.tileShaders.set(shBands, shader); + } + return shader; + } + + private getBinShader(shBands: number): Shader { + let shader = this.binShaders.get(shBands); + if (!shader) { + const defines = new Map(); + defines.set('SH_BANDS', `${shBands}`); + shader = ShaderUtils.createShader(this.device, { + uniqueName: `histBin_SH${shBands}`, + attributes: { vertex_position: SEMANTIC_POSITION }, + vertexGLSL: binVS, + fragmentGLSL: binFS, + vertexDefines: defines + }); + this.binShaders.set(shBands, shader); + } + return shader; + } + + private setSplatUniforms(splat: Splat, mode: number, options?: CalcHistogramOptions) { + const { scope } = this.device; + const numSplats = splat.splatData.numSplats; + const resource = splat.entity.gsplat.instance.resource as any; + const transformA = resource.getTexture('transformA'); + const transformB = resource.getTexture('transformB'); + const splatColor = resource.getTexture('splatColor'); + const splatTransform = splat.transformTexture; + const transformPalette = splat.transformPalette.texture; + const splatState = splat.stateTexture; + + const shBands = getShBands(splat); + const numCoeffs = SH_NUM_COEFFS[shBands] ?? 0; + + const entityMatrix = options?.entityMatrix ?? identity; + const viewMatrix = options?.viewMatrix ?? identity; + const viewProjection = options?.viewProjection ?? identity; + const cameraPos = options?.cameraPos ?? zeroVec3; + const onScreenOnly = options?.onScreenOnly ? 1 : 0; + + // ColorGrade math, kept in sync with ColorGrade in src/color-grade.ts. + const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = splat; + const cgInvRange = 1 / (whitePoint - blackPoint); + + const values: any = { + transformA, + transformB, + splatColor, + splatTransform, + transformPalette, + splatState, + splat_params: [transformA.width, numSplats], + propMode: mode, + entityMatrix: entityMatrix.data, + viewMatrix: viewMatrix.data, + viewProjection: viewProjection.data, + cameraWorldPos: [cameraPos.x, cameraPos.y, cameraPos.z], + onScreenOnly, + cgScale: [ + cgInvRange * tintClr.r * (1 + temperature), + cgInvRange * tintClr.g, + cgInvRange * tintClr.b * (1 - temperature) + ], + cgOffset: -blackPoint + brightness, + cgSaturation: saturation, + transparency + }; + + if (shBands > 0) { + values.splatSH_1to3 = resource.getTexture('splatSH_1to3'); + values.shNumCoeffs = numCoeffs; + } + if (shBands > 1) { + values.splatSH_4to7 = resource.getTexture('splatSH_4to7'); + values.splatSH_8to11 = resource.getTexture('splatSH_8to11'); + } + if (shBands > 2) { + values.splatSH_12to15 = resource.getTexture('splatSH_12to15'); + } + + resolve(scope, values); + + return numSplats; + } + + private clearRT(rt: RenderTarget) { + const d = this.device as any; + const oldRt = d.renderTarget; + const oldVx = d.vx, oldVy = d.vy, oldVw = d.vw, oldVh = d.vh; + const oldSx = d.sx, oldSy = d.sy, oldSw = d.sw, oldSh = d.sh; + + d.setRenderTarget(rt); + d.updateBegin(); + d.setViewport(0, 0, rt.width, rt.height); + d.setScissor(0, 0, rt.width, rt.height); + d.clear({ color: [0, 0, 0, 0], flags: 1 }); + d.updateEnd(); + + d.setRenderTarget(oldRt); + d.setViewport(oldVx, oldVy, oldVw, oldVh); + d.setScissor(oldSx, oldSy, oldSw, oldSh); + } + + // release all GPU resources owned by this instance. peer data-processor + // classes (Intersect, SelectByRange, CalcBound) destroy resources only on + // size change; CalcHistogram resources are fixed-size, so this exists for + // explicit teardown (context loss, scene reload) rather than per-run reuse. + destroy() { + this.tileRT?.destroy(); + this.tileTex?.destroy(); + this.minMaxRT?.destroy(); + this.minMaxTex?.destroy(); + this.binRT?.destroy(); + this.binTex?.destroy(); + this.tileRT = null; + this.tileTex = null; + this.minMaxRT = null; + this.minMaxTex = null; + this.binRT = null; + this.binTex = null; + this.tileShaders.clear(); + this.binShaders.clear(); + this.reduceShader = null; + } + + async run(splat: Splat, mode: number, options?: CalcHistogramOptions): Promise { + this.ensureSharedResources(); + const { device } = this; + const { scope } = device; + + const shBands = getShBands(splat); + const tileShader = this.getTileShader(shBands); + const binShader = this.getBinShader(shBands); + + const numSplats = this.setSplatUniforms(splat, mode, options); + + const tileSize = Math.ceil(numSplats / (GRID_DIM * GRID_DIM)); + scope.resolve('tileSize').setValue(tileSize); + scope.resolve('gridDim').setValue(GRID_DIM); + + // pass 1: tile min/max (fullscreen quad over GRID_DIM x GRID_DIM) + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, this.tileRT, tileShader); + + // pass 2: final reduce 64x64 → 1x1 + scope.resolve('inputTex').setValue(this.tileTex); + scope.resolve('gridDim').setValue(GRID_DIM); + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, this.minMaxRT, this.reduceShader); + + // pass 3: clear bins, then additive-blend point dispatch + this.clearRT(this.binRT); + + // bin shader needs same splat uniforms + minMax + numBins + this.setSplatUniforms(splat, mode, options); + scope.resolve('minMax').setValue(this.minMaxTex); + scope.resolve('numBins').setValue(NUM_BINS); + + drawPointsWithShader(device, this.binRT, binShader, numSplats, this.additiveBlend); + + // readback minMax (8 bytes) and bins (4 KB) + await this.minMaxTex.read(0, 0, 1, 1, { + renderTarget: this.minMaxRT, + data: this.minMaxData, + immediate: false + }); + + await this.binTex.read(0, 0, NUM_BINS, 1, { + renderTarget: this.binRT, + data: this.binData, + immediate: false + }); + + let min = this.minMaxData[0]; + let max = this.minMaxData[1]; + + // detect "nothing contributed" (sentinel survives reduction) + if (min > max) { + min = 0; + max = 0; + } + + const selected = new Float32Array(NUM_BINS); + const unselected = new Float32Array(NUM_BINS); + let numValues = 0; + for (let i = 0; i < NUM_BINS; i++) { + const s = this.binData[i * 4]; + const u = this.binData[i * 4 + 1]; + selected[i] = s; + unselected[i] = u; + numValues += s + u; + } + + return { selected, unselected, min, max, numValues }; + } +} + +export { CalcHistogram }; +export type { CalcHistogramOptions, CalcHistogramResult }; diff --git a/src/data-processor/calc-positions.ts b/src/data-processor/calc-positions.ts new file mode 100644 index 0000000..5b06f40 --- /dev/null +++ b/src/data-processor/calc-positions.ts @@ -0,0 +1,113 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + PIXELFORMAT_RGBA32F, + SEMANTIC_POSITION, + drawQuadWithShader, + GraphicsDevice, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + Texture, + BlendState +} from 'playcanvas'; + +import { vertexShader, fragmentShader } from '../shaders/position-shader'; +import { Splat } from '../splat'; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +class CalcPositions { + private device: GraphicsDevice; + private shader: Shader = null; + private texture: Texture = null; + private renderTarget: RenderTarget = null; + private data: Float32Array = null; + + constructor(device: GraphicsDevice) { + this.device = device; + } + + private getResources(width: number, height: number) { + const { device } = this; + + if (!this.shader) { + this.shader = ShaderUtils.createShader(device, { + uniqueName: 'calcPositionShader', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + } + + if (!this.texture || this.texture.width !== width || this.texture.height !== height) { + if (this.texture) { + this.texture.destroy(); + this.renderTarget.destroy(); + } + + this.texture = new Texture(device, { + name: 'positionTex', + width, + height, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + + this.renderTarget = new RenderTarget({ + colorBuffer: this.texture, + depth: false + }); + + this.data = new Float32Array(width * height * 4); + } + + return { + shader: this.shader, + texture: this.texture, + renderTarget: this.renderTarget, + data: this.data + }; + } + + async run(splat: Splat): Promise { + const { device } = this; + const { scope } = device; + + const numSplats = splat.splatData.numSplats; + const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA'); + const splatTransform = splat.transformTexture; + const transformPalette = splat.transformPalette.texture; + + // allocate resources + const resources = this.getResources(transformA.width, transformA.height); + + resolve(scope, { + transformA, + splatTransform, + transformPalette, + splat_params: [transformA.width, numSplats] + }); + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, resources.renderTarget, resources.shader); + + const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, { + renderTarget: resources.renderTarget, + data: resources.data, + immediate: false + }); + + return data as Float32Array; + } +} + +export { CalcPositions }; diff --git a/src/data-processor/draw-points.ts b/src/data-processor/draw-points.ts new file mode 100644 index 0000000..14830c2 --- /dev/null +++ b/src/data-processor/draw-points.ts @@ -0,0 +1,72 @@ +import { + PRIMITIVE_POINTS, + SEMANTIC_POSITION, + TYPE_FLOAT32, + BlendState, + DepthState, + GraphicsDevice, + RenderTarget, + Shader, + VertexBuffer, + VertexFormat +} from 'playcanvas'; + +let cachedDevice: GraphicsDevice = null; +let cachedVB: VertexBuffer = null; + +const getInstancingVB = (device: GraphicsDevice) => { + if (cachedVB && cachedDevice === device) { + return cachedVB; + } + const format = new VertexFormat(device, [ + { semantic: SEMANTIC_POSITION, components: 1, type: TYPE_FLOAT32 } + ]); + (format as any).instancing = true; + cachedVB = new VertexBuffer(device, format, 1); + cachedVB.lock(); + cachedVB.unlock(); + cachedDevice = device; + return cachedVB; +}; + +const drawPointsWithShader = ( + device: GraphicsDevice, + target: RenderTarget, + shader: Shader, + count: number, + blendState: BlendState +) => { + const vb = getInstancingVB(device); + const d = device as any; + + const oldRt = d.renderTarget; + const oldVx = d.vx, oldVy = d.vy, oldVw = d.vw, oldVh = d.vh; + const oldSx = d.sx, oldSy = d.sy, oldSw = d.sw, oldSh = d.sh; + + d.setRenderTarget(target); + d.updateBegin(); + + const w = target ? target.width : d.width; + const h = target ? target.height : d.height; + d.setViewport(0, 0, w, h); + d.setScissor(0, 0, w, h); + + d.setBlendState(blendState); + d.setDepthState(DepthState.NODEPTH); + d.setVertexBuffer(vb); + d.setShader(shader); + + d.draw({ + type: PRIMITIVE_POINTS, + base: 0, + count, + indexed: false + }); + + d.updateEnd(); + d.setRenderTarget(oldRt); + d.setViewport(oldVx, oldVy, oldVw, oldVh); + d.setScissor(oldSx, oldSy, oldSw, oldSh); +}; + +export { drawPointsWithShader }; diff --git a/src/data-processor/histogram-config.ts b/src/data-processor/histogram-config.ts new file mode 100644 index 0000000..0be301f --- /dev/null +++ b/src/data-processor/histogram-config.ts @@ -0,0 +1,25 @@ +// shared sizing constants for histogram and per-splat-mask GPU passes. +// keeping them in one place stops calc-histogram, select-by-range, intersect, +// and the histogram shaders from drifting independently. + +// histogram tile reduction grid: tile shader writes a GRID_DIM x GRID_DIM +// texture, the reduce shader collapses it to 1x1. the histogram shaders +// hardcode MAX_GRID_DIM=64 as the unrolled loop bound; if GRID_DIM ever +// exceeds 64 the shader bound must be raised alongside it. +const GRID_DIM = 64; + +// default bin count for histogram results when the caller does not override. +const NUM_BINS = 256; + +// per-splat mask packing: each RGBA8 output texel carries 4 splats. width is +// derived from the source transformA texture width via this helper so all +// callers (Intersect, SelectByRange) agree on layout. +const packedMaskWidth = (sourceWidth: number): number => { + return Math.max(1, Math.floor(sourceWidth / 2)); +}; + +const packedMaskHeight = (packedWidth: number, numSplats: number): number => { + return Math.ceil(numSplats / (packedWidth * 4)); +}; + +export { GRID_DIM, NUM_BINS, packedMaskWidth, packedMaskHeight }; diff --git a/src/data-processor/index.ts b/src/data-processor/index.ts new file mode 100644 index 0000000..a8232d1 --- /dev/null +++ b/src/data-processor/index.ts @@ -0,0 +1,126 @@ +import { + SEMANTIC_POSITION, + drawQuadWithShader, + BoundingBox, + GraphicsDevice, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + BlendState +} from 'playcanvas'; + +import { BufferPool } from './buffer-pool'; +import { CalcBound } from './calc-bound'; +import { CalcHistogram, CalcHistogramOptions } from './calc-histogram'; +import { CalcPositions } from './calc-positions'; +import { Intersect, IntersectOptions } from './intersect'; +import { SelectByRange, SelectByRangeOptions } from './select-by-range'; +import { Splat } from '../splat'; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +// gpu processor for splat data. methods are plain (no internal serialisation): +// callers that need ordering relative to other async work must run them inside +// a shared CommandQueue task. see src/command-queue.ts. +class DataProcessor { + private device: GraphicsDevice; + private copyShader: Shader; + + // shared pool of readback buffers used by GPU passes that hand bytes back + // to the caller. Callers receive ownership and must call releaseMask() when + // done. + private bufferPool = new BufferPool(); + + // instances + private intersectImpl: Intersect; + private calcBoundImpl: CalcBound; + private calcPositionsImpl: CalcPositions; + private calcHistogramImpl: CalcHistogram; + private selectByRangeImpl: SelectByRange; + + constructor(device: GraphicsDevice) { + this.device = device; + + this.copyShader = ShaderUtils.createShader(device, { + uniqueName: 'copyShader', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } + `, + fragmentGLSL: ` + uniform sampler2D colorTex; + void main(void) { + ivec2 texel = ivec2(gl_FragCoord.xy); + gl_FragColor = texelFetch(colorTex, texel, 0); + } + ` + }); + + // create instances + this.intersectImpl = new Intersect(device); + this.calcBoundImpl = new CalcBound(device); + this.calcPositionsImpl = new CalcPositions(device); + this.calcHistogramImpl = new CalcHistogram(device); + this.selectByRangeImpl = new SelectByRange(device); + } + + // calculate the intersection of a mask canvas with splat centers. + // returns an owned mask buffer the caller must release via releaseMask(). + intersect(options: IntersectOptions, splat: Splat) { + return this.intersectImpl.run(options, splat, this.bufferPool); + } + + // use gpu to calculate both selected and visible bounds in a single pass + calcBound(splat: Splat, selectionBound: BoundingBox, localBound: BoundingBox): Promise { + return this.calcBoundImpl.run(splat, selectionBound, localBound); + } + + // calculate world-space splat positions + calcPositions(splat: Splat) { + return this.calcPositionsImpl.run(splat); + } + + // calculate histogram (bin counts + min/max) entirely on GPU + calcHistogram(splat: Splat, mode: number, options?: CalcHistogramOptions) { + return this.calcHistogramImpl.run(splat, mode, options); + } + + // compute a per-splat byte mask (255 = in range and visible, 0 = not) for + // the given histogram bucket range. mode matches the propMode dispatch in + // src/shaders/splat-value-shader.ts (0..20 = built-in props, 21+N = f_rest_N). + // returns an owned mask buffer the caller must release via releaseMask(). + selectByRange(splat: Splat, mode: number, options: SelectByRangeOptions) { + return this.selectByRangeImpl.run(splat, mode, options, this.bufferPool); + } + + // release a mask buffer returned by intersect() or selectByRange() back to + // the pool so subsequent calls can reuse it without re-allocating. + releaseMask(mask: Uint8Array) { + this.bufferPool.release(mask); + } + + copyRt(source: RenderTarget, dest: RenderTarget) { + const { device } = this; + + resolve(device.scope, { + colorTex: source.colorBuffer + }); + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, dest, this.copyShader); + } +} + +export { DataProcessor }; +export type { IntersectOptions, CalcHistogramOptions, SelectByRangeOptions }; +export { MaskOptions, RectOptions, SphereOptions, BoxOptions } from './intersect'; diff --git a/src/data-processor/intersect.ts b/src/data-processor/intersect.ts new file mode 100644 index 0000000..4e28022 --- /dev/null +++ b/src/data-processor/intersect.ts @@ -0,0 +1,223 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + PIXELFORMAT_RGBA8, + SEMANTIC_POSITION, + drawQuadWithShader, + GraphicsDevice, + Mat4, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + Texture, + BlendState +} from 'playcanvas'; + +import { BufferPool } from './buffer-pool'; +import { packedMaskHeight, packedMaskWidth } from './histogram-config'; +import { vertexShader, fragmentShader } from '../shaders/intersection-shader'; +import { Splat } from '../splat'; + +type MaskOptions = { + mask: Texture; +}; + +type RectOptions = { + rect: { x1: number, y1: number, x2: number, y2: number }; +}; + +type SphereOptions = { + sphere: { x: number, y: number, z: number, radius: number }; +}; + +type BoxOptions = { + box: { x: number, y: number, z: number, lenx: number, leny: number, lenz: number }; +}; + +type IntersectOptions = MaskOptions | RectOptions | SphereOptions | BoxOptions; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +class Intersect { + private device: GraphicsDevice; + private dummyTexture: Texture; + private viewProjectionMat = new Mat4(); + private shader: Shader = null; + private texture: Texture = null; + private renderTarget: RenderTarget = null; + + constructor(device: GraphicsDevice) { + this.device = device; + this.dummyTexture = new Texture(device, { + width: 1, + height: 1, + format: PIXELFORMAT_RGBA8 + }); + } + + private getResources(width: number, numSplats: number) { + const { device } = this; + + if (!this.shader) { + this.shader = ShaderUtils.createShader(device, { + uniqueName: 'intersectByMaskShader', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + } + + const resultWidth = packedMaskWidth(width); + const resultHeight = packedMaskHeight(resultWidth, numSplats); + + if (!this.texture || this.texture.width !== resultWidth || this.texture.height !== resultHeight) { + if (this.texture) { + this.texture.destroy(); + this.renderTarget.destroy(); + } + + this.texture = new Texture(device, { + name: 'intersectTexture', + width: resultWidth, + height: resultHeight, + format: PIXELFORMAT_RGBA8, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + + this.renderTarget = new RenderTarget({ + colorBuffer: this.texture, + depth: false + }); + } + + return { + shader: this.shader, + texture: this.texture, + renderTarget: this.renderTarget + }; + } + + async run(options: IntersectOptions, splat: Splat, bufferPool: BufferPool): Promise { + const { device } = this; + const { scope } = device; + + const numSplats = splat.splatData.numSplats; + const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA'); + const splatTransform = splat.transformTexture; + const transformPalette = splat.transformPalette.texture; + + // update view projection matrix + const camera = splat.scene.camera.camera; + this.viewProjectionMat.mul2(camera.projectionMatrix, camera.viewMatrix); + + // allocate resources + const resources = this.getResources(transformA.width, numSplats); + + resolve(scope, { + transformA, + splatTransform, + transformPalette, + splat_params: [transformA.width, numSplats], + matrix_model: splat.entity.getWorldTransform().data, + matrix_viewProjection: this.viewProjectionMat.data, + output_params: [resources.texture.width, resources.texture.height] + }); + + const maskOptions = options as MaskOptions; + + if (maskOptions.mask) { + resolve(scope, { + mode: 0, + mask: maskOptions.mask, + mask_params: [maskOptions.mask.width, maskOptions.mask.height] + }); + } else { + resolve(scope, { + mask: this.dummyTexture, + mask_params: [0, 0] + }); + } + + const rectOptions = options as RectOptions; + if (rectOptions.rect) { + resolve(scope, { + mode: 1, + rect_params: [ + rectOptions.rect.x1 * 2.0 - 1.0, + rectOptions.rect.y1 * 2.0 - 1.0, + rectOptions.rect.x2 * 2.0 - 1.0, + rectOptions.rect.y2 * 2.0 - 1.0 + ] + }); + } else { + resolve(scope, { + rect_params: [0, 0, 0, 0] + }); + } + + const sphereOptions = options as SphereOptions; + if (sphereOptions.sphere) { + resolve(scope, { + mode: 2, + sphere_params: [ + sphereOptions.sphere.x, + sphereOptions.sphere.y, + sphereOptions.sphere.z, + sphereOptions.sphere.radius + ] + }); + } else { + resolve(scope, { + sphere_params: [0, 0, 0, 0] + }); + } + + const boxOptions = options as BoxOptions; + if (boxOptions.box) { + resolve(scope, { + mode: 3, + box_params: [ + boxOptions.box.x, + boxOptions.box.y, + boxOptions.box.z, + 0 + ], + aabb_params: [ + boxOptions.box.lenx * 0.5, + boxOptions.box.leny * 0.5, + boxOptions.box.lenz * 0.5, + 0 + ] + }); + } else { + resolve(scope, { + box_params: [0, 0, 0, 0], + aabb_params: [0, 0, 0, 0] + }); + } + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, resources.renderTarget, resources.shader); + + const byteLen = resources.texture.width * resources.texture.height * 4; + const buffer = bufferPool.acquire(byteLen); + + const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, { + renderTarget: resources.renderTarget, + data: buffer, + immediate: false + }); + + return data as Uint8Array; + } +} + +export { Intersect, IntersectOptions, MaskOptions, RectOptions, SphereOptions, BoxOptions }; diff --git a/src/data-processor/select-by-range.ts b/src/data-processor/select-by-range.ts new file mode 100644 index 0000000..97fc1bb --- /dev/null +++ b/src/data-processor/select-by-range.ts @@ -0,0 +1,208 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + PIXELFORMAT_RGBA8, + SEMANTIC_POSITION, + drawQuadWithShader, + BlendState, + GraphicsDevice, + Mat4, + RenderTarget, + ScopeSpace, + Shader, + ShaderUtils, + Texture, + Vec3 +} from 'playcanvas'; + +import { BufferPool } from './buffer-pool'; +import { packedMaskHeight, packedMaskWidth } from './histogram-config'; +import { vertexShader, fragmentShader } from '../shaders/select-by-range-shader'; +import { Splat } from '../splat'; + +const identity = new Mat4(); +const zeroVec3 = new Vec3(); + +// number of SH coefficients per RGB band, indexed by GSplatResource.shBands. +const SH_NUM_COEFFS: { [k: number]: number } = { 0: 0, 1: 3, 2: 8, 3: 15 }; + +type SelectByRangeOptions = { + min: number; + max: number; + numBins: number; + rangeStart: number; + rangeEnd: number; + entityMatrix?: Mat4; + viewMatrix?: Mat4; + viewProjection?: Mat4; + cameraPos?: Vec3; + onScreenOnly?: boolean; +}; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +const getShBands = (splat: Splat): number => { + return (splat.entity.gsplat.instance.resource as any).shBands ?? 0; +}; + +// GPU pass that produces a 1-byte-per-splat selection mask for a given +// histogram bucket range. mirrors the packing scheme used by Intersect so the +// CPU readback is `numSplats` bytes (with up to 3 bytes of padding at the +// end), addressable directly as `mask[splatIdx]`. +class SelectByRange { + private device: GraphicsDevice; + + // shaders compiled per SH_BANDS, same pattern as CalcHistogram. + private shaders: Map = new Map(); + private texture: Texture = null; + private renderTarget: RenderTarget = null; + + constructor(device: GraphicsDevice) { + this.device = device; + } + + private getShader(shBands: number): Shader { + let shader = this.shaders.get(shBands); + if (!shader) { + const defines = new Map(); + defines.set('SH_BANDS', `${shBands}`); + shader = ShaderUtils.createShader(this.device, { + uniqueName: `selectByRangeShader_SH${shBands}`, + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader, + fragmentDefines: defines + }); + this.shaders.set(shBands, shader); + } + return shader; + } + + private getResources(width: number, numSplats: number) { + // pack 4 splats per RGBA8 texel; layout shared with Intersect via histogram-config. + const resultWidth = packedMaskWidth(width); + const resultHeight = packedMaskHeight(resultWidth, numSplats); + + if (!this.texture || this.texture.width !== resultWidth || this.texture.height !== resultHeight) { + if (this.texture) { + this.texture.destroy(); + this.renderTarget.destroy(); + } + + this.texture = new Texture(this.device, { + name: 'selectByRangeTexture', + width: resultWidth, + height: resultHeight, + format: PIXELFORMAT_RGBA8, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + + this.renderTarget = new RenderTarget({ + colorBuffer: this.texture, + depth: false + }); + } + + return { + texture: this.texture, + renderTarget: this.renderTarget + }; + } + + async run(splat: Splat, mode: number, options: SelectByRangeOptions, bufferPool: BufferPool): Promise { + const { device } = this; + const { scope } = device; + + const numSplats = splat.splatData.numSplats; + const resource = splat.entity.gsplat.instance.resource as any; + const transformA = resource.getTexture('transformA'); + const transformB = resource.getTexture('transformB'); + const splatColor = resource.getTexture('splatColor'); + const splatTransform = splat.transformTexture; + const transformPalette = splat.transformPalette.texture; + const splatState = splat.stateTexture; + + const shBands = getShBands(splat); + const numCoeffs = SH_NUM_COEFFS[shBands] ?? 0; + + const resources = this.getResources(transformA.width, numSplats); + const shader = this.getShader(shBands); + + const entityMatrix = options.entityMatrix ?? identity; + const viewMatrix = options.viewMatrix ?? identity; + const viewProjection = options.viewProjection ?? identity; + const cameraPos = options.cameraPos ?? zeroVec3; + const onScreenOnly = options.onScreenOnly ? 1 : 0; + + // ColorGrade math, kept in sync with ColorGrade in src/color-grade.ts. + const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = splat; + const cgInvRange = 1 / (whitePoint - blackPoint); + + const values: any = { + transformA, + transformB, + splatColor, + splatTransform, + transformPalette, + splatState, + splat_params: [transformA.width, numSplats], + propMode: mode, + entityMatrix: entityMatrix.data, + viewMatrix: viewMatrix.data, + viewProjection: viewProjection.data, + cameraWorldPos: [cameraPos.x, cameraPos.y, cameraPos.z], + onScreenOnly, + cgScale: [ + cgInvRange * tintClr.r * (1 + temperature), + cgInvRange * tintClr.g, + cgInvRange * tintClr.b * (1 - temperature) + ], + cgOffset: -blackPoint + brightness, + cgSaturation: saturation, + transparency, + output_params: [resources.texture.width, resources.texture.height], + minMax: [options.min, options.max], + numBins: options.numBins, + rangeStart: options.rangeStart, + rangeEnd: options.rangeEnd + }; + + if (shBands > 0) { + values.splatSH_1to3 = resource.getTexture('splatSH_1to3'); + values.shNumCoeffs = numCoeffs; + } + if (shBands > 1) { + values.splatSH_4to7 = resource.getTexture('splatSH_4to7'); + values.splatSH_8to11 = resource.getTexture('splatSH_8to11'); + } + if (shBands > 2) { + values.splatSH_12to15 = resource.getTexture('splatSH_12to15'); + } + + resolve(scope, values); + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, resources.renderTarget, shader); + + const byteLen = resources.texture.width * resources.texture.height * 4; + const buffer = bufferPool.acquire(byteLen); + + const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, { + renderTarget: resources.renderTarget, + data: buffer, + immediate: false + }); + + return data as Uint8Array; + } +} + +export { SelectByRange }; +export type { SelectByRangeOptions }; diff --git a/src/doc.ts b/src/doc.ts new file mode 100644 index 0000000..32b0302 --- /dev/null +++ b/src/doc.ts @@ -0,0 +1,374 @@ +import { ZipFileSystem, ZipReadFileSystem } from '@playcanvas/splat-transform'; + +import { Events } from './events'; +import { BrowserFileSystem, BlobReadSource } from './io'; +import { recentFiles } from './recent-files'; +import { Scene } from './scene'; +import { Splat } from './splat'; +import { serializePly } from './splat-serialize'; +import { Transform } from './transform'; +import { i18n } from './ui/localization'; + +// ts compiler and vscode find this type, but eslint does not +type FilePickerAcceptType = unknown; + +const SuperFileType: FilePickerAcceptType[] = [{ + description: 'SuperSplat document', + accept: { + 'application/x-supersplat': ['.ssproj'] + } +}]; + +type FileSelectorCallback = (fileList: File) => void; + +// helper class to show a file selector dialog. +// used when showOpenFilePicker is not available. +class FileSelector { + show: (callbackFunc: FileSelectorCallback) => void; + + constructor() { + const fileSelector = document.createElement('input'); + fileSelector.setAttribute('id', 'document-file-selector'); + fileSelector.setAttribute('type', 'file'); + fileSelector.setAttribute('accept', '.ssproj'); + fileSelector.setAttribute('multiple', 'false'); + + document.body.append(fileSelector); + + let callbackFunc: FileSelectorCallback = null; + + fileSelector.addEventListener('change', () => { + callbackFunc(fileSelector.files[0]); + }); + + fileSelector.addEventListener('cancel', () => { + callbackFunc(null); + }); + + this.show = (func: FileSelectorCallback) => { + callbackFunc = func; + fileSelector.click(); + }; + } +} + +const registerDocEvents = (scene: Scene, events: Events) => { + // construct the file selector + const fileSelector = window.showOpenFilePicker ? null : new FileSelector(); + + // this file handle is updated as the current document is loaded and saved + let documentFileHandle: FileSystemFileHandle = null; + + // show the user a reset confirmation popup + const getResetConfirmation = async () => { + const result = await events.invoke('showPopup', { + type: 'yesno', + header: i18n.t('doc.reset'), + message: i18n.t(events.invoke('scene.dirty') ? 'doc.unsaved-message' : 'doc.reset-message') + }); + + if (result.action !== 'yes') { + return false; + } + + return true; + }; + + // reset the scene + const resetScene = () => { + events.fire('scene.clear'); + events.fire('camera.reset'); + events.fire('doc.setName', null); + documentFileHandle = null; + }; + + // load the document from the given file + const loadDocument = async (file: File) => { + events.fire('startSpinner'); + + // Create streaming ZIP reader from the file + const blobSource = new BlobReadSource(file); + const zipFs = new ZipReadFileSystem(blobSource); + + try { + // the document's view settings are applied through the same events + // as user changes - suspend preference capture so they don't + // overwrite the user's stored preferences. resumed in the finally + // below so a failed load can't leave capture suspended. + events.fire('preferences.suspend'); + + // reset the scene + resetScene(); + + // read document.json via streaming (only reads what's needed) + const docSource = await zipFs.createSource('document.json'); + const docData = await docSource.read().readAll(); + docSource.close(); + const document = JSON.parse(new TextDecoder().decode(docData)); + + // run through each splat and load it + for (let i = 0; i < document.splats.length; ++i) { + const filename = `splat_${i}.ply`; + const splatSettings = document.splats[i]; + + // load splat directly from the zip filesystem (streams on-demand) + // skipReorder=true because ssproj PLY files are already in morton order + const splat = await scene.assetLoader.load(filename, zipFs, false, true); + + await scene.add(splat); + + splat.docDeserialize(splatSettings); + } + + // FIXME: trigger scene bound calc in a better way + const tmp = scene.bound; + if (tmp === null) { + console.error('this should never fire'); + } + + events.invoke('docDeserialize.timeline', document.timeline); + events.invoke('docDeserialize.poseSets', document.poseSets, document.camera?.fov); + events.invoke('docDeserialize.view', document.view); + scene.camera.docDeserialize(document.camera); + + // refresh the pivot to reflect the loaded transform + const currentSelection = events.invoke('selection'); + if (currentSelection) { + const pivot = events.invoke('pivot'); + const transform = new Transform(); + const pivotOrigin = events.invoke('pivot.origin'); + currentSelection.getPivot(pivotOrigin, false, transform); + pivot.place(transform); + } + } catch (error) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('doc.load-failed'), + message: `'${error.message ?? error}'` + }); + } finally { + // fire events before cleanup so a throwing close can't leave + // preference capture suspended or the spinner running + events.fire('preferences.resume'); + events.fire('stopSpinner'); + + // Clean up resources + zipFs.close(); + } + }; + + const saveDocument = async (options: { stream?: FileSystemWritableFileStream, filename?: string }) => { + events.fire('startSpinner'); + + try { + const splats = events.invoke('scene.allSplats') as Splat[]; + + const document = { + version: 0, + camera: scene.camera.docSerialize(), + view: events.invoke('docSerialize.view'), + poseSets: events.invoke('docSerialize.poseSets'), + timeline: events.invoke('docSerialize.timeline'), + splats: splats.map(s => s.docSerialize()) + }; + + const serializeSettings = { + // even though we support saving selection state, we disable that for now + // because including a uint8 array in the document PLY results in slow loading + // path. + keepStateData: false, + keepWorldTransform: true, + keepColorTint: true + }; + + // Create browser filesystem and zip filesystem + const browserFs = new BrowserFileSystem(options.filename, options.stream); + const browserWriter = await browserFs.createWriter(options.filename); + const zipFs = new ZipFileSystem(browserWriter); + + // Write document.json + const docWriter = await zipFs.createWriter('document.json'); + await docWriter.write(new TextEncoder().encode(JSON.stringify(document))); + await docWriter.close(); + + // Write each splat as PLY + for (let i = 0; i < splats.length; ++i) { + await serializePly([splats[i]], serializeSettings, zipFs, `splat_${i}.ply`); + } + + // Close zip (also closes underlying browser writer) + await zipFs.close(); + } catch (error) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('doc.save-failed'), + message: `'${error.message ?? error}'` + }); + } finally { + events.fire('stopSpinner'); + } + }; + + // handle user requesting a new document + events.function('doc.new', async () => { + if (!await getResetConfirmation()) { + return false; + } + resetScene(); + // new documents start from the user's stored preferences rather than + // whatever view state the previous document left behind + events.fire('preferences.apply'); + return true; + }); + + // handle document file being dropped + // NOTE: on chrome it's possible to get the FileSystemFileHandle from the DataTransferItem + // (which would result in more seamless user experience), but this is not yet supported in + // other browsers. + events.function('doc.load', async (file: File, handle?: FileSystemFileHandle) => { + if (!events.invoke('scene.empty') && !await getResetConfirmation()) { + return false; + } + + await loadDocument(file); + + events.fire('doc.setName', file.name); + + if (handle) { + documentFileHandle = handle; + recentFiles.add(handle); + } + }); + + events.function('doc.open', async () => { + if (!events.invoke('scene.empty') && !await getResetConfirmation()) { + return false; + } + + if (fileSelector) { + fileSelector.show(async (file?: File) => { + if (file) { + await loadDocument(file); + } + }); + } else { + try { + const fileHandles = await window.showOpenFilePicker({ + id: 'SuperSplatDocumentOpen', + multiple: false, + types: SuperFileType + }); + + if (fileHandles?.length === 1) { + const fileHandle = fileHandles[0]; + + // null file handle incase loadDocument fails + await loadDocument(await fileHandle.getFile()); + + // store file handle for subsequent saves + documentFileHandle = fileHandle; + events.fire('doc.setName', fileHandle.name); + recentFiles.add(fileHandle); + } + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + } + }); + + events.function('doc.openRecent', async (fileHandle: FileSystemFileHandle) => { + if (!events.invoke('scene.empty') && !await getResetConfirmation()) { + return false; + } + + try { + if (await fileHandle.queryPermission({ mode: 'read' }) !== 'granted') { + if (await fileHandle.requestPermission({ mode: 'read' }) !== 'granted') { + return false; + } + } + + await loadDocument(await fileHandle.getFile()); + + // store file handle for subsequent saves + documentFileHandle = fileHandle; + events.fire('doc.setName', fileHandle.name); + recentFiles.add(fileHandle); + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.error-loading'), + message: `${error.message ?? error}` + }); + } + } + }); + + events.function('doc.save', async () => { + if (documentFileHandle) { + try { + await saveDocument({ + stream: await documentFileHandle.createWritable() + }); + events.fire('doc.saved'); + } catch (error) { + if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') { + console.error(error); + } + } + } else { + await events.invoke('doc.saveAs'); + } + }); + + events.function('doc.saveAs', async () => { + if (window.showSaveFilePicker) { + try { + const handle = await window.showSaveFilePicker({ + id: 'SuperSplatDocumentSave', + types: SuperFileType, + suggestedName: 'scene.ssproj' + }); + await saveDocument({ stream: await handle.createWritable() }); + documentFileHandle = handle; + events.fire('doc.setName', handle.name); + events.fire('doc.saved'); + recentFiles.add(handle); + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + } else { + await saveDocument({ + filename: 'scene.ssproj' + }); + events.fire('doc.saved'); + } + }); + + // doc name + + let docName: string = null; + + const setDocName = (name: string) => { + if (name !== docName) { + docName = name; + events.fire('doc.name', docName); + } + }; + + events.function('doc.name', () => { + return docName; + }); + + events.on('doc.setName', (name) => { + setDocName(name); + }); +}; + +export { registerDocEvents }; diff --git a/src/drop-handler.ts b/src/drop-handler.ts new file mode 100644 index 0000000..789cecf --- /dev/null +++ b/src/drop-handler.ts @@ -0,0 +1,145 @@ +import { path } from 'playcanvas'; + +class DroppedFile { + filename: string; + file: File; + handle?: FileSystemFileHandle; + + constructor(filename: string, file: File, handle?: FileSystemFileHandle) { + this.filename = filename; + this.file = file; + this.handle = handle; + } +} + +type DropHandlerFunc = (files: Array, resetScene: boolean) => void; + +const resolveDirectories = (entries: Array): Promise> => { + const promises: Promise>[] = []; + const result: Array = []; + + entries.forEach((entry) => { + if (entry.isFile) { + result.push(entry as FileSystemFileEntry); + } else if (entry.isDirectory) { + promises.push( + new Promise((resolve, reject) => { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + + const p: Promise[] = []; + + const read = () => { + reader.readEntries((children: Array) => { + if (children.length > 0) { + p.push(resolveDirectories(children)); + read(); + } else { + Promise.all(p).then((children: Array>) => { + resolve(children.flat()); + }); + } + }); + }; + read(); + }) + ); + } + }); + + return Promise.all(promises).then((children: Array>) => { + return result.concat(...children); + }); +}; + +const removeCommonPrefix = (urls: Array) => { + const split = (pathname: string) => { + const parts = pathname.split(path.delimiter); + const base = parts[0]; + const rest = parts.slice(1).join(path.delimiter); + return [base, rest]; + }; + while (true) { + const parts = split(urls[0].filename); + if (parts[1].length === 0) { + return; + } + for (let i = 1; i < urls.length; ++i) { + const other = split(urls[i].filename); + if (parts[0] !== other[0]) { + return; + } + } + for (let i = 0; i < urls.length; ++i) { + urls[i].filename = split(urls[i].filename)[1]; + } + } +}; + +// configure drag and drop +const CreateDropHandler = (target: HTMLElement, dropHandler: DropHandlerFunc) => { + + const dragstart = (ev: DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + ev.dataTransfer.effectAllowed = 'all'; + }; + + const dragover = (ev: DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + ev.dataTransfer.effectAllowed = 'all'; + }; + + const drop = async (ev: DragEvent) => { + ev.preventDefault(); + + const items = Array.from(ev.dataTransfer.items); + + // handle single file drops so documents can propagate the filesystemfilehandle + if (items.length === 1) { + const item = items[0]; + if (item.getAsFileSystemHandle && item.webkitGetAsEntry().isFile) { + const handle = await item.getAsFileSystemHandle(); + if (handle?.kind === 'file') { + const fileHandle = handle as FileSystemFileHandle; + const file = await fileHandle.getFile(); + const droppedFile = new DroppedFile(file.name, file, fileHandle); + dropHandler([droppedFile], ev.shiftKey); + return; + } + } + } + + // Map to entries first + const entries = items + .map(item => item.webkitGetAsEntry()) + .filter(v => v); + + // resolve directories to files + const resolvedEntries = await resolveDirectories(entries); + + const files = await Promise.all( + resolvedEntries.map((entry) => { + return new Promise((resolve, reject) => { + entry.file((entryFile: any) => { + resolve(new DroppedFile(entry.fullPath.substring(1), entryFile)); + }); + }); + }) + ); + + if (files.length > 1) { + // if all files share a common filename prefix, remove it + removeCommonPrefix(files); + } + + // finally, call the drop handler + dropHandler(files, ev.shiftKey); + }; + + target.addEventListener('dragstart', dragstart, true); + target.addEventListener('dragover', dragover, true); + target.addEventListener('drop', drop, true); +}; + +export { CreateDropHandler }; diff --git a/src/edit-history.ts b/src/edit-history.ts new file mode 100644 index 0000000..74e4319 --- /dev/null +++ b/src/edit-history.ts @@ -0,0 +1,143 @@ +import { CommandQueue } from './command-queue'; +import { EditOp, MultiOp } from './edit-ops'; +import { Events } from './events'; +import { Splat } from './splat'; + +// Check if an operation references a specific splat +const opReferencesSplat = (op: EditOp, splat: Splat): boolean => { + // Handle MultiOp by checking nested operations + if (op instanceof MultiOp) { + return op.ops.some(nestedOp => opReferencesSplat(nestedOp, splat)); + } + // Check for splat property on the operation + return (op as any).splat === splat; +}; + +class EditHistory { + history: EditOp[] = []; + cursor = 0; + events: Events; + + // shared queue used to serialize every history mutation. the same physical + // CommandQueue is shared with DataProcessor callers via scene.commandQueue + // and the 'queue' event, so all async splat work applies in initiation order. + private commandQueue: CommandQueue; + + constructor(events: Events, commandQueue: CommandQueue) { + this.events = events; + this.commandQueue = commandQueue; + + events.on('edit.undo', () => this.undo()); + events.on('edit.redo', () => this.redo()); + events.on('edit.add', (editOp: EditOp, suppressOp = false) => this.add(editOp, suppressOp)); + } + + private queue(fn: () => T | Promise): Promise { + return this.commandQueue.enqueue(fn); + } + + add(editOp: EditOp, suppressOp = false) { + return this.queue(() => this._add(editOp, suppressOp)); + } + + canUndo() { + return this.cursor > 0; + } + + canRedo() { + return this.cursor < this.history.length; + } + + undo() { + return this.queue(async () => { + if (this.canUndo()) { + await this._undo(); + } + }); + } + + redo(suppressOp = false) { + return this.queue(async () => { + if (this.canRedo()) { + await this._redo(suppressOp); + } + }); + } + + private async _add(editOp: EditOp, suppressOp = false) { + while (this.cursor < this.history.length) { + this.history.pop().destroy?.(); + } + this.history.push(editOp); + await this._redo(suppressOp); + } + + private async _undo() { + // only advance the cursor after a successful undo so a thrown editOp leaves + // history in a consistent state for subsequent undo/redo. + const editOp = this.history[this.cursor - 1]; + await editOp.undo(); + this.cursor--; + this.events.fire('edit.apply', editOp); + this.fireEvents(); + } + + private async _redo(suppressOp = false) { + // only advance the cursor after a successful redo so a thrown editOp leaves + // history in a consistent state for subsequent undo/redo. + const editOp = this.history[this.cursor]; + if (!suppressOp) { + await editOp.do(); + } + this.cursor++; + this.events.fire('edit.apply', editOp); + this.fireEvents(); + } + + fireEvents() { + this.events.fire('edit.canUndo', this.canUndo()); + this.events.fire('edit.canRedo', this.canRedo()); + } + + clear() { + // route through the queue so any in-flight add/undo/redo finishes before we wipe + // history, preventing queued ops from running against a cleared state. + return this.queue(() => { + this.history.forEach((editOp) => { + editOp.destroy?.(); + }); + this.history = []; + this.cursor = 0; + this.fireEvents(); + }); + } + + // Remove all operations that reference a specific splat + removeForSplat(splat: Splat) { + // serialize with the queue so we don't reshape history while a queued op is mid-flight + // (which could leave queued undo/redo pointing at indices that no longer exist). + return this.queue(() => { + let newCursor = 0; + const newHistory: EditOp[] = []; + + for (let i = 0; i < this.history.length; i++) { + const op = this.history[i]; + // Skip ops referencing the splat; don't destroy them since the caller handles that + if (!opReferencesSplat(op, splat)) { + // Keep this operation + newHistory.push(op); + // Track cursor position (count kept operations before original cursor) + if (i < this.cursor) { + newCursor++; + } + } + } + + this.history = newHistory; + this.cursor = newCursor; + this.fireEvents(); + }); + } +} + +export { EditHistory }; diff --git a/src/edit-ops.ts b/src/edit-ops.ts new file mode 100644 index 0000000..f49b89f --- /dev/null +++ b/src/edit-ops.ts @@ -0,0 +1,464 @@ +import { Color, Mat4 } from 'playcanvas'; + +import { AnimTrack } from './anim-track'; +import { IndexRanges, sortedPredicate } from './index-ranges'; +import { Pivot } from './pivot'; +import { Scene } from './scene'; +import { Splat } from './splat'; +import { State } from './splat-state'; +import { Transform } from './transform'; + +interface EditOp { + name: string; + do(): void | Promise; + undo(): void | Promise; + destroy?(): void; +} + +const enum BitOp { + SET, + CLEAR, + TOGGLE +} + +class StateOp { + splat: Splat; + ranges: IndexRanges; + mask: number; + op: BitOp; + updateFlags: number; + + constructor(splat: Splat, ranges: IndexRanges, mask: number, op: BitOp, updateFlags = State.selected) { + this.splat = splat; + this.ranges = ranges; + this.mask = mask; + this.op = op; + this.updateFlags = updateFlags; + } + + private apply(op: BitOp) { + const { state } = this.splat; + const { mask, ranges } = this; + + switch (op) { + case BitOp.SET: + state.setBits(ranges, mask); + break; + case BitOp.CLEAR: + state.clearBits(ranges, mask); + break; + case BitOp.TOGGLE: + state.toggleBits(ranges, mask); + break; + } + } + + async do() { + this.apply(this.op); + await this.splat.updateState(this.updateFlags); + } + + async undo() { + const undoOp = this.op === BitOp.TOGGLE ? BitOp.TOGGLE : + this.op === BitOp.SET ? BitOp.CLEAR : BitOp.SET; + this.apply(undoOp); + await this.splat.updateState(this.updateFlags); + } + + destroy() { + this.splat = null; + this.ranges = null; + } +} + +class SelectAllOp extends StateOp { + name = 'selectAll'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === 0), State.selected, BitOp.SET); + } +} + +class SelectNoneOp extends StateOp { + name = 'selectNone'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.selected, BitOp.CLEAR); + } +} + +class SelectInvertOp extends StateOp { + name = 'selectInvert'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & (State.locked | State.deleted)) === 0), State.selected, BitOp.TOGGLE); + } +} + +class SelectOp extends StateOp { + name = 'selectOp'; + + // `sel` is a committed snapshot of hits: either a per-splat mask + // (Uint8Array, 255 = hit) or a sorted Uint32Array of indices. taking a + // committed mask rather than a closure removes the foot-gun where a + // predicate captured `state[i]` at call time and was evaluated later. + // `op` semantics: + // add — select valid splats that are hit and currently unselected + // remove — deselect valid splats that are hit and currently selected + // set — make selection match the hit mask (toggle valid splats whose + // current selection state differs from the mask). NOT a replace — + // the underlying BitOp is TOGGLE on the rows where selection and + // hit disagree, which leaves locked/deleted bits untouched. + // intersect — keep only splats currently selected AND in the hit mask + // (clear the selected bit on selected splats that are not hit). + constructor(splat: Splat, op: 'add' | 'remove' | 'set' | 'intersect', sel: Uint8Array | Uint32Array) { + const splatData = splat.splatData; + const state = splatData.getProp('state') as Uint8Array; + const isHit = sel instanceof Uint32Array ? sortedPredicate(sel) : (i: number) => sel[i] === 255; + + // single rule applied uniformly: only valid (clean or selected) splats + // are considered. consolidates the locked/deleted guard in one place so + // each producer doesn't have to remember it for the 'set' (toggle) path. + const valid = (i: number) => state[i] === 0 || state[i] === State.selected; + + // op → bit operation and op → predicate, kept as parallel lookups keyed + // by the same union so adding an op forces both to be updated together. + const bitOps = { + add: BitOp.SET, + remove: BitOp.CLEAR, + set: BitOp.TOGGLE, + intersect: BitOp.CLEAR + }; + + const preds = { + add: (i: number) => valid(i) && isHit(i) && state[i] === 0, + remove: (i: number) => valid(i) && isHit(i) && state[i] === State.selected, + set: (i: number) => valid(i) && ((state[i] === State.selected) !== isHit(i)), + intersect: (i: number) => valid(i) && state[i] === State.selected && !isHit(i) + }; + + super(splat, IndexRanges.fromPredicate(splatData.numSplats, preds[op]), State.selected, bitOps[op]); + } +} + +class HideSelectionOp extends StateOp { + name = 'hideSelection'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.locked, BitOp.SET, State.locked); + } +} + +class UnhideAllOp extends StateOp { + name = 'unhideAll'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & (State.locked | State.deleted)) === State.locked), State.locked, BitOp.CLEAR, State.locked); + } +} + +class DeleteSelectionOp extends StateOp { + name = 'deleteSelection'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.deleted, BitOp.SET, State.deleted); + } +} + +class ResetOp extends StateOp { + name = 'reset'; + + constructor(splat: Splat) { + const state = splat.splatData.getProp('state') as Uint8Array; + super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & State.deleted) !== 0), State.deleted, BitOp.CLEAR, State.deleted); + } +} + +// op for modifying a splat transform +class EntityTransformOp { + name = 'entityTransform'; + splat: Splat; + oldt: Transform; + newt: Transform; + + constructor(options: { splat: Splat, oldt: Transform, newt: Transform }) { + this.splat = options.splat; + this.oldt = options.oldt; + this.newt = options.newt; + } + + do() { + this.splat.move(this.newt.position, this.newt.rotation, this.newt.scale); + } + + undo() { + this.splat.move(this.oldt.position, this.oldt.rotation, this.oldt.scale); + } + + destroy() { + this.splat = null; + this.oldt = null; + this.newt = null; + } +} + +const mat = new Mat4(); + +// op for modifying a subset of individual splats +class SplatsTransformOp { + name = 'splatsTransform'; + + splat: Splat; + transform: Mat4; + paletteMap: Map; + + constructor(options: { splat: Splat, transform: Mat4, paletteMap: Map }) { + this.splat = options.splat; + this.transform = options.transform; + this.paletteMap = options.paletteMap; + } + + async do() { + const { splat, transform, paletteMap } = this; + const state = splat.splatData.getProp('state') as Uint8Array; + const indices = splat.transformTexture.lock() as Uint16Array; + + // update splat transform palette indices + for (let i = 0; i < state.length; ++i) { + if (state[i] === State.selected) { + indices[i] = paletteMap.get(indices[i]); + } + } + + splat.transformTexture.unlock(); + + splat.transformPalette.alloc(paletteMap.size); + + // update transform palette + const { transformPalette } = splat; + this.paletteMap.forEach((newIdx, oldIdx) => { + transformPalette.getTransform(oldIdx, mat); + mat.mul2(transform, mat); + transformPalette.setTransform(newIdx, mat); + }); + + await splat.updatePositions(); + } + + async undo() { + const { splat, paletteMap } = this; + const state = splat.splatData.getProp('state') as Uint8Array; + const indices = splat.transformTexture.lock() as Uint16Array; + + // invert the palette map + const inverseMap = new Map(); + paletteMap.forEach((newIdx, oldIdx) => { + inverseMap.set(newIdx, oldIdx); + }); + + // restore the original transform indices + for (let i = 0; i < state.length; ++i) { + if (state[i] === State.selected) { + indices[i] = inverseMap.get(indices[i]); + } + } + + splat.transformTexture.unlock(); + + splat.transformPalette.free(paletteMap.size); + + await splat.updatePositions(); + } + + destroy() { + this.splat = null; + this.transform = null; + this.paletteMap = null; + } +} + +class PlacePivotOp { + name = 'setPivot'; + pivot: Pivot; + oldt: Transform; + newt: Transform; + + constructor(options: { pivot: Pivot, oldt: Transform, newt: Transform }) { + this.pivot = options.pivot; + this.oldt = options.oldt; + this.newt = options.newt; + } + + do() { + this.pivot.place(this.newt); + } + + undo() { + this.pivot.place(this.oldt); + } +} + +type ColorAdjustment = { + tintClr?: Color + temperature?: number, + saturation?: number, + brightness?: number, + blackPoint?: number, + whitePoint?: number, + transparency?: number +}; + +class SetSplatColorAdjustmentOp { + name = 'setSplatColor'; + splat: Splat; + + newState: ColorAdjustment; + oldState: ColorAdjustment; + + constructor(options: { splat: Splat, oldState: ColorAdjustment, newState: ColorAdjustment }) { + const { splat, oldState, newState } = options; + this.splat = splat; + this.oldState = oldState; + this.newState = newState; + } + + do() { + const { splat } = this; + const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = this.newState; + if (tintClr) splat.tintClr = tintClr; + if (temperature !== null) splat.temperature = temperature; + if (saturation !== null) splat.saturation = saturation; + if (brightness !== null) splat.brightness = brightness; + if (blackPoint !== null) splat.blackPoint = blackPoint; + if (whitePoint !== null) splat.whitePoint = whitePoint; + if (transparency !== null) splat.transparency = transparency; + } + + undo() { + const { splat } = this; + const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = this.oldState; + if (tintClr) splat.tintClr = tintClr; + if (temperature !== null) splat.temperature = temperature; + if (saturation !== null) splat.saturation = saturation; + if (brightness !== null) splat.brightness = brightness; + if (blackPoint !== null) splat.blackPoint = blackPoint; + if (whitePoint !== null) splat.whitePoint = whitePoint; + if (transparency !== null) splat.transparency = transparency; + } +} + +// Snapshot-based undo/redo for animation track edits. +// Captures the full track state before and after a mutation. +class AnimTrackEditOp { + name: string; + track: AnimTrack; + before: unknown; + after: unknown; + + constructor(name: string, track: AnimTrack, before: unknown, after: unknown) { + this.name = name; + this.track = track; + this.before = before; + this.after = after; + } + + do() { + this.track.restore(this.after); + } + + undo() { + this.track.restore(this.before); + } +} + +class MultiOp { + name = 'multiOp'; + ops: EditOp[]; + + constructor(ops: EditOp[]) { + this.ops = ops; + } + + async do() { + for (const op of this.ops) { + await op.do(); + } + } + + async undo() { + for (const op of this.ops) { + await op.undo(); + } + } +} + +class AddSplatOp { + name = 'addSplat'; + scene: Scene; + splat: Splat; + + constructor(scene: Scene, splat: Splat) { + this.scene = scene; + this.splat = splat; + } + + async do() { + await this.scene.add(this.splat); + } + + undo() { + this.scene.remove(this.splat); + } + + destroy() { + this.splat.destroy(); + } +} + +class SplatRenameOp { + name = 'splatRename'; + splat: Splat; + oldName: string; + newName: string; + + constructor(splat: Splat, newName: string) { + this.splat = splat; + this.oldName = splat.name; + this.newName = newName; + } + + do() { + this.splat.name = this.newName; + } + + undo() { + this.splat.name = this.oldName; + } +} + +export { + EditOp, + SelectAllOp, + SelectNoneOp, + SelectInvertOp, + SelectOp, + HideSelectionOp, + UnhideAllOp, + DeleteSelectionOp, + ResetOp, + EntityTransformOp, + SplatsTransformOp, + PlacePivotOp, + ColorAdjustment, + SetSplatColorAdjustmentOp, + AnimTrackEditOp, + MultiOp, + AddSplatOp, + SplatRenameOp +}; diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..7389399 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,879 @@ +import { MemoryFileSystem } from '@playcanvas/splat-transform'; +import { Color, Mat4, path, Texture, Vec3, Vec4 } from 'playcanvas'; + +import { EditHistory } from './edit-history'; +import { SelectAllOp, SelectNoneOp, SelectInvertOp, SelectOp, HideSelectionOp, UnhideAllOp, DeleteSelectionOp, ResetOp, MultiOp, AddSplatOp } from './edit-ops'; +import { Element, ElementType } from './element'; +import { Events } from './events'; +import { MappedReadFileSystem } from './io'; +import { Scene } from './scene'; +import { Splat } from './splat'; +import { serializePly } from './splat-serialize'; + +const removeExtension = (filename: string) => { + return filename.substring(0, filename.length - path.getExtension(filename).length); +}; + +// register for editor and scene events +const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: Scene) => { + const vec = new Vec3(); + const vec2 = new Vec3(); + const vec4 = new Vec4(); + const mat = new Mat4(); + const SH_C0 = 0.28209479177387814; + + const decodeColorChannel = (value: number) => { + return Math.min(1, Math.max(0, 0.5 + value * SH_C0)); + }; + + // get the list of selected splats (currently limited to just a single one) + const selectedSplats = () => { + const selected = events.invoke('selection') as Splat; + return selected?.visible ? [selected] : []; + }; + + let lastExportCursor = 0; + + // add unsaved changes warning message. + window.addEventListener('beforeunload', (e) => { + if (!events.invoke('scene.dirty')) { + // if the undo cursor matches last export, then we have no unsaved changes + return undefined; + } + + const msg = 'You have unsaved changes. Are you sure you want to leave?'; + e.returnValue = msg; + return msg; + }); + + events.function('targetSize', () => { + return scene.targetSize; + }); + + events.on('scene.clear', () => { + scene.clear(); + editHistory.clear(); + lastExportCursor = 0; + }); + + // When a splat is removed from the scene, remove all edit operations that reference it + events.on('scene.elementRemoved', (element: Element) => { + if (element.type === ElementType.splat) { + editHistory.removeForSplat(element as Splat); + } + }); + + events.function('scene.dirty', () => { + return editHistory.cursor !== lastExportCursor; + }); + + events.on('doc.saved', () => { + lastExportCursor = editHistory.cursor; + }); + + // force render on some events + + [ + 'camera.mode', 'camera.overlay', 'camera.splatSize', 'view.outlineSelection', + 'view.centersUseGaussianColor', 'view.bands', 'camera.bound', 'camera.boundDimensions', 'camera.showPoses', + 'camera.showInfo', 'selection.changed', 'tool.coordSpace' + ].forEach((eventName) => { + events.on(eventName, () => { + scene.forceRender = true; + }); + }); + + // grid.visible + + const setGridVisible = (visible: boolean) => { + if (visible !== scene.grid.visible) { + scene.grid.visible = visible; + events.fire('grid.visible', visible); + } + }; + + events.function('grid.visible', () => { + return scene.grid.visible; + }); + + events.on('grid.setVisible', (visible: boolean) => { + setGridVisible(visible); + }); + + events.on('grid.toggleVisible', () => { + setGridVisible(!scene.grid.visible); + }); + + setGridVisible(scene.config.show.grid); + + // camera.fovDolly + + let fovDolly = false; + + const setFovDolly = (value: boolean) => { + if (value !== fovDolly) { + fovDolly = value; + events.fire('camera.fovDolly', fovDolly); + } + }; + + events.function('camera.fovDolly', () => { + return fovDolly; + }); + + events.on('camera.setFovDolly', (value: boolean) => { + setFovDolly(value); + }); + + // camera.fov + + const setCameraFov = (fov: number) => { + const { camera } = scene; + if (fov !== camera.fov) { + const oldFovFactor = camera.fovFactor; + camera.fov = fov; + + // by default a fov change acts like a lens zoom: scale distance so + // the camera's world-space offset from the focal point (distance * + // sceneRadius / fovFactor) is unchanged. with auto-dolly enabled + // the camera moves instead, preserving the subject's framing. + if (!fovDolly) { + const { controls } = scene.config; + const k = camera.fovFactor / oldFovFactor; + const t = camera.distanceTween; + for (const s of [t.value, t.source, t.target]) { + s.distance = Math.max(controls.minZoom, Math.min(controls.maxZoom, s.distance * k)); + } + } + + events.fire('camera.fov', camera.fov); + } + }; + + events.function('camera.fov', () => { + return scene.camera.fov; + }); + + events.on('camera.setFov', (fov: number) => { + setCameraFov(fov); + }); + + // camera.tonemapping + + events.function('camera.tonemapping', () => { + return scene.camera.tonemapping; + }); + + events.on('camera.setTonemapping', (value: string) => { + scene.camera.tonemapping = value; + }); + + // camera.bound + + let bound = scene.config.show.bound; + + const setBoundVisible = (visible: boolean) => { + if (visible !== bound) { + bound = visible; + events.fire('camera.bound', bound); + } + }; + + events.function('camera.bound', () => { + return bound; + }); + + events.on('camera.setBound', (value: boolean) => { + setBoundVisible(value); + }); + + events.on('camera.toggleBound', () => { + setBoundVisible(!events.invoke('camera.bound')); + }); + + // camera.boundDimensions + + let boundDimensions = scene.config.show.boundDimensions; + + const setBoundDimensionsVisible = (visible: boolean) => { + if (visible !== boundDimensions) { + boundDimensions = visible; + events.fire('camera.boundDimensions', boundDimensions); + } + }; + + events.function('camera.boundDimensions', () => { + return boundDimensions; + }); + + events.on('camera.setBoundDimensions', (value: boolean) => { + setBoundDimensionsVisible(value); + }); + + events.on('camera.toggleBoundDimensions', () => { + setBoundDimensionsVisible(!events.invoke('camera.boundDimensions')); + }); + + // camera.showPoses + + let showPoses = scene.config.show.cameraPoses; + + const setShowPoses = (visible: boolean) => { + if (visible !== showPoses) { + showPoses = visible; + events.fire('camera.showPoses', showPoses); + } + }; + + events.function('camera.showPoses', () => { + return showPoses; + }); + + events.on('camera.setShowPoses', (value: boolean) => { + setShowPoses(value); + }); + + events.on('camera.toggleShowPoses', () => { + setShowPoses(!events.invoke('camera.showPoses')); + }); + + // camera.showInfo + + let showInfo = scene.config.show.cameraInfo; + + const setShowInfo = (visible: boolean) => { + if (visible !== showInfo) { + showInfo = visible; + events.fire('camera.showInfo', showInfo); + } + }; + + events.function('camera.showInfo', () => { + return showInfo; + }); + + events.on('camera.setShowInfo', (value: boolean) => { + setShowInfo(value); + }); + + events.on('camera.toggleShowInfo', () => { + setShowInfo(!events.invoke('camera.showInfo')); + }); + + // camera.focus + + events.on('camera.focus', () => { + const splat = selectedSplats()[0]; + if (splat) { + // use current bounds (caller should have awaited the operation that changed data) + const bound = splat.numSelected > 0 ? + splat.selectionBound : + splat.localBound; + vec.copy(bound.center); + + const worldTransform = splat.worldTransform; + worldTransform.transformPoint(vec, vec); + worldTransform.getScale(vec2); + + scene.camera.focus({ + focalPoint: vec, + radius: bound.halfExtents.length() * vec2.x, + speed: 1 + }); + } + }); + + events.on('camera.reset', () => { + const { initialAzim, initialElev, initialZoom } = scene.config.controls; + const x = Math.sin(initialAzim * Math.PI / 180) * Math.cos(initialElev * Math.PI / 180); + const y = -Math.sin(initialElev * Math.PI / 180); + const z = Math.cos(initialAzim * Math.PI / 180) * Math.cos(initialElev * Math.PI / 180); + const zoom = initialZoom; + + scene.camera.setPose(new Vec3(x * zoom, y * zoom, z * zoom), new Vec3(0, 0, 0)); + }); + + // handle camera align events + events.on('camera.align', (axis: string) => { + switch (axis) { + case 'px': scene.camera.setAzimElev(90, 0); break; + case 'py': scene.camera.setAzimElev(0, -90); break; + case 'pz': scene.camera.setAzimElev(0, 0); break; + case 'nx': scene.camera.setAzimElev(270, 0); break; + case 'ny': scene.camera.setAzimElev(0, 90); break; + case 'nz': scene.camera.setAzimElev(180, 0); break; + } + + // switch to ortho mode + scene.camera.ortho = true; + }); + + // returns true if the selected splat has selected gaussians + events.function('selection.splats', () => { + const splat = events.invoke('selection') as Splat; + return splat?.numSelected > 0; + }); + + events.on('select.all', () => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new SelectAllOp(splat)); + }); + }); + + events.on('select.none', () => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new SelectNoneOp(splat)); + }); + }); + + events.on('select.invert', () => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new SelectInvertOp(splat)); + }); + }); + + events.on('select.mask', (op: 'add'|'remove'|'set'|'intersect', mask: Uint8Array | Uint32Array) => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new SelectOp(splat, op, mask)); + }); + }); + + // run the GPU intersect + the resulting SelectOp inside one queued task so the + // gpu readback is ordered relative to other queued history ops (rapid drag + + // undo, drag-while-camera-settling, etc). + const runSelectIntersect = (splat: Splat, op: 'add'|'remove'|'set'|'intersect', options: any) => { + return scene.commandQueue.enqueue(async () => { + const data = await scene.dataProcessor.intersect(options, splat); + // SelectOp consumes `data` synchronously in its constructor + // (IndexRanges.fromPredicate iterates immediately), so we can + // return the buffer to the pool as soon as the op is constructed. + events.fire('edit.add', new SelectOp(splat, op, data)); + scene.dataProcessor.releaseMask(data); + }); + }; + + events.on('select.bySphere', async (op: 'add'|'remove'|'set'|'intersect', sphere: number[]) => { + for (const splat of selectedSplats()) { + await runSelectIntersect(splat, op, { + sphere: { x: sphere[0], y: sphere[1], z: sphere[2], radius: sphere[3] } + }); + } + }); + + events.on('select.byBox', async (op: 'add'|'remove'|'set'|'intersect', box: number[]) => { + for (const splat of selectedSplats()) { + await runSelectIntersect(splat, op, { + box: { x: box[0], y: box[1], z: box[2], lenx: box[3], leny: box[4], lenz: box[5] } + }); + } + }); + + events.function('select.rect', async (op: 'add'|'remove'|'set'|'intersect', rect: any) => { + const mode = events.invoke('camera.mode'); + + for (const splat of selectedSplats()) { + if (mode === 'centers') { + await runSelectIntersect(splat, op, { + rect: { x1: rect.start.x, y1: rect.start.y, x2: rect.end.x, y2: rect.end.y } + }); + } else if (mode === 'rings') { + scene.camera.pickPrep(splat, op); + const pick = await scene.camera.pickRect( + rect.start.x, + rect.start.y, + rect.end.x - rect.start.x, + rect.end.y - rect.start.y + ); + + const sortedIds = new Uint32Array(new Set(pick)).sort(); + events.fire('edit.add', new SelectOp(splat, op, sortedIds)); + } + } + }); + + let maskTexture: Texture = null; + + events.function('select.byMask', async (op: 'add'|'remove'|'set'|'intersect', canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) => { + const mode = events.invoke('camera.mode'); + + for (const splat of selectedSplats()) { + if (mode === 'centers') { + // create mask texture + if (!maskTexture || maskTexture.width !== canvas.width || maskTexture.height !== canvas.height) { + if (maskTexture) { + maskTexture.destroy(); + } + maskTexture = new Texture(scene.graphicsDevice); + } + maskTexture.setSource(canvas); + + await runSelectIntersect(splat, op, { + mask: maskTexture + }); + } else if (mode === 'rings') { + const mask = context.getImageData(0, 0, canvas.width, canvas.height); + + // calculate mask bound so we limit pixel operations + let mx0 = mask.width - 1; + let my0 = mask.height - 1; + let mx1 = 0; + let my1 = 0; + for (let y = 0; y < mask.height; ++y) { + for (let x = 0; x < mask.width; ++x) { + if (mask.data[(y * mask.width + x) * 4 + 3] === 255) { + mx0 = Math.min(mx0, x); + my0 = Math.min(my0, y); + mx1 = Math.max(mx1, x); + my1 = Math.max(my1, y); + } + } + } + + // Convert mask bounds to normalized coordinates + const nx0 = mx0 / mask.width; + const ny0 = my0 / mask.height; + const nx1 = (mx1 + 1) / mask.width; + const ny1 = (my1 + 1) / mask.height; + const nw = nx1 - nx0; + const nh = ny1 - ny0; + + scene.camera.pickPrep(splat, op); + const pick = await scene.camera.pickRect(nx0, ny0, nw, nh); + + // Calculate actual pixel dimensions for iteration + const { width, height } = scene.targetSize; + + // Convert normalized coordinates to render target pixels + const px = Math.floor(nx0 * width); + const py = Math.floor(ny0 * height); + const pw = Math.max(1, Math.ceil((nx0 + nw) * width) - px); + const ph = Math.max(1, Math.ceil((ny0 + nh) * height) - py); + + const selected = new Set(); + for (let y = 0; y < ph; ++y) { + for (let x = 0; x < pw; ++x) { + const mx = Math.floor((nx0 + x / width) * mask.width); + const my = Math.floor((ny0 + y / height) * mask.height); + if (mask.data[(my * mask.width + mx) * 4] === 255) { + selected.add(pick[(ph - 1 - y) * pw + x]); + } + } + } + + const sortedIds = new Uint32Array(selected).sort(); + events.fire('edit.add', new SelectOp(splat, op, sortedIds)); + } + } + }); + + events.function('select.point', async (op: 'add'|'remove'|'set'|'intersect', point: { x: number, y: number }) => { + const { width, height } = scene.targetSize; + const mode = events.invoke('camera.mode'); + + for (const splat of selectedSplats()) { + const splatData = splat.splatData; + + if (mode === 'centers') { + const x = splatData.getProp('x'); + const y = splatData.getProp('y'); + const z = splatData.getProp('z'); + + const splatSize = events.invoke('camera.splatSize'); + const camera = scene.camera.camera; + const sx = point.x * width; + const sy = point.y * height; + + // calculate final matrix + mat.mul2(camera.camera._viewProjMat, splat.worldTransform); + + // materialize hits into an owned mask. SelectOp consumes a + // committed snapshot rather than a closure so we never have to + // worry about state shifting between capture and apply. + const numSplats = splatData.numSplats; + const mask = new Uint8Array(numSplats); + for (let i = 0; i < numSplats; i++) { + vec4.set(x[i], y[i], z[i], 1.0); + mat.transformVec4(vec4, vec4); + const px = (vec4.x / vec4.w * 0.5 + 0.5) * width; + const py = (-vec4.y / vec4.w * 0.5 + 0.5) * height; + if (Math.abs(px - sx) < splatSize && Math.abs(py - sy) < splatSize) { + mask[i] = 255; + } + } + + events.fire('edit.add', new SelectOp(splat, op, mask)); + } else if (mode === 'rings') { + scene.camera.pickPrep(splat, op); + + // Use normalized coordinates with minimal size for single pixel pick + const pickResult = await scene.camera.pickRect( + point.x, + point.y, + 1 / width, + 1 / height + ); + const pickId = pickResult[0]; + events.fire('edit.add', new SelectOp(splat, op, new Uint32Array([pickId]))); + } + } + }); + + // Eyedropper selection with SelectOp so undo/redo and selection state updates remain consistent. + // Threshold acts as a per-channel absolute difference: 0 only matches identical colors while 1 matches everything. + // TO DO: + // - alternative distance metrics such as HSV. + // - alternative UI for threshold, two handles for min/max? + events.function('select.colorMatch', async (op: 'add'|'remove'|'set', point: { x: number, y: number }, threshold = 0) => { + const splats = selectedSplats(); + const targetSize = scene.targetSize; + if (!splats.length || !targetSize || !point) { + return; + } + + const { width, height } = targetSize; + if (!width || !height) { + return; + } + + // Clamp normalized coordinates to valid range + const nx = Math.max(0, Math.min(1, point.x)); + const ny = Math.max(0, Math.min(1, point.y)); + const colorThreshold = Math.min(1, Math.max(0, Number.isFinite(threshold) ? threshold : 0)); + + for (const splat of splats) { + scene.camera.pickPrep(splat, 'set'); + // Use normalized coordinates with minimal size for single pixel pick + const pickBuffer = await scene.camera.pickRect(nx, ny, 1 / width, 1 / height); + const pickId = pickBuffer?.[0]; + if (pickId === undefined || pickId === 0xffffffff) { + continue; + } + + const reds = splat.splatData.getProp('f_dc_0') as Float32Array; + const greens = splat.splatData.getProp('f_dc_1') as Float32Array; + const blues = splat.splatData.getProp('f_dc_2') as Float32Array; + // validate pickId and color channels exist + if (!reds || !greens || !blues || pickId < 0 || pickId >= reds.length) { + continue; + } + // decode color channels for the reference pixel + const refR = decodeColorChannel(reds[pickId]); + const refG = decodeColorChannel(greens[pickId]); + const refB = decodeColorChannel(blues[pickId]); + + // materialize hits into an owned mask up front; SelectOp consumes + // a committed snapshot. + const numSplats = splat.splatData.numSplats; + const mask = new Uint8Array(numSplats); + for (let i = 0; i < numSplats; i++) { + if (Math.abs(decodeColorChannel(reds[i]) - refR) <= colorThreshold && + Math.abs(decodeColorChannel(greens[i]) - refG) <= colorThreshold && + Math.abs(decodeColorChannel(blues[i]) - refB) <= colorThreshold) { + mask[i] = 255; + } + } + + events.fire('edit.add', new SelectOp(splat, op, mask)); + } + }); + + events.on('select.hide', () => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new HideSelectionOp(splat)); + }); + }); + + events.on('select.unhide', () => { + selectedSplats().forEach((splat) => { + events.fire('edit.add', new UnhideAllOp(splat)); + }); + }); + + events.on('select.delete', () => { + // Don't delete gaussians when measure tool is active (backspace deletes measure points instead) + if (events.invoke('tool.active') === 'measure') { + return; + } + // Don't delete gaussians while a polygon selection is in progress (backspace removes the last point instead) + if (events.invoke('polygonSelection.removeLastPoint')) { + return; + } + selectedSplats().forEach((splat) => { + editHistory.add(new DeleteSelectionOp(splat)); + }); + }); + + const performSelectionFunc = async (func: 'duplicate' | 'separate') => { + const splats = selectedSplats(); + + const memFs = new MemoryFileSystem(); + + await serializePly(splats, { + maxSHBands: 3, + selected: true + }, memFs); + + const data = memFs.results.get('output.ply'); + + if (data) { + const splat = splats[0]; + + // wrap PLY in a blob and load it + const blob = new Blob([data.buffer as ArrayBuffer], { type: 'application/octet-stream' }); + const filename = `${removeExtension(splat.filename)}.ply`; + const fileSystem = new MappedReadFileSystem(); + fileSystem.addFile(filename, blob); + const copy = await scene.assetLoader.load(filename, fileSystem); + + if (func === 'separate') { + editHistory.add(new MultiOp([ + new DeleteSelectionOp(splat), + new AddSplatOp(scene, copy) + ])); + } else { + editHistory.add(new AddSplatOp(scene, copy)); + } + } + }; + + // duplicate the current selection + events.on('edit.duplicate', async () => { + await performSelectionFunc('duplicate'); + }); + + events.on('edit.separate', async () => { + await performSelectionFunc('separate'); + }); + + events.on('scene.reset', () => { + selectedSplats().forEach((splat) => { + editHistory.add(new ResetOp(splat)); + }); + }); + + // camera mode (visual: centers/rings) + + let activeMode = 'centers'; + + const setCameraMode = (mode: string) => { + if (mode !== activeMode) { + activeMode = mode; + events.fire('camera.mode', activeMode); + } + }; + + events.function('camera.mode', () => { + return activeMode; + }); + + events.on('camera.setMode', (mode: string) => { + setCameraMode(mode); + }); + + events.on('camera.toggleMode', () => { + setCameraMode(events.invoke('camera.mode') === 'centers' ? 'rings' : 'centers'); + }); + + // camera control mode (orbit/fly) + + let controlMode: 'orbit' | 'fly' = 'orbit'; + + const setControlMode = (mode: 'orbit' | 'fly') => { + if (mode !== controlMode) { + controlMode = mode; + scene.camera.controlMode = mode; + events.fire('camera.controlMode', controlMode); + } + }; + + events.function('camera.controlMode', () => { + return controlMode; + }); + + events.on('camera.setControlMode', (mode: 'orbit' | 'fly') => { + setControlMode(mode); + }); + + events.on('camera.toggleControlMode', () => { + setControlMode(controlMode === 'orbit' ? 'fly' : 'orbit'); + }); + + // camera overlay + + let cameraOverlay = scene.config.camera.overlay; + + const setCameraOverlay = (enabled: boolean) => { + if (enabled !== cameraOverlay) { + cameraOverlay = enabled; + events.fire('camera.overlay', cameraOverlay); + } + }; + + events.function('camera.overlay', () => { + return cameraOverlay; + }); + + events.on('camera.setOverlay', (value: boolean) => { + setCameraOverlay(value); + }); + + events.on('camera.toggleOverlay', () => { + setCameraOverlay(!events.invoke('camera.overlay')); + }); + + // splat size + + let splatSize = 2; + + const setSplatSize = (value: number) => { + if (value !== splatSize) { + splatSize = value; + events.fire('camera.splatSize', splatSize); + } + }; + + events.function('camera.splatSize', () => { + return splatSize; + }); + + events.on('camera.setSplatSize', (value: number) => { + setSplatSize(value); + }); + + // camera fly speed + + const setFlySpeed = (value: number) => { + if (value !== scene.camera.flySpeed) { + scene.camera.flySpeed = value; + events.fire('camera.flySpeed', value); + } + }; + + events.function('camera.flySpeed', () => { + return scene.camera.flySpeed; + }); + + events.on('camera.setFlySpeed', (value: number) => { + setFlySpeed(value); + }); + + // outline selection + + let outlineSelection = false; + + const setOutlineSelection = (value: boolean) => { + if (value !== outlineSelection) { + outlineSelection = value; + events.fire('view.outlineSelection', outlineSelection); + } + }; + + events.function('view.outlineSelection', () => { + return outlineSelection; + }); + + events.on('view.setOutlineSelection', (value: boolean) => { + setOutlineSelection(value); + }); + + // view spherical harmonic bands + + let viewBands = scene.config.show.shBands; + + const setViewBands = (value: number) => { + if (value !== viewBands) { + viewBands = value; + events.fire('view.bands', viewBands); + } + }; + + events.function('view.bands', () => { + return viewBands; + }); + + events.on('view.setBands', (value: number) => { + setViewBands(value); + }); + + // centers gaussian color toggle + let centersUseGaussianColor = false; + events.function('view.centersUseGaussianColor', () => centersUseGaussianColor); + events.on('view.setCentersUseGaussianColor', (value: boolean) => { + centersUseGaussianColor = value; + events.fire('view.centersUseGaussianColor', value); + }); + + events.function('camera.getPose', () => { + const camera = scene.camera; + const position = camera.position; + const focalPoint = camera.focalPoint; + return { + position: { x: position.x, y: position.y, z: position.z }, + target: { x: focalPoint.x, y: focalPoint.y, z: focalPoint.z }, + fov: camera.fov + }; + }); + + events.on('camera.setPose', (pose: { position: Vec3, target: Vec3, fov?: number }, speed = 1) => { + // assign fov before setPose so distance is computed using the new fovFactor + if (pose.fov !== undefined) { + // pose-driven fov (timeline playback, fly-to-pose) is not a user + // preference - suspend capture around the notify and the + // synchronous ui echo it triggers + events.fire('preferences.suspend'); + try { + scene.camera.fov = pose.fov; + events.fire('camera.fov', pose.fov); + } finally { + events.fire('preferences.resume'); + } + } + scene.camera.setPose(pose.position, pose.target, speed); + }); + + // hack: fire events to initialize UI + events.fire('camera.fov', scene.camera.fov); + events.fire('camera.overlay', cameraOverlay); + events.fire('view.bands', viewBands); + events.fire('camera.showInfo', showInfo); + + // doc serialization + events.function('docSerialize.view', () => { + const packC = (c: Color) => [c.r, c.g, c.b, c.a]; + return { + bgColor: packC(events.invoke('bgClr')), + selectedColor: packC(events.invoke('selectedClr')), + unselectedColor: packC(events.invoke('unselectedClr')), + lockedColor: packC(events.invoke('lockedClr')), + shBands: events.invoke('view.bands'), + centersSize: events.invoke('camera.splatSize'), + outlineSelection: events.invoke('view.outlineSelection'), + showGrid: events.invoke('grid.visible'), + showBound: events.invoke('camera.bound'), + showBoundDimensions: events.invoke('camera.boundDimensions'), + showCameraPoses: events.invoke('camera.showPoses'), + showCameraInfo: events.invoke('camera.showInfo'), + flySpeed: events.invoke('camera.flySpeed'), + fovDolly: events.invoke('camera.fovDolly') + }; + }); + + events.function('docDeserialize.view', (docView: any) => { + events.fire('setBgClr', new Color(docView.bgColor)); + events.fire('setSelectedClr', new Color(docView.selectedColor)); + events.fire('setUnselectedClr', new Color(docView.unselectedColor)); + events.fire('setLockedClr', new Color(docView.lockedColor)); + events.fire('view.setBands', docView.shBands); + events.fire('camera.setSplatSize', docView.centersSize); + events.fire('view.setOutlineSelection', docView.outlineSelection); + events.fire('grid.setVisible', docView.showGrid); + events.fire('camera.setBound', docView.showBound); + events.fire('camera.setBoundDimensions', docView.showBoundDimensions ?? false); + events.fire('camera.setShowPoses', docView.showCameraPoses ?? false); + events.fire('camera.setShowInfo', docView.showCameraInfo ?? false); + events.fire('camera.setFlySpeed', docView.flySpeed); + events.fire('camera.setFovDolly', docView.fovDolly ?? false); + }); +}; + +export { registerEditorEvents }; diff --git a/src/element.ts b/src/element.ts new file mode 100644 index 0000000..60c7a99 --- /dev/null +++ b/src/element.ts @@ -0,0 +1,67 @@ +import { BoundingBox, Quat, Vec3 } from 'playcanvas'; + +import { Scene } from './scene'; +import { Serializer } from './serializer'; + +enum ElementType { + camera = 'camera', + model = 'model', + splat = 'splat', + shadow = 'shadow', + debug = 'debug', + other = 'other' +} + +const ElementTypeList = [ + ElementType.camera, + ElementType.model, + ElementType.splat, + ElementType.shadow, + ElementType.debug, + ElementType.other +]; + +let nextUid = 1; + +class Element { + type: ElementType; + scene: Scene = null; + uid: number; + + constructor(type: ElementType) { + this.type = type; + this.uid = nextUid++; + } + + destroy() { + if (this.scene) { + this.scene.remove(this); + } + } + + add(): void | Promise {} + + remove() {} + + serialize(serializer: Serializer) {} + + onUpdate(deltaTime: number) {} + + onPostUpdate() {} + + onPreRender() {} + + onPostRender() {} + + onAdded(element: Element) {} + + onRemoved(element: Element) {} + + move(position?: Vec3, rotation?: Quat, scale?: Vec3) {} + + get worldBound(): BoundingBox | null { + return null; + } +} + +export { ElementType, ElementTypeList, Element }; diff --git a/src/entity-transform-handler.ts b/src/entity-transform-handler.ts new file mode 100644 index 0000000..5ad8a65 --- /dev/null +++ b/src/entity-transform-handler.ts @@ -0,0 +1,131 @@ +import { Mat4, Quat, Vec3 } from 'playcanvas'; + +import { PlacePivotOp, EntityTransformOp, MultiOp } from './edit-ops'; +import { Events } from './events'; +import { Pivot } from './pivot'; +import { Splat } from './splat'; +import { Transform } from './transform'; +import { TransformHandler } from './transform-handler'; + +const mat = new Mat4(); +const quat = new Quat(); +const transform = new Transform(); + +class EntityTransformHandler implements TransformHandler { + events: Events; + splat: Splat; + top: EntityTransformOp; + pop: PlacePivotOp; + bindMat = new Mat4(); + + constructor(events: Events) { + this.events = events; + + events.on('pivot.started', (pivot: Pivot) => { + if (this.splat) { + this.start(); + } + }); + + events.on('pivot.moved', (pivot: Pivot) => { + if (this.splat) { + this.update(pivot.transform); + } + }); + + events.on('pivot.ended', (pivot: Pivot) => { + if (this.splat) { + this.end(); + } + }); + + events.on('pivot.origin', (mode: 'center' | 'boundCenter') => { + if (this.splat) { + this.placePivot(); + } + }); + + events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => { + if (this.splat && ['move', 'rotate', 'scale'].includes(this.events.invoke('tool.active'))) { + const pivot = events.invoke('pivot') as Pivot; + const newt = new Transform(details.position, pivot.transform.rotation, pivot.transform.scale); + const op = new PlacePivotOp({ pivot, oldt: pivot.transform.clone(), newt }); + events.fire('edit.add', op); + } + }); + } + + placePivot() { + // place initial pivot point + const origin = this.events.invoke('pivot.origin'); + this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', false, transform); + this.events.invoke('pivot').place(transform); + } + + activate() { + this.splat = this.events.invoke('selection') as Splat; + if (this.splat) { + this.placePivot(); + } + } + + deactivate() { + this.splat = null; + } + + start() { + const pivot = this.events.invoke('pivot') as Pivot; + const { transform } = pivot; + const { entity } = this.splat; + + // calculate bind matrix + this.bindMat.setTRS(transform.position, transform.rotation, transform.scale); + this.bindMat.invert(); + this.bindMat.mul2(this.bindMat, entity.getLocalTransform()); + + const p = entity.getLocalPosition(); + const r = entity.getLocalRotation(); + const s = entity.getLocalScale(); + + // create op + this.top = new EntityTransformOp({ + splat: this.splat, + oldt: new Transform(p, r, s), + newt: new Transform(p, r, s) + }); + + this.pop = new PlacePivotOp({ + pivot, + oldt: transform.clone(), + newt: transform.clone() + }); + } + + update(transform: Transform) { + mat.setTRS(transform.position, transform.rotation, transform.scale); + mat.mul2(mat, this.bindMat); + quat.setFromMat4(mat); + + const t = mat.getTranslation(); + const r = quat; + const s = mat.getScale(); + + this.splat.move(t, r, s); + this.top.newt.set(t, r, s); + this.pop.newt.copy(transform); + } + + end() { + // if anything changed then register the op with undo/redo system + const { oldt, newt } = this.top; + + if (!oldt.equals(newt)) { + this.events.fire('edit.add', new MultiOp([this.top, this.pop])); + } + + this.top = null; + this.pop = null; + } +} + +export { EntityTransformHandler }; diff --git a/src/equirect-renderer.ts b/src/equirect-renderer.ts new file mode 100644 index 0000000..4b908f8 --- /dev/null +++ b/src/equirect-renderer.ts @@ -0,0 +1,121 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + FILTER_LINEAR, + FILTER_NEAREST, + PIXELFORMAT_RGBA8, + SEMANTIC_POSITION, + drawQuadWithShader, + BlendState, + GraphicsDevice, + Quat, + RenderTarget, + Shader, + ShaderUtils, + Texture +} from 'playcanvas'; + +import { faceFov, vertexShader, fragmentShader } from './shaders/equirect-shader'; + +// renders the six cube faces of a panorama to individual 2d textures and +// projects them to an equirectangular target. faces are rendered wider than +// 90° (see shaders/equirect-shader.ts) so the projection can blend the +// overlap, feathering away per-face splat shape and sort order differences at +// face boundaries. face order and the (s, t) bases used by the projection +// shader are defined together in shaders/equirect-shader.ts. +class EquirectRenderer { + // fov of each face camera in degrees; must match the projection shader + static faceFov = faceFov; + + // capture-space rotations for the six face cameras: front (-Z), right (+X), + // back (+Z), left (-X), up (+Y), down (-Y). multiply by the capture + // orientation to get the world-space face rotation. + static faceRotations = [ + new Quat().setFromEulerAngles(0, 0, 0), + new Quat().setFromEulerAngles(0, -90, 0), + new Quat().setFromEulerAngles(0, 180, 0), + new Quat().setFromEulerAngles(0, 90, 0), + new Quat().setFromEulerAngles(90, 0, 0), + new Quat().setFromEulerAngles(-90, 0, 0) + ]; + + device: GraphicsDevice; + faceTargets: RenderTarget[]; + equirectTarget: RenderTarget; + shader: Shader; + + constructor(device: GraphicsDevice, faceSize: number, width: number, height: number) { + this.device = device; + + const createTexture = (name: string, width: number, height: number, filter: number) => { + return new Texture(device, { + name, + width, + height, + format: PIXELFORMAT_RGBA8, + mipmaps: false, + minFilter: filter, + magFilter: filter, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + }; + + this.faceTargets = []; + for (let i = 0; i < 6; ++i) { + this.faceTargets.push(new RenderTarget({ + colorBuffer: createTexture(`equirectFace${i}`, faceSize, faceSize, FILTER_LINEAR), + depth: false, + autoResolve: false + })); + } + + this.equirectTarget = new RenderTarget({ + colorBuffer: createTexture('equirectColor', width, height, FILTER_NEAREST), + depth: false, + autoResolve: false + }); + + this.shader = ShaderUtils.createShader(device, { + uniqueName: 'equirectShader', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + } + + // project the six captured faces to the equirectangular target + project() { + const { device, equirectTarget } = this; + + for (let i = 0; i < 6; ++i) { + device.scope.resolve(`uFace${i}`).setValue(this.faceTargets[i].colorBuffer); + } + device.scope.resolve('uTargetSize').setValue([equirectTarget.width, equirectTarget.height]); + + device.setBlendState(BlendState.NOBLEND); + drawQuadWithShader(device, equirectTarget, this.shader); + } + + // read the projected equirectangular pixels back to the cpu + read(data: Uint8Array) { + const { equirectTarget } = this; + return equirectTarget.colorBuffer.read(0, 0, equirectTarget.width, equirectTarget.height, { + renderTarget: equirectTarget, + data + }); + } + + destroy() { + this.faceTargets.forEach((target) => { + target.colorBuffer.destroy(); + target.destroy(); + }); + this.equirectTarget.colorBuffer.destroy(); + this.equirectTarget.destroy(); + this.faceTargets = []; + } +} + +export { EquirectRenderer }; diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..3e1a47d --- /dev/null +++ b/src/events.ts @@ -0,0 +1,27 @@ +import { EventHandler } from 'playcanvas'; + +type FunctionCallback = (...args: any[]) => any; + +class Events extends EventHandler { + functions = new Map(); + + // declare an editor function + function(name: string, fn: FunctionCallback) { + if (this.functions.has(name)) { + throw new Error(`error: function ${name} already exists`); + } + this.functions.set(name, fn); + } + + // invoke an editor function + invoke(name: string, ...args: any[]) { + const fn = this.functions.get(name); + if (!fn) { + console.log(`error: function not found '${name}'`); + return; + } + return fn(...args); + } +} + +export { Events }; diff --git a/src/file-handler.ts b/src/file-handler.ts new file mode 100644 index 0000000..54e1fbf --- /dev/null +++ b/src/file-handler.ts @@ -0,0 +1,610 @@ +import { path, Quat, Vec3 } from 'playcanvas'; + +import { CreateDropHandler } from './drop-handler'; +import { ElementType } from './element'; +import { Events } from './events'; +import { BrowserFileSystem, MappedReadFileSystem } from './io'; +import { Scene } from './scene'; +import { Splat } from './splat'; +import { serializePly, serializePlyCompressed, SerializeSettings, serializeSog, serializeSplat, serializeSpz, serializeViewer, SogSettings, SpzSettings, ViewerExportSettings, WebGPUUnavailableError } from './splat-serialize'; +import { i18n } from './ui/localization'; + +// ts compiler and vscode find this type, but eslint does not +type FilePickerAcceptType = unknown; + +type ExportType = 'ply' | 'splat' | 'sog' | 'spz' | 'viewer'; + +type FileType = 'ply' | 'compressedPly' | 'splat' | 'sog' | 'spz' | 'htmlViewer' | 'packageViewer'; + +interface SceneExportOptions { + filename: string; + splatIdx: 'all' | number; + serializeSettings: SerializeSettings; + + // ply + compressedPly?: boolean; + + // sog + sogIterations?: number; + + // spz + spzVersion?: 3 | 4; + + // viewer + viewerExportSettings?: ViewerExportSettings; +} + +const filePickerTypes: { [key: string]: FilePickerAcceptType } = { + 'ply': { + description: 'Gaussian Splat PLY File', + accept: { + 'application/ply': ['.ply'] + } + }, + 'compressedPly': { + description: 'Compressed Gaussian Splat PLY File', + accept: { + 'application/ply': ['.ply'] + } + }, + 'sog': { + description: 'SOG Scene', + accept: { + 'application/x-gaussian-splat': ['.json', '.sog'], + 'image/webp': ['.webp'] + } + }, + 'lcc': { + description: 'LCC Scene', + accept: { + 'application/x-lcc': ['.lcc', '.bin'] + } + }, + 'splat': { + description: 'Splat File', + accept: { + 'application/x-gaussian-splat': ['.splat'] + } + }, + 'ksplat': { + description: 'KSplat File', + accept: { + 'application/x-gaussian-splat': ['.ksplat'] + } + }, + 'spz': { + description: 'SPZ File (Niantic)', + accept: { + 'application/x-gaussian-splat': ['.spz'] + } + }, + 'indexTxt': { + description: 'Colmap Poses (Images.txt)', + accept: { + 'text/plain': ['.txt'] + } + }, + 'htmlViewer': { + description: 'Viewer HTML', + accept: { + 'text/html': ['.html'] + } + }, + 'packageViewer': { + description: 'Viewer ZIP', + accept: { + 'application/zip': ['.zip'] + } + } +}; + +const allImportTypes = { + description: 'Supported Files', + accept: { + 'application/ply': ['.ply'], + 'application/x-gaussian-splat': ['.json', '.sog', '.splat', '.ksplat', '.spz'], + 'image/webp': ['.webp'], + 'application/x-lcc': ['.lcc', '.bin'], + 'text/plain': ['.txt'] + } +}; + +// determine if all files share a common filename prefix followed by +// a frame number, e.g. "frame0001.ply", "frame0002.ply", etc. +const isPlySequence = (filenames: string[]) => { + if (filenames.length < 2) { + return false; + } + + // eslint-disable-next-line regexp/no-super-linear-backtracking + const regex = /(.*?)(\d+)(?:\.compressed)?\.ply$/; + const baseMatch = filenames[0].match(regex); + if (!baseMatch) { + return false; + } + + for (let i = 1; i < filenames.length; i++) { + const thisMatch = filenames[i].match(regex); + if (!thisMatch || thisMatch[1] !== baseMatch[1]) { + return false; + } + } + + return true; +}; + +// sog comprises a single meta.json file and zero or more .webp files +const isSog = (filenames: string[]) => { + const count = (extension: string) => filenames.reduce((sum, f) => sum + (f.endsWith(extension) ? 1 : 0), 0); + return count('meta.json') === 1; +}; + +// The LCC file contains meta.lcc, index.bin, data.bin and shcoef.bin (optional) +const isLcc = (filenames: string[]) => { + const count = (extension: string) => filenames.reduce((sum, f) => sum + (f.endsWith(extension) ? 1 : 0), 0); + return count('.lcc') === 1; +}; + +type ImportFile = { + filename: string; + url?: string; + contents?: File; + handle?: FileSystemFileHandle; +}; + +const vec = new Vec3(); + +// load inria camera poses from json file +const loadCameraPoses = async (file: ImportFile, events: Events) => { + const response = new Response(file.contents); + const json = await response.json(); + + if (json.length > 0) { + // sort entries by trailing number if it exists + const sorter = (a: any, b: any) => { + const avalue = a.id ?? a.img_name?.match(/\d*$/)?.[0]; + const bvalue = b.id ?? b.img_name?.match(/\d*$/)?.[0]; + return (avalue && bvalue) ? parseInt(avalue, 10) - parseInt(bvalue, 10) : 0; + }; + + json.sort(sorter).forEach((pose: any, i: number) => { + if (pose.hasOwnProperty('position') && pose.hasOwnProperty('rotation')) { + const p = new Vec3(pose.position); + const z = new Vec3(pose.rotation[0][2], pose.rotation[1][2], pose.rotation[2][2]); + + // Use fixed offset along Z-axis direction instead of variable dot product + vec.copy(z).mulScalar(10).add(p); + + // compute max FOV from intrinsics (vertical or horizontal, whichever is larger) + let fov = 60; + if (pose.fx && pose.fy && pose.width && pose.height) { + const fovX = 2 * Math.atan(pose.width / (2 * pose.fx)) * (180 / Math.PI); + const fovY = 2 * Math.atan(pose.height / (2 * pose.fy)) * (180 / Math.PI); + fov = Math.max(fovX, fovY); + } + + events.fire('camera.addPose', { + name: pose.img_name ?? `${file.filename}_${i}`, + frame: i, + position: new Vec3(-p.x, -p.y, p.z), + target: new Vec3(-vec.x, -vec.y, vec.z), + fov + }); + } + }); + } +}; + +const removeExtension = (filename: string) => { + return filename.substring(0, filename.length - path.getExtension(filename).length); +}; + +// https://colmap.github.io/format.html#images-txt +const loadImagesTxt = async (file: ImportFile, events: Events) => { + const response = new Response(file.contents); + const text = await response.text(); + + // split into lines, remove comments and empty lines + const poses = text.split('\n') + .map(line => line.trim()) + .filter(line => !line.startsWith('#')) // remove comments + .filter((_, i) => i % 2 === 0) // remove every second line + .map((line, i) => { + const parts = line.split(' '); + if (parts.length !== 10) { + return null; + } + const name = parts[9]; + const order = parseInt(removeExtension(name).match(/\d+$/)?.[0], 10); + return { + w: parseFloat(parts[1]), + x: parseFloat(parts[2]), + y: parseFloat(parts[3]), + z: parseFloat(parts[4]), + tx: parseFloat(parts[5]), + ty: parseFloat(parts[6]), + tz: parseFloat(parts[7]), + name: name ?? `${file.filename}_${i}`, + order: isFinite(order) ? order : i + }; + }) + .filter(entry => !!entry) + .sort((a, b) => (a.order < b.order ? -1 : 1)); + + const q = new Quat(); + const t = new Vec3(); + + poses.forEach((pose, i) => { + const { w, x, y, z, tx, ty, tz } = pose; + + q.set(x, y, z, w).normalize().invert(); + t.set(-tx, -ty, -tz); + q.transformVector(t, t); + + q.transformVector(Vec3.BACK, vec); + vec.mulScalar(10).add(t); + + events.fire('camera.addPose', { + name: pose.name, + frame: i, + position: new Vec3(-t.x, -t.y, t.z), + target: new Vec3(-vec.x, -vec.y, vec.z) + }); + }); +}; + +// initialize file handler events +const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement) => { + + const showLoadError = async (message: string, filename: string) => { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.error-loading'), + message: `${message} while loading '${filename}'` + }); + }; + + // import splat model(s) - handles single files, SOG, and LCC formats + const importSplatModel = async (files: ImportFile[], animationFrame: boolean) => { + try { + const filenames = files.map(f => f.filename.toLowerCase()); + + // Determine the main file based on format + let mainIndex: number; + if (filenames.some(f => f === 'meta.json')) { + mainIndex = filenames.findIndex(f => f === 'meta.json'); + } else if (filenames.some(f => f.endsWith('.lcc'))) { + mainIndex = filenames.findIndex(f => f.endsWith('.lcc')); + } else { + mainIndex = 0; // Single file case + } + + const mainFile = files[mainIndex]; + const baseUrl = mainFile.url ? new URL('.', new URL(mainFile.url, window.location.href)).href : undefined; + + // Create file system with all local files, falling back to URL loading + const fileSystem = new MappedReadFileSystem(baseUrl); + files.forEach((f) => { + if (f.contents) fileSystem.addFile(f.filename, f.contents); + }); + + // For URL-only single file, use full URL as filename + const filename = (files.length === 1 && !mainFile.contents && mainFile.url) ? + mainFile.url : + mainFile.filename; + + const model = await scene.assetLoader.load(filename, fileSystem, animationFrame); + await scene.add(model); + return model; + } catch (error) { + const displayName = files[0]?.filename ?? 'unknown'; + await showLoadError(error.message ?? error, displayName); + } + }; + + // figure out what the set of files are (ply sequence, document, sog set, ply) and then import them + const importFiles = async (files: ImportFile[], animationFrame = false) => { + const filenames = files.map(f => f.filename.toLowerCase()); + + const result: Splat[] = []; + + if (isPlySequence(filenames)) { + // handle ply sequence + events.fire('sequence.setPlyFrames', files.map(f => f.contents)); + events.fire('timeline.frame', 0); + } else if (isSog(filenames) || isLcc(filenames)) { + if (isLcc(filenames)) { + const response = await events.invoke('showPopup', { + type: 'okcancel', + header: 'LCC', + message: i18n.t('popup.lcc-upload-warning'), + link: `${window.location.origin}/upload` + }); + if (response.action === 'cancel') { + return result; + } + } + const model = await importSplatModel(files, animationFrame); + if (model) result.push(model); + } else { + // check for unrecognized file types + for (let i = 0; i < filenames.length; i++) { + const filename = filenames[i].toLowerCase(); + if (['.ssproj', '.ply', '.splat', '.sog', '.webp', 'images.txt', '.json', '.ksplat', '.spz'].every(ext => !filename.endsWith(ext))) { + await showLoadError('Unrecognized file type', filename); + return; + } + } + + // handle multiple files as independent imports + for (let i = 0; i < files.length; i++) { + const filename = filenames[i].toLowerCase(); + + if (filename.endsWith('.ssproj')) { + // load ssproj document + await events.invoke('doc.load', files[i].contents ?? (await fetch(files[i].url)).arrayBuffer(), files[i].handle); + } else if (['.ply', '.splat', '.sog', '.ksplat', '.spz'].some(ext => filename.endsWith(ext))) { + // load gaussian splat model + const model = await importSplatModel([files[i]], animationFrame); + if (model) result.push(model); + } else if (filename.endsWith('images.txt')) { + // load colmap frames + await loadImagesTxt(files[i], events); + } else if (filename.endsWith('.json')) { + // load inria camera poses + await loadCameraPoses(files[i], events); + } + } + } + + return result; + }; + + events.function('import', (files: ImportFile[], animationFrame = false) => { + return importFiles(files, animationFrame); + }); + + // create a file selector element as fallback when showOpenFilePicker isn't available + let fileSelector: HTMLInputElement; + if (!window.showOpenFilePicker) { + fileSelector = document.createElement('input'); + fileSelector.setAttribute('id', 'file-selector'); + fileSelector.setAttribute('type', 'file'); + fileSelector.setAttribute('accept', '.ply,.splat,meta.json,.json,.webp,.ssproj,.sog,.lcc,.bin,.txt,.ksplat,.spz'); + fileSelector.setAttribute('multiple', 'true'); + + fileSelector.onchange = () => { + const files = []; + for (let i = 0; i < fileSelector.files.length; i++) { + const file = fileSelector.files[i]; + files.push({ + filename: file.name, + contents: file + }); + } + importFiles(files); + fileSelector.value = ''; + }; + document.body.append(fileSelector); + } + + // create the file drag & drop handler + CreateDropHandler(dropTarget, (entries, shift) => { + importFiles(entries.map((e) => { + return { + filename: e.filename, + contents: e.file, + handle: e.handle + }; + })); + }); + + // get the list of visible splats containing gaussians + const getSplats = () => { + return (scene.getElementsByType(ElementType.splat) as Splat[]) + .filter(splat => splat.visible) + .filter(splat => splat.numSplats > 0); + }; + + events.function('scene.allSplats', () => { + return (scene.getElementsByType(ElementType.splat) as Splat[]); + }); + + events.function('scene.splats', () => { + return getSplats(); + }); + + events.function('scene.empty', () => { + return getSplats().length === 0; + }); + + events.function('scene.import', async () => { + if (fileSelector) { + fileSelector.click(); + } else { + try { + const handles = await window.showOpenFilePicker({ + id: 'SuperSplatFileImport', + multiple: true, + excludeAcceptAllOption: false, + types: [ + allImportTypes, + filePickerTypes.ply, + filePickerTypes.compressedPly, + filePickerTypes.splat, + filePickerTypes.sog, + filePickerTypes.lcc, + filePickerTypes.ksplat, + filePickerTypes.spz, + filePickerTypes.indexTxt + ] + }); + + const files = []; + for (let i = 0; i < handles.length; i++) { + files.push({ + filename: handles[i].name, + contents: await handles[i].getFile() + }); + } + + importFiles(files); + + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + } + }); + + // open a folder + events.function('scene.openAnimation', async () => { + try { + const handle = await window.showDirectoryPicker({ + id: 'SuperSplatFileOpenAnimation', + mode: 'readwrite' + }); + + if (handle) { + const files = []; + for await (const value of handle.values()) { + if (value.kind === 'file') { + const file = await value.getFile(); + if (file.name.toLowerCase().endsWith('.ply')) { + files.push(file); + } + } + } + events.fire('sequence.setPlyFrames', files); + events.fire('timeline.frame', 0); + } + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + }); + + events.function('scene.export', async (exportType: ExportType) => { + const splats = getSplats(); + + const hasFilePicker = !!window.showSaveFilePicker; + + // show viewer export options + const options = await events.invoke('show.exportPopup', exportType, splats.map(s => s.name), !hasFilePicker) as SceneExportOptions; + + // return if user cancelled + if (!options) { + return; + } + + const fileType: FileType = + (exportType === 'viewer') ? (options.viewerExportSettings!.type === 'zip' ? 'packageViewer' : 'htmlViewer') : + (exportType === 'ply') ? (options.compressedPly ? 'compressedPly' : 'ply') : + (exportType === 'sog') ? 'sog' : + (exportType === 'spz') ? 'spz' : 'splat'; + + if (hasFilePicker) { + try { + const fileHandle = await window.showSaveFilePicker({ + id: 'SuperSplatFileExport', + types: [filePickerTypes[fileType]], + suggestedName: options.filename + }); + await events.invoke('scene.write', fileType, options, await fileHandle.createWritable()); + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + } else { + await events.invoke('scene.write', fileType, options); + } + }); + + events.function('scene.write', async (fileType: FileType, options: SceneExportOptions, stream?: FileSystemWritableFileStream) => { + // SOG, SPZ and viewer exports have their own progress UI, other formats use spinner + const useSpinner = fileType !== 'sog' && fileType !== 'spz' && fileType !== 'htmlViewer' && fileType !== 'packageViewer'; + + if (useSpinner) { + events.fire('startSpinner'); + } + + try { + // setTimeout so spinner/progress has a chance to activate + await new Promise((resolve) => { + setTimeout(resolve); + }); + + const { filename, splatIdx, serializeSettings, viewerExportSettings } = options; + + // Create FileSystem for output + const fs = new BrowserFileSystem(filename, stream); + + const splats = splatIdx === 'all' ? getSplats() : [getSplats()[splatIdx]]; + + switch (fileType) { + case 'ply': + await serializePly(splats, serializeSettings, fs); + break; + case 'compressedPly': + serializeSettings.minOpacity = 1 / 255; + serializeSettings.removeInvalid = true; + await serializePlyCompressed(splats, serializeSettings, fs); + break; + case 'splat': + await serializeSplat(splats, serializeSettings, fs); + break; + case 'sog': { + const sogSettings: SogSettings = { + ...serializeSettings, + minOpacity: 1 / 255, + removeInvalid: true, + iterations: options.sogIterations ?? 10, + events + }; + await serializeSog(splats, sogSettings, fs); + break; + } + case 'spz': { + const spzSettings: SpzSettings = { + ...serializeSettings, + minOpacity: 1 / 255, + removeInvalid: true, + version: options.spzVersion ?? 4, + events + }; + await serializeSpz(splats, spzSettings, fs); + break; + } + case 'htmlViewer': + case 'packageViewer': + await serializeViewer(splats, serializeSettings, { ...viewerExportSettings!, events }, fs); + break; + } + + } catch (error) { + if (error instanceof WebGPUUnavailableError) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.error'), + message: i18n.t('popup.webgpu-unavailable') + }); + } else { + const message = error instanceof Error ? error.message : String(error); + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.error'), + message: `${message} while saving file` + }); + } + } finally { + if (useSpinner) { + events.fire('stopSpinner'); + } + } + }); +}; + +export { initFileHandler, ExportType, SceneExportOptions }; diff --git a/src/iframe-api.ts b/src/iframe-api.ts new file mode 100644 index 0000000..d6f6cb5 --- /dev/null +++ b/src/iframe-api.ts @@ -0,0 +1,39 @@ +import { Events } from './events'; + +const IS_SCENE_DIRTY = 'supersplat:is-scene-dirty'; + +interface IsSceneDirtyQuery { + type: typeof IS_SCENE_DIRTY; +} + +interface IsSceneDirtyResponse { + type: typeof IS_SCENE_DIRTY; + result: boolean; +} + +const isSceneDirtyQuery = (data: any): data is IsSceneDirtyQuery => { + return ( + data && + typeof data === 'object' && + data.type === IS_SCENE_DIRTY + ); +}; + +const registerIframeApi = (events: Events) => { + window.addEventListener('message', (event: MessageEvent) => { + const source = event.source as Window | null; + if (!source) { + return; + } + + if (isSceneDirtyQuery(event.data)) { + const response: IsSceneDirtyResponse = { + type: IS_SCENE_DIRTY, + result: events.invoke('scene.dirty') as boolean + }; + source.postMessage(response, event.origin); + } + }); +}; + +export { registerIframeApi }; diff --git a/src/index-ranges.ts b/src/index-ranges.ts new file mode 100644 index 0000000..1540320 --- /dev/null +++ b/src/index-ranges.ts @@ -0,0 +1,89 @@ +// High bit flags a single index entry (1 uint32) vs a range pair [start, count] (2 uint32s). +// This limits index values to 2^31 - 1, which is sufficient for any practical gaussian count. +const SINGLE_BIT = 0x80000000; +const INDEX_MASK = 0x7FFFFFFF; + +// Emit a run of contiguous indices to the ranges array. +const emit = (ranges: number[], start: number, count: number) => { + if (count === 1) { + ranges.push(start | SINGLE_BIT); + } else { + ranges.push(start, count); + } +}; + +/** + * Create a cursor-based membership predicate from sorted unique IDs. Returns a function + * that tests whether a given index is in the set. Must be called with strictly increasing + * values of i (as IndexRanges.fromPredicate guarantees). + */ +const sortedPredicate = (sortedIds: Uint32Array): (i: number) => boolean => { + let cursor = 0; + return (i: number) => { + if (cursor < sortedIds.length && sortedIds[cursor] === i) { + cursor++; + return true; + } + return false; + }; +}; + +/** + * A compact container for storing and iterating sets of indices. Internally stores contiguous + * runs as [start, count] pairs and lone indices as single entries with a high-bit flag. + * Efficient for spatially coherent data where selections form long contiguous runs. + */ +class IndexRanges { + readonly data: Uint32Array; + + private constructor(data: Uint32Array) { + this.data = data; + } + + /** + * Build ranges by scanning [0, total) and including indices where pred returns true. + * Single pass, O(total). + */ + static fromPredicate(total: number, pred: (i: number) => boolean) { + const ranges: number[] = []; + let rangeStart = -1; + + for (let i = 0; i < total; ++i) { + if (pred(i)) { + if (rangeStart === -1) rangeStart = i; + } else if (rangeStart !== -1) { + emit(ranges, rangeStart, i - rangeStart); + rangeStart = -1; + } + } + if (rangeStart !== -1) { + emit(ranges, rangeStart, total - rangeStart); + } + + return new IndexRanges(new Uint32Array(ranges)); + } + + /** Whether there are no indices. */ + get empty() { + return this.data.length === 0; + } + + /** Iterate each index. */ + forEach(fn: (index: number) => void) { + const { data } = this; + let r = 0; + while (r < data.length) { + if (data[r] & SINGLE_BIT) { + fn(data[r] & INDEX_MASK); + r += 1; + } else { + for (let i = data[r], end = data[r] + data[r + 1]; i < end; i++) { + fn(i); + } + r += 2; + } + } + } +} + +export { IndexRanges, sortedPredicate }; diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..8572469 --- /dev/null +++ b/src/index.html @@ -0,0 +1,27 @@ + + + + SuperSplat + + + + + + + + + + + + + + + + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..56129f3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +import './ui/scss/style.scss'; +import { version as pcuiVersion, revision as pcuiRevision } from '@playcanvas/pcui'; +import { version as stVersion, revision as stRevision } from '@playcanvas/splat-transform'; +import { version as engineVersion, revision as engineRevision } from 'playcanvas'; + +import { main } from './main'; +import { version as appVersion } from '../package.json'; + +// print out versions of dependent packages +// NOTE: add dummy style reference to prevent tree shaking +console.log(`SuperSplat v${appVersion} | SplatTransform v${stVersion} (${stRevision}) | Engine v${engineVersion} (${engineRevision}) | PCUI v${pcuiVersion} (${pcuiRevision})`); + +main(); diff --git a/src/infinite-grid.ts b/src/infinite-grid.ts new file mode 100644 index 0000000..2c37522 --- /dev/null +++ b/src/infinite-grid.ts @@ -0,0 +1,114 @@ +import { + BLENDMODE_ONE, + BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDMODE_SRC_ALPHA, + BLENDEQUATION_ADD, + CULLFACE_NONE, + FUNC_LESSEQUAL, + SEMANTIC_POSITION, + BlendState, + DepthState, + Layer, + QuadRender, + ScopeSpace, + Shader, + ShaderUtils, + Vec3, + Mat4 +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { Serializer } from './serializer'; +import { vertexShader, fragmentShader } from './shaders/infinite-grid-shader'; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const key in values) { + scope.resolve(key).setValue(values[key]); + } +}; + +class InfiniteGrid extends Element { + shader: Shader; + quadRender: QuadRender; + blendState = new BlendState(false); + depthState = new DepthState(FUNC_LESSEQUAL, true); + + visible = true; + + constructor() { + super(ElementType.debug); + } + + add() { + const device = this.scene.app.graphicsDevice; + + this.shader = ShaderUtils.createShader(device, { + uniqueName: 'infinite-grid', + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + + this.quadRender = new QuadRender(this.shader); + + const blendState = new BlendState( + true, + BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA + ); + + const view_position = [0, 0, 0]; + const viewProjectionMatrix = new Mat4(); + let plane; + + this.scene.camera.camera.on('preRenderLayer', (layer: Layer, transparent: boolean) => { + const { scene } = this; + if (this.visible && layer === scene.worldLayer && !transparent && scene.camera.renderOverlays) { + const { camera } = scene; + + device.setBlendState(blendState); + device.setCullMode(CULLFACE_NONE); + device.setDepthState(DepthState.WRITEDEPTH); + device.setStencilState(null, null); + + // select the correctly plane in orthographic mode + if (camera.ortho) { + const cmp = (a:Vec3, b: Vec3) => 1.0 - Math.abs(a.dot(b)) < 1e-03; + const z = camera.worldTransform.getZ(); + plane = cmp(z, Vec3.RIGHT) ? 0 : (cmp(z, Vec3.BACK) ? 2 : 1); + } else { + // default is xz plane + plane = 1; + } + + const p = camera.position; + view_position[0] = p.x; + view_position[1] = p.y; + view_position[2] = p.z; + + viewProjectionMatrix.mul2(camera.camera.projectionMatrix, camera.camera.viewMatrix); + + resolve(device.scope, { + plane, + view_position, + matrix_viewProjection: viewProjectionMatrix.data + }); + + this.quadRender.render(); + } + }); + } + + remove() { + this.shader.destroy(); + this.quadRender.destroy(); + } + + serialize(serializer: Serializer): void { + serializer.pack(this.visible); + } +} + +export { InfiniteGrid }; diff --git a/src/io/index.ts b/src/io/index.ts new file mode 100644 index 0000000..674fac7 --- /dev/null +++ b/src/io/index.ts @@ -0,0 +1,18 @@ +/** + * IO module - handles reading and writing splat data. + */ + +// Read operations +export { + BlobReadSource, + MappedReadFileSystem, + loadGSplatData, + validateGSplatData +} from './read'; + +// Write operations +export { + BrowserFileSystem, + GZipWriter, + ProgressWriter +} from './write'; diff --git a/src/io/read/file-systems.ts b/src/io/read/file-systems.ts new file mode 100644 index 0000000..39467b2 --- /dev/null +++ b/src/io/read/file-systems.ts @@ -0,0 +1,146 @@ +/** + * File system implementations for reading splat data from various sources. + */ + +import { + BufferedReadStream, + ReadFileSystem, + ReadSource, + ReadStream, + UrlReadFileSystem +} from '@playcanvas/splat-transform'; + +// Read blob in 4MB chunks to balance async overhead vs memory usage +const BLOB_CHUNK_SIZE = 4 * 1024 * 1024; + +/** + * ReadStream implementation for reading from Blob/File. + */ +class BlobReadStream extends ReadStream { + private blob: Blob; + private offset: number; + private end: number; + + constructor(blob: Blob, start: number, end: number) { + super(end - start); + this.blob = blob; + this.offset = start; + this.end = end; + } + + async pull(target: Uint8Array): Promise { + const remaining = this.end - this.offset; + if (remaining <= 0) { + return 0; + } + + const bytesToRead = Math.min(target.length, remaining); + const slice = this.blob.slice(this.offset, this.offset + bytesToRead); + const arrayBuffer = await slice.arrayBuffer(); + target.set(new Uint8Array(arrayBuffer)); + this.offset += bytesToRead; + this.bytesRead += bytesToRead; + return bytesToRead; + } +} + +/** + * ReadSource implementation for Blob/File. + */ +class BlobReadSource implements ReadSource { + readonly size: number; + readonly seekable: boolean = true; + + private blob: Blob; + private closed: boolean = false; + + constructor(blob: Blob) { + this.blob = blob; + this.size = blob.size; + } + + read(start: number = 0, end: number = this.size): ReadStream { + if (this.closed) { + throw new Error('Source has been closed'); + } + + const clampedStart = Math.max(0, Math.min(start, this.size)); + const clampedEnd = Math.max(clampedStart, Math.min(end, this.size)); + + // Wrap with BufferedReadStream to reduce async overhead from blob reads + const raw = new BlobReadStream(this.blob, clampedStart, clampedEnd); + return new BufferedReadStream(raw, BLOB_CHUNK_SIZE); + } + + close(): void { + this.closed = true; + } +} + +/** + * ReadFileSystem for reading from browser File/Blob objects. + * Used for drag & drop and file picker scenarios. + */ +class BlobReadFileSystem implements ReadFileSystem { + private files: Map = new Map(); + + /** + * Add a file to the file system. + */ + set(name: string, blob: Blob): void { + this.files.set(name.toLowerCase(), blob); + } + + /** + * Get a file by name. + */ + get(name: string): Blob | undefined { + return this.files.get(name.toLowerCase()); + } + + createSource(filename: string): Promise { + const blob = this.files.get(filename.toLowerCase()); + if (!blob) { + return Promise.reject(new Error(`File not found: ${filename}`)); + } + return Promise.resolve(new BlobReadSource(blob)); + } +} + +/** + * ReadFileSystem that combines URL-based loading with local file storage. + * Used for multi-file formats (SOG, LCC) where some files may be local + * and others may need to be fetched from URLs. + */ +class MappedReadFileSystem implements ReadFileSystem { + private blobFs: BlobReadFileSystem; + private urlFs: UrlReadFileSystem; + + constructor(baseUrl?: string) { + this.blobFs = new BlobReadFileSystem(); + this.urlFs = new UrlReadFileSystem(baseUrl); + } + + /** + * Add a local file. + */ + addFile(name: string, blob: Blob): void { + this.blobFs.set(name, blob); + } + + async createSource(filename: string): Promise { + // First check if we have a local blob + const localBlob = this.blobFs.get(filename); + if (localBlob) { + return new BlobReadSource(localBlob); + } + + // Fall back to URL loading + return await this.urlFs.createSource(filename); + } +} + +export { + BlobReadSource, + MappedReadFileSystem +}; diff --git a/src/io/read/index.ts b/src/io/read/index.ts new file mode 100644 index 0000000..ce4fe9e --- /dev/null +++ b/src/io/read/index.ts @@ -0,0 +1,15 @@ +/** + * IO Read module - handles loading splat data from various sources. + */ + +// File system implementations +export { + BlobReadSource, + MappedReadFileSystem +} from './file-systems'; + +// Loading functions +export { + loadGSplatData, + validateGSplatData +} from './loader'; diff --git a/src/io/read/loader.ts b/src/io/read/loader.ts new file mode 100644 index 0000000..9bb808e --- /dev/null +++ b/src/io/read/loader.ts @@ -0,0 +1,159 @@ +/** + * Unified loader for all splat file formats using splat-transform. + */ + +import { + getInputFormat, + readFile, + sortMortonOrder, + Column, + ColumnType, + DataTable, + Options, + ReadFileSystem, + Transform, + ZipReadFileSystem +} from '@playcanvas/splat-transform'; +import { GSplatData } from 'playcanvas'; + +type LoadResult = { + gsplatData: GSplatData; + transform: Transform; +}; + +/** + * Default options for readFile. + */ +const defaultOptions: Options = { + iterations: 10, + lodSelect: [0], + unbundled: false, + lodChunkCount: 512, + lodChunkExtent: 16 +}; + +/** + * Map splat-transform column types to GSplatData property types. + */ +const columnTypeToGSplatType = (colType: ColumnType | null): string => { + switch (colType) { + case 'int8': return 'char'; + case 'uint8': return 'uchar'; + case 'int16': return 'short'; + case 'uint16': return 'ushort'; + case 'int32': return 'int'; + case 'uint32': return 'uint'; + case 'float32': return 'float'; + case 'float64': return 'double'; + default: return 'float'; + } +}; + +/** + * Convert a splat-transform DataTable to PlayCanvas GSplatData. + */ +const dataTableToGSplatData = (dataTable: DataTable): GSplatData => { + const properties = dataTable.columns.map((col: Column) => ({ + type: columnTypeToGSplatType(col.dataType), + name: col.name, + storage: col.data, + byteSize: col.data.BYTES_PER_ELEMENT + })); + + const gsplatData = new GSplatData([{ + name: 'vertex', + count: dataTable.numRows, + properties + }]); + + // Support loading 2D splats by adding scale_2 property with almost 0 scale + if (gsplatData.getProp('scale_0') && gsplatData.getProp('scale_1') && !gsplatData.getProp('scale_2')) { + const scale2 = new Float32Array(gsplatData.numSplats).fill(Math.log(1e-6)); + gsplatData.addProp('scale_2', scale2); + + // Place the new scale_2 property just after scale_1 + const props = gsplatData.getElement('vertex').properties; + props.splice(props.findIndex((prop: any) => prop.name === 'scale_1') + 1, 0, props.splice(props.length - 1, 1)[0]); + } + + return gsplatData; +}; + +/** + * Load a file using splat-transform and convert to GSplatData. + * @param filename - The filename to load + * @param fileSystem - The file system to read from + * @param skipReorder - Skip morton reordering (for files already in morton order or animation playback) + */ +const loadGSplatData = async (filename: string, fileSystem: ReadFileSystem, skipReorder?: boolean): Promise => { + const inputFormat = getInputFormat(filename); + const lowerFilename = filename.toLowerCase(); + + // Handle bundled SOG (.sog extension) - wrap with ZipReadFileSystem + if (inputFormat === 'sog' && lowerFilename.endsWith('.sog')) { + const source = await fileSystem.createSource(filename); + const zipFs = new ZipReadFileSystem(source); + try { + const tables = await readFile({ + filename: 'meta.json', + inputFormat: 'sog', + options: defaultOptions, + params: [], + fileSystem: zipFs + }); + return { gsplatData: dataTableToGSplatData(tables[0]), transform: tables[0].transform }; + } finally { + zipFs.close(); + } + } + + // Read the file using splat-transform + const tables = await readFile({ + filename, + inputFormat, + options: defaultOptions, + params: [], + fileSystem + }); + + // Reorder data into morton order for better render performance. + // Skip reordering for: + // - SOG format (already in morton order) + // - Compressed PLY (already in morton order from write-compressed-ply) + // - When skipReorder is true (ssproj files are already ordered, animation frames need speed) + const isCompressedPly = lowerFilename.endsWith('.compressed.ply'); + if (inputFormat !== 'sog' && !isCompressedPly && !skipReorder) { + const indices = new Uint32Array(tables[0].numRows); + for (let i = 0; i < indices.length; i++) { + indices[i] = i; + } + sortMortonOrder(tables[0], indices); + tables[0].permuteRowsInPlace(indices); + } + + // Convert to GSplatData (use first table, as most formats return single table) + // LCC may return multiple tables for different LOD levels - we use the first (highest detail) + return { gsplatData: dataTableToGSplatData(tables[0]), transform: tables[0].transform }; +}; + +/** + * Validate that GSplatData contains required properties. + */ +const validateGSplatData = (gsplatData: GSplatData): void => { + const required = [ + 'x', 'y', 'z', + 'scale_0', 'scale_1', 'scale_2', + 'rot_0', 'rot_1', 'rot_2', 'rot_3', + 'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity' + ]; + + const missing = required.filter(x => !gsplatData.getProp(x)); + if (missing.length > 0) { + throw new Error(`This file does not contain gaussian splatting data. The following properties are missing: ${missing.join(', ')}`); + } +}; + +export { + loadGSplatData, + validateGSplatData +}; diff --git a/src/io/write/browser-file-system.ts b/src/io/write/browser-file-system.ts new file mode 100644 index 0000000..36d8cf3 --- /dev/null +++ b/src/io/write/browser-file-system.ts @@ -0,0 +1,127 @@ +/** + * Browser FileSystem implementation for splat-transform compatibility. + * Provides FileSystem abstraction for browser file operations. + */ + +import { MemoryFileSystem, type FileSystem, type Writer } from '@playcanvas/splat-transform'; + +/** + * Writer implementation for FileSystemWritableFileStream (File System Access API). + */ +class BrowserFileWriter implements Writer { + private stream: FileSystemWritableFileStream; + private cursor: number = 0; + private ready: Promise; + + constructor(stream: FileSystemWritableFileStream) { + this.stream = stream; + this.ready = this.stream.seek(0); + } + + get bytesWritten(): number { + return this.cursor; + } + + async write(data: Uint8Array): Promise { + await this.ready; + this.cursor += data.byteLength; + await this.stream.write(data as unknown as ArrayBuffer); + } + + async close(): Promise { + await this.ready; + await this.stream.truncate(this.cursor); + await this.stream.close(); + } +} + +/** + * Trigger a browser download for the given data. + */ +const triggerDownload = (data: Uint8Array, filename: string): void => { + const blob = new Blob([data as BlobPart], { type: 'application/octet-stream' }); + const url = window.URL.createObjectURL(blob); + + const lnk = document.createElement('a'); + lnk.download = filename; + lnk.href = url; + + // create a "fake" click-event to trigger the download + if (document.createEvent) { + const e = document.createEvent('MouseEvents'); + e.initMouseEvent('click', true, true, window, + 0, 0, 0, 0, 0, false, false, false, + false, 0, null); + lnk.dispatchEvent(e); + } else { + // @ts-ignore + lnk.fireEvent?.('onclick'); + } + + window.URL.revokeObjectURL(url); +}; + +/** + * Writer implementation that triggers a browser download on close. + * Uses MemoryFileSystem internally for efficient buffer management. + */ +class BrowserDownloadWriter implements Writer { + private memFs: MemoryFileSystem; + private innerWriter: Writer; + private filename: string; + + constructor(filename: string) { + this.filename = filename; + this.memFs = new MemoryFileSystem(); + this.innerWriter = this.memFs.createWriter(filename); + } + + get bytesWritten(): number { + return this.innerWriter.bytesWritten; + } + + write(data: Uint8Array): void { + this.innerWriter.write(data); + } + + close(): void { + this.innerWriter.close(); + const data = this.memFs.results.get(this.filename); + if (data) { + triggerDownload(data, this.filename); + } + } +} + +/** + * FileSystem implementation for browser environments. + * Supports both File System Access API (stream) and fallback download. + */ +class BrowserFileSystem implements FileSystem { + private stream?: FileSystemWritableFileStream; + private filename: string; + + /** + * Create a BrowserFileSystem. + * @param filename - The filename for downloads (fallback mode) + * @param stream - Optional FileSystemWritableFileStream for direct file access + */ + constructor(filename: string, stream?: FileSystemWritableFileStream) { + this.filename = filename; + this.stream = stream; + } + + createWriter(_filename: string): Writer { + if (this.stream) { + return new BrowserFileWriter(this.stream); + } + return new BrowserDownloadWriter(this.filename); + } + + mkdir(_path: string): Promise { + // No-op in browser - directories not supported + return Promise.resolve(); + } +} + +export { BrowserFileSystem }; diff --git a/src/io/write/index.ts b/src/io/write/index.ts new file mode 100644 index 0000000..b5d15e2 --- /dev/null +++ b/src/io/write/index.ts @@ -0,0 +1,12 @@ +/** + * IO Write module - handles writing splat data to various destinations. + */ + +// Browser file system +export { BrowserFileSystem } from './browser-file-system'; + +// Writer utilities +export { + GZipWriter, + ProgressWriter +} from './writer'; diff --git a/src/io/write/writer.ts b/src/io/write/writer.ts new file mode 100644 index 0000000..574dd29 --- /dev/null +++ b/src/io/write/writer.ts @@ -0,0 +1,79 @@ +/** + * Writer utilities for splat serialization. + */ + +import type { Writer } from '@playcanvas/splat-transform'; + +/** + * Compress the incoming stream with gzip. + */ +class GZipWriter implements Writer { + write: (data: Uint8Array) => Promise; + close: () => Promise; + + private cursor = 0; + + get bytesWritten(): number { + return this.cursor; + } + + constructor(writer: Writer) { + const stream = new CompressionStream('gzip'); + const streamWriter = stream.writable.getWriter(); + const streamReader = stream.readable.getReader(); + + // hook up the reader side of the compressed stream + const reader = (async () => { + while (true) { + const { done, value } = await streamReader.read(); + if (done) break; + await writer.write(value); + } + })(); + + this.write = async (data: Uint8Array) => { + this.cursor += data.byteLength; + await streamWriter.ready; + await streamWriter.write(data as unknown as ArrayBuffer); + }; + + this.close = async () => { + // close the writer, we're done + await streamWriter.close(); + + // wait for the reader to finish sending data + await reader; + }; + } +} + +/** + * Wrapper that tracks write progress. + */ +class ProgressWriter implements Writer { + write: (data: Uint8Array) => Promise; + close: () => void; + + private cursor = 0; + + get bytesWritten(): number { + return this.cursor; + } + + constructor(writer: Writer, totalBytes: number, progress?: (progress: number, total: number) => void) { + this.write = async (data: Uint8Array) => { + this.cursor += data.byteLength; + await writer.write(data); + progress?.(this.cursor, totalBytes); + }; + + this.close = () => { + if (this.cursor !== totalBytes) { + throw new Error(`ProgressWriter: expected ${totalBytes} bytes, but wrote ${this.cursor} bytes`); + } + progress?.(this.cursor, totalBytes); + }; + } +} + +export { GZipWriter, ProgressWriter }; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4b1e911 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,308 @@ +import { WebPCodec, WorkerQueue } from '@playcanvas/splat-transform'; +import { Color, createGraphicsDevice } from 'playcanvas'; + +import { registerCameraPosesEvents } from './camera-poses'; +import { CommandQueue } from './command-queue'; +import { registerDocEvents } from './doc'; +import { EditHistory } from './edit-history'; +import { registerEditorEvents } from './editor'; +import { Events } from './events'; +import { initFileHandler } from './file-handler'; +import { registerIframeApi } from './iframe-api'; +import { registerPreferences } from './preferences'; +import { registerPublishEvents } from './publish'; +import { registerRenderEvents } from './render'; +import { Scene } from './scene'; +import { getSceneConfig } from './scene-config'; +import { registerSelectionEvents } from './selection'; +import { registerSequenceEvents } from './sequence'; +import { ShortcutManager } from './shortcut-manager'; +import { registerTimelineEvents } from './timeline'; +import { BoxSelection } from './tools/box-selection'; +import { BrushSelection } from './tools/brush-selection'; +import { EyedropperSelection } from './tools/eyedropper-selection'; +import { FloodSelection } from './tools/flood-selection'; +import { LassoSelection } from './tools/lasso-selection'; +import { MeasureTool } from './tools/measure-tool'; +import { MoveTool } from './tools/move-tool'; +import { PolygonSelection } from './tools/polygon-selection'; +import { RectSelection } from './tools/rect-selection'; +import { RotateTool } from './tools/rotate-tool'; +import { ScaleTool } from './tools/scale-tool'; +import { SphereSelection } from './tools/sphere-selection'; +import { ToolManager } from './tools/tool-manager'; +import { registerTrackManagerEvents } from './track-manager'; +import { registerTransformHandlerEvents } from './transform-handler'; +import { BoundDimensionsOverlay } from './ui/bound-dimensions-overlay'; +import { EditorUI } from './ui/editor'; +import { i18n } from './ui/localization'; +import { registerSelectCursor } from './ui/select-cursor'; + +declare global { + interface LaunchParams { + readonly files: FileSystemFileHandle[]; + } + + interface Window { + launchQueue: { + setConsumer: (callback: (launchParams: LaunchParams) => void) => void; + }; + scene: Scene; + } +} + +const getURLArgs = () => { + // extract settings from command line in non-prod builds only + const config = {}; + + const apply = (key: string, value: string) => { + let obj: any = config; + key.split('.').forEach((k, i, a) => { + if (i === a.length - 1) { + obj[k] = value; + } else { + if (!obj.hasOwnProperty(k)) { + obj[k] = {}; + } + obj = obj[k]; + } + }); + }; + + const params = new URLSearchParams(window.location.search.slice(1)); + params.forEach((value: string, key: string) => { + apply(key, value); + }); + + return config; +}; + +const main = async () => { + // root events object + const events = new Events(); + + // url + const url = new URL(window.location.href); + + // shared command queue for all async splat work (GPU readbacks + history mutations). + // every consumer that needs ordering relative to other commands enqueues here. + const commandQueue = new CommandQueue(); + + // edit history (uses the shared queue internally) + const editHistory = new EditHistory(events, commandQueue); + + // expose the queue as an event for any module that needs to serialise async work + // alongside history mutations. + events.function('queue', (fn: () => Promise | void) => commandQueue.enqueue(fn)); + + // init localization + await i18n.init(); + + // Configure WebP WASM for SOG format (used for both reading and writing) + WebPCodec.wasmUrl = new URL('static/lib/webp/webp.wasm', document.baseURI).toString(); + + // Run SOG writing inline rather than in worker threads. We don't ship + // splat-transform's worker.mjs, so leaving the pool enabled makes it try to + // spawn a worker that 404s; under SOG's parallel task load it then hangs + // instead of falling back, producing an empty export. + WorkerQueue.maxWorkers = 0; + + // register events that only need the events object (before UI is created) + registerTimelineEvents(events); + registerCameraPosesEvents(events); + registerTrackManagerEvents(events); + registerTransformHandlerEvents(events); + registerPublishEvents(events); + registerIframeApi(events); + + // initialize shortcuts + const shortcutManager = new ShortcutManager(events); + events.function('shortcutManager', () => shortcutManager); + + // editor ui + const editorUI = new EditorUI(events); + + // create the graphics device + const graphicsDevice = await createGraphicsDevice(editorUI.canvas, { + deviceTypes: ['webgl2'], + antialias: false, + depth: false, + stencil: false, + xrCompatible: false, + powerPreference: 'high-performance' + }); + + const urlArgs = getURLArgs(); + + const overrides = [ + urlArgs + ]; + + // resolve scene config + const sceneConfig = getSceneConfig(overrides); + + // construct the manager + const scene = new Scene( + events, + sceneConfig, + editorUI.canvas, + graphicsDevice, + commandQueue + ); + + // colors + const bgClr = new Color(); + const selectedClr = new Color(); + const unselectedClr = new Color(); + const lockedClr = new Color(); + + const setClr = (target: Color, value: Color, event: string) => { + if (!target.equals(value)) { + target.copy(value); + events.fire(event, target); + } + }; + + const setBgClr = (clr: Color) => { + setClr(bgClr, clr, 'bgClr'); + }; + const setSelectedClr = (clr: Color) => { + setClr(selectedClr, clr, 'selectedClr'); + }; + const setUnselectedClr = (clr: Color) => { + setClr(unselectedClr, clr, 'unselectedClr'); + }; + const setLockedClr = (clr: Color) => { + setClr(lockedClr, clr, 'lockedClr'); + }; + + events.on('setBgClr', (clr: Color) => { + setBgClr(clr); + }); + events.on('setSelectedClr', (clr: Color) => { + setSelectedClr(clr); + }); + events.on('setUnselectedClr', (clr: Color) => { + setUnselectedClr(clr); + }); + events.on('setLockedClr', (clr: Color) => { + setLockedClr(clr); + }); + + events.function('bgClr', () => { + return bgClr; + }); + events.function('selectedClr', () => { + return selectedClr; + }); + events.function('unselectedClr', () => { + return unselectedClr; + }); + events.function('lockedClr', () => { + return lockedClr; + }); + + events.on('bgClr', (clr: Color) => { + const cnv = (v: number) => `${Math.max(0, Math.min(255, (v * 255))).toFixed(0)}`; + document.body.style.backgroundColor = `rgba(${cnv(clr.r)},${cnv(clr.g)},${cnv(clr.b)},1)`; + }); + events.on('selectedClr', (clr: Color) => { + scene.forceRender = true; + }); + events.on('unselectedClr', (clr: Color) => { + scene.forceRender = true; + }); + events.on('lockedClr', (clr: Color) => { + scene.forceRender = true; + }); + + // initialize colors from application config + const toColor = (value: { r: number, g: number, b: number, a: number }) => { + return new Color(value.r, value.g, value.b, value.a); + }; + setBgClr(toColor(sceneConfig.bgClr)); + setSelectedClr(toColor(sceneConfig.selectedClr)); + setUnselectedClr(toColor(sceneConfig.unselectedClr)); + setLockedClr(toColor(sceneConfig.lockedClr)); + + // create the mask selection canvas + const maskCanvas = document.createElement('canvas'); + const maskContext = maskCanvas.getContext('2d'); + maskCanvas.setAttribute('id', 'mask-canvas'); + maskContext.globalCompositeOperation = 'copy'; + + const mask = { + canvas: maskCanvas, + context: maskContext + }; + + // tool manager + const toolManager = new ToolManager(events); + toolManager.register('rectSelection', new RectSelection(events, editorUI.toolsContainer.dom)); + toolManager.register('brushSelection', new BrushSelection(events, editorUI.toolsContainer.dom, mask)); + toolManager.register('floodSelection', new FloodSelection(events, editorUI.toolsContainer.dom, mask, editorUI.canvasContainer)); + toolManager.register('polygonSelection', new PolygonSelection(events, editorUI.toolsContainer.dom, mask)); + toolManager.register('lassoSelection', new LassoSelection(events, editorUI.toolsContainer.dom, mask)); + toolManager.register('sphereSelection', new SphereSelection(events, scene, editorUI.canvasContainer)); + toolManager.register('boxSelection', new BoxSelection(events, scene, editorUI.canvasContainer)); + toolManager.register('eyedropperSelection', new EyedropperSelection(events, editorUI.toolsContainer.dom, editorUI.canvasContainer)); + toolManager.register('move', new MoveTool(events, scene)); + toolManager.register('rotate', new RotateTool(events, scene)); + toolManager.register('scale', new ScaleTool(events, scene)); + toolManager.register('measure', new MeasureTool(events, scene, editorUI.toolsContainer.dom, editorUI.canvasContainer)); + + const boundDimensionsOverlay = new BoundDimensionsOverlay(events, scene, editorUI.canvasContainer); + + editorUI.toolsContainer.dom.appendChild(maskCanvas); + + // show the active selection op (add/remove/intersect) at the cursor + registerSelectCursor(events, editorUI.toolsContainer.dom); + + window.scene = scene; + + // register events that need scene or other dependencies + registerEditorEvents(events, editHistory, scene); + registerSelectionEvents(events, scene); + registerSequenceEvents(events, scene); + registerDocEvents(scene, events); + registerRenderEvents(scene, events); + initFileHandler(scene, events, editorUI.appContainer.dom); + + // apply stored user preferences and start capturing changes to them. + // registered after the boot-time initialization events above so they are + // never captured as user changes. + registerPreferences(events, sceneConfig, urlArgs); + + // load async models + scene.start(); + + // handle load params + const loadList = url.searchParams.getAll('load'); + const filenameList = url.searchParams.getAll('filename'); + for (const [i, value] of loadList.entries()) { + const decoded = decodeURIComponent(value); + const filename = i < filenameList.length ? + decodeURIComponent(filenameList[i]) : + decoded.split('/').pop(); + + await events.invoke('import', [{ + filename, + url: decoded + }]); + } + + + // handle OS-based file association in PWA mode + if ('launchQueue' in window) { + window.launchQueue.setConsumer(async (launchParams: LaunchParams) => { + for (const file of launchParams.files) { + await events.invoke('import', [{ + filename: file.name, + contents: await file.getFile() + }]); + } + }); + } +}; + +export { main }; diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..7d1a88b --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "SuperSplat", + "short_name": "SuperSplat", + "description": "SuperSplat is an advanced browser-based editor for manipulating and optimizing 3D Gaussian Splats. It is open source and engine agnostic.", + "display": "fullscreen", + "start_url": "./", + "scope": "./", + "background_color": "#ffffff", + "icons": [ + { + "src": "./static/icons/logo-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./static/icons/logo-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "screenshots": [ + { + "src": "./static/images/screenshot-narrow.jpg", + "type": "image/jpeg", + "sizes": "2160x3840", + "form_factor": "narrow" + }, + { + "src": "./static/images/screenshot-wide.jpg", + "type": "image/jpeg", + "sizes": "3840x2160", + "form_factor": "wide" + } + ], + "file_handlers": [ + { + "action": "./", + "accept": { + "application/ply": [".ply"] + } + } + ] +} \ No newline at end of file diff --git a/src/outline.ts b/src/outline.ts new file mode 100644 index 0000000..7e03e21 --- /dev/null +++ b/src/outline.ts @@ -0,0 +1,61 @@ +import { + BlendState, + Layer +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { vertexShader, fragmentShader } from './shaders/outline-shader'; +import { ShaderQuad, SimpleRenderPass } from './utils/simple-render-pass'; + +class Outline extends Element { + shaderQuad: ShaderQuad; + renderPass: SimpleRenderPass; + enabled = true; + + constructor() { + super(ElementType.other); + } + + add() { + const device = this.scene.app.graphicsDevice; + + this.shaderQuad = new ShaderQuad(device, vertexShader, fragmentShader, 'apply-outline'); + this.renderPass = new SimpleRenderPass(device, this.shaderQuad, { + blendState: BlendState.ALPHABLEND + }); + + const clr = [1, 1, 1, 1]; + + const { camera, events } = this.scene; + + camera.camera.on('postRenderLayer', (layer: Layer, transparent: boolean) => { + // only apply when outline mode is enabled + if (!this.enabled || !events.invoke('view.outlineSelection')) { + return; + } + + // apply at the end of the gizmo layer (after overlay renders) + if (layer !== this.scene.gizmoLayer || !transparent) { + return; + } + + events.invoke('selectedClr').toArray(clr); + + this.renderPass.execute({ + srcTexture: camera.workTarget.colorBuffer, + alphaCutoff: events.invoke('camera.mode') === 'rings' ? 0.0 : 0.8, + clr + }); + }); + } + + remove() { + // event listeners are cleaned up when camera is destroyed + } + + onPreRender() { + // no longer need to manage a separate camera + } +} + +export { Outline }; diff --git a/src/pc-app.ts b/src/pc-app.ts new file mode 100644 index 0000000..cb215ce --- /dev/null +++ b/src/pc-app.ts @@ -0,0 +1,160 @@ +import { + // platform, + // SoundManager, + // Lightmapper, + // BatchManager, + AppBase, + AppOptions, + // script, + // AnimationComponentSystem, + AnimComponentSystem, + // AudioListenerComponentSystem, + // AudioSourceComponentSystem, + // ButtonComponentSystem, + // CollisionComponentSystem, + // ElementComponentSystem, + // JointComponentSystem, + // LayoutChildComponentSystem, + // LayoutGroupComponentSystem, + // ModelComponentSystem, + // ParticleSystemComponentSystem, + RenderComponentSystem, + // RigidBodyComponentSystem, + // ScreenComponentSystem, + // ScriptLegacyComponentSystem, + // ScrollViewComponentSystem, + // ScrollbarComponentSystem, + // SoundComponentSystem, + // SpriteComponentSystem, + // ZoneComponentSystem, + CameraComponentSystem, + LightComponentSystem, + GSplatComponentSystem, + // ScriptComponentSystem, + RenderHandler, + // AnimationHandler, + AnimClipHandler, + AnimStateGraphHandler, + // AudioHandler, + // BinaryHandler, + ContainerHandler, + // CssHandler, + CubemapHandler, + // FolderHandler, + // FontHandler, + GSplatHandler, + // HierarchyHandler, + // HtmlHandler, + // JsonHandler, + // MaterialHandler, + // ModelHandler, + // SceneHandler, + // ScriptHandler, + // ShaderHandler, + // SpriteHandler, + // TemplateHandler, + // TextHandler, + // TextureAtlasHandler, + TextureHandler + // XrManager +} from 'playcanvas'; + +class PCApp extends AppBase { + constructor(canvas: HTMLCanvasElement, options: any) { + super(canvas); + + const appOptions = new AppOptions(); + + appOptions.graphicsDevice = options.graphicsDevice; + this.addComponentSystems(appOptions); + this.addResourceHandles(appOptions); + + appOptions.elementInput = options.elementInput; + appOptions.keyboard = options.keyboard; + appOptions.mouse = options.mouse; + appOptions.touch = options.touch; + appOptions.gamepads = options.gamepads; + + appOptions.scriptPrefix = options.scriptPrefix; + appOptions.assetPrefix = options.assetPrefix; + appOptions.scriptsOrder = options.scriptsOrder; + + // appOptions.soundManager = new SoundManager(options); + // appOptions.lightmapper = Lightmapper; + // appOptions.batchManager = BatchManager; + // appOptions.xr = XrManager; + + this.init(appOptions); + } + + addComponentSystems(appOptions: AppOptions) { + appOptions.componentSystems = [ + // RigidBodyComponentSystem, + // CollisionComponentSystem, + // JointComponentSystem, + // AnimationComponentSystem, + // @ts-ignore + AnimComponentSystem, + // ModelComponentSystem, + // @ts-ignore + RenderComponentSystem, + // @ts-ignore + CameraComponentSystem, + // @ts-ignore + LightComponentSystem, + // script.legacy ? ScriptLegacyComponentSystem : ScriptComponentSystem, + // AudioSourceComponentSystem, + // SoundComponentSystem, + // AudioListenerComponentSystem, + // ParticleSystemComponentSystem, + // ScreenComponentSystem, + // ElementComponentSystem, + // ButtonComponentSystem, + // ScrollViewComponentSystem, + // ScrollbarComponentSystem, + // SpriteComponentSystem, + // LayoutGroupComponentSystem, + // LayoutChildComponentSystem, + // ZoneComponentSystem, + GSplatComponentSystem + ]; + } + + addResourceHandles(appOptions: AppOptions) { + appOptions.resourceHandlers = [ + // @ts-ignore + RenderHandler, + // AnimationHandler, + // @ts-ignore + AnimClipHandler, + // @ts-ignore + AnimStateGraphHandler, + // ModelHandler, + // MaterialHandler, + // @ts-ignore + TextureHandler, + // TextHandler, + // JsonHandler, + // AudioHandler, + // ScriptHandler, + // SceneHandler, + // @ts-ignore + CubemapHandler, + // HtmlHandler, + // CssHandler, + // ShaderHandler, + // HierarchyHandler, + // FolderHandler, + // FontHandler, + // BinaryHandler, + // TextureAtlasHandler, + // SpriteHandler, + // TemplateHandler, + // @ts-ignore + ContainerHandler, + GSplatHandler + ]; + } +} + +export { PCApp }; diff --git a/src/picker.ts b/src/picker.ts new file mode 100644 index 0000000..e0a0138 --- /dev/null +++ b/src/picker.ts @@ -0,0 +1,251 @@ +import { + BLENDEQUATION_ADD, + BLENDMODE_ONE, + BLENDMODE_ZERO, + BLENDMODE_ONE_MINUS_SRC_ALPHA, + BlendState, + Color, + GraphicsDevice, + RenderPassPicker, + RenderTarget +} from 'playcanvas'; + +import { ElementType } from './element'; +import { Scene } from './scene'; +import { Splat } from './splat'; + +const idClearColor = new Color(1, 1, 1, 1); +const depthClearColor = new Color(0, 0, 0, 1); + +// Shared buffer for half-to-float conversion +const float32 = new Float32Array(1); +const uint32 = new Uint32Array(float32.buffer); + +// Convert 16-bit half-float to 32-bit float using bit manipulation +const half2Float = (h: number): number => { + const sign = (h & 0x8000) << 16; // Move sign to bit 31 + const exponent = (h & 0x7C00) >> 10; // Extract 5-bit exponent + const mantissa = h & 0x03FF; // Extract 10-bit mantissa + + if (exponent === 0) { + if (mantissa === 0) { + // Zero + uint32[0] = sign; + } else { + // Denormalized: convert to normalized float32 + let e = -1; + let m = mantissa; + do { + e++; + m <<= 1; + } while ((m & 0x0400) === 0); + uint32[0] = sign | ((127 - 15 - e) << 23) | ((m & 0x03FF) << 13); + } + } else if (exponent === 31) { + // Infinity or NaN + uint32[0] = sign | 0x7F800000 | (mantissa << 13); + } else { + // Normalized: adjust exponent bias from 15 to 127 + uint32[0] = sign | ((exponent + 127 - 15) << 23) | (mantissa << 13); + } + + return float32[0]; +}; + +class Picker { + private device: GraphicsDevice; + private scene: Scene; + + // Render targets (provided by camera) + private depthRenderTarget: RenderTarget | null = null; + private idRenderTarget: RenderTarget | null = null; + + // Render pass (shared for depth and ID picking) + private renderPass: RenderPassPicker; + + // Blend state for depth accumulation + private depthBlendState: BlendState; + + constructor(scene: Scene) { + this.scene = scene; + this.device = scene.graphicsDevice; + + // Create shared render pass for picking + this.renderPass = new RenderPassPicker(this.device, this.scene.app.renderer); + + // Blend state for depth accumulation: + // RGB: additive depth accumulation (ONE, ONE_MINUS_SRC_ALPHA) + // Alpha: multiplicative transmittance (ZERO, ONE_MINUS_SRC_ALPHA) -> T = T * (1 - alpha) + this.depthBlendState = new BlendState( + true, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA, // RGB blend + BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE_MINUS_SRC_ALPHA // Alpha blend (transmittance) + ); + } + + // Set render targets from camera + setRenderTargets(depthRT: RenderTarget, idRT: RenderTarget) { + this.depthRenderTarget = depthRT; + this.idRenderTarget = idRT; + } + + // Prepare for ID picking by rendering the specified splat + prepareId(splat: Splat, mode: 'add' | 'remove' | 'set' | 'intersect') { + if (!this.idRenderTarget) { + return; + } + + const { splatLayer } = this.scene; + + // Hide non-selected elements + const splats = this.scene.getElementsByType(ElementType.splat) as Splat[]; + splats.forEach((s) => { + s.entity.enabled = s === splat; + }); + + // 'intersect' picks against the currently-selected set (same render as + // 'remove') so unselected splats can't occlude selected ones and skew it. + const pickOp = mode === 'intersect' ? 'remove' : mode; + + // Set picker uniforms + this.device.scope.resolve('pickOp').setValue(['add', 'remove', 'set'].indexOf(pickOp)); + this.device.scope.resolve('pickMode').setValue(0); + + // Render ID picking pass + const emptyMap = new Map(); + this.renderPass.blendState = BlendState.NOBLEND; + this.renderPass.init(this.idRenderTarget); + this.renderPass.setClearColor(idClearColor); + this.renderPass.update(this.scene.camera.camera, this.scene.app.scene, [splatLayer], emptyMap, false); + this.renderPass.render(); + + // Re-enable all splats + splats.forEach((s) => { + s.entity.enabled = true; + }); + } + + // Read single splat ID at normalized screen position (after prepareId) + async readId(x: number, y: number): Promise { + if (!this.idRenderTarget) { + return -1; + } + // For single pixel read, use a minimal normalized size + const rt = this.idRenderTarget; + const ids = await this.readIds(x, y, 1 / rt.width, 1 / rt.height); + return ids[0]; + } + + // Read rectangle of splat IDs using normalized coordinates (0-1 range) (after prepareId) + async readIds(x: number, y: number, width: number, height: number): Promise { + if (!this.idRenderTarget) { + return []; + } + + const rt = this.idRenderTarget; + const colorBuffer = rt.colorBuffer; + + // Convert normalized coordinates to render target pixels + const px = Math.floor(x * rt.width); + const py = Math.floor(y * rt.height); + const pw = Math.max(1, Math.ceil((x + width) * rt.width) - px); + const ph = Math.max(1, Math.ceil((y + height) * rt.height) - py); + + // Flip Y for texture read on WebGL (texture origin is bottom-left) + const texY = this.device.isWebGL2 ? rt.height - py - ph : py; + + // Read pixels using texture.read() API + const pixels = await colorBuffer.read(px, texY, pw, ph, { + renderTarget: rt, + immediate: false + }); + + const result: number[] = []; + for (let i = 0; i < pw * ph; i++) { + // Use >>> 0 to convert signed 32-bit to unsigned (so 0xffffffff instead of -1) + result.push( + (pixels[i * 4] | + (pixels[i * 4 + 1] << 8) | + (pixels[i * 4 + 2] << 16) | + (pixels[i * 4 + 3] << 24)) >>> 0 + ); + } + + return result; + } + + // Prepare for depth picking by rendering the specified splat + prepareDepth(splat: Splat) { + if (!this.depthRenderTarget) { + return; + } + + const { scene } = this; + const { app, camera, splatLayer } = scene; + const emptyMap = new Map(); + + // Hide non-selected elements + const splats = scene.getElementsByType(ElementType.splat) as Splat[]; + splats.forEach((s) => { + s.entity.enabled = s === splat; + }); + + // Set depth estimation mode uniform + this.device.scope.resolve('pickOp').setValue(2); // 'set' mode - don't skip any visible splats + this.device.scope.resolve('pickMode').setValue(1); + + // Render scene with depth pass + this.renderPass.blendState = this.depthBlendState; + this.renderPass.init(this.depthRenderTarget); + this.renderPass.setClearColor(depthClearColor); + this.renderPass.update(camera.camera, app.scene, [splatLayer], emptyMap, false); + this.renderPass.render(); + + // Re-enable all splats + splats.forEach((s) => { + s.entity.enabled = true; + }); + } + + // Read normalized depth (0-1) at normalized screen position (0-1 range) (after prepareDepth) + async readDepth(x: number, y: number): Promise { + if (!this.depthRenderTarget) { + return null; + } + + const rt = this.depthRenderTarget; + const colorBuffer = rt.colorBuffer; + + // Convert normalized coordinates to render target pixels + const px = Math.floor(x * rt.width); + const py = Math.floor(y * rt.height); + + // Flip Y for texture read on WebGL (texture origin is bottom-left) + const texY = this.device.isWebGL2 ? rt.height - py - 1 : py; + + // Read the pixel using Texture.read() which handles RGBA16F format + const pixels = await colorBuffer.read(px, texY, 1, 1, { renderTarget: rt }); + + // Convert half-float values to floats + // R channel: accumulated depth * alpha + // A channel: transmittance (1 - alpha) + const r = half2Float(pixels[0]); + const transmittance = half2Float(pixels[3]); + const alpha = 1 - transmittance; + + // Check alpha (transmittance close to 1 means nothing visible) + if (alpha < 1e-6) { + return null; + } + + // Return normalized depth (0-1 range) + return r / alpha; + } + + // Clean up resources + destroy() { + this.renderPass?.destroy(); + } +} + +export { Picker }; diff --git a/src/pivot.ts b/src/pivot.ts new file mode 100644 index 0000000..5b08c90 --- /dev/null +++ b/src/pivot.ts @@ -0,0 +1,86 @@ +import { Vec3, Quat } from 'playcanvas'; + +import { Events } from './events'; +import { Transform } from './transform'; + +// stores the transform pivot location in world space +// the transform tools (translate, rotate, scale) and transform panel modify this pivot +// then the active transform handler applies the changes to the current selection. +class Pivot { + transform = new Transform(); + + place: (transform: Transform) => void; + start: () => void; + move: (transform: Transform) => void; + moveTRS: (position: Vec3, rotation: Quat, scale: Vec3) => void; + end: () => void; + + constructor(events: Events) { + + this.place = (transform: Transform) => { + if (!this.transform.equals(transform)) { + this.transform.copy(transform); + + events.fire('pivot.placed', this); + } + }; + + this.start = () => { + events.fire('pivot.started', this); + }; + + this.move = (transform: Transform) => { + if (!this.transform.equals(transform)) { + this.transform.copy(transform); + + events.fire('pivot.moved', this); + } + }; + + this.moveTRS = (position: Vec3, rotation: Quat, scale: Vec3) => { + if (!this.transform.equalsTRS(position, rotation, scale)) { + this.transform.set(position, rotation, scale); + + events.fire('pivot.moved', this); + } + }; + + this.end = () => { + events.fire('pivot.ended', this); + }; + } +} + +type PivotOrigin = 'center' | 'boundCenter'; + +const registerPivotEvents = (events: Events) => { + const pivot = new Pivot(events); + + events.function('pivot', () => { + return pivot; + }); + + // pivot mode + let origin: PivotOrigin = 'center'; + + const setOrigin = (o: PivotOrigin) => { + if (o !== origin) { + origin = o; + events.fire('pivot.origin', origin); + } + }; + + events.function('pivot.origin', () => { + return origin; + }); + + events.on('pivot.setOrigin', (o: PivotOrigin) => { + setOrigin(o === 'center' ? 'center' : 'boundCenter'); + }); + + events.on('pivot.toggleOrigin', () => { + setOrigin(origin === 'center' ? 'boundCenter' : 'center'); + }); +}; + +export { registerPivotEvents, Pivot }; diff --git a/src/preferences.ts b/src/preferences.ts new file mode 100644 index 0000000..24914f3 --- /dev/null +++ b/src/preferences.ts @@ -0,0 +1,215 @@ +import { Color } from 'playcanvas'; + +import { Events } from './events'; +import { SceneConfig } from './scene-config'; +import { i18n } from './ui/localization'; + +const storageKey = 'supersplat:preferences'; +const storageVersion = 1; + +type PrefValue = boolean | number | string | number[]; + +type Descriptor = { + // storage key, which doubles as the notify event fired when the setting changes + key: string; + // command event used to apply a value + setCommand: string; + // the sceneConfig path a url query parameter would override. when the url + // specifies this setting, the stored preference is ignored for the session. + urlPath?: string; + // default event payload. reads the resolved sceneConfig, which already has + // url overrides folded in, so url parameters naturally take precedence. + getDefault: () => any; + // validate a stored (json) value + validate: (value: PrefValue) => boolean; + // stored (json) value -> event payload + toEvent?: (value: PrefValue) => any; + // notify payload -> stored (json) value + fromEvent?: (value: any) => PrefValue; +}; + +// mirror the kebab-case matching used by scene-config's Params so url +// detection agrees with how getSceneConfig consumed the query parameters +const kebabize = (s: string) => s.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase()); + +const registerPreferences = (events: Events, config: SceneConfig, urlArgs: any) => { + // returns true if the url query args specify the given config path + const urlHas = (path?: string) => { + if (!path) { + return false; + } + const lookup = (segments: string[]) => { + let obj = urlArgs; + for (const segment of segments) { + if (typeof obj !== 'object' || obj === null || !obj.hasOwnProperty(segment)) { + return undefined; + } + obj = obj[segment]; + } + return obj; + }; + const parts = path.split('.'); + return lookup(parts.map(kebabize)) !== undefined || lookup(parts) !== undefined; + }; + + const isBool = (v: PrefValue) => typeof v === 'boolean'; + const isNumber = (min: number, max: number) => (v: PrefValue) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max; + const isEnum = (options: string[]) => (v: PrefValue) => typeof v === 'string' && options.includes(v); + const isColor = (v: PrefValue) => Array.isArray(v) && v.length === 4 && v.every(c => typeof c === 'number' && c >= 0 && c <= 1); + + const color = (key: string, setCommand: string, getDefault: () => { r: number, g: number, b: number, a: number }): Descriptor => ({ + key, + setCommand, + urlPath: key, + getDefault: () => { + const c = getDefault(); + return new Color(c.r, c.g, c.b, c.a); + }, + validate: isColor, + toEvent: (v: PrefValue) => { + const a = v as number[]; + return new Color(a[0], a[1], a[2], a[3]); + }, + fromEvent: (c: Color) => [c.r, c.g, c.b, c.a] + }); + + // the order of this table is the application order. camera.mode and + // camera.overlay must come after camera.splatSize because the view panel's + // centers size slider side-fires setOverlay/setMode when its value changes. + const descriptors: Descriptor[] = [ + color('bgClr', 'setBgClr', () => config.bgClr), + color('selectedClr', 'setSelectedClr', () => config.selectedClr), + color('unselectedClr', 'setUnselectedClr', () => config.unselectedClr), + color('lockedClr', 'setLockedClr', () => config.lockedClr), + { key: 'camera.tonemapping', setCommand: 'camera.setTonemapping', urlPath: 'camera.toneMapping', getDefault: () => config.camera.toneMapping, validate: isEnum(['linear', 'neutral', 'aces', 'aces2', 'filmic', 'hejl']) }, + { key: 'camera.fovDolly', setCommand: 'camera.setFovDolly', getDefault: () => false, validate: isBool }, + { key: 'camera.fov', setCommand: 'camera.setFov', urlPath: 'camera.fov', getDefault: () => config.camera.fov, validate: isNumber(10, 120) }, + { key: 'view.bands', setCommand: 'view.setBands', urlPath: 'show.shBands', getDefault: () => config.show.shBands, validate: v => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 3 }, + { key: 'camera.flySpeed', setCommand: 'camera.setFlySpeed', getDefault: () => 1, validate: isNumber(0.1, 30) }, + { key: 'camera.splatSize', setCommand: 'camera.setSplatSize', getDefault: () => 2, validate: isNumber(0, 10) }, + { key: 'view.centersUseGaussianColor', setCommand: 'view.setCentersUseGaussianColor', getDefault: () => false, validate: isBool }, + { key: 'view.outlineSelection', setCommand: 'view.setOutlineSelection', getDefault: () => false, validate: isBool }, + { key: 'grid.visible', setCommand: 'grid.setVisible', urlPath: 'show.grid', getDefault: () => config.show.grid, validate: isBool }, + { key: 'camera.bound', setCommand: 'camera.setBound', urlPath: 'show.bound', getDefault: () => config.show.bound, validate: isBool }, + { key: 'camera.boundDimensions', setCommand: 'camera.setBoundDimensions', urlPath: 'show.boundDimensions', getDefault: () => config.show.boundDimensions, validate: isBool }, + { key: 'camera.showPoses', setCommand: 'camera.setShowPoses', urlPath: 'show.cameraPoses', getDefault: () => config.show.cameraPoses, validate: isBool }, + { key: 'camera.showInfo', setCommand: 'camera.setShowInfo', urlPath: 'show.cameraInfo', getDefault: () => config.show.cameraInfo, validate: isBool }, + { key: 'camera.mode', setCommand: 'camera.setMode', getDefault: () => 'centers', validate: isEnum(['centers', 'rings']) }, + { key: 'camera.overlay', setCommand: 'camera.setOverlay', urlPath: 'camera.overlay', getDefault: () => config.camera.overlay, validate: isBool }, + { key: 'camera.controlMode', setCommand: 'camera.setControlMode', getDefault: () => 'orbit', validate: isEnum(['orbit', 'fly']) } + ]; + + // load and validate the stored blob. unknown keys and invalid values are + // discarded so preferences survive settings being added, removed or + // reshaped between releases. + const load = (): Record => { + const result: Record = {}; + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const blob = JSON.parse(raw); + if (blob?.version === storageVersion && typeof blob.values === 'object' && blob.values) { + for (const descriptor of descriptors) { + const value = blob.values[descriptor.key]; + if (value !== undefined && descriptor.validate(value)) { + result[descriptor.key] = value; + } + } + } + } + } catch (e) { + // corrupt or inaccessible storage - fall back to defaults + } + return result; + }; + + const values = load(); + + // coalesce bursts of changes (slider drags, color picking) into a single + // write per task + let storageWarned = false; + let writeScheduled = false; + const scheduleWrite = () => { + if (writeScheduled) { + return; + } + writeScheduled = true; + queueMicrotask(() => { + writeScheduled = false; + try { + localStorage.setItem(storageKey, JSON.stringify({ version: storageVersion, values })); + } catch (e) { + if (!storageWarned) { + storageWarned = true; + console.warn('preferences will not persist: localStorage is unavailable'); + } + } + }); + }; + + // while suspended, changes are not captured. document loading and camera + // pose playback suspend capture so programmatic changes don't overwrite + // stored preferences. + let suspendDepth = 0; + events.on('preferences.suspend', () => { + suspendDepth++; + }); + events.on('preferences.resume', () => { + suspendDepth = Math.max(0, suspendDepth - 1); + }); + + // apply stored preferences (or defaults) to the live state. every setting + // is applied so side effects between settings self-heal, and the resolved + // sceneConfig already contains url overrides, which win over stored values. + const apply = () => { + events.fire('preferences.suspend'); + try { + for (const descriptor of descriptors) { + const stored = urlHas(descriptor.urlPath) ? undefined : values[descriptor.key]; + const payload = stored !== undefined ? (descriptor.toEvent ? descriptor.toEvent(stored) : stored) : descriptor.getDefault(); + events.fire(descriptor.setCommand, payload); + } + } finally { + events.fire('preferences.resume'); + } + }; + + apply(); + + // capture user changes. registered after the initial apply so it never + // observes its own writes or the boot-time initialization events. + descriptors.forEach((descriptor) => { + events.on(descriptor.key, (value: any) => { + if (suspendDepth > 0) { + return; + } + const stored = descriptor.fromEvent ? descriptor.fromEvent(value) : value; + if (!descriptor.validate(stored)) { + return; + } + if (JSON.stringify(values[descriptor.key]) === JSON.stringify(stored)) { + return; + } + values[descriptor.key] = stored; + scheduleWrite(); + }); + }); + + // re-apply stored preferences (used by File > New) + events.on('preferences.apply', () => { + apply(); + }); + + // clear stored preferences and restore defaults. language lives in its + // own store (i18nextLng) but its default is 'automatic', so reset it too. + events.on('preferences.reset', () => { + for (const key in values) { + delete values[key]; + } + scheduleWrite(); + apply(); + i18n.setLanguage(null); + }); +}; + +export { registerPreferences }; diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..f9dc845 --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,409 @@ +import type { FileSystem, Writer } from '@playcanvas/splat-transform'; + +import { Events } from './events'; +import { GZipWriter } from './io'; +import { serializePly, ExperienceSettings, SerializeSettings } from './splat-serialize'; +import { i18n } from './ui/localization'; + +/** + * Simple FileSystem wrapper around a single Writer. + * Used for cases like GZip compression where we need FileSystem semantics + * but only have a single Writer to wrap. + * The wrapper makes close() a no-op since the caller manages the writer lifecycle. + */ +class WriterFileSystem implements FileSystem { + private writer: Writer; + + constructor(writer: Writer) { + this.writer = writer; + } + + createWriter(_filename: string): Writer { + // Return a wrapper that delegates write but makes close a no-op + // The caller is responsible for closing the underlying writer + const inner = this.writer; + return { + get bytesWritten() { + return inner.bytesWritten; + }, + write: (data: Uint8Array) => inner.write(data), + close: () => Promise.resolve() + }; + } + + mkdir(_path: string): Promise { + return Promise.resolve(); + } +} + +type User = { + id: string; + username: string; + token: string; + apiServer: string; +}; + +type Scene = { + hash: string; + title: string; + description: string; + format: string; +}; + +type UserStatus = { + user: User; + scenes: Scene[]; +}; + +type PublishSettings = { + user: User; + title: string; + description: string; + listed: boolean; + serializeSettings: SerializeSettings; + experienceSettings: ExperienceSettings; + overwriteHash?: string; + overrideModel?: boolean; + overrideAnimation?: boolean; + generateLods: boolean; +}; + +const origin = location.origin; + +// check whether user is logged in +const fetchUser = async () => { + try { + const urlResponse = await fetch(`${origin}/api/id`); + return urlResponse.ok && (await urlResponse.json() as User); + } catch (e) { + return null; + } +}; + +const fetchSceneList = async (user: User) => { + const response = await fetch(`${user.apiServer}/splats?limit=128`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.token}` + } + }); + + if (!response.ok) { + throw new Error(`failed to get scene list (${response.statusText})`); + } + + return (await response.json()).result as Scene[]; +}; + +const fetchSceneSettings = async (user: User, sceneHash: string): Promise => { + const response = await fetch(`${user.apiServer}/splats/${sceneHash}/settings`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.token}` + } + }); + if (!response.ok) { + throw new Error(`failed to fetch scene settings (${response.statusText})`); + } + return await response.json() as ExperienceSettings; +}; + +const updateSceneSettings = async (user: User, sceneHash: string, settings: ExperienceSettings) => { + const response = await fetch(`${user.apiServer}/splats/${sceneHash}/settings`, { + method: 'PUT', + body: JSON.stringify(settings), + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`failed to update scene settings (${response.statusText})`); + } + return response; +}; + +class PublishWriter implements Writer { + write: (data: Uint8Array) => void; + close: () => Promise; + + private cursor = 0; + + get bytesWritten(): number { + return this.cursor; + } + + static async create(publishSettings: PublishSettings) { + const { user } = publishSettings; + + const filename = 'scene.ply'; + + // start upload + const startResponse = await fetch(`${user.apiServer}/upload/start-upload`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ fileName: filename }) + }); + + const startJson = await startResponse.json(); + + const result = new PublishWriter(); + + const uploadBuf = new Uint8Array(10 * 1024 * 1024); // 10MB buffer + const parts: { PartNumber: number, ETag: string }[] = []; + let partNumber = 1; + let cursor = 0; + + const upload = async () => { + if (cursor === 0) return; + + // get signed url for this part + const urlResponse = await fetch(`${user.apiServer}/upload/signed-urls`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + uploadId: startJson.uploadId, + key: startJson.key, + parts: 1, + partBase: partNumber + }) + }); + + if (!urlResponse.ok) { + throw new Error(`failed to get signed url (${urlResponse.statusText})`); + } + + const urlJson = await urlResponse.json(); + + const uploadResponse = await fetch(urlJson.signedUrls[0], { + method: 'PUT', + body: uploadBuf.slice(0, cursor), + headers: { + 'Content-Type': 'application/octet-stream' + } + }); + + if (!uploadResponse.ok) { + throw new Error(`failed to upload data (${uploadResponse.statusText})`); + } + + parts.push({ + PartNumber: partNumber, + ETag: uploadResponse.headers.get('etag').replace(/^"|"$/g, '') + }); + + cursor = 0; + partNumber++; + }; + + result.write = async (data: Uint8Array) => { + let readcursor = 0; + + while (data.byteLength - readcursor > 0) { + const readSize = data.byteLength - readcursor; + const writeSize = uploadBuf.byteLength - cursor; + const copySize = Math.min(readSize, writeSize); + + uploadBuf.set(data.subarray(readcursor, readcursor + copySize), cursor); + + readcursor += copySize; + cursor += copySize; + + if (cursor === uploadBuf.byteLength) { + await upload(); + } + } + result.cursor += data.byteLength; + }; + + result.close = async () => { + // final upload + await upload(); + + // complete the multipart upload + const completeResult = await fetch(`${user.apiServer}/upload/complete-upload`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + uploadId: startJson.uploadId, + key: startJson.key, + parts + }) + }); + + if (!completeResult.ok) { + throw new Error(`failed to complete upload (${completeResult.statusText})`); + } + + const completeJson = await completeResult.json(); + + const publishFormat = publishSettings.generateLods ? 'ssog' : 'sog'; + + const doPublish = () => fetch(`${user.apiServer}/splats/publish`, { + method: 'POST', + body: JSON.stringify({ + s3Key: startJson.key, + title: publishSettings.title, + description: publishSettings.description, + listed: publishSettings.listed, + settings: publishSettings.experienceSettings, + sourceFormat: 'ply', + publishFormat + }), + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + } + }); + + const doRepublish = () => fetch(`${user.apiServer}/splats/${publishSettings.overwriteHash}/republish`, { + method: 'PUT', + body: JSON.stringify({ + s3Key: startJson.key, + settings: publishSettings.experienceSettings, + sourceFormat: 'ply', + publishFormat + }), + headers: { + 'Authorization': `Bearer ${user.token}`, + 'Content-Type': 'application/json' + } + }); + + const publishResponse = await (publishSettings.overwriteHash ? doRepublish() : doPublish()); + + if (!publishResponse.ok) { + let msg; + try { + const err = await publishResponse.json(); + msg = err.error ?? msg; + } catch (e) { + msg = 'Failed to publish'; + } + + throw new Error(msg); + } + + return await publishResponse.json(); + }; + + return result; + } +} + +const registerPublishEvents = (events: Events) => { + + events.function('publish.userStatus', async () => { + const user = await fetchUser(); + if (!user || !user.username) { + return null; + } + const scenes = await fetchSceneList(user); + return { user, scenes }; + }); + + events.function('scene.publish', async (publishSettings: PublishSettings) => { + try { + events.fire('progressStart', 'Publishing...'); + events.fire('progressUpdate', { + text: i18n.t('popup.publish.converting', { ellipsis: true }), + progress: 0 + }); + + // delay to allow spinner to show (hopefully 10ms is enough) + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + const { overwriteHash, overrideAnimation } = publishSettings; + const overrideModel = publishSettings.overrideModel ?? true; + + const mergeAnimation = (target: ExperienceSettings, source: ExperienceSettings) => { + target.animTracks = source.animTracks; + target.startMode = source.startMode; + }; + + if (overwriteHash && !overrideModel) { + if (!overrideAnimation) { + throw new Error('No overrides selected'); + } + + // animation-only update: fetch existing settings, merge animation, PUT settings + const existingSettings = await fetchSceneSettings(publishSettings.user, overwriteHash); + mergeAnimation(existingSettings, publishSettings.experienceSettings); + await updateSceneSettings(publishSettings.user, overwriteHash, existingSettings); + + await events.invoke('showPopup', { + type: 'info', + header: i18n.t('popup.publish.succeeded'), + message: i18n.t('popup.publish.message'), + link: `${origin}/scene/${overwriteHash}/edit` + }); + } else { + // new scene or model override: upload PLY and publish/republish + if (overwriteHash) { + // republishing with model override: merge with existing settings + const existingSettings = await fetchSceneSettings(publishSettings.user, overwriteHash); + if (overrideAnimation) { + mergeAnimation(existingSettings, publishSettings.experienceSettings); + } + publishSettings.experienceSettings = existingSettings; + } + + const progressFunc = (loaded: number, total: number) => { + events.fire('progressUpdate', { + text: i18n.t('popup.publish.uploading', { ellipsis: true }), + progress: 100 * loaded / total + }); + }; + + // create the writer chain: gzip->stream->upload + const publishWriter = await PublishWriter.create(publishSettings); + const gzipWriter = new GZipWriter(publishWriter); + + const splats = events.invoke('scene.splats'); + + // serialize using WriterFileSystem wrapper (close is managed by caller) + const fs = new WriterFileSystem(gzipWriter); + await serializePly(splats, publishSettings.serializeSettings, fs, 'output.ply', progressFunc); + + await gzipWriter.close(); + const response = await publishWriter.close(); + + if (!response) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.publish.failed'), + message: i18n.t('popup.publish.please-try-again') + }); + } else { + await events.invoke('showPopup', { + type: 'info', + header: i18n.t('popup.publish.succeeded'), + message: i18n.t('popup.publish.message'), + link: `${origin}/scene/${response.hash}/edit` + }); + } + } + } catch (error) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.publish.failed'), + message: `'${error.message ?? error}'` + }); + } finally { + events.fire('progressEnd'); + } + }); +}; + +export { PublishSettings, UserStatus, registerPublishEvents }; diff --git a/src/recent-files.ts b/src/recent-files.ts new file mode 100644 index 0000000..d2fd55b --- /dev/null +++ b/src/recent-files.ts @@ -0,0 +1,82 @@ +const DB_NAME = 'supersplat'; +const DB_VERSION = 1; +const STORE_NAME = 'recent-files'; + +interface RecentFile { + handle: FileSystemFileHandle; + name: string; + date: number; +} + +// wrap IDBRequest in a promise +const wrap = (IDBRequest: IDBRequest): Promise => { + return new Promise((resolve, reject) => { + IDBRequest.onsuccess = () => resolve(IDBRequest.result); + IDBRequest.onerror = () => { + console.error('IndexedDB error', IDBRequest.error); + reject(IDBRequest.error); + }; + }); +}; + +class RecentFiles { + db: Promise; + + constructor() { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + // NOTE: for now we store by filename even though files in + // loaded from different directories could have the same name. + // We do this because we can't distinguish files from different + // directories anyway due to File System Access API limitations. + db.createObjectStore(STORE_NAME, { keyPath: 'name' }); + } + }; + this.db = wrap(request); + } + + async add(handle: FileSystemFileHandle) { + const db = await this.db; + + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ + handle: handle, + name: handle.name, + date: Date.now() + }); + + await wrap(request); + } + + async get(): Promise { + const db = await this.db; + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const result = await wrap(store.getAll()) as RecentFile[]; + + // Sort by date descending + result.sort((a, b) => b.date - a.date); + return result; + } + + async clear() { + const db = await this.db; + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + await wrap(store.clear()); + } + + async count(): Promise { + const db = await this.db; + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + return wrap(store.count()); + } +} + +const recentFiles = new RecentFiles(); + +export { recentFiles }; diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..224e043 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,694 @@ +import { WebPCodec } from '@playcanvas/splat-transform'; +import { BufferTarget, EncodedPacket, EncodedVideoPacketSource, MkvOutputFormat, MovOutputFormat, Mp4OutputFormat, Output, StreamTarget, WebMOutputFormat } from 'mediabunny'; +import { Color, path, Quat, Vec3 } from 'playcanvas'; + +import { ElementType } from './element'; +import { EquirectRenderer } from './equirect-renderer'; +import { Events } from './events'; +import { Scene } from './scene'; +import { injectSphericalMetadata } from './spherical-metadata'; +import { Splat } from './splat'; +import { i18n } from './ui/localization'; + +const nullClr = new Color(0, 0, 0, 0); + +// Lookup maps for video output format and codec configuration +const FORMAT_CONFIG: Record Mp4OutputFormat | MovOutputFormat | MkvOutputFormat | WebMOutputFormat; extension: string }> = { + mp4: { create: streaming => new Mp4OutputFormat({ fastStart: streaming ? false : 'in-memory' }), extension: 'mp4' }, + webm: { create: () => new WebMOutputFormat(), extension: 'webm' }, + mov: { create: streaming => new MovOutputFormat({ fastStart: streaming ? false : 'in-memory' }), extension: 'mov' }, + mkv: { create: () => new MkvOutputFormat(), extension: 'mkv' } +}; + +const CODEC_CONFIG: Record string }> = { + h264: { type: 'avc', codec: h => (h < 1080 ? 'avc1.420028' : 'avc1.640033') }, // H.264 Constrained Baseline/High profile + h265: { type: 'hevc', codec: () => 'hev1.1.6.L120.B0' }, // H.265 Main profile, Level 4.0 + vp9: { type: 'vp9', codec: () => 'vp09.00.10.08' }, // VP9 Profile 0, Level 1.0 + av1: { type: 'av1', codec: () => 'av01.0.05M.08' } // AV1 Main Profile, Level 3.1 +}; + +type ImageSettings = { + width: number; + height: number; + transparentBg: boolean; + showDebug: boolean; + projection?: 'standard' | 'equirect'; + levelHorizon?: boolean; +}; + +type VideoSettings = { + startFrame: number; + endFrame: number; + frameRate: number; + width: number; + height: number; + bitrate: number; + transparentBg: boolean; + showDebug: boolean; + format: 'mp4' | 'webm' | 'mov' | 'mkv'; + codec: 'h264' | 'h265' | 'vp9' | 'av1'; + projection?: 'standard' | 'equirect'; + levelHorizon?: boolean; +}; + +const removeExtension = (filename: string) => { + return filename.substring(0, filename.length - path.getExtension(filename).length); +}; + +const isInvalidFilenameChar = (char: string) => { + return /[<>:"/\\|?*]/.test(char) || char.charCodeAt(0) < 32; +}; + +const sanitizeFilename = (filename: string) => { + const sanitized = Array.from(filename, char => (isInvalidFilenameChar(char) ? '_' : char)).join('').trim(); + return sanitized.length > 0 ? sanitized : 'supersplat'; +}; + +// extract a plain filename from url-style names (e.g. splats imported via ?load=) +const getImportedFilename = (filename: string) => { + const trimmed = filename.split(/[?#]/)[0]; + + if (trimmed.includes('://') || trimmed.startsWith('blob:')) { + try { + return path.getBasename(new URL(trimmed).pathname); + } catch { + // fall through to the raw filename below + } + } + + return path.getBasename(trimmed); +}; + +// sort splats and wait for the sort to complete (or a 1s timeout) +const sortSplatsAndWait = (scene: Scene, splats: Splat[]) => { + return Promise.all(splats.map((splat) => { + return new Promise((resolve) => { + const { instance } = splat.entity.gsplat; + instance.sorter.once('updated', resolve); + instance.sort(scene.camera.mainCamera); + setTimeout(resolve, 1000); + }); + })); +}; + +const downloadFile = (data: ArrayBuffer | Uint8Array, filename: string) => { + const blob = new Blob([data], { type: 'application/octet-stream' }); + const url = window.URL.createObjectURL(blob); + const el = document.createElement('a'); + el.download = filename; + el.href = url; + el.click(); + window.URL.revokeObjectURL(url); +}; + +const registerRenderEvents = (scene: Scene, events: Events) => { + let webpCodec: WebPCodec; + + // default base filename for rendered output: the project document name if + // set, otherwise the first visible splat's name + const baseFilename = () => { + const docName = events.invoke('doc.name'); + const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible); + const source = docName || (splats[0]?.name ?? 'supersplat'); + return sanitizeFilename(removeExtension(getImportedFilename(source))); + }; + + events.function('render.baseFilename', baseFilename); + + // wait for postrender to fire + const postRender = () => { + return new Promise((resolve, reject) => { + const handle = scene.events.on('postrender', () => { + handle.off(); + try { + resolve(true); + } catch (error) { + reject(error); + } + }); + }); + }; + + events.function('render.offscreen', async (width: number, height: number): Promise => { + try { + // start rendering to offscreen buffer only + scene.camera.startOffscreenMode(width, height); + scene.camera.renderOverlays = false; + scene.gizmoLayer.enabled = false; + + // render the next frame + scene.forceRender = true; + + // for render to finish + await postRender(); + + // cpu-side buffer to read pixels into + const data = new Uint8Array(width * height * 4); + + const { mainTarget, workTarget } = scene.camera; + + scene.dataProcessor.copyRt(mainTarget, workTarget); + + // read the rendered frame + await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data }); + + // flip y positions to have 0,0 at the top + let line = new Uint8Array(width * 4); + for (let y = 0; y < height / 2; y++) { + line = data.slice(y * width * 4, (y + 1) * width * 4); + data.copyWithin(y * width * 4, (height - y - 1) * width * 4, (height - y) * width * 4); + data.set(line, (height - y - 1) * width * 4); + } + + return data; + } finally { + scene.camera.endOffscreenMode(); + scene.camera.renderOverlays = true; + scene.gizmoLayer.enabled = true; + scene.camera.camera.clearColor.set(0, 0, 0, 0); + } + }); + + events.function('render.image', async (imageSettings: ImageSettings, fileStream?: FileSystemWritableFileStream) => { + events.fire('startSpinner'); + + let equirect: EquirectRenderer | null = null; + let savedFov = 0; + let savedOrtho = false; + + try { + const { width, height, transparentBg, showDebug, projection, levelHorizon } = imageSettings; + const is360 = projection === 'equirect'; + + // in 360 mode the offscreen target is a square cube face; the + // equirect target holds the output-sized frame + const faceSize = Math.min(height, scene.graphicsDevice.maxTextureSize); + + // start rendering to offscreen buffer only + scene.camera.startOffscreenMode(is360 ? faceSize : width, is360 ? faceSize : height); + scene.camera.renderOverlays = is360 ? false : showDebug; + scene.gizmoLayer.enabled = false; + if (!transparentBg) { + scene.camera.clearPass.setClearColor(events.invoke('bgClr')); + } + + // cpu-side buffer to read pixels into + const data = new Uint8Array(width * height * 4); + + if (is360) { + savedFov = scene.camera.fov; + savedOrtho = scene.camera.ortho; + equirect = new EquirectRenderer(scene.graphicsDevice, faceSize, width, height); + scene.camera.ortho = false; + + // snapshot the current camera pose. supersplat cameras never + // roll, so with level horizon the capture frame is the + // camera yaw, otherwise yaw and pitch + const camPos = new Vec3().copy(scene.camera.position); + const qCapture = new Quat(); + if (levelHorizon ?? true) { + qCapture.setFromEulerAngles(0, scene.camera.azim, 0); + } else { + qCapture.copy(scene.camera.mainCamera.getRotation()); + } + + // all faces share direction-independent clipping planes so + // near-plane culling cannot differ across a face boundary + const boundRadius = scene.bound.halfExtents.length(); + const dist = new Vec3().sub2(scene.bound.center, camPos).length(); + const far = dist + boundRadius; + const near = Math.max(1e-6, dist < boundRadius ? far / (1024 * 16) : dist - boundRadius); + + const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible); + const qWorld = new Quat(); + + for (let face = 0; face < 6; face++) { + qWorld.mul2(qCapture, EquirectRenderer.faceRotations[face]); + scene.camera.setPoseOverride({ position: camPos, rotation: qWorld, fov: EquirectRenderer.faceFov, near, far }); + + // faces view different directions, so each render must + // wait for its own sort + await sortSplatsAndWait(scene, splats); + + // render a frame and wait for it to finish + scene.forceRender = true; + await postRender(); + + scene.dataProcessor.copyRt(scene.camera.mainTarget, equirect.faceTargets[face]); + } + + // project the faces to the equirect target and read back + equirect.project(); + await equirect.read(data); + } else { + // render the next frame + scene.forceRender = true; + + // for render to finish + await postRender(); + + const { mainTarget, workTarget } = scene.camera; + + scene.dataProcessor.copyRt(mainTarget, workTarget); + + // read the rendered frame + await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data }); + } + + // flip the buffer vertically: the framebuffer read is bottom-up + // but webp (and image files generally) expect top-down rows + const line = new Uint8Array(width * 4); + for (let y = 0; y < height / 2; y++) { + const top = y * width * 4; + const bottom = (height - y - 1) * width * 4; + line.set(data.subarray(top, top + width * 4)); + data.copyWithin(top, bottom, bottom + width * 4); + data.set(line, bottom); + } + + // construct the webp codec + if (!webpCodec) { + webpCodec = await WebPCodec.create(); + } + + const bytes = webpCodec.encodeLosslessRGBA(data, width, height); + + if (fileStream) { + await fileStream.write(bytes); + await fileStream.close(); + } else { + downloadFile(bytes, `${baseFilename()}.webp`); + } + + return true; + } catch (error) { + // close the stream even on failure so the caller can remove the + // empty file + if (fileStream) { + try { + await fileStream.close(); + } catch { + // stream already closed or errored + } + } + + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('panel.render.failed'), + message: `'${error.message ?? error}'` + }); + + return false; + } finally { + if (equirect) { + scene.camera.setPoseOverride(null); + scene.camera.fov = savedFov; + scene.camera.ortho = savedOrtho; + equirect.destroy(); + equirect = null; + } + + scene.camera.endOffscreenMode(); + scene.camera.renderOverlays = true; + scene.gizmoLayer.enabled = true; + scene.camera.clearPass.setClearColor(nullClr); + + events.fire('stopSpinner'); + } + }); + + events.function('render.video', (videoSettings: VideoSettings, fileStream: FileSystemWritableFileStream) => { + const renderImpl = async () => { + events.fire('progressStart', i18n.t('panel.render.render-video'), true); + + let cancelled = false; + const cancelHandler = events.on('progressCancel', () => { + cancelled = true; + }); + + let encoder: VideoEncoder | null = null; + let equirect: EquirectRenderer | null = null; + let savedFov = 0; + let savedOrtho = false; + + try { + const { startFrame, endFrame, frameRate, width, height, bitrate, transparentBg, showDebug, format, codec: codecChoice, projection, levelHorizon } = videoSettings; + + const is360 = projection === 'equirect'; + + // 360 mp4/mov exports have spherical metadata patched into the + // finished buffer, so they render to memory with moov written + // last (fastStart false) instead of streaming to disk + const taggable = is360 && (format === 'mp4' || format === 'mov'); + + const target = (fileStream && !taggable) ? new StreamTarget(fileStream) : new BufferTarget(); + + // Configure output format and codec from lookup maps (default to mp4/h264) + const formatConfig = FORMAT_CONFIG[format] ?? FORMAT_CONFIG.mp4; + const outputFormat = formatConfig.create(taggable || !!fileStream); + const fileExtension = formatConfig.extension; + + const codecConfig = CODEC_CONFIG[codecChoice] ?? CODEC_CONFIG.h264; + const codecType = codecConfig.type; + const codec = codecConfig.codec(height); + + const output = new Output({ + format: outputFormat, + target + }); + + const videoSource = new EncodedVideoPacketSource(codecType); + output.addVideoTrack(videoSource, { + rotation: 0, + frameRate + }); + + await output.start(); + + let encoderError: Error | null = null; + + // helper to create and configure a VideoEncoder instance + const createEncoder = () => { + encoderError = null; + const enc = new VideoEncoder({ + output: async (chunk, meta) => { + const encodedPacket = EncodedPacket.fromEncodedChunk(chunk); + await videoSource.add(encodedPacket, meta); + }, + error: (error) => { + encoderError = error; + } + }); + enc.configure({ codec, width, height, bitrate }); + return enc; + }; + + // fail fast on unsupported configurations (e.g. encoder + // dimension limits) instead of erroring mid-render + const support = await VideoEncoder.isConfigSupported({ codec, width, height, bitrate }); + if (!support.supported) { + throw new Error(`Unsupported video configuration (${codecChoice} @ ${width}x${height})`); + } + + encoder = createEncoder(); + + // in 360 mode the offscreen target is a square cube face; the + // equirect target holds the output-sized frame + const faceSize = Math.min(height, scene.graphicsDevice.maxTextureSize); + + // start rendering to offscreen buffer only + scene.camera.startOffscreenMode(is360 ? faceSize : width, is360 ? faceSize : height); + scene.camera.renderOverlays = is360 ? false : showDebug; + scene.gizmoLayer.enabled = false; + if (!transparentBg) { + scene.camera.clearPass.setClearColor(events.invoke('bgClr')); + } + scene.lockedRenderMode = true; + + if (is360) { + savedFov = scene.camera.fov; + savedOrtho = scene.camera.ortho; + equirect = new EquirectRenderer(scene.graphicsDevice, faceSize, width, height); + scene.camera.ortho = false; + } + + // cpu-side buffer to read pixels into + const data = new Uint8Array(width * height * 4); + const line = new Uint8Array(width * 4); + + // remember last camera position so we can skip sorting if the camera didn't move + const last_pos = new Vec3(0, 0, 0); + const last_forward = new Vec3(1, 0, 0); + + // helper to sort splats and wait for completion + const sortAndWait = (splats: Splat[]) => sortSplatsAndWait(scene, splats); + + // prepare the frame for rendering, returns the newly loaded splat if any + const prepareFrame = async (frameTime: number, skipSort = false): Promise => { + // Fire timeline.time for camera animation interpolation + events.fire('timeline.time', frameTime); + + // Wait for PLY sequence to load the frame if present + const newSplat = await events.invoke('plysequence.setFrameAsync', Math.floor(frameTime)) as Splat | null; + + // manually update the camera so position and rotation are correct + scene.camera.onUpdate(0); + + // 360 capture re-sorts per cube face, so skip sorting here + if (skipSort) { + return newSplat; + } + + // If a new PLY was loaded, sort and wait for completion + if (newSplat) { + await sortAndWait([newSplat]); + } else { + // No new PLY - sort existing splats if camera moved + const pos = scene.camera.position; + const forward = scene.camera.forward; + if (!last_pos.equals(pos) || !last_forward.equals(forward)) { + last_pos.copy(pos); + last_forward.copy(forward); + + const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible); + await sortAndWait(splats); + } + } + + return newSplat; + }; + + // flip, wrap and submit the pixels currently in the data buffer + const encodeFrame = async (frameTime: number) => { + // flip the buffer vertically + for (let y = 0; y < height / 2; y++) { + const top = y * width * 4; + const bottom = (height - y - 1) * width * 4; + line.set(data.subarray(top, top + width * 4)); + data.copyWithin(top, bottom, bottom + width * 4); + data.set(line, bottom); + } + + // construct the video frame + const videoFrame = new VideoFrame(data, { + format: 'RGBA', + codedWidth: width, + codedHeight: height, + timestamp: Math.floor(1e6 * frameTime), + duration: Math.floor(1e6 / frameRate) + }); + + // wait for encoder queue to drain if necessary (backpressure handling) + while (encoder.encodeQueueSize > 5) { + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + } + + // if the codec was reclaimed (e.g. browser backgrounded the tab), + // recreate the encoder and continue + let forceKeyFrame = false; + if (encoder.state === 'closed' && encoderError?.message?.includes('reclaimed')) { + encoder = createEncoder(); + forceKeyFrame = true; + } + + // check for non-recoverable encoder errors + if (encoderError) { + videoFrame.close(); + throw encoderError; + } + + encoder.encode(videoFrame, { keyFrame: forceKeyFrame }); + videoFrame.close(); + }; + + // capture the current video frame + const captureFrame = async (frameTime: number) => { + const { mainTarget, workTarget } = scene.camera; + + scene.dataProcessor.copyRt(mainTarget, workTarget); + + // read the rendered frame + await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data }); + + await encodeFrame(frameTime); + }; + + const animFrameRate = events.invoke('timeline.frameRate'); + const duration = (endFrame - startFrame) / animFrameRate; + const totalFrames = Math.floor(duration * frameRate) + 1; + + // work objects for 360 capture + const camPos = new Vec3(); + const vec = new Vec3(); + const qCapture = new Quat(); + const qWorld = new Quat(); + + // capture a 360 frame: render the six cube faces from the + // animated camera position, re-sorting splats per face + // direction, then project to equirect and encode + const capture360 = async (frameTime: number) => { + // snapshot the animated camera pose. supersplat cameras + // never roll, so with level horizon the capture frame is + // the camera yaw, otherwise yaw and pitch + camPos.copy(scene.camera.position); + if (levelHorizon ?? true) { + qCapture.setFromEulerAngles(0, scene.camera.azim, 0); + } else { + qCapture.copy(scene.camera.mainCamera.getRotation()); + } + + // all faces share direction-independent clipping planes so + // near-plane culling cannot differ across a face boundary + const boundRadius = scene.bound.halfExtents.length(); + const dist = vec.sub2(scene.bound.center, camPos).length(); + const far = dist + boundRadius; + const near = Math.max(1e-6, dist < boundRadius ? far / (1024 * 16) : dist - boundRadius); + + const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible); + + for (let face = 0; face < 6; face++) { + // check for cancellation + if (cancelled) return; + + qWorld.mul2(qCapture, EquirectRenderer.faceRotations[face]); + scene.camera.setPoseOverride({ position: camPos, rotation: qWorld, fov: EquirectRenderer.faceFov, near, far }); + + // faces view different directions, so each render must + // wait for its own sort + await sortAndWait(splats); + + // render a frame + scene.lockedRender = true; + + // wait for render to finish + await postRender(); + + scene.dataProcessor.copyRt(scene.camera.mainTarget, equirect.faceTargets[face]); + + const frameIndex = Math.round(frameTime * frameRate); + events.fire('progressUpdate', { + text: i18n.t('panel.render.rendering', { ellipsis: true }), + progress: 100 * (frameIndex + (face + 1) / 6) / totalFrames + }); + } + + // project the faces to the equirect target and encode + equirect.project(); + await equirect.read(data); + await encodeFrame(frameTime); + }; + + for (let frameTime = 0; frameTime <= duration; frameTime += 1.0 / frameRate) { + // check for cancellation + if (cancelled) break; + + if (is360) { + // restore animated-pose evaluation before the timeline + // advances (fov feeds the tween-to-position mapping) + scene.camera.setPoseOverride(null); + scene.camera.fov = savedFov; + + // prepare the frame (loads PLY if needed, updates camera) + await prepareFrame(startFrame + frameTime * animFrameRate, true); + + await capture360(frameTime); + } else { + // prepare the frame (loads PLY if needed, updates camera, sorts) + await prepareFrame(startFrame + frameTime * animFrameRate); + + // render a frame + scene.lockedRender = true; + + // wait for render to finish + await postRender(); + + // wait for capture + await captureFrame(frameTime); + + events.fire('progressUpdate', { + text: i18n.t('panel.render.rendering', { ellipsis: true }), + progress: 100 * frameTime / duration + }); + } + } + + // Flush and finalize output + await encoder.flush(); + await output.finalize(); + + const filename = () => `${baseFilename()}.${fileExtension}`; + + if (taggable) { + // patch spherical metadata into the finished buffer so + // players auto-detect the equirectangular projection + if (!cancelled) { + let buffer = (target as BufferTarget).buffer; + try { + buffer = injectSphericalMetadata(buffer); + } catch (error) { + console.warn(`failed to inject spherical metadata: ${error.message ?? error}`); + } + + if (fileStream) { + await fileStream.write(buffer); + } else { + downloadFile(buffer, filename()); + } + } + + // close the stream even when cancelled so the caller can + // remove the empty file + if (fileStream) { + await fileStream.close(); + } + } else if (!cancelled && !fileStream) { + // Download (skip if cancelled -- the caller will delete the file) + downloadFile((target as BufferTarget).buffer, filename()); + } + + return !cancelled; + } catch (error) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('panel.render.failed'), + message: `'${(error as any).message ?? error}'` + }); + return false; + } finally { + if (encoder && encoder.state !== 'closed') { + encoder.close(); + } + cancelHandler.off(); + + if (equirect) { + scene.camera.setPoseOverride(null); + scene.camera.fov = savedFov; + scene.camera.ortho = savedOrtho; + equirect.destroy(); + equirect = null; + } + + scene.camera.endOffscreenMode(); + scene.camera.renderOverlays = true; + scene.gizmoLayer.enabled = true; + scene.camera.clearPass.setClearColor(nullClr); + scene.lockedRenderMode = false; + scene.forceRender = true; // camera likely moved, finish with normal render + + events.fire('progressEnd'); + } + }; + + // Acquire a Web Lock during encoding to signal the browser that this tab is + // actively working, which helps prevent aggressive background throttling and + // codec reclamation. + if (navigator.locks) { + return navigator.locks.request('supersplat-video-render', renderImpl); + } + return renderImpl(); + }); +}; + +export { ImageSettings, VideoSettings, registerRenderEvents }; diff --git a/src/scene-config.ts b/src/scene-config.ts new file mode 100644 index 0000000..9d55dee --- /dev/null +++ b/src/scene-config.ts @@ -0,0 +1,171 @@ +type Color = { r: number, g: number, b: number, a: number }; + +const DEFAULT_BG_CLR: Color = { r: 0, g: 0, b: 0, a: 1 }; +const DEFAULT_SELECTED_CLR: Color = { r: 1, g: 1, b: 0, a: 1 }; +const DEFAULT_UNSELECTED_CLR: Color = { r: 0, g: 0, b: 1, a: 0.5 }; +const DEFAULT_LOCKED_CLR: Color = { r: 0, g: 0, b: 0, a: 0.05 }; + +// default config +const sceneConfig = { + bgClr: DEFAULT_BG_CLR, + selectedClr: DEFAULT_SELECTED_CLR, + unselectedClr: DEFAULT_UNSELECTED_CLR, + lockedClr: DEFAULT_LOCKED_CLR, + camera: { + pixelScale: 1, + multisample: false, + fov: 75, + exposure: 1.0, + toneMapping: 'linear', + overlay: false + }, + show: { + grid: true, + bound: true, + boundDimensions: false, + cameraPoses: false, + cameraInfo: false, + shBands: 3 + }, + controls: { + dampingFactor: 0.2, + minPolarAngle: 0, + maxPolarAngle: Math.PI, + minZoom: 1e-6, + maxZoom: 10.0, + initialAzim: -45, + initialElev: -10, + initialZoom: 1.0, + orbitSensitivity: 0.3, + zoomSensitivity: 0.4 + }, + debug: { + showBound: false + } +}; + +type SceneConfig = typeof sceneConfig; + +class Params { + sources: any[]; + + constructor(sources: any[]) { + this.sources = sources; + } + + private resolve(configs: any[], path: string[]): any { + const get = (obj: any): any => { + for (const name of path) { + if (!obj.hasOwnProperty(name)) { + return undefined; + } + obj = obj[name]; + } + return obj; + }; + + for (const config of configs) { + const value = get(config); + if (value !== undefined) { + return value; + } + } + return undefined; + } + + get(path: string): any { + // https://stackoverflow.com/a/67243723/2405687 + const kebabize = (s: string) => s.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase()); + return this.resolve(this.sources, path.split('.').map(kebabize)) ?? this.resolve(this.sources, path.split('.')); + } + + getBool(path: string): boolean | undefined { + const value = this.get(path); + return typeof value === 'string' ? value.toLowerCase() === 'true' : value === undefined ? undefined : !!value; + } + + getNumber(path: string) { + const value = this.get(path); + return typeof value === 'string' ? parseFloat(value) : value; + } + + getVec(path: string) { + const value = this.get(path); + return typeof value === 'string' ? value.split(',') : undefined; + } + + getVec3(path: string) { + const value = this.getVec(path); + if (value) { + const numbers = value.map(v => parseFloat(v)); + if (value.length === 1) { + return { x: numbers[0], y: numbers[0], z: numbers[0] }; + } else if (value.length === 3) { + return { x: numbers[0], y: numbers[1], z: numbers[2] }; + } + } + return undefined; + } + + getColor(path: string) { + const value = this.getVec(path); + if (value) { + const numbers = value.map(v => parseFloat(v)); + if (value.length === 1) { + return { r: numbers[0], g: numbers[0], b: numbers[0], a: 1 }; + } else if (value.length === 3) { + return { r: numbers[0], g: numbers[1], b: numbers[2], a: 1 }; + } else if (value.length === 4) { + return { r: numbers[0], g: numbers[1], b: numbers[2], a: numbers[3] }; + } + } + return undefined; + } +} + +const getSceneConfig = (overrides: any[]) => { + const params = new Params(overrides); + + const cmp = (a: any[], b: any[]) => { + return a.length === b.length && a.every((v, i) => v === b[i]); + }; + + // recurse the object and replace concrete leaf values with overrides + const rec = (obj: any, path: string) => { + for (const child in obj) { + const childPath = `${path}${path.length ? '.' : ''}${child}`; + const childValue = obj[child]; + switch (typeof childValue) { + case 'number': + obj[child] = params.getNumber(childPath) ?? childValue; + break; + case 'boolean': + obj[child] = params.getBool(childPath) ?? childValue; + break; + case 'string': + obj[child] = params.get(childPath) ?? childValue; + break; + case 'object': { + const keys = Object.keys(childValue).sort(); + if (cmp(keys, ['a', 'b', 'g', 'r'])) { + obj[child] = params.getColor(childPath) ?? childValue; + } else if (cmp(keys, ['x', 'y', 'z'])) { + obj[child] = params.getVec3(childPath) ?? childValue; + } else { + rec(childValue, childPath); + } + break; + } + default: + rec(childValue, childPath); + break; + } + } + }; + + rec(sceneConfig, ''); + + return sceneConfig; +}; + +export { SceneConfig, getSceneConfig }; diff --git a/src/scene-state.ts b/src/scene-state.ts new file mode 100644 index 0000000..75a7890 --- /dev/null +++ b/src/scene-state.ts @@ -0,0 +1,151 @@ +import { Element, ElementType, ElementTypeList } from './element'; +import { Serializer } from './serializer'; + +const common = new Set(); + +// this class tracks the state of scene elements and determines what +// type of objects have changed in a frame. this allows the rest of +// the application to respond to changes like re-rendering +// the scene or recalculating the scene bounding box. +class SceneState { + states: any = {}; + activeValues: any[]; + + serializer = new Serializer((value: any) => { + this.activeValues.push(value); + }); + + constructor() { + for (let i = 0; i < ElementTypeList.length; ++i) { + this.states[ElementTypeList[i]] = { + elements: new Map(), + valueStart: [], + valueCount: [], + values: [] + }; + } + } + + reset() { + ElementTypeList.forEach((type) => { + const state = this.states[type]; + state.elements.clear(); + state.valueStart.length = 0; + state.valueCount.length = 0; + state.values.length = 0; + }); + } + + pack(element: Element) { + const state = this.states[element.type]; + const start = state.values.length; + + // let element store its values + this.activeValues = state.values; + element.serialize(this.serializer); + + state.elements.set(element, state.elements.size); + state.valueStart.push(start); + state.valueCount.push(state.values.length - start); + } + + compare(other: SceneState) { + function intersection(result: Set, a: Map, b: Map) { + result.clear(); + for (const e of a.keys()) { + if (b.has(e)) { + result.add(e); + } + } + } + + function diff(a: Map, b: Set) { + for (const e of a.keys()) { + if (!b.has(e)) { + return true; + } + } + } + + function some(it: IterableIterator, predicate: (t: T) => boolean) { + for (const e of it) { + if (predicate(e)) { + return true; + } + } + return false; + } + + const result = { + added: new Array(), + removed: new Array(), + moved: new Array(), + changed: new Array() + }; + + ElementTypeList.forEach((type) => { + const prevState = other.states[type]; + const currState = this.states[type]; + + // generate map of element to index + const prev = prevState.elements; + const curr = currState.elements; + + // make a set containing the elements present in both the previous and current frame + intersection(common, prev, curr); + + // determine if any elements were added + if (diff(curr, common)) { + result.added.push(type); + } + + // determine if any elements were removed + if (diff(prev, common)) { + result.removed.push(type); + } + + // determine if any element moved order + if ( + some(common.values(), (e: Element) => { + return prev.get(e) !== curr.get(e); + }) + ) { + result.moved.push(type); + } + + // determine if any element's state changed + if ( + some(common.values(), (e: Element) => { + const prevIdx = prev.get(e); + const currIdx = curr.get(e); + const count = prevState.valueCount[prevIdx]; + + if (count !== currState.valueCount[currIdx]) { + // number of state values changed + return true; + } + + const prevStart = prevState.valueStart[prevIdx]; + const currStart = currState.valueStart[currIdx]; + const prevValues = prevState.values; + const currValues = currState.values; + + for (let i = 0; i < count; ++i) { + if (prevValues[prevStart + i] !== currValues[currStart + i]) { + // state value changed + return true; + } + } + + return false; + }) + ) { + result.changed.push(type); + } + }); + + return result; + } +} + +export { SceneState }; diff --git a/src/scene.ts b/src/scene.ts new file mode 100644 index 0000000..173026d --- /dev/null +++ b/src/scene.ts @@ -0,0 +1,413 @@ +import { + EVENT_POSTRENDER_LAYER, + EVENT_PRERENDER_LAYER, + LAYERID_DEPTH, + SORTMODE_CUSTOM, + BoundingBox, + CameraComponent, + Color, + Entity, + Layer, + GraphicsDevice, + MeshInstance, + Vec3 +} from 'playcanvas'; + +import { AssetLoader } from './asset-loader'; +import { Camera } from './camera'; +import { CameraPoseGizmos } from './camera-pose-gizmos'; +import { CommandQueue } from './command-queue'; +import { DataProcessor } from './data-processor'; +import { Element, ElementType, ElementTypeList } from './element'; +import { Events } from './events'; +import { InfiniteGrid as Grid } from './infinite-grid'; +import { Outline } from './outline'; +import { PCApp } from './pc-app'; +import { SceneConfig } from './scene-config'; +import { SceneState } from './scene-state'; +import { Splat } from './splat'; +import { SplatOverlay } from './splat-overlay'; +import { Underlay } from './underlay'; + +// sort meshInstances by the aabb corner furthest from the camera +const corner = new Vec3(); +const specialSort = (instances: MeshInstance[], numInstances: number, cameraPos: Vec3, cameraDir: Vec3) => { + const distances = new Map(); + + for (let i = 0; i < numInstances; i++) { + const instance = instances[i]; + const { aabb } = instance; + const { center, halfExtents } = aabb; + + // loop over all 8 aabb corners and find the furthest distance along the camera view direction + let maxDist = -Infinity; + for (let cx = -1; cx <= 1; cx += 2) { + for (let cy = -1; cy <= 1; cy += 2) { + for (let cz = -1; cz <= 1; cz += 2) { + corner.set( + center.x + cx * halfExtents.x, + center.y + cy * halfExtents.y, + center.z + cz * halfExtents.z + ); + // project camera-to-corner vector onto camera direction + const dist = (corner.x - cameraPos.x) * cameraDir.x + + (corner.y - cameraPos.y) * cameraDir.y + + (corner.z - cameraPos.z) * cameraDir.z; + if (dist > maxDist) { + maxDist = dist; + } + } + } + } + + // store in map for reuse during sort + distances.set(instance, maxDist); + } + + // sort instances back-to-front by calculated distance (furthest first) + instances.sort((a, b) => distances.get(b) - distances.get(a)); +}; + +class Scene { + events: Events; + config: SceneConfig; + canvas: HTMLCanvasElement; + app: PCApp; + worldLayer: Layer; + splatLayer: Layer; + gizmoLayer: Layer; + sceneState = [new SceneState(), new SceneState()]; + elements: Element[] = []; + boundStorage = new BoundingBox(); + boundDirty = true; + forceRender = false; + + lockedRenderMode = false; + lockedRender = false; + + canvasResize: {width: number; height: number} | null = null; + targetSize = { + width: 0, + height: 0 + }; + + dataProcessor: DataProcessor; + assetLoader: AssetLoader; + camera: Camera; + cameraPoseGizmos: CameraPoseGizmos; + splatOverlay: SplatOverlay; + grid: Grid; + outline: Outline; + underlay: Underlay; + + // shared queue for serialising async splat work. exposed so subsystems that + // need to order their async work alongside edit-history operations can do so + // without going through edit-history directly. + commandQueue: CommandQueue; + + contentRoot: Entity; + cameraRoot: Entity; + + constructor( + events: Events, + config: SceneConfig, + canvas: HTMLCanvasElement, + graphicsDevice: GraphicsDevice, + commandQueue: CommandQueue + ) { + this.events = events; + this.config = config; + this.canvas = canvas; + this.commandQueue = commandQueue; + + // configure the playcanvas application. we render to an offscreen buffer so require + // only the simplest of backbuffers. + this.app = new PCApp(canvas, { graphicsDevice }); + + // only render the scene when instructed + this.app.autoRender = false; + // @ts-ignore + this.app._allowResize = false; + this.app.scene.clusteredLightingEnabled = false; + + // hack: disable lightmapper first bake until we expose option for this + // @ts-ignore + this.app.off('prerender', this.app._firstBake, this.app); + + // @ts-ignore + this.app.loader.getHandler('texture').imgParser.crossOrigin = 'anonymous'; + + // this is required to get full res AR mode backbuffer + this.app.graphicsDevice.maxPixelRatio = window.devicePixelRatio; + + // configure application canvas + const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + if (entries.length > 0) { + const entry = entries[0]; + if (entry) { + if (entry.devicePixelContentBoxSize) { + // on non-safari browsers, we are given the pixel-perfect canvas size + this.canvasResize = { + width: entry.devicePixelContentBoxSize[0].inlineSize, + height: entry.devicePixelContentBoxSize[0].blockSize + }; + } else if (entry.contentBoxSize.length > 0) { + // on safari browsers we must calculate pixel size from CSS size ourselves + // and hope the browser performs the same calculation. + const pixelRatio = window.devicePixelRatio; + this.canvasResize = { + width: Math.ceil(entry.contentBoxSize[0].inlineSize * pixelRatio), + height: Math.ceil(entry.contentBoxSize[0].blockSize * pixelRatio) + }; + } + } + this.forceRender = true; + } + }); + + observer.observe(window.document.getElementById('canvas-container')); + + // configure depth layers to handle dynamic refraction + const depthLayer = this.app.scene.layers.getLayerById(LAYERID_DEPTH); + this.app.scene.layers.remove(depthLayer); + this.app.scene.layers.insertOpaque(depthLayer, 2); + + // register application callbacks + this.app.on('update', (deltaTime: number) => this.onUpdate(deltaTime)); + this.app.on('prerender', () => this.onPreRender()); + this.app.on('postrender', () => this.onPostRender()); + + // force render on device restored + this.app.graphicsDevice.on('devicerestored', () => { + this.forceRender = true; + }); + + // fire pre and post render events on the camera + this.app.scene.on(EVENT_PRERENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => { + camera.fire('preRenderLayer', layer, transparent); + }); + + this.app.scene.on(EVENT_POSTRENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => { + camera.fire('postRenderLayer', layer, transparent); + }); + + // get the world layer + this.worldLayer = this.app.scene.layers.getLayerByName('World'); + + // splat layer - dedicated layer for splat rendering with MRT + this.splatLayer = new Layer({ + name: 'Splat', + opaqueSortMode: SORTMODE_CUSTOM, + transparentSortMode: SORTMODE_CUSTOM + }); + this.splatLayer.customCalculateSortValues = specialSort; + + // gizmo layer + this.gizmoLayer = new Layer({ name: 'Gizmo' }); + + const layers = this.app.scene.layers; + layers.push(this.splatLayer); + layers.push(this.gizmoLayer); + + this.dataProcessor = new DataProcessor(this.app.graphicsDevice); + this.assetLoader = new AssetLoader(this.app, events); + + // create root entities + this.contentRoot = new Entity('contentRoot'); + this.app.root.addChild(this.contentRoot); + + this.cameraRoot = new Entity('cameraRoot'); + this.app.root.addChild(this.cameraRoot); + + // create elements + this.camera = new Camera(); + this.add(this.camera); + + this.cameraPoseGizmos = new CameraPoseGizmos(); + this.add(this.cameraPoseGizmos); + + this.splatOverlay = new SplatOverlay(); + this.add(this.splatOverlay); + + this.grid = new Grid(); + this.add(this.grid); + + this.outline = new Outline(); + this.add(this.outline); + this.underlay = new Underlay(); + this.add(this.underlay); + } + + start() { + // start the app + this.app.start(); + } + + clear() { + const splats = this.getElementsByType(ElementType.splat); + splats.forEach((splat) => { + this.remove(splat); + (splat as Splat).destroy(); + }); + } + + // add a scene element + async add(element: Element) { + if (!element.scene) { + // add the new element + element.scene = this; + await element.add(); + this.elements.push(element); + + // notify all elements of scene addition + this.forEachElement(e => e !== element && e.onAdded(element)); + + // notify listeners + this.events.fire('scene.elementAdded', element); + } + } + + // remove an element from the scene + remove(element: Element) { + if (element.scene === this) { + // remove from list + this.elements.splice(this.elements.indexOf(element), 1); + + // notify listeners + this.events.fire('scene.elementRemoved', element); + + // notify all elements of scene removal + this.forEachElement(e => e.onRemoved(element)); + + element.remove(); + element.scene = null; + } + } + + // get the scene bound + get bound() { + if (this.boundDirty) { + let valid = false; + this.forEachElement((e) => { + const bound = e.worldBound; + if (bound) { + if (!valid) { + valid = true; + this.boundStorage.copy(bound); + } else { + this.boundStorage.add(bound); + } + } + }); + + this.boundDirty = false; + this.events.fire('scene.boundChanged', this.boundStorage); + } + + return this.boundStorage; + } + + getElementsByType(elementType: ElementType) { + return this.elements.filter(e => e.type === elementType); + } + + get graphicsDevice() { + return this.app.graphicsDevice; + } + + private forEachElement(action: (e: Element) => void) { + this.elements.forEach(action); + } + + private onUpdate(deltaTime: number) { + // allow elements to update + this.forEachElement(e => e.onUpdate(deltaTime)); + + // fire global update + this.events.fire('update', deltaTime); + + // fire a 'serialize' event which listers will use to store their state. we'll use + // this to decide if the view has changed and so requires rendering. + const i = this.app.frame % 2; + const state = this.sceneState[i]; + state.reset(); + this.forEachElement(e => state.pack(e)); + + // diff with previous state + const result = state.compare(this.sceneState[1 - i]); + + // generate the set of all element types that changed + const all = new Set([...result.added, ...result.removed, ...result.moved, ...result.changed]); + + // compare with previously serialized + if (this.lockedRenderMode) { + this.app.renderNextFrame = this.lockedRender; + this.lockedRender = false; + } else if (!this.app.renderNextFrame) { + this.app.renderNextFrame = this.forceRender || all.size > 0; + } + this.forceRender = false; + + // raise per-type update events + ElementTypeList.forEach((type) => { + if (all.has(type)) { + this.events.fire(`updated:${type}`); + } + }); + + // allow elements to postupdate + this.forEachElement(e => e.onPostUpdate()); + } + + private onPreRender() { + if (this.canvasResize) { + this.canvas.width = this.canvasResize.width; + this.canvas.height = this.canvasResize.height; + this.canvasResize = null; + } + + // update render target size + this.targetSize.width = Math.ceil(this.app.graphicsDevice.width / this.config.camera.pixelScale); + this.targetSize.height = Math.ceil(this.app.graphicsDevice.height / this.config.camera.pixelScale); + + this.forEachElement(e => e.onPreRender()); + + this.events.fire('prerender', this.camera.displayTransform); + + // debug - display scene bound + if (this.config.debug.showBound) { + // draw element bounds + this.forEachElement((e: Element) => { + if (e.type === ElementType.splat) { + const splat = e as Splat; + + const local = splat.localBound; + this.app.drawWireAlignedBox( + local.getMin(), + local.getMax(), + Color.RED, + true, + undefined, + splat.entity.getWorldTransform()); + + const world = splat.worldBound; + this.app.drawWireAlignedBox( + world.getMin(), + world.getMax(), + Color.GREEN); + } + }); + + // draw scene bound + this.app.drawWireAlignedBox(this.bound.getMin(), this.bound.getMax(), Color.BLUE); + } + } + + private onPostRender() { + this.forEachElement(e => e.onPostRender()); + + this.events.fire('postrender'); + } +} + +export { SceneConfig, Scene }; diff --git a/src/select-op.ts b/src/select-op.ts new file mode 100644 index 0000000..226074b --- /dev/null +++ b/src/select-op.ts @@ -0,0 +1,10 @@ +// Map the keyboard modifiers held during a selection gesture to a selection op. +// shift+ctrl = intersect, shift = add, ctrl = remove, none = set. +const opFromModifiers = (e: { shiftKey: boolean; ctrlKey: boolean }) => { + if (e.shiftKey && e.ctrlKey) return 'intersect'; + if (e.shiftKey) return 'add'; + if (e.ctrlKey) return 'remove'; + return 'set'; +}; + +export { opFromModifiers }; diff --git a/src/selection.ts b/src/selection.ts new file mode 100644 index 0000000..0f4d6c6 --- /dev/null +++ b/src/selection.ts @@ -0,0 +1,57 @@ +import { Element, ElementType } from './element'; +import { Events } from './events'; +import { Scene } from './scene'; +import { Splat } from './splat'; + +const registerSelectionEvents = (events: Events, scene: Scene) => { + let selection: Splat = null; + + const setSelection = (splat: Splat) => { + if (splat !== selection && (!splat || splat.visible)) { + const prev = selection; + selection = splat; + events.fire('selection.changed', selection, prev); + } + }; + + events.on('selection', (splat: Splat) => { + setSelection(splat); + }); + + events.function('selection', () => { + return selection; + }); + + events.on('selection.next', () => { + const splats = scene.getElementsByType(ElementType.splat) as Splat[]; + if (splats.length > 1) { + const idx = splats.indexOf(selection); + setSelection(splats[(idx + 1) % splats.length]); + } + }); + + events.on('scene.elementAdded', (element: Element) => { + if (element.type === ElementType.splat) { + setSelection(element as Splat); + } + }); + + events.on('scene.elementRemoved', (element: Element) => { + if (element === selection) { + const splats = scene.getElementsByType(ElementType.splat) as Splat[]; + setSelection(splats.length === 1 ? null : splats.find(v => v !== element)); + } + }); + + events.on('splat.visibility', (splat: Splat) => { + if (splat === selection && !splat.visible) { + setSelection(null); + } + }); + + events.on('camera.focalPointPicked', (details: { splat: Splat }) => { + setSelection(details.splat); + }); +}; + +export { registerSelectionEvents }; diff --git a/src/sequence.ts b/src/sequence.ts new file mode 100644 index 0000000..e265bba --- /dev/null +++ b/src/sequence.ts @@ -0,0 +1,214 @@ +import { Asset, Quat } from 'playcanvas'; + +import { Events } from './events'; +import { loadGSplatData, MappedReadFileSystem, validateGSplatData } from './io'; +import { Scene } from './scene'; +import { Splat } from './splat'; + +type FrameData = { + asset: Asset; + rotation: Quat; +}; + +// A source of animation frames. getFrame produces a ready gsplat Asset (plus the +// orientation to apply when the persistent splat is first created) for a frame. +interface FrameSource { + readonly frameCount: number; + getFrame(index: number): Promise; + destroy(): void; +} + +// PLY sequence: a set of frameNNNN.ply files, sorted by trailing frame number. +class PlyFrameSource implements FrameSource { + private files: File[]; + private scene: Scene; + + constructor(files: File[], scene: Scene) { + this.scene = scene; + + // eslint-disable-next-line regexp/no-super-linear-backtracking + const regex = /(.*?)(\d+)(?:\.compressed)?\.ply$/; + const key = (f: File) => f.name?.toLowerCase().match(regex)?.[2]; + this.files = files.slice().sort((a, b) => { + const av = key(a); + const bv = key(b); + return (av && bv) ? parseInt(av, 10) - parseInt(bv, 10) : 0; + }); + } + + get frameCount() { + return this.files.length; + } + + async getFrame(index: number): Promise { + const file = this.files[index]; + const fileSystem = new MappedReadFileSystem(); + fileSystem.addFile(file.name, file); + + // skipReorder: animation frames prioritise load speed over morton ordering + const { gsplatData, transform } = await loadGSplatData(file.name, fileSystem, true); + validateGSplatData(gsplatData); + + const asset = this.scene.assetLoader.createGSplatAsset(gsplatData, file.name); + return { asset, rotation: transform.rotation }; + } + + destroy() {} +} + +/** + * Manages animation-sequence playback (PLY sequence). + * + * A sequence is rendered by a single persistent Splat element whose gaussian data + * is swapped in place each frame (Splat.replaceData). This keeps the user's + * whole-model transform and visual properties across frames and avoids the full + * element teardown/recreate (and scene-reset prompt) of the previous approach. + */ +const registerSequenceEvents = (events: Events, scene: Scene) => { + let source: FrameSource | null = null; + let splat: Splat | null = null; + let currentFrame = -1; + let loading = false; + let nextFrame = -1; + let loadingPromise: Promise | null = null; + + // apply a frame's data to the persistent splat, creating it on the first frame + const applyFrame = async (data: FrameData) => { + if (!splat) { + splat = new Splat(data.asset, data.rotation); + await scene.add(splat); + } else { + // in-place swap: preserves entity transform, visual props and selection + await splat.replaceData(data.asset); + } + }; + + // release an asset whose load was abandoned (source switched mid-load) + const discardAsset = (asset: Asset) => { + asset.registry?.remove(asset); + asset.unload(); + }; + + const setSource = (newSource: FrameSource) => { + source?.destroy(); + source = newSource; + currentFrame = -1; + nextFrame = -1; + + // tear down the previous sequence's splat so the new source's first frame + // is bound as an initial load (applying its rotation/name) rather than + // swapped onto the old element + if (splat) { + scene.remove(splat); + splat.destroy(); + splat = null; + } + + events.fire('timeline.frames', source.frameCount); + }; + + const setFrame = async (frame: number) => { + if (!source || frame < 0 || frame >= source.frameCount) { + return; + } + + // coalesce while a frame is in flight (rapid scrubbing) + if (loading) { + nextFrame = frame; + return; + } + + if (frame === currentFrame) { + return; + } + + loading = true; + const loadSource = source; + try { + const data = await source.getFrame(frame); + if (source !== loadSource) { + // source was switched (or the scene cleared) while loading — discard + // this frame's asset rather than applying a stale one + discardAsset(data.asset); + } else { + // applyFrame swaps data in place; replaceData keeps the previous frame + // on screen until the new one has rendered, so no extra wait is needed + await applyFrame(data); + currentFrame = frame; + } + } catch (error) { + console.error(error); + } finally { + loading = false; + } + + // process the most recent frame requested while we were loading + if (nextFrame !== -1) { + const frameToLoad = nextFrame; + nextFrame = -1; + setFrame(frameToLoad); + } + }; + + events.on('sequence.setPlyFrames', (files: File[]) => { + setSource(new PlyFrameSource(files, scene)); + }); + + events.on('timeline.frame', async (frame: number) => { + await setFrame(frame); + }); + + // drop references when the scene is cleared (scene.clear destroys the splat) + events.on('scene.clear', () => { + source?.destroy(); + source = null; + splat = null; + currentFrame = -1; + nextFrame = -1; + }); + + // Async per-frame advance for the video renderer (render.ts). Awaits the frame + // swap so the splat is ready to sort, then returns the (persistent) splat when + // the frame actually changed, or null when it didn't. Name kept for render.ts. + events.function('plysequence.setFrameAsync', async (frame: number): Promise => { + if (!source || frame < 0 || frame >= source.frameCount) { + return null; + } + + if (currentFrame === frame && !loading) { + return null; + } + + // if a load is already in flight, wait for it before deciding + if (loading && loadingPromise) { + await loadingPromise; + } + + if (currentFrame === frame) { + return null; + } + + loadingPromise = (async () => { + loading = true; + const loadSource = source; + try { + const data = await source.getFrame(frame); + if (source !== loadSource) { + // source switched / scene cleared mid-load — discard the asset + discardAsset(data.asset); + } else { + await applyFrame(data); + currentFrame = frame; + } + } finally { + loading = false; + loadingPromise = null; + } + })(); + + await loadingPromise; + return splat; + }); +}; + +export { registerSequenceEvents }; diff --git a/src/serializer.ts b/src/serializer.ts new file mode 100644 index 0000000..5e73d23 --- /dev/null +++ b/src/serializer.ts @@ -0,0 +1,34 @@ +import { Color, Vec3 } from 'playcanvas'; + +// this class is used by elements to store their pertinent state +// every frame. the data is then compared with the previous frame's +// values in order to determine if any changes happened. +class Serializer { + constructor(packValue: (value: any) => void) { + this.packValue = packValue; + } + + packValue: (value: any) => void; + + pack(...args: any[]) { + for (let j = 0; j < args.length; ++j) { + this.packValue(args[j]); + } + } + + packa(a: any[] | Float32Array) { + for (let j = 0; j < a.length; ++j) { + this.packValue(a[j]); + } + } + + packVec3(v: Vec3) { + this.pack(v.x, v.y, v.z); + } + + packColor(c: Color) { + this.pack(c.r, c.g, c.b, c.a); + } +} + +export { Serializer }; diff --git a/src/sh-utils.ts b/src/sh-utils.ts new file mode 100644 index 0000000..65eaf17 --- /dev/null +++ b/src/sh-utils.ts @@ -0,0 +1,191 @@ +import { Mat3 } from 'playcanvas'; + +/* eslint-disable indent */ + +const kSqrt03_02 = Math.sqrt(3.0 / 2.0); +const kSqrt01_03 = Math.sqrt(1.0 / 3.0); +const kSqrt02_03 = Math.sqrt(2.0 / 3.0); +const kSqrt04_03 = Math.sqrt(4.0 / 3.0); +const kSqrt01_04 = Math.sqrt(1.0 / 4.0); +const kSqrt03_04 = Math.sqrt(3.0 / 4.0); +const kSqrt01_05 = Math.sqrt(1.0 / 5.0); +const kSqrt03_05 = Math.sqrt(3.0 / 5.0); +const kSqrt06_05 = Math.sqrt(6.0 / 5.0); +const kSqrt08_05 = Math.sqrt(8.0 / 5.0); +const kSqrt09_05 = Math.sqrt(9.0 / 5.0); +const kSqrt01_06 = Math.sqrt(1.0 / 6.0); +const kSqrt05_06 = Math.sqrt(5.0 / 6.0); +const kSqrt03_08 = Math.sqrt(3.0 / 8.0); +const kSqrt05_08 = Math.sqrt(5.0 / 8.0); +const kSqrt09_08 = Math.sqrt(9.0 / 8.0); +const kSqrt05_09 = Math.sqrt(5.0 / 9.0); +const kSqrt08_09 = Math.sqrt(8.0 / 9.0); +const kSqrt01_10 = Math.sqrt(1.0 / 10.0); +const kSqrt03_10 = Math.sqrt(3.0 / 10.0); +const kSqrt01_12 = Math.sqrt(1.0 / 12.0); +const kSqrt04_15 = Math.sqrt(4.0 / 15.0); +const kSqrt01_16 = Math.sqrt(1.0 / 16.0); +const kSqrt15_16 = Math.sqrt(15.0 / 16.0); +const kSqrt01_18 = Math.sqrt(1.0 / 18.0); +const kSqrt01_60 = Math.sqrt(1.0 / 60.0); + +const dp = (n: number, start: number, a: number[] | Float32Array, b: number[] | Float32Array) => { + let sum = 0; + for (let i = 0; i < n; i++) { + sum += a[start + i] * b[i]; + } + return sum; +}; + +const coeffsIn = new Float32Array(15); + +// Rotate spherical harmonics up to band 3 based on https://github.com/andrewwillmott/sh-lib +// +// This implementation calculates the rotation factors during construction which can then +// be used to rotate multiple spherical harmonics cheaply. +class SHRotation { + apply: (result: Float32Array | number[], src?: Float32Array | number[]) => void; + + constructor(mat: Mat3) { + const rot = mat.data; + + // band 1 + const sh1 = [ + [rot[4], -rot[7], rot[1]], + [-rot[5], rot[8], -rot[2]], + [rot[3], -rot[6], rot[0]] + ]; + + // band 2 + const sh2 = [[ + kSqrt01_04 * ((sh1[2][2] * sh1[0][0] + sh1[2][0] * sh1[0][2]) + (sh1[0][2] * sh1[2][0] + sh1[0][0] * sh1[2][2])), + (sh1[2][1] * sh1[0][0] + sh1[0][1] * sh1[2][0]), + kSqrt03_04 * (sh1[2][1] * sh1[0][1] + sh1[0][1] * sh1[2][1]), + (sh1[2][1] * sh1[0][2] + sh1[0][1] * sh1[2][2]), + kSqrt01_04 * ((sh1[2][2] * sh1[0][2] - sh1[2][0] * sh1[0][0]) + (sh1[0][2] * sh1[2][2] - sh1[0][0] * sh1[2][0])) + ], [ + kSqrt01_04 * ((sh1[1][2] * sh1[0][0] + sh1[1][0] * sh1[0][2]) + (sh1[0][2] * sh1[1][0] + sh1[0][0] * sh1[1][2])), + sh1[1][1] * sh1[0][0] + sh1[0][1] * sh1[1][0], + kSqrt03_04 * (sh1[1][1] * sh1[0][1] + sh1[0][1] * sh1[1][1]), + sh1[1][1] * sh1[0][2] + sh1[0][1] * sh1[1][2], + kSqrt01_04 * ((sh1[1][2] * sh1[0][2] - sh1[1][0] * sh1[0][0]) + (sh1[0][2] * sh1[1][2] - sh1[0][0] * sh1[1][0])) + ], [ + kSqrt01_03 * (sh1[1][2] * sh1[1][0] + sh1[1][0] * sh1[1][2]) - kSqrt01_12 * ((sh1[2][2] * sh1[2][0] + sh1[2][0] * sh1[2][2]) + (sh1[0][2] * sh1[0][0] + sh1[0][0] * sh1[0][2])), + kSqrt04_03 * sh1[1][1] * sh1[1][0] - kSqrt01_03 * (sh1[2][1] * sh1[2][0] + sh1[0][1] * sh1[0][0]), + sh1[1][1] * sh1[1][1] - kSqrt01_04 * (sh1[2][1] * sh1[2][1] + sh1[0][1] * sh1[0][1]), + kSqrt04_03 * sh1[1][1] * sh1[1][2] - kSqrt01_03 * (sh1[2][1] * sh1[2][2] + sh1[0][1] * sh1[0][2]), + kSqrt01_03 * (sh1[1][2] * sh1[1][2] - sh1[1][0] * sh1[1][0]) - kSqrt01_12 * ((sh1[2][2] * sh1[2][2] - sh1[2][0] * sh1[2][0]) + (sh1[0][2] * sh1[0][2] - sh1[0][0] * sh1[0][0])) + ], [ + kSqrt01_04 * ((sh1[1][2] * sh1[2][0] + sh1[1][0] * sh1[2][2]) + (sh1[2][2] * sh1[1][0] + sh1[2][0] * sh1[1][2])), + sh1[1][1] * sh1[2][0] + sh1[2][1] * sh1[1][0], + kSqrt03_04 * (sh1[1][1] * sh1[2][1] + sh1[2][1] * sh1[1][1]), + sh1[1][1] * sh1[2][2] + sh1[2][1] * sh1[1][2], + kSqrt01_04 * ((sh1[1][2] * sh1[2][2] - sh1[1][0] * sh1[2][0]) + (sh1[2][2] * sh1[1][2] - sh1[2][0] * sh1[1][0])) + ], [ + kSqrt01_04 * ((sh1[2][2] * sh1[2][0] + sh1[2][0] * sh1[2][2]) - (sh1[0][2] * sh1[0][0] + sh1[0][0] * sh1[0][2])), + (sh1[2][1] * sh1[2][0] - sh1[0][1] * sh1[0][0]), + kSqrt03_04 * (sh1[2][1] * sh1[2][1] - sh1[0][1] * sh1[0][1]), + (sh1[2][1] * sh1[2][2] - sh1[0][1] * sh1[0][2]), + kSqrt01_04 * ((sh1[2][2] * sh1[2][2] - sh1[2][0] * sh1[2][0]) - (sh1[0][2] * sh1[0][2] - sh1[0][0] * sh1[0][0])) + ]]; + + // band 3 + const sh3 = [[ + kSqrt01_04 * ((sh1[2][2] * sh2[0][0] + sh1[2][0] * sh2[0][4]) + (sh1[0][2] * sh2[4][0] + sh1[0][0] * sh2[4][4])), + kSqrt03_02 * (sh1[2][1] * sh2[0][0] + sh1[0][1] * sh2[4][0]), + kSqrt15_16 * (sh1[2][1] * sh2[0][1] + sh1[0][1] * sh2[4][1]), + kSqrt05_06 * (sh1[2][1] * sh2[0][2] + sh1[0][1] * sh2[4][2]), + kSqrt15_16 * (sh1[2][1] * sh2[0][3] + sh1[0][1] * sh2[4][3]), + kSqrt03_02 * (sh1[2][1] * sh2[0][4] + sh1[0][1] * sh2[4][4]), + kSqrt01_04 * ((sh1[2][2] * sh2[0][4] - sh1[2][0] * sh2[0][0]) + (sh1[0][2] * sh2[4][4] - sh1[0][0] * sh2[4][0])) + ], [ + kSqrt01_06 * (sh1[1][2] * sh2[0][0] + sh1[1][0] * sh2[0][4]) + kSqrt01_06 * ((sh1[2][2] * sh2[1][0] + sh1[2][0] * sh2[1][4]) + (sh1[0][2] * sh2[3][0] + sh1[0][0] * sh2[3][4])), + sh1[1][1] * sh2[0][0] + (sh1[2][1] * sh2[1][0] + sh1[0][1] * sh2[3][0]), + kSqrt05_08 * sh1[1][1] * sh2[0][1] + kSqrt05_08 * (sh1[2][1] * sh2[1][1] + sh1[0][1] * sh2[3][1]), + kSqrt05_09 * sh1[1][1] * sh2[0][2] + kSqrt05_09 * (sh1[2][1] * sh2[1][2] + sh1[0][1] * sh2[3][2]), + kSqrt05_08 * sh1[1][1] * sh2[0][3] + kSqrt05_08 * (sh1[2][1] * sh2[1][3] + sh1[0][1] * sh2[3][3]), + sh1[1][1] * sh2[0][4] + (sh1[2][1] * sh2[1][4] + sh1[0][1] * sh2[3][4]), + kSqrt01_06 * (sh1[1][2] * sh2[0][4] - sh1[1][0] * sh2[0][0]) + kSqrt01_06 * ((sh1[2][2] * sh2[1][4] - sh1[2][0] * sh2[1][0]) + (sh1[0][2] * sh2[3][4] - sh1[0][0] * sh2[3][0])) + ], [ + kSqrt04_15 * (sh1[1][2] * sh2[1][0] + sh1[1][0] * sh2[1][4]) + kSqrt01_05 * (sh1[0][2] * sh2[2][0] + sh1[0][0] * sh2[2][4]) - kSqrt01_60 * ((sh1[2][2] * sh2[0][0] + sh1[2][0] * sh2[0][4]) - (sh1[0][2] * sh2[4][0] + sh1[0][0] * sh2[4][4])), + kSqrt08_05 * sh1[1][1] * sh2[1][0] + kSqrt06_05 * sh1[0][1] * sh2[2][0] - kSqrt01_10 * (sh1[2][1] * sh2[0][0] - sh1[0][1] * sh2[4][0]), + sh1[1][1] * sh2[1][1] + kSqrt03_04 * sh1[0][1] * sh2[2][1] - kSqrt01_16 * (sh1[2][1] * sh2[0][1] - sh1[0][1] * sh2[4][1]), + kSqrt08_09 * sh1[1][1] * sh2[1][2] + kSqrt02_03 * sh1[0][1] * sh2[2][2] - kSqrt01_18 * (sh1[2][1] * sh2[0][2] - sh1[0][1] * sh2[4][2]), + sh1[1][1] * sh2[1][3] + kSqrt03_04 * sh1[0][1] * sh2[2][3] - kSqrt01_16 * (sh1[2][1] * sh2[0][3] - sh1[0][1] * sh2[4][3]), + kSqrt08_05 * sh1[1][1] * sh2[1][4] + kSqrt06_05 * sh1[0][1] * sh2[2][4] - kSqrt01_10 * (sh1[2][1] * sh2[0][4] - sh1[0][1] * sh2[4][4]), + kSqrt04_15 * (sh1[1][2] * sh2[1][4] - sh1[1][0] * sh2[1][0]) + kSqrt01_05 * (sh1[0][2] * sh2[2][4] - sh1[0][0] * sh2[2][0]) - kSqrt01_60 * ((sh1[2][2] * sh2[0][4] - sh1[2][0] * sh2[0][0]) - (sh1[0][2] * sh2[4][4] - sh1[0][0] * sh2[4][0])) + ], [ + kSqrt03_10 * (sh1[1][2] * sh2[2][0] + sh1[1][0] * sh2[2][4]) - kSqrt01_10 * ((sh1[2][2] * sh2[3][0] + sh1[2][0] * sh2[3][4]) + (sh1[0][2] * sh2[1][0] + sh1[0][0] * sh2[1][4])), + kSqrt09_05 * sh1[1][1] * sh2[2][0] - kSqrt03_05 * (sh1[2][1] * sh2[3][0] + sh1[0][1] * sh2[1][0]), + kSqrt09_08 * sh1[1][1] * sh2[2][1] - kSqrt03_08 * (sh1[2][1] * sh2[3][1] + sh1[0][1] * sh2[1][1]), + sh1[1][1] * sh2[2][2] - kSqrt01_03 * (sh1[2][1] * sh2[3][2] + sh1[0][1] * sh2[1][2]), + kSqrt09_08 * sh1[1][1] * sh2[2][3] - kSqrt03_08 * (sh1[2][1] * sh2[3][3] + sh1[0][1] * sh2[1][3]), + kSqrt09_05 * sh1[1][1] * sh2[2][4] - kSqrt03_05 * (sh1[2][1] * sh2[3][4] + sh1[0][1] * sh2[1][4]), + kSqrt03_10 * (sh1[1][2] * sh2[2][4] - sh1[1][0] * sh2[2][0]) - kSqrt01_10 * ((sh1[2][2] * sh2[3][4] - sh1[2][0] * sh2[3][0]) + (sh1[0][2] * sh2[1][4] - sh1[0][0] * sh2[1][0])) + ], [ + kSqrt04_15 * (sh1[1][2] * sh2[3][0] + sh1[1][0] * sh2[3][4]) + kSqrt01_05 * (sh1[2][2] * sh2[2][0] + sh1[2][0] * sh2[2][4]) - kSqrt01_60 * ((sh1[2][2] * sh2[4][0] + sh1[2][0] * sh2[4][4]) + (sh1[0][2] * sh2[0][0] + sh1[0][0] * sh2[0][4])), + kSqrt08_05 * sh1[1][1] * sh2[3][0] + kSqrt06_05 * sh1[2][1] * sh2[2][0] - kSqrt01_10 * (sh1[2][1] * sh2[4][0] + sh1[0][1] * sh2[0][0]), + sh1[1][1] * sh2[3][1] + kSqrt03_04 * sh1[2][1] * sh2[2][1] - kSqrt01_16 * (sh1[2][1] * sh2[4][1] + sh1[0][1] * sh2[0][1]), + kSqrt08_09 * sh1[1][1] * sh2[3][2] + kSqrt02_03 * sh1[2][1] * sh2[2][2] - kSqrt01_18 * (sh1[2][1] * sh2[4][2] + sh1[0][1] * sh2[0][2]), + sh1[1][1] * sh2[3][3] + kSqrt03_04 * sh1[2][1] * sh2[2][3] - kSqrt01_16 * (sh1[2][1] * sh2[4][3] + sh1[0][1] * sh2[0][3]), + kSqrt08_05 * sh1[1][1] * sh2[3][4] + kSqrt06_05 * sh1[2][1] * sh2[2][4] - kSqrt01_10 * (sh1[2][1] * sh2[4][4] + sh1[0][1] * sh2[0][4]), + kSqrt04_15 * (sh1[1][2] * sh2[3][4] - sh1[1][0] * sh2[3][0]) + kSqrt01_05 * (sh1[2][2] * sh2[2][4] - sh1[2][0] * sh2[2][0]) - kSqrt01_60 * ((sh1[2][2] * sh2[4][4] - sh1[2][0] * sh2[4][0]) + (sh1[0][2] * sh2[0][4] - sh1[0][0] * sh2[0][0])) + ], [ + kSqrt01_06 * (sh1[1][2] * sh2[4][0] + sh1[1][0] * sh2[4][4]) + kSqrt01_06 * ((sh1[2][2] * sh2[3][0] + sh1[2][0] * sh2[3][4]) - (sh1[0][2] * sh2[1][0] + sh1[0][0] * sh2[1][4])), + sh1[1][1] * sh2[4][0] + (sh1[2][1] * sh2[3][0] - sh1[0][1] * sh2[1][0]), + kSqrt05_08 * sh1[1][1] * sh2[4][1] + kSqrt05_08 * (sh1[2][1] * sh2[3][1] - sh1[0][1] * sh2[1][1]), + kSqrt05_09 * sh1[1][1] * sh2[4][2] + kSqrt05_09 * (sh1[2][1] * sh2[3][2] - sh1[0][1] * sh2[1][2]), + kSqrt05_08 * sh1[1][1] * sh2[4][3] + kSqrt05_08 * (sh1[2][1] * sh2[3][3] - sh1[0][1] * sh2[1][3]), + sh1[1][1] * sh2[4][4] + (sh1[2][1] * sh2[3][4] - sh1[0][1] * sh2[1][4]), + kSqrt01_06 * (sh1[1][2] * sh2[4][4] - sh1[1][0] * sh2[4][0]) + kSqrt01_06 * ((sh1[2][2] * sh2[3][4] - sh1[2][0] * sh2[3][0]) - (sh1[0][2] * sh2[1][4] - sh1[0][0] * sh2[1][0])) + ], [ + kSqrt01_04 * ((sh1[2][2] * sh2[4][0] + sh1[2][0] * sh2[4][4]) - (sh1[0][2] * sh2[0][0] + sh1[0][0] * sh2[0][4])), + kSqrt03_02 * (sh1[2][1] * sh2[4][0] - sh1[0][1] * sh2[0][0]), + kSqrt15_16 * (sh1[2][1] * sh2[4][1] - sh1[0][1] * sh2[0][1]), + kSqrt05_06 * (sh1[2][1] * sh2[4][2] - sh1[0][1] * sh2[0][2]), + kSqrt15_16 * (sh1[2][1] * sh2[4][3] - sh1[0][1] * sh2[0][3]), + kSqrt03_02 * (sh1[2][1] * sh2[4][4] - sh1[0][1] * sh2[0][4]), + kSqrt01_04 * ((sh1[2][2] * sh2[4][4] - sh1[2][0] * sh2[4][0]) - (sh1[0][2] * sh2[0][4] - sh1[0][0] * sh2[0][0])) + ]]; + + // rotate spherical harmonic coefficients, up to band 3 + this.apply = (result: Float32Array | number[], src?: Float32Array | number[]) => { + if (!src || src === result) { + coeffsIn.set(result); + src = coeffsIn; + } + + // band 1 + if (result.length < 3) { + return; + } + result[0] = dp(3, 0, src, sh1[0]); + result[1] = dp(3, 0, src, sh1[1]); + result[2] = dp(3, 0, src, sh1[2]); + + // band 2 + if (result.length < 8) { + return; + } + result[3] = dp(5, 3, src, sh2[0]); + result[4] = dp(5, 3, src, sh2[1]); + result[5] = dp(5, 3, src, sh2[2]); + result[6] = dp(5, 3, src, sh2[3]); + result[7] = dp(5, 3, src, sh2[4]); + + // band 3 + if (result.length < 15) { + return; + } + result[8] = dp(7, 8, src, sh3[0]); + result[9] = dp(7, 8, src, sh3[1]); + result[10] = dp(7, 8, src, sh3[2]); + result[11] = dp(7, 8, src, sh3[3]); + result[12] = dp(7, 8, src, sh3[4]); + result[13] = dp(7, 8, src, sh3[5]); + result[14] = dp(7, 8, src, sh3[6]); + }; + } +} + +export { SHRotation }; diff --git a/src/shaders/blit-shader.ts b/src/shaders/blit-shader.ts new file mode 100644 index 0000000..489a2ab --- /dev/null +++ b/src/shaders/blit-shader.ts @@ -0,0 +1,16 @@ +const vertexShader = /* glsl*/ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl*/ ` + uniform sampler2D srcTexture; + void main(void) { + ivec2 texel = ivec2(gl_FragCoord.xy); + gl_FragColor = texelFetch(srcTexture, texel, 0); + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/bound-shader.ts b/src/shaders/bound-shader.ts new file mode 100644 index 0000000..c95539f --- /dev/null +++ b/src/shaders/bound-shader.ts @@ -0,0 +1,84 @@ +const vertexShader = /* glsl */ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + uniform highp usampler2D transformA; // splat center x, y, z + uniform highp usampler2D splatTransform; // transform palette index + uniform sampler2D transformPalette; // palette of transforms + uniform sampler2D splatState; // per-splat state + uniform highp ivec3 splat_params; // texture width, texture height, num splats + + // Custom infinity check that transpiles correctly to WGSL + bvec3 isInf(vec3 v) { + return greaterThan(abs(v), vec3(1e30)); + } + + // calculate min and max for a single column of splats + // outputs both selected bounds and all visible bounds + void main(void) { + + vec3 selectedMin = vec3(1e6); + vec3 selectedMax = vec3(-1e6); + vec3 visibleMin = vec3(1e6); + vec3 visibleMax = vec3(-1e6); + + for (int id = 0; id < splat_params.y; id++) { + // calculate splatUV + ivec2 splatUV = ivec2(gl_FragCoord.x, id); + + // skip out-of-range splats + if ((splatUV.x + splatUV.y * splat_params.x) >= splat_params.z) { + continue; + } + + // read splat state + uint state = uint(texelFetch(splatState, splatUV, 0).r * 255.0); + + // skip deleted splats for both bounds + if ((state & 4u) != 0u) { + continue; + } + + // read splat center + vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz); + + // apply optional per-splat transform + uint transformIndex = texelFetch(splatTransform, splatUV, 0).r; + if (transformIndex > 0u) { + // read transform matrix + int u = int(transformIndex % 512u) * 3; + int v = int(transformIndex / 512u); + + mat3x4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + + center = vec4(center, 1.0) * t; + } + + vec3 safeCenter = mix(center, vec3(1e6), isInf(center)); + + // update visible bounds (all non-deleted splats) + visibleMin = min(visibleMin, safeCenter); + visibleMax = max(visibleMax, mix(center, visibleMax, isInf(center))); + + // update selected bounds (only exactly selected splats) + if (state == 1u) { + selectedMin = min(selectedMin, safeCenter); + selectedMax = max(selectedMax, mix(center, selectedMax, isInf(center))); + } + } + + pcFragColor0 = vec4(selectedMin, 0.0); + pcFragColor1 = vec4(selectedMax, 0.0); + pcFragColor2 = vec4(visibleMin, 0.0); + pcFragColor3 = vec4(visibleMax, 0.0); + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/box-shape-shader.ts b/src/shaders/box-shape-shader.ts new file mode 100644 index 0000000..efd7f80 --- /dev/null +++ b/src/shaders/box-shape-shader.ts @@ -0,0 +1,111 @@ +const vertexShader = /* glsl */ ` + attribute vec3 vertex_position; + + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + void main() { + gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + // ray-box intersection in box space + bool intersectBox(out float t0, out float t1, out int axis0, out int axis1, vec3 pos, vec3 dir, vec3 boxCen, vec3 boxLen) + { + bvec3 validDir = notEqual(dir, vec3(0.0)); + vec3 absDir = abs(dir); + vec3 signDir = sign(dir); + vec3 m = vec3( + validDir.x ? 1.0 / absDir.x : 0.0, + validDir.y ? 1.0 / absDir.y : 0.0, + validDir.z ? 1.0 / absDir.z : 0.0 + ) * signDir; + + vec3 n = m * (pos - boxCen); + vec3 k = abs(m) * boxLen; + + vec3 v0 = -n - k; + vec3 v1 = -n + k; + + // replace invalid axes with -inf and +inf so the tests below ignore them + v0 = mix(vec3(-1.0 / 0.0000001), v0, validDir); + v1 = mix(vec3(1.0 / 0.0000001), v1, validDir); + + axis0 = (v0.x > v0.y) ? ((v0.x > v0.z) ? 0 : 2) : ((v0.y > v0.z) ? 1 : 2); + axis1 = (v1.x < v1.y) ? ((v1.x < v1.z) ? 0 : 2) : ((v1.y < v1.z) ? 1 : 2); + + t0 = v0[axis0]; + t1 = v1[axis1]; + + if (t0 > t1 || t1 < 0.0) { + return false; + } + + return true; + } + + float calcDepth(in vec3 pos, in mat4 viewProjection) { + vec4 v = viewProjection * vec4(pos, 1.0); + return (v.z / v.w) * 0.5 + 0.5; + } + + uniform sampler2D blueNoiseTex32; + uniform mat4 matrix_viewProjection; + uniform vec3 boxCen; + uniform vec3 boxLen; + + uniform vec3 near_origin; + uniform vec3 near_x; + uniform vec3 near_y; + + uniform vec3 far_origin; + uniform vec3 far_x; + uniform vec3 far_y; + + uniform vec2 targetSize; + + bool writeDepth(float alpha) { + ivec2 uv = ivec2(gl_FragCoord.xy); + ivec2 size = textureSize(blueNoiseTex32, 0); + return alpha > texelFetch(blueNoiseTex32, uv % size, 0).y; + } + + bool strips(vec3 pos, int axis) { + bvec3 b = lessThan(fract(pos * 2.0 + vec3(0.015)), vec3(0.03)); + b[axis] = false; + return any(b); + } + + void main() { + vec2 clip = gl_FragCoord.xy / targetSize; + vec3 worldNear = near_origin + near_x * clip.x + near_y * clip.y; + vec3 worldFar = far_origin + far_x * clip.x + far_y * clip.y; + vec3 rayDir = normalize(worldFar - worldNear); + + float t0, t1; + int axis0, axis1; + if (!intersectBox(t0, t1, axis0, axis1, worldNear, rayDir, boxCen, boxLen)) { + gl_FragColor = vec4(1.0, 0.0, 0.0, 0.6); + return; + } + + vec3 frontPos = worldNear + rayDir * t0; + bool front = t0 > 0.0 && strips(frontPos - boxCen, axis0); + + vec3 backPos = worldNear + rayDir * t1; + bool back = strips(backPos - boxCen, axis1); + + if (front) { + gl_FragColor = vec4(1.0, 1.0, 1.0, 0.6); + gl_FragDepth = writeDepth(0.6) ? calcDepth(frontPos, matrix_viewProjection) : 1.0; + } else if (back) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 0.6); + gl_FragDepth = writeDepth(0.6) ? calcDepth(backPos, matrix_viewProjection) : 1.0; + } else { + discard; + } + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/debug-shader.ts b/src/shaders/debug-shader.ts new file mode 100644 index 0000000..da0fd94 --- /dev/null +++ b/src/shaders/debug-shader.ts @@ -0,0 +1,37 @@ +const vertexShader = /* glsl */ ` + attribute vec3 vertex_position; + attribute vec4 vertex_color; + + varying vec4 vColor; + varying vec2 vZW; + + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + void main(void) { + gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); + + // store z/w for later use in fragment shader + vColor = vertex_color; + vZW = gl_Position.zw; + + // disable depth clipping + gl_Position.z = 0.0; + } +`; + +const fragmentShader = /* glsl */ ` + precision highp float; + + varying vec4 vColor; + varying vec2 vZW; + + void main(void) { + gl_FragColor = vColor; + + // clamp depth in Z to [0, 1] range + gl_FragDepth = max(0.0, min(1.0, (vZW.x / vZW.y + 1.0) * 0.5)); + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/equirect-shader.ts b/src/shaders/equirect-shader.ts new file mode 100644 index 0000000..c65cd47 --- /dev/null +++ b/src/shaders/equirect-shader.ts @@ -0,0 +1,88 @@ +// cube faces are rendered with a fov wider than 90° so neighbouring faces +// overlap. splats rasterize with slightly different shapes and composite in a +// different sort order per face, so a hard face boundary shows as a seam; +// blending the overlap feathers the mismatch away. weights ramp from 1 at +// blendStart degrees off-axis to 0 at faceFov / 2. +const faceFov = 100; +const blendStart = 40; + +const outerTan = Math.tan((faceFov / 2) * (Math.PI / 180)); +const innerTan = Math.tan(blendStart * (Math.PI / 180)); +const uvScale = 0.5 / outerTan; + +const vertexShader = /* glsl*/ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +// project six overlapping face renders to an equirectangular panorama. faces +// are captured in the (level or full) camera orientation frame, so the +// direction below is a capture-space direction: image center (lon 0, lat 0) +// is the capture forward (-Z), lon +PI/2 is capture right (+X), lat +PI/2 is +// up (+Y). +// +// face textures store camera renders bottom-row-first (flipY: false render +// targets), which matches gl_FragCoord's bottom-left origin, so t is +// up-positive and no flips are needed anywhere. +const fragmentShader = /* glsl*/ ` + uniform sampler2D uFace0; // front -Z + uniform sampler2D uFace1; // right +X + uniform sampler2D uFace2; // back +Z + uniform sampler2D uFace3; // left -X + uniform sampler2D uFace4; // up +Y + uniform sampler2D uFace5; // down -Y + uniform vec2 uTargetSize; + + #define PI 3.141592653589793 + + // blend weight and texture uv for a face with the given basis: the + // direction is mapped to the face's ndc, p = (dot(d, right), dot(d, up)) / + // dot(d, forward), and the weight feathers to zero towards the face edge + float faceWeight(vec3 d, vec3 r, vec3 u, vec3 f, out vec2 st) { + float dn = dot(d, f); + if (dn <= 0.0) { + st = vec2(0.0); + return 0.0; + } + vec2 p = vec2(dot(d, r), dot(d, u)) / dn; + st = p * ${uvScale.toFixed(8)} + 0.5; + return 1.0 - smoothstep(${innerTan.toFixed(8)}, ${outerTan.toFixed(8)}, max(abs(p.x), abs(p.y))); + } + + void main(void) { + vec2 uv = gl_FragCoord.xy / uTargetSize; + float lon = (uv.x - 0.5) * 2.0 * PI; + float lat = (uv.y - 0.5) * PI; + + vec3 d = vec3(sin(lon) * cos(lat), sin(lat), -cos(lon) * cos(lat)); + + vec4 acc = vec4(0.0); + float wsum = 0.0; + vec2 st; + float w; + + w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0), st); + if (w > 0.0) { acc += w * texture2D(uFace0, st); wsum += w; } + + w = faceWeight(d, vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), st); + if (w > 0.0) { acc += w * texture2D(uFace1, st); wsum += w; } + + w = faceWeight(d, vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0), st); + if (w > 0.0) { acc += w * texture2D(uFace2, st); wsum += w; } + + w = faceWeight(d, vec3(0.0, 0.0, -1.0), vec3(0.0, 1.0, 0.0), vec3(-1.0, 0.0, 0.0), st); + if (w > 0.0) { acc += w * texture2D(uFace3, st); wsum += w; } + + w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), st); + if (w > 0.0) { acc += w * texture2D(uFace4, st); wsum += w; } + + w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, -1.0), vec3(0.0, -1.0, 0.0), st); + if (w > 0.0) { acc += w * texture2D(uFace5, st); wsum += w; } + + gl_FragColor = acc / max(wsum, 1e-5); + } +`; + +export { faceFov, vertexShader, fragmentShader }; diff --git a/src/shaders/histogram-shaders.ts b/src/shaders/histogram-shaders.ts new file mode 100644 index 0000000..7ee8588 --- /dev/null +++ b/src/shaders/histogram-shaders.ts @@ -0,0 +1,120 @@ +import { computeSplatValueGLSL } from './splat-value-shader'; + +const fullscreenVS = /* glsl */ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +// pass 1: tile min/max +// each fragment owns a contiguous range of splat indices and reduces them inline +const tileMinMaxFS = /* glsl */ ` + ${computeSplatValueGLSL} + + uniform int tileSize; + uniform int gridDim; + + #define MAX_TILE_SIZE 65536 + + void main(void) { + ivec2 tileXY = ivec2(gl_FragCoord); + int tileId = tileXY.y * gridDim + tileXY.x; + int baseIdx = tileId * tileSize; + int endIdx = min(baseIdx + tileSize, splat_params.y); + + float minVal = 1e30; + float maxVal = -1e30; + + for (int k = 0; k < MAX_TILE_SIZE; k++) { + int idx = baseIdx + k; + if (idx >= endIdx) break; + float val; + bool sel; + bool vis; + bool valid = computeSplatValue(idx, val, sel, vis); + if (!valid || !vis) continue; + minVal = min(minVal, val); + maxVal = max(maxVal, val); + } + + gl_FragColor = vec4(minVal, maxVal, 0.0, 0.0); + } +`; + +// pass 2: reduce 64×64 → 1×1 +const finalReduceFS = /* glsl */ ` + uniform sampler2D inputTex; + uniform int gridDim; + + #define MAX_GRID_DIM 64 + + void main(void) { + float minVal = 1e30; + float maxVal = -1e30; + + for (int y = 0; y < MAX_GRID_DIM; y++) { + if (y >= gridDim) break; + for (int x = 0; x < MAX_GRID_DIM; x++) { + if (x >= gridDim) break; + vec2 v = texelFetch(inputTex, ivec2(x, y), 0).rg; + minVal = min(minVal, v.x); + maxVal = max(maxVal, v.y); + } + } + + gl_FragColor = vec4(minVal, maxVal, 0.0, 0.0); + } +`; + +// pass 3: bin counting (point rendering, additive blending) +const binVS = /* glsl */ ` + ${computeSplatValueGLSL} + + uniform sampler2D minMax; + uniform int numBins; + + varying float v_flag; + + void main(void) { + float val; + bool sel; + bool vis; + bool valid = computeSplatValue(gl_VertexID, val, sel, vis); + bool include = valid && vis; + v_flag = include ? (sel ? 2.0 : 1.0) : 0.0; + + if (!include) { + gl_Position = vec4(2.0, 2.0, 0.0, 1.0); + gl_PointSize = 0.0; + return; + } + + vec2 mm = texelFetch(minMax, ivec2(0, 0), 0).rg; + float minV = mm.x; + float maxV = mm.y; + float n = (maxV == minV) ? 0.0 : (val - minV) / (maxV - minV); + int bin = clamp(int(n * float(numBins)), 0, numBins - 1); + + float xNDC = (float(bin) + 0.5) / float(numBins) * 2.0 - 1.0; + gl_Position = vec4(xNDC, 0.0, 0.0, 1.0); + gl_PointSize = 1.0; + } +`; + +const binFS = /* glsl */ ` + varying float v_flag; + void main(void) { + float sel = v_flag == 2.0 ? 1.0 : 0.0; + float unsel = v_flag == 1.0 ? 1.0 : 0.0; + gl_FragColor = vec4(sel, unsel, 0.0, 0.0); + } +`; + +export { + fullscreenVS, + tileMinMaxFS, + finalReduceFS, + binVS, + binFS +}; diff --git a/src/shaders/infinite-grid-shader.ts b/src/shaders/infinite-grid-shader.ts new file mode 100644 index 0000000..06d40d8 --- /dev/null +++ b/src/shaders/infinite-grid-shader.ts @@ -0,0 +1,174 @@ +const vertexShader = /* glsl*/ ` + uniform vec3 near_origin; + uniform vec3 near_x; + uniform vec3 near_y; + + uniform vec3 far_origin; + uniform vec3 far_x; + uniform vec3 far_y; + + attribute vec2 vertex_position; + + varying vec3 worldFar; + varying vec3 worldNear; + + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + + vec2 p = vertex_position * 0.5 + 0.5; + worldNear = near_origin + near_x * p.x + near_y * p.y; + worldFar = far_origin + far_x * p.x + far_y * p.y; + } +`; + +const fragmentShader = /* glsl*/ ` + uniform vec3 view_position; + uniform mat4 matrix_viewProjection; + uniform sampler2D blueNoiseTex32; + + uniform int plane; // 0: x (yz), 1: y (xz), 2: z (xy) + + vec4 planes[3] = vec4[3]( + vec4(1.0, 0.0, 0.0, 0.0), + vec4(0.0, 1.0, 0.0, 0.0), + vec4(0.0, 0.0, 1.0, 0.0) + ); + + vec3 colors[3] = vec3[3]( + vec3(1.0, 0.2, 0.2), + vec3(0.2, 1.0, 0.2), + vec3(0.2, 0.2, 1.0) + ); + + int axis0[3] = int[3](1, 0, 0); + int axis1[3] = int[3](2, 2, 1); + + varying vec3 worldNear; + varying vec3 worldFar; + + bool intersectPlane(inout float t, vec3 pos, vec3 dir, vec4 plane) { + float d = dot(dir, plane.xyz); + if (abs(d) < 1e-06) { + return false; + } + + float n = -(dot(pos, plane.xyz) + plane.w) / d; + if (n < 0.0) { + return false; + } + + t = n; + + return true; + } + + // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c + float pristineGrid(in vec2 uv, in vec2 ddx, in vec2 ddy, vec2 lineWidth) { + vec2 uvDeriv = vec2(length(vec2(ddx.x, ddy.x)), length(vec2(ddx.y, ddy.y))); + bvec2 invertLine = bvec2(lineWidth.x > 0.5, lineWidth.y > 0.5); + vec2 targetWidth = vec2( + invertLine.x ? 1.0 - lineWidth.x : lineWidth.x, + invertLine.y ? 1.0 - lineWidth.y : lineWidth.y + ); + vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); + vec2 lineAA = uvDeriv * 1.5; + vec2 gridUV = abs(fract(uv) * 2.0 - 1.0); + gridUV.x = invertLine.x ? gridUV.x : 1.0 - gridUV.x; + gridUV.y = invertLine.y ? gridUV.y : 1.0 - gridUV.y; + vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); + + grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); + grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); + grid2.x = invertLine.x ? 1.0 - grid2.x : grid2.x; + grid2.y = invertLine.y ? 1.0 - grid2.y : grid2.y; + + return mix(grid2.x, 1.0, grid2.y); + } + + float calcDepth(vec3 p) { + vec4 v = matrix_viewProjection * vec4(p, 1.0); + return (v.z / v.w) * 0.5 + 0.5; + } + + bool writeDepth(float alpha) { + vec2 uv = fract(gl_FragCoord.xy / 32.0); + float noise = texture2DLod(blueNoiseTex32, uv, 0.0).y; + return alpha > noise; + } + + void main(void) { + vec3 p = worldNear; + vec3 v = normalize(worldFar - worldNear); + + // intersect ray with the world xz plane + float t; + if (!intersectPlane(t, p, v, planes[plane])) { + discard; + } + + // calculate grid intersection + vec3 worldPos = p + v * t; + vec2 pos = plane == 0 ? worldPos.yz : (plane == 1 ? worldPos.xz : worldPos.xy); + vec2 ddx = dFdx(pos); + vec2 ddy = dFdy(pos); + + float epsilon = 1.0 / 255.0; + + // calculate fade + float fade = 1.0 - smoothstep(400.0, 1000.0, length(worldPos - view_position)); + if (fade < epsilon) { + discard; + } + + vec2 levelPos; + float levelSize; + float levelAlpha; + + // 10m grid with colored main axes + levelPos = pos * 0.1; + levelSize = 2.0 / 1000.0; + levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2(levelSize)) * fade; + if (levelAlpha > epsilon) { + vec3 color; + vec2 loc = abs(levelPos); + if (loc.x < levelSize) { + if (loc.y < levelSize) { + color = vec3(1.0); + } else { + color = colors[axis1[plane]]; + } + } else if (loc.y < levelSize) { + color = colors[axis0[plane]]; + } else { + color = vec3(0.9); + } + gl_FragColor = vec4(color, levelAlpha); + gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0; + return; + } + + // 1m grid + levelPos = pos; + levelSize = 1.0 / 100.0; + levelAlpha = pristineGrid(levelPos, ddx, ddy, vec2(levelSize)) * fade; + if (levelAlpha > epsilon) { + gl_FragColor = vec4(vec3(0.7), levelAlpha); + gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0; + return; + } + + // 0.1m grid + levelPos = pos * 10.0; + levelSize = 1.0 / 100.0; + levelAlpha = pristineGrid(levelPos, ddx * 10.0, ddy * 10.0, vec2(levelSize)) * fade; + if (levelAlpha > epsilon) { + gl_FragColor = vec4(vec3(0.7), levelAlpha); + gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0; + return; + } + + discard; + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/intersection-shader.ts b/src/shaders/intersection-shader.ts new file mode 100644 index 0000000..8cea65b --- /dev/null +++ b/src/shaders/intersection-shader.ts @@ -0,0 +1,116 @@ +const vertexShader = /* glsl */ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + uniform highp usampler2D transformA; // splat center x, y, z + uniform highp usampler2D splatTransform; // transform palette index + uniform sampler2D transformPalette; // palette of transforms + uniform uvec2 splat_params; // splat texture width, num splats + + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + uniform uvec2 output_params; // output width, height + + // 0: mask, 1: rect, 2: sphere, 3: box + uniform int mode; + + // mask params + uniform sampler2D mask; // mask in alpha channel + uniform vec2 mask_params; // mask width, height + + // rect params + uniform vec4 rect_params; // rect x, y, width, height + + // sphere params + uniform vec4 sphere_params; // sphere x, y, z, radius + + // box params + uniform vec4 box_params; // box x, y, z + uniform vec4 aabb_params; // len x, y, z + + void main(void) { + // calculate output id + uvec2 outputUV = uvec2(gl_FragCoord); + uint outputId = (outputUV.x + outputUV.y * output_params.x) * 4u; + + vec4 clr = vec4(0.0); + + for (uint i = 0u; i < 4u; i++) { + uint id = outputId + i; + + if (id >= splat_params.y) { + continue; + } + + // calculate splatUV + ivec2 splatUV = ivec2( + int(id % splat_params.x), + int(id / splat_params.x) + ); + + // read splat center + vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz); + + // apply optional per-splat transform + uint transformIndex = texelFetch(splatTransform, splatUV, 0).r; + if (transformIndex > 0u) { + // read transform matrix + int u = int(transformIndex % 512u) * 3; + int v = int(transformIndex / 512u); + + mat3x4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + + center = vec4(center, 1.0) * t; + } + + // transform to world space (sphere/box modes test world-space containment) + vec3 world = (matrix_model * vec4(center, 1.0)).xyz; + + if (mode == 0 || mode == 1) { + // screen-space modes: project to clip space and skip offscreen fragments + vec4 clip = matrix_viewProjection * vec4(world, 1.0); + vec3 ndc = clip.xyz / clip.w; + + if (!any(greaterThan(abs(ndc), vec3(1.0)))) { + if (mode == 0) { + // select by mask + ivec2 maskUV = ivec2((ndc.xy * vec2(0.5, -0.5) + 0.5) * mask_params); + clr[i] = texelFetch(mask, maskUV, 0).a < 1.0 ? 0.0 : 1.0; + } else { + // select by rect + clr[i] = all(greaterThan(ndc.xy * vec2(1.0, -1.0), rect_params.xy)) && all(lessThan(ndc.xy * vec2(1.0, -1.0), rect_params.zw)) ? 1.0 : 0.0; + } + } + } else if (mode == 2) { + // select by sphere (world-space, independent of camera frustum) + clr[i] = length(world - sphere_params.xyz) < sphere_params.w ? 1.0 : 0.0; + } else if (mode == 3) { + // select by box (world-space, independent of camera frustum) + vec3 relativePosition = world - box_params.xyz; + bool isInsideCube = true; + if (relativePosition.x < -aabb_params.x || relativePosition.x > aabb_params.x) { + isInsideCube = false; + } + if (relativePosition.y < -aabb_params.y || relativePosition.y > aabb_params.y) { + isInsideCube = false; + } + if (relativePosition.z < -aabb_params.z || relativePosition.z > aabb_params.z) { + isInsideCube = false; + } + clr[i] = isInsideCube ? 1.0 : 0.0; + } + } + + gl_FragColor = clr; + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/outline-shader.ts b/src/shaders/outline-shader.ts new file mode 100644 index 0000000..eebdcdb --- /dev/null +++ b/src/shaders/outline-shader.ts @@ -0,0 +1,34 @@ +const vertexShader = /* glsl*/ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl*/ ` + uniform sampler2D srcTexture; + uniform float alphaCutoff; + uniform vec4 clr; + + void main(void) { + ivec2 texel = ivec2(gl_FragCoord.xy); + + // skip solid pixels + if (texelFetch(srcTexture, texel, 0).a > alphaCutoff) { + discard; + } + + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + if ((x != 0) && (y != 0) && (texelFetch(srcTexture, texel + ivec2(x, y), 0).a > alphaCutoff)) { + gl_FragColor = clr; + return; + } + } + } + + discard; + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/position-shader.ts b/src/shaders/position-shader.ts new file mode 100644 index 0000000..45f4298 --- /dev/null +++ b/src/shaders/position-shader.ts @@ -0,0 +1,45 @@ +const vertexShader = /* glsl */ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + uniform highp usampler2D transformA; // splat center x, y, z + uniform highp usampler2D splatTransform; // transform palette index + uniform sampler2D transformPalette; // palette of transforms + uniform ivec2 splat_params; // splat texture width, num splats + + void main(void) { + // calculate output id + ivec2 splatUV = ivec2(gl_FragCoord); + + // skip if splat index is out of bounds + if (splatUV.x + splatUV.y * splat_params.x >= splat_params.y) { + discard; + } + + // read splat center + vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz); + + // apply optional per-splat transform + uint transformIndex = texelFetch(splatTransform, splatUV, 0).r; + if (transformIndex > 0u) { + // read transform matrix + int u = int(transformIndex % 512u) * 3; + int v = int(transformIndex / 512u); + + mat3x4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + + center = vec4(center, 1.0) * t; + } + + gl_FragColor = vec4(center, 0.0); + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/select-by-range-shader.ts b/src/shaders/select-by-range-shader.ts new file mode 100644 index 0000000..b7d2927 --- /dev/null +++ b/src/shaders/select-by-range-shader.ts @@ -0,0 +1,50 @@ +import { computeSplatValueGLSL } from './splat-value-shader'; + +// fragment writes a 4-byte texel per output pixel where each channel is 255 if +// the corresponding splat falls within the requested histogram-bucket range +// (and is visible / not locked / not deleted), or 0 otherwise. callers can +// then read the result back as a Uint8Array and use `data[i] === 255` as the +// per-splat selection predicate. + +const vertexShader = /* glsl */ ` + attribute vec2 vertex_position; + void main(void) { + gl_Position = vec4(vertex_position, 0.0, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + ${computeSplatValueGLSL} + + uniform ivec2 output_params; // result texture (width, height) + uniform vec2 minMax; // (min, max) from the last histogram pass + uniform int numBins; + uniform int rangeStart; + uniform int rangeEnd; + + float check(int idx) { + float val; + bool sel; + bool vis; + bool valid = computeSplatValue(idx, val, sel, vis); + if (!valid || !vis) return 0.0; + + float n = (minMax.y == minMax.x) ? 0.0 : (val - minMax.x) / (minMax.y - minMax.x); + int bin = clamp(int(n * float(numBins)), 0, numBins - 1); + return (bin >= rangeStart && bin <= rangeEnd) ? 1.0 : 0.0; + } + + void main(void) { + ivec2 outUV = ivec2(gl_FragCoord); + int baseIdx = (outUV.y * output_params.x + outUV.x) * 4; + + gl_FragColor = vec4( + check(baseIdx), + check(baseIdx + 1), + check(baseIdx + 2), + check(baseIdx + 3) + ); + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/sphere-shape-shader.ts b/src/shaders/sphere-shape-shader.ts new file mode 100644 index 0000000..77df544 --- /dev/null +++ b/src/shaders/sphere-shape-shader.ts @@ -0,0 +1,102 @@ +const vertexShader = /* glsl */ ` + attribute vec3 vertex_position; + + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + void main() { + gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); + } +`; + +const fragmentShader = /* glsl */ ` + bool intersectSphere(out float t0, out float t1, vec3 pos, vec3 dir, vec4 sphere) { + vec3 L = sphere.xyz - pos; + float tca = dot(L, dir); + + float d2 = sphere.w * sphere.w - (dot(L, L) - tca * tca); + if (d2 <= 0.0) { + return false; + } + + float thc = sqrt(d2); + t0 = tca - thc; + t1 = tca + thc; + if (t1 <= 0.0) { + return false; + } + + return true; + } + + float calcDepth(in vec3 pos, in mat4 viewProjection) { + vec4 v = viewProjection * vec4(pos, 1.0); + return (v.z / v.w) * 0.5 + 0.5; + } + + vec2 calcAzimuthElev(in vec3 dir) { + float azimuth = atan(dir.z, dir.x); + float elev = asin(dir.y); + return vec2(azimuth, elev) * 180.0 / 3.14159; + } + + uniform sampler2D blueNoiseTex32; + uniform mat4 matrix_viewProjection; + uniform vec4 sphere; + + uniform vec3 near_origin; + uniform vec3 near_x; + uniform vec3 near_y; + + uniform vec3 far_origin; + uniform vec3 far_x; + uniform vec3 far_y; + + uniform vec2 targetSize; + + bool writeDepth(float alpha) { + vec2 uv = fract(gl_FragCoord.xy / 32.0); + float noise = texture2DLod(blueNoiseTex32, uv, 0.0).y; + return alpha > noise; + } + + bool strips(vec3 lp) { + vec2 ae = calcAzimuthElev(normalize(lp)); + + float spacing = 180.0 / (2.0 * 3.14159 * sphere.w); + float size = 0.03; + return fract(ae.x / spacing) < size || + fract(ae.y / spacing) < size; + } + + void main() { + vec2 clip = gl_FragCoord.xy / targetSize; + vec3 worldNear = near_origin + near_x * clip.x + near_y * clip.y; + vec3 worldFar = far_origin + far_x * clip.x + far_y * clip.y; + + vec3 rayDir = normalize(worldFar - worldNear); + + float t0, t1; + if (!intersectSphere(t0, t1, worldNear, rayDir, sphere)) { + discard; + } + + vec3 frontPos = worldNear + rayDir * t0; + bool front = t0 > 0.0 && strips(frontPos - sphere.xyz); + + vec3 backPos = worldNear + rayDir * t1; + bool back = strips(backPos - sphere.xyz); + + if (front) { + gl_FragColor = vec4(1.0, 1.0, 1.0, 0.6); + gl_FragDepth = writeDepth(0.6) ? calcDepth(frontPos, matrix_viewProjection) : 1.0; + } else if (back) { + gl_FragColor = vec4(0.0, 0.0, 0.0, 0.6); + gl_FragDepth = writeDepth(0.6) ? calcDepth(backPos, matrix_viewProjection) : 1.0; + } else { + discard; + } + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/splat-overlay-shader.ts b/src/shaders/splat-overlay-shader.ts new file mode 100644 index 0000000..a51c81e --- /dev/null +++ b/src/shaders/splat-overlay-shader.ts @@ -0,0 +1,171 @@ +const vertexShader = /* glsl */ ` + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + uniform highp usampler2D splatOrder; // order texture mapping render order to splat ID + uniform uint splatTextureSize; // width of order texture + + uniform sampler2D splatState; + uniform highp usampler2D splatPosition; + uniform highp usampler2D splatTransform; // per-splat index into transform palette + uniform sampler2D transformPalette; // palette of transform matrices + uniform sampler2D splatColor; // Gaussian color texture (RGBA16F) + + // SH textures (for uncompressed format) + #if SH_BANDS > 0 + uniform highp usampler2D splatSH_1to3; + #if SH_BANDS > 1 + uniform highp usampler2D splatSH_4to7; + uniform highp usampler2D splatSH_8to11; + #if SH_BANDS > 2 + uniform highp usampler2D splatSH_12to15; + #endif + #endif + #endif + + uniform vec3 view_position; // camera position in world space + + uniform uvec2 texParams; + + uniform float splatSize; + uniform float useGaussianColor; // 0.0 = use selection colors, 1.0 = use gaussian color + uniform vec4 selectedClr; + uniform vec4 unselectedClr; + + varying vec4 varying_color; + + // calculate the current splat index and uv + ivec2 calcSplatUV(uint index, uint width) { + return ivec2(int(index % width), int(index / width)); + } + + #if SH_BANDS > 0 + + // include SH evaluation from engine (provides SH_COEFFS, constants, and evalSH) + #include "gsplatEvalSHVS" + + // unpack signed 11 10 11 bits + vec3 unpack111011s(uint bits) { + return vec3((uvec3(bits) >> uvec3(21u, 11u, 0u)) & uvec3(0x7ffu, 0x3ffu, 0x7ffu)) / vec3(2047.0, 1023.0, 2047.0) * 2.0 - 1.0; + } + + // fetch quantized spherical harmonic coefficients with scale + void fetchScale(in uvec4 t, out float scale, out vec3 a, out vec3 b, out vec3 c) { + scale = uintBitsToFloat(t.x); + a = unpack111011s(t.y); + b = unpack111011s(t.z); + c = unpack111011s(t.w); + } + + // fetch quantized spherical harmonic coefficients + void fetchSH(in uvec4 t, out vec3 a, out vec3 b, out vec3 c, out vec3 d) { + a = unpack111011s(t.x); + b = unpack111011s(t.y); + c = unpack111011s(t.z); + d = unpack111011s(t.w); + } + + void fetchSH1(in uint t, out vec3 a) { + a = unpack111011s(t); + } + + #if SH_BANDS == 1 + void readSHData(in ivec2 uv, out vec3 sh[3], out float scale) { + fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]); + } + #elif SH_BANDS == 2 + void readSHData(in ivec2 uv, out vec3 sh[8], out float scale) { + fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]); + fetchSH(texelFetch(splatSH_4to7, uv, 0), sh[3], sh[4], sh[5], sh[6]); + fetchSH1(texelFetch(splatSH_8to11, uv, 0).x, sh[7]); + } + #elif SH_BANDS == 3 + void readSHData(in ivec2 uv, out vec3 sh[15], out float scale) { + fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]); + fetchSH(texelFetch(splatSH_4to7, uv, 0), sh[3], sh[4], sh[5], sh[6]); + fetchSH(texelFetch(splatSH_8to11, uv, 0), sh[7], sh[8], sh[9], sh[10]); + fetchSH(texelFetch(splatSH_12to15, uv, 0), sh[11], sh[12], sh[13], sh[14]); + } + #endif + + #endif + + void main(void) { + // look up splat ID from order texture using gl_VertexID + ivec2 orderUV = ivec2(gl_VertexID % int(splatTextureSize), gl_VertexID / int(splatTextureSize)); + uint splatId = texelFetch(splatOrder, orderUV, 0).r; + + ivec2 splatUV = calcSplatUV(splatId, texParams.x); + uint splatState = uint(texelFetch(splatState, splatUV, 0).r * 255.0); + + // check for locked splats (deleted splats are already excluded from order texture) + if ((splatState & 2u) != 0u) { + // locked + gl_Position = vec4(0.0, 0.0, 2.0, 1.0); + gl_PointSize = 0.0; + } else { + mat4 model = matrix_model; + + // handle per-splat transform + uint transformIndex = texelFetch(splatTransform, splatUV, 0).r; + if (transformIndex > 0u) { + // read transform matrix + int u = int(transformIndex % 512u) * 3; + int v = int(transformIndex / 512u); + + mat4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + t[3] = vec4(0.0, 0.0, 0.0, 1.0); + + model = matrix_model * transpose(t); + } + + vec3 center = uintBitsToFloat(texelFetch(splatPosition, splatUV, 0).xyz); + + vec3 gaussianClr; + + if (useGaussianColor > 0.0) { + // get base gaussian color + gaussianClr = texelFetch(splatColor, splatUV, 0).xyz; + + #if SH_BANDS > 0 + // calculate world position and view direction + vec3 worldPos = (model * vec4(center, 1.0)).xyz; + vec3 viewDir = normalize(worldPos - view_position); + // transform view direction to model space + vec3 modelViewDir = normalize(viewDir * mat3(model)); + + // read and evaluate SH + vec3 sh[SH_COEFFS]; + float scale; + readSHData(splatUV, sh, scale); + gaussianClr += evalSH(sh, modelViewDir) * scale; + #endif + } else { + gaussianClr = unselectedClr.xyz; + } + + // choose between selection colors and gaussian color + varying_color = vec4(mix(gaussianClr, selectedClr.xyz, (splatState == 1u) ? selectedClr.w : 0.0), unselectedClr.w); + + gl_Position = matrix_viewProjection * model * vec4(center, 1.0); + + // disable depth clipping + gl_Position.z = 0.0; + + gl_PointSize = splatSize; + } + } +`; + +const fragmentShader = /* glsl */ ` + varying vec4 varying_color; + + void main(void) { + gl_FragColor = varying_color; + } +`; + +export { vertexShader, fragmentShader }; diff --git a/src/shaders/splat-shader.ts b/src/shaders/splat-shader.ts new file mode 100644 index 0000000..e59580a --- /dev/null +++ b/src/shaders/splat-shader.ts @@ -0,0 +1,282 @@ +const vertexShader = /* glsl*/` +#include "gsplatCommonVS" + +uniform sampler2D splatState; + +uniform vec4 selectedClr; +uniform vec4 lockedClr; + +uniform vec3 clrOffset; +uniform vec4 clrScale; + +varying mediump vec4 texCoord_flags; // xy: texCoord, z: selected, w: locked +varying mediump vec4 color; + +#if PICK_PASS + uniform uint pickOp; // 0: add, 1: remove, 2: set + uniform int pickMode; // 0: pick id, 1: depth estimation +#endif + +mediump vec4 discardVec = vec4(0.0, 0.0, 2.0, 1.0); + +uniform float saturation; + +vec3 applySaturation(vec3 color) { + vec3 grey = vec3(dot(color, vec3(0.299, 0.587, 0.114))); + return grey + (color - grey) * saturation; +} + +void main(void) { + // read gaussian details + SplatSource source; + if (!initSource(source)) { + gl_Position = discardVec; + return; + } + + // get per-gaussian edit state, discard if deleted + uint vertexState = uint(texelFetch(splatState, splat.uv, 0).r * 255.0 + 0.5) & 7u; + + #if PICK_PASS + if (pickOp == 0u) { + // add: skip deleted, locked and selected splats + if (vertexState != 0u) { + gl_Position = discardVec; + return; + } + } else if (pickOp == 1u) { + // remove: skip deleted, locked and unselected splats + if (vertexState != 1u) { + gl_Position = discardVec; + return; + } + } else { + // set: skip deleted and locked splats + if ((vertexState & 6u) != 0u) { + gl_Position = discardVec; + return; + } + } + #else + // skip deleted splats + if ((vertexState & 4u) != 0u) { + gl_Position = discardVec; + return; + } + #endif + + // get center + vec3 modelCenter = getCenter(); + + SplatCenter center; + center.modelCenterOriginal = modelCenter; + center.modelCenterModified = modelCenter; + if (!initCenter(modelCenter, center)) { + gl_Position = discardVec; + return; + } + + SplatCorner corner; + if (!initCorner(source, center, corner)) { + gl_Position = discardVec; + return; + } + + gl_Position = center.proj + vec4(corner.offset, 0.0); + + // store texture coord and locked state + texCoord_flags = vec4( + corner.uv, + (vertexState & 1u) != 0u ? 1.0 : 0.0, // selected + (vertexState & 2u) != 0u ? 1.0 : 0.0 // locked + ); + + #if PICK_PASS + if (pickMode == 1) { + // depth estimation mode: compute normalized depth in vertex shader + float linearDepth = -center.view.z; + float normalizedDepth = (linearDepth - camera_params.z) / (camera_params.y - camera_params.z); + vec4 clr = getColor(); + color = vec4(normalizedDepth, 0.0, 0.0, 1.0) * clr.a; + } else { + // pick id + uvec4 bits = (uvec4(splat.index) >> uvec4(0u, 8u, 16u, 24u)) & uvec4(255u); + color = vec4(bits) / 255.0; + } + // handle splat color + #elif FORWARD_PASS + // read color + color = getColor(); + + // evaluate spherical harmonics + #if SH_BANDS > 0 + // calculate the model-space view direction + vec3 dir = normalize(center.view * mat3(center.modelView)); + + // read sh coefficients + vec3 sh[SH_COEFFS]; + float scale; + readSHData(sh, scale); + + // evaluate + color.xyz += evalSH(sh, dir) * scale; + #endif + + // apply tint/brightness + color = color * clrScale + vec4(clrOffset, 0.0); + + // apply saturation + color.xyz = applySaturation(color.xyz); + + // don't allow out-of-range alpha + color.a = clamp(color.a, 0.0, 1.0); + + // apply tonemapping + color = vec4(prepareOutputFromGamma(max(color.xyz, 0.0), -center.view.z), color.w); + + // apply locked/selected colors + if ((vertexState & 2u) != 0u) { + // locked + color *= lockedClr; + } else if ((vertexState & 1u) != 0u) { + // selected + color.xyz = mix(color.xyz, selectedClr.xyz, selectedClr.a); + } + #endif +} +`; + +const fragmentShader = /* glsl*/` +varying mediump vec4 texCoord_flags; +varying mediump vec4 color; + +uniform bool outlineMode; +uniform float ringSize; + +#if PICK_PASS + uniform int pickMode; // 0: id, 1: depth estimation +#endif + +const float EXP4 = exp(-4.0); +const float INV_EXP4 = 1.0 / (1.0 - EXP4); + +float normExp(float x) { + return (exp(x * -4.0) - EXP4) * INV_EXP4; +} + +void main(void) { + mediump float A = dot(texCoord_flags.xy, texCoord_flags.xy); + + if (A > 1.0) { + discard; + } + + #if PICK_PASS + if (pickMode == 1) { + // depth estimation + mediump float alpha = normExp(A); + if (alpha < 1.0 / 255.0) { + discard; + } + // we should multiply by alpha here to take into account gaussian falloff, + // but it results in less accurate depth for some reason + gl_FragColor = color * alpha; + } else { + // pick id + gl_FragColor = color; + } + #else + mediump float norm = normExp(A); + mediump float alpha = norm * color.a; + + if (texCoord_flags.w == 0.0 && ringSize > 0.0) { + // rings mode + if (A < 1.0 - ringSize) { + alpha = max(0.05, alpha); + } else { + alpha = 0.6; + } + } + + bool selected = texCoord_flags.z != 0.0 && texCoord_flags.w == 0.0; + + if (outlineMode) { + pcFragColor0 = vec4(color.xyz * alpha, alpha); + pcFragColor1 = vec4(0.0, 0.0, 0.0, selected ? norm : 0.0); + } else { + if (selected) { + pcFragColor0 = vec4(color.xyz * alpha * 0.8, alpha); + pcFragColor1 = vec4(color.xyz * alpha * 0.2, alpha); + } else { + pcFragColor0 = vec4(color.xyz * alpha, alpha); + pcFragColor1 = vec4(0.0, 0.0, 0.0, 0.0); + } + } + #endif +} +`; + +const gsplatCenter = /* glsl*/` +uniform highp usampler2D splatTransform; // per-splat index into transform palette +uniform sampler2D transformPalette; // palette of transform matrices + +mat4 applyPaletteTransform(mat4 model) { + uint transformIndex = texelFetch(splatTransform, splat.uv, 0).r; + if (transformIndex == 0u) { + return model; + } + + // read transform matrix + int u = int(transformIndex % 512u) * 3; + int v = int(transformIndex / 512u); + + mat4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + t[3] = vec4(0.0, 0.0, 0.0, 1.0); + + return model * transpose(t); +} + +uniform mat4 matrix_model; +uniform mat4 matrix_view; +#ifndef GSPLAT_CENTER_NOPROJ + uniform vec4 camera_params; // 1 / far, far, near, isOrtho + uniform mat4 matrix_projection; +#endif + +// project the model space gaussian center to view and clip space +bool initCenter(vec3 modelCenter, inout SplatCenter center) { + mat4 modelView = matrix_view * applyPaletteTransform(matrix_model); + vec4 centerView = modelView * vec4(modelCenter, 1.0); + + #ifndef GSPLAT_CENTER_NOPROJ + + // early out if splat is behind the camera (perspective only) + // orthographic projections don't need this check as frustum culling handles it + if (camera_params.w != 1.0 && centerView.z > 0.0) { + return false; + } + + vec4 centerProj = matrix_projection * centerView; + + // ensure gaussians are not clipped by camera near and far + #if WEBGPU + centerProj.z = clamp(centerProj.z, 0, abs(centerProj.w)); + #else + centerProj.z = clamp(centerProj.z, -abs(centerProj.w), abs(centerProj.w)); + #endif + + center.proj = centerProj; + center.projMat00 = matrix_projection[0][0]; + + #endif + + center.view = centerView.xyz / centerView.w; + center.modelView = modelView; + return true; +} +`; + +export { vertexShader, fragmentShader, gsplatCenter }; diff --git a/src/shaders/splat-value-shader.ts b/src/shaders/splat-value-shader.ts new file mode 100644 index 0000000..b2985a3 --- /dev/null +++ b/src/shaders/splat-value-shader.ts @@ -0,0 +1,393 @@ +// shared GLSL chunk used by histogram and select-by-range GPU passes. +// +// declares the texture and uniform interface for reading per-splat data and +// computing a single scalar value selected by `propMode`. exposes a small +// extractor API (`Splat` struct + `readSplat`, `readColorDC`, `readOpacity`, +// `readScale`, `readRotation`, `readSHCoeff`, `readFinalColor`) so callers can +// also pull individual fields if they need something other than the propMode +// dispatch. +// +// propMode values: +// +// 0..2 world.x / world.y / world.z +// 3 distance (= length(world)) +// 4 camera depth (= -(viewMatrix * world).z) +// 5..7 "Red"/"Green"/"Blue" = final on-screen color channels. +// applyColorGrade(dcDecode(f_dc) + evalSH(viewDir)). view-dependent. +// 8 opacity (= splatColor.a * transparency) +// 9..11 scale_0 / scale_1 / scale_2 (exp'd in transformB.xyz) +// 12 volume (= scale.x * scale.y * scale.z) +// 13 surface area (= dot(scale, scale)) +// 14..17 quat W (reconstructed, always >= 0) / X / Y / Z +// 18..20 H / S / V of the final on-screen color (same dependence as 5..7) +// 21.. f_rest_N (N = propMode - 21), decoded from the engine's packed SH +// textures. only valid when SH_BANDS > 0 and N < 3 * shNumCoeffs. +// 66..68 raw "DC R"/"DC G"/"DC B" coefficients: inverse of `dcDecode` applied +// to `splatColor.rgb`. camera-independent. +// +// the active SH_BANDS define controls which SH samplers are declared and which +// branches are compiled in. callers must select a matching uniqueName so that +// each SH_BANDS variant gets its own cached shader. + +const computeSplatValueGLSL = /* glsl */ ` + +#ifndef SH_BANDS +#define SH_BANDS 0 +#endif + +#define SH_C0 0.28209479177387814 + +#if SH_BANDS == 1 +#define SH_COEFFS 3 +#elif SH_BANDS == 2 +#define SH_COEFFS 8 +#elif SH_BANDS == 3 +#define SH_COEFFS 15 +#endif + +uniform highp usampler2D transformA; +uniform sampler2D transformB; +uniform sampler2D splatColor; +uniform highp usampler2D splatTransform; +uniform sampler2D transformPalette; +uniform sampler2D splatState; + +#if SH_BANDS > 0 +uniform highp usampler2D splatSH_1to3; +uniform int shNumCoeffs; +#endif +#if SH_BANDS > 1 +uniform highp usampler2D splatSH_4to7; +uniform highp usampler2D splatSH_8to11; +#endif +#if SH_BANDS > 2 +uniform highp usampler2D splatSH_12to15; +#endif + +uniform ivec2 splat_params; +uniform int propMode; +uniform mat4 entityMatrix; +uniform mat4 viewMatrix; +uniform mat4 viewProjection; +uniform vec3 cameraWorldPos; +uniform int onScreenOnly; + +uniform vec3 cgScale; +uniform float cgOffset; +uniform float cgSaturation; +uniform float transparency; + +// SH band weighting constants (matches engine's gsplatEvalSH GLSL chunk). +#if SH_BANDS > 0 +const float SH_C1 = 0.4886025119029199; +#if SH_BANDS > 1 +const float SH_C2_0 = 1.0925484305920792; +const float SH_C2_1 = -1.0925484305920792; +const float SH_C2_2 = 0.31539156525252005; +const float SH_C2_3 = -1.0925484305920792; +const float SH_C2_4 = 0.5462742152960396; +#endif +#if SH_BANDS > 2 +const float SH_C3_0 = -0.5900435899266435; +const float SH_C3_1 = 2.890611442640554; +const float SH_C3_2 = -0.4570457994644658; +const float SH_C3_3 = 0.3731763325901154; +const float SH_C3_4 = -0.4570457994644658; +const float SH_C3_5 = 1.445305721320277; +const float SH_C3_6 = -0.5900435899266435; +#endif +#endif + +struct Splat { + int idx; + ivec2 uv; + int state; + bool selected; // state == 1 + bool valid; // state == 0 || state == 1 (not locked / deleted) + vec3 localPos; // pre-transform (from transformA) + vec3 worldPos; // post entity + per-splat transform + bool visible; // passes the onScreenOnly filter +}; + +vec3 applyColorGrade(vec3 c) { + c = cgOffset + c * cgScale; + float grey = dot(c, vec3(0.299, 0.587, 0.114)); + return mix(vec3(grey), c, cgSaturation); +} + +vec3 rgb2hsv(vec3 c) { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +#if SH_BANDS > 0 +// unpack the (R, G, B) SH triplet at coefficient index coeffIdx (0..14) for +// the splat at uv. R/B are stored in 11 bits, G in 10 bits, all per-splat +// normalized by the max value packed into splatSH_1to3.x. +vec3 unpackSHTriplet(int coeffIdx, ivec2 uv) { + uvec4 sh1 = texelFetch(splatSH_1to3, uv, 0); + float maxV = uintBitsToFloat(sh1.x); + + uint packed = 0u; + if (coeffIdx < 3) { + packed = sh1[coeffIdx + 1]; + } + #if SH_BANDS > 1 + else if (coeffIdx < 7) { + packed = texelFetch(splatSH_4to7, uv, 0)[coeffIdx - 3]; + } + else if (coeffIdx < 11) { + packed = texelFetch(splatSH_8to11, uv, 0)[coeffIdx - 7]; + } + #endif + #if SH_BANDS > 2 + else if (coeffIdx < 15) { + packed = texelFetch(splatSH_12to15, uv, 0)[coeffIdx - 11]; + } + #endif + + uint encR = (packed >> 21) & 0x7FFu; + uint encG = (packed >> 11) & 0x3FFu; + uint encB = packed & 0x7FFu; + + vec3 normalized = vec3( + (float(encR) / 2047.0) * 2.0 - 1.0, + (float(encG) / 1023.0) * 2.0 - 1.0, + (float(encB) / 2047.0) * 2.0 - 1.0 + ); + + return normalized * maxV; +} +#endif + +// populate s from the given index. returns false when the splat is +// out-of-bounds or in a locked / deleted state; in that case only idx, state, +// valid, and selected are reliably set. +bool readSplat(int idx, out Splat s) { + s.idx = idx; + s.uv = ivec2(0); + s.state = 0; + s.selected = false; + s.valid = false; + s.localPos = vec3(0.0); + s.worldPos = vec3(0.0); + s.visible = false; + + if (idx >= splat_params.y) return false; + s.uv = ivec2(idx % splat_params.x, idx / splat_params.x); + + s.state = int(texelFetch(splatState, s.uv, 0).r * 255.0 + 0.5); + s.selected = (s.state == 1); + bool clean = (s.state == 0); + if (!(s.selected || clean)) return false; + s.valid = true; + + uvec4 transformAData = texelFetch(transformA, s.uv, 0); + s.localPos = uintBitsToFloat(transformAData.xyz); + + vec3 pos = s.localPos; + uint ti = texelFetch(splatTransform, s.uv, 0).r; + if (ti > 0u) { + int u = int(ti % 512u) * 3; + int v = int(ti / 512u); + mat3x4 t; + t[0] = texelFetch(transformPalette, ivec2(u, v), 0); + t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0); + t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0); + pos = vec4(s.localPos, 1.0) * t; + } + + s.worldPos = (entityMatrix * vec4(pos, 1.0)).xyz; + + s.visible = true; + if (onScreenOnly == 1) { + vec4 clip = viewProjection * vec4(s.worldPos, 1.0); + if (clip.w <= 0.0) { + s.visible = false; + } else { + // OpenGL-style clip space: ndc in [-1, 1] on all axes. + vec3 ndc = clip.xyz / clip.w; + if (any(greaterThan(abs(ndc), vec3(1.0)))) { + s.visible = false; + } + } + } + + return true; +} + +// color-graded DC color (no SH contribution). this is what was historically +// returned for the "Red"/"Green"/"Blue" propModes, now exposed as a building +// block. +vec3 readColorDC(Splat s) { + return applyColorGrade(texelFetch(splatColor, s.uv, 0).rgb); +} + +// post-grade alpha = sigmoid(opacity) * transparency. +float readOpacity(Splat s) { + return texelFetch(splatColor, s.uv, 0).a * transparency; +} + +// exponentiated scale per axis. +vec3 readScale(Splat s) { + return texelFetch(transformB, s.uv, 0).xyz; +} + +// quaternion components as (W, X, Y, Z). W is reconstructed and always >= 0 +// because the engine canonicalises rotation signs during splat construction. +vec4 readRotation(Splat s) { + vec2 qxy = unpackHalf2x16(texelFetch(transformA, s.uv, 0).w); + float qz = texelFetch(transformB, s.uv, 0).w; + float qw = sqrt(max(0.0, 1.0 - qxy.x * qxy.x - qxy.y * qxy.y - qz * qz)); + return vec4(qw, qxy.x, qxy.y, qz); +} + +#if SH_BANDS > 0 +// returns the single-channel f_rest value at the given index (0..44 for +// shBands 3). identical layout to getSHData in the engine. +float readSHCoeff(Splat s, int fRestIdx) { + int channel = fRestIdx / shNumCoeffs; + int coeffIdx = fRestIdx % shNumCoeffs; + vec3 triplet = unpackSHTriplet(coeffIdx, s.uv); + if (channel == 0) return triplet.r; + if (channel == 1) return triplet.g; + return triplet.b; +} +#endif + +// final on-screen color: dcDecode(f_dc) + evalSH(viewDir), then ColorGrade. +// view-dependent. for shBands == 0 this collapses to the DC-only color. +vec3 readFinalColor(Splat s) { + vec3 color = texelFetch(splatColor, s.uv, 0).rgb; + + #if SH_BANDS > 0 + vec3 dir = normalize(s.worldPos - cameraWorldPos); + float x = dir.x; + float y = dir.y; + float z = dir.z; + + vec3 sh0 = unpackSHTriplet(0, s.uv); + vec3 sh1 = unpackSHTriplet(1, s.uv); + vec3 sh2 = unpackSHTriplet(2, s.uv); + color += SH_C1 * (-sh0 * y + sh1 * z - sh2 * x); + + #if SH_BANDS > 1 + float xx = x * x; + float yy = y * y; + float zz = z * z; + float xy = x * y; + float yz = y * z; + float xz = x * z; + + vec3 sh3 = unpackSHTriplet(3, s.uv); + vec3 sh4 = unpackSHTriplet(4, s.uv); + vec3 sh5 = unpackSHTriplet(5, s.uv); + vec3 sh6 = unpackSHTriplet(6, s.uv); + vec3 sh7 = unpackSHTriplet(7, s.uv); + color += + sh3 * (SH_C2_0 * xy) + + sh4 * (SH_C2_1 * yz) + + sh5 * (SH_C2_2 * (2.0 * zz - xx - yy)) + + sh6 * (SH_C2_3 * xz) + + sh7 * (SH_C2_4 * (xx - yy)); + #endif + + #if SH_BANDS > 2 + vec3 sh8 = unpackSHTriplet(8, s.uv); + vec3 sh9 = unpackSHTriplet(9, s.uv); + vec3 sh10 = unpackSHTriplet(10, s.uv); + vec3 sh11 = unpackSHTriplet(11, s.uv); + vec3 sh12 = unpackSHTriplet(12, s.uv); + vec3 sh13 = unpackSHTriplet(13, s.uv); + vec3 sh14 = unpackSHTriplet(14, s.uv); + color += + sh8 * (SH_C3_0 * y * (3.0 * xx - yy)) + + sh9 * (SH_C3_1 * xy * z) + + sh10 * (SH_C3_2 * y * (4.0 * zz - xx - yy)) + + sh11 * (SH_C3_3 * z * (2.0 * zz - 3.0 * xx - 3.0 * yy)) + + sh12 * (SH_C3_4 * x * (4.0 * zz - xx - yy)) + + sh13 * (SH_C3_5 * z * (xx - yy)) + + sh14 * (SH_C3_6 * x * (xx - 3.0 * yy)); + #endif + #endif + + return applyColorGrade(color); +} + +// computes the scalar value for the splat at the given index. out-params +// receive the value, whether the splat is selected, and whether it passes the +// onScreenOnly filter (always true when onScreenOnly == 0). +bool computeSplatValue(int idx, out float value, out bool selected, out bool visible) { + value = 0.0; + Splat s; + if (!readSplat(idx, s)) { + selected = false; + visible = false; + return false; + } + selected = s.selected; + visible = s.visible; + + if (propMode == 0) value = s.worldPos.x; + else if (propMode == 1) value = s.worldPos.y; + else if (propMode == 2) value = s.worldPos.z; + else if (propMode == 3) value = length(s.worldPos); + else if (propMode == 4) value = -(viewMatrix * vec4(s.worldPos, 1.0)).z; + else if (propMode == 5 || propMode == 6 || propMode == 7) { + vec3 rgb = readFinalColor(s); + if (propMode == 5) value = rgb.r; + else if (propMode == 6) value = rgb.g; + else value = rgb.b; + } + else if (propMode == 8) { + value = readOpacity(s); + } + else if (propMode >= 9 && propMode <= 13) { + vec3 sc = readScale(s); + if (propMode == 9) value = sc.x; + else if (propMode == 10) value = sc.y; + else if (propMode == 11) value = sc.z; + else if (propMode == 12) value = sc.x * sc.y * sc.z; + else value = dot(sc, sc); + } + else if (propMode >= 14 && propMode <= 17) { + vec4 q = readRotation(s); + if (propMode == 14) value = q.x; // W + else if (propMode == 15) value = q.y; // X + else if (propMode == 16) value = q.z; // Y + else value = q.w; // Z + } + else if (propMode == 18 || propMode == 19 || propMode == 20) { + // rgb2hsv expects channels in [0, 1]; the renderer clamps the final + // pixel at output, so HSV is well-defined only on the clamped color. + // without this, unclamped HDR / SH-shifted channels yield nonsense + // S/V (e.g. (max - min)/max blowing up when max ≈ 0). + vec3 hsv = rgb2hsv(clamp(readFinalColor(s), 0.0, 1.0)); + if (propMode == 18) value = hsv.x * 360.0; + else if (propMode == 19) value = hsv.y; + else value = hsv.z; + } + #if SH_BANDS > 0 + else if (propMode >= 21 && propMode <= 65) { + value = readSHCoeff(s, propMode - 21); + } + #endif + else if (propMode >= 66 && propMode <= 68) { + // raw f_dc_N coefficient: invert the engine's dcDecode. + vec3 dc = texelFetch(splatColor, s.uv, 0).rgb; + float c; + if (propMode == 66) c = dc.r; + else if (propMode == 67) c = dc.g; + else c = dc.b; + value = (c - 0.5) / SH_C0; + } + + return true; +} +`; + +export { computeSplatValueGLSL }; diff --git a/src/shortcut-manager.ts b/src/shortcut-manager.ts new file mode 100644 index 0000000..4651009 --- /dev/null +++ b/src/shortcut-manager.ts @@ -0,0 +1,141 @@ +import { platform } from 'playcanvas'; + +import { Events } from './events'; +import { Shortcuts, ShortcutBinding } from './shortcuts'; + +// Mac uses different symbols for modifier keys +const isMac = platform.name === 'osx'; + +// Default shortcut bindings - the source of truth for key mappings +const defaultShortcuts: Record = { + // Navigation + 'camera.reset': { keys: ['f'], shift: 'required' }, + 'camera.focus': { keys: ['f'] }, + 'camera.toggleControlMode': { keys: ['v'] }, + + // Show + 'camera.toggleOverlay': { keys: ['Tab'] }, + 'camera.toggleMode': { keys: ['m'] }, + 'grid.toggleVisible': { keys: ['g'] }, + 'camera.toggleShowInfo': { keys: ['i'] }, + 'select.hide': { keys: ['h'] }, + 'select.unhide': { keys: ['h'], shift: 'required' }, + + // Playback + 'timeline.togglePlay': { keys: [' '] }, + 'timeline.prevFrame': { keys: [','], repeat: true }, + 'timeline.nextFrame': { keys: ['.'], repeat: true }, + 'timeline.prevKey': { keys: ['<'], shift: 'optional', repeat: true }, + 'timeline.nextKey': { keys: ['>'], shift: 'optional', repeat: true }, + 'track.addKey': { keys: ['Enter'] }, + 'track.removeKey': { keys: ['Enter'], shift: 'required' }, + + // Selection + 'select.all': { keys: ['a'], ctrl: 'required', capture: true }, + 'select.none': { keys: ['a'], ctrl: 'required', shift: 'required', capture: true }, + 'select.invert': { keys: ['i'], ctrl: 'required' }, + 'select.delete': { keys: ['Delete', 'Backspace'] }, + + // Tools + 'tool.move': { keys: ['1'] }, + 'tool.rotate': { keys: ['2'] }, + 'tool.scale': { keys: ['3'] }, + 'tool.rectSelection': { keys: ['r'] }, + 'tool.lassoSelection': { keys: ['l'] }, + 'tool.polygonSelection': { keys: ['p'] }, + 'tool.brushSelection': { keys: ['b'] }, + 'tool.floodSelection': { keys: ['o'] }, + 'tool.eyedropperSelection': { keys: ['e'], ctrl: 'required', capture: true }, + 'tool.brushSelection.smaller': { keys: ['['], repeat: true }, + 'tool.brushSelection.bigger': { keys: [']'], repeat: true }, + 'tool.deactivate': { keys: ['Escape'] }, + 'tool.toggleCoordSpace': { keys: ['c'], shift: 'required' }, + + // Other + 'edit.undo': { keys: ['z'], ctrl: 'required', repeat: true, capture: true }, + 'edit.redo': { keys: ['z'], ctrl: 'required', shift: 'required', repeat: true, capture: true }, + 'dataPanel.toggle': { keys: ['d'], ctrl: 'required', capture: true }, + 'timelinePanel.toggle': { keys: ['t'], ctrl: 'required', capture: true }, + + // Camera fly keys - use physical positions (codes) for WASD layout on non-QWERTY keyboards + 'camera.fly.forward': { codes: ['KeyW'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.fly.backward': { codes: ['KeyS'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.fly.left': { codes: ['KeyA'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.fly.right': { codes: ['KeyD'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.fly.down': { codes: ['KeyQ'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.fly.up': { codes: ['KeyE'], held: true, shift: 'optional', alt: 'optional' }, + 'camera.modifier.fast': { codes: ['ShiftLeft', 'ShiftRight'], held: true, alt: 'optional' }, + 'camera.modifier.slow': { codes: ['AltLeft', 'AltRight'], held: true, shift: 'optional' } +}; + +class ShortcutManager { + private bindings: Record; + + constructor(events: Events) { + // Clone the defaults so they can be modified without affecting the originals + this.bindings = {}; + for (const id in defaultShortcuts) { + this.bindings[id] = { ...defaultShortcuts[id] }; + } + + // Create shortcuts and register all bindings + const shortcuts = new Shortcuts(events); + for (const id in this.bindings) { + const binding = this.bindings[id]; + shortcuts.register({ + event: id, + keys: binding.keys, + codes: binding.codes, + ctrl: binding.ctrl, + shift: binding.shift, + alt: binding.alt, + held: binding.held, + repeat: binding.repeat, + capture: binding.capture + }); + } + } + + /** + * Get a shortcut binding by its event ID. + */ + get(id: string): ShortcutBinding | undefined { + return this.bindings[id]; + } + + /** + * Format a shortcut for display (e.g., "Ctrl + Shift + Z" or "⌘⇧Z" on Mac). + */ + formatShortcut(id: string): string { + const binding = this.bindings[id]; + if (!binding) return ''; + + const parts: string[] = []; + + // Use Mac symbols: ⌘ (Cmd), ⌥ (Option), ⇧ (Shift) + if (binding.ctrl === 'required') parts.push(isMac ? '⌘' : 'Ctrl'); + if (binding.alt === 'required') parts.push(isMac ? '⌥' : 'Alt'); + if (binding.shift === 'required') parts.push(isMac ? '⇧' : 'Shift'); + + // Get the first key or code for display + let keyDisplay = binding.keys?.[0] ?? binding.codes?.[0]; + if (!keyDisplay) return ''; + + if (keyDisplay === ' ') { + keyDisplay = 'Space'; + } else if (keyDisplay === 'Escape') { + keyDisplay = 'Esc'; + } else if (keyDisplay.startsWith('Key')) { + // Physical key codes like 'KeyW' -> 'W' + keyDisplay = keyDisplay.slice(3); + } else if (keyDisplay.length === 1) { + keyDisplay = keyDisplay.toUpperCase(); + } + + parts.push(keyDisplay); + + return isMac ? parts.join(' ') : parts.join(' + '); + } +} + +export { ShortcutManager }; diff --git a/src/shortcuts.ts b/src/shortcuts.ts new file mode 100644 index 0000000..ee47768 --- /dev/null +++ b/src/shortcuts.ts @@ -0,0 +1,130 @@ +import { Events } from './events'; + +/** + * Modifier key requirement state. + * - 'required': modifier must be pressed + * - 'forbidden': modifier must NOT be pressed (default if unspecified) + * - 'optional': don't care either way + */ +type ModifierState = 'required' | 'forbidden' | 'optional'; + +/** + * A shortcut binding definition. + */ +interface ShortcutBinding { + keys?: string[]; // list of keys + codes?: string[]; // list of codes + ctrl?: ModifierState; + shift?: ModifierState; + alt?: ModifierState; + held?: boolean; + repeat?: boolean; // whether to fire on keyboard repeat events (for non-held shortcuts) + capture?: boolean; // whether to use capture phase for the event listener +} + +/** + * Options for registering a shortcut handler. + * Extends ShortcutBinding with event/func handler. + */ +interface ShortcutOptions extends ShortcutBinding { + event?: string; + func?: (down?: boolean) => void; +} + +/** + * Check if a modifier key state matches the requirement. + */ +const checkMod = (requirement: ModifierState | undefined, isPressed: boolean): boolean => { + switch (requirement) { + case 'required': return isPressed; + case 'optional': return true; + case 'forbidden': + default: return !isPressed; + } +}; + +class Shortcuts { + shortcuts: ShortcutOptions[] = []; + + constructor(events: Events) { + const shortcuts = this.shortcuts; + + const handleEvent = (e: KeyboardEvent, down: boolean, capture: boolean) => { + // skip if focus is elsewhere (input fields, modals, etc.) + if (e.target !== document.body) return; + + const isCtrlKey = e.code.startsWith('Control'); + const isShiftKey = e.code.startsWith('Shift'); + const isAltKey = e.code.startsWith('Alt'); + + for (let i = 0; i < shortcuts.length; i++) { + const options = shortcuts[i]; + + const ctrlMatch = isCtrlKey || checkMod(options.ctrl, !!(e.ctrlKey || e.metaKey)); + const shiftMatch = isShiftKey || checkMod(options.shift, e.shiftKey); + const altMatch = isAltKey || checkMod(options.alt, e.altKey); + + // Match if key matches keys array OR code matches codes array + const keyMatches = (options.keys?.some(k => k.toLowerCase() === e.key.toLowerCase()) || + options.codes?.some(c => c === e.code)) ?? false; + + if (keyMatches && + ((options.capture ?? false) === capture) && + ctrlMatch && shiftMatch && altMatch) { + + // consume the event + e.stopPropagation(); + e.preventDefault(); + + if (options.held) { + // Skip repeated keydown events, but fire on initial down and all up events + if (down && e.repeat) { + return; + } + } else { + // Non-held: ignore up events + // Also ignore repeated keydown events unless repeat is explicitly allowed + if (!down || (e.repeat && !options.repeat)) return; + } + + if (options.event) { + // Only pass 'down' state for held shortcuts + if (options.held) { + events.fire(options.event, down); + } else { + events.fire(options.event); + } + } else { + options.func(down); + } + + break; + } + } + }; + + // register keyboard handler + document.addEventListener('keydown', (e) => { + handleEvent(e, true, false); + }); + + document.addEventListener('keyup', (e) => { + handleEvent(e, false, false); + }); + + // also handle capture phase + document.addEventListener('keydown', (e) => { + handleEvent(e, true, true); + }, true); + + document.addEventListener('keyup', (e) => { + handleEvent(e, false, true); + }, true); + } + + register(options: ShortcutOptions) { + this.shortcuts.push(options); + } +} + +export { Shortcuts, ModifierState, ShortcutBinding }; diff --git a/src/sphere-shape.ts b/src/sphere-shape.ts new file mode 100644 index 0000000..ecca3d2 --- /dev/null +++ b/src/sphere-shape.ts @@ -0,0 +1,111 @@ +import { + BLENDEQUATION_ADD, + BLENDMODE_ONE, + BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDMODE_SRC_ALPHA, + CULLFACE_FRONT, + BlendState, + BoundingBox, + Entity, + ShaderMaterial, + Vec3 +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { Serializer } from './serializer'; +import { vertexShader, fragmentShader } from './shaders/sphere-shape-shader'; + +const v = new Vec3(); +const bound = new BoundingBox(); + +class SphereShape extends Element { + _radius = 1; + pivot: Entity; + material: ShaderMaterial; + + constructor() { + super(ElementType.debug); + + this.pivot = new Entity('spherePivot'); + this.pivot.addComponent('render', { + type: 'box' + }); + const r = this._radius * 2; + this.pivot.setLocalScale(r, r, r); + } + + add() { + const material = new ShaderMaterial({ + uniqueName: 'sphereShape', + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + material.cull = CULLFACE_FRONT; + material.blendState = new BlendState( + true, + BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA + ); + material.update(); + + this.pivot.render.meshInstances[0].material = material; + this.pivot.render.layers = [this.scene.worldLayer.id]; + + this.material = material; + + this.scene.contentRoot.addChild(this.pivot); + + this.updateBound(); + } + + remove() { + this.scene.contentRoot.removeChild(this.pivot); + this.scene.boundDirty = true; + } + + destroy() { + + } + + serialize(serializer: Serializer): void { + serializer.packa(this.pivot.getWorldTransform().data); + serializer.pack(this.radius); + } + + onPreRender() { + this.pivot.getWorldTransform().getTranslation(v); + this.material.setParameter('sphere', [v.x, v.y, v.z, this.radius]); + + const device = this.scene.graphicsDevice; + device.scope.resolve('targetSize').setValue([device.width, device.height]); + } + + moved() { + this.updateBound(); + } + + updateBound() { + bound.center.copy(this.pivot.getPosition()); + bound.halfExtents.set(this.radius, this.radius, this.radius); + this.scene.boundDirty = true; + } + + get worldBound(): BoundingBox | null { + return bound; + } + + set radius(radius: number) { + this._radius = radius; + + const r = this._radius * 2; + this.pivot.setLocalScale(r, r, r); + + this.updateBound(); + } + + get radius() { + return this._radius; + } +} + +export { SphereShape }; diff --git a/src/spherical-metadata.ts b/src/spherical-metadata.ts new file mode 100644 index 0000000..d981d33 --- /dev/null +++ b/src/spherical-metadata.ts @@ -0,0 +1,185 @@ +// Injects Spherical Video V1 metadata into an MP4/MOV buffer so players +// (YouTube, VLC, Quest) auto-detect the equirectangular projection. +// https://github.com/google/spatial-media/blob/master/docs/spherical-video-rfc.md +// +// The metadata is a uuid box appended as the last child of the video trak. +// Throws on unexpected input; callers should fall back to the untagged buffer. + +const SPHERICAL_UUID = new Uint8Array([ + 0xff, 0xcc, 0x82, 0x63, 0xf8, 0x55, 0x4a, 0x93, + 0x88, 0x14, 0x58, 0x7a, 0x02, 0x52, 0x1f, 0xdd +]); + +const SPHERICAL_XML = + '' + + '' + + 'true' + + 'true' + + 'SuperSplat' + + 'equirectangular' + + ''; + +type Box = { + type: string; + start: number; + size: number; + headerSize: number; +}; + +// read the box header at offset, returning null when truncated or malformed +const readBox = (view: DataView, offset: number, end: number): Box | null => { + if (offset + 8 > end) { + return null; + } + + let size = view.getUint32(offset); + const type = String.fromCharCode( + view.getUint8(offset + 4), + view.getUint8(offset + 5), + view.getUint8(offset + 6), + view.getUint8(offset + 7) + ); + let headerSize = 8; + + if (size === 1) { + // 64-bit largesize (mdat can use this for long clips) + if (offset + 16 > end) { + return null; + } + size = view.getUint32(offset + 8) * 4294967296 + view.getUint32(offset + 12); + headerSize = 16; + } else if (size === 0) { + // box extends to the end of the file + size = end - offset; + } + + if (size < headerSize || offset + size > end) { + return null; + } + + return { type, start: offset, size, headerSize }; +}; + +// list the direct children of a container box (or the whole file) +const childBoxes = (view: DataView, start: number, end: number): Box[] => { + const children: Box[] = []; + let offset = start; + while (offset < end) { + const box = readBox(view, offset, end); + if (!box) { + break; + } + children.push(box); + offset = box.start + box.size; + } + return children; +}; + +const findChild = (view: DataView, parent: Box, type: string): Box | null => { + const children = childBoxes(view, parent.start + parent.headerSize, parent.start + parent.size); + return children.find(child => child.type === type) ?? null; +}; + +// a trak is the video track when its mdia > hdlr handler_type is 'vide' +const isVideoTrak = (view: DataView, trak: Box): boolean => { + const mdia = findChild(view, trak, 'mdia'); + const hdlr = mdia && findChild(view, mdia, 'hdlr'); + if (!hdlr) { + return false; + } + + // hdlr payload: version/flags (4) + pre_defined (4) + handler_type (4) + const offset = hdlr.start + hdlr.headerSize + 8; + return offset + 4 <= hdlr.start + hdlr.size && + String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3) + ) === 'vide'; +}; + +// add delta to every chunk offset in the stco/co64 boxes of a moov subtree. +// only needed when moov precedes mdat (fast-start layout); with moov written +// last the chunk offsets are unaffected by the insertion. +const patchChunkOffsets = (view: DataView, box: Box, delta: number) => { + const containers = ['moov', 'trak', 'mdia', 'minf', 'stbl']; + + if (containers.includes(box.type)) { + const children = childBoxes(view, box.start + box.headerSize, box.start + box.size); + children.forEach(child => patchChunkOffsets(view, child, delta)); + } else if (box.type === 'stco' || box.type === 'co64') { + // full box: version/flags (4) + entry_count (4) + entries + const count = view.getUint32(box.start + box.headerSize + 4); + let offset = box.start + box.headerSize + 8; + for (let i = 0; i < count; ++i) { + if (box.type === 'stco') { + view.setUint32(offset, view.getUint32(offset) + delta); + offset += 4; + } else { + const value = view.getUint32(offset) * 4294967296 + view.getUint32(offset + 4) + delta; + view.setUint32(offset, Math.floor(value / 4294967296)); + view.setUint32(offset + 4, value % 4294967296); + offset += 8; + } + } + } +}; + +const injectSphericalMetadata = (buffer: ArrayBuffer): ArrayBuffer => { + const view = new DataView(buffer); + const topLevel = childBoxes(view, 0, buffer.byteLength); + + if (topLevel.length === 0 || topLevel[topLevel.length - 1].start + topLevel[topLevel.length - 1].size !== buffer.byteLength) { + throw new Error('malformed container'); + } + + const moov = topLevel.find(box => box.type === 'moov'); + if (!moov) { + throw new Error('moov box not found'); + } + + const moovChildren = childBoxes(view, moov.start + moov.headerSize, moov.start + moov.size); + const trak = moovChildren.find(box => box.type === 'trak' && isVideoTrak(view, box)); + if (!trak) { + throw new Error('video trak not found'); + } + + if (moov.headerSize !== 8 || trak.headerSize !== 8) { + throw new Error('unexpected 64-bit box size'); + } + + // build the uuid box: size (4) + 'uuid' (4) + extended type (16) + xml + const xml = new TextEncoder().encode(SPHERICAL_XML); + const uuidBox = new Uint8Array(24 + xml.length); + const uuidView = new DataView(uuidBox.buffer); + uuidView.setUint32(0, uuidBox.length); + uuidBox.set([0x75, 0x75, 0x69, 0x64], 4); // 'uuid' + uuidBox.set(SPHERICAL_UUID, 8); + uuidBox.set(xml, 24); + + // splice the uuid box in as the last child of the video trak + const src = new Uint8Array(buffer); + const trakEnd = trak.start + trak.size; + const out = new Uint8Array(buffer.byteLength + uuidBox.length); + out.set(src.subarray(0, trakEnd), 0); + out.set(uuidBox, trakEnd); + out.set(src.subarray(trakEnd), trakEnd + uuidBox.length); + + // grow the ancestor box sizes + const outView = new DataView(out.buffer); + outView.setUint32(moov.start, moov.size + uuidBox.length); + outView.setUint32(trak.start, trak.size + uuidBox.length); + + // fast-start layout defense: growing a moov that precedes mdat shifts the + // media data, so chunk offsets must follow + const mdat = topLevel.find(box => box.type === 'mdat'); + if (mdat && moov.start < mdat.start) { + const outMoov = readBox(outView, moov.start, out.byteLength); + patchChunkOffsets(outView, outMoov, uuidBox.length); + } + + return out.buffer; +}; + +export { injectSphericalMetadata }; diff --git a/src/splat-overlay.ts b/src/splat-overlay.ts new file mode 100644 index 0000000..1db25bf --- /dev/null +++ b/src/splat-overlay.ts @@ -0,0 +1,206 @@ +import { + BLEND_NORMAL, + PRIMITIVE_POINTS, + SEMANTIC_POSITION, + TYPE_FLOAT32, + Color, + Entity, + EventHandler, + GSplatResource, + ShaderMaterial, + Mesh, + MeshInstance, + VertexBuffer, + VertexFormat +} from 'playcanvas'; + +import { ElementType, Element } from './element'; +import { vertexShader, fragmentShader } from './shaders/splat-overlay-shader'; +import { Splat } from './splat'; + +const nullClr = new Color(0, 0, 0, 0); + +class SplatOverlay extends Element { + entity: Entity; + mesh: Mesh; + material: ShaderMaterial; + meshInstance: MeshInstance; + splat: Splat; + onSorterUpdated: (count: number) => void; + // the sorter we subscribed to in attach(); cached so detach() unsubscribes + // from it directly (splat.entity may have been swapped out by replaceData) + sorter: EventHandler; + + constructor() { + super(ElementType.debug); + } + + add() { + const scene = this.scene; + const device = scene.graphicsDevice; + + this.material = new ShaderMaterial({ + uniqueName: 'splatOverlayMaterial', + vertexGLSL: vertexShader, + fragmentGLSL: fragmentShader + }); + this.material.blendType = BLEND_NORMAL; + this.material.depthWrite = false; + this.material.depthTest = true; + this.material.update(); + + this.mesh = new Mesh(device); + + // dummy 1-vertex VB so the engine caches the VAO (avoids creating a new one every frame) + const format = new VertexFormat(device, [ + { semantic: SEMANTIC_POSITION, components: 1, type: TYPE_FLOAT32 } + ]); + format.instancing = true; + const vb = new VertexBuffer(device, format, 1); + vb.lock(); + vb.unlock(); + this.mesh.vertexBuffer = vb; + + this.mesh.primitive[0] = { + baseVertex: 0, + type: PRIMITIVE_POINTS, + base: 0, + count: 0 + }; + + this.meshInstance = new MeshInstance(this.mesh, this.material, null); + // slightly higher priority so it renders before gizmos + this.meshInstance.drawBucket = 128; + // disable frustum culling since mesh has no vertex buffer for AABB calculation + this.meshInstance.cull = false; + + this.entity = new Entity('splatOverlay'); + this.entity.addComponent('render', { + meshInstances: [this.meshInstance], + layers: [scene.gizmoLayer.id] + }); + + scene.events.on('selection.changed', (selection: Splat) => { + if (selection) { + this.attach(selection); + } else { + this.detach(); + } + }); + + // re-attach when the attached splat swaps its frame data (animated + // sequence): replaceData builds a new entity/instance, so our captured + // instance and our entity (parented under the old one) are stale. + scene.events.on('splat.replaced', (splat: Splat) => { + if (this.splat === splat) { + this.attach(splat); + } + }); + } + + destroy() { + this.detach(); + this.entity.destroy(); + } + + attach(splat: Splat) { + // detach from previous splat first + this.detach(); + + const { mesh, material } = this; + const instance = splat.entity.gsplat.instance; + const orderTexture = instance.orderTexture; + + // set up order texture uniforms + material.setParameter('splatOrder', orderTexture); + material.setParameter('splatTextureSize', orderTexture.width); + + // set up other uniforms + const resource = instance.resource as GSplatResource; + material.setParameter('splatState', splat.stateTexture); + material.setParameter('splatPosition', (resource as any).getTexture('transformA')); + material.setParameter('splatTransform', splat.transformTexture); + material.setParameter('splatColor', (resource as any).getTexture('splatColor')); + material.setParameter('texParams', [splat.stateTexture.width, splat.stateTexture.height]); + + // set up SH textures and define based on SH bands + const shBands = resource.shBands; + material.setDefine('SH_BANDS', `${shBands}`); + if (shBands > 0) { + material.setParameter('splatSH_1to3', (resource as any).getTexture('splatSH_1to3')); + if (shBands > 1) { + material.setParameter('splatSH_4to7', (resource as any).getTexture('splatSH_4to7')); + material.setParameter('splatSH_8to11', (resource as any).getTexture('splatSH_8to11')); + if (shBands > 2) { + material.setParameter('splatSH_12to15', (resource as any).getTexture('splatSH_12to15')); + } + } + } + + material.update(); + + // subscribe to sorter updates for dynamic count, caching the sorter so + // detach() can unsubscribe from this exact instance + this.onSorterUpdated = () => { + mesh.primitive[0].count = instance.sorter.pendingSorted?.count ?? mesh.primitive[0].count; + }; + this.sorter = instance.sorter; + this.sorter.on('updated', this.onSorterUpdated); + + // initialize count - numSplats is the current visible count (excluding deleted) + mesh.primitive[0].count = splat.numSplats; + + splat.entity.addChild(this.entity); + this.splat = splat; + } + + detach() { + // unsubscribe from the cached sorter (not splat.entity, which replaceData + // may have already swapped to a new entity/instance) + if (this.sorter && this.onSorterUpdated) { + this.sorter.off('updated', this.onSorterUpdated); + } + this.sorter = null; + this.onSorterUpdated = null; + + this.entity.remove(); + this.splat = null; + } + + onPreRender() { + const { enabled, scene } = this; + const { events } = scene; + + this.entity.enabled = enabled; + + if (enabled) { + const { material } = this; + const splatSize = events.invoke('camera.splatSize'); + const selectedClr = events.invoke('view.outlineSelection') ? nullClr : events.invoke('selectedClr'); + const unselectedClr = events.invoke('unselectedClr'); + const useGaussianColor = events.invoke('view.centersUseGaussianColor') ? 1.0 : 0.0; + + material.setParameter('splatSize', splatSize * window.devicePixelRatio); + material.setParameter('selectedClr', [selectedClr.r, selectedClr.g, selectedClr.b, selectedClr.a]); + material.setParameter('unselectedClr', [unselectedClr.r, unselectedClr.g, unselectedClr.b, unselectedClr.a]); + material.setParameter('useGaussianColor', useGaussianColor); + material.setParameter('transformPalette', this.splat.transformPalette.texture); + + // pass camera position for SH evaluation + const camPos = scene.camera.mainCamera.getPosition(); + material.setParameter('view_position', [camPos.x, camPos.y, camPos.z]); + } + } + + get enabled() { + const { scene, splat } = this; + const { events } = scene; + return splat && + events.invoke('camera.splatSize') > 0 && + scene.camera.renderOverlays && + events.invoke('camera.overlay') && + events.invoke('camera.mode') === 'centers'; + } +} + +export { SplatOverlay }; diff --git a/src/splat-serialize.ts b/src/splat-serialize.ts new file mode 100644 index 0000000..e5679b5 --- /dev/null +++ b/src/splat-serialize.ts @@ -0,0 +1,1431 @@ +import { + Column, + DataTable, + logger as splatTransformLogger, + MemoryFileSystem, + Transform, + writeHtml, + writeSog as writeSogInternal, + writeSpz, + ZipFileSystem, + type FileSystem, + type LogEvent, + type Renderer, + type Writer +} from '@playcanvas/splat-transform'; +import { + GSplatData, + Mat3, + Mat4, + PIXELFORMAT_BGRA8, + Quat, + Texture, + Vec3, + WebgpuGraphicsDevice +} from 'playcanvas'; + +import { version } from '../package.json'; +import { ColorGrade, dcDecode, dcEncode, sigmoid } from './color-grade'; +import { Events } from './events'; +import { ProgressWriter } from './io'; +import { SHRotation } from './sh-utils'; +import { Splat } from './splat'; +import { State } from './splat-state'; + +type SerializeSettings = { + maxSHBands?: number; // specifies the maximum number of bands to be exported + selected?: boolean; // only export selected gaussians. used for copy/paste + minOpacity?: number; // filter out gaussians with alpha less than or equal to minAlpha + removeInvalid?: boolean; // filter out gaussians with invalid data (NaN/Infinity) + + // the following options are used when serializing the PLY for document save + // and are only supported by serializePly + keepStateData?: boolean; // keep the state data array + keepWorldTransform?: boolean; // don't apply the world transform when resolving splat transforms + keepColorTint?: boolean; // refrain from applying color tints +}; + +type AnimTrack = { + name: string, + duration: number, + frameRate: number, + loopMode: 'none' | 'repeat' | 'pingpong', + interpolation: 'step' | 'spline', + smoothness: number, + keyframes: { + times: number[], + values: { + position: number[], + target: number[], + fov: number[], + } + } +}; + +type CameraPose = { + position: [number, number, number], + target: [number, number, number], + fov: number +}; + +type Camera = { + initial: CameraPose, +}; + +type Annotation = { + position: [number, number, number], + title: string, + text: string, + extras: any, + camera: Camera +}; + +type PostEffectSettings = { + sharpness: { + enabled: boolean, + amount: number, + }, + bloom: { + enabled: boolean, + intensity: number, + blurLevel: number, + }, + grading: { + enabled: boolean, + brightness: number, + contrast: number, + saturation: number, + tint: [number, number, number], + }, + vignette: { + enabled: boolean, + intensity: number, + inner: number, + outer: number, + curvature: number, + }, + fringing: { + enabled: boolean, + intensity: number + } +}; + +const defaultPostEffectSettings: PostEffectSettings = { + sharpness: { enabled: false, amount: 0 }, + bloom: { enabled: false, intensity: 1, blurLevel: 2 }, + grading: { enabled: false, brightness: 1, contrast: 1, saturation: 1, tint: [1, 1, 1] }, + vignette: { enabled: false, intensity: 0.5, inner: 0.3, outer: 0.75, curvature: 1 }, + fringing: { enabled: false, intensity: 0.5 } +}; + +type ExperienceSettings = { + version: 2, + tonemapping: 'none' | 'linear' | 'filmic' | 'hejl' | 'aces' | 'aces2' | 'neutral', + highPrecisionRendering: boolean, + soundUrl?: string, + background: { + color: [number, number, number], + skyboxUrl?: string + }, + postEffectSettings: PostEffectSettings, + animTracks: AnimTrack[], + cameras: Camera[], + annotations: Annotation[], + startMode: 'default' | 'animTrack' | 'annotation' +}; + +type ViewerExportSettings = { + type: 'html' | 'zip'; + experienceSettings: ExperienceSettings; + events?: Events; +}; + +type ProgressFunc = (loaded: number, total: number) => void; + +const generatedByString = `Generated by SuperSplat ${version}`; + +// create a filter for gaussians +class GaussianFilter { + set: (splat: Splat) => void; + test: (i: number) => boolean; + + constructor(serializeSettings: SerializeSettings) { + let splat: Splat = null; + let state: Uint8Array = null; + let opacity: Float32Array = null; + + this.set = (s: Splat) => { + splat = s; + state = splat.splatData.getProp('state') as Uint8Array; + opacity = splat.splatData.getProp('opacity') as Float32Array; + }; + + const onlySelected = serializeSettings.selected ?? false; + const minOpacity = serializeSettings.minOpacity ?? 0; + const removeInvalid = serializeSettings.removeInvalid ?? false; + + // properties where +Infinity and -Infinity are valid values + const infOk = new Set(['opacity']); + // properties where -Infinity is a valid value + const negInfOk = new Set(['scale_0', 'scale_1', 'scale_2']); + + this.test = (i: number) => { + // splat is deleted, always removed + if ((state[i] & State.deleted) !== 0) { + return false; + } + + // optionally filter out unselected gaussians + if (onlySelected && (state[i] !== State.selected)) { + return false; + } + + // optionally filter based on opacity + if (minOpacity > 0 && sigmoid(opacity[i]) < minOpacity) { + return false; + } + + if (removeInvalid) { + const { splatData } = splat; + + // check if any property of the gaussian is NaN/Infinity + const element = splatData.getElement('vertex'); + for (let k = 0; k < element.properties.length; ++k) { + const prop = element.properties[k]; + const { storage, name } = prop; + if (storage && !Number.isFinite(storage[i])) { + if (storage[i] === -Infinity && (infOk.has(name) || negInfOk.has(name))) continue; + if (storage[i] === Infinity && infOk.has(name)) continue; + return false; + } + } + } + + return true; + }; + } +} + +// count the total number of gaussians given a filter +const countGaussians = (splats: Splat[], filter: GaussianFilter) => { + return splats.reduce((accum, splat) => { + filter.set(splat); + for (let i = 0; i < splat.splatData.numSplats; ++i) { + accum += filter.test(i) ? 1 : 0; + } + return accum; + }, 0); +}; + +const getVertexProperties = (splatData: GSplatData) => { + return new Set( + splatData.getElement('vertex') + .properties.filter((p: any) => p.storage) + .map((p: any) => p.name) + ); +}; + +const getCommonPropNames = (splats: Splat[]) => { + let result: Set; + + for (let i = 0; i < splats.length; ++i) { + const props = getVertexProperties(splats[i].splatData); + result = i === 0 ? props : new Set([...result].filter(i => props.has(i))); + } + + return [...result]; +}; + +const getCommonProps = (splats: Splat[]) => { + const result = new Map>(); // map of name->type + + for (let i = 0; i < splats.length; ++i) { + const properties = splats[i].splatData.getElement('vertex').properties.filter((p: any) => p.storage); + properties.forEach((p: any) => { + if (result.has(p.name)) { + result.get(p.name).add(p.type); + } else { + result.set(p.name, new Set([p.type])); + } + }); + } + + return [...result].filter(([_, v]) => v.size === 1).map(([name, type]) => { + return { name, type: type.values().next().value }; + }); +}; + +const shNames = new Array(45).fill('').map((_, i) => `f_rest_${i}`); +const shBandCoeffs = [0, 3, 8, 15]; + +// determine the number of sh bands present given an object with 'f_rest_*' properties +const calcSHBands = (data: Set) => { + return { '9': 1, '24': 2, '-1': 3 }[shNames.findIndex(v => !data.has(v))] ?? 0; +}; + +type DataType = 'char' | 'uchar' | 'short' | 'ushort' | 'int' | 'uint' | 'float' | 'double'; + +const DataTypeSize = (dataType: DataType) => { + return { + char: 1, + uchar: 1, + short: 2, + ushort: 2, + int: 4, + uint: 4, + float: 4, + double: 8 + }[dataType]; +}; + +const v = new Vec3(); +const q = new Quat(); + +// calculate splat transforms on demand and cache the result for next time +class SplatTransformCache { + getMat: (index: number) => Mat4; + getRot: (index: number) => Quat; + getScale: (index: number) => Vec3; + getSHRot: (index: number) => SHRotation; + + constructor(splat: Splat, keepWorldTransform = false) { + const transforms = new Map(); + const indices = splat.transformTexture.getSource() as unknown as Uint32Array; + const tmpMat = new Mat4(); + const tmpMat3 = new Mat3(); + const tmpQuat = new Quat(); + + const getTransform = (index: number) => { + const transformIndex = indices?.[index] ?? 0; + let result = transforms.get(transformIndex); + if (!result) { + result = { transformIndex, mat: null, rot: null, scale: null, shRot: null }; + transforms.set(transformIndex, result); + } + return result; + }; + + this.getMat = (index: number) => { + const transform = getTransform(index); + + if (!transform.mat) { + const mat = new Mat4(); + + // we must undo the transform we apply at load time to output data + if (!keepWorldTransform) { + mat.setFromEulerAngles(0, 0, -180); + mat.mul2(mat, splat.entity.getWorldTransform()); + } + + // combine with transform palette matrix + if (transform.transformIndex > 0) { + splat.transformPalette.getTransform(transform.transformIndex, tmpMat); + mat.mul2(mat, tmpMat); + } + + transform.mat = mat; + } + + return transform.mat; + }; + + this.getRot = (index: number) => { + const transform = getTransform(index); + + if (!transform.rot) { + transform.rot = new Quat().setFromMat4(this.getMat(index)); + } + + return transform.rot; + }; + + this.getScale = (index: number) => { + const transform = getTransform(index); + + if (!transform.scale) { + const scale = new Vec3(); + this.getMat(index).getScale(scale); + transform.scale = scale; + } + + return transform.scale; + }; + + this.getSHRot = (index: number) => { + const transform = getTransform(index); + + if (!transform.shRot) { + tmpQuat.setFromMat4(this.getMat(index)); + tmpMat3.setFromQuat(tmpQuat); + transform.shRot = new SHRotation(tmpMat3); + } + + return transform.shRot; + }; + } +} + +// helper class for extracting and transforming a single splat's data +// to prepare it for export +class SingleSplat { + // final data keyed on member name + data: any = {}; + + // read a single gaussian's data and transform it for export + read: (splats: Splat, i: number) => void; + + // specify the data members required + constructor(members: string[], serializeSettings: SerializeSettings) { + const data: any = {}; + members.forEach((name) => { + data[name] = 0; + }); + + const hasPosition = ['x', 'y', 'z'].every(v => data.hasOwnProperty(v)); + const hasRotation = ['rot_0', 'rot_1', 'rot_2', 'rot_3'].every(v => data.hasOwnProperty(v)); + const hasScale = ['scale_0', 'scale_1', 'scale_2'].every(v => data.hasOwnProperty(v)); + const hasColor = ['f_dc_0', 'f_dc_1', 'f_dc_2'].every(v => data.hasOwnProperty(v)); + const hasOpacity = data.hasOwnProperty('opacity'); + + const dstSHBands = calcSHBands(new Set(Object.keys(data))); + const dstSHCoeffs = shBandCoeffs[dstSHBands]; + const tmpSHData = dstSHBands ? new Float32Array(dstSHCoeffs) : null; + + type CacheEntry = { + splat: Splat; + transformCache: SplatTransformCache; + srcProps: { [name: string]: Float32Array }; + grade: ColorGrade; + }; + + const cacheMap = new Map(); + let cacheEntry: CacheEntry; + + const read = (splat: Splat, i: number) => { + // get the cached data entry for this splat + if (splat !== cacheEntry?.splat) { + if (!cacheMap.has(splat)) { + const transformCache = new SplatTransformCache(splat, serializeSettings.keepWorldTransform); + + const srcPropNames = getVertexProperties(splat.splatData); + const srcSHBands = calcSHBands(srcPropNames); + const srcSHCoeffs = shBandCoeffs[srcSHBands]; + + // cache the props objects + const srcProps: { [name: string]: Float32Array } = {}; + + members.forEach((name) => { + const shIndex = shNames.indexOf(name); + if (shIndex >= 0) { + const a = Math.floor(shIndex / dstSHCoeffs); + const b = shIndex % dstSHCoeffs; + srcProps[name] = (b < srcSHCoeffs) ? splat.splatData.getProp(shNames[a * srcSHCoeffs + b]) as Float32Array : null; + } else { + srcProps[name] = splat.splatData.getProp(name) as Float32Array; + } + }); + + const grade = new ColorGrade(splat); + + cacheEntry = { splat, transformCache, srcProps, grade }; + + cacheMap.set(splat, cacheEntry); + } else { + cacheEntry = cacheMap.get(splat); + } + } + + const { transformCache, srcProps, grade } = cacheEntry; + + // copy members + members.forEach((name) => { + data[name] = srcProps[name]?.[i] ?? 0; + }); + + // apply transform palette transforms + const mat = transformCache.getMat(i); + + if (hasPosition) { + v.set(data.x, data.y, data.z); + mat.transformPoint(v, v); + [data.x, data.y, data.z] = [v.x, v.y, v.z]; + } + + if (hasRotation) { + const quat = transformCache.getRot(i); + q.set(data.rot_1, data.rot_2, data.rot_3, data.rot_0).mul2(quat, q); + [data.rot_1, data.rot_2, data.rot_3, data.rot_0] = [q.x, q.y, q.z, q.w]; + } + + if (hasScale) { + const scale = transformCache.getScale(i); + data.scale_0 = Math.log(Math.exp(data.scale_0) * scale.x); + data.scale_1 = Math.log(Math.exp(data.scale_1) * scale.y); + data.scale_2 = Math.log(Math.exp(data.scale_2) * scale.z); + } + + if (dstSHBands > 0) { + for (let c = 0; c < 3; ++c) { + for (let d = 0; d < dstSHCoeffs; ++d) { + tmpSHData[d] = data[shNames[c * dstSHCoeffs + d]]; + } + + transformCache.getSHRot(i).apply(tmpSHData); + + for (let d = 0; d < dstSHCoeffs; ++d) { + data[shNames[c * dstSHCoeffs + d]] = tmpSHData[d]; + } + } + } + + if (!serializeSettings.keepColorTint && hasColor && grade.hasTint) { + const c = { + r: dcDecode(data.f_dc_0), + g: dcDecode(data.f_dc_1), + b: dcDecode(data.f_dc_2) + }; + + grade.applyDC(c); + data.f_dc_0 = dcEncode(c.r); + data.f_dc_1 = dcEncode(c.g); + data.f_dc_2 = dcEncode(c.b); + + if (dstSHBands > 0) { + for (let d = 0; d < dstSHCoeffs; ++d) { + c.r = data[shNames[d]]; + c.g = data[shNames[d + dstSHCoeffs]]; + c.b = data[shNames[d + dstSHCoeffs * 2]]; + + grade.applySH(c); + data[shNames[d]] = c.r; + data[shNames[d + dstSHCoeffs]] = c.g; + data[shNames[d + dstSHCoeffs * 2]] = c.b; + } + } + } + + if (!serializeSettings.keepColorTint && hasOpacity && splat.transparency !== 1) { + data.opacity = grade.applyOpacity(data.opacity); + } + }; + + this.data = data; + this.read = read; + } +} + +const serializePly = async (splats: Splat[], serializeSettings: SerializeSettings, fs: FileSystem, filename = 'output.ply', progress?: ProgressFunc): Promise => { + const { maxSHBands, keepStateData } = serializeSettings; + + // create filter and count total gaussians + const filter = new GaussianFilter(serializeSettings); + const totalGaussians = countGaussians(splats, filter); + if (totalGaussians === 0) { + return; + } + + // this data is filtered out, as it holds internal editor state + const internalProps = keepStateData ? ['transform'] : ['state', 'transform']; + + const props = getCommonProps(splats) + // filter out internal props + .filter(p => !internalProps.includes(p.name)) + // filter out max SH bands + .filter((p) => { + if (!p.name.startsWith('f_rest_')) { + return true; + } + const i = parseInt(p.name.slice(7), 10); + return i < [0, 9, 24, 45][maxSHBands ?? 3]; + }); + + const headerText = [ + 'ply', + 'format binary_little_endian 1.0', + // FIXME: disable for now due to other tooling not supporting any header + // `comment ${generatedByString}`, + `element vertex ${totalGaussians}`, + props.map(p => `property ${p.type} ${p.name}`), + 'end_header', + '' + ].flat().join('\n'); + + const singleSplat = new SingleSplat(props.map(p => p.name), serializeSettings); + + const gaussianSizeBytes = props.reduce((tot, p) => tot + DataTypeSize(p.type as DataType), 0); + + const buf = new Uint8Array(1024 * gaussianSizeBytes); + const dataView = new DataView(buf.buffer); + let offset = 0; + + const header = new TextEncoder().encode(headerText); + + // create writer from filesystem + const writer = await fs.createWriter(filename); + + // construct a progress writer over the writer + const progressWriter = new ProgressWriter(writer, header.byteLength + totalGaussians * gaussianSizeBytes, progress); + + // write encoded header + await progressWriter.write(header); + + for (let e = 0; e < splats.length; ++e) { + const splat = splats[e]; + const { splatData } = splat; + filter.set(splat); + + for (let i = 0; i < splatData.numSplats; ++i) { + if (!filter.test(i)) continue; + + singleSplat.read(splat, i); + + // write + for (let j = 0; j < props.length; ++j) { + if (props[j].type === 'uchar') { + dataView.setUint8(offset, singleSplat.data[props[j].name]); + offset += 1; + } else { + dataView.setFloat32(offset, singleSplat.data[props[j].name], true); + offset += 4; + } + } + + // buffer is full, write it to the output stream + if (offset === buf.byteLength) { + await progressWriter.write(buf); + offset = 0; + } + } + } + + // write the last (most likely partially filled) buf + if (offset > 0) { + await progressWriter.write(new Uint8Array(buf.buffer, 0, offset)); + } + + progressWriter.close(); + await writer.close(); +}; + +interface CompressedIndex { + splatIndex: number; + i: number; + globalIndex: number; +} + +// process and compress a chunk of 256 splats +class Chunk { + static members = [ + 'x', 'y', 'z', + 'scale_0', 'scale_1', 'scale_2', + 'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity', + 'rot_0', 'rot_1', 'rot_2', 'rot_3' + ]; + + size: number; + data: any = {}; + + // compressed data + position: Uint32Array; + rotation: Uint32Array; + scale: Uint32Array; + color: Uint32Array; + + constructor(size = 256) { + this.size = size; + Chunk.members.forEach((m) => { + this.data[m] = new Float32Array(size); + }); + this.position = new Uint32Array(size); + this.rotation = new Uint32Array(size); + this.scale = new Uint32Array(size); + this.color = new Uint32Array(size); + } + + set(index: number, splat: SingleSplat) { + Chunk.members.forEach((name) => { + this.data[name][index] = splat.data[name]; + }); + } + + pack() { + const calcMinMax = (data: Float32Array) => { + let min; + let max; + min = max = data[0]; + for (let i = 1; i < data.length; ++i) { + const v = data[i]; + min = Math.min(min, v); + max = Math.max(max, v); + } + return { min, max }; + }; + + const normalize = (x: number, min: number, max: number) => { + if (x <= min) return 0; + if (x >= max) return 1; + return (max - min < 0.00001) ? 0 : (x - min) / (max - min); + }; + + const data = this.data; + + const x = data.x; + const y = data.y; + const z = data.z; + const scale_0 = data.scale_0; + const scale_1 = data.scale_1; + const scale_2 = data.scale_2; + const rot_0 = data.rot_0; + const rot_1 = data.rot_1; + const rot_2 = data.rot_2; + const rot_3 = data.rot_3; + const f_dc_0 = data.f_dc_0; + const f_dc_1 = data.f_dc_1; + const f_dc_2 = data.f_dc_2; + const opacity = data.opacity; + + const px = calcMinMax(x); + const py = calcMinMax(y); + const pz = calcMinMax(z); + + const sx = calcMinMax(scale_0); + const sy = calcMinMax(scale_1); + const sz = calcMinMax(scale_2); + + // clamp scale because sometimes values are at infinity + const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); + sx.min = clamp(sx.min, -20, 20); + sx.max = clamp(sx.max, -20, 20); + sy.min = clamp(sy.min, -20, 20); + sy.max = clamp(sy.max, -20, 20); + sz.min = clamp(sz.min, -20, 20); + sz.max = clamp(sz.max, -20, 20); + + // convert f_dc_ to colors before calculating min/max and packaging + for (let i = 0; i < f_dc_0.length; ++i) { + f_dc_0[i] = dcDecode(f_dc_0[i]); + f_dc_1[i] = dcDecode(f_dc_1[i]); + f_dc_2[i] = dcDecode(f_dc_2[i]); + } + + const cr = calcMinMax(f_dc_0); + const cg = calcMinMax(f_dc_1); + const cb = calcMinMax(f_dc_2); + + const packUnorm = (value: number, bits: number) => { + const t = (1 << bits) - 1; + return Math.max(0, Math.min(t, Math.floor(value * t + 0.5))); + }; + + const pack111011 = (x: number, y: number, z: number) => { + return packUnorm(x, 11) << 21 | + packUnorm(y, 10) << 11 | + packUnorm(z, 11); + }; + + const pack8888 = (x: number, y: number, z: number, w: number) => { + return packUnorm(x, 8) << 24 | + packUnorm(y, 8) << 16 | + packUnorm(z, 8) << 8 | + packUnorm(w, 8); + }; + + // pack quaternion into 2,10,10,10 + const packRot = (x: number, y: number, z: number, w: number) => { + q.set(x, y, z, w).normalize(); + const a = [q.x, q.y, q.z, q.w]; + const largest = a.reduce((curr, v, i) => (Math.abs(v) > Math.abs(a[curr]) ? i : curr), 0); + + if (a[largest] < 0) { + a[0] = -a[0]; + a[1] = -a[1]; + a[2] = -a[2]; + a[3] = -a[3]; + } + + const norm = Math.sqrt(2) * 0.5; + let result = largest; + for (let i = 0; i < 4; ++i) { + if (i !== largest) { + result = (result << 10) | packUnorm(a[i] * norm + 0.5, 10); + } + } + + return result; + }; + + // pack + for (let i = 0; i < this.size; ++i) { + this.position[i] = pack111011( + normalize(x[i], px.min, px.max), + normalize(y[i], py.min, py.max), + normalize(z[i], pz.min, pz.max) + ); + + this.rotation[i] = packRot(rot_0[i], rot_1[i], rot_2[i], rot_3[i]); + + this.scale[i] = pack111011( + normalize(scale_0[i], sx.min, sx.max), + normalize(scale_1[i], sy.min, sy.max), + normalize(scale_2[i], sz.min, sz.max) + ); + + this.color[i] = pack8888( + normalize(f_dc_0[i], cr.min, cr.max), + normalize(f_dc_1[i], cg.min, cg.max), + normalize(f_dc_2[i], cb.min, cb.max), + 1 / (1 + Math.exp(-opacity[i])) + ); + } + + return { px, py, pz, sx, sy, sz, cr, cg, cb }; + } +} + +// sort the compressed indices into morton order +const sortSplats = (splats: Splat[], indices: CompressedIndex[]) => { + // https://fgiesen.wordpress.com/2009/12/13/decoding-morton-codes/ + const encodeMorton3 = (x: number, y: number, z: number) : number => { + const Part1By2 = (x: number) => { + x &= 0x000003ff; + x = (x ^ (x << 16)) & 0xff0000ff; + x = (x ^ (x << 8)) & 0x0300f00f; + x = (x ^ (x << 4)) & 0x030c30c3; + x = (x ^ (x << 2)) & 0x09249249; + return x; + }; + + return (Part1By2(z) << 2) + (Part1By2(y) << 1) + Part1By2(x); + }; + + let minx: number; + let miny: number; + let minz: number; + let maxx: number; + let maxy: number; + let maxz: number; + + // calculate scene extents across all splats (using sort centers, because they're in world space) + for (let i = 0; i < splats.length; ++i) { + const splat = splats[i]; + const splatData = splat.splatData; + const state = splatData.getProp('state') as Uint8Array; + const { centers } = splat.entity.gsplat.instance.sorter; + + for (let i = 0; i < splatData.numSplats; ++i) { + if ((state[i] & State.deleted) === 0) { + const x = centers[i * 3 + 0]; + const y = centers[i * 3 + 1]; + const z = centers[i * 3 + 2]; + + if (minx === undefined) { + minx = maxx = x; + miny = maxy = y; + minz = maxz = z; + } else { + if (x < minx) minx = x; else if (x > maxx) maxx = x; + if (y < miny) miny = y; else if (y > maxy) maxy = y; + if (z < minz) minz = z; else if (z > maxz) maxz = z; + } + } + } + } + + const xlen = maxx - minx; + const ylen = maxy - miny; + const zlen = maxz - minz; + + const morton = new Uint32Array(indices.length); + let idx = 0; + for (let i = 0; i < splats.length; ++i) { + const splat = splats[i]; + const splatData = splat.splatData; + const state = splatData.getProp('state') as Uint8Array; + const { centers } = splat.entity.gsplat.instance.sorter; + + for (let i = 0; i < splatData.numSplats; ++i) { + if ((state[i] & State.deleted) === 0) { + const x = centers[i * 3 + 0]; + const y = centers[i * 3 + 1]; + const z = centers[i * 3 + 2]; + + const ix = Math.min(1023, Math.floor(1024 * (x - minx) / xlen)); + const iy = Math.min(1023, Math.floor(1024 * (y - miny) / ylen)); + const iz = Math.min(1023, Math.floor(1024 * (z - minz) / zlen)); + + morton[idx++] = encodeMorton3(ix, iy, iz); + } + } + } + + // order splats by morton code + indices.sort((a, b) => morton[a.globalIndex] - morton[b.globalIndex]); +}; + +const serializePlyCompressed = async (splats: Splat[], options: SerializeSettings, fs: FileSystem, progress?: ProgressFunc): Promise => { + const { maxSHBands } = options; + + // create filter and count total gaussians + const filter = new GaussianFilter(options); + + // make a list of indices spanning all splats (so we can sort them together) + const indices: CompressedIndex[] = []; + for (let splatIndex = 0; splatIndex < splats.length; ++splatIndex) { + const splatData = splats[splatIndex].splatData; + filter.set(splats[splatIndex]); + for (let i = 0; i < splatData.numSplats; ++i) { + if (filter.test(i)) { + indices.push({ splatIndex, i, globalIndex: indices.length }); + } + } + } + + if (indices.length === 0) { + console.error('nothing to export'); + return; + } + + // create writer from filesystem + const writer = await fs.createWriter('output.compressed.ply'); + + const numSplats = indices.length; + const numChunks = Math.ceil(numSplats / 256); + + const chunkProps = [ + 'min_x', 'min_y', 'min_z', + 'max_x', 'max_y', 'max_z', + 'min_scale_x', 'min_scale_y', 'min_scale_z', + 'max_scale_x', 'max_scale_y', 'max_scale_z', + 'min_r', 'min_g', 'min_b', + 'max_r', 'max_g', 'max_b' + ]; + + const vertexProps = [ + 'packed_position', + 'packed_rotation', + 'packed_scale', + 'packed_color' + ]; + + // calculate the number of output bands given the scene splat data and + // user-chosen maxSHBands + const outputSHBands = (() => { + const splatBands = splats.map(s => calcSHBands(getVertexProperties(s.splatData))); + return Math.min(maxSHBands ?? 3, Math.max(...splatBands)); + })(); + const outputSHCoeffs = shBandCoeffs[outputSHBands]; + + const shHeader = outputSHBands ? [ + `element sh ${numSplats}`, + new Array(outputSHCoeffs * 3).fill('').map((_, i) => `property uchar f_rest_${i}`) + ].flat() : []; + + const headerText = [ + 'ply', + 'format binary_little_endian 1.0', + `comment ${generatedByString}`, + `element chunk ${numChunks}`, + chunkProps.map(p => `property float ${p}`), + `element vertex ${numSplats}`, + vertexProps.map(p => `property uint ${p}`), + shHeader, + 'end_header\n' + ].flat().join('\n'); + + // sort splats into some kind of order (morton order rn) + sortSplats(splats, indices); + + const singleSplat = new SingleSplat([ + 'x', 'y', 'z', + 'scale_0', 'scale_1', 'scale_2', + 'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity', + 'rot_0', 'rot_1', 'rot_2', 'rot_3' + ], options); + + const prepareChunk = (chunk: Chunk, i: number) => { + const num = Math.min(numSplats, (i + 1) * 256) - i * 256; + + for (let j = 0; j < num; ++j) { + const index = indices[i * 256 + j]; + + // read splat + singleSplat.read(splats[index.splatIndex], index.i); + + // update chunk + chunk.set(j, singleSplat); + } + + // pad the end of the last chunk with duplicate data + if (num < 256) { + for (let j = num; j < 256; ++j) { + chunk.set(j, singleSplat); + } + } + }; + + const header = new TextEncoder().encode(headerText); + + const totalBytes = + header.byteLength + + numChunks * chunkProps.length * 4 + + numSplats * vertexProps.length * 4 + + outputSHCoeffs * 3 * numSplats; + + const progressWriter = new ProgressWriter(writer, totalBytes, progress); + + // write the header + await progressWriter.write(header); + + const chunk = new Chunk(); + + // write chunks + const chunkData = new Float32Array(18); + const chunkDataUint8 = new Uint8Array(chunkData.buffer); + for (let i = 0; i < numChunks; ++i) { + prepareChunk(chunk, i); + + const result = chunk.pack(); + + chunkData[0] = result.px.min; + chunkData[1] = result.py.min; + chunkData[2] = result.pz.min; + chunkData[3] = result.px.max; + chunkData[4] = result.py.max; + chunkData[5] = result.pz.max; + + chunkData[6] = result.sx.min; + chunkData[7] = result.sy.min; + chunkData[8] = result.sz.min; + chunkData[9] = result.sx.max; + chunkData[10] = result.sy.max; + chunkData[11] = result.sz.max; + + chunkData[12] = result.cr.min; + chunkData[13] = result.cg.min; + chunkData[14] = result.cb.min; + chunkData[15] = result.cr.max; + chunkData[16] = result.cg.max; + chunkData[17] = result.cb.max; + + await progressWriter.write(chunkDataUint8); + } + + // write vertices + const vertexData = new Uint32Array(256 * 4); + const vertexDataUint8 = new Uint8Array(vertexData.buffer); + for (let i = 0; i < numChunks; ++i) { + const num = Math.min(numSplats, (i + 1) * 256) - i * 256; + + prepareChunk(chunk, i); + chunk.pack(); + + // write vertex data + for (let j = 0; j < num; ++j) { + vertexData[j * 4 + 0] = chunk.position[j]; + vertexData[j * 4 + 1] = chunk.rotation[j]; + vertexData[j * 4 + 2] = chunk.scale[j]; + vertexData[j * 4 + 3] = chunk.color[j]; + } + + await progressWriter.write(num === 256 ? vertexDataUint8 : new Uint8Array(vertexData.buffer, 0, num * 4 * 4)); + } + + // write sh + const singleSplatSH = new SingleSplat(shNames.slice(0, outputSHCoeffs * 3), options); + + const shData = new Uint8Array(outputSHCoeffs * 3 * 256); + for (let i = 0; i < numChunks; ++i) { + const num = Math.min(numSplats, (i + 1) * 256) - i * 256; + + for (let j = 0; j < num; ++j) { + const index = indices[i * 256 + j]; + + // read splat + singleSplatSH.read(splats[index.splatIndex], index.i); + + // quantize and write sh data + const offset = j * outputSHCoeffs * 3; + for (let k = 0; k < outputSHCoeffs * 3; ++k) { + const nvalue = singleSplatSH.data[shNames[k]] / 8 + 0.5; + shData[offset + k] = Math.max(0, Math.min(255, Math.trunc(nvalue * 256))); + } + } + + await progressWriter.write(num === 256 ? shData : new Uint8Array(shData.buffer, 0, num * outputSHCoeffs * 3)); + } + + progressWriter.close(); + await writer.close(); +}; + +const serializeSplat = async (splats: Splat[], options: SerializeSettings, fs: FileSystem): Promise => { + // create writer from filesystem + const writer = await fs.createWriter('output.splat'); + // create filter and count total gaussians + const filter = new GaussianFilter(options); + const totalGaussians = countGaussians(splats, filter); + if (totalGaussians === 0) { + return; + } + + // position.xyz: float32, scale.xyz: float32, color.rgba: uint8, quaternion.ijkl: uint8 + const result = new Uint8Array(totalGaussians * 32); + const dataView = new DataView(result.buffer); + + let idx = 0; + + const props = ['x', 'y', 'z', 'opacity', 'rot_0', 'rot_1', 'rot_2', 'rot_3', 'f_dc_0', 'f_dc_1', 'f_dc_2', 'scale_0', 'scale_1', 'scale_2']; + const singleSplat = new SingleSplat(props, options); + const { data } = singleSplat; + + const clamp = (x: number) => Math.max(0, Math.min(255, x)); + + for (let e = 0; e < splats.length; ++e) { + const splat = splats[e]; + const { splatData } = splat; + filter.set(splat); + + for (let i = 0; i < splatData.numSplats; ++i) { + if (!filter.test(i)) continue; + + singleSplat.read(splat, i); + + const off = idx++ * 32; + + dataView.setFloat32(off + 0, data.x, true); + dataView.setFloat32(off + 4, data.y, true); + dataView.setFloat32(off + 8, data.z, true); + + dataView.setFloat32(off + 12, Math.exp(data.scale_0), true); + dataView.setFloat32(off + 16, Math.exp(data.scale_1), true); + dataView.setFloat32(off + 20, Math.exp(data.scale_2), true); + + dataView.setUint8(off + 24, clamp(dcDecode(data.f_dc_0) * 255)); + dataView.setUint8(off + 25, clamp(dcDecode(data.f_dc_1) * 255)); + dataView.setUint8(off + 26, clamp(dcDecode(data.f_dc_2) * 255)); + dataView.setUint8(off + 27, clamp(sigmoid(data.opacity) * 255)); + + dataView.setUint8(off + 28, clamp(data.rot_0 * 128 + 128)); + dataView.setUint8(off + 29, clamp(data.rot_1 * 128 + 128)); + dataView.setUint8(off + 30, clamp(data.rot_2 * 128 + 128)); + dataView.setUint8(off + 31, clamp(data.rot_3 * 128 + 128)); + } + } + + await writer.write(result); + await writer.close(); +}; + +// Thrown when the WebGPU device needed for SOG compression can't be created. +// Callers show a friendly message for this instead of the raw error text. +class WebGPUUnavailableError extends Error { + constructor() { + super('WebGPU is not available'); + this.name = 'WebGPUUnavailableError'; + } +} + +// Cached WebGPU device for SOG compression +let cachedGpuDevice: WebgpuGraphicsDevice | null = null; +let cachedBackbuffer: Texture | null = null; + +const createGpuDevice = async (): Promise => { + if (cachedGpuDevice) { + return cachedGpuDevice; + } + + if (!navigator.gpu) { + throw new WebGPUUnavailableError(); + } + + // Create a minimal canvas for the graphics device + const canvas = document.createElement('canvas'); + canvas.width = 1024; + canvas.height = 512; + + const graphicsDevice = new WebgpuGraphicsDevice(canvas, { + antialias: false, + depth: false, + stencil: false + }); + + try { + await graphicsDevice.createDevice(); + } catch (err) { + // createDevice fails with an obscure internal error when no adapter + // is available (e.g. blocklisted GPU or missing drivers) + console.error(err); + throw new WebGPUUnavailableError(); + } + + // createDevice can also resolve without creating a device (e.g. + // blocklisted adapters) + // @ts-ignore - wgpu is an internal property + if (!graphicsDevice.wgpu) { + throw new WebGPUUnavailableError(); + } + + // Create external backbuffer (required by PlayCanvas) + cachedBackbuffer = new Texture(graphicsDevice, { + width: 1024, + height: 512, + name: 'SogComputeBackbuffer', + mipmaps: false, + format: PIXELFORMAT_BGRA8 + }); + + // @ts-ignore - externalBackbuffer is an internal property + graphicsDevice.externalBackbuffer = cachedBackbuffer; + + cachedGpuDevice = graphicsDevice; + return graphicsDevice; +}; + +/** + * Extract Splat data into a DataTable for use with splat-transform writers. + * This is shared between serializeSog and serializeViewer. + */ +const extractDataTable = (splats: Splat[], settings: SerializeSettings): DataTable => { + const { maxSHBands = 3 } = settings; + + // Determine which members to extract + const shCoeffs = [0, 3, 8, 15][maxSHBands]; + const memberNames = [ + 'x', 'y', 'z', + 'scale_0', 'scale_1', 'scale_2', + 'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity', + 'rot_0', 'rot_1', 'rot_2', 'rot_3', + ...shNames.slice(0, shCoeffs * 3) + ]; + + // Create SingleSplat for data extraction + const singleSplat = new SingleSplat(memberNames, settings); + + // Create filter + const filter = new GaussianFilter(settings); + + // Count total gaussians to export + let totalCount = 0; + for (const splat of splats) { + filter.set(splat); + for (let i = 0; i < splat.splatData.numSplats; ++i) { + if (filter.test(i)) { + totalCount++; + } + } + } + + if (totalCount === 0) { + throw new Error('No gaussians to export'); + } + + // Create DataTable columns. Tag the table with Transform.PLY because + // SingleSplat.read pre-applies the PLY-style 180° Z flip (see + // SplatTransformCache.getMat), so the column data is in PLY space. + // Without this, splat-transform writers (writeSog, writeHtml) would apply + // the flip a second time and produce upside-down output. + const columns = memberNames.map(name => new Column(name, new Float32Array(totalCount))); + const dataTable = new DataTable(columns, Transform.PLY); + + // Extract data into DataTable + let idx = 0; + for (const splat of splats) { + filter.set(splat); + for (let i = 0; i < splat.splatData.numSplats; ++i) { + if (!filter.test(i)) continue; + + singleSplat.read(splat, i); + + for (let j = 0; j < memberNames.length; ++j) { + (columns[j].data as Float32Array)[idx] = singleSplat.data[memberNames[j]] ?? 0; + } + idx++; + } + } + + return dataTable; +}; + +// Bridge splat-transform progress events to supersplat's events. +const createProgressRenderer = (header: string, events?: Events): Renderer => ({ + handle: (event: LogEvent) => { + switch (event.kind) { + case 'scopeStart': + if (event.depth === 0) { + events?.fire('progressStart', header); + } else { + events?.fire('progressUpdate', { + text: event.index !== undefined && event.total !== undefined ? + `Step ${event.index} of ${event.total}: ${event.name}` : + event.name, + progress: 0 + }); + } + break; + case 'scopeEnd': + if (event.depth === 0) { + events?.fire('progressEnd'); + } + break; + case 'barStart': + events?.fire('progressUpdate', { text: event.name, progress: 0 }); + break; + case 'barTick': + events?.fire('progressUpdate', { + progress: event.total > 0 ? 100 * event.current / event.total : 0 + }); + break; + case 'barEnd': + events?.fire('progressUpdate', { progress: 100 }); + break; + case 'message': + if (event.level === 'error') console.error(event.text); + else if (event.level === 'warn') console.warn(event.text); + else if (event.level === 'info') console.info(event.text); + else if (event.level === 'debug') console.debug(event.text); + break; + case 'output': + console.log(event.text); + break; + } + } +}); + +const serializeViewer = async (splats: Splat[], serializeSettings: SerializeSettings, options: ViewerExportSettings, fs: FileSystem): Promise => { + const { experienceSettings, events } = options; + + splatTransformLogger.setRenderer(createProgressRenderer('Exporting HTML', events)); + + // Extract splat data to DataTable + const dataTable = extractDataTable(splats, serializeSettings); + + // splat-transform's writers leave their top-level scope open on error + // (their contract is for the caller to unwind), so we explicitly + // unwind here to deliver a matching depth-0 `scopeEnd(failed)` to the + // renderer. That fires `progressEnd` and dismisses the dialog before + // any error popup is shown. + try { + if (options.type === 'html') { + // Bundled HTML - writeHtml handles everything + await writeHtml({ + filename: 'output.html', + dataTable, + viewerSettingsJson: experienceSettings, + bundle: true, + iterations: 10, + createDevice: createGpuDevice + }, fs); + } else { + // Package - use unbundled mode into a MemoryFileSystem, then ZIP + const memFs = new MemoryFileSystem(); + await writeHtml({ + filename: 'index.html', + dataTable, + viewerSettingsJson: experienceSettings, + bundle: false, + iterations: 10, + createDevice: createGpuDevice + }, memFs); + + // Create ZIP from memory filesystem results. The try/finally + // ensures zipFs (and its underlying writer) is closed even if a + // write throws partway through, so we don't leak the output file. + const zipWriter = await fs.createWriter('output.zip'); + const zipFs = new ZipFileSystem(zipWriter); + try { + for (const [filename, data] of memFs.results.entries()) { + const writer = await zipFs.createWriter(filename); + await writer.write(data); + await writer.close(); + } + } finally { + await zipFs.close(); + } + } + } catch (err) { + splatTransformLogger.unwindAll(true); + throw err; + } +}; + +// SOG serialization using splat-transform library + +type SogSettings = SerializeSettings & { + iterations: number; + events?: Events; +}; + +const serializeSog = async (splats: Splat[], settings: SogSettings, fs: FileSystem): Promise => { + const { iterations = 10, events } = settings; + + splatTransformLogger.setRenderer(createProgressRenderer('Exporting SOG', events)); + + // Extract splat data to DataTable + const dataTable = extractDataTable(splats, settings); + + // splat-transform's writers leave their top-level scope open on error + // (their contract is for the caller to unwind), so we explicitly + // unwind here to deliver a matching depth-0 `scopeEnd(failed)` to the + // renderer. That fires `progressEnd` and dismisses the dialog before + // any error popup is shown. + try { + await writeSogInternal({ + filename: 'output.sog', + dataTable, + bundle: true, + iterations, + createDevice: createGpuDevice + }, fs); + } catch (err) { + splatTransformLogger.unwindAll(true); + throw err; + } +}; + +type SpzSettings = SerializeSettings & { + version?: 3 | 4; + events?: Events; +}; + +const serializeSpz = async (splats: Splat[], settings: SpzSettings, fs: FileSystem): Promise => { + const { version = 4, events } = settings; + + splatTransformLogger.setRenderer(createProgressRenderer('Exporting SPZ', events)); + + // Extract splat data to DataTable + const dataTable = extractDataTable(splats, settings); + + // unwind the logger's top-level scope on error (see serializeSog) + try { + await writeSpz({ + filename: 'output.spz', + dataTable, + version + }, fs); + } catch (err) { + splatTransformLogger.unwindAll(true); + throw err; + } +}; + +export { + Writer, + serializePly, + serializePlyCompressed, + serializeSplat, + serializeSog, + serializeSpz, + serializeViewer, + AnimTrack, + CameraPose, + Camera, + Annotation, + PostEffectSettings, + defaultPostEffectSettings, + ExperienceSettings, + SerializeSettings, + SogSettings, + SpzSettings, + ViewerExportSettings, + WebGPUUnavailableError +}; diff --git a/src/splat-state.ts b/src/splat-state.ts new file mode 100644 index 0000000..dc9fffe --- /dev/null +++ b/src/splat-state.ts @@ -0,0 +1,124 @@ +import { Texture } from 'playcanvas'; + +import { IndexRanges } from './index-ranges'; + +enum State { + selected = 1, + locked = 2, + deleted = 4 +} + +// CPU/GPU mirror of the per-splat state byte (selected/locked/deleted bits). +// Mutators record a dirty range; flush() uploads to the GPU texture and refreshes +// the cached counts. Replaces the implicit "remember to call updateState() after +// mutating state[]" contract with an encapsulated owner. +class SplatState { + // shared with splatData.getProp('state') so existing read consumers keep + // working without any indirection. SplatState is the sole writer. + readonly data: Uint8Array; + private readonly gpu: Texture; + private dirtyLo = -1; + private dirtyHi = -1; + + // cached counts, refreshed by flush(). + numSelected = 0; + numLocked = 0; + numDeleted = 0; + + constructor(data: Uint8Array, gpu: Texture) { + this.data = data; + this.gpu = gpu; + + // mark everything dirty so the first flush uploads whatever was loaded + // from disk (ply state column) and seeds the cached counts. + this.dirtyLo = 0; + this.dirtyHi = data.length; + } + + private markDirty(lo: number, hi: number) { + if (this.dirtyLo < 0) { + this.dirtyLo = lo; + this.dirtyHi = hi; + } else { + if (lo < this.dirtyLo) this.dirtyLo = lo; + if (hi > this.dirtyHi) this.dirtyHi = hi; + } + } + + setBits(ranges: IndexRanges, mask: number): void { + const { data } = this; + let lo = Infinity; + let hi = -1; + ranges.forEach((i) => { + data[i] |= mask; + if (i < lo) lo = i; + if (i >= hi) hi = i + 1; + }); + if (hi > 0) this.markDirty(lo, hi); + } + + clearBits(ranges: IndexRanges, mask: number): void { + const { data } = this; + let lo = Infinity; + let hi = -1; + ranges.forEach((i) => { + data[i] &= ~mask; + if (i < lo) lo = i; + if (i >= hi) hi = i + 1; + }); + if (hi > 0) this.markDirty(lo, hi); + } + + toggleBits(ranges: IndexRanges, mask: number): void { + const { data } = this; + let lo = Infinity; + let hi = -1; + ranges.forEach((i) => { + data[i] ^= mask; + if (i < lo) lo = i; + if (i >= hi) hi = i + 1; + }); + if (hi > 0) this.markDirty(lo, hi); + } + + // recount selected/locked/deleted from scratch. cheap relative to a GPU + // readback (single CPU pass over numSplats bytes) and only triggered from + // flush, so the same call that uploads to GPU also refreshes counts. + private recount() { + const { data } = this; + let numSelected = 0; + let numLocked = 0; + let numDeleted = 0; + for (let i = 0; i < data.length; ++i) { + const s = data[i]; + if (s & State.deleted) { + numDeleted++; + } else if (s & State.locked) { + numLocked++; + } else if (s & State.selected) { + numSelected++; + } + } + this.numSelected = numSelected; + this.numLocked = numLocked; + this.numDeleted = numDeleted; + } + + // upload dirty bytes to the GPU texture and refresh cached counts. + // idempotent and cheap when nothing is dirty. + flush(): void { + if (this.dirtyLo < 0) return; + // full upload. sub-rect upload is a worthwhile future optimisation + // (would drop a 4M-byte upload to a few KB for small selections) but + // requires engine-side support; current path keeps the same behaviour + // as the prior `updateState` lock/set/unlock pair. + const buffer = this.gpu.lock() as Uint8Array; + buffer.set(this.data); + this.gpu.unlock(); + this.recount(); + this.dirtyLo = -1; + this.dirtyHi = -1; + } +} + +export { State, SplatState }; diff --git a/src/splat.ts b/src/splat.ts new file mode 100644 index 0000000..ef1b264 --- /dev/null +++ b/src/splat.ts @@ -0,0 +1,677 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + FILTER_NEAREST, + PIXELFORMAT_R8, + PIXELFORMAT_R16U, + Asset, + BoundingBox, + Color, + Entity, + GSplatData, + GSplatResource, + Mat4, + Quat, + Texture, + Vec3 +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { Serializer } from './serializer'; +import { vertexShader, fragmentShader, gsplatCenter } from './shaders/splat-shader'; +import { State, SplatState } from './splat-state'; +import { Transform } from './transform'; +import { TransformPalette } from './transform-palette'; + +const vec = new Vec3(); +const veca = new Vec3(); +const vecb = new Vec3(); + +const boundingPoints = + [-1, 1].map((x) => { + return [-1, 1].map((y) => { + return [-1, 1].map((z) => { + return [ + new Vec3(x, y, z), new Vec3(x * 0.75, y, z), + new Vec3(x, y, z), new Vec3(x, y * 0.75, z), + new Vec3(x, y, z), new Vec3(x, y, z * 0.75) + ]; + }); + }); + }).flat(3); + +class Splat extends Element { + asset: Asset; + splatData: GSplatData; + numSplats = 0; + numDeleted = 0; + numLocked = 0; + numSelected = 0; + entity: Entity; + changedCounter = 0; + stateTexture: Texture; + // encapsulates per-splat state mirror (cpu Uint8Array + gpu Texture). + // all writes go through state.setBits/clearBits/toggleBits, then flush(). + state: SplatState; + transformTexture: Texture; + selectionBoundStorage: BoundingBox; + localBoundStorage: BoundingBox; + worldBoundStorage: BoundingBox; + + _visible = true; + transformPalette: TransformPalette; + + selectionAlpha = 1; + + _name = ''; + _tintClr = new Color(1, 1, 1); + _temperature = 0; + _saturation = 1; + _brightness = 0; + _blackPoint = 0; + _whitePoint = 1; + _transparency = 1; + + measurePoints: Vec3[] = []; + measureSelection = -1; + + rebuildMaterial: (bands: number) => void; + + constructor(asset: Asset, rotation: Quat) { + super(ElementType.splat); + + const { device } = asset.resource as GSplatResource; + + // create the entity once. its transform persists across frame swaps so + // an animated sequence can replace its data without losing the user's + // transform (see replaceData). + this.entity = new Entity('splatEntity'); + + this.selectionBoundStorage = new BoundingBox(); + + // create the transform palette (reused across frame swaps; index 0 is identity) + this.transformPalette = new TransformPalette(device); + + // rebuilds material chunks/params. reads the *current* gsplat instance and + // state/transform textures so it remains valid after a replaceData swap + // (the 'view.bands' listener registered in add() keeps pointing at it). + this.rebuildMaterial = (bands: number) => { + const instance = this.entity.gsplat.instance; + const { material } = instance; + const { glsl } = material.shaderChunks; + glsl.set('gsplatVS', vertexShader); + glsl.set('gsplatPS', fragmentShader); + glsl.set('gsplatCenterVS', gsplatCenter); + + material.setDefine('SH_BANDS', `${Math.min(bands, (instance.resource as GSplatResource).shBands)}`); + material.setParameter('splatState', this.stateTexture); + material.setParameter('splatTransform', this.transformTexture); + material.update(); + }; + + // bind the initial frame's data, applying the file's load rotation + this.bindAsset(asset, rotation); + } + + // bind a gsplat asset onto this element's entity: creates the gsplat + // component, the per-splat state/transform channels and their gpu textures, + // and caches the instance bounds. When `rotation` is supplied (initial load) + // the entity rotation is set; on a frame swap it is omitted so the user's + // transform is preserved. + private bindAsset(asset: Asset, rotation?: Quat) { + const splatResource = asset.resource as GSplatResource; + const splatData = splatResource.gsplatData as GSplatData; + const { device } = splatResource; + + this.asset = asset; + this.splatData = splatData; + this.numSplats = splatData.numSplats; + + // name and orientation are set on the initial bind only; a frame swap + // (replaceData, no rotation) keeps the element's name and transform + if (rotation) { + this._name = (asset.file as any).filename; + this.entity.setLocalRotation(rotation); + } + + this.entity.addComponent('gsplat', { + asset, + unified: false + }); + + const instance = this.entity.gsplat.instance; + + // added per-splat state channel + // bit 1: selected + // bit 2: deleted + // bit 3: locked + if (!splatData.getProp('state')) { + splatData.getElement('vertex').properties.push({ + type: 'uchar', + name: 'state', + storage: new Uint8Array(splatData.numSplats), + byteSize: 1 + }); + } + + // per-splat transform matrix + splatData.getElement('vertex').properties.push({ + type: 'ushort', + name: 'transform', + storage: new Uint16Array(splatData.numSplats), + byteSize: 2 + }); + + const { x: width, y: height } = (splatResource as any).textureDimensions; + + // pack spherical harmonic data + const createTexture = (name: string, format: number) => { + return new Texture(device, { + name: name, + width: width, + height: height, + format: format, + mipmaps: false, + minFilter: FILTER_NEAREST, + magFilter: FILTER_NEAREST, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + }; + + // create the state texture and the SplatState mirror that owns it. + // splatData.getProp('state') aliases state.data so existing read-only + // consumers (serialize, status-bar, etc) keep working unchanged. + this.stateTexture = createTexture('splatState', PIXELFORMAT_R8); + this.state = new SplatState(splatData.getProp('state') as Uint8Array, this.stateTexture); + this.transformTexture = createTexture('splatTransform', PIXELFORMAT_R16U); + + this.localBoundStorage = instance.resource.aabb; + // @ts-ignore + this.worldBoundStorage = instance.meshInstance._aabb; + + // @ts-ignore + instance.meshInstance._updateAabb = false; + + // when sort changes, re-render the scene + instance.sorter.on('updated', () => { + this.changedCounter++; + }); + } + + // wait for the next scene render to complete, with a safety timeout so a + // stalled render loop (e.g. a backgrounded tab where rAF is paused) can't + // block frame swapping forever. In a live app postrender fires within a + // frame, so the timeout never matters. + private waitForRender(): Promise { + return new Promise((resolve) => { + // single finish() removes the listener and clears the timeout, so the + // common case (postrender fires first) doesn't leave a pending timer. + const handles: { off?: { off: () => void }, timer?: ReturnType } = {}; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + handles.off?.off(); + clearTimeout(handles.timer); + resolve(); + }; + handles.off = this.scene.events.on('postrender', finish); + // safety: don't block frame swapping forever if the render loop is stalled + handles.timer = setTimeout(finish, 200); + }); + } + + // swap in a new frame's gsplat data while preserving this element's identity, + // transform and visual properties. used by animated sequence playback so each + // frame doesn't recreate the whole element. + // + // The gsplat lives on this.entity (read in many places), so we can't double + // buffer on a child. Instead we bind the new frame to a *fresh* entity, sort + // it, and let it render once alongside the still-present old entity before + // destroying the old one. This overlap avoids a blank/unsorted frame + // flickering on screen during the swap (the old frame masks the new one's + // first sort), matching the previous per-frame load behaviour. The user's + // transform is carried across so it persists. + async replaceData(asset: Asset) { + const oldEntity = this.entity; + const oldAsset = this.asset; + const oldStateTexture = this.stateTexture; + const oldTransformTexture = this.transformTexture; + + // carry the current transform onto the new entity + const position = oldEntity.getLocalPosition().clone(); + const rotation = oldEntity.getLocalRotation().clone(); + const scale = oldEntity.getLocalScale().clone(); + + this.entity = new Entity('splatEntity'); + this.entity.setLocalPosition(position); + this.entity.setLocalRotation(rotation); + this.entity.setLocalScale(scale); + + // bind the new frame (no rotation: transform already applied above) + this.bindAsset(asset); + + // add the new entity to the scene and configure its instance + this.scene.contentRoot.addChild(this.entity); + this.entity.gsplat.layers = [this.scene.splatLayer.id]; + this.rebuildMaterial(this.scene.events.invoke('view.bands')); + + // refresh gpu state/counts/bounds, then wait for the new frame to render + // before removing the old entity, which keeps the previous frame on screen + // in the meantime. Skip the wait during offline video render + // (lockedRenderMode): renders are gated on scene.lockedRender there, so + // blocking on a render would deadlock — and the render loop sorts+captures + // each frame deterministically anyway. + await this.updateState(State.deleted); + if (!this.scene.lockedRenderMode) { + await this.waitForRender(); + } + + // notify dependents (e.g. the centers overlay, which parents itself under + // this.entity) to re-bind to the new entity/instance before the old entity + // is destroyed — otherwise they're torn down with it and never re-attach + // (no selection.changed fires on a frame swap). + this.scene.events.fire('splat.replaced', this); + + // tear down the previous frame + oldEntity.destroy(); + oldStateTexture.destroy(); + oldTransformTexture.destroy(); + oldAsset.registry?.remove(oldAsset); + oldAsset.unload(); + + this.changedCounter++; + this.scene.forceRender = true; + } + + destroy() { + super.destroy(); + this.entity.destroy(); + this.asset.registry.remove(this.asset); + this.asset.unload(); + } + + async updateState(changedState = State.selected) { + // uploads dirty range + refreshes counts in one pass. + this.state.flush(); + this.numSplats = this.state.data.length - this.state.numDeleted; + this.numLocked = this.state.numLocked; + this.numSelected = this.state.numSelected; + this.numDeleted = this.state.numDeleted; + + // handle splats being added or removed + if (changedState & State.deleted) { + await this.updateSorting(); + } else { + await this.updateLocalBounds(); + } + + this.scene.forceRender = true; + this.scene.events.fire('splat.stateChanged', this); + } + + async updatePositions() { + const data = await this.scene.dataProcessor.calcPositions(this); + + // update the splat centers which are used for render-time sorting + const state = this.splatData.getProp('state') as Uint8Array; + const { sorter } = this.entity.gsplat.instance; + const { centers } = sorter; + for (let i = 0; i < this.splatData.numSplats; ++i) { + if (state[i] === State.selected) { + centers[i * 3 + 0] = data[i * 4]; + centers[i * 3 + 1] = data[i * 4 + 1]; + centers[i * 3 + 2] = data[i * 4 + 2]; + } + } + + await this.updateSorting(); + + this.scene.forceRender = true; + this.scene.events.fire('splat.positionsChanged', this); + } + + async updateSorting() { + const state = this.splatData.getProp('state') as Uint8Array; + + let mapping; + + // create a sorter mapping to remove deleted splats + if (this.numSplats !== state.length) { + mapping = new Uint32Array(this.numSplats); + let idx = 0; + for (let i = 0; i < state.length; ++i) { + if ((state[i] & State.deleted) === 0) { + mapping[idx++] = i; + } + } + } + + // update sorting instance + this.entity.gsplat.instance.sorter.setMapping(mapping); + + // recalculate bounds after sorting changes + await this.updateLocalBounds(); + } + + get worldTransform() { + return this.entity.getWorldTransform(); + } + + set name(newName: string) { + if (newName !== this.name) { + this._name = newName; + this.scene.events.fire('splat.name', this); + } + } + + get name() { + return this._name; + } + + get filename() { + return (this.asset.file as any).filename; + } + + calcSplatWorldPosition(splatId: number, result: Vec3) { + if (splatId >= this.splatData.numSplats) { + return false; + } + + // use centers data, which are updated when edits occur + const { sorter } = this.entity.gsplat.instance; + const { centers } = sorter; + + result.set( + centers[splatId * 3 + 0], + centers[splatId * 3 + 1], + centers[splatId * 3 + 2] + ); + + this.worldTransform.transformPoint(result, result); + + return true; + } + + async add() { + // add the entity to the scene + this.scene.contentRoot.addChild(this.entity); + + // assign splat to the dedicated splat layer (rendered by splat camera with MRT) + this.entity.gsplat.layers = [this.scene.splatLayer.id]; + + this.scene.events.on('view.bands', this.rebuildMaterial, this); + this.rebuildMaterial(this.scene.events.invoke('view.bands')); + + // we must update state in case the state data was loaded from ply + await this.updateState(); + } + + remove() { + this.scene.events.off('view.bands', this.rebuildMaterial, this); + + this.scene.contentRoot.removeChild(this.entity); + this.scene.boundDirty = true; + } + + serialize(serializer: Serializer) { + serializer.packa(this.entity.getWorldTransform().data); + serializer.pack(this.changedCounter); + serializer.pack(this.visible); + serializer.pack(this.tintClr.r, this.tintClr.g, this.tintClr.b); + serializer.pack(this.temperature, this.saturation, this.brightness, this.blackPoint, this.whitePoint, this.transparency); + } + + onPreRender() { + const events = this.scene.events; + const selected = this.scene.camera.renderOverlays && events.invoke('selection') === this; + const cameraMode = events.invoke('camera.mode'); + const cameraOverlay = events.invoke('camera.overlay'); + + // configure rings rendering + const material = this.entity.gsplat.instance.material; + material.setParameter('outlineMode', events.invoke('view.outlineSelection') ? 1 : 0); + material.setParameter('ringSize', (selected && cameraOverlay && cameraMode === 'rings') ? 0.04 : 0); + + // configure colors + const selectedClr = events.invoke('selectedClr'); + const unselectedClr = events.invoke('unselectedClr'); + const lockedClr = events.invoke('lockedClr'); + + if (!selected) { + material.setParameter('selectedClr', [0, 0, 0, 0]); + } else if (events.invoke('view.outlineSelection')) { + material.setParameter('selectedClr', [0, 0, 0, 0]); + } else { + material.setParameter('selectedClr', [selectedClr.r, selectedClr.g, selectedClr.b, selectedClr.a * this.selectionAlpha]); + } + material.setParameter('unselectedClr', [unselectedClr.r, unselectedClr.g, unselectedClr.b, unselectedClr.a]); + material.setParameter('lockedClr', [lockedClr.r, lockedClr.g, lockedClr.b, lockedClr.a]); + + // combine black pointer, white point and brightness + const offset = -this.blackPoint + this.brightness; + const scale = 1 / (this.whitePoint - this.blackPoint); + + material.setParameter('clrOffset', [offset, offset, offset]); + material.setParameter('clrScale', [ + scale * this.tintClr.r * (1 + this.temperature), + scale * this.tintClr.g, + scale * this.tintClr.b * (1 - this.temperature), + this.transparency + ]); + + material.setParameter('saturation', this.saturation); + material.setParameter('transformPalette', this.transformPalette.texture); + + if (this.visible && selected) { + // render bounding box + if (events.invoke('camera.bound')) { + const bound = this.localBound; + const scale = new Mat4().setTRS(bound.center, Quat.IDENTITY, bound.halfExtents); + scale.mul2(this.entity.getWorldTransform(), scale); + + for (let i = 0; i < boundingPoints.length / 2; i++) { + const a = boundingPoints[i * 2]; + const b = boundingPoints[i * 2 + 1]; + scale.transformPoint(a, veca); + scale.transformPoint(b, vecb); + + this.scene.app.drawLine(veca, vecb, Color.WHITE, true, this.scene.worldLayer); + } + } + } + + this.entity.enabled = this.visible; + } + + focalPoint() { + // GSplatData has a function for calculating an weighted average of the splat positions + // to get a focal point for the camera, but we use bound center instead + return this.worldBound.center; + } + + move(position?: Vec3, rotation?: Quat, scale?: Vec3) { + const entity = this.entity; + if (position) { + entity.setLocalPosition(position); + } + if (rotation) { + entity.setLocalRotation(rotation); + } + if (scale) { + entity.setLocalScale(scale); + } + + this.updateWorldBound(); + + this.scene.events.fire('splat.moved', this); + } + + // calculate both selection and local bounds (async, callers must await) + async updateLocalBounds(): Promise { + await this.scene.dataProcessor.calcBound(this, this.selectionBoundStorage, this.localBoundStorage); + this.updateWorldBound(); + } + + // update world bound from local bound (synchronous) + private updateWorldBound() { + this.worldBoundStorage.setFromTransformedAabb(this.localBoundStorage, this.entity.getWorldTransform()); + this.scene.boundDirty = true; + } + + // get the selection bound + get selectionBound() { + return this.selectionBoundStorage; + } + + // get local space bound + get localBound() { + return this.localBoundStorage; + } + + // get world space bound + get worldBound() { + return this.worldBoundStorage; + } + + set visible(value: boolean) { + if (value !== this.visible) { + this._visible = value; + this.scene?.events.fire('splat.visibility', this); + } + } + + get visible() { + return this._visible; + } + + set tintClr(value: Color) { + if (!this._tintClr.equals(value)) { + this._tintClr.set(value.r, value.g, value.b); + this.scene.events.fire('splat.tintClr', this); + } + } + + get tintClr() { + return this._tintClr; + } + + set temperature(value: number) { + if (value !== this._temperature) { + this._temperature = value; + this.scene.events.fire('splat.temperature', this); + } + } + + get temperature() { + return this._temperature; + } + + set saturation(value: number) { + if (value !== this._saturation) { + this._saturation = value; + this.scene.events.fire('splat.saturation', this); + } + } + + get saturation() { + return this._saturation; + } + + set brightness(value: number) { + if (value !== this._brightness) { + this._brightness = value; + this.scene.events.fire('splat.brightness', this); + } + } + + get brightness() { + return this._brightness; + } + + set blackPoint(value: number) { + if (value !== this._blackPoint) { + this._blackPoint = value; + this.scene.events.fire('splat.blackPoint', this); + } + } + + get blackPoint() { + return this._blackPoint; + } + + set whitePoint(value: number) { + if (value !== this._whitePoint) { + this._whitePoint = value; + this.scene.events.fire('splat.whitePoint', this); + } + } + + get whitePoint() { + return this._whitePoint; + } + + set transparency(value: number) { + if (value !== this._transparency) { + this._transparency = value; + this.scene.events.fire('splat.transparency', this); + } + } + + get transparency() { + return this._transparency; + } + + // get pivot position/rotation/scale (caller should have awaited operation that changed data) + getPivot(mode: 'center' | 'boundCenter', selection: boolean, result: Transform) { + const { entity } = this; + switch (mode) { + case 'center': + result.set(entity.getLocalPosition(), entity.getLocalRotation(), entity.getLocalScale()); + break; + case 'boundCenter': { + const bound = selection ? this.selectionBound : this.localBound; + entity.getLocalTransform().transformPoint(bound.center, vec); + result.set(vec, entity.getLocalRotation(), entity.getLocalScale()); + break; + } + } + } + + docSerialize() { + const pack3 = (v: Vec3) => [v.x, v.y, v.z]; + const pack4 = (q: Quat) => [q.x, q.y, q.z, q.w]; + const packC = (c: Color) => [c.r, c.g, c.b, c.a]; + return { + name: this.name, + position: pack3(this.entity.getLocalPosition()), + rotation: pack4(this.entity.getLocalRotation()), + scale: pack3(this.entity.getLocalScale()), + visible: this.visible, + tintClr: packC(this.tintClr), + temperature: this.temperature, + saturation: this.saturation, + brightness: this.brightness, + blackPoint: this.blackPoint, + whitePoint: this.whitePoint, + transparency: this.transparency + }; + } + + docDeserialize(doc: any) { + const { name, position, rotation, scale, visible, tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = doc; + + this.name = name; + this.move(new Vec3(position), new Quat(rotation), new Vec3(scale)); + this.visible = visible; + this.tintClr = new Color(tintClr[0], tintClr[1], tintClr[2], tintClr[3]); + this.temperature = temperature ?? 0; + this.saturation = saturation ?? 1; + this.brightness = brightness; + this.blackPoint = blackPoint; + this.whitePoint = whitePoint; + this.transparency = transparency; + } +} + +export { Splat }; diff --git a/src/splats-transform-handler.ts b/src/splats-transform-handler.ts new file mode 100644 index 0000000..aa25ca9 --- /dev/null +++ b/src/splats-transform-handler.ts @@ -0,0 +1,196 @@ +import { Mat4, Vec3 } from 'playcanvas'; + +import { PlacePivotOp, SplatsTransformOp, MultiOp } from './edit-ops'; +import { Events } from './events'; +import { Pivot } from './pivot'; +import { Splat } from './splat'; +import { State } from './splat-state'; +import { Transform } from './transform'; +import { TransformHandler } from './transform-handler'; + +const mat = new Mat4(); +const mat2 = new Mat4(); +const transform = new Transform(); + +class SplatsTransformHandler implements TransformHandler { + events: Events; + splat: Splat; + pivotStart = new Transform(); + localToPivot = new Mat4(); + worldToLocal = new Mat4(); + + transform = new Mat4(); + paletteMap = new Map(); + + constructor(events: Events) { + this.events = events; + + events.on('pivot.started', (pivot: Pivot) => { + if (this.splat) { + this.start(); + } + }); + + events.on('pivot.moved', (pivot: Pivot) => { + if (this.splat) { + this.update(pivot.transform); + } + }); + + events.on('pivot.ended', (pivot: Pivot) => { + if (this.splat) { + this.end(); + } + }); + + events.on('selection.changed', (splat) => { + if (this.splat && splat === this.splat) { + this.placePivot(); + } + }); + + events.on('pivot.origin', (mode: 'center' | 'boundCenter') => { + if (this.splat) { + this.placePivot(); + } + }); + + events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => { + if (this.splat && ['move', 'rotate', 'scale'].includes(this.events.invoke('tool.active'))) { + const pivot = events.invoke('pivot') as Pivot; + const oldt = pivot.transform.clone(); + const newt = new Transform(details.position, pivot.transform.rotation, pivot.transform.scale); + const op = new PlacePivotOp({ pivot, oldt, newt }); + events.fire('edit.add', op); + } + }); + } + + placePivot() { + const origin = this.events.invoke('pivot.origin'); + this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', true, transform); + this.events.invoke('pivot').place(transform); + } + + activate() { + this.splat = this.events.invoke('selection') as Splat; + if (this.splat) { + this.placePivot(); + } + } + + deactivate() { + this.splat = null; + } + + start() { + const pivot = this.events.invoke('pivot') as Pivot; + const { transform } = pivot; + const { splat } = this; + const { transformPalette } = splat; + + mat.setTRS(transform.position, transform.rotation, transform.scale); + + // calculate local -> pivot transform + this.localToPivot.invert(mat); + this.localToPivot.mul2(this.localToPivot, splat.entity.getLocalTransform()); + + // calculate the world -> local transform + this.worldToLocal.invert(splat.entity.getLocalTransform()); + + this.pivotStart.copy(transform); + + // allocate a new transform for the current selection + const state = splat.splatData.getProp('state') as Uint8Array; + const indices = splat.transformTexture.lock() as Uint16Array; + + const { paletteMap } = this; + paletteMap.clear(); + + for (let i = 0; i < state.length; ++i) { + if (state[i] === State.selected) { + const oldIdx = indices[i]; + let newIdx; + if (!paletteMap.has(oldIdx)) { + newIdx = transformPalette.alloc(); + paletteMap.set(oldIdx, newIdx); + } else { + newIdx = paletteMap.get(oldIdx); + } + + indices[i] = newIdx; + } + } + + splat.transformTexture.unlock(); + + // initialize transforms + this.paletteMap.forEach((newIdx, oldIdx) => { + transformPalette.getTransform(oldIdx, mat); + transformPalette.setTransform(newIdx, mat); + }); + + splat.selectionAlpha = 0; + splat.scene.outline.enabled = false; + splat.scene.underlay.enabled = false; + } + + update(transform: Transform) { + // calculate updated new pivot -> world transform + mat.setTRS(transform.position, transform.rotation, transform.scale); + mat.mul2(mat, this.localToPivot); // local -> world + mat.mul2(this.worldToLocal, mat); // world -> local + + this.transform.copy(mat); + + // update the transform palette + const { transformPalette } = this.splat; + this.paletteMap.forEach((newIdx, oldIdx) => { + transformPalette.getTransform(oldIdx, mat2); + mat2.mul2(mat, mat2); + transformPalette.setTransform(newIdx, mat2); + }); + + // route through the shared queue so overlapping drag ticks don't race + // on CalcBound's shared render targets / readback buffers. fire-and- + // forget is fine: the final bound is recomputed when end() awaits + // updatePositions -> updateSorting -> updateLocalBounds. + this.events.invoke('queue', () => this.splat.updateLocalBounds()); + } + + async end() { + const { splat, transform, paletteMap } = this; + + // create op for splat transform (already applied to GPU during update()) + const top = new SplatsTransformOp({ + splat, + transform: transform.clone(), + paletteMap: new Map(paletteMap) + }); + + // create op for pivot placement + const pivot = this.events.invoke('pivot') as Pivot; + const oldt = this.pivotStart.clone(); + const newt = pivot.transform.clone(); + const pop = new PlacePivotOp({ pivot, newt, oldt }); + + // record the editop on the shared command queue BEFORE awaiting any async work. + // events.fire synchronously enqueues the add, so any subsequent undo/redo + // (e.g. user pressing Ctrl+Z while updatePositions is still resolving) is + // guaranteed to land AFTER this op on the queue — which means the undo will + // revert this transform operation rather than the prior selection op. + this.events.fire('edit.add', new MultiOp([top, pop]), true); + + // enqueue the GPU readback onto the same shared queue so any subsequent + // undo/redo waits for it to finish before mutating the sorter's centers buffer. + // TODO: consider moving this to update() function above so splats are sorted correctly + // for render during drag (which is slower). + await this.events.invoke('queue', () => splat.updatePositions()); + + splat.selectionAlpha = 1; + splat.scene.outline.enabled = true; + splat.scene.underlay.enabled = true; + } +} + +export { SplatsTransformHandler }; diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 0000000..7cdfa94 --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,59 @@ +import { version as appVersion } from '../package.json'; + +// export default null +declare let self: ServiceWorkerGlobalScope; + +const cacheName = `superSplat-v${appVersion}`; + +const cacheUrls = [ + './', + './index.css', + './index.html', + './index.js', + './index.js.map', + './manifest.json', + './static/icons/logo-192.png', + './static/icons/logo-512.png', + './static/images/screenshot-narrow.jpg', + './static/images/screenshot-wide.jpg', + './static/lib/webp/webp.mjs', + './static/lib/webp/webp.wasm', + './static/locales/de.json', + './static/locales/en.json', + './static/locales/fr.json', + './static/locales/ja.json', + './static/locales/ko.json', + './static/locales/zh-CN.json' +]; + +self.addEventListener('install', (event) => { + console.log(`installing v${appVersion}`); + + // create cache for current version + event.waitUntil( + caches.open(cacheName) + .then((cache) => { + cache.addAll(cacheUrls); + }) + ); +}); + +self.addEventListener('activate', () => { + console.log(`activating v${appVersion}`); + + // delete the old caches once this one is activated + caches.keys().then((names) => { + for (const name of names) { + if (name !== cacheName) { + caches.delete(name); + } + } + }); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request) + .then(response => response ?? fetch(event.request)) + ); +}); diff --git a/src/timeline.ts b/src/timeline.ts new file mode 100644 index 0000000..45ec575 --- /dev/null +++ b/src/timeline.ts @@ -0,0 +1,219 @@ +import { EventHandle } from 'playcanvas'; + +import { Events } from './events'; + +/** + * Register global timeline events. + * The timeline manages playback state (frames, frameRate, current frame, playing). + * Key management is delegated to individual animation tracks via track.* events. + */ +const registerTimelineEvents = (events: Events) => { + let frames = 180; + let frameRate = 30; + let smoothness = 1; + let loop = true; + + // frames + + const setFrames = (value: number) => { + if (value !== frames) { + frames = value; + events.fire('timeline.frames', frames); + } + }; + + events.function('timeline.frames', () => { + return frames; + }); + + events.on('timeline.setFrames', (value: number) => { + setFrames(value); + }); + + // frame rate + + const setFrameRate = (value: number) => { + if (value !== frameRate) { + frameRate = value; + events.fire('timeline.frameRate', frameRate); + } + }; + + events.function('timeline.frameRate', () => { + return frameRate; + }); + + events.on('timeline.setFrameRate', (value: number) => { + setFrameRate(value); + }); + + // smoothness + + const setSmoothness = (value: number) => { + if (value !== smoothness) { + smoothness = value; + events.fire('timeline.smoothness', smoothness); + } + }; + + events.function('timeline.smoothness', () => { + return smoothness; + }); + + events.on('timeline.setSmoothness', (value: number) => { + setSmoothness(value); + }); + + // loop + + const setLoop = (value: boolean) => { + if (value !== loop) { + loop = value; + events.fire('timeline.loop', loop); + } + }; + + events.function('timeline.loop', () => { + return loop; + }); + + events.on('timeline.setLoop', (value: boolean) => { + setLoop(value); + }); + + // current frame + let frame = 0; + + const setFrame = (value: number) => { + if (value !== frame) { + frame = value; + events.fire('timeline.frame', frame); + } + }; + + events.function('timeline.frame', () => { + return frame; + }); + + events.on('timeline.setFrame', (value: number) => { + setFrame(value); + }); + + // anim controls + let animHandle: EventHandle = null; + + const play = () => { + let time = frame; + + // handle application update tick + animHandle = events.on('update', (dt: number) => { + time = (time + dt * frameRate) % frames; + setFrame(Math.floor(time)); + events.fire('timeline.time', time); + }); + }; + + const stop = () => { + animHandle.off(); + animHandle = null; + }; + + // playing state + let playing = false; + + const setPlaying = (value: boolean) => { + if (value !== playing) { + playing = value; + events.fire('timeline.playing', playing); + if (playing) { + play(); + } else { + stop(); + } + } + }; + + events.function('timeline.playing', () => { + return playing; + }); + + events.on('timeline.setPlaying', (value: boolean) => { + setPlaying(value); + }); + + // shortcut handlers + events.on('timeline.togglePlay', () => { + setPlaying(!playing); + }); + + events.on('timeline.prevFrame', () => { + setFrame((frame - 1 + frames) % frames); + }); + + events.on('timeline.nextFrame', () => { + setFrame((frame + 1) % frames); + }); + + // Key navigation - delegates to active track's keys + const skipToKey = (dir: 'forward' | 'back') => { + const keys = events.invoke('track.keys') as number[] ?? []; + + if (keys.length > 0) { + const orderedKeys = keys.slice().sort((a, b) => a - b); + const l = orderedKeys.length; + + const nextKeyIndex = orderedKeys.findIndex(k => (dir === 'back' ? k >= frame : k > frame)); + + if (nextKeyIndex === -1) { + setFrame(orderedKeys[dir === 'back' ? l - 1 : 0]); + } else { + setFrame(orderedKeys[dir === 'back' ? (nextKeyIndex + l - 1) % l : nextKeyIndex]); + } + } else { + setFrame(dir === 'back' ? 0 : frames - 1); + } + }; + + events.on('timeline.prevKey', () => { + skipToKey('back'); + }); + + events.on('timeline.nextKey', () => { + skipToKey('forward'); + }); + + // clear timeline state when scene is cleared + events.on('scene.clear', () => { + events.fire('timeline.frames', frames); + }); + + // Serialization - only global state, keys are owned by tracks + + events.function('docSerialize.timeline', () => { + return { + frames, + frameRate, + frame, + smoothness, + loop + }; + }); + + events.function('docDeserialize.timeline', (data: any = {}) => { + // Set values + frames = data.frames ?? 180; + frameRate = data.frameRate ?? 30; + frame = data.frame ?? 0; + smoothness = data.smoothness ?? 1; + loop = data.loop ?? true; + + // Fire events to update UI (always fire to ensure rebuild) + events.fire('timeline.frames', frames); + events.fire('timeline.frameRate', frameRate); + events.fire('timeline.frame', frame); + events.fire('timeline.smoothness', smoothness); + events.fire('timeline.loop', loop); + }); +}; + +export { registerTimelineEvents }; diff --git a/src/tools/box-selection.ts b/src/tools/box-selection.ts new file mode 100644 index 0000000..76c87de --- /dev/null +++ b/src/tools/box-selection.ts @@ -0,0 +1,169 @@ +import { Button, Container, Label, VectorInput } from '@playcanvas/pcui'; +import { TranslateGizmo, Vec3 } from 'playcanvas'; + +import { BoxShape } from '../box-shape'; +import { Events } from '../events'; +import { Scene } from '../scene'; +import { Splat } from '../splat'; +import { i18n } from '../ui/localization'; + +class BoxSelection { + activate: () => void; + deactivate: () => void; + + active = false; + + constructor(events: Events, scene: Scene, canvasContainer: Container) { + const box = new BoxShape(); + + const gizmo = new TranslateGizmo(scene.camera.camera, scene.gizmoLayer); + + gizmo.on('render:update', () => { + scene.forceRender = true; + }); + + gizmo.on('transform:move', () => { + box.moved(); + }); + + // ui + const selectToolbar = new Container({ + class: 'select-toolbar', + hidden: true + }); + + selectToolbar.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + }); + + const setButton = new Button({ class: 'select-toolbar-button' }); + const addButton = new Button({ class: 'select-toolbar-button' }); + const removeButton = new Button({ class: 'select-toolbar-button' }); + const intersectButton = new Button({ class: 'select-toolbar-button' }); + + i18n.bindText(setButton, 'select-toolbar.set'); + i18n.bindText(addButton, 'select-toolbar.add'); + i18n.bindText(removeButton, 'select-toolbar.remove'); + i18n.bindText(intersectButton, 'select-toolbar.intersect'); + + const positionLabel = new Label({ class: 'select-toolbar-label' }); + i18n.bindText(positionLabel, 'select-toolbar.position'); + + const position = new VectorInput({ + class: 'select-toolbar-vector', + precision: 2, + dimensions: 3, + placeholder: ['X', 'Y', 'Z'], + value: [0, 0, 0] + }); + + const sizeLabel = new Label({ class: 'select-toolbar-label' }); + i18n.bindText(sizeLabel, 'select-toolbar.size'); + + const size = new VectorInput({ + class: 'select-toolbar-vector', + precision: 2, + dimensions: 3, + placeholder: ['X', 'Y', 'Z'], + value: [box.lenX, box.lenY, box.lenZ], + min: 0.01 + }); + + selectToolbar.append(setButton); + selectToolbar.append(addButton); + selectToolbar.append(removeButton); + selectToolbar.append(intersectButton); + selectToolbar.append(positionLabel); + selectToolbar.append(position); + selectToolbar.append(sizeLabel); + selectToolbar.append(size); + + canvasContainer.append(selectToolbar); + + // write the volume's world position into the ui without retriggering + // the position input's change handler + let uiUpdating = false; + const updateUI = () => { + uiUpdating = true; + const p = box.pivot.getPosition(); + position.value = [p.x, p.y, p.z]; + uiUpdating = false; + }; + + gizmo.on('transform:move', () => { + updateUI(); + }); + + const apply = (op: 'set' | 'add' | 'remove' | 'intersect') => { + const p = box.pivot.getPosition(); + events.fire('select.byBox', op, [p.x, p.y, p.z, box.lenX, box.lenY, box.lenZ]); + }; + + setButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + apply('set'); + }); + addButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + apply('add'); + }); + removeButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + apply('remove'); + }); + intersectButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + apply('intersect'); + }); + position.on('change', (v: number[]) => { + if (!uiUpdating) { + box.pivot.setPosition(v[0], v[1], v[2]); + box.moved(); + gizmo.attach([box.pivot]); + } + }); + size.on('change', (v: number[]) => { + box.lenX = v[0]; + box.lenY = v[1]; + box.lenZ = v[2]; + }); + + events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => { + if (this.active) { + box.pivot.setPosition(details.position); + box.moved(); + gizmo.attach([box.pivot]); + updateUI(); + } + }); + + const updateGizmoSize = () => { + const { camera, canvas } = scene; + if (camera.ortho) { + gizmo.size = 1125 / canvas.clientHeight; + } else { + gizmo.size = 1200 / Math.max(canvas.clientWidth, canvas.clientHeight); + } + }; + updateGizmoSize(); + events.on('camera.resize', updateGizmoSize); + events.on('camera.ortho', updateGizmoSize); + + this.activate = () => { + this.active = true; + scene.add(box); + gizmo.attach([box.pivot]); + updateUI(); + selectToolbar.hidden = false; + }; + + this.deactivate = () => { + selectToolbar.hidden = true; + gizmo.detach(); + scene.remove(box); + this.active = false; + }; + } +} + +export { BoxSelection }; diff --git a/src/tools/brush-selection.ts b/src/tools/brush-selection.ts new file mode 100644 index 0000000..f770862 --- /dev/null +++ b/src/tools/brush-selection.ts @@ -0,0 +1,150 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; + +class BrushSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, mask: { canvas: HTMLCanvasElement, context: CanvasRenderingContext2D }) { + // create svg + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('tool-svg', 'hidden'); + svg.id = 'brush-select-svg'; + parent.appendChild(svg); + + // create circle element + const circle = document.createElementNS(svg.namespaceURI, 'circle') as SVGCircleElement; + svg.appendChild(circle); + + const { canvas, context } = mask; + + let radius = 40; + + circle.setAttribute('r', radius.toString()); + + const prev = { x: 0, y: 0 }; + let dragId: number | undefined; + + const update = (e: PointerEvent) => { + const x = e.offsetX; + const y = e.offsetY; + + circle.setAttribute('cx', x.toString()); + circle.setAttribute('cy', y.toString()); + + if (dragId !== undefined) { + context.beginPath(); + context.strokeStyle = '#f60'; + context.lineCap = 'round'; + context.lineWidth = radius * 2; + context.moveTo(prev.x, prev.y); + context.lineTo(x, y); + context.stroke(); + + prev.x = x; + prev.y = y; + } + }; + + const pointerdown = (e: PointerEvent) => { + if (dragId === undefined && (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { + e.preventDefault(); + e.stopPropagation(); + + dragId = e.pointerId; + parent.setPointerCapture(dragId); + + // initialize canvas + if (canvas.width !== parent.clientWidth || canvas.height !== parent.clientHeight) { + canvas.width = parent.clientWidth; + canvas.height = parent.clientHeight; + } + + // clear canvas + context.clearRect(0, 0, canvas.width, canvas.height); + + // display it + canvas.style.display = 'inline'; + + prev.x = e.offsetX; + prev.y = e.offsetY; + + update(e); + } + }; + + const pointermove = (e: PointerEvent) => { + if (dragId !== undefined) { + e.preventDefault(); + e.stopPropagation(); + } + + update(e); + }; + + const dragEnd = () => { + parent.releasePointerCapture(dragId); + dragId = undefined; + canvas.style.display = 'none'; + }; + + const pointerup = async (e: PointerEvent) => { + if (e.pointerId === dragId) { + e.preventDefault(); + e.stopPropagation(); + + dragEnd(); + + await events.invoke( + 'select.byMask', + opFromModifiers(e), + canvas, + context + ); + } + }; + + const wheel = (e: WheelEvent) => { + if (e.altKey || e.metaKey) { + const { deltaX, deltaY } = e; + events.fire((Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY) > 0 ? 'tool.brushSelection.smaller' : 'tool.brushSelection.bigger'); + e.preventDefault(); + e.stopPropagation(); + } + }; + + this.activate = () => { + svg.classList.remove('hidden'); + parent.style.display = 'block'; + parent.addEventListener('pointerdown', pointerdown); + parent.addEventListener('pointermove', pointermove); + parent.addEventListener('pointerup', pointerup); + parent.addEventListener('wheel', wheel); + }; + + this.deactivate = () => { + // cancel active operation + if (dragId !== undefined) { + dragEnd(); + } + svg.classList.add('hidden'); + parent.style.display = 'none'; + parent.removeEventListener('pointerdown', pointerdown); + parent.removeEventListener('pointermove', pointermove); + parent.removeEventListener('pointerup', pointerup); + parent.removeEventListener('wheel', wheel); + }; + + events.on('tool.brushSelection.smaller', () => { + radius = Math.max(1, radius / 1.05); + circle.setAttribute('r', radius.toString()); + }); + + events.on('tool.brushSelection.bigger', () => { + radius = Math.min(500, radius * 1.05); + circle.setAttribute('r', radius.toString()); + }); + } +} + +export { BrushSelection }; diff --git a/src/tools/eyedropper-selection.ts b/src/tools/eyedropper-selection.ts new file mode 100644 index 0000000..105b6b3 --- /dev/null +++ b/src/tools/eyedropper-selection.ts @@ -0,0 +1,131 @@ +import { Container, NumericInput } from '@playcanvas/pcui'; + +import { Events } from '../events'; + +type PointerOp = 'set' | 'add' | 'remove'; + +type NormalizedPoint = { x: number, y: number }; + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +class EyedropperSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, canvasContainer: Container) { + let pointerId: number | null = null; + let threshold = 0.2; + + const selectToolbar = new Container({ + class: 'select-toolbar', + hidden: true + }); + + selectToolbar.dom.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + }); + + const thresholdInput = new NumericInput({ + value: threshold, + placeholder: 'Threshold', + width: 120, + precision: 3, + min: 0, + max: 1 + }); + + selectToolbar.append(thresholdInput); + canvasContainer.append(selectToolbar); + + const getPointerOp = (event: PointerEvent): PointerOp => { + if (event.shiftKey) { + return 'add'; + } + if (event.ctrlKey) { + return 'remove'; + } + return 'set'; + }; + // Convert pointer event to normalized coordinates within the parent element + const toNormalizedPoint = (event: PointerEvent): NormalizedPoint => { + const width = parent.clientWidth || 1; + const height = parent.clientHeight || 1; + return { + x: clamp01(event.offsetX / width), + y: clamp01(event.offsetY / height) + }; + }; + + const resetPointer = () => { + if (pointerId !== null) { + parent.releasePointerCapture(pointerId); + pointerId = null; + } + }; + + thresholdInput.on('change', () => { + threshold = clamp01(thresholdInput.value ?? threshold); + }); + + const pointerdown = (event: PointerEvent) => { + if (pointerId === null && (event.pointerType === 'mouse' ? event.button === 0 : event.isPrimary)) { + event.preventDefault(); + event.stopPropagation(); + pointerId = event.pointerId; + parent.setPointerCapture(pointerId); + } + }; + + const pointermove = (event: PointerEvent) => { + if (event.pointerId === pointerId) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const pointerup = async (event: PointerEvent) => { + if (event.pointerId === pointerId) { + event.preventDefault(); + event.stopPropagation(); + + await events.invoke( + 'select.colorMatch', + getPointerOp(event), + toNormalizedPoint(event), + threshold + ); + + resetPointer(); + } + }; + + const pointercancel = (event: PointerEvent) => { + if (event.pointerId === pointerId) { + event.preventDefault(); + event.stopPropagation(); + resetPointer(); + } + }; + + this.activate = () => { + parent.style.display = 'block'; + selectToolbar.hidden = false; + parent.addEventListener('pointerdown', pointerdown); + parent.addEventListener('pointermove', pointermove); + parent.addEventListener('pointerup', pointerup); + parent.addEventListener('pointercancel', pointercancel); + }; + + this.deactivate = () => { + parent.style.display = 'none'; + selectToolbar.hidden = true; + resetPointer(); + parent.removeEventListener('pointerdown', pointerdown); + parent.removeEventListener('pointermove', pointermove); + parent.removeEventListener('pointerup', pointerup); + parent.removeEventListener('pointercancel', pointercancel); + }; + } +} + +export { EyedropperSelection }; diff --git a/src/tools/flood-selection.ts b/src/tools/flood-selection.ts new file mode 100644 index 0000000..7de8216 --- /dev/null +++ b/src/tools/flood-selection.ts @@ -0,0 +1,160 @@ +import { Container, NumericInput } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; + +type Pt = {x : number, y: number }; + +const RED = 0; +const GREEN = 1; +const BLUE = 2; +const ALPHA = 3; +const PIXEL = 4; + +class FloodSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, mask: { canvas: HTMLCanvasElement, context: CanvasRenderingContext2D }, canvasContainer: Container) { + + // create canvas + const { canvas, context } = mask; + + let threshold = 0.2; + let point: Pt; + let imageData: ImageData; + + // ui + const selectToolbar = new Container({ + class: 'select-toolbar', + hidden: true + }); + + selectToolbar.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + }); + + const thresholdInput = new NumericInput({ + value: threshold, + placeholder: 'Threshold', + width: 120, + precision: 3, + min: 0.001, + max: 0.999 + }); + selectToolbar.append(thresholdInput); + + canvasContainer.append(selectToolbar); + + const apply = async (op: 'set' | 'add' | 'remove' | 'intersect') => { + await events.invoke( + 'select.byMask', + op, + canvas, + context + ); + }; + + const refreshSelection = async () => { + if (!point) return; + + const width = parent.clientWidth; + const height = parent.clientHeight; + + if (!imageData || canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + imageData = context.createImageData(width, height); + } + + const data = await (events.invoke('render.offscreen', width, height) as Promise); + let current: Pt = { + ...point + }; + + const start = (current.y * width + current.x) * PIXEL; + let idx = start; + const pickedOpacity = data[idx + ALPHA]; + + const testPixels: Pt[] = [current]; + const d = imageData.data; + + d.fill(102); + + while (testPixels.length > 0) { + current = testPixels.pop(); + idx = (current.y * width + current.x) * PIXEL; + if (Math.abs(data[idx + 3] - pickedOpacity) < threshold * 255) { + d[idx + RED] = 255; + d[idx + BLUE] = 0; + d[idx + ALPHA] = 255; + + if (current.x > 0 && d[idx - PIXEL + ALPHA] === 102) testPixels.push({ x: current.x - 1, y: current.y }); + if (current.x < width - 1 && d[idx + PIXEL + ALPHA] === 102) testPixels.push({ x: current.x + 1, y: current.y }); + if (current.y > 0 && d[idx - width * PIXEL + ALPHA] === 102) testPixels.push({ x: current.x, y: current.y - 1 }); + if (current.y < height - 1 && d[idx + width * PIXEL + ALPHA] === 102) testPixels.push({ x: current.x, y: current.y + 1 }); + } else { + d[idx + ALPHA] = 0; + } + } + + context.putImageData(imageData, 0, 0); + }; + + thresholdInput.on('change', () => { + threshold = thresholdInput.value; + }); + + const isPrimary = (e: PointerEvent) => { + return e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary; + }; + + let clicked = false; + + const pointerdown = (e: PointerEvent) => { + if (!clicked && isPrimary(e)) { + clicked = true; + } + }; + + const pointermove = (e: PointerEvent) => { + clicked = false; + }; + + const pointerup = async (e: PointerEvent) => { + if (clicked && isPrimary(e)) { + clicked = false; + + point = { + x: Math.floor(e.offsetX), + y: Math.floor(e.offsetY) + }; + + await refreshSelection(); + + await apply(opFromModifiers(e)); + + context.clearRect(0, 0, canvas.width, canvas.height); + } + }; + + this.activate = () => { + parent.style.display = 'block'; + selectToolbar.hidden = false; + canvasContainer.dom.addEventListener('pointerdown', pointerdown); + canvasContainer.dom.addEventListener('pointermove', pointermove); + canvasContainer.dom.addEventListener('pointerup', pointerup, true); + }; + + this.deactivate = () => { + parent.style.display = 'none'; + selectToolbar.hidden = true; + canvasContainer.dom.removeEventListener('pointerdown', pointerdown); + canvasContainer.dom.removeEventListener('pointermove', pointermove); + canvasContainer.dom.removeEventListener('pointerup', pointerup); + point = undefined; + }; + } +} + +export { FloodSelection }; diff --git a/src/tools/lasso-selection.ts b/src/tools/lasso-selection.ts new file mode 100644 index 0000000..f4541d4 --- /dev/null +++ b/src/tools/lasso-selection.ts @@ -0,0 +1,153 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; + +type Point = { x: number, y: number }; + +class LassoSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, mask: { canvas: HTMLCanvasElement, context: CanvasRenderingContext2D }) { + // create svg + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('tool-svg', 'hidden'); + svg.id = 'lasso-select-svg'; + parent.appendChild(svg); + + // create polygon element + const polygon = document.createElementNS(svg.namespaceURI, 'polygon') as SVGPolygonElement; + svg.appendChild(polygon); + + const { canvas, context } = mask; + let points: Point[] = []; + let currentPoint: Point = null; + let lastPointTime = 0; + + const dist = (a: Point, b: Point) => { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); + }; + + const isClosed = () => { + return points.length > 1 && dist(currentPoint, points[0]) < 8; + }; + + const paint = () => { + polygon.setAttribute('points', [...points, currentPoint].reduce((prev, current) => `${prev}${current.x}, ${current.y} `, '')); + polygon.setAttribute('stroke', isClosed() ? '#fa6' : '#f60'); + }; + + let dragId: number | undefined; + + const update = (e: PointerEvent) => { + currentPoint = { x: e.offsetX, y: e.offsetY }; + + const distance = points.length === 0 ? 0 : dist(currentPoint, points[points.length - 1]); + const millis = Date.now() - lastPointTime; + const preventCorners = distance > 20; + const slowNarrowSpacing = millis > 500 && distance > 2; + const fasterMediumSpacing = millis > 200 && distance > 10; + const firstPoints = points.length === 0; + + if (dragId !== undefined && (preventCorners || slowNarrowSpacing || fasterMediumSpacing || firstPoints)) { + points.push(currentPoint); + lastPointTime = Date.now(); + } + paint(); + }; + + const commitSelection = async (e: PointerEvent) => { + // initialize canvas + if (canvas.width !== parent.clientWidth || canvas.height !== parent.clientHeight) { + canvas.width = parent.clientWidth; + canvas.height = parent.clientHeight; + } + + // clear canvas + context.clearRect(0, 0, canvas.width, canvas.height); + + context.beginPath(); + context.fillStyle = '#f60'; + context.beginPath(); + points.forEach((p, idx) => { + if (idx === 0) { + context.moveTo(p.x, p.y); + } else { + context.lineTo(p.x, p.y); + } + }); + context.closePath(); + context.fill(); + + // wait for selection to complete + await events.invoke( + 'select.byMask', + opFromModifiers(e), + canvas, + context + ); + }; + + const pointerdown = (e: PointerEvent) => { + if (dragId === undefined && (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { + e.preventDefault(); + e.stopPropagation(); + + dragId = e.pointerId; + parent.setPointerCapture(dragId); + + update(e); + } + }; + + const pointermove = (e: PointerEvent) => { + if (dragId !== undefined) { + e.preventDefault(); + e.stopPropagation(); + } + + update(e); + }; + + const dragEnd = () => { + parent.releasePointerCapture(dragId); + dragId = undefined; + }; + + const pointerup = async (e: PointerEvent) => { + if (e.pointerId === dragId) { + e.preventDefault(); + e.stopPropagation(); + + // wait for selection to complete before clearing polygon + await commitSelection(e); + + dragEnd(); + + points = []; + paint(); + } + }; + + this.activate = () => { + svg.classList.remove('hidden'); + parent.style.display = 'block'; + parent.addEventListener('pointerdown', pointerdown); + parent.addEventListener('pointermove', pointermove); + parent.addEventListener('pointerup', pointerup); + }; + + this.deactivate = () => { + // cancel active operation + if (dragId !== undefined) { + dragEnd(); + } + svg.classList.add('hidden'); + parent.style.display = 'none'; + parent.removeEventListener('pointerdown', pointerdown); + parent.removeEventListener('pointermove', pointermove); + parent.removeEventListener('pointerup', pointerup); + }; + } +} + +export { LassoSelection }; diff --git a/src/tools/measure-tool.ts b/src/tools/measure-tool.ts new file mode 100644 index 0000000..c212929 --- /dev/null +++ b/src/tools/measure-tool.ts @@ -0,0 +1,414 @@ +import { Container, Label, NumericInput } from '@playcanvas/pcui'; +import { Entity, Mat4, Quat, TranslateGizmo, Vec3 } from 'playcanvas'; + +import { EntityTransformOp } from '../edit-ops'; +import { Events } from '../events'; +import { Scene } from '../scene'; +import { Splat } from '../splat'; +import { Transform } from '../transform'; +import { i18n } from '../ui/localization'; + +const mat = new Mat4(); +const mat1 = new Mat4(); +const mat2 = new Mat4(); +const mat3 = new Mat4(); +const p = new Vec3(); +const p0 = new Vec3(); +const p1 = new Vec3(); +const r = new Quat(); +const s = new Vec3(); + +const t = new Transform(); + +class MeasureTransformHandler { + activate() {} + deactivate() {} +} + +class MeasureTool { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, scene: Scene, parent: HTMLElement, canvasContainer: Container) { + // create svg + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('tool-svg', 'hidden'); + svg.id = 'measure-tool-svg'; + parent.appendChild(svg); + + const ns = svg.namespaceURI; + + // create defs node + const defs = document.createElementNS(ns, 'defs'); + + // create line element + const line = document.createElementNS(ns, 'line') as SVGLineElement; + line.id = 'measure-line'; + defs.appendChild(line); + + const lineBottom = document.createElementNS(ns, 'use') as SVGUseElement; + lineBottom.id = 'measure-line-bottom'; + lineBottom.setAttribute('href', '#measure-line'); + + const lineTop = document.createElementNS(ns, 'use') as SVGUseElement; + lineTop.id = 'measure-line-top'; + lineTop.setAttribute('href', '#measure-line'); + + // create line ends + const lineStart = document.createElementNS(ns, 'circle') as SVGCircleElement; + lineStart.id = 'measure-line-start'; + + const lineEnd = document.createElementNS(ns, 'circle') as SVGCircleElement; + lineEnd.id = 'measure-line-end'; + + svg.appendChild(defs); + svg.appendChild(lineBottom); + svg.appendChild(lineTop); + svg.appendChild(lineStart); + svg.appendChild(lineEnd); + + // ui + const lengthLabel = new Label(); + i18n.bindText(lengthLabel, 'measure.length'); + + const lengthInput = new NumericInput({ + width: 90, + placeholder: 'm', + precision: 2, + min: 0.0001, + value: 0 + }); + let suppressUI = 0; + + const selectToolbar = new Container({ + class: 'select-toolbar', + hidden: true + }); + + selectToolbar.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + }); + + selectToolbar.append(lengthLabel); + selectToolbar.append(lengthInput); + canvasContainer.append(selectToolbar); + + const gizmo = new TranslateGizmo(scene.camera.camera, scene.gizmoLayer); + const entity = new Entity('measureGizmoPivot'); + const transformHandler = new MeasureTransformHandler(); + + let active = false; + let splat: Splat; + + // get world space point + const getPoint = (index: number, result: Vec3) => { + splat.worldTransform.transformPoint(splat.measurePoints[index], result); + }; + + const getPoint2d = (index: number, result: Vec3) => { + getPoint(index, result); + scene.camera.worldToScreen(result, result); + result.x *= canvasContainer.dom.clientWidth; + result.y *= canvasContainer.dom.clientHeight; + }; + + const updateVisuals = () => { + gizmo.detach(); + + if (splat && active && splat.measureSelection >= 0 && splat.measureSelection < splat.measurePoints.length) { + getPoint(splat.measureSelection, p); + t.set(p, Quat.IDENTITY, Vec3.ONE); + events.invoke('pivot').place(t); + entity.setLocalPosition(p); + gizmo.attach(entity); + } + + if (splat && splat.measurePoints.length === 2) { + getPoint(0, p0); + getPoint(1, p1); + const len = p0.distance(p1); + + suppressUI++; + lengthInput.value = len; + lengthInput.enabled = true; + suppressUI--; + } else { + lengthInput.enabled = false; + } + }; + + gizmo.on('render:update', () => { + scene.forceRender = true; + }); + + gizmo.on('transform:start', () => { + events.invoke('pivot').start(); + }); + + gizmo.on('transform:move', () => { + events.invoke('pivot').moveTRS(entity.getLocalPosition(), entity.getLocalRotation(), entity.getLocalScale()); + }); + + gizmo.on('transform:end', () => { + events.invoke('pivot').end(); + }); + + events.on('selection.changed', (selection: Splat) => { + splat = selection; + if (active) { + // for now we always deactivate the tool so the current transform handler remains in place + events.fire('tool.deactivate'); + } + }); + + events.on('pivot.started', () => { + + }); + + events.on('pivot.moved', () => { + if (active && splat && splat.measureSelection >= 0 && splat.measureSelection < splat.measurePoints.length) { + const p = events.invoke('pivot').transform.position; + mat.invert(splat.worldTransform); + mat.transformPoint(p, splat.measurePoints[splat.measureSelection]); + } + scene.forceRender = true; + }); + + events.on('pivot.ended', () => { + if (active && splat && splat.measureSelection >= 0 && splat.measureSelection < splat.measurePoints.length) { + updateVisuals(); + } + }); + + const origTransform = new Mat4(); + const origP = new Vec3(); + const origR = new Quat(); + const origS = new Vec3(); + const mid = new Vec3(); + let startLen = 0; + + const startScale = () => { + if (!splat || splat.measurePoints.length !== 2) { + return; + } + + origTransform.copy(splat.worldTransform); + origP.copy(splat.entity.getLocalPosition()); + origR.copy(splat.entity.getLocalRotation()); + origS.copy(splat.entity.getLocalScale()); + + getPoint(0, p0); + getPoint(1, p1); + mid.sub2(p1, p0); + startLen = mid.length(); + mid.mulScalar(0.5).add(p0); + }; + + // position and scale the splat according to the new length + const applyLength = (newLength: number) => { + if (!splat || splat.measurePoints.length !== 2 || newLength <= 0) { + return; + } + + const scale = newLength / startLen; + + // calculate mid point + p.copy(mid); + + // construct a transform matrix that scales from p by len * 0.5 + mat1.setTranslate(-p.x, -p.y, -p.z); + mat2.setScale(scale, scale, scale); + mat3.setTranslate(p.x, p.y, p.z); + + mat.mul2(mat1, origTransform); + mat.mul2(mat2, mat); + mat.mul2(mat3, mat); + + mat.getTranslation(p); + r.setFromMat4(mat); + mat.getScale(s); + + splat.entity.setLocalPosition(p); + splat.entity.setLocalRotation(r); + splat.entity.setLocalScale(s); + + scene.forceRender = true; + }; + + const endScale = () => { + const top = new EntityTransformOp({ + splat: splat, + oldt: new Transform(origP, origR, origS), + newt: new Transform(splat.entity.getLocalPosition(), splat.entity.getLocalRotation(), splat.entity.getLocalScale()) + }); + + events.fire('edit.add', top); + updateVisuals(); + }; + + let dragging = false; + + // handle length input updates + lengthInput.on('slider:mousedown', () => { + startScale(); + dragging = true; + }); + lengthInput.on('change', (value) => { + if (dragging) { + applyLength(value); + } else if (!suppressUI) { + startScale(); + applyLength(value); + endScale(); + } + }); + lengthInput.on('slider:mouseup', () => { + endScale(); + dragging = false; + }); + + events.on('select.delete', () => { + if (active && splat && splat.measureSelection >= 0 && splat.measureSelection < splat.measurePoints.length) { + splat.measurePoints.splice(splat.measureSelection, 1); + splat.measureSelection--; + updateVisuals(); + } + }); + + const isPrimary = (e: PointerEvent) => { + return e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary; + }; + + let clicked = false; + + const pointerdown = (e: PointerEvent) => { + if (!clicked && isPrimary(e)) { + clicked = true; + } + }; + + const pointermove = (e: PointerEvent) => { + clicked = false; + }; + + const pointerup = async (e: PointerEvent) => { + if (splat && clicked && isPrimary(e)) { + clicked = false; + + let closestIdx = -1; + + // check for intersection with existing point + for (let i = 0; i < splat.measurePoints.length; i++) { + getPoint2d(i, p); + + if (Math.abs(p.x - e.offsetX) < 8 && Math.abs(p.y - e.offsetY) < 8) { + closestIdx = i; + break; + } + } + + if (closestIdx >= 0) { + splat.measureSelection = closestIdx; + updateVisuals(); + return; + } + + if (splat.measurePoints.length < 2) { + const result = await scene.camera.intersect(e.offsetX / canvasContainer.dom.clientWidth, e.offsetY / canvasContainer.dom.clientHeight); + if (result) { + mat.invert(splat.worldTransform); + mat.transformPoint(result.position, p); + splat.measureSelection = splat.measurePoints.length; + splat.measurePoints.push(p.clone()); + updateVisuals(); + } + } + + e.preventDefault(); + e.stopPropagation(); + } + }; + + events.on('postrender', () => { + if (active && splat) { + line.setAttribute('visibility', splat.measurePoints.length > 1 ? 'visible' : 'hidden'); + + for (let i = 0; i < 2; i++) { + if (i < splat.measurePoints.length) { + getPoint2d(i, p); + + const x = p.x.toString(); + const y = p.y.toString(); + + if (i === 0) { + line.setAttribute('x1', x); + line.setAttribute('y1', y); + lineStart.setAttribute('cx', x); + lineStart.setAttribute('cy', y); + + lineStart.setAttribute('visibility', 'visible'); + } else if (i === 1) { + line.setAttribute('x2', x); + line.setAttribute('y2', y); + lineEnd.setAttribute('cx', x); + lineEnd.setAttribute('cy', y); + lineEnd.setAttribute('visibility', 'visible'); + } + } else { + if (i === 0) { + lineStart.setAttribute('visibility', 'hidden'); + } else { + lineEnd.setAttribute('visibility', 'hidden'); + } + } + } + } else { + line.setAttribute('visibility', 'hidden'); + lineStart.setAttribute('visibility', 'hidden'); + lineEnd.setAttribute('visibility', 'hidden'); + } + }); + + const updateGizmoSize = () => { + const { camera, canvas } = scene; + if (camera.ortho) { + gizmo.size = 1125 / canvas.clientHeight; + } else { + gizmo.size = 1200 / Math.max(canvas.clientWidth, canvas.clientHeight); + } + }; + updateGizmoSize(); + events.on('camera.resize', updateGizmoSize); + events.on('camera.ortho', updateGizmoSize); + + this.activate = () => { + active = true; + updateVisuals(); + canvasContainer.dom.addEventListener('pointerdown', pointerdown); + canvasContainer.dom.addEventListener('pointermove', pointermove); + canvasContainer.dom.addEventListener('pointerup', pointerup, true); + selectToolbar.hidden = false; + parent.style.display = 'block'; + parent.classList.add('noevents'); + svg.classList.remove('hidden'); + + events.fire('transformHandler.push', transformHandler); + }; + + this.deactivate = () => { + active = false; + updateVisuals(); + canvasContainer.dom.removeEventListener('pointerdown', pointerdown); + canvasContainer.dom.removeEventListener('pointermove', pointermove); + canvasContainer.dom.removeEventListener('pointerup', pointerup); + selectToolbar.hidden = true; + parent.style.display = 'none'; + parent.classList.remove('noevents'); + svg.classList.add('hidden'); + + events.fire('transformHandler.pop'); + }; + } +} + +export { MeasureTool }; diff --git a/src/tools/move-tool.ts b/src/tools/move-tool.ts new file mode 100644 index 0000000..93539de --- /dev/null +++ b/src/tools/move-tool.ts @@ -0,0 +1,15 @@ +import { TranslateGizmo } from 'playcanvas'; + +import { TransformTool } from './transform-tool'; +import { Events } from '../events'; +import { Scene } from '../scene'; + +class MoveTool extends TransformTool { + constructor(events: Events, scene: Scene) { + const gizmo = new TranslateGizmo(scene.camera.camera, scene.gizmoLayer); + + super(gizmo, events, scene); + } +} + +export { MoveTool }; diff --git a/src/tools/polygon-selection.ts b/src/tools/polygon-selection.ts new file mode 100644 index 0000000..d892a3f --- /dev/null +++ b/src/tools/polygon-selection.ts @@ -0,0 +1,166 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; + +type Point = { x: number, y: number }; + +class PolygonSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, mask: { canvas: HTMLCanvasElement, context: CanvasRenderingContext2D }) { + // create svg + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('tool-svg', 'hidden'); + svg.id = 'polygon-select-svg'; + parent.appendChild(svg); + + // create polyline element + const polyline = document.createElementNS(svg.namespaceURI, 'polyline') as SVGPolylineElement; + svg.appendChild(polyline); + + // create canvas + const { canvas, context } = mask; + + let points: Point[] = []; + let currentPoint: Point = null; + let active = false; + + const dist = (a: Point, b: Point) => { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); + }; + + const isClosed = () => { + return points.length > 1 && dist(currentPoint, points[0]) < 8; + }; + + const paint = () => { + polyline.setAttribute('points', [...points, currentPoint].filter(v => v).reduce((prev, current) => `${prev}${current.x}, ${current.y} `, '')); + polyline.setAttribute('stroke', isClosed() ? '#fa6' : '#f60'); + }; + + const commitSelection = async (e: MouseEvent | KeyboardEvent) => { + // initialize canvas + if (canvas.width !== parent.clientWidth || canvas.height !== parent.clientHeight) { + canvas.width = parent.clientWidth; + canvas.height = parent.clientHeight; + } + + // clear canvas + context.clearRect(0, 0, canvas.width, canvas.height); + + context.beginPath(); + context.fillStyle = '#f60'; + context.beginPath(); + points.forEach((p, idx) => { + if (idx === 0) { + context.moveTo(p.x, p.y); + } else { + context.lineTo(p.x, p.y); + } + }); + context.closePath(); + context.fill(); + + // wait for selection to complete + await events.invoke( + 'select.byMask', + opFromModifiers(e), + canvas, + context + ); + + // clear polygon after selection completes + points = []; + paint(); + }; + + const pointermove = (e: PointerEvent) => { + currentPoint = { x: e.offsetX, y: e.offsetY }; + + if (points.length > 0) { + paint(); + } + }; + + const pointerdown = (e: PointerEvent) => { + if (points.length > 0 || (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + const pointerup = async (e: PointerEvent) => { + if (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary) { + e.preventDefault(); + e.stopPropagation(); + + if (isClosed()) { + await commitSelection(e); + } else if (points.length === 0 || dist(points[points.length - 1], currentPoint) > 0) { + points.push(currentPoint); + } + } + }; + + const dblclick = async (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (points.length > 2) { + await commitSelection(e); + } + }; + + const keydown = (e: KeyboardEvent) => { + // ignore when focus is elsewhere (input fields, modals, etc.) + if (e.target !== document.body) return; + + if (e.key === 'Enter' && points.length > 2) { + e.preventDefault(); + e.stopPropagation(); + // ignore held-key repeats so a single commit runs at a time + if (!e.repeat) { + commitSelection(e); + } + } + }; + + // remove the last placed point, returning whether a point was removed + events.function('polygonSelection.removeLastPoint', () => { + if (active && points.length > 0) { + points.pop(); + paint(); + return true; + } + return false; + }); + + this.activate = () => { + active = true; + svg.classList.remove('hidden'); + parent.style.display = 'block'; + parent.addEventListener('pointerdown', pointerdown); + parent.addEventListener('pointermove', pointermove); + parent.addEventListener('pointerup', pointerup); + parent.addEventListener('dblclick', dblclick); + // capture phase so enter commits the polygon before the shortcut handlers run + document.addEventListener('keydown', keydown, true); + }; + + this.deactivate = () => { + // cancel active operation + active = false; + svg.classList.add('hidden'); + parent.style.display = 'none'; + parent.removeEventListener('pointerdown', pointerdown); + parent.removeEventListener('pointermove', pointermove); + parent.removeEventListener('pointerup', pointerup); + parent.removeEventListener('dblclick', dblclick); + document.removeEventListener('keydown', keydown, true); + points = []; + paint(); + }; + } +} + +export { PolygonSelection }; diff --git a/src/tools/rect-selection.ts b/src/tools/rect-selection.ts new file mode 100644 index 0000000..99e0528 --- /dev/null +++ b/src/tools/rect-selection.ts @@ -0,0 +1,125 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; + +class RectSelection { + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement) { + // create svg + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('tool-svg', 'hidden'); + svg.id = 'rect-select-svg'; + parent.appendChild(svg); + + // create rect element + const rect = document.createElementNS(svg.namespaceURI, 'rect') as SVGRectElement; + svg.appendChild(rect); + + const start = { x: 0, y: 0 }; + const end = { x: 0, y: 0 }; + let dragId: number | undefined; + let dragMoved = false; + + const updateRect = () => { + const x = Math.min(start.x, end.x); + const y = Math.min(start.y, end.y); + const width = Math.abs(start.x - end.x); + const height = Math.abs(start.y - end.y); + + rect.setAttribute('x', x.toString()); + rect.setAttribute('y', y.toString()); + rect.setAttribute('width', width.toString()); + rect.setAttribute('height', height.toString()); + }; + + const pointerdown = (e: PointerEvent) => { + if (dragId === undefined && (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { + e.preventDefault(); + e.stopPropagation(); + + dragId = e.pointerId; + dragMoved = false; + parent.setPointerCapture(dragId); + + start.x = end.x = e.offsetX; + start.y = end.y = e.offsetY; + + updateRect(); + + svg.classList.remove('hidden'); + } + }; + + const pointermove = (e: PointerEvent) => { + if (e.pointerId === dragId) { + e.preventDefault(); + e.stopPropagation(); + + dragMoved = true; + end.x = e.offsetX; + end.y = e.offsetY; + + updateRect(); + } + }; + + const dragEnd = () => { + parent.releasePointerCapture(dragId); + dragId = undefined; + svg.classList.add('hidden'); + }; + + const pointerup = async (e: PointerEvent) => { + if (e.pointerId === dragId) { + e.preventDefault(); + e.stopPropagation(); + + const w = parent.clientWidth; + const h = parent.clientHeight; + + if (dragMoved) { + // rect select - wait for selection to complete before hiding rect + await events.invoke( + 'select.rect', + opFromModifiers(e), { + start: { x: Math.min(start.x, end.x) / w, y: Math.min(start.y, end.y) / h }, + end: { x: Math.max(start.x, end.x) / w, y: Math.max(start.y, end.y) / h } + }); + } else { + // pick - wait for selection to complete before hiding rect + await events.invoke( + 'select.point', + opFromModifiers(e), + { x: e.offsetX / parent.clientWidth, y: e.offsetY / parent.clientHeight } + ); + } + + dragEnd(); + } + }; + + this.activate = () => { + parent.style.display = 'block'; + parent.addEventListener('pointerdown', pointerdown); + parent.addEventListener('pointermove', pointermove); + parent.addEventListener('pointerup', pointerup); + }; + + this.deactivate = () => { + if (dragId !== undefined) { + dragEnd(); + } + parent.style.display = 'none'; + parent.removeEventListener('pointerdown', pointerdown); + parent.removeEventListener('pointermove', pointermove); + parent.removeEventListener('pointerup', pointerup); + }; + } + + destroy() { + + } +} + +export { RectSelection }; diff --git a/src/tools/rotate-tool.ts b/src/tools/rotate-tool.ts new file mode 100644 index 0000000..ad11050 --- /dev/null +++ b/src/tools/rotate-tool.ts @@ -0,0 +1,16 @@ +import { RotateGizmo } from 'playcanvas'; + +import { TransformTool } from './transform-tool'; +import { Events } from '../events'; +import { Scene } from '../scene'; + +class RotateTool extends TransformTool { + constructor(events: Events, scene: Scene) { + const gizmo = new RotateGizmo(scene.camera.camera, scene.gizmoLayer); + gizmo.rotationMode = 'orbit'; + + super(gizmo, events, scene); + } +} + +export { RotateTool }; diff --git a/src/tools/scale-tool.ts b/src/tools/scale-tool.ts new file mode 100644 index 0000000..1e05946 --- /dev/null +++ b/src/tools/scale-tool.ts @@ -0,0 +1,23 @@ +import { ScaleGizmo } from 'playcanvas'; + +import { TransformTool } from './transform-tool'; +import { Events } from '../events'; +import { Scene } from '../scene'; + +class ScaleTool extends TransformTool { + constructor(events: Events, scene: Scene) { + const gizmo = new ScaleGizmo(scene.camera.camera, scene.gizmoLayer); + + // disable everything except uniform scale + ['x', 'y', 'z', 'yz', 'xz', 'xy'].forEach((axis) => { + gizmo.enableShape(axis as 'x' | 'y' | 'z' | 'yz' | 'xz' | 'xy', false); + }); + + // set lower bound on scale + gizmo.lowerBoundScale.set(1e-6, 1e-6, 1e-6); + + super(gizmo, events, scene); + } +} + +export { ScaleTool }; diff --git a/src/tools/sphere-selection.ts b/src/tools/sphere-selection.ts new file mode 100644 index 0000000..0fa2434 --- /dev/null +++ b/src/tools/sphere-selection.ts @@ -0,0 +1,160 @@ +import { Button, Container, Label, NumericInput, VectorInput } from '@playcanvas/pcui'; +import { TranslateGizmo, Vec3 } from 'playcanvas'; + +import { Events } from '../events'; +import { Scene } from '../scene'; +import { SphereShape } from '../sphere-shape'; +import { Splat } from '../splat'; +import { i18n } from '../ui/localization'; + +class SphereSelection { + activate: () => void; + deactivate: () => void; + + active = false; + + constructor(events: Events, scene: Scene, canvasContainer: Container) { + const sphere = new SphereShape(); + + const gizmo = new TranslateGizmo(scene.camera.camera, scene.gizmoLayer); + + gizmo.on('render:update', () => { + scene.forceRender = true; + }); + + gizmo.on('transform:move', () => { + sphere.moved(); + }); + + // ui + const selectToolbar = new Container({ + class: 'select-toolbar', + hidden: true + }); + + selectToolbar.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + }); + + const setButton = new Button({ class: 'select-toolbar-button' }); + const addButton = new Button({ class: 'select-toolbar-button' }); + const removeButton = new Button({ class: 'select-toolbar-button' }); + const intersectButton = new Button({ class: 'select-toolbar-button' }); + + i18n.bindText(setButton, 'select-toolbar.set'); + i18n.bindText(addButton, 'select-toolbar.add'); + i18n.bindText(removeButton, 'select-toolbar.remove'); + i18n.bindText(intersectButton, 'select-toolbar.intersect'); + + const positionLabel = new Label({ class: 'select-toolbar-label' }); + i18n.bindText(positionLabel, 'select-toolbar.position'); + + const position = new VectorInput({ + class: 'select-toolbar-vector', + precision: 2, + dimensions: 3, + placeholder: ['X', 'Y', 'Z'], + value: [0, 0, 0] + }); + + const radiusLabel = new Label({ class: 'select-toolbar-label' }); + i18n.bindText(radiusLabel, 'select-toolbar.radius'); + + const radius = new NumericInput({ + precision: 2, + value: sphere.radius, + min: 0.01 + }); + + selectToolbar.append(setButton); + selectToolbar.append(addButton); + selectToolbar.append(removeButton); + selectToolbar.append(intersectButton); + selectToolbar.append(positionLabel); + selectToolbar.append(position); + selectToolbar.append(radiusLabel); + selectToolbar.append(radius); + + canvasContainer.append(selectToolbar); + + // write the volume's world position into the ui without retriggering + // the position input's change handler + let uiUpdating = false; + const updateUI = () => { + uiUpdating = true; + const p = sphere.pivot.getPosition(); + position.value = [p.x, p.y, p.z]; + uiUpdating = false; + }; + + gizmo.on('transform:move', () => { + updateUI(); + }); + + const apply = (op: 'set' | 'add' | 'remove' | 'intersect') => { + const p = sphere.pivot.getPosition(); + events.fire('select.bySphere', op, [p.x, p.y, p.z, sphere.radius]); + }; + + setButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); apply('set'); + }); + addButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); apply('add'); + }); + removeButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); apply('remove'); + }); + intersectButton.dom.addEventListener('pointerdown', (e) => { + e.stopPropagation(); apply('intersect'); + }); + position.on('change', (v: number[]) => { + if (!uiUpdating) { + sphere.pivot.setPosition(v[0], v[1], v[2]); + sphere.moved(); + gizmo.attach([sphere.pivot]); + } + }); + radius.on('change', () => { + sphere.radius = radius.value; + }); + + events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => { + if (this.active) { + sphere.pivot.setPosition(details.position); + sphere.moved(); + gizmo.attach([sphere.pivot]); + updateUI(); + } + }); + + const updateGizmoSize = () => { + const { camera, canvas } = scene; + if (camera.ortho) { + gizmo.size = 1125 / canvas.clientHeight; + } else { + gizmo.size = 1200 / Math.max(canvas.clientWidth, canvas.clientHeight); + } + }; + updateGizmoSize(); + events.on('camera.resize', updateGizmoSize); + events.on('camera.ortho', updateGizmoSize); + + this.activate = () => { + this.active = true; + scene.add(sphere); + gizmo.attach([sphere.pivot]); + updateUI(); + selectToolbar.hidden = false; + }; + + this.deactivate = () => { + selectToolbar.hidden = true; + gizmo.detach(); + scene.remove(sphere); + this.active = false; + }; + } +} + +export { SphereSelection }; diff --git a/src/tools/tool-manager.ts b/src/tools/tool-manager.ts new file mode 100644 index 0000000..c9ba00d --- /dev/null +++ b/src/tools/tool-manager.ts @@ -0,0 +1,87 @@ +import { Events } from '../events'; + +interface Tool { + activate: () => void; + deactivate: () => void; +} + +class ToolManager { + tools = new Map(); + events: Events; + active: string | null = null; + + constructor(events: Events) { + this.events = events; + + this.events.on('tool.deactivate', () => { + this.activate(null); + }); + + this.events.function('tool.active', () => { + return this.active; + }); + + let coordSpace: 'local' | 'world' = 'world'; + + const setCoordSpace = (space: 'local' | 'world') => { + if (space !== coordSpace) { + coordSpace = space; + events.fire('tool.coordSpace', coordSpace); + } + }; + + events.function('tool.coordSpace', () => { + return coordSpace; + }); + + events.on('tool.setCoordSpace', (value: 'local' | 'world') => { + setCoordSpace(value); + }); + + events.on('tool.toggleCoordSpace', () => { + setCoordSpace(coordSpace === 'local' ? 'world' : 'local'); + }); + } + + register(name: string, tool: Tool) { + this.tools.set(name, tool); + + this.events.on(`tool.${name}`, () => { + this.activate(name); + }); + } + + get(toolName: string) { + return (toolName && this.tools.get(toolName)) ?? null; + } + + activate(toolName: string | null) { + if (toolName === this.active) { + // re-activating the currently active tool deactivates it + if (toolName) { + this.activate(null); + } + } else { + // deactive old tool + if (this.active) { + const tool = this.tools.get(this.active); + tool.deactivate(); + this.events.fire(`tool.${this.active}.deactivated`); + this.events.fire('tool.deactivated', this.active); + } + + this.active = toolName; + + // activate the new + if (this.active) { + const tool = this.tools.get(this.active); + tool.activate(); + } + + this.events.fire(`tool.${toolName}.activated`); + this.events.fire('tool.activated', toolName); + } + } +} + +export { ToolManager }; diff --git a/src/tools/transform-tool.ts b/src/tools/transform-tool.ts new file mode 100644 index 0000000..6f47441 --- /dev/null +++ b/src/tools/transform-tool.ts @@ -0,0 +1,96 @@ +import { Entity, GraphicsDevice, TransformGizmo } from 'playcanvas'; + +import { Events } from '../events'; +import { Pivot } from '../pivot'; +import { Scene } from '../scene'; + +class TransformTool { + activate: () => void; + deactivate: () => void; + + constructor(gizmo: TransformGizmo, events: Events, scene: Scene) { + let pivot: Pivot; + let active = false; + let dragging = false; + + // create the transform pivot + const pivotEntity = new Entity('gizmoPivot'); + scene.app.root.addChild(pivotEntity); + + gizmo.on('render:update', () => { + scene.forceRender = true; + }); + + gizmo.on('transform:start', () => { + dragging = true; + pivot.start(); + }); + + gizmo.on('transform:move', () => { + pivot.moveTRS(pivotEntity.getLocalPosition(), pivotEntity.getLocalRotation(), pivotEntity.getLocalScale()); + scene.forceRender = true; + }); + + gizmo.on('transform:end', () => { + pivot.end(); + dragging = false; + }); + + // reattach the gizmo to the pivot + const reattach = () => { + if (!active || !events.invoke('selection')) { + if (gizmo.enabled) { + gizmo.detach(); + } + } else if (!dragging) { + pivot = events.invoke('pivot') as Pivot; + pivotEntity.setLocalPosition(pivot.transform.position); + pivotEntity.setLocalRotation(pivot.transform.rotation); + pivotEntity.setLocalScale(pivot.transform.scale); + gizmo.attach([pivotEntity]); + } + }; + + events.on('tool.coordSpace', (coordSpace: string) => { + gizmo.coordSpace = coordSpace as 'local' | 'world'; + }); + + // set the gizmo size to remain a constant size in screen space. + // called in response to changes in canvas size + const updateGizmoSize = () => { + const { camera, canvas } = scene; + if (camera.ortho) { + gizmo.size = 1125 / canvas.clientHeight; + } else { + gizmo.size = 1200 / Math.max(canvas.clientWidth, canvas.clientHeight); + } + }; + updateGizmoSize(); + events.on('camera.resize', updateGizmoSize); + events.on('camera.ortho', updateGizmoSize); + + this.activate = () => { + active = true; + + reattach(); + + events.on('pivot.placed', reattach); + events.on('pivot.moved', reattach); + events.on('selection.changed', reattach); + }; + + this.deactivate = () => { + active = false; + reattach(); + + events.off('pivot.placed', reattach); + events.off('pivot.moved', reattach); + events.off('selection.changed', reattach); + }; + + // initialize coodinate space + gizmo.coordSpace = events.invoke('tool.coordSpace'); + } +} + +export { TransformTool }; diff --git a/src/track-manager.ts b/src/track-manager.ts new file mode 100644 index 0000000..f106677 --- /dev/null +++ b/src/track-manager.ts @@ -0,0 +1,61 @@ +import { AnimTrack } from './anim-track'; +import { AnimTrackEditOp } from './edit-ops'; +import { Events } from './events'; + +/** + * Manages the active animation track and provides undo-wrapped + * key operations. Resolves which track the user is interacting + * with and ensures all mutations are undoable. + * + * For now, the active track is always the camera track. + * When selection-based switching is added, getActiveTrack() + * will inspect the current selection. + */ +const registerTrackManagerEvents = (events: Events) => { + // Get the animation track of the currently active element. + // For now, always returns the camera animation track. + const getActiveTrack = (): AnimTrack | null => { + return events.invoke('camera.animTrack') ?? null; + }; + + // Helper: execute an edit on the active track wrapped in undo. + // The editFn must return true if it modified the track, false if it was a no-op. + const trackEdit = (name: string, editFn: (track: AnimTrack) => boolean) => { + const track = getActiveTrack(); + if (!track) return; + const before = track.snapshot(); + if (!editFn(track)) return; + const after = track.snapshot(); + events.fire('edit.add', new AnimTrackEditOp(name, track, before, after), true); + }; + + // Get keys from active track + events.function('track.keys', () => { + const track = getActiveTrack(); + return track ? track.keys : []; + }); + + // Add key to active track + events.on('track.addKey', (frame?: number) => { + const keyFrame = frame ?? events.invoke('timeline.frame'); + trackEdit('addKey', track => track.addKey(keyFrame)); + }); + + // Remove key from active track + events.on('track.removeKey', (frame?: number) => { + const keyFrame = frame ?? events.invoke('timeline.frame'); + trackEdit('removeKey', track => track.removeKey(keyFrame)); + }); + + // Move key in active track + events.on('track.moveKey', (fromFrame: number, toFrame: number) => { + trackEdit('moveKey', track => track.moveKey(fromFrame, toFrame)); + }); + + // Copy key in active track + events.on('track.copyKey', (fromFrame: number, toFrame: number) => { + trackEdit('copyKey', track => track.copyKey(fromFrame, toFrame)); + }); +}; + +export { registerTrackManagerEvents }; diff --git a/src/transform-handler.ts b/src/transform-handler.ts new file mode 100644 index 0000000..613da7d --- /dev/null +++ b/src/transform-handler.ts @@ -0,0 +1,64 @@ +import { EntityTransformHandler } from './entity-transform-handler'; +import { Events } from './events'; +import { registerPivotEvents } from './pivot'; +import { Splat } from './splat'; +import { SplatsTransformHandler } from './splats-transform-handler'; + +interface TransformHandler { + activate: () => void; + deactivate: () => void; +} + +const registerTransformHandlerEvents = (events: Events) => { + const transformHandlers: TransformHandler[] = []; + + const push = (handler: TransformHandler) => { + if (transformHandlers.length > 0) { + const transformHandler = transformHandlers[transformHandlers.length - 1]; + transformHandler.deactivate(); + } + transformHandlers.push(handler); + handler.activate(); + }; + + const pop = () => { + if (transformHandlers.length > 0) { + const transformHandler = transformHandlers.pop(); + transformHandler.deactivate(); + } + if (transformHandlers.length > 0) { + const transformHandler = transformHandlers[transformHandlers.length - 1]; + transformHandler.activate(); + } + }; + + // bind transform target when selection changes + const entityTransformHandler = new EntityTransformHandler(events); + const splatsTransformHandler = new SplatsTransformHandler(events); + + const update = (splat: Splat) => { + pop(); + if (splat) { + if (splat.numSelected > 0) { + push(splatsTransformHandler); + } else { + push(entityTransformHandler); + } + } + }; + + events.on('selection.changed', update); + events.on('splat.stateChanged', update); + + events.on('transformHandler.push', (handler: TransformHandler) => { + push(handler); + }); + + events.on('transformHandler.pop', () => { + pop(); + }); + + registerPivotEvents(events); +}; + +export { registerTransformHandlerEvents, TransformHandler }; diff --git a/src/transform-palette.ts b/src/transform-palette.ts new file mode 100644 index 0000000..75e634a --- /dev/null +++ b/src/transform-palette.ts @@ -0,0 +1,106 @@ +import { + ADDRESS_CLAMP_TO_EDGE, + PIXELFORMAT_RGBA32F, + GraphicsDevice, + Mat4, + Texture +} from 'playcanvas'; + +// mapping from Mat4 to transposed 3x4 matrix +const idx = [ + 0, 4, 8, 12, + 1, 5, 9, 13, + 2, 6, 10, 14 +]; + +// texture data stores 512 matrices per row: 512 * 3 * 4 (rgba) floats +const width = 512 * 3; + +// wraps a palette of transform data. transforms are stored as 3x4 (non-perspective) +// matrices +class TransformPalette { + getTransform: (index: number, transform: Mat4) => void; + setTransform: (index: number, transform: Mat4) => void; + alloc: (num?: number) => number; + free: (num?: number) => void; + texture: Texture; + + constructor(device: GraphicsDevice, initialSize = 4096) { + let texture: Texture; + let data: Float32Array; + + // reallocate the storage texture and copy over old data + const realloc = (width: number, height: number) => { + const newTexture = new Texture(device, { + name: 'transformPalette', + width, + height, + format: PIXELFORMAT_RGBA32F, + mipmaps: false, + addressU: ADDRESS_CLAMP_TO_EDGE, + addressV: ADDRESS_CLAMP_TO_EDGE + }); + + const newData = newTexture.lock() as Float32Array; + newTexture.unlock(); + + // copy over data if this is a realloc + if (texture) { + newData.set(data); + + texture.destroy(); + } + + texture = newTexture; + data = newData; + }; + + this.getTransform = (index: number, transform: Mat4) => { + const dst = transform.data; + for (let i = 0; i < 12; ++i) { + dst[idx[i]] = data[index * 12 + i]; + } + }; + + this.setTransform = (index: number, transform: Mat4) => { + const src = transform.data; + for (let i = 0; i < 12; ++i) { + data[index * 12 + i] = src[idx[i]]; + } + + texture.upload(); + }; + + // index of the next available matrix. index 0 is identity. + let nextIdx = 1; + + // allocate one or more matrices from the palette, returns the index of the first matrix + this.alloc = (num = 1) => { + const result = nextIdx; + + while (nextIdx + num > data.length / 12) { + realloc(width, texture.height * 2); + } + + nextIdx += num; + + return result; + }; + + this.free = (num = 1) => { + nextIdx -= num; + }; + + Object.defineProperty(this, 'texture', { get() { + return texture; + } }); + + // allocate initial storage + realloc(width, Math.ceil(initialSize / (width / 3))); + + // initialize first matrix to identity + this.setTransform(0, Mat4.IDENTITY); + } +} + +export { TransformPalette }; diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 0000000..0bf7e5f --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,59 @@ +import { Quat, Vec3 } from 'playcanvas'; + +class Transform { + position = new Vec3(); + rotation = new Quat(); + scale = new Vec3(1, 1, 1); + + constructor(position?: Vec3, rotation?: Quat, scale?: Vec3) { + this.set(position, rotation, scale); + } + + set(position?: Vec3, rotation?: Quat, scale?: Vec3) { + if (position) { + this.position.copy(position); + } + if (rotation) { + this.rotation.copy(rotation); + } + if (scale) { + this.scale.copy(scale); + } + } + + copy(transform: Transform) { + this.position.copy(transform.position); + this.rotation.copy(transform.rotation); + this.scale.copy(transform.scale); + } + + clone() { + return new Transform(this.position.clone(), this.rotation.clone(), this.scale.clone()); + } + + equals(transform: Transform) { + return this.position.equals(transform.position) && + this.rotation.equals(transform.rotation) && + this.scale.equals(transform.scale); + } + + equalsApprox(transform: Transform, epsilon = 1e-6) { + return this.position.equalsApprox(transform.position, epsilon) && + this.rotation.equalsApprox(transform.rotation, epsilon) && + this.scale.equalsApprox(transform.scale, epsilon); + } + + equalsTRS(position: Vec3, rotation: Quat, scale: Vec3) { + return this.position.equals(position) && + this.rotation.equals(rotation) && + this.scale.equals(scale); + } + + equalsApproxTRS(position: Vec3, rotation: Quat, scale: Vec3, epsilon = 1e-6) { + return this.position.equalsApprox(position, epsilon) && + this.rotation.equalsApprox(rotation, epsilon) && + this.scale.equalsApprox(scale, epsilon); + } +} + +export { Transform }; diff --git a/src/tween-value.ts b/src/tween-value.ts new file mode 100644 index 0000000..05d22c4 --- /dev/null +++ b/src/tween-value.ts @@ -0,0 +1,75 @@ +// possible interpolation functions +const Interp = { + sinosidal: (n: number) => Math.sin((n * Math.PI) / 2.0), + quadratic: (n: number) => n * (2 - n), + quartic: (n: number) => 1 - --n * n * n * n, + quintic: (n: number) => Math.pow(n - 1, 5) + 1, + vertebrae: (n: number) => -Math.pow((Math.cos(n * Math.PI) + 1) / 2, 2) + 1 +}; + +class Ops { + keys: string[]; + + constructor(value: any) { + this.keys = Object.keys(value); + } + + clone(obj: any) { + const result: any = {}; + this.keys.forEach((key: string) => { + result[key] = obj[key]; + }); + return result; + } + + copy(target: any, source: any) { + this.keys.forEach((key: string) => { + target[key] = source[key]; + }); + } + + lerp(target: any, a: any, b: any, t: number) { + this.keys.forEach((key: string) => { + target[key] = a[key] + t * (b[key] - a[key]); + }); + } +} + +class TweenValue { + ops: Ops; + value: any; + source: any; + target: any; + timer: number; + transitionTime: number; + + constructor(value: any) { + this.ops = new Ops(value); + this.value = value; + this.source = this.ops.clone(value); + this.target = this.ops.clone(value); + this.timer = 0; + this.transitionTime = 0; + } + + goto(target: any, transitionTime = 0.25) { + if (transitionTime === 0) { + this.ops.copy(this.value, target); + } + this.ops.copy(this.source, this.value); + this.ops.copy(this.target, target); + this.timer = 0; + this.transitionTime = transitionTime; + } + + update(deltaTime: number) { + if (this.timer < this.transitionTime) { + this.timer = Math.min(this.timer + deltaTime, this.transitionTime); + this.ops.lerp(this.value, this.source, this.target, Interp.quintic(this.timer / this.transitionTime)); + } else { + this.ops.copy(this.value, this.target); + } + } +} + +export { TweenValue }; diff --git a/src/ui/about-popup.ts b/src/ui/about-popup.ts new file mode 100644 index 0000000..ea1b4e3 --- /dev/null +++ b/src/ui/about-popup.ts @@ -0,0 +1,145 @@ +import { Container, Label, version as pcuiVersion, revision as pcuiRevision } from '@playcanvas/pcui'; +import { version as engineVersion, revision as engineRevision } from 'playcanvas'; + +import { version as appVersion } from '../../package.json'; + +// Inline SVG for the SuperSplat logo +const logoSvg = ` + + + + + + + +`; + +class AboutPopup extends Container { + constructor(args = {}) { + args = { + ...args, + id: 'about-popup', + hidden: true, + tabIndex: -1 + }; + + super(args); + + // Handle keyboard events + this.dom.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.hidden = true; + } + e.stopPropagation(); + }); + + // Close when clicking outside dialog + this.on('click', () => { + this.hidden = true; + }); + + const dialog = new Container({ + id: 'about-dialog' + }); + + // Prevent clicks inside dialog from closing + dialog.on('click', (event: MouseEvent) => { + event.stopPropagation(); + }); + + // Header bar + const header = new Label({ + id: 'about-header', + text: 'About' + }); + + // Content area + const content = new Container({ + id: 'about-content' + }); + + // Logo + const logoContainer = new Container({ + id: 'about-logo' + }); + logoContainer.dom.innerHTML = logoSvg; + logoContainer.dom.addEventListener('click', () => { + window.open('https://github.com/playcanvas/supersplat', '_blank')?.focus(); + }); + + // App name and version + const appInfo = new Container({ + id: 'about-app-info' + }); + appInfo.dom.addEventListener('click', () => { + window.open('https://github.com/playcanvas/supersplat', '_blank')?.focus(); + }); + + const appName = new Label({ + id: 'about-app-name', + text: 'SuperSplat' + }); + + const appVersionLabel = new Label({ + id: 'about-app-version', + text: `v${appVersion}` + }); + + appInfo.append(appName); + appInfo.append(appVersionLabel); + + // Dependencies + const depsContainer = new Container({ + id: 'about-deps' + }); + + // PCUI + const pcuiRow = new Container({ + class: 'about-dep-row' + }); + pcuiRow.dom.addEventListener('click', () => { + window.open('https://github.com/playcanvas/pcui', '_blank')?.focus(); + }); + const pcuiName = new Label({ class: 'about-dep-name', text: 'PCUI' }); + const pcuiVersionL = new Label({ class: 'about-dep-version', text: `v${pcuiVersion}` }); + const pcuiRev = new Label({ class: 'about-dep-revision', text: `(${pcuiRevision.substring(0, 7)})` }); + pcuiRow.append(pcuiName); + pcuiRow.append(pcuiVersionL); + pcuiRow.append(pcuiRev); + + // Engine + const engineRow = new Container({ + class: 'about-dep-row' + }); + engineRow.dom.addEventListener('click', () => { + window.open('https://github.com/playcanvas/engine', '_blank')?.focus(); + }); + const engineName = new Label({ class: 'about-dep-name', text: 'PlayCanvas' }); + const engineVer = new Label({ class: 'about-dep-version', text: `v${engineVersion}` }); + const engineRev = new Label({ class: 'about-dep-revision', text: `(${engineRevision.substring(0, 7)})` }); + engineRow.append(engineName); + engineRow.append(engineVer); + engineRow.append(engineRev); + + depsContainer.append(pcuiRow); + depsContainer.append(engineRow); + + // Assemble content + content.append(logoContainer); + content.append(appInfo); + content.append(depsContainer); + + // Assemble dialog + dialog.append(header); + dialog.append(content); + + this.append(dialog); + + // Focus when shown so keyboard events work + this.on('show', () => { + this.dom.focus(); + }); + } +} + +export { AboutPopup }; diff --git a/src/ui/bottom-toolbar.ts b/src/ui/bottom-toolbar.ts new file mode 100644 index 0000000..948edf0 --- /dev/null +++ b/src/ui/bottom-toolbar.ts @@ -0,0 +1,244 @@ +import { Button, Element, Container } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; +import redoSvg from './svg/redo.svg'; +import brushSvg from './svg/select-brush.svg'; +import eyedropperSvg from './svg/select-eyedropper.svg'; +import floodSvg from './svg/select-flood.svg'; +import lassoSvg from './svg/select-lasso.svg'; +import pickerSvg from './svg/select-picker.svg'; +import polygonSvg from './svg/select-poly.svg'; +import sphereSvg from './svg/select-sphere.svg'; +import boxSvg from './svg/show-hide-splats.svg'; +import undoSvg from './svg/undo.svg'; +import { Tooltips } from './tooltips'; +// import cropSvg from './svg/crop.svg'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement; +}; + +class BottomToolbar extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'bottom-toolbar' + }; + + super(args); + + this.dom.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + }); + + const undo = new Button({ + id: 'bottom-toolbar-undo', + class: 'bottom-toolbar-button', + enabled: false + }); + + const redo = new Button({ + id: 'bottom-toolbar-redo', + class: 'bottom-toolbar-button', + enabled: false + }); + + const picker = new Button({ + id: 'bottom-toolbar-picker', + class: 'bottom-toolbar-tool' + }); + + const polygon = new Button({ + id: 'bottom-toolbar-polygon', + class: 'bottom-toolbar-tool' + }); + + const brush = new Button({ + id: 'bottom-toolbar-brush', + class: 'bottom-toolbar-tool' + }); + + const flood = new Button({ + id: 'bottom-toolbar-flood', + class: 'bottom-toolbar-tool' + }); + + const lasso = new Button({ + id: 'bottom-toolbar-lasso', + class: 'bottom-toolbar-tool' + }); + + const sphere = new Button({ + id: 'bottom-toolbar-sphere', + class: 'bottom-toolbar-tool' + }); + + const box = new Button({ + id: 'bottom-toolbar-box', + class: 'bottom-toolbar-tool' + }); + + const eyedropper = new Button({ + id: 'bottom-toolbar-eyedropper', + class: 'bottom-toolbar-tool' + }); + + // const crop = new Button({ + // id: 'bottom-toolbar-crop', + // class: ['bottom-toolbar-tool', 'disabled'] + // }); + + const translate = new Button({ + id: 'bottom-toolbar-translate', + class: 'bottom-toolbar-tool', + icon: 'E111' + }); + + const rotate = new Button({ + id: 'bottom-toolbar-rotate', + class: 'bottom-toolbar-tool', + icon: 'E113' + }); + + const scale = new Button({ + id: 'bottom-toolbar-scale', + class: 'bottom-toolbar-tool', + icon: 'E112' + }); + + const measure = new Button({ + id: 'bottom-toolbar-measure', + class: 'bottom-toolbar-tool', + icon: 'E358' + }); + + const coordSpace = new Button({ + id: 'bottom-toolbar-coord-space', + class: 'bottom-toolbar-toggle', + icon: 'E118' + }); + + const origin = new Button({ + id: 'bottom-toolbar-origin', + class: ['bottom-toolbar-toggle'], + icon: 'E189' + }); + + undo.dom.appendChild(createSvg(undoSvg)); + redo.dom.appendChild(createSvg(redoSvg)); + picker.dom.appendChild(createSvg(pickerSvg)); + polygon.dom.appendChild(createSvg(polygonSvg)); + brush.dom.appendChild(createSvg(brushSvg)); + flood.dom.appendChild(createSvg(floodSvg)); + sphere.dom.appendChild(createSvg(sphereSvg)); + box.dom.appendChild(createSvg(boxSvg)); + lasso.dom.appendChild(createSvg(lassoSvg)); + eyedropper.dom.appendChild(createSvg(eyedropperSvg)); + // crop.dom.appendChild(createSvg(cropSvg)); + + this.append(undo); + this.append(redo); + this.append(new Element({ class: 'bottom-toolbar-separator' })); + this.append(picker); + this.append(lasso); + this.append(polygon); + this.append(brush); + this.append(flood); + this.append(eyedropper); + this.append(new Element({ class: 'bottom-toolbar-separator' })); + this.append(sphere); + this.append(box); + // this.append(crop); + this.append(new Element({ class: 'bottom-toolbar-separator' })); + this.append(translate); + this.append(rotate); + this.append(scale); + this.append(new Element({ class: 'bottom-toolbar-separator' })); + this.append(measure); + this.append(coordSpace); + this.append(origin); + + undo.dom.addEventListener('click', () => events.fire('edit.undo')); + redo.dom.addEventListener('click', () => events.fire('edit.redo')); + polygon.dom.addEventListener('click', () => events.fire('tool.polygonSelection')); + lasso.dom.addEventListener('click', () => events.fire('tool.lassoSelection')); + brush.dom.addEventListener('click', () => events.fire('tool.brushSelection')); + flood.dom.addEventListener('click', () => events.fire('tool.floodSelection')); + picker.dom.addEventListener('click', () => events.fire('tool.rectSelection')); + eyedropper.dom.addEventListener('click', () => events.fire('tool.eyedropperSelection')); + sphere.dom.addEventListener('click', () => events.fire('tool.sphereSelection')); + box.dom.addEventListener('click', () => events.fire('tool.boxSelection')); + translate.dom.addEventListener('click', () => events.fire('tool.move')); + rotate.dom.addEventListener('click', () => events.fire('tool.rotate')); + scale.dom.addEventListener('click', () => events.fire('tool.scale')); + measure.dom.addEventListener('click', () => events.fire('tool.measure')); + coordSpace.dom.addEventListener('click', () => events.fire('tool.toggleCoordSpace')); + origin.dom.addEventListener('click', () => events.fire('pivot.toggleOrigin')); + + events.on('edit.canUndo', (value: boolean) => { + undo.enabled = value; + }); + events.on('edit.canRedo', (value: boolean) => { + redo.enabled = value; + }); + + events.on('tool.activated', (toolName: string) => { + picker.class[toolName === 'rectSelection' ? 'add' : 'remove']('active'); + brush.class[toolName === 'brushSelection' ? 'add' : 'remove']('active'); + flood.class[toolName === 'floodSelection' ? 'add' : 'remove']('active'); + polygon.class[toolName === 'polygonSelection' ? 'add' : 'remove']('active'); + lasso.class[toolName === 'lassoSelection' ? 'add' : 'remove']('active'); + sphere.class[toolName === 'sphereSelection' ? 'add' : 'remove']('active'); + box.class[toolName === 'boxSelection' ? 'add' : 'remove']('active'); + translate.class[toolName === 'move' ? 'add' : 'remove']('active'); + rotate.class[toolName === 'rotate' ? 'add' : 'remove']('active'); + scale.class[toolName === 'scale' ? 'add' : 'remove']('active'); + measure.class[toolName === 'measure' ? 'add' : 'remove']('active'); + eyedropper.class[toolName === 'eyedropperSelection' ? 'add' : 'remove']('active'); + }); + + events.on('tool.coordSpace', (space: 'local' | 'world') => { + coordSpace.dom.classList[space === 'local' ? 'add' : 'remove']('active'); + }); + + events.on('pivot.origin', (o: 'center' | 'boundCenter') => { + origin.dom.classList[o === 'boundCenter' ? 'add' : 'remove']('active'); + }); + + // Helper to compose localized tooltip text with shortcut + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + const tooltip = (localeKey: string, shortcutId?: string) => () => { + const text = i18n.t(localeKey); + if (shortcutId) { + const shortcut = shortcutManager.formatShortcut(shortcutId); + if (shortcut) { + return i18n.formatTooltipWithShortcut(text, shortcut); + } + } + return text; + }; + + // register tooltips + tooltips.register(undo, tooltip('tooltip.bottom-toolbar.undo', 'edit.undo')); + tooltips.register(redo, tooltip('tooltip.bottom-toolbar.redo', 'edit.redo')); + tooltips.register(picker, tooltip('tooltip.bottom-toolbar.rect', 'tool.rectSelection')); + tooltips.register(lasso, tooltip('tooltip.bottom-toolbar.lasso', 'tool.lassoSelection')); + tooltips.register(polygon, tooltip('tooltip.bottom-toolbar.polygon', 'tool.polygonSelection')); + tooltips.register(brush, tooltip('tooltip.bottom-toolbar.brush', 'tool.brushSelection')); + tooltips.register(flood, tooltip('tooltip.bottom-toolbar.flood', 'tool.floodSelection')); + tooltips.register(sphere, tooltip('tooltip.bottom-toolbar.sphere')); + tooltips.register(box, tooltip('tooltip.bottom-toolbar.box')); + tooltips.register(translate, tooltip('tooltip.bottom-toolbar.translate', 'tool.move')); + tooltips.register(rotate, tooltip('tooltip.bottom-toolbar.rotate', 'tool.rotate')); + tooltips.register(scale, tooltip('tooltip.bottom-toolbar.scale', 'tool.scale')); + tooltips.register(measure, tooltip('tooltip.bottom-toolbar.measure')); + tooltips.register(coordSpace, tooltip('tooltip.bottom-toolbar.local-space', 'tool.toggleCoordSpace')); + tooltips.register(origin, tooltip('tooltip.bottom-toolbar.bound-center')); + tooltips.register(eyedropper, tooltip('tooltip.bottom-toolbar.eyedropper', 'tool.eyedropperSelection')); + } +} + +export { BottomToolbar }; diff --git a/src/ui/bound-dimensions-overlay.ts b/src/ui/bound-dimensions-overlay.ts new file mode 100644 index 0000000..468c7e3 --- /dev/null +++ b/src/ui/bound-dimensions-overlay.ts @@ -0,0 +1,187 @@ +import { Container } from '@playcanvas/pcui'; +import { Vec3 } from 'playcanvas'; + +import { Events } from '../events'; +import { Scene } from '../scene'; +import { Splat } from '../splat'; + +const corners = Array.from({ length: 8 }, () => new Vec3()); +const screenCorners = Array.from({ length: 8 }, () => new Vec3()); +const cornerInFront = new Array(8); +const screenBoundCenter = new Vec3(); +const worldBoundCenter = new Vec3(); +const tmpVec = new Vec3(); + +// indices into the 8-corner array, ordered as (sx, sy, sz) where each s is 0 or 1 +// corner index = sx*4 + sy*2 + sz +const cornerIndex = (sx: number, sy: number, sz: number) => sx * 4 + sy * 2 + sz; + +// for each axis, the 4 pairs of corner indices that form the parallel edges along that axis +const axisEdges: number[][][] = [ + // X edges: vary sx from 0->1, hold sy, sz constant + [ + [cornerIndex(0, 0, 0), cornerIndex(1, 0, 0)], + [cornerIndex(0, 0, 1), cornerIndex(1, 0, 1)], + [cornerIndex(0, 1, 0), cornerIndex(1, 1, 0)], + [cornerIndex(0, 1, 1), cornerIndex(1, 1, 1)] + ], + // Y edges + [ + [cornerIndex(0, 0, 0), cornerIndex(0, 1, 0)], + [cornerIndex(0, 0, 1), cornerIndex(0, 1, 1)], + [cornerIndex(1, 0, 0), cornerIndex(1, 1, 0)], + [cornerIndex(1, 0, 1), cornerIndex(1, 1, 1)] + ], + // Z edges + [ + [cornerIndex(0, 0, 0), cornerIndex(0, 0, 1)], + [cornerIndex(0, 1, 0), cornerIndex(0, 1, 1)], + [cornerIndex(1, 0, 0), cornerIndex(1, 0, 1)], + [cornerIndex(1, 1, 0), cornerIndex(1, 1, 1)] + ] +]; + +class BoundDimensionsOverlay { + constructor(events: Events, scene: Scene, canvasContainer: Container) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.classList.add('tool-svg', 'bound-dimensions-svg', 'hidden'); + svg.id = 'bound-dimensions-svg'; + canvasContainer.dom.appendChild(svg); + + const labels: SVGTextElement[] = []; + for (let i = 0; i < 3; i++) { + const text = document.createElementNS(ns, 'text') as SVGTextElement; + text.classList.add(['bound-dim-x', 'bound-dim-y', 'bound-dim-z'][i]); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('dominant-baseline', 'middle'); + svg.appendChild(text); + labels.push(text); + } + + events.on('prerender', () => { + const selection = events.invoke('selection') as Splat; + + if (!selection || + !selection.visible || + !events.invoke('camera.boundDimensions')) { + svg.classList.add('hidden'); + return; + } + + svg.classList.remove('hidden'); + + const width = canvasContainer.dom.clientWidth; + const height = canvasContainer.dom.clientHeight; + const camera = scene.camera; + const transform = selection.entity.getWorldTransform(); + const bound = selection.localBound; + const { center, halfExtents } = bound; + + // compute 8 world-space corners + for (let i = 0; i < 8; i++) { + const sx = (i >> 2) & 1; + const sy = (i >> 1) & 1; + const sz = i & 1; + const local = corners[i]; + local.set( + center.x + (sx ? 1 : -1) * halfExtents.x, + center.y + (sy ? 1 : -1) * halfExtents.y, + center.z + (sz ? 1 : -1) * halfExtents.z + ); + transform.transformPoint(local, local); + } + + // determine which corners are in front of the camera (for behind-camera culling) + const cameraPos = camera.mainCamera.getPosition(); + const cameraFwd = camera.mainCamera.forward; + for (let i = 0; i < 8; i++) { + tmpVec.sub2(corners[i], cameraPos); + cornerInFront[i] = tmpVec.dot(cameraFwd) > 0; + } + + // project all corners to screen + for (let i = 0; i < 8; i++) { + camera.worldToScreen(corners[i], screenCorners[i]); + } + + // project bound center to screen (used to choose the outer-most edge) + transform.transformPoint(center, worldBoundCenter); + camera.worldToScreen(worldBoundCenter, screenBoundCenter); + const scx = screenBoundCenter.x * width; + const scy = screenBoundCenter.y * height; + + for (let axis = 0; axis < 3; axis++) { + const edges = axisEdges[axis]; + let bestEdge = -1; + let bestScore = -Infinity; + + // pick the parallel edge on the outer silhouette: farthest screen-space distance + // from the projected box centroid. Ties are common in orthographic projection + // (opposite edges are exactly equidistant), so require a meaningful difference + // before swapping the chosen edge to avoid frame-to-frame flicker. + for (let e = 0; e < edges.length; e++) { + const [a, b] = edges[e]; + if (!cornerInFront[a] || !cornerInFront[b]) continue; + const mxe = (screenCorners[a].x + screenCorners[b].x) * 0.5 * width; + const mye = (screenCorners[a].y + screenCorners[b].y) * 0.5 * height; + const dxe = mxe - scx; + const dye = mye - scy; + const score = dxe * dxe + dye * dye; + if (score > bestScore + 1) { + bestScore = score; + bestEdge = e; + } + } + + const text = labels[axis]; + + if (bestEdge < 0) { + // no parallel edge has both endpoints in front of the camera + text.setAttribute('visibility', 'hidden'); + continue; + } + text.setAttribute('visibility', 'visible'); + + const [a, b] = edges[bestEdge]; + const sa = screenCorners[a]; + const sb = screenCorners[b]; + + // world-space edge length + const length = corners[a].distance(corners[b]); + + // screen-space endpoints in pixels + const x0 = sa.x * width; + const y0 = sa.y * height; + const x1 = sb.x * width; + const y1 = sb.y * height; + + const mx = (x0 + x1) * 0.5; + const my = (y0 + y1) * 0.5; + + let theta = Math.atan2(y1 - y0, x1 - x0); + // flip 180° to keep text upright + if (Math.cos(theta) < 0) { + theta += Math.PI; + } + + // perpendicular offset so the label sits outside the box + const perpX = -Math.sin(theta); + const perpY = Math.cos(theta); + const toCenterX = scx - mx; + const toCenterY = scy - my; + const dot = perpX * toCenterX + perpY * toCenterY; + const sign = dot > 0 ? -1 : 1; + const offsetPx = 10; + const ox = perpX * offsetPx * sign; + const oy = perpY * offsetPx * sign; + + const thetaDeg = theta * 180 / Math.PI; + text.setAttribute('transform', `translate(${(mx + ox).toFixed(1)}, ${(my + oy).toFixed(1)}) rotate(${thetaDeg.toFixed(1)})`); + text.textContent = length.toFixed(2); + } + }); + } +} + +export { BoundDimensionsOverlay }; diff --git a/src/ui/camera-info-overlay.ts b/src/ui/camera-info-overlay.ts new file mode 100644 index 0000000..f5c3b1e --- /dev/null +++ b/src/ui/camera-info-overlay.ts @@ -0,0 +1,163 @@ +import { Container, Label } from '@playcanvas/pcui'; +import { Vec3 } from 'playcanvas'; + +import { Events } from '../events'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +// Accepts "1,2,3", "1, 2, 3", "1 2 3", with or without trailing whitespace. +const parseVector = (text: string): [number, number, number] | null => { + const parts = text.trim().split(/[\s,]+/).filter(p => p.length > 0); + if (parts.length !== 3) return null; + const nums = parts.map(Number); + if (nums.some(n => !Number.isFinite(n))) return null; + return [nums[0], nums[1], nums[2]]; +}; + +const flash = (dom: HTMLElement, cls: string) => { + dom.classList.add(cls); + setTimeout(() => dom.classList.remove(cls), 250); +}; + +// round to 2 decimals and drop trailing zeros ("0.00" -> "0", "0.50" -> "0.5") +const fmt = (n: number) => `${parseFloat(n.toFixed(2))}`; + +class CameraInfoOverlay extends Container { + constructor(events: Events, tooltips: Tooltips) { + super({ + id: 'camera-info-overlay', + hidden: true + }); + + // stop pointer events reaching the canvas and moving the camera + ['pointerdown', 'pointerup', 'pointermove', 'wheel', 'dblclick'].forEach((eventName) => { + this.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); + }); + + const createRow = (letter: string, tooltipKey: string, apply: (value: [number, number, number]) => void) => { + const row = new Container({ + class: 'camera-info-row' + }); + + const key = new Label({ + class: 'camera-info-key', + text: letter + }); + + const value = new Label({ + class: 'camera-info-value' + }); + + row.append(key); + row.append(value); + this.append(row); + + tooltips.register(row, () => i18n.t(tooltipKey), 'top'); + + const dom = value.dom; + dom.setAttribute('contenteditable', 'plaintext-only'); + dom.setAttribute('spellcheck', 'false'); + + // display holds the latest formatted value; live updates are + // suppressed while the user is editing so their input isn't + // overwritten by the per-frame refresh. + let display = ''; + let editing = false; + let canceled = false; + + dom.addEventListener('focus', () => { + editing = true; + canceled = false; + // select all text so the user can start typing to replace + const range = document.createRange(); + range.selectNodeContents(dom); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }); + + // stop key events reaching the editor's global shortcut handlers + const stopKey = (e: KeyboardEvent) => e.stopPropagation(); + dom.addEventListener('keydown', (e: KeyboardEvent) => { + stopKey(e); + if (e.key === 'Enter') { + e.preventDefault(); + dom.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + canceled = true; + dom.blur(); + } + }); + dom.addEventListener('keyup', stopKey); + dom.addEventListener('keypress', stopKey); + + dom.addEventListener('blur', () => { + const wasCanceled = canceled; + editing = false; + canceled = false; + // clear any leftover selection + window.getSelection()?.removeAllRanges(); + + if (!wasCanceled) { + const parsed = parseVector(dom.textContent ?? ''); + if (parsed) { + apply(parsed); + flash(dom, 'flash-ok'); + } else { + flash(dom, 'flash-bad'); + } + } + + // restore the live display; the next prerender picks up any + // pose change resulting from the edit + dom.textContent = display; + }); + + return { + update: (text: string) => { + if (editing) { + return; + } + if (display !== text) { + display = text; + dom.textContent = text; + } + } + }; + }; + + const positionRow = createRow('P', 'camera-info.position', (v) => { + const { target } = events.invoke('camera.getPose'); + events.fire('camera.setPose', { + position: new Vec3(v[0], v[1], v[2]), + target: new Vec3(target.x, target.y, target.z) + }); + }); + + const targetRow = createRow('T', 'camera-info.target', (v) => { + const { position } = events.invoke('camera.getPose'); + events.fire('camera.setPose', { + position: new Vec3(position.x, position.y, position.z), + target: new Vec3(v[0], v[1], v[2]) + }); + }); + + events.on('camera.showInfo', (visible: boolean) => { + this.hidden = !visible; + }); + + events.on('prerender', () => { + if (this.hidden) { + return; + } + + const { position, target } = events.invoke('camera.getPose'); + + positionRow.update(`${fmt(position.x)}, ${fmt(position.y)}, ${fmt(position.z)}`); + targetRow.update(`${fmt(target.x)}, ${fmt(target.y)}, ${fmt(target.z)}`); + }); + } +} + +export { CameraInfoOverlay }; diff --git a/src/ui/color-panel.ts b/src/ui/color-panel.ts new file mode 100644 index 0000000..5ff4d62 --- /dev/null +++ b/src/ui/color-panel.ts @@ -0,0 +1,440 @@ +import { ColorPicker, Container, Label, SliderInput } from '@playcanvas/pcui'; +import { Color } from 'playcanvas'; + +import { Events } from '../events'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; +import { SetSplatColorAdjustmentOp } from '../edit-ops'; +import { Splat } from '../splat'; + +// pcui slider doesn't include start and end events +class MyFancySliderInput extends SliderInput { + _onSlideStart(pageX: number) { + super._onSlideStart(pageX); + this.emit('slide:start'); + } + + _onSlideEnd(pageX: number) { + super._onSlideEnd(pageX); + this.emit('slide:end'); + } +} + +class ColorPanel extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'color-panel', + class: 'panel', + hidden: true + }; + + super(args); + + // stop pointer events bubbling + ['pointerdown', 'pointerup', 'pointermove', 'wheel', 'dblclick'].forEach((eventName) => { + this.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); + }); + + // header + + const header = new Container({ + class: 'panel-header' + }); + + const icon = new Label({ + class: 'panel-header-icon', + text: '\uE146' + }); + + const label = new Label({ + class: 'panel-header-label' + }); + i18n.bindText(label, 'panel.colors'); + + header.append(icon); + header.append(label); + + // tint + + const tintRow = new Container({ + class: 'color-panel-row' + }); + + const tintLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(tintLabel, 'panel.colors.tint'); + + const tintPicker = new ColorPicker({ + class: 'color-panel-row-picker', + value: [1, 1, 1] + }); + + tintRow.append(tintLabel); + tintRow.append(tintPicker); + + // temperature + + const temperatureRow = new Container({ + class: 'color-panel-row' + }); + + const temperatureLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(temperatureLabel, 'panel.colors.temperature'); + + const temperatureSlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: -0.5, + max: 0.5, + step: 0.005, + value: 0 + }); + + temperatureRow.append(temperatureLabel); + temperatureRow.append(temperatureSlider); + + // saturation + + const saturationRow = new Container({ + class: 'color-panel-row' + }); + + const saturationLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(saturationLabel, 'panel.colors.saturation'); + + const saturationSlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: 0, + max: 2, + step: 0.1, + value: 1 + }); + + saturationRow.append(saturationLabel); + saturationRow.append(saturationSlider); + + // brightness + + const brightnessRow = new Container({ + class: 'color-panel-row' + }); + + const brightnessLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(brightnessLabel, 'panel.colors.brightness'); + + const brightnessSlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: -1, + max: 1, + step: 0.1, + value: 1 + }); + + brightnessRow.append(brightnessLabel); + brightnessRow.append(brightnessSlider); + + // black point + + const blackPointRow = new Container({ + class: 'color-panel-row' + }); + + const blackPointLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(blackPointLabel, 'panel.colors.black-point'); + + const blackPointSlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: 0, + max: 1, + step: 0.01, + value: 0 + }); + + blackPointRow.append(blackPointLabel); + blackPointRow.append(blackPointSlider); + + // white point + + const whitePointRow = new Container({ + class: 'color-panel-row' + }); + + const whitePointLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(whitePointLabel, 'panel.colors.white-point'); + + const whitePointSlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: 0, + max: 1, + step: 0.01, + value: 1 + }); + + whitePointRow.append(whitePointLabel); + whitePointRow.append(whitePointSlider); + + // transparency + + const transparencyRow = new Container({ + class: 'color-panel-row' + }); + + const transparencyLabel = new Label({ + class: 'color-panel-row-label' + }); + i18n.bindText(transparencyLabel, 'panel.colors.transparency'); + + const transparencySlider = new MyFancySliderInput({ + class: 'color-panel-row-slider', + min: -6, + max: 6, + step: 0.01, + value: 1 + }); + + transparencyRow.append(transparencyLabel); + transparencyRow.append(transparencySlider); + + // control row + + const controlRow = new Container({ + class: 'color-panel-control-row' + }); + + const reset = new Label({ + class: 'panel-header-button', + text: '\uE304' + }); + + controlRow.append(new Label({ class: 'panel-header-spacer' })); + controlRow.append(reset); + controlRow.append(new Label({ class: 'panel-header-spacer' })); + + this.append(header); + this.append(tintRow); + this.append(temperatureRow); + this.append(saturationRow); + this.append(brightnessRow); + this.append(blackPointRow); + this.append(whitePointRow); + this.append(transparencyRow); + this.append(new Label({ class: 'panel-header-spacer' })); + this.append(controlRow); + + // handle ui updates + + let suppress = false; + let selected: Splat = null; + let op: SetSplatColorAdjustmentOp = null; + + const updateUIFromState = (splat: Splat) => { + if (suppress) return; + suppress = true; + tintPicker.value = splat ? [splat.tintClr.r, splat.tintClr.g, splat.tintClr.b] : [1, 1, 1]; + temperatureSlider.value = splat ? splat.temperature : 0; + saturationSlider.value = splat ? splat.saturation : 0; + brightnessSlider.value = splat ? splat.brightness : 0; + blackPointSlider.value = splat ? splat.blackPoint : 0; + whitePointSlider.value = splat ? splat.whitePoint : 1; + transparencySlider.value = splat ? Math.log(splat.transparency) : 0; + suppress = false; + }; + + const start = () => { + if (selected) { + op = new SetSplatColorAdjustmentOp({ + splat: selected, + newState: { + tintClr: selected.tintClr.clone(), + temperature: selected.temperature, + saturation: selected.saturation, + brightness: selected.brightness, + blackPoint: selected.blackPoint, + whitePoint: selected.whitePoint, + transparency: selected.transparency + }, + oldState: { + tintClr: selected.tintClr.clone(), + temperature: selected.temperature, + saturation: selected.saturation, + brightness: selected.brightness, + blackPoint: selected.blackPoint, + whitePoint: selected.whitePoint, + transparency: selected.transparency + } + }); + } + }; + + const end = () => { + if (op) { + const { newState } = op; + newState.tintClr.set(tintPicker.value[0], tintPicker.value[1], tintPicker.value[2]); + newState.temperature = temperatureSlider.value; + newState.saturation = saturationSlider.value; + newState.brightness = brightnessSlider.value; + newState.blackPoint = blackPointSlider.value; + newState.whitePoint = whitePointSlider.value; + newState.transparency = Math.exp(transparencySlider.value); + events.fire('edit.add', op); + op = null; + } + }; + + const updateOp = (setFunc: (op: SetSplatColorAdjustmentOp) => void) => { + if (!suppress) { + suppress = true; + if (op) { + setFunc(op); + op.do(); + } else if (selected) { + start(); + setFunc(op); + op.do(); + end(); + } + suppress = false; + } + }; + + [temperatureSlider, saturationSlider, brightnessSlider, blackPointSlider, whitePointSlider, transparencySlider].forEach((slider) => { + slider.on('slide:start', start); + slider.on('slide:end', end); + }); + tintPicker.on('picker:color:start', start); + tintPicker.on('picker:color:end', end); + + tintPicker.on('change', (value: number[]) => { + updateOp((op) => { + op.newState.tintClr.set(value[0], value[1], value[2]); + }); + }); + + temperatureSlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.temperature = value; + }); + }); + + saturationSlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.saturation = value; + }); + }); + + brightnessSlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.brightness = value; + }); + }); + + blackPointSlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.blackPoint = value; + }); + + if (value > whitePointSlider.value) { + whitePointSlider.value = value; + } + }); + + whitePointSlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.whitePoint = value; + }); + + if (value < blackPointSlider.value) { + blackPointSlider.value = value; + } + }); + + transparencySlider.on('change', (value: number) => { + updateOp((op) => { + op.newState.transparency = Math.exp(value); + }); + }); + + reset.on('click', () => { + if (selected) { + const op = new SetSplatColorAdjustmentOp({ + splat: selected, + newState: { + tintClr: new Color(1, 1, 1), + temperature: 0, + saturation: 1, + brightness: 0, + blackPoint: 0, + whitePoint: 1, + transparency: 1 + }, + oldState: { + tintClr: selected.tintClr.clone(), + temperature: selected.temperature, + saturation: selected.saturation, + brightness: selected.brightness, + blackPoint: selected.blackPoint, + whitePoint: selected.whitePoint, + transparency: selected.transparency + } + }); + + events.fire('edit.add', op); + } + }); + + events.on('selection.changed', (splat) => { + selected = splat; + updateUIFromState(splat); + }); + + events.on('splat.tintClr', updateUIFromState); + events.on('splat.temperature', updateUIFromState); + events.on('splat.saturation', updateUIFromState); + events.on('splat.brightness', updateUIFromState); + events.on('splat.blackPoint', updateUIFromState); + events.on('splat.whitePoint', updateUIFromState); + events.on('splat.transparency', updateUIFromState); + + tooltips.register(reset, () => i18n.t('panel.colors.reset'), 'bottom'); + + // handle panel visibility + + const setVisible = (visible: boolean) => { + if (visible === this.hidden) { + this.hidden = !visible; + events.fire('colorPanel.visible', visible); + } + }; + + events.function('colorPanel.visible', () => { + return !this.hidden; + }); + + events.on('colorPanel.setVisible', (visible: boolean) => { + setVisible(visible); + }); + + events.on('colorPanel.toggleVisible', () => { + setVisible(this.hidden); + }); + + events.on('settingsPanel.visible', (visible: boolean) => { + if (visible) { + setVisible(false); + } + }); + } +} + +export { ColorPanel }; diff --git a/src/ui/color.ts b/src/ui/color.ts new file mode 100644 index 0000000..bf45e72 --- /dev/null +++ b/src/ui/color.ts @@ -0,0 +1,64 @@ +const rgb2hsv = (rgb: { r: number, g: number, b: number }) => { + const r = rgb.r; + const g = rgb.g; + const b = rgb.b; + const v = Math.max(r, g, b); + const diff = v - Math.min(r, g, b); + + const diffc = (c: number) => { + return (v - c) / 6 / diff + 1 / 2; + }; + + let h, s; + + if (diff === 0) { + h = s = 0; + } else { + s = diff / v; + const rr = diffc(r); + const gg = diffc(g); + const bb = diffc(b); + + if (r === v) { + h = bb - gg; + } else if (g === v) { + h = (1 / 3) + rr - bb; + } else if (b === v) { + h = (2 / 3) + gg - rr; + } + if (h < 0) { + h += 1; + } else if (h > 1) { + h -= 1; + } + } + + return { h, s, v }; +}; + +const hsv2rgb = (hsv: { h: number, s: number, v: number }) => { + const h = hsv.h; + const s = hsv.s; + const v = hsv.v; + + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + let r, g, b; + + switch (i % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + + return { r, g, b }; +}; + +export { rgb2hsv, hsv2rgb }; diff --git a/src/ui/data-panel.ts b/src/ui/data-panel.ts new file mode 100644 index 0000000..458eaf5 --- /dev/null +++ b/src/ui/data-panel.ts @@ -0,0 +1,847 @@ +import { BooleanInput, Container, Label } from '@playcanvas/pcui'; +import { Mat4 } from 'playcanvas'; + +import { Element } from '../element'; +import { Events } from '../events'; +import { Splat } from '../splat'; +import { Histogram } from './histogram'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +// gpu propMode constants. these must match the propMode dispatch in +// src/shaders/splat-value-shader.ts. +// +// modes 5..7 and 18..20 read the final on-screen color (DC + evaluated SH for +// the current view direction), so they are camera-dependent. +// modes 66..68 read the raw f_dc_N coefficients reconstructed from the +// already-decoded splatColor texture. +const PROP_MODE: { [key: string]: number } = { + x: 0, + y: 1, + z: 2, + distance: 3, + 'camera-depth': 4, + red: 5, + green: 6, + blue: 7, + opacity: 8, + scale_0: 9, + scale_1: 10, + scale_2: 11, + volume: 12, + 'surface-area': 13, + rot_0: 14, + rot_1: 15, + rot_2: 16, + rot_3: 17, + hue: 18, + saturation: 19, + value: 20, + f_dc_0: 66, + f_dc_1: 67, + f_dc_2: 68 +}; + +// f_rest_N maps to mode (21 + N). max 45 SH coefficients (shBands 3). +const F_REST_BASE_MODE = 21; + +const SH_NUM_COEFFS: { [k: number]: number } = { 0: 0, 1: 3, 2: 8, 3: 15 }; + +const propModeFor = (prop: string): number | undefined => { + if (prop in PROP_MODE) return PROP_MODE[prop]; + const m = /^f_rest_(\d+)$/.exec(prop); + if (m) return F_REST_BASE_MODE + parseInt(m[1], 10); + return undefined; +}; + +// final-color (DC + evaluated SH for current view direction) — depends on +// world-space splat position, camera position and ColorGrade. +const isFinalColorMode = (mode: number) => { + return (mode >= 5 && mode <= 7) || (mode >= 18 && mode <= 20); +}; + +// what kinds of state changes affect a given prop's histogram. mirrors the +// previous per-event filtering, but consulted only inside hash(). +const isCameraDependentMode = (mode: number) => mode === 4 /* camera-depth */ || isFinalColorMode(mode); +const isPositionDependentMode = (mode: number) => { + return mode === 0 || mode === 1 || mode === 2 || // x / y / z + mode === 3 || mode === 4 || // distance / camera-depth + isFinalColorMode(mode); +}; +// ColorGrade-dependent. f_dc_* (raw DC, modes 66..68) bypasses ColorGrade. +const isColorGradeDependentMode = (mode: number) => mode === 8 /* opacity */ || isFinalColorMode(mode); + +// every input that can require a histogram refresh. subscribers update one +// field and call tick(); the hash collapses no-op changes into a fast path +// and lets the existing per-prop filtering live in pure hash(). +type HistogramInputs = { + splatId: number; + mode: number; + onScreenOnly: boolean; + logScale: boolean; + cameraVersion: number; + stateVersion: number; + colorGradeVersion: number; + positionsVersion: number; +}; + +const hashInputs = (i: HistogramInputs): string => { + const m = i.mode; + const camMatters = i.onScreenOnly || isCameraDependentMode(m); + const posMatters = isPositionDependentMode(m); + const cgMatters = isColorGradeDependentMode(m); + return `${i.splatId}|${m}|${i.onScreenOnly ? 1 : 0}|${i.logScale ? 1 : 0}|` + + `${camMatters ? i.cameraVersion : 0}|${i.stateVersion}|` + + `${cgMatters ? i.colorGradeVersion : 0}|${posMatters ? i.positionsVersion : 0}`; +}; + +class DataPanel extends Container { + constructor(events: Events, tooltips: Tooltips, args = { }) { + args = { + ...args, + id: 'data-panel', + hidden: true, + flex: true, + flexDirection: 'row' + }; + + super(args); + + // resize handle + const resizeHandle = document.createElement('div'); + resizeHandle.id = 'data-panel-resize-handle'; + this.dom.appendChild(resizeHandle); + + let resizing = false; + let startY = 0; + let startHeight = 0; + + resizeHandle.addEventListener('pointerdown', (event: PointerEvent) => { + if (event.isPrimary) { + resizing = true; + startY = event.clientY; + startHeight = this.dom.offsetHeight; + resizeHandle.setPointerCapture(event.pointerId); + event.preventDefault(); + } + }); + + resizeHandle.addEventListener('pointermove', (event: PointerEvent) => { + if (resizing) { + const delta = startY - event.clientY; + const newHeight = Math.max(120, Math.min(1000, startHeight + delta)); + this.dom.style.height = `${newHeight}px`; + } + }); + + resizeHandle.addEventListener('pointerup', (event: PointerEvent) => { + if (resizing && event.isPrimary) { + resizeHandle.releasePointerCapture(event.pointerId); + } + }); + + resizeHandle.addEventListener('lostpointercapture', () => { + resizing = false; + }); + + // build the data controls + const controlsContainer = new Container({ + id: 'data-controls-container' + }); + + const controls = new Container({ + id: 'data-controls' + }); + + // track the selected data property + let selectedDataProp = 'x'; + + // data list box + const dataListBox = new Container({ + id: 'data-list-box' + }); + + const logScale = new Container({ + class: 'data-panel-toggle-row', + flex: true, + flexDirection: 'row' + }); + + const logScaleLabel = new Label({ + class: 'data-panel-toggle-label' + }); + i18n.bindText(logScaleLabel, 'panel.splat-data.log-scale'); + + const logScaleValue = new BooleanInput({ + type: 'toggle', + class: 'data-panel-toggle', + value: false + }); + + logScale.append(logScaleLabel); + logScale.append(logScaleValue); + + const showAll = new Container({ + class: 'data-panel-toggle-row', + flex: true, + flexDirection: 'row' + }); + + const showAllLabel = new Label({ + class: 'data-panel-toggle-label' + }); + i18n.bindText(showAllLabel, 'panel.splat-data.show-all'); + + const showAllValue = new BooleanInput({ + type: 'toggle', + class: 'data-panel-toggle', + value: false + }); + + showAll.append(showAllLabel); + showAll.append(showAllValue); + + const onScreenOnly = new Container({ + class: 'data-panel-toggle-row', + flex: true, + flexDirection: 'row' + }); + + const onScreenOnlyLabel = new Label({ + class: 'data-panel-toggle-label' + }); + i18n.bindText(onScreenOnlyLabel, 'panel.splat-data.on-screen-only'); + + const onScreenOnlyValue = new BooleanInput({ + type: 'toggle', + class: 'data-panel-toggle', + value: false + }); + + onScreenOnly.append(onScreenOnlyLabel); + onScreenOnly.append(onScreenOnlyValue); + + const populateDataSelector = (splat: Splat) => { + // default prop localizations - order defines display order. "red", + // "green", "blue" and HSV here are the final on-screen color (DC + // + evaluated SH for the current view direction). + const localizations: any = { + x: `${i18n.t('panel.splat-data.position')} X`, + y: `${i18n.t('panel.splat-data.position')} Y`, + z: `${i18n.t('panel.splat-data.position')} Z`, + opacity: i18n.t('panel.splat-data.opacity'), + red: i18n.t('panel.splat-data.red'), + green: i18n.t('panel.splat-data.green'), + blue: i18n.t('panel.splat-data.blue'), + scale_0: i18n.t('panel.splat-data.scale-x'), + scale_1: i18n.t('panel.splat-data.scale-y'), + scale_2: i18n.t('panel.splat-data.scale-z'), + rot_0: `${i18n.t('panel.splat-data.quat')} W`, + rot_1: `${i18n.t('panel.splat-data.quat')} X`, + rot_2: `${i18n.t('panel.splat-data.quat')} Y`, + rot_3: `${i18n.t('panel.splat-data.quat')} Z`, + distance: i18n.t('panel.splat-data.distance'), + 'camera-depth': i18n.t('panel.splat-data.camera-depth'), + volume: i18n.t('panel.splat-data.volume'), + 'surface-area': i18n.t('panel.splat-data.surface-area'), + hue: i18n.t('panel.splat-data.hue'), + saturation: i18n.t('panel.splat-data.saturation'), + value: i18n.t('panel.splat-data.value') + }; + + // "Show All" extras: raw DC coefficients first, then spherical + // harmonics coefficients labelled with their channel (R/G/B) and + // within-channel index. all filtered by the splat's actual SH band + // count so we never offer a mode the GPU shader can't decode. + const extras: any = { + f_dc_0: i18n.t('panel.splat-data.dc-red'), + f_dc_1: i18n.t('panel.splat-data.dc-green'), + f_dc_2: i18n.t('panel.splat-data.dc-blue') + }; + const shBands = (splat.entity.gsplat.instance.resource as any).shBands ?? 0; + const numCoeffs = SH_NUM_COEFFS[shBands] ?? 0; + const channels = ['R', 'G', 'B']; + const maxFRest = numCoeffs * 3; + for (let i = 0; i < maxFRest; i++) { + const channel = channels[Math.floor(i / numCoeffs)]; + const idx = i % numCoeffs; + extras[`f_rest_${i}`] = `${channel} ${i18n.t('panel.splat-data.sh')} ${idx}`; + } + + const dataProps = splat.splatData.getElement('vertex').properties.map(p => p.name); + const derivedProps = ['distance', 'camera-depth', 'volume', 'surface-area', 'red', 'green', 'blue', 'hue', 'saturation', 'value']; + const availableProps = new Set(dataProps.concat(derivedProps)); + + // build ordered default props from localizations keys, filtered to available + const defaultProps = Object.keys(localizations).filter(p => availableProps.has(p)); + + // build ordered extra props from extras keys, filtered to available + const extraProps = showAllValue.value ? + Object.keys(extras).filter(p => availableProps.has(p)) : + []; + + const allProps = [...defaultProps, ...extraProps]; + + // if the current selection is no longer in the list (e.g. "All + // Properties" turned off after picking f_rest_5, or selection moved + // to a splat with fewer SH bands), fall back to the first available + // prop so inputs.mode doesn't stay pinned to an unsupported propMode + // with no active row to indicate it. + if (allProps.length > 0 && !allProps.includes(selectedDataProp)) { + selectedDataProp = allProps[0]; + // eslint-disable-next-line no-use-before-define + inputs.mode = propModeFor(selectedDataProp) ?? 0; + } + + // clear existing items + dataListBox.dom.innerHTML = ''; + + allProps.forEach((prop) => { + const item = document.createElement('div'); + item.classList.add('data-list-item'); + if (prop === selectedDataProp) { + item.classList.add('active'); + } + item.textContent = localizations[prop] ?? extras[prop] ?? prop; + + item.addEventListener('click', () => { + selectedDataProp = prop; + // eslint-disable-next-line no-use-before-define + inputs.mode = propModeFor(prop) ?? 0; + dataListBox.dom.querySelectorAll('.data-list-item').forEach((el) => { + el.classList.remove('active'); + }); + item.classList.add('active'); + tick(); // eslint-disable-line no-use-before-define + }); + + dataListBox.dom.appendChild(item); + }); + }; + + // ordered: visible-only (histogram filter), log scale (histogram + // display), then all-properties (list filter, sitting right above the + // property list it affects). + controls.append(onScreenOnly); + controls.append(logScale); + controls.append(showAll); + controls.append(dataListBox); + + // tooltips explain what each toggle actually does. registered on the + // row containers so the entire row (label + toggle) shares one + // hover target. + tooltips.register(onScreenOnly, () => i18n.t('tooltip.splat-data.on-screen-only'), 'right'); + tooltips.register(logScale, () => i18n.t('tooltip.splat-data.log-scale'), 'right'); + tooltips.register(showAll, () => i18n.t('tooltip.splat-data.show-all'), 'right'); + + controlsContainer.append(controls); + + // build histogram + const histogram = new Histogram(256, 128); + + const histogramContainer = new Container({ + id: 'histogram-container' + }); + + // wrap the canvas, SVG highlight overlay and stats overlay so the + // parent can be a flex column with a fixed info row underneath. without + // this wrapper, the canvas's inline width/height:100% consumes the + // whole container and pushes the info row out of view. + const histogramCanvasArea = document.createElement('div'); + histogramCanvasArea.id = 'histogram-canvas-area'; + histogramCanvasArea.appendChild(histogram.canvas); + + // top-right stats overlay: shows the aggregate counts for the hovered + // bucket or the drag range. pointer-events: none so it never blocks + // the canvas pointer interactions. + const statsOverlay = document.createElement('div'); + statsOverlay.id = 'histogram-stats-overlay'; + statsOverlay.style.display = 'none'; + + const statsCountRow = document.createElement('div'); + statsCountRow.className = 'histogram-stats-row'; + const statsCountLabel = document.createElement('span'); + statsCountLabel.className = 'histogram-stats-label'; + i18n.onChange(() => { + statsCountLabel.textContent = `${i18n.t('panel.splat-data.totals.splats')}:`; + }); + const statsCountValue = document.createElement('span'); + statsCountValue.className = 'histogram-stats-value'; + statsCountRow.appendChild(statsCountLabel); + statsCountRow.appendChild(statsCountValue); + + const statsSelectedRow = document.createElement('div'); + statsSelectedRow.className = 'histogram-stats-row'; + const statsSelectedLabel = document.createElement('span'); + statsSelectedLabel.className = 'histogram-stats-label'; + i18n.onChange(() => { + statsSelectedLabel.textContent = `${i18n.t('panel.splat-data.totals.selected')}:`; + }); + const statsSelectedValue = document.createElement('span'); + statsSelectedValue.className = 'histogram-stats-value'; + statsSelectedRow.appendChild(statsSelectedLabel); + statsSelectedRow.appendChild(statsSelectedValue); + + statsOverlay.appendChild(statsCountRow); + statsOverlay.appendChild(statsSelectedRow); + histogramCanvasArea.appendChild(statsOverlay); + + histogramContainer.dom.appendChild(histogramCanvasArea); + + // info row pinned underneath the histogram canvas. min sits left, max + // sits right. while hovering, the cursor label slides along to show + // the bucket value under the pointer. while dragging, the anchor label + // sits at the click position (where the drag started) and the cursor + // label tracks the live pointer, so the user can read start -> end. + const histogramInfoRow = document.createElement('div'); + histogramInfoRow.id = 'histogram-info-row'; + + const histogramInfoMin = document.createElement('div'); + histogramInfoMin.className = 'histogram-info-min'; + + const histogramInfoAnchor = document.createElement('div'); + histogramInfoAnchor.className = 'histogram-info-anchor'; + + const histogramInfoCursor = document.createElement('div'); + histogramInfoCursor.className = 'histogram-info-cursor'; + + const histogramInfoMax = document.createElement('div'); + histogramInfoMax.className = 'histogram-info-max'; + + histogramInfoRow.appendChild(histogramInfoMin); + histogramInfoRow.appendChild(histogramInfoAnchor); + histogramInfoRow.appendChild(histogramInfoMax); + // cursor last so it stacks above min/max (same row, absolute positioning). + histogramInfoRow.appendChild(histogramInfoCursor); + histogramContainer.dom.appendChild(histogramInfoRow); + + this.append(controlsContainer); + this.append(histogramContainer); + + // current splat + let splat: Splat; + + // rebuild the localized property list when the language changes + i18n.onChange(() => { + if (splat) { + populateDataSelector(splat); + } + }); + + let pendingToken = 0; + let lastGpuMode = 0; + let lastHash = ''; + const viewProjection = new Mat4(); + + // single source of truth for everything that could trigger a refresh. + // subscribers update one field and call tick(); tick hashes the inputs + // (with per-mode dependency filtering) and only schedules a GPU pass + // when the hash actually changed. + const inputs: HistogramInputs = { + splatId: -1, + mode: 0, + onScreenOnly: false, + logScale: false, + cameraVersion: 0, + stateVersion: 0, + colorGradeVersion: 0, + positionsVersion: 0 + }; + + const buildGpuOpts = () => { + const cam = splat.scene.camera.camera; + const opts: any = { + entityMatrix: splat.entity.getWorldTransform(), + viewMatrix: cam.viewMatrix, + cameraPos: splat.scene.camera.position + }; + if (inputs.onScreenOnly) { + viewProjection.mul2(cam.projectionMatrix, cam.viewMatrix); + opts.viewProjection = viewProjection; + opts.onScreenOnly = true; + } + return opts; + }; + + const scheduleUpdate = () => { + if (!splat || this.hidden) return; + const mode = inputs.mode; + const opts = buildGpuOpts(); + // pendingToken collapses bursts of triggers within a single queue + // tick (e.g. rapid camera-settle + color-grade) so only the latest + // intent issues a GPU pass. ordering vs select / history mutations + // is provided by the shared command queue. + const myToken = ++pendingToken; + splat.scene.commandQueue.enqueue(async () => { + if (myToken !== pendingToken) return; + try { + const result = await splat.scene.dataProcessor.calcHistogram(splat, mode, opts); + if (myToken !== pendingToken) return; + + lastGpuMode = mode; + + histogram.setData({ + selected: result.selected, + unselected: result.unselected, + min: result.min, + max: result.max, + numValues: result.numValues, + logScale: inputs.logScale + }); + + // eslint-disable-next-line no-use-before-define + refreshRange(); + } catch (err) { + // clear lastHash so the next tick with the same inputs retries + // instead of being deduped against the failed pass. + lastHash = ''; + throw err; + } + }); + }; + + // format a numeric bucket value compactly for the overlay readout. + // mode-aware: index-only modes (like 'state') would round, but all + // current props are floats so a fixed-precision render is fine. + const formatValue = (v: number) => { + if (!Number.isFinite(v)) return '-'; + const abs = Math.abs(v); + if (abs !== 0 && (abs < 0.01 || abs >= 10000)) { + return v.toExponential(2); + } + return v.toFixed(3); + }; + + const refreshRange = () => { + const h = histogram.histogram; + if (!h.numValues) { + histogramInfoMin.textContent = ''; + histogramInfoMax.textContent = ''; + } else { + histogramInfoMin.textContent = formatValue(h.minValue); + histogramInfoMax.textContent = formatValue(h.maxValue); + } + }; + refreshRange(); + + const tick = () => { + if (!splat || this.hidden) return; + const h = hashInputs(inputs); + if (h === lastHash) return; + lastHash = h; + scheduleUpdate(); + }; + + events.on('splat.stateChanged', (splat_: Splat) => { + // only react when the change is for the splat we're currently + // displaying. another splat's stateChanged is irrelevant to this + // histogram. + if (splat_ === splat) { + inputs.stateVersion++; + tick(); + } + }); + + events.on('splat.positionsChanged', (splat_: Splat) => { + if (splat_ === splat) { + inputs.positionsVersion++; + tick(); + } + }); + + events.on('splat.moved', (splat_: Splat) => { + if (splat_ === splat) { + inputs.positionsVersion++; + tick(); + } + }); + + // bump cameraVersion only after the camera has stopped moving for + // CAMERA_SETTLE_MS, so a single drag doesn't spam GPU passes. whether + // the bump triggers a refresh is decided per-prop inside hashInputs. + const CAMERA_SETTLE_MS = 150; + let cameraTimer: number | null = null; + const lastCameraMatrix = new Mat4(); + const clearCameraTimer = () => { + if (cameraTimer !== null) { + clearTimeout(cameraTimer); + cameraTimer = null; + } + }; + events.on('prerender', (cameraMatrix: Mat4) => { + // skip when panel is hidden — no need to schedule a refresh that + // would short-circuit in tick(); also drop any in-flight timer so + // it doesn't fire against a hidden panel. + if (this.hidden) { + clearCameraTimer(); + return; + } + if (!cameraMatrix.equals(lastCameraMatrix)) { + lastCameraMatrix.copy(cameraMatrix); + clearCameraTimer(); + cameraTimer = window.setTimeout(() => { + cameraTimer = null; + inputs.cameraVersion++; + tick(); + }, CAMERA_SETTLE_MS); + } + }); + + onScreenOnlyValue.on('change', () => { + inputs.onScreenOnly = onScreenOnlyValue.value; + tick(); + }); + + const colorEvents = [ + 'splat.tintClr', 'splat.temperature', 'splat.saturation', + 'splat.brightness', 'splat.blackPoint', 'splat.whitePoint', + 'splat.transparency' + ]; + colorEvents.forEach((name) => { + events.on(name, (splat_: Splat) => { + if (splat_ === splat) { + inputs.colorGradeVersion++; + tick(); + } + }); + }); + + events.on('selection.changed', (selection: Element) => { + if (selection instanceof Splat) { + splat = selection; + inputs.splatId = splat.uid; + inputs.mode = propModeFor(selectedDataProp) ?? 0; + populateDataSelector(splat); + tick(); + } + }); + + events.on('statusBar.panelChanged', (panel: string | null) => { + if (panel === 'splatData') { + // defer until panel is visible (this.hidden flips) + requestAnimationFrame(() => { + // panel just became visible; clear the dedupe hash so the + // next tick definitely fires. + lastHash = ''; + tick(); + + // scroll the selected list item into view + const activeItem = dataListBox.dom.querySelector('.data-list-item.active'); + if (activeItem) { + activeItem.scrollIntoView({ block: 'nearest' }); + } + }); + } + }); + + logScaleValue.on('change', () => { + inputs.logScale = logScaleValue.value; + tick(); + }); + + showAllValue.on('change', () => { + if (splat) { + populateDataSelector(splat); + // populateDataSelector may have remapped selectedDataProp when + // the previous one was hidden by toggling extras off; refresh + // the histogram in case inputs.mode changed. + tick(); + } + }); + + // is the user mid-drag? while true, hover updates are suppressed and + // the anchor label remains pinned at the click position. cleared on + // pointerup / cancel. + let dragging = false; + + type Align = 'left' | 'center' | 'right'; + + const applyAlign = (el: HTMLElement, align: Align) => { + el.classList.toggle('align-left', align === 'left'); + el.classList.toggle('align-right', align === 'right'); + }; + + const setLabel = (el: HTMLElement, x: number, value: number, align: Align) => { + el.style.left = `${x}px`; + el.textContent = formatValue(value); + applyAlign(el, align); + }; + + /** Keep cursor readout inside #histogram-info-row horizontal padding. */ + const clampCursorX = (align: Align) => { + const el = histogramInfoCursor; + if (!el.textContent) return; + const w = el.offsetWidth; + const R = histogramInfoRow.clientWidth; + if (w <= 0 || R <= 0) return; + const lo = 4; + const hi = R - 4; + const x = parseFloat(el.style.left); + if (!Number.isFinite(x)) return; + let nx = x; + if (align === 'center') { + const hw = w * 0.5; + nx = hi - lo < w ? (lo + hi) * 0.5 : Math.min(hi - hw, Math.max(lo + hw, x)); + } else if (align === 'left') { + nx = hi - lo < w ? lo : Math.min(hi - w, Math.max(lo, x)); + } else { + // `left` style is the chip's right edge + nx = hi - lo < w ? hi : Math.min(hi, Math.max(lo + w, x)); + } + if (nx !== x) el.style.left = `${nx}px`; + }; + + const setCursorLabel = (x: number, value: number, align: Align) => { + setLabel(histogramInfoCursor, x, value, align); + requestAnimationFrame(() => clampCursorX(align)); + }; + + const setAnchorLabel = (x: number, value: number, align: Align) => { + setLabel(histogramInfoAnchor, x, value, align); + }; + + const clearLabel = (el: HTMLElement) => { + el.textContent = ''; + // reset alignment so the next hover starts from the centered default. + applyAlign(el, 'center'); + }; + + const clearCursorLabel = () => clearLabel(histogramInfoCursor); + const clearAnchorLabel = () => clearLabel(histogramInfoAnchor); + + const showStats = (count: number, selected: number, total: number) => { + const pct = total ? (count / total * 100).toFixed(1) : '0.0'; + const fmt = (n: number) => n.toLocaleString(); + statsCountValue.textContent = `${fmt(count)} (${pct}%)`; + statsSelectedValue.textContent = fmt(selected); + statsOverlay.style.display = ''; + }; + + const hideStats = () => { + statsOverlay.style.display = 'none'; + }; + + histogram.events.on('showOverlay', () => { + // pointermove will populate the readout; nothing else to do here. + }); + + histogram.events.on('hideOverlay', () => { + // pointer left the canvas. only clear the hover UI; if a drag is + // in progress (capture is still active), the highlight handler + // keeps driving the labels. + if (!dragging) { + clearCursorLabel(); + hideStats(); + } + }); + + histogram.events.on('updateOverlay', (info: any) => { + if (dragging) return; // drag handler owns the labels mid-gesture + if (!histogram.histogram.numValues) return; + // continuous (non-bucketed) value at the cursor pixel, centered. + setCursorLabel(info.x, info.cursorValue, 'center'); + showStats(info.selected + info.unselected, info.selected, info.total); + }); + + // highlight + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('id', 'histogram-svg'); + + // create rect element + const rect = document.createElementNS(svg.namespaceURI, 'rect') as SVGRectElement; + rect.setAttribute('id', 'highlight-rect'); + rect.setAttribute('fill', 'rgba(255, 102, 0, 0.2)'); + rect.setAttribute('stroke', '#f60'); + rect.setAttribute('stroke-width', '1'); + rect.setAttribute('stroke-dasharray', '5, 5'); + + svg.appendChild(rect); + histogramCanvasArea.appendChild(svg); + + histogram.events.on('highlight', (info: any) => { + rect.setAttribute('x', info.x.toString()); + rect.setAttribute('y', info.y.toString()); + rect.setAttribute('width', info.width.toString()); + rect.setAttribute('height', info.height.toString()); + + svg.style.display = 'inline'; + dragging = true; + + // anchor and cursor sit at the outer edges of the highlight rect. + // align them so their text grows OUT of the rect: the left-most + // label is right-aligned (text grows left), the right-most label + // is left-aligned (text grows right). dragging-right is the + // default direction (also covers the zero-width click case). + const draggingRight = info.cursorBucket >= info.anchorBucket; + const anchorAlign: Align = draggingRight ? 'right' : 'left'; + const cursorAlign: Align = draggingRight ? 'left' : 'right'; + setAnchorLabel(info.anchorX, info.anchorValue, anchorAlign); + setCursorLabel(info.cursorX, info.cursorValue, cursorAlign); + + // sum stats over the selected bucket range. + const h = histogram.histogram; + let count = 0; + let selected = 0; + for (let i = info.startBucket; i <= info.endBucket; ++i) { + const bin = h.bins[i]; + count += bin.selected + bin.unselected; + selected += bin.selected; + } + showStats(count, selected, h.numValues); + }); + + // aborted drag (pointer released off-canvas / cancelled / capture lost). + // hide the highlight rect and clear the drag labels / stats so the + // readout doesn't stay stuck on a range the user never committed. + histogram.events.on('cancelHighlight', () => { + svg.style.display = 'none'; + dragging = false; + clearAnchorLabel(); + clearCursorLabel(); + hideStats(); + }); + + histogram.events.on('select', (op: string, start: number, end: number) => { + svg.style.display = 'none'; + dragging = false; + clearAnchorLabel(); + // cursor label + stats will repopulate on the next pointermove if + // the pointer is still inside the canvas; clear them now for the + // case where the gesture ended off-canvas. + clearCursorLabel(); + hideStats(); + if (!splat) return; + + // capture state synchronously at drag-end and enqueue the whole + // gpu pass + select fire on the shared command queue. queue ordering + // guarantees this select runs after any in-flight histogram update + // and that any subsequent operation runs after this select's + // edit.add lands in history. no defensive token / target-splat + // checks are needed. + const targetSplat = splat; + const mode = lastGpuMode; + const minValue = histogram.histogram.minValue; + const maxValue = histogram.histogram.maxValue; + const numBins = histogram.histogram.bins.length; + const opts = buildGpuOpts(); + + targetSplat.scene.commandQueue.enqueue(async () => { + const data = await targetSplat.scene.dataProcessor.selectByRange(targetSplat, mode, { + ...opts, + min: minValue, + max: maxValue, + numBins, + rangeStart: start, + rangeEnd: end + }); + // SelectOp (via 'select.mask') consumes the bytes synchronously + // in its constructor, so the buffer is safe to release once + // events.fire returns. + events.fire('select.mask', op, data); + targetSplat.scene.dataProcessor.releaseMask(data); + }); + }); + } +} + +export { DataPanel }; diff --git a/src/ui/editor.ts b/src/ui/editor.ts new file mode 100644 index 0000000..f1b8933 --- /dev/null +++ b/src/ui/editor.ts @@ -0,0 +1,421 @@ +import { Container, Label } from '@playcanvas/pcui'; +import { Mat4 } from 'playcanvas'; + +import { DataPanel } from './data-panel'; +import { Events } from '../events'; +import { AboutPopup } from './about-popup'; +import { BottomToolbar } from './bottom-toolbar'; +import { CameraInfoOverlay } from './camera-info-overlay'; +import { ColorPanel } from './color-panel'; +import { ExportPopup } from './export-popup'; +import { ImageSettingsDialog } from './image-settings-dialog'; +import { i18n } from './localization'; +import { Menu } from './menu'; +import { ModeToggle } from './mode-toggle'; +import logo from './playcanvas-logo.png'; +import { Popup, ShowOptions } from './popup'; +import { Progress } from './progress'; +import { PublishSettingsDialog } from './publish-settings-dialog'; +import { RightToolbar } from './right-toolbar'; +import { ScenePanel } from './scene-panel'; +import { SettingsPanel } from './settings-panel'; +import { ShortcutsPopup } from './shortcuts-popup'; +import { Spinner } from './spinner'; +import { StatusBar } from './status-bar'; +import { TimelinePanel } from './timeline-panel'; +import { Tooltips } from './tooltips'; +import { VideoSettingsDialog } from './video-settings-dialog'; +import { ViewCube } from './view-cube'; +import { version } from '../../package.json'; + +// ts compiler and vscode find this type, but eslint does not +type FilePickerAcceptType = unknown; + +class EditorUI { + appContainer: Container; + topContainer: Container; + canvasContainer: Container; + toolsContainer: Container; + canvas: HTMLCanvasElement; + popup: Popup; + + constructor(events: Events) { + // favicon + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = logo; + document.head.appendChild(link); + + // app + const appContainer = new Container({ + id: 'app-container' + }); + + // editor + const editorContainer = new Container({ + id: 'editor-container' + }); + + // tooltips container + const tooltipsContainer = new Container({ + id: 'tooltips-container' + }); + + // top container + const topContainer = new Container({ + id: 'top-container' + }); + + // canvas + const canvas = document.createElement('canvas'); + canvas.id = 'canvas'; + + // app label + const appLabel = new Label({ + id: 'app-label', + text: `SUPERSPLAT v${version}` + }); + + // canvas container + const canvasContainer = new Container({ + id: 'canvas-container' + }); + + // tools container + const toolsContainer = new Container({ + id: 'tools-container' + }); + + // tooltips + const tooltips = new Tooltips(); + tooltipsContainer.append(tooltips); + + // bottom toolbar + const scenePanel = new ScenePanel(events, tooltips); + const settingsPanel = new SettingsPanel(events, tooltips); + const colorPanel = new ColorPanel(events, tooltips); + const bottomToolbar = new BottomToolbar(events, tooltips); + const rightToolbar = new RightToolbar(events, tooltips); + const modeToggle = new ModeToggle(events, tooltips); + const menu = new Menu(events); + const cameraInfoOverlay = new CameraInfoOverlay(events, tooltips); + + canvasContainer.dom.appendChild(canvas); + canvasContainer.append(appLabel); + canvasContainer.append(cameraInfoOverlay); + canvasContainer.append(toolsContainer); + canvasContainer.append(scenePanel); + canvasContainer.append(settingsPanel); + canvasContainer.append(colorPanel); + canvasContainer.append(bottomToolbar); + canvasContainer.append(rightToolbar); + canvasContainer.append(modeToggle); + canvasContainer.append(menu); + + // view axes container + const viewCube = new ViewCube(events); + canvasContainer.append(viewCube); + events.on('prerender', (cameraMatrix: Mat4) => { + viewCube.update(cameraMatrix); + }); + + // main container + const mainContainer = new Container({ + id: 'main-container' + }); + + const timelinePanel = new TimelinePanel(events, tooltips); + const dataPanel = new DataPanel(events, tooltips); + const statusBar = new StatusBar(events, tooltips); + + timelinePanel.hidden = true; + + mainContainer.append(canvasContainer); + mainContainer.append(timelinePanel); + mainContainer.append(dataPanel); + mainContainer.append(statusBar); + + // Wire up status bar panel toggles + events.on('statusBar.panelChanged', (panel: string | null) => { + timelinePanel.hidden = panel !== 'timeline'; + dataPanel.hidden = panel !== 'splatData'; + }); + + editorContainer.append(mainContainer); + + // message popup + const popup = new Popup(tooltips); + + // shortcuts popup + const shortcutsPopup = new ShortcutsPopup(events); + + // export popup + const exportPopup = new ExportPopup(events); + + // publish settings + const publishSettingsDialog = new PublishSettingsDialog(events); + + // image settings + const imageSettingsDialog = new ImageSettingsDialog(events); + + // video settings + const videoSettingsDialog = new VideoSettingsDialog(events); + + // about popup + const aboutPopup = new AboutPopup(); + + topContainer.append(popup); + topContainer.append(exportPopup); + topContainer.append(publishSettingsDialog); + topContainer.append(imageSettingsDialog); + topContainer.append(videoSettingsDialog); + topContainer.append(shortcutsPopup); + topContainer.append(aboutPopup); + + appContainer.append(editorContainer); + appContainer.append(topContainer); + appContainer.append(tooltipsContainer); + + this.appContainer = appContainer; + this.topContainer = topContainer; + this.canvasContainer = canvasContainer; + this.toolsContainer = toolsContainer; + this.canvas = canvas; + this.popup = popup; + + document.body.appendChild(appContainer.dom); + document.body.setAttribute('tabIndex', '-1'); + + events.on('show.shortcuts', () => { + shortcutsPopup.hidden = false; + }); + + events.function('show.exportPopup', (exportType, splatNames: [string], showFilenameEdit: boolean) => { + return exportPopup.show(exportType, splatNames, showFilenameEdit); + }); + + events.function('show.publishSettingsDialog', async () => { + // show popup if user isn't logged in + const userStatus = await events.invoke('publish.userStatus'); + if (!userStatus) { + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('popup.error'), + message: i18n.t('popup.publish.please-log-in') + }); + return false; + } + + // get user publish settings + const publishSettings = await publishSettingsDialog.show(userStatus); + + // do publish + if (publishSettings) { + await events.invoke('scene.publish', publishSettings); + } + }); + + events.function('show.imageSettingsDialog', async () => { + const imageSettings = await imageSettingsDialog.show(); + + if (imageSettings) { + try { + let writable; + let fileHandle: FileSystemFileHandle | undefined; + + if (window.showSaveFilePicker) { + fileHandle = await window.showSaveFilePicker({ + id: 'SuperSplatImageFileExport', + types: [{ + description: 'WebP Image', + accept: { 'image/webp': ['.webp'] } + }], + suggestedName: `${events.invoke('render.baseFilename')}.webp` + }); + + writable = await fileHandle.createWritable(); + } + + const result = await events.invoke('render.image', imageSettings, writable); + + // if the render failed, remove the empty file left on disk + if (result === false && fileHandle?.remove) { + await fileHandle.remove(); + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + // user cancelled save dialog + return; + } + + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('panel.render.failed'), + message: `'${error.message ?? error}'` + }); + } + } + }); + + events.function('show.videoSettingsDialog', async () => { + const videoSettings = await videoSettingsDialog.show(); + + if (videoSettings) { + + try { + // Determine file extension and mime type based on format + let fileExtension: string; + let filePickerTypes: FilePickerAcceptType[]; + + // Codec name mapping for display + const codecNames: Record = { + 'h264': 'H.264', + 'h265': 'H.265', + 'vp9': 'VP9', + 'av1': 'AV1' + }; + const codecName = codecNames[videoSettings.codec] || videoSettings.codec.toUpperCase(); + + if (videoSettings.format === 'webm') { + fileExtension = '.webm'; + filePickerTypes = [{ + description: `WebM Video (${codecName})`, + accept: { 'video/webm': ['.webm'] } + }]; + } else if (videoSettings.format === 'mov') { + fileExtension = '.mov'; + filePickerTypes = [{ + description: `MOV Video (${codecName})`, + accept: { 'video/quicktime': ['.mov'] } + }]; + } else if (videoSettings.format === 'mkv') { + fileExtension = '.mkv'; + filePickerTypes = [{ + description: `MKV Video (${codecName})`, + accept: { 'video/x-matroska': ['.mkv'] } + }]; + } else { + fileExtension = '.mp4'; + filePickerTypes = [{ + description: `MP4 Video (${codecName})`, + accept: { 'video/mp4': ['.mp4'] } + }]; + } + + const suggested = `${events.invoke('render.baseFilename')}${fileExtension}`; + + let writable; + let fileHandle: FileSystemFileHandle | undefined; + + if (window.showSaveFilePicker) { + fileHandle = await window.showSaveFilePicker({ + id: 'SuperSplatVideoFileExport', + types: filePickerTypes, + suggestedName: suggested + }); + + writable = await fileHandle.createWritable(); + } + + const result = await events.invoke('render.video', videoSettings, writable); + + // if the render was cancelled, remove the empty file left on disk + if (result === false && fileHandle?.remove) { + await fileHandle.remove(); + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + // user cancelled save dialog + return; + } + + await events.invoke('showPopup', { + type: 'error', + header: i18n.t('panel.render.failed'), + message: `'${error.message ?? error}'` + }); + } + } + }); + + events.on('show.about', () => { + aboutPopup.hidden = false; + }); + + events.function('showPopup', (options: ShowOptions) => { + return this.popup.show(options); + }); + + // spinner with reference counting to handle nested operations + const spinner = new Spinner(); + topContainer.append(spinner); + + let spinnerCount = 0; + + events.on('startSpinner', () => { + spinnerCount++; + if (spinnerCount === 1) { + spinner.hidden = false; + } + }); + + events.on('stopSpinner', () => { + spinnerCount = Math.max(0, spinnerCount - 1); + if (spinnerCount === 0) { + spinner.hidden = true; + } + }); + + // progress + + const progress = new Progress(); + + topContainer.append(progress); + + events.on('progressStart', (header: string, cancellable?: boolean) => { + progress.hidden = false; + progress.setHeader(header); + progress.setText(''); + progress.setProgress(0); + progress.showCancelButton(!!cancellable); + progress.onCancel = cancellable ? () => events.fire('progressCancel') : null; + }); + + events.on('progressUpdate', (options: { text?: string, progress?: number }) => { + if (options.text !== undefined) { + progress.setText(options.text); + } + if (options.progress !== undefined) { + progress.setProgress(options.progress); + } + }); + + events.on('progressEnd', () => { + progress.hidden = true; + progress.showCancelButton(false); + progress.onCancel = null; + }); + + // initialize canvas to correct size before creating graphics device etc + const pixelRatio = window.devicePixelRatio; + canvas.width = Math.ceil(canvasContainer.dom.offsetWidth * pixelRatio); + canvas.height = Math.ceil(canvasContainer.dom.offsetHeight * pixelRatio); + + ['contextmenu', 'gesturestart', 'gesturechange', 'gestureend'].forEach((event) => { + document.addEventListener(event, (e) => { + e.preventDefault(); + }, true); + }); + + // whenever the canvas container is clicked, set keyboard focus on the body + canvasContainer.dom.addEventListener('pointerdown', (event: PointerEvent) => { + // set focus on the body if user is busy pressing on the canvas or a child of the tools + // element + if (event.target === canvas || toolsContainer.dom.contains(event.target as Node)) { + document.body.focus(); + } + }, true); + } +} + +export { EditorUI }; diff --git a/src/ui/export-popup.ts b/src/ui/export-popup.ts new file mode 100644 index 0000000..2d9c420 --- /dev/null +++ b/src/ui/export-popup.ts @@ -0,0 +1,585 @@ +import { BooleanInput, Button, ColorPicker, Container, Element, Label, SelectInput, SliderInput, TextInput } from '@playcanvas/pcui'; + +import { Pose } from '../camera-poses'; +import { i18n } from './localization'; +import { Events } from '../events'; +import { ExportType, SceneExportOptions } from '../file-handler'; +import { AnimTrack, ExperienceSettings, defaultPostEffectSettings } from '../splat-serialize'; +import sceneExport from './svg/export.svg'; + +const createSvg = (svgString: string, args = {}) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new Element({ + dom: new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement, + ...args + }); +}; + +const removeKnownExtension = (filename: string) => { + // remove known extensions (ordered from longest to shortest for compound extensions) + const knownExtensions = [ + '.compressed.ply', + '.ksplat', + '.splat', + '.html', + '.ply', + '.sog', + '.spz', + '.lcc', + '.zip' + ]; + + for (let i = 0; i < knownExtensions.length; ++i) { + const ext = knownExtensions[i]; + if (filename.endsWith(ext)) { + return filename.slice(0, -ext.length); + } + } + + return filename; +}; + +class ExportPopup extends Container { + show: (exportType: ExportType, splatNames: string[], showFilenameEdit: boolean) => Promise; + hide: () => void; + destroy: () => void; + + constructor(events: Events, args = {}) { + args = { + id: 'export-popup', + hidden: true, + tabIndex: -1, + ...args + }; + + super(args); + + // UI + + const dialog = new Container({ + id: 'dialog' + }); + + // header + + const header = new Container({ + id: 'header' + }); + + const headerText = new Label({ + id: 'header' + }); + i18n.bindText(headerText, 'popup.export.header'); + + header.append(createSvg(sceneExport, { + id: 'icon' + })); + + header.append(headerText); + + // content + + const content = new Container({ id: 'content' }); + + // type + + const viewerTypeRow = new Container({ + class: 'row' + }); + + const viewerTypeLabel = new Label({ + class: 'label' + }); + i18n.bindText(viewerTypeLabel, 'popup.export.type'); + + const viewerTypeSelect = new SelectInput({ + class: 'select', + defaultValue: 'html' + }); + i18n.bindOptions(viewerTypeSelect, () => [ + { v: 'html', t: i18n.t('popup.export.html') }, + { v: 'zip', t: i18n.t('popup.export.package') } + ]); + + viewerTypeRow.append(viewerTypeLabel); + viewerTypeRow.append(viewerTypeSelect); + + // viewer: animation + + const animationLabel = new Label({ class: 'label' }); + i18n.bindText(animationLabel, 'popup.export.animation'); + const animationToggle = new BooleanInput({ class: 'boolean', type: 'toggle', value: false }); + const animationRow = new Container({ class: 'row' }); + animationRow.append(animationLabel); + animationRow.append(animationToggle); + + // viewer: loop mode + + const loopLabel = new Label({ class: 'label' }); + i18n.bindText(loopLabel, 'popup.export.loop-mode'); + const loopSelect = new SelectInput({ + class: 'select', + defaultValue: 'repeat' + }); + i18n.bindOptions(loopSelect, () => [ + { v: 'none', t: i18n.t('popup.export.loop-mode.none') }, + { v: 'repeat', t: i18n.t('popup.export.loop-mode.repeat') }, + { v: 'pingpong', t: i18n.t('popup.export.loop-mode.pingpong') } + ]); + const loopRow = new Container({ class: 'row' }); + loopRow.append(loopLabel); + loopRow.append(loopSelect); + + // viewer: clear color + + const colorRow = new Container({ + class: 'row' + }); + + const colorLabel = new Label({ + class: 'label' + }); + i18n.bindText(colorLabel, 'popup.export.background-color'); + + const colorPicker = new ColorPicker({ + class: 'color-picker', + value: [1, 1, 1, 1] + }); + + colorRow.append(colorLabel); + colorRow.append(colorPicker); + + // viewer: fov + + const fovRow = new Container({ + class: 'row' + }); + + const fovLabel = new Label({ + class: 'label' + }); + i18n.bindText(fovLabel, 'popup.export.fov'); + + const fovSlider = new SliderInput({ + class: 'slider', + min: 10, + max: 120, + precision: 0, + value: 60 + }); + + fovRow.append(fovLabel); + fovRow.append(fovSlider); + + // compress + + const compressRow = new Container({ + class: 'row' + }); + + const compressLabel = new Label({ + class: 'label' + }); + i18n.bindText(compressLabel, 'popup.export.compress-ply'); + + const compressBoolean = new BooleanInput({ + class: 'boolean', + type: 'toggle' + }); + + compressRow.append(compressLabel); + compressRow.append(compressBoolean); + + // spherical harmonic bands + + const bandsRow = new Container({ + class: 'row' + }); + + const bandsLabel = new Label({ + class: 'label' + }); + i18n.bindText(bandsLabel, 'popup.export.sh-bands'); + + const bandsSlider = new SliderInput({ + class: 'slider', + min: 0, + max: 3, + precision: 0, + value: 3 + }); + + bandsRow.append(bandsLabel); + bandsRow.append(bandsSlider); + + // sog iterations + + const iterationsRow = new Container({ + class: 'row' + }); + + const iterationsLabel = new Label({ + class: 'label' + }); + i18n.bindText(iterationsLabel, 'popup.export.iterations'); + + const iterationsSlider = new SliderInput({ + class: 'slider', + min: 1, + max: 20, + precision: 0, + value: 10 + }); + + iterationsRow.append(iterationsLabel); + iterationsRow.append(iterationsSlider); + + // spz version + + const spzVersionRow = new Container({ + class: 'row' + }); + + const spzVersionLabel = new Label({ + class: 'label' + }); + i18n.bindText(spzVersionLabel, 'popup.export.spz-version'); + + const spzVersionSelect = new SelectInput({ + class: 'select', + defaultValue: '4' + }); + i18n.bindOptions(spzVersionSelect, () => [ + { v: '4', t: i18n.t('popup.export.spz-version.4') }, + { v: '3', t: i18n.t('popup.export.spz-version.3') } + ]); + + spzVersionRow.append(spzVersionLabel); + spzVersionRow.append(spzVersionSelect); + + // filename + + const filenameRow = new Container({ + class: 'row' + }); + + const filenameLabel = new Label({ + class: 'label' + }); + i18n.bindText(filenameLabel, 'popup.export.filename'); + + const filenameEntry = new TextInput({ + class: 'text-input' + }); + + filenameRow.append(filenameLabel); + filenameRow.append(filenameEntry); + + // content + + content.append(viewerTypeRow); + content.append(animationRow); + content.append(loopRow); + content.append(colorRow); + content.append(fovRow); + content.append(compressRow); + content.append(bandsRow); + content.append(iterationsRow); + content.append(spzVersionRow); + content.append(filenameRow); + + // footer + + const footer = new Container({ id: 'footer' }); + + const cancelButton = new Button({ + class: 'button' + }); + i18n.bindText(cancelButton, 'popup.cancel'); + + const exportButton = new Button({ + class: 'button' + }); + i18n.bindText(exportButton, 'popup.export'); + + footer.append(cancelButton); + footer.append(exportButton); + + dialog.append(header); + dialog.append(content); + dialog.append(footer); + + this.append(dialog); + + // handlers + + let onCancel: () => void; + let onExport: () => void; + + cancelButton.on('click', () => onCancel()); + exportButton.on('click', () => onExport()); + + const keydown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onCancel(); + break; + case 'Enter': + if (!e.shiftKey) onExport(); + break; + default: + e.stopPropagation(); + break; + } + }; + + const updateExtension = (ext: string) => { + filenameEntry.value = removeKnownExtension(filenameEntry.value) + ext; + }; + + compressBoolean.on('change', () => { + updateExtension(compressBoolean.value ? '.compressed.ply' : '.ply'); + }); + + viewerTypeSelect.on('change', () => { + updateExtension(viewerTypeSelect.value === 'html' ? '.html' : '.zip'); + }); + + animationToggle.on('change', (value: boolean) => { + loopSelect.enabled = value; + }); + + const reset = (exportType: ExportType, splatNames: string[], hasPoses: boolean) => { + const allRows = [ + viewerTypeRow, animationRow, loopRow, colorRow, fovRow, compressRow, bandsRow, iterationsRow, spzVersionRow, filenameRow + ]; + + const activeRows = { + ply: [compressRow, bandsRow, filenameRow], + splat: [filenameRow], + sog: [bandsRow, iterationsRow, filenameRow], + spz: [bandsRow, spzVersionRow, filenameRow], + viewer: [viewerTypeRow, animationRow, loopRow, colorRow, fovRow, bandsRow, filenameRow] + }[exportType]; + + allRows.forEach((r) => { + r.hidden = activeRows.indexOf(r) === -1; + }); + + bandsSlider.value = events.invoke('view.bands'); + + // ply + compressBoolean.value = false; + + // sog + iterationsSlider.value = 10; + + // spz + spzVersionSelect.value = '4'; + + // filename + filenameEntry.value = splatNames[0]; + switch (exportType) { + case 'ply': + updateExtension('.ply'); + break; + case 'splat': + updateExtension('.splat'); + break; + case 'sog': + updateExtension('.sog'); + break; + case 'spz': + updateExtension('.spz'); + break; + case 'viewer': + updateExtension(viewerTypeSelect.value === 'html' ? '.html' : '.zip'); + break; + } + + // viewer + const bgClr = events.invoke('bgClr'); + + animationToggle.value = hasPoses; + animationToggle.enabled = hasPoses; + loopSelect.value = 'repeat'; + loopSelect.enabled = hasPoses; + + colorPicker.value = [bgClr.r, bgClr.g, bgClr.b]; + + fovSlider.value = events.invoke('camera.fov'); + }; + + this.show = (exportType: ExportType, splatNames: string[], showFilenameEdit: boolean) => { + const frames = events.invoke('timeline.frames'); + const frameRate = events.invoke('timeline.frameRate'); + const smoothness = events.invoke('timeline.smoothness'); + const orderedPoses = (events.invoke('camera.poses') as Pose[]) + .slice() + .filter(p => p.frame >= 0 && p.frame < frames) + .sort((a, b) => a.frame - b.frame); + + reset(exportType, splatNames, orderedPoses.length > 0); + + // filename is only shown in safari where file picker is not supported + filenameRow.hidden = !showFilenameEdit; + + this.hidden = false; + this.dom.addEventListener('keydown', keydown); + this.dom.focus(); + + const assemblePlyOptions = () : SceneExportOptions => { + return { + filename: filenameEntry.value, + splatIdx: 'all', + serializeSettings: { + maxSHBands: bandsSlider.value + }, + compressedPly: compressBoolean.value + }; + }; + + const assembleSplatOptions = () : SceneExportOptions => { + return { + filename: filenameEntry.value, + splatIdx: 'all', + serializeSettings: { } + }; + }; + + const assembleSogOptions = () : SceneExportOptions => { + return { + filename: filenameEntry.value, + splatIdx: 'all', + serializeSettings: { + maxSHBands: bandsSlider.value + }, + sogIterations: iterationsSlider.value + }; + }; + + const assembleSpzOptions = () : SceneExportOptions => { + return { + filename: filenameEntry.value, + splatIdx: 'all', + serializeSettings: { + maxSHBands: bandsSlider.value + }, + spzVersion: spzVersionSelect.value === '3' ? 3 : 4 + }; + }; + + const assembleViewerOptions = () : SceneExportOptions => { + const fov = fovSlider.value; + + // use current viewport as start pose + const pose = events.invoke('camera.getPose'); + const p = pose?.position; + const t = pose?.target; + const cameras = (p && t) ? [{ + initial: { + position: [p.x, p.y, p.z] as [number, number, number], + target: [t.x, t.y, t.z] as [number, number, number], + fov + } + }] : []; + + const includeAnimation = animationToggle.value; + const animTracks: AnimTrack[] = []; + + if (includeAnimation && orderedPoses.length > 0) { + const times: number[] = []; + const position: number[] = []; + const target: number[] = []; + const fovKeys: number[] = []; + for (let i = 0; i < orderedPoses.length; ++i) { + const op = orderedPoses[i]; + times.push(op.frame); + position.push(op.position.x, op.position.y, op.position.z); + target.push(op.target.x, op.target.y, op.target.z); + fovKeys.push(op.fov ?? fov); + } + + animTracks.push({ + name: 'cameraAnim', + duration: frames / frameRate, + frameRate, + loopMode: loopSelect.value as 'none' | 'repeat' | 'pingpong', + interpolation: 'spline', + smoothness, + keyframes: { + times, + values: { position, target, fov: fovKeys } + } + }); + } + + const bgColor = colorPicker.value.slice(0, 3) as [number, number, number]; + + const experienceSettings: ExperienceSettings = { + version: 2, + tonemapping: 'none', + highPrecisionRendering: false, + background: { color: bgColor }, + postEffectSettings: defaultPostEffectSettings, + animTracks, + cameras, + annotations: [], + startMode: includeAnimation ? 'animTrack' : 'default' + }; + + return { + filename: filenameEntry.value, + splatIdx: 'all', + serializeSettings: { + maxSHBands: bandsSlider.value + }, + viewerExportSettings: { + type: viewerTypeSelect.value, + experienceSettings + } + }; + }; + + return new Promise((resolve) => { + onCancel = () => { + resolve(null); + }; + + onExport = () => { + switch (exportType) { + case 'ply': + resolve(assemblePlyOptions()); + break; + case 'splat': + resolve(assembleSplatOptions()); + break; + case 'sog': + resolve(assembleSogOptions()); + break; + case 'spz': + resolve(assembleSpzOptions()); + break; + case 'viewer': + resolve(assembleViewerOptions()); + break; + } + }; + }).finally(() => { + this.dom.removeEventListener('keydown', keydown); + this.hide(); + }); + }; + + this.hide = () => { + this.hidden = true; + }; + + this.destroy = () => { + this.hide(); + super.destroy(); + }; + } +} + +export { ExportPopup }; diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts new file mode 100644 index 0000000..01c9daa --- /dev/null +++ b/src/ui/histogram.ts @@ -0,0 +1,298 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; +import { applyOpCursor } from './select-cursor'; + +class HistogramData { + bins: { selected: number, unselected: number }[]; + numValues: number; + minValue: number; + maxValue: number; + + constructor(numBins: number) { + this.bins = []; + for (let i = 0; i < numBins; ++i) { + this.bins.push({ selected: 0, unselected: 0 }); + } + this.numValues = 0; + this.minValue = 0; + this.maxValue = 0; + } + + bucketValue(bucket: number) { + return this.minValue + bucket * this.bucketSize; + } + + get bucketSize() { + return (this.maxValue - this.minValue) / this.bins.length; + } + + valueToBucket(value: number) { + const n = this.minValue === this.maxValue ? 0 : (value - this.minValue) / (this.maxValue - this.minValue); + return Math.min(this.bins.length - 1, Math.floor(n * this.bins.length)); + } +} + +interface SetDataOptions { + selected: Float32Array; // length = numBins + unselected: Float32Array; // length = numBins + min: number; + max: number; + numValues: number; + logScale?: boolean +} + +class Histogram { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + histogram: HistogramData; + pixelData: ImageData; + events = new Events(); + + constructor(numBins: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.setAttribute('id', 'histogram-canvas'); + canvas.width = numBins; + canvas.height = height; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + const context = canvas.getContext('2d'); + context.globalCompositeOperation = 'copy'; + + context.fillStyle = 'black'; + context.fillRect(0, 0, canvas.width, canvas.height); + + this.canvas = canvas; + this.context = context; + this.histogram = new HistogramData(numBins); + this.pixelData = context.createImageData(canvas.width, canvas.height); + + let dragging = false; + let dragStart = 0; + let dragEnd = 0; + let activePointerId = -1; + let hovering = false; + + const offsetToBucket = (offset: number) => { + const rect = this.canvas.getBoundingClientRect(); + const bins = this.histogram.bins.length; + return Math.max(0, Math.min(bins - 1, Math.floor((offset - rect.left) / rect.width * bins))); + }; + + const bucketToOffset = (bucket: number) => { + const rect = this.canvas.getBoundingClientRect(); + return bucket / this.histogram.bins.length * rect.width; + }; + + const updateHighlight = () => { + const rect = this.canvas.getBoundingClientRect(); + const h = this.histogram; + const bins = h.bins.length; + const start = Math.min(dragStart, dragEnd); + const end = Math.max(dragStart, dragEnd); + // anchorEdge / cursorEdge are bucket-boundary indices in [0, bins]. + // they identify the OUTER edges of the highlight rect: anchorEdge + // is the side closest to the click, cursorEdge is the side closest + // to the live pointer. when dragging right (or zero-width), anchor + // is the left edge of dragStart and cursor is the right edge of + // dragEnd; reversed when dragging left. + const draggingRight = dragEnd >= dragStart; + const anchorEdge = draggingRight ? dragStart : dragStart + 1; + const cursorEdge = draggingRight ? dragEnd + 1 : dragEnd; + const edgeX = (i: number) => i / bins * rect.width; + const edgeValue = (i: number) => h.minValue + i * h.bucketSize; + this.events.fire('highlight', { + x: bucketToOffset(start), + y: 0, + width: (end - start + 1) / bins * rect.width, + height: rect.height, + startBucket: start, + endBucket: end, + anchorBucket: dragStart, + cursorBucket: dragEnd, + anchorX: edgeX(anchorEdge), + cursorX: edgeX(cursorEdge), + anchorValue: edgeValue(anchorEdge), + cursorValue: edgeValue(cursorEdge) + }); + }; + + // unify drag-end behavior so pointerup commits and pointercancel / + // lostpointercapture abort without leaving `dragging` stuck true. all + // three event paths funnel here, so the SVG highlight rect cannot be + // orphaned by a missed pointerup (e.g. release off-canvas, alt-tab, + // OS modal interrupt). + const endDrag = (commit: boolean, shiftKey = false, ctrlKey = false) => { + if (!dragging) return; + dragging = false; + if (activePointerId !== -1) { + try { + this.canvas.releasePointerCapture(activePointerId); + } catch { + // capture may already be lost (the very thing we're + // recovering from); swallow. + } + activePointerId = -1; + } + if (commit) { + const op = opFromModifiers({ shiftKey, ctrlKey }); + this.events.fire('select', op, Math.min(dragStart, dragEnd), Math.max(dragStart, dragEnd)); + } else { + this.events.fire('cancelHighlight'); + } + }; + + this.canvas.addEventListener('pointerdown', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const h = this.histogram; + + if (h.numValues) { + this.canvas.setPointerCapture(e.pointerId); + activePointerId = e.pointerId; + dragging = true; + dragStart = dragEnd = offsetToBucket(e.clientX); + updateHighlight(); + } + }); + + this.canvas.addEventListener('pointerup', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + endDrag(true, e.shiftKey, e.ctrlKey); + }); + + // pointercancel signals user intent to abort (OS modal, alt-tab, + // multi-touch, etc.). pointer is gone, nothing to commit. + this.canvas.addEventListener('pointercancel', () => endDrag(false)); + + // lostpointercapture is informational, not a user-intent signal. in the + // normal pointerup flow it fires AFTER our pointerup handler has run, by + // which time `dragging` is false and endDrag short-circuits. when it + // fires mid-drag without a prior pointerup (Chrome occasionally reorders + // these around DOM mutation / extension-injected events), the prior + // behaviour was to silently abort — which produces the "click sometimes + // doesn't register" symptom. commit instead, using the modifier state + // on the event so add/remove/set are preserved. + this.canvas.addEventListener('lostpointercapture', (e: PointerEvent) => { + endDrag(true, e.shiftKey, e.ctrlKey); + }); + + this.canvas.addEventListener('pointermove', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const h = this.histogram; + + if (h.numValues) { + if (dragging) { + dragEnd = offsetToBucket(e.clientX); + updateHighlight(); + } + + const rect = this.canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + const binIndex = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); + const bin = h.bins[binIndex]; + + // continuous (non-bucketed) value at the cursor's pixel x. + // x is already clamped to [0, 1] above. + const cursorValue = h.minValue + x * (h.maxValue - h.minValue); + + this.events.fire('updateOverlay', { + x: e.offsetX, + y: e.offsetY, + bucketIndex: binIndex, + value: h.bucketValue(binIndex), + size: h.bucketSize, + cursorValue, + selected: bin.selected, + unselected: bin.unselected, + total: h.numValues + }); + } + }); + + // keep the cursor showing the op the modifiers will apply (add/remove/ + // intersect) while hovering the interactive histogram, matching the + // selection tools. numValues gates it so an empty histogram is unaffected. + const syncCursor = (e: { shiftKey: boolean, ctrlKey: boolean }) => { + if (hovering) { + applyOpCursor(this.canvas, this.histogram.numValues ? opFromModifiers(e) : 'set'); + } + }; + + this.canvas.addEventListener('pointerenter', (e: PointerEvent) => { + hovering = true; + this.events.fire('showOverlay'); + syncCursor(e); + }); + + this.canvas.addEventListener('pointerleave', () => { + hovering = false; + this.events.fire('hideOverlay'); + applyOpCursor(this.canvas, 'set'); + }); + + // capture phase so a modifier press updates the cursor even while another + // element holds focus; blur clears because a key release while unfocused + // never fires. (mirrors the selection-tool cursor and controllers.ts) + window.addEventListener('keydown', e => syncCursor(e), { capture: true }); + window.addEventListener('keyup', e => syncCursor(e), { capture: true }); + window.addEventListener('blur', () => applyOpCursor(this.canvas, 'set')); + } + + private render(logScale: boolean) { + const canvas = this.canvas; + const context = this.context; + const pixelData = this.pixelData; + const pixels = new Uint32Array(pixelData.data.buffer); + + const binMap = logScale ? (x: number) => Math.log(x + 1) : (x: number) => x; + const bins = this.histogram.bins.map((v) => { + return { + selected: binMap(v.unselected + v.selected), + unselected: binMap(v.unselected) + }; + }); + const binMax = bins.reduce((a, v) => Math.max(a, v.selected), 0); + + let i = 0; + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < bins.length; x++) { + const bin = bins[x]; + const targetMin = binMax / canvas.height * (canvas.height - 1 - y); + + if (targetMin >= bin.selected) { + pixels[i++] = 0xff000000; + } else { + const targetMax = targetMin + binMax / canvas.height; + if (bin.selected === bin.unselected || targetMax < bin.unselected) { + pixels[i++] = 0xffff7777; + } else { + pixels[i++] = 0xff00ffff; + } + } + } + } + + context.putImageData(pixelData, 0, 0); + } + + setData(options: SetDataOptions) { + const bins = this.histogram.bins; + const n = Math.min(bins.length, options.selected.length); + for (let i = 0; i < n; i++) { + bins[i].selected = options.selected[i]; + bins[i].unselected = options.unselected[i]; + } + this.histogram.numValues = options.numValues; + this.histogram.minValue = options.min; + this.histogram.maxValue = options.max; + this.render(options.logScale); + } +} + +export { Histogram }; diff --git a/src/ui/image-settings-dialog.ts b/src/ui/image-settings-dialog.ts new file mode 100644 index 0000000..af23707 --- /dev/null +++ b/src/ui/image-settings-dialog.ts @@ -0,0 +1,295 @@ +import { BooleanInput, Button, Container, Element, Label, NumericInput, SelectInput, VectorInput } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ImageSettings } from '../render'; +import { i18n } from './localization'; +import sceneExport from './svg/export.svg'; + +const createSvg = (svgString: string, args = {}) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new Element({ + dom: new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement, + ...args + }); +}; + +class ImageSettingsDialog extends Container { + show: () => Promise; + hide: () => void; + destroy: () => void; + + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'image-settings-dialog', + class: 'settings-dialog', + hidden: true, + tabIndex: -1 + }; + + super(args); + + const dialog = new Container({ + id: 'dialog' + }); + + // header + + const headerIcon = createSvg(sceneExport, { id: 'icon' }); + const headerText = new Label({ id: 'text' }); + i18n.bindText(headerText, () => i18n.t('popup.render-image.header').toUpperCase()); + const header = new Container({ id: 'header' }); + header.append(headerIcon); + header.append(headerText); + + // projection + + const projectionLabel = new Label({ class: 'label' }); + i18n.bindText(projectionLabel, 'popup.render-image.projection'); + const projectionSelect = new SelectInput({ + class: 'select', + defaultValue: 'standard' + }); + i18n.bindOptions(projectionSelect, () => [ + { v: 'standard', t: i18n.t('popup.render-image.projection-standard') }, + { v: 'equirect', t: i18n.t('popup.render-image.projection-360') } + ]); + const projectionRow = new Container({ class: 'row' }); + projectionRow.append(projectionLabel); + projectionRow.append(projectionSelect); + + // preset + + // 360 output is 2:1 equirectangular, capped at 4096 wide to stay + // within common encoder dimension limits (mirrors video's presets) + const buildPresetOptions = () => { + return projectionSelect.value === 'equirect' ? [ + { v: '360-1k', t: '1024x512' }, + { v: '360-2k', t: '2048x1024' }, + { v: '360-4k', t: '3840x1920' }, + { v: '360-4096', t: '4096x2048' }, + { v: 'custom', t: i18n.t('popup.render-image.resolution-custom') } + ] : [ + { v: 'viewport', t: i18n.t('popup.render-image.resolution-current') }, + { v: 'HD', t: 'HD' }, + { v: 'QHD', t: 'QHD' }, + { v: '4K', t: '4K' }, + { v: 'custom', t: i18n.t('popup.render-image.resolution-custom') } + ]; + }; + + const presetLabel = new Label({ class: 'label' }); + i18n.bindText(presetLabel, 'popup.render-image.preset'); + const presetSelect = new SelectInput({ + class: 'select', + defaultValue: 'viewport' + }); + i18n.bindOptions(presetSelect, buildPresetOptions); + const presetRow = new Container({ class: 'row' }); + presetRow.append(presetLabel); + presetRow.append(presetSelect); + + // resolution + + const resolutionLabel = new Label({ class: 'label' }); + i18n.bindText(resolutionLabel, 'popup.render-image.resolution'); + const resolutionValue = new VectorInput({ + class: 'vector-input', + dimensions: 2, + min: 4, + max: 16000, + precision: 0, + value: [1024, 768] + }); + const resolutionRow = new Container({ class: 'row', enabled: false }); + resolutionRow.append(resolutionLabel); + resolutionRow.append(resolutionValue); + + // level horizon (360 only) + + const levelHorizonLabel = new Label({ class: 'label' }); + i18n.bindText(levelHorizonLabel, 'popup.render-image.level-horizon'); + const levelHorizonBoolean = new BooleanInput({ class: 'boolean', value: true }); + const levelHorizonRow = new Container({ class: 'row' }); + levelHorizonRow.append(levelHorizonLabel); + levelHorizonRow.append(levelHorizonBoolean); + + // hidden until 360 projection is selected + levelHorizonRow.hidden = true; + + // transparent background + + const transparentBgLabel = new Label({ class: 'label' }); + i18n.bindText(transparentBgLabel, 'popup.render-image.transparent-bg'); + const transparentBgBoolean = new BooleanInput({ class: 'boolean', value: false }); + const transparentBgRow = new Container({ class: 'row' }); + transparentBgRow.append(transparentBgLabel); + transparentBgRow.append(transparentBgBoolean); + + // show debug overlays + + const showDebugLabel = new Label({ class: 'label' }); + i18n.bindText(showDebugLabel, 'popup.render-image.show-debug'); + const showDebugBoolean = new BooleanInput({ class: 'boolean', value: false }); + const showDebugRow = new Container({ class: 'row' }); + showDebugRow.append(showDebugLabel); + showDebugRow.append(showDebugBoolean); + + // content + + const content = new Container({ id: 'content' }); + content.append(projectionRow); + content.append(presetRow); + content.append(resolutionRow); + content.append(transparentBgRow); + content.append(showDebugRow); + content.append(levelHorizonRow); + + // footer + + const footer = new Container({ id: 'footer' }); + + const cancelButton = new Button({ + class: 'button' + }); + i18n.bindText(cancelButton, 'panel.render.cancel'); + + const okButton = new Button({ + class: 'button' + }); + i18n.bindText(okButton, 'panel.render.ok'); + + footer.append(cancelButton); + footer.append(okButton); + + dialog.append(header); + dialog.append(content); + dialog.append(footer); + + this.append(dialog); + + let targetSize: { width: number, height: number }; + + // Handle custom resolution activation + + const updateResolution = () => { + const widths: Record = { + 'viewport': targetSize.width, + 'HD': 1920, + 'QHD': 2560, + '4K': 3840, + '360-1k': 1024, + '360-2k': 2048, + '360-4k': 3840, + '360-4096': 4096 + }; + + const heights: Record = { + 'viewport': targetSize.height, + 'HD': 1080, + 'QHD': 1440, + '4K': 2160, + '360-1k': 512, + '360-2k': 1024, + '360-4k': 1920, + '360-4096': 2048 + }; + + resolutionValue.value = [ + widths[presetSelect.value] ?? resolutionValue.value[0], + heights[presetSelect.value] ?? resolutionValue.value[1] + ]; + }; + + presetSelect.on('change', () => { + resolutionRow.enabled = presetSelect.value === 'custom'; + + if (presetSelect.value !== 'custom') { + updateResolution(); + } + }); + + // sync the ui to the selected projection: 360 renders are 2:1 + // equirectangular without debug overlays, and gain a level horizon toggle + const syncProjection = () => { + const is360 = projectionSelect.value === 'equirect'; + presetSelect.options = buildPresetOptions(); + presetSelect.value = is360 ? '360-4k' : 'viewport'; + resolutionRow.enabled = presetSelect.value === 'custom'; + updateResolution(); + showDebugRow.hidden = is360; + levelHorizonRow.hidden = !is360; + }; + + projectionSelect.on('change', syncProjection); + + // handle key bindings for enter and escape + + let onCancel: () => void; + let onOK: () => void; + + cancelButton.on('click', () => onCancel()); + okButton.on('click', () => onOK()); + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } + }; + + // reset UI and configure for current state + const reset = () => { + updateResolution(); + }; + + // function implementations + + this.show = () => { + targetSize = events.invoke('targetSize'); + + reset(); + + this.hidden = false; + document.addEventListener('keydown', keydown); + this.dom.focus(); + + return new Promise((resolve) => { + onCancel = () => { + resolve(null); + }; + + onOK = () => { + const [width, height] = resolutionValue.value; + const is360 = projectionSelect.value === 'equirect'; + + const imageSettings = { + width, + height, + transparentBg: transparentBgBoolean.value, + showDebug: !is360 && showDebugBoolean.value, + projection: (is360 ? 'equirect' : 'standard') as 'standard' | 'equirect', + levelHorizon: is360 && levelHorizonBoolean.value + }; + + resolve(imageSettings); + }; + }).finally(() => { + document.removeEventListener('keydown', keydown); + this.hide(); + }); + }; + + this.hide = () => { + this.hidden = true; + }; + + this.destroy = () => { + this.hide(); + super.destroy(); + }; + } +} + +export { ImageSettingsDialog }; diff --git a/src/ui/localization.ts b/src/ui/localization.ts new file mode 100644 index 0000000..a415e56 --- /dev/null +++ b/src/ui/localization.ts @@ -0,0 +1,173 @@ +import i18next from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import Backend from 'i18next-http-backend'; + +interface LocalizeOptions { + ellipsis?: boolean; +} + +// minimal shapes the binders operate on (pcui elements emit a 'destroy' event) +type Destroyable = { once?: (event: string, fn: () => void) => void }; +type TextElement = { text: string } & Destroyable; +type Option = { v: any, t: string }; +type SelectElement = { value: any, options: Option[] } & Destroyable; + +/** + * Wraps i18next and adds reactive localization: UI strings bound through the + * helpers below re-translate live when the language changes, with no page + * reload. A single shared instance is exported as {@link i18n}. + * + * IMPORTANT: any persistent UI string must be registered via bindText / + * bindOptions / onChange, or it will NOT update when the user switches language. + * Plain {@link Localization.t} is only correct for strings re-evaluated at call + * time (e.g. popup messages built when shown). + */ +class Localization { + /** + * Supported languages. `name` is the NATIVE name so a user who can't read + * the current UI language can still recognise their own. Single source of + * truth for both i18next init and the language selector. + */ + readonly languages = [ + { code: 'en', name: 'English' }, + { code: 'de', name: 'Deutsch' }, + { code: 'es', name: 'Español' }, + { code: 'fr', name: 'Français' }, + { code: 'ja', name: '日本語' }, + { code: 'ko', name: '한국어' }, + { code: 'pt-BR', name: 'Português (Brasil)' }, + { code: 'ru', name: 'Русский' }, + { code: 'zh-CN', name: '中文 (简体)' } + ]; + + private updaters = new Set<() => void>(); + + // localStorage key i18next's detector reads. We write it ONLY on an explicit + // user choice (see setLanguage), never auto-cached, so its presence reliably + // means "the user pinned a language" rather than "detected this time". + private readonly storageKey = 'i18nextLng'; + + /** Initialise i18next. Call once at startup, before building the UI. */ + init() { + i18next.on('languageChanged', () => this.updaters.forEach(fn => fn())); + + return i18next + .use(Backend) + .use(LanguageDetector) + .init({ + detection: { + // `querystring` (?lng=) wins so shareable links keep working. + // `localStorage` holds an EXPLICIT user choice and takes + // precedence over the browser locale. `navigator` is the default + // when nothing is stored. caches:[] disables i18next's automatic + // write-back so a stored value only ever means an explicit pick + // (setLanguage manages the key itself). + order: ['querystring', 'localStorage', 'navigator', 'htmlTag'], + caches: [] + }, + backend: { + loadPath: './static/locales/{{lng}}.json' + }, + supportedLngs: this.languages.map(l => l.code), + fallbackLng: 'en', + interpolation: { + escapeValue: false + } + }); + } + + /** Translate a key. */ + t(key: string, options?: LocalizeOptions): string { + let text = i18next.t(key); + + if (options?.ellipsis) text += '...'; + + return text; + } + + /** The active language code (e.g. 'en', 'pt-BR'). */ + get locale(): string { + return i18next.language || 'en'; + } + + /** + * The explicitly chosen language code, or null when on "Automatic" + * (detection-driven). Use this for the selector's default so an untouched + * setting reads "Automatic" rather than the detected language. + */ + get storedLanguage(): string | null { + return localStorage.getItem(this.storageKey); + } + + /** + * Switch language live (no reload). Pass a language code to pin it + * explicitly (persisted to localStorage), or null for "Automatic" — forgets + * the stored choice and reverts to the browser-detected language. + */ + setLanguage(code: string | null): Promise { + if (code === null) { + localStorage.removeItem(this.storageKey); + const detected = i18next.services.languageDetector?.detect(); + const detectedLng = Array.isArray(detected) ? detected[0] : detected; + return i18next.changeLanguage(detectedLng || 'en'); + } + localStorage.setItem(this.storageKey, code); + return i18next.changeLanguage(code); + } + + /** + * Run `update` now and on every subsequent language change. Auto-removed + * when the owning element is destroyed (any pcui Element, which emits + * 'destroy'). Returns an unbind function for manual removal. + */ + onChange(update: () => void, owner?: Destroyable): () => void { + update(); + this.updaters.add(update); + const unbind = () => this.updaters.delete(update); + owner?.once?.('destroy', unbind); + return unbind; + } + + /** + * Bind an element's `.text` to a localization key OR a builder function. + * Works for both Label and Button (Button shares the `.text` setter) and any + * element exposing `.text`. A string key covers the common case; a builder + * covers composed/transformed text (e.g. .toUpperCase(), shortcut tooltips). + */ + bindText(el: TextElement, key: string | (() => string), options?: LocalizeOptions): () => void { + return this.onChange(() => { + el.text = typeof key === 'function' ? key() : this.t(key, options); + }, el); + } + + /** Bind a SelectInput's `.options` to a builder, preserving the current value. */ + bindOptions(el: SelectElement, build: () => Option[]): () => void { + return this.onChange(() => { + const value = el.value; + el.options = build(); + el.value = value; + }, el); + } + + /** Locale-aware integer formatting. */ + formatInteger(value: number): string { + return new Intl.NumberFormat(this.locale, { + maximumFractionDigits: 0 + }).format(Math.round(value)); + } + + // Spaces inside "( … )" would otherwise allow awkward wraps (e.g. "Camera (" + // on one line and "V )" on the next). NBSP keeps the shortcut group intact; + // the normal space before '(' still allows a wrap before the parenthetical. + formatTooltipWithShortcut(label: string, shortcut: string): string { + if (!shortcut) { + return label; + } + return `${label} (\u00A0${shortcut}\u00A0)`; + } +} + +const i18n = new Localization(); + +export { i18n }; +export type { LocalizeOptions }; diff --git a/src/ui/menu-panel.ts b/src/ui/menu-panel.ts new file mode 100644 index 0000000..c517223 --- /dev/null +++ b/src/ui/menu-panel.ts @@ -0,0 +1,239 @@ +import { Container, Element, Label } from '@playcanvas/pcui'; + +import { i18n } from './localization'; + +type Direction = 'left' | 'right' | 'top' | 'bottom'; + +type MenuItem = { + // a resolver (() => string) makes the row re-localize on language change + text?: string | (() => string); + icon?: string | Element; + extra?: string | Element; + subMenu?: MenuPanel; + + isEnabled?: () => boolean | Promise; + isVisible?: () => boolean | Promise; + onSelect?: () => any; +}; + +const offsetParent = (elem: HTMLElement) : HTMLElement => { + const parent = elem.parentNode as HTMLElement; + + return (parent.tagName === 'BODY' || window.getComputedStyle(parent).position !== 'static') ? + parent : + offsetParent(parent); +}; + +const arrange = (element: HTMLElement, target: HTMLElement, direction: Direction, padding: number) => { + const rect = target.getBoundingClientRect(); + const parentRect = offsetParent(element).getBoundingClientRect(); + + const style = element.style; + switch (direction) { + case 'left': + break; + case 'right': + style.left = `${rect.right - parentRect.left + padding}px`; + style.top = `${rect.top - parentRect.top}px`; + break; + case 'top': + break; + case 'bottom': + style.left = `${rect.left - parentRect.left}px`; + style.top = `${rect.bottom - parentRect.top + padding}px`; + break; + } +}; + +const isString = (value: any) => { + return !value || typeof value === 'string' || value instanceof String; +}; + +const createIcon = (icon: string | Element) => { + return isString(icon) ? + new Label({ class: 'menu-row-icon', text: icon && String.fromCodePoint(parseInt(icon as string, 16)) }) : + icon; +}; + +// create the row text label; if `text` is a resolver, bind it so the row +// re-localizes when the language changes (auto-unbinds on label destroy) +const createTextLabel = (text: string | (() => string)) => { + const label = new Label({ class: 'menu-row-text' }); + i18n.bindText(label, text); + return label; +}; + +// Detect if we're on a touch device +const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + +class MenuPanel extends Container { + parentPanel: MenuPanel | null = null; + menuItems: MenuItem[] = []; + + constructor(menuItems: MenuItem[], args = {}) { + args = { + ...args, + class: 'menu-panel', + hidden: true + }; + + super(args); + + this.on('hide', () => { + for (const menuItem of this.menuItems) { + if (menuItem.subMenu) { + menuItem.subMenu.hidden = true; + } + } + }); + + this.on('show', async () => { + for (let i = 0; i < this.menuItems.length; i++) { + const menuItem = this.menuItems[i]; + if (menuItem.isEnabled) { + this.dom.children.item(i).ui.enabled = await menuItem.isEnabled(); + } + if (menuItem.isVisible) { + this.dom.children.item(i).ui.hidden = !(await menuItem.isVisible()); + } + } + }); + + this.setItems(menuItems); + } + + setItems(menuItems: MenuItem[]) { + this.menuItems = menuItems; + this.clear(); + + for (const menuItem of menuItems) { + const type = menuItem.subMenu ? 'menu' : menuItem.text ? 'button' : 'separator'; + + let row: Container | null = null; + let activate: () => void | null = null; + let deactivate: () => void | null = null; + switch (type) { + case 'button': { + row = new Container({ class: 'menu-row' }); + const icon = createIcon(menuItem.icon); + const text = createTextLabel(menuItem.text); + const postscript = isString(menuItem.extra) ? new Label({ class: 'menu-row-postscript', text: menuItem.extra as string }) : menuItem.extra; + row.append(icon); + row.append(text); + row.append(postscript); + + break; + } + + case 'menu': { + row = new Container({ class: 'menu-row' }); + const icon = createIcon(menuItem.icon); + const text = createTextLabel(menuItem.text); + const postscript = new Label({ class: 'menu-row-postscript', text: '\u232A' }); + row.append(icon); + row.append(text); + row.append(postscript); + + // set parent panel + menuItem.subMenu.parentPanel = this; + + const childPanel = menuItem.subMenu; + if (childPanel) { + activate = () => { + if (childPanel.hidden) { + childPanel.position(row.dom, 'right', 2); + childPanel.hidden = !childPanel.hidden; + } + + deactivate = () => { + childPanel.hidden = true; + }; + }; + } + + break; + } + + case 'separator': + this.append(new Container({ class: 'menu-row-separator' })); + break; + } + + if (row) { + let timer = -1; + + // For desktop: use hover behavior + if (!isTouchDevice) { + row.dom.addEventListener('pointerenter', () => { + timer = window.setTimeout(() => { + if (deactivate) { + deactivate(); + } + if (activate) { + activate(); + } + }, 250); + }); + + row.dom.addEventListener('pointerleave', () => { + if (timer !== -1) { + clearTimeout(timer); + timer = -1; + } + }); + } + + row.dom.addEventListener('pointerdown', (event: PointerEvent) => { + event.stopPropagation(); + }); + + row.dom.addEventListener('pointerup', (event: PointerEvent) => { + event.stopPropagation(); + + if (!row.disabled) { + // Handle submenu items differently on touch devices + if (menuItem.subMenu) { + if (isTouchDevice) { + // On touch devices: tap to open/close submenu + if (menuItem.subMenu.hidden) { + // Close other submenus in this panel first + if (deactivate) { + deactivate(); + } + if (activate) { + activate(); + } + } else { + // Close the submenu if it's already open + menuItem.subMenu.hidden = true; + } + } + // On desktop, submenus are handled by hover, so don't close the root panel + } else if (menuItem.onSelect) { + // Regular menu item: execute action and close menu + this.rootPanel.hidden = true; + menuItem.onSelect(); + } + } + }); + + this.append(row); + } + } + } + + get rootPanel() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let panel: MenuPanel = this; + while (panel.parentPanel) { + panel = panel.parentPanel; + } + return panel; + } + + position(parent: HTMLElement, direction: Direction, padding = 2) { + arrange(this.dom, parent, direction, padding); + } +} + +export { MenuItem, MenuPanel }; diff --git a/src/ui/menu.ts b/src/ui/menu.ts new file mode 100644 index 0000000..fe78210 --- /dev/null +++ b/src/ui/menu.ts @@ -0,0 +1,426 @@ +import { Container, Element, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { recentFiles } from '../recent-files'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; +import { MenuPanel, MenuItem } from './menu-panel'; +import arrowSvg from './svg/arrow.svg'; +import collapseSvg from './svg/collapse.svg'; +import selectDelete from './svg/delete.svg'; +import editRedo from './svg/edit-redo.svg'; +import editUndo from './svg/edit-undo.svg'; +import sceneExport from './svg/export.svg'; +import sceneImport from './svg/import.svg'; +import sceneNew from './svg/new.svg'; +import sceneOpen from './svg/open.svg'; +import scenePublish from './svg/publish.svg'; +import sceneSave from './svg/save.svg'; +import selectAll from './svg/select-all.svg'; +import selectDuplicate from './svg/select-duplicate.svg'; +import selectInverse from './svg/select-inverse.svg'; +import selectLock from './svg/select-lock.svg'; +import selectNone from './svg/select-none.svg'; +import selectSeparate from './svg/select-separate.svg'; +import selectUnlock from './svg/select-unlock.svg'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new Element({ + dom: new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement + }); +}; + +const getOpenRecentItems = async (events: Events) => { + const files = await recentFiles.get(); + const items: MenuItem[] = files.map((file) => { + return { + text: file.name, + onSelect: () => events.invoke('doc.openRecent', file.handle) + }; + }); + + if (items.length > 0) { + items.push({}); // separator + items.push({ + text: () => i18n.t('menu.file.open-recent.clear'), + icon: createSvg(selectDelete), + onSelect: () => recentFiles.clear() + }); + } + + return items; +}; + +class Menu extends Container { + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'menu' + }; + + super(args); + + const menubar = new Container({ + id: 'menu-bar' + }); + + menubar.dom.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + }); + + const scene = new Label({ + class: 'menu-option' + }); + i18n.bindText(scene, 'menu.file'); + + const edit = new Label({ + class: 'menu-option' + }); + i18n.bindText(edit, 'menu.edit'); + + const render = new Label({ + class: 'menu-option' + }); + i18n.bindText(render, 'menu.render'); + + const selection = new Label({ + class: 'menu-option' + }); + i18n.bindText(selection, 'menu.select'); + + const help = new Label({ + class: 'menu-option' + }); + i18n.bindText(help, 'menu.help'); + + const toggleCollapsed = () => { + document.body.classList.toggle('collapsed'); + }; + + // collapse menu on mobile + if (document.body.clientWidth < 600) { + toggleCollapsed(); + } + + const collapse = createSvg(collapseSvg); + collapse.dom.classList.add('menu-icon'); + collapse.dom.setAttribute('id', 'menu-collapse'); + collapse.dom.addEventListener('click', toggleCollapsed); + + const arrow = createSvg(arrowSvg); + arrow.dom.classList.add('menu-icon'); + arrow.dom.setAttribute('id', 'menu-arrow'); + arrow.dom.addEventListener('click', toggleCollapsed); + + const buttonsContainer = new Container({ + id: 'menu-bar-options' + }); + buttonsContainer.append(scene); + buttonsContainer.append(edit); + buttonsContainer.append(selection); + buttonsContainer.append(render); + buttonsContainer.append(help); + buttonsContainer.append(collapse); + buttonsContainer.append(arrow); + + menubar.append(buttonsContainer); + + // Get the shortcut manager for displaying keyboard shortcuts + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + + const exportMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.file.export.ply'), + icon: createSvg(sceneExport), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('scene.export', 'ply') + }, { + text: () => i18n.t('menu.file.export.splat'), + icon: createSvg(sceneExport), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('scene.export', 'splat') + }, { + text: () => i18n.t('menu.file.export.sog'), + icon: createSvg(sceneExport), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('scene.export', 'sog') + }, { + text: () => i18n.t('menu.file.export.spz'), + icon: createSvg(sceneExport), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('scene.export', 'spz') + }, { + // separator + }, { + text: () => i18n.t('menu.file.export.viewer', { ellipsis: true }), + icon: createSvg(sceneExport), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('scene.export', 'viewer') + }]); + + const openRecentMenuPanel = new MenuPanel([]); + + const fileMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.file.new'), + icon: createSvg(sceneNew), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: () => events.invoke('doc.new') + }, { + text: () => i18n.t('menu.file.open'), + icon: createSvg(sceneOpen), + onSelect: async () => { + await events.invoke('doc.open'); + } + }, { + text: () => i18n.t('menu.file.open-recent'), + icon: createSvg(sceneOpen), + subMenu: openRecentMenuPanel, + isEnabled: async () => { + // refresh open recent menu items when the parent menu is opened + try { + const items = await getOpenRecentItems(events); + openRecentMenuPanel.setItems(items); + return items.length > 0; + } catch (error) { + console.error('Failed to load recent files:', error); + return false; + } + } + }, { + // separator + }, { + text: () => i18n.t('menu.file.save'), + icon: createSvg(sceneSave), + isEnabled: () => events.invoke('doc.name'), + onSelect: async () => await events.invoke('doc.save') + }, { + text: () => i18n.t('menu.file.save-as', { ellipsis: true }), + icon: createSvg(sceneSave), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: async () => await events.invoke('doc.saveAs') + }, { + // separator + }, { + text: () => i18n.t('menu.file.import', { ellipsis: true }), + icon: createSvg(sceneImport), + onSelect: async () => { + await events.invoke('scene.import'); + } + }, { + text: () => i18n.t('menu.file.export'), + icon: createSvg(sceneExport), + subMenu: exportMenuPanel + }, { + text: () => i18n.t('menu.file.publish', { ellipsis: true }), + icon: createSvg(scenePublish), + isEnabled: () => !events.invoke('scene.empty'), + onSelect: async () => await events.invoke('show.publishSettingsDialog') + }]); + + // track undo/redo availability for menu item enablement + let canUndo = false; + let canRedo = false; + events.on('edit.canUndo', (value: boolean) => { + canUndo = value; + }); + events.on('edit.canRedo', (value: boolean) => { + canRedo = value; + }); + + const editMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.edit.undo'), + icon: createSvg(editUndo), + extra: shortcutManager.formatShortcut('edit.undo'), + isEnabled: () => canUndo, + onSelect: () => events.fire('edit.undo') + }, { + text: () => i18n.t('menu.edit.redo'), + icon: createSvg(editRedo), + extra: shortcutManager.formatShortcut('edit.redo'), + isEnabled: () => canRedo, + onSelect: () => events.fire('edit.redo') + }, { + // separator + }, { + text: () => i18n.t('menu.edit.duplicate'), + icon: createSvg(selectDuplicate), + isEnabled: () => events.invoke('selection.splats'), + onSelect: () => events.fire('edit.duplicate') + }, { + text: () => i18n.t('menu.edit.separate'), + icon: createSvg(selectSeparate), + isEnabled: () => events.invoke('selection.splats'), + onSelect: () => events.fire('edit.separate') + }]); + + const selectionMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.select.all'), + icon: createSvg(selectAll), + extra: shortcutManager.formatShortcut('select.all'), + onSelect: () => events.fire('select.all') + }, { + text: () => i18n.t('menu.select.none'), + icon: createSvg(selectNone), + extra: shortcutManager.formatShortcut('select.none'), + onSelect: () => events.fire('select.none') + }, { + text: () => i18n.t('menu.select.invert'), + icon: createSvg(selectInverse), + extra: shortcutManager.formatShortcut('select.invert'), + onSelect: () => events.fire('select.invert') + }, { + // separator + }, { + text: () => i18n.t('menu.select.lock'), + icon: createSvg(selectLock), + extra: shortcutManager.formatShortcut('select.hide'), + isEnabled: () => events.invoke('selection.splats'), + onSelect: () => events.fire('select.hide') + }, { + text: () => i18n.t('menu.select.unlock'), + icon: createSvg(selectUnlock), + extra: shortcutManager.formatShortcut('select.unhide'), + onSelect: () => events.fire('select.unhide') + }, { + text: () => i18n.t('menu.select.delete'), + icon: createSvg(selectDelete), + extra: shortcutManager.formatShortcut('select.delete'), + isEnabled: () => events.invoke('selection.splats'), + onSelect: () => events.fire('select.delete') + }, { + text: () => i18n.t('menu.select.reset'), + onSelect: () => events.fire('scene.reset') + }]); + + const renderMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.render.image', { ellipsis: true }), + icon: createSvg(sceneExport), + onSelect: async () => await events.invoke('show.imageSettingsDialog') + }, { + text: () => i18n.t('menu.render.video', { ellipsis: true }), + icon: createSvg(sceneExport), + onSelect: async () => await events.invoke('show.videoSettingsDialog') + }]); + + const videoTutorialsMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.help.video-tutorials.basics'), + icon: 'E261', + onSelect: () => window.open('https://youtu.be/MwzaEM2I55I', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.video-tutorials.in-depth'), + icon: 'E261', + onSelect: () => window.open('https://youtu.be/J37rTieKgJ8', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.video-tutorials.deleting-floaters'), + icon: 'E261', + onSelect: () => window.open('https://youtu.be/8qaLfwkkSdU', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.video-tutorials.scaling'), + icon: 'E261', + onSelect: () => window.open('https://youtu.be/fRK1vVMg_EU', '_blank')?.focus() + }]); + + const helpMenuPanel = new MenuPanel([{ + text: () => i18n.t('menu.help.video-tutorials'), + icon: 'E261', + subMenu: videoTutorialsMenuPanel + }, { + text: () => i18n.t('menu.help.user-guide'), + icon: 'E232', + onSelect: () => window.open('https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.shortcuts'), + icon: 'E136', + onSelect: () => events.fire('show.shortcuts') + }, { + // separator + }, { + text: () => i18n.t('menu.help.discord'), + icon: 'E233', + onSelect: () => window.open('https://discord.gg/T3pnhRTTAY', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.forum'), + icon: 'E432', + onSelect: () => window.open('https://forum.playcanvas.com', '_blank')?.focus() + }, { + // separator + }, { + text: () => i18n.t('menu.help.github-repo'), + icon: 'E259', + onSelect: () => window.open('https://github.com/playcanvas/supersplat', '_blank')?.focus() + }, { + text: () => i18n.t('menu.help.log-issue'), + icon: 'E336', + onSelect: () => window.open('https://github.com/playcanvas/supersplat/issues', '_blank')?.focus() + }, { + // separator + }, { + text: () => i18n.t('menu.help.about'), + icon: 'E138', + onSelect: () => events.fire('show.about') + }]); + + this.append(menubar); + this.append(fileMenuPanel); + this.append(openRecentMenuPanel); + this.append(exportMenuPanel); + this.append(editMenuPanel); + this.append(selectionMenuPanel); + this.append(renderMenuPanel); + this.append(videoTutorialsMenuPanel); + this.append(helpMenuPanel); + + const options: { dom: HTMLElement, menuPanel: MenuPanel }[] = [{ + dom: scene.dom, + menuPanel: fileMenuPanel + }, { + dom: edit.dom, + menuPanel: editMenuPanel + }, { + dom: selection.dom, + menuPanel: selectionMenuPanel + }, { + dom: render.dom, + menuPanel: renderMenuPanel + }, { + dom: help.dom, + menuPanel: helpMenuPanel + }]; + + options.forEach((option) => { + const activate = () => { + option.menuPanel.position(option.dom, 'bottom', 2); + options.forEach((opt) => { + opt.menuPanel.hidden = opt !== option; + }); + }; + + option.dom.addEventListener('pointerdown', (event: PointerEvent) => { + if (!option.menuPanel.hidden) { + option.menuPanel.hidden = true; + } else { + activate(); + } + }); + + option.dom.addEventListener('pointerenter', (event: PointerEvent) => { + if (!options.every(opt => opt.menuPanel.hidden)) { + activate(); + } + }); + }); + + const checkEvent = (event: PointerEvent) => { + if (!this.dom.contains(event.target as Node)) { + options.forEach((opt) => { + opt.menuPanel.hidden = true; + }); + } + }; + + window.addEventListener('pointerdown', checkEvent, true); + window.addEventListener('pointerup', checkEvent, true); + } +} + +export { Menu }; diff --git a/src/ui/mode-toggle.ts b/src/ui/mode-toggle.ts new file mode 100644 index 0000000..ed54498 --- /dev/null +++ b/src/ui/mode-toggle.ts @@ -0,0 +1,64 @@ +import { Container, Element, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { i18n } from './localization'; +import centersSvg from './svg/centers.svg'; +import ringsSvg from './svg/rings.svg'; +import { Tooltips } from './tooltips'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement; +}; + +class ModeToggle extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + id: 'mode-toggle', + class: 'centers-mode', + ...args + }; + + super(args); + + const centersIcon = new Element({ + id: 'centers-icon', + dom: createSvg(centersSvg) + }); + + const ringsIcon = new Element({ + id: 'rings-icon', + dom: createSvg(ringsSvg) + }); + + const centersText = new Label({ + id: 'centers-text' + }); + i18n.bindText(centersText, 'panel.mode.centers'); + + const ringsText = new Label({ + id: 'rings-text' + }); + i18n.bindText(ringsText, 'panel.mode.rings'); + + this.append(centersIcon); + this.append(ringsIcon); + this.append(centersText); + this.append(ringsText); + + this.dom.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + events.fire('camera.toggleMode'); + events.fire('camera.setOverlay', true); + }); + + events.on('camera.mode', (mode: string) => { + this.class[mode === 'centers' ? 'add' : 'remove']('centers-mode'); + this.class[mode === 'rings' ? 'add' : 'remove']('rings-mode'); + }); + + tooltips.register(this, () => i18n.t('tooltip.right-toolbar.splat-mode')); + } +} + +export { ModeToggle }; diff --git a/src/ui/playcanvas-logo.png b/src/ui/playcanvas-logo.png new file mode 100644 index 0000000..a1ff05f Binary files /dev/null and b/src/ui/playcanvas-logo.png differ diff --git a/src/ui/popup.ts b/src/ui/popup.ts new file mode 100644 index 0000000..c7bd1e8 --- /dev/null +++ b/src/ui/popup.ts @@ -0,0 +1,197 @@ +import { Button, Container, Label, TextInput } from '@playcanvas/pcui'; + +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +interface ShowOptions { + type: 'error' | 'info' | 'yesno' | 'okcancel'; + message: string; + header?: string; + link?: string; +} + +class Popup extends Container { + show: (options: ShowOptions) => void; + hide: () => void; + destroy: () => void; + + constructor(tooltips: Tooltips, args = {}) { + args = { + id: 'popup', + hidden: true, + tabIndex: -1, + ...args + }; + + super(args); + + const dialog = new Container({ + id: 'popup-dialog' + }); + + const header = new Label({ + id: 'popup-header' + }); + + const text = new Label({ + id: 'popup-text' + }); + + const linkText = new Label({ + id: 'popup-link-text' + }); + + const linkCopy = new Button({ + id: 'popup-link-copy', + icon: 'E351' + }); + + const linkRow = new Container({ + id: 'popup-link-row' + }); + + linkRow.append(linkText); + linkRow.append(linkCopy); + + const okButton = new Button({ + class: 'popup-button' + }); + i18n.bindText(okButton, 'popup.ok'); + + const cancelButton = new Button({ + class: 'popup-button' + }); + i18n.bindText(cancelButton, 'popup.cancel'); + + const yesButton = new Button({ + class: 'popup-button' + }); + i18n.bindText(yesButton, 'popup.yes'); + + const noButton = new Button({ + class: 'popup-button' + }); + i18n.bindText(noButton, 'popup.no'); + + const buttons = new Container({ + id: 'popup-buttons' + }); + + buttons.append(okButton); + buttons.append(cancelButton); + buttons.append(yesButton); + buttons.append(noButton); + + dialog.append(header); + dialog.append(text); + dialog.append(linkRow); + dialog.append(buttons); + + this.append(dialog); + + let okFn: () => void; + let cancelFn: () => void; + let yesFn: () => void; + let noFn: () => void; + let containerFn: () => void; + let copyFn: () => void; + + okButton.on('click', () => { + okFn(); + }); + + cancelButton.on('click', () => { + cancelFn(); + }); + + yesButton.on('click', () => { + yesFn(); + }); + + noButton.on('click', () => { + noFn(); + }); + + this.on('click', () => { + containerFn(); + }); + + dialog.on('click', (event) => { + event.stopPropagation(); + }); + + linkCopy.on('click', () => { + copyFn(); + }); + + this.show = (options: ShowOptions) => { + header.text = options.header; + text.text = options.message; + + const { type, link } = options; + + ['error', 'info', 'yesno', 'okcancel'].forEach((t) => { + text.class[t === type ? 'add' : 'remove'](t); + }); + + // configure based on message type + okButton.hidden = type === 'yesno'; + cancelButton.hidden = type !== 'okcancel'; + yesButton.hidden = type !== 'yesno'; + noButton.hidden = type !== 'yesno'; + this.hidden = false; + + linkRow.hidden = link === undefined; + if (link !== undefined) { + linkText.dom.innerHTML = `${link}`; + linkCopy.icon = 'E352'; + } + + // take keyboard focus so shortcuts stop working + this.dom.focus(); + + return new Promise<{action: string, value?: string}>((resolve) => { + okFn = () => { + this.hide(); + resolve({ + action: 'ok' + }); + }; + cancelFn = () => { + this.hide(); + resolve({ action: 'cancel' }); + }; + yesFn = () => { + this.hide(); + resolve({ action: 'yes' }); + }; + noFn = () => { + this.hide(); + resolve({ action: 'no' }); + }; + containerFn = () => { + if (type === 'info' && link === undefined) { + cancelFn(); + } + }; + copyFn = () => { + navigator.clipboard.writeText(link); + linkCopy.icon = 'E348'; + }; + }); + }; + + this.hide = () => { + this.hidden = true; + }; + + this.destroy = () => { + this.hide(); + super.destroy(); + }; + + tooltips.register(linkCopy, () => i18n.t('popup.copy-to-clipboard')); + } +} + +export { ShowOptions, Popup }; diff --git a/src/ui/progress.ts b/src/ui/progress.ts new file mode 100644 index 0000000..ea9881c --- /dev/null +++ b/src/ui/progress.ts @@ -0,0 +1,89 @@ +import { Button, Container, Element, Label } from '@playcanvas/pcui'; + +import { i18n } from './localization'; + +class Progress extends Container { + setHeader: (headerText: string) => void; + setText: (text: string) => void; + setProgress: (progress: number) => void; + showCancelButton: (show: boolean) => void; + onCancel: (() => void) | null; + + constructor(args = {}) { + args = { + ...args, + id: 'progress-container', + hidden: true + }; + + super(args); + + this.onCancel = null; + + this.dom.tabIndex = 0; + + const header = new Label({ + id: 'header' + }); + + const text = new Element({ + dom: 'div', + id: 'text' + }); + + const bar = new Element({ + dom: 'div', + id: 'bar', + class: 'pulsate' + }); + + const cancelButton = new Button({ + id: 'cancel-button', + hidden: true + }); + i18n.bindText(cancelButton, 'panel.render.cancel'); + + cancelButton.on('click', () => { + if (this.onCancel) this.onCancel(); + }); + + const content = new Container({ + id: 'content' + }); + content.append(text); + content.append(bar); + content.append(cancelButton); + + const dialog = new Container({ + id: 'dialog' + }); + + dialog.append(header); + dialog.append(content); + this.append(dialog); + + this.dom.addEventListener('keydown', (event) => { + if (this.hidden) return; + event.stopPropagation(); + event.preventDefault(); + }); + + this.setHeader = (headerMsg: string) => { + header.text = headerMsg; + }; + + this.setText = (textMsg: string) => { + text.dom.textContent = textMsg; + }; + + this.setProgress = (progress: number) => { + bar.dom.style.backgroundImage = `linear-gradient(90deg, #F60 0%, #F60 ${progress}%, #00000000 ${progress}%, #00000000 100%)`; + }; + + this.showCancelButton = (show: boolean) => { + cancelButton.hidden = !show; + }; + } +} + +export { Progress }; diff --git a/src/ui/publish-settings-dialog.ts b/src/ui/publish-settings-dialog.ts new file mode 100644 index 0000000..de88bb5 --- /dev/null +++ b/src/ui/publish-settings-dialog.ts @@ -0,0 +1,426 @@ +import { BooleanInput, Button, ColorPicker, Container, Element, Label, SelectInput, SliderInput, TextAreaInput, TextInput } from '@playcanvas/pcui'; + +import { Pose } from '../camera-poses'; +import { Events } from '../events'; +import { i18n } from './localization'; +import { PublishSettings, UserStatus } from '../publish'; +import { AnimTrack, ExperienceSettings, defaultPostEffectSettings } from '../splat-serialize'; +import sceneExport from './svg/export.svg'; + +const createSvg = (svgString: string, args = {}) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new Element({ + dom: new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement, + ...args + }); +}; + +class PublishSettingsDialog extends Container { + show: (userStatus: UserStatus) => Promise; + hide: () => void; + destroy: () => void; + + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'publish-settings-dialog', + class: 'settings-dialog', + hidden: true, + tabIndex: -1 + }; + + super(args); + + const dialog = new Container({ + id: 'dialog' + }); + + // header + + const headerIcon = createSvg(sceneExport, { id: 'icon' }); + const headerText = new Label({ id: 'text' }); + i18n.bindText(headerText, 'popup.publish.header'); + const header = new Container({ id: 'header' }); + header.append(headerIcon); + header.append(headerText); + + // overwrite + + const overwriteLabel = new Label({ class: 'label' }); + i18n.bindText(overwriteLabel, 'popup.publish.to'); + const overwriteSelect = new SelectInput({ + class: 'select' + }); + + const overwriteRow = new Container({ class: 'row' }); + overwriteRow.append(overwriteLabel); + overwriteRow.append(overwriteSelect); + + // title + + const titleLabel = new Label({ class: 'label' }); + i18n.bindText(titleLabel, 'popup.publish.title'); + const titleInput = new TextInput({ class: 'text-input' }); + const titleRow = new Container({ class: 'row' }); + titleRow.append(titleLabel); + titleRow.append(titleInput); + + // description + + const descLabel = new Label({ class: 'label' }); + i18n.bindText(descLabel, 'popup.publish.description'); + const descInput = new TextAreaInput({ class: 'text-area' }); + const descRow = new Container({ class: 'row' }); + descRow.append(descLabel); + descRow.append(descInput); + + // override model + + const overrideModelLabel = new Label({ class: 'label' }); + i18n.bindText(overrideModelLabel, 'popup.publish.override-model'); + const overrideModelToggle = new BooleanInput({ class: 'boolean', type: 'toggle', value: true }); + const overrideModelRow = new Container({ class: 'row', hidden: true }); + overrideModelRow.append(overrideModelLabel); + overrideModelRow.append(overrideModelToggle); + + // override animation + + const overrideAnimationLabel = new Label({ class: 'label' }); + i18n.bindText(overrideAnimationLabel, 'popup.publish.override-animation'); + const overrideAnimationToggle = new BooleanInput({ class: 'boolean', type: 'toggle', value: true }); + const overrideAnimationRow = new Container({ class: 'row', hidden: true }); + overrideAnimationRow.append(overrideAnimationLabel); + overrideAnimationRow.append(overrideAnimationToggle); + + // animation + + const animationLabel = new Label({ class: 'label' }); + i18n.bindText(animationLabel, 'popup.export.animation'); + const animationToggle = new BooleanInput({ class: 'boolean', type: 'toggle', value: false }); + const animationRow = new Container({ class: 'row' }); + animationRow.append(animationLabel); + animationRow.append(animationToggle); + + // loop mode + + const loopLabel = new Label({ class: 'label' }); + i18n.bindText(loopLabel, 'popup.export.loop-mode'); + const loopSelect = new SelectInput({ + class: 'select', + defaultValue: 'repeat' + }); + i18n.bindOptions(loopSelect, () => [ + { v: 'none', t: i18n.t('popup.export.loop-mode.none') }, + { v: 'repeat', t: i18n.t('popup.export.loop-mode.repeat') }, + { v: 'pingpong', t: i18n.t('popup.export.loop-mode.pingpong') } + ]); + const loopRow = new Container({ class: 'row' }); + loopRow.append(loopLabel); + loopRow.append(loopSelect); + + // background color + + const colorLabel = new Label({ class: 'label' }); + i18n.bindText(colorLabel, 'popup.export.background-color'); + const colorPicker = new ColorPicker({ + class: 'color-picker', + value: [1, 1, 1, 1] + }); + const colorRow = new Container({ class: 'row' }); + colorRow.append(colorLabel); + colorRow.append(colorPicker); + + // generate LODs + + const generateLodsLabel = new Label({ class: 'label' }); + i18n.bindText(generateLodsLabel, 'popup.publish.generate-lods'); + const generateLodsToggle = new BooleanInput({ class: 'boolean', type: 'toggle', value: false }); + const generateLodsRow = new Container({ class: 'row' }); + generateLodsRow.append(generateLodsLabel); + generateLodsRow.append(generateLodsToggle); + + // fov + + const fovLabel = new Label({ class: 'label' }); + i18n.bindText(fovLabel, 'popup.export.fov'); + const fovSlider = new SliderInput({ + class: 'slider', + min: 10, + max: 120, + precision: 0, + value: 60 + }); + const fovRow = new Container({ class: 'row' }); + fovRow.append(fovLabel); + fovRow.append(fovSlider); + + // content + + const content = new Container({ id: 'content' }); + content.append(overwriteRow); + content.append(titleRow); + content.append(descRow); + content.append(overrideModelRow); + content.append(overrideAnimationRow); + content.append(colorRow); + content.append(fovRow); + content.append(animationRow); + content.append(loopRow); + content.append(generateLodsRow); + + // footer + + const footer = new Container({ id: 'footer' }); + + const cancelButton = new Button({ + class: 'button' + }); + i18n.bindText(cancelButton, 'popup.publish.cancel'); + + const okButton = new Button({ + class: 'button' + }); + i18n.bindText(okButton, 'popup.publish.ok'); + + footer.append(cancelButton); + footer.append(okButton); + + dialog.append(header); + dialog.append(content); + dialog.append(footer); + + this.append(dialog); + + // handle key bindings for enter and escape + + let onCancel: () => void; + let onOK: () => void; + + cancelButton.on('click', () => onCancel()); + okButton.on('click', () => onOK()); + + const keydown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onCancel(); + break; + case 'Enter': + if (!e.shiftKey && !okButton.disabled) onOK(); + break; + default: + e.stopPropagation(); + break; + } + }; + + let hasPosesState = false; + + const updateLayout = () => { + const isNew = overwriteSelect.value === '0'; + const modelOn = overrideModelToggle.value; + const animOn = overrideAnimationToggle.value; + + // new-scene vs existing-scene row visibility + titleRow.hidden = !isNew; + descRow.hidden = !isNew; + colorRow.hidden = !isNew; + fovRow.hidden = !isNew; + animationRow.hidden = !isNew; + overrideModelRow.hidden = isNew; + overrideAnimationRow.hidden = isNew; + // generateLods only matters when a model is uploaded — hide when republishing animation-only + generateLodsRow.hidden = !isNew && !modelOn; + + if (isNew) { + animationToggle.enabled = hasPosesState; + loopRow.hidden = false; + loopSelect.enabled = hasPosesState && animationToggle.value; + } else { + overrideAnimationToggle.enabled = hasPosesState; + loopRow.hidden = false; + loopSelect.enabled = animOn && hasPosesState; + } + + // disable publish when existing scene with no overrides selected + okButton.disabled = !isNew && !modelOn && !animOn; + }; + + overwriteSelect.on('change', updateLayout); + overrideModelToggle.on('change', updateLayout); + overrideAnimationToggle.on('change', updateLayout); + animationToggle.on('change', updateLayout); + + // reset UI and configure for current state + const reset = (hasPoses: boolean, overwriteList: string[]) => { + hasPosesState = hasPoses; + + const splats = events.invoke('scene.splats'); + const filename = splats[0].filename; + const dot = splats[0].filename.lastIndexOf('.'); + const bgClr = events.invoke('bgClr'); + const totalSplats = splats.reduce((sum: number, s: any) => sum + (s.numSplats ?? 0), 0); + + // union scene bounds to decide LOD default for large scenes + const sceneMin = [Infinity, Infinity, Infinity]; + const sceneMax = [-Infinity, -Infinity, -Infinity]; + for (const s of splats) { + const bound = s.worldBound; + if (!bound) continue; + const { center, halfExtents } = bound; + const c = [center.x, center.y, center.z]; + const h = [halfExtents.x, halfExtents.y, halfExtents.z]; + for (let i = 0; i < 3; i++) { + sceneMin[i] = Math.min(sceneMin[i], c[i] - h[i]); + sceneMax[i] = Math.max(sceneMax[i], c[i] + h[i]); + } + } + const largeAxes = [0, 1, 2].filter(i => sceneMax[i] - sceneMin[i] > 16).length; + const isLargeScene = largeAxes >= 2; + + overwriteSelect.options = [{ + v: '0', t: i18n.t('popup.publish.new-scene') + }].concat(overwriteList.map((s, i) => ({ v: (i + 1).toString(), t: s }))); + + overwriteSelect.value = '0'; + titleInput.value = filename.slice(0, dot > 0 ? dot : undefined); + descInput.value = ''; + overrideModelToggle.value = true; + overrideAnimationToggle.value = hasPoses; + animationToggle.value = hasPoses; + loopSelect.value = events.invoke('timeline.loop') ? 'repeat' : 'none'; + colorPicker.value = [bgClr.r, bgClr.g, bgClr.b]; + fovSlider.value = events.invoke('camera.fov'); + generateLodsToggle.value = totalSplats >= 1_000_000 && isLargeScene; + + updateLayout(); + }; + + // function implementations + + this.show = (userStatus: UserStatus) => { + const frames = events.invoke('timeline.frames'); + const frameRate = events.invoke('timeline.frameRate'); + const smoothness = events.invoke('timeline.smoothness'); + + // get poses + const orderedPoses = (events.invoke('camera.poses') as Pose[]) + .slice() + .filter(p => p.frame >= 0 && p.frame < frames) + .sort((a, b) => a.frame - b.frame); + + // overwrite options + const overwriteList = userStatus.scenes.map((s) => { + return `${s.hash} - ${s.title}`; + }); + + // reset UI + reset(orderedPoses.length > 0, overwriteList); + + this.hidden = false; + this.dom.addEventListener('keydown', keydown); + this.dom.focus(); + + return new Promise((resolve) => { + onCancel = () => { + resolve(null); + }; + + onOK = () => { + const isNew = overwriteSelect.value === '0'; + const selectedScene = !isNew ? userStatus.scenes[parseInt(overwriteSelect.value, 10) - 1] : null; + + // extract camera animation + const includeAnimation = isNew ? animationToggle.value : overrideAnimationToggle.value; + const animTracks: AnimTrack[] = []; + + if (includeAnimation && orderedPoses.length > 0) { + const times: number[] = []; + const position: number[] = []; + const target: number[] = []; + const fovKeys: number[] = []; + for (let i = 0; i < orderedPoses.length; ++i) { + const op = orderedPoses[i]; + times.push(op.frame); + position.push(op.position.x, op.position.y, op.position.z); + target.push(op.target.x, op.target.y, op.target.z); + fovKeys.push(op.fov); + } + + animTracks.push({ + name: 'cameraAnim', + duration: frames / frameRate, + frameRate, + loopMode: loopSelect.value as 'none' | 'repeat' | 'pingpong', + interpolation: 'spline', + smoothness, + keyframes: { + times, + values: { position, target, fov: fovKeys } + } + }); + } + + const fov = fovSlider.value; + const bgColor = colorPicker.value.slice(0, 3) as [number, number, number]; + + // use current viewport as start pose + const pose = events.invoke('camera.getPose'); + const p = pose?.position; + const t = pose?.target; + const cameras = (p && t) ? [{ + initial: { + position: [p.x, p.y, p.z] as [number, number, number], + target: [t.x, t.y, t.z] as [number, number, number], + fov + } + }] : []; + + const experienceSettings: ExperienceSettings = { + version: 2, + tonemapping: 'none', + highPrecisionRendering: false, + background: { color: bgColor }, + postEffectSettings: defaultPostEffectSettings, + animTracks, + cameras, + annotations: [], + startMode: includeAnimation ? 'animTrack' : 'default' + }; + + const serializeSettings = { + maxSHBands: 3, + minOpacity: 1 / 255, + removeInvalid: true + }; + + resolve({ + user: userStatus.user, + title: titleInput.value, + description: descInput.value, + listed: false, + serializeSettings, + experienceSettings, + overwriteHash: selectedScene?.hash, + overrideModel: isNew || overrideModelToggle.value, + overrideAnimation: !isNew && overrideAnimationToggle.value, + generateLods: generateLodsToggle.value + }); + }; + }).finally(() => { + this.dom.removeEventListener('keydown', keydown); + this.hide(); + }); + }; + + this.hide = () => { + this.hidden = true; + }; + + this.destroy = () => { + this.hide(); + super.destroy(); + }; + } +} + +export { PublishSettingsDialog }; diff --git a/src/ui/right-toolbar.ts b/src/ui/right-toolbar.ts new file mode 100644 index 0000000..e2ac03a --- /dev/null +++ b/src/ui/right-toolbar.ts @@ -0,0 +1,161 @@ +import { Button, Container, Element, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; +import cameraFrameSelectionSvg from './svg/camera-frame-selection.svg'; +import cameraResetSvg from './svg/camera-reset.svg'; +import centersSvg from './svg/centers.svg'; +import colorPanelSvg from './svg/color-panel.svg'; +import flyCameraSvg from './svg/fly-camera.svg'; +import orbitCameraSvg from './svg/orbit-camera.svg'; +import ringsSvg from './svg/rings.svg'; +import showHideSplatsSvg from './svg/show-hide-splats.svg'; +import { Tooltips } from './tooltips'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement; +}; + +class RightToolbar extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'right-toolbar' + }; + + super(args); + + this.dom.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + }); + + const ringsModeToggle = new Button({ + id: 'right-toolbar-mode-toggle', + class: 'right-toolbar-toggle' + }); + + const showHideSplats = new Button({ + id: 'right-toolbar-show-hide', + class: ['right-toolbar-toggle', 'active'] + }); + + const orbitMode = new Button({ + id: 'right-toolbar-orbit-mode', + class: ['right-toolbar-toggle', 'active'] + }); + + const flyMode = new Button({ + id: 'right-toolbar-fly-mode', + class: 'right-toolbar-toggle' + }); + + const cameraFrameSelection = new Button({ + id: 'right-toolbar-frame-selection', + class: 'right-toolbar-button' + }); + + const cameraReset = new Button({ + id: 'right-toolbar-camera-origin', + class: 'right-toolbar-button' + }); + + const colorPanel = new Button({ + id: 'right-toolbar-color-panel', + class: 'right-toolbar-toggle' + }); + + const options = new Button({ + id: 'right-toolbar-options', + class: 'right-toolbar-toggle', + icon: 'E283' + }); + + const centersDom = createSvg(centersSvg); + const ringsDom = createSvg(ringsSvg); + ringsDom.style.display = 'none'; + + ringsModeToggle.dom.appendChild(centersDom); + ringsModeToggle.dom.appendChild(ringsDom); + showHideSplats.dom.appendChild(createSvg(showHideSplatsSvg)); + orbitMode.dom.appendChild(createSvg(orbitCameraSvg)); + flyMode.dom.appendChild(createSvg(flyCameraSvg)); + cameraFrameSelection.dom.appendChild(createSvg(cameraFrameSelectionSvg)); + cameraReset.dom.appendChild(createSvg(cameraResetSvg)); + colorPanel.dom.appendChild(createSvg(colorPanelSvg)); + + this.append(ringsModeToggle); + this.append(showHideSplats); + this.append(new Element({ class: 'right-toolbar-separator' })); + this.append(orbitMode); + this.append(flyMode); + this.append(new Element({ class: 'right-toolbar-separator' })); + this.append(cameraFrameSelection); + this.append(cameraReset); + this.append(new Element({ class: 'right-toolbar-separator' })); + this.append(colorPanel); + this.append(options); + + // Helper to compose localized tooltip text with shortcut + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + const tooltip = (localeKey: string, shortcutId?: string) => () => { + const text = i18n.t(localeKey); + if (shortcutId) { + const shortcut = shortcutManager.formatShortcut(shortcutId); + if (shortcut) { + return i18n.formatTooltipWithShortcut(text, shortcut); + } + } + return text; + }; + + tooltips.register(ringsModeToggle, tooltip('tooltip.right-toolbar.splat-mode', 'camera.toggleMode'), 'left'); + tooltips.register(showHideSplats, tooltip('tooltip.right-toolbar.show-hide', 'camera.toggleOverlay'), 'left'); + tooltips.register(orbitMode, tooltip('tooltip.right-toolbar.orbit-camera', 'camera.toggleControlMode'), 'left'); + tooltips.register(flyMode, tooltip('tooltip.right-toolbar.fly-camera', 'camera.toggleControlMode'), 'left'); + tooltips.register(cameraFrameSelection, tooltip('tooltip.right-toolbar.frame-selection', 'camera.focus'), 'left'); + tooltips.register(cameraReset, tooltip('tooltip.right-toolbar.reset-camera', 'camera.reset'), 'left'); + tooltips.register(colorPanel, tooltip('tooltip.right-toolbar.colors'), 'left'); + tooltips.register(options, tooltip('tooltip.right-toolbar.settings'), 'left'); + + // add event handlers + + ringsModeToggle.on('click', () => { + events.fire('camera.toggleMode'); + events.fire('camera.setOverlay', true); + }); + showHideSplats.on('click', () => events.fire('camera.toggleOverlay')); + orbitMode.on('click', () => events.fire('camera.setControlMode', 'orbit')); + flyMode.on('click', () => events.fire('camera.setControlMode', 'fly')); + cameraFrameSelection.on('click', () => events.fire('camera.focus')); + cameraReset.on('click', () => events.fire('camera.reset')); + colorPanel.on('click', () => events.fire('colorPanel.toggleVisible')); + options.on('click', () => events.fire('settingsPanel.toggleVisible')); + + events.on('camera.mode', (mode: string) => { + ringsModeToggle.class[mode === 'rings' ? 'add' : 'remove']('active'); + centersDom.style.display = mode === 'rings' ? 'none' : 'block'; + ringsDom.style.display = mode === 'rings' ? 'block' : 'none'; + }); + + events.on('camera.overlay', (value: boolean) => { + showHideSplats.class[value ? 'add' : 'remove']('active'); + }); + + events.on('camera.controlMode', (mode: 'orbit' | 'fly') => { + orbitMode.class[mode === 'orbit' ? 'add' : 'remove']('active'); + flyMode.class[mode === 'fly' ? 'add' : 'remove']('active'); + }); + + events.on('colorPanel.visible', (visible: boolean) => { + colorPanel.class[visible ? 'add' : 'remove']('active'); + }); + + events.on('settingsPanel.visible', (visible: boolean) => { + options.class[visible ? 'add' : 'remove']('active'); + }); + } +} + +export { RightToolbar }; diff --git a/src/ui/scene-panel.ts b/src/ui/scene-panel.ts new file mode 100644 index 0000000..45629ae --- /dev/null +++ b/src/ui/scene-panel.ts @@ -0,0 +1,126 @@ +import { Container, Element, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { i18n } from './localization'; +import { SplatList } from './splat-list'; +import sceneImportSvg from './svg/import.svg'; +import sceneNewSvg from './svg/new.svg'; +import soloSvg from './svg/solo.svg'; +import { Tooltips } from './tooltips'; +import { Transform } from './transform'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement; +}; + +class ScenePanel extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'scene-panel', + class: 'panel' + }; + + super(args); + + // stop pointer events bubbling + ['pointerdown', 'pointerup', 'pointermove', 'wheel', 'dblclick'].forEach((eventName) => { + this.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); + }); + + const sceneHeader = new Container({ + class: 'panel-header' + }); + + const sceneIcon = new Label({ + text: '\uE344', + class: 'panel-header-icon' + }); + + const sceneLabel = new Label({ + class: 'panel-header-label' + }); + i18n.bindText(sceneLabel, 'panel.scene-manager'); + + let soloActive = false; + + const soloToggle = new Container({ + class: 'panel-header-button' + }); + soloToggle.dom.appendChild(createSvg(soloSvg)); + + soloToggle.on('click', () => { + soloActive = !soloActive; + if (soloActive) { + soloToggle.class.add('active'); + } else { + soloToggle.class.remove('active'); + } + events.fire('scene.solo', soloActive); + }); + + const sceneImport = new Container({ + class: 'panel-header-button' + }); + sceneImport.dom.appendChild(createSvg(sceneImportSvg)); + + const sceneNew = new Container({ + class: 'panel-header-button' + }); + sceneNew.dom.appendChild(createSvg(sceneNewSvg)); + + sceneHeader.append(sceneIcon); + sceneHeader.append(sceneLabel); + sceneHeader.append(soloToggle); + sceneHeader.append(sceneImport); + sceneHeader.append(sceneNew); + + sceneImport.on('click', async () => { + await events.invoke('scene.import'); + }); + + sceneNew.on('click', () => { + events.invoke('doc.new'); + }); + + tooltips.register(soloToggle, () => i18n.t('tooltip.scene.solo'), 'top'); + tooltips.register(sceneImport, () => i18n.t('tooltip.scene.import'), 'top'); + tooltips.register(sceneNew, () => i18n.t('tooltip.scene.new'), 'top'); + + const splatList = new SplatList(events); + + const splatListContainer = new Container({ + class: 'splat-list-container' + }); + splatListContainer.append(splatList); + + const transformHeader = new Container({ + class: 'panel-header' + }); + + const transformIcon = new Label({ + text: '\uE111', + class: 'panel-header-icon' + }); + + const transformLabel = new Label({ + class: 'panel-header-label' + }); + i18n.bindText(transformLabel, 'panel.scene-manager.transform'); + + transformHeader.append(transformIcon); + transformHeader.append(transformLabel); + + this.append(sceneHeader); + this.append(splatListContainer); + this.append(transformHeader); + this.append(new Transform(events)); + this.append(new Element({ + class: 'panel-header', + height: 20 + })); + } +} + +export { ScenePanel }; diff --git a/src/ui/scss/about-popup.scss b/src/ui/scss/about-popup.scss new file mode 100644 index 0000000..49e86de --- /dev/null +++ b/src/ui/scss/about-popup.scss @@ -0,0 +1,144 @@ +@use 'colors.scss' as *; + +#about-popup { + width: 100%; + height: 100%; + + background-color: $bcg-darken; + pointer-events: all; + + #about-dialog { + position: absolute; + left: 50%; + top: 50%; + min-width: 320px; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + overflow: hidden; + + border-radius: 8px; + background-color: $bcg-primary; + + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; + } + + #about-header { + height: 32px; + line-height: 32px; + margin: 0; + padding: 0 8px; + + font-weight: bold; + color: $text-primary; + background-color: $bcg-darker; + text-transform: uppercase; + } + + #about-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + gap: 12px; + } + + #about-logo { + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.8; + } + + svg { + display: block; + } + } + + #about-app-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.8; + } + } + + #about-app-name { + margin: 0; + font-family: 'Proxima Nova Bold', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-size: 18px; + font-weight: 700; + color: $text-primary; + } + + #about-app-version { + margin: 0; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; + font-size: 12px; + font-weight: 500; + color: $clr-hilight; + } + + #about-deps { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + margin-top: 16px; + } + + .about-dep-row { + display: flex; + flex-direction: row; + align-items: center; + padding: 6px 10px; + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.2); + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(246, 102, 44, 0.15); + + .about-dep-name { + color: $clr-hilight; + } + } + } + + .about-dep-name { + min-width: 80px; + margin: 0; + font-size: 11px; + font-weight: 500; + color: $text-secondary; + transition: color 0.15s ease; + } + + .about-dep-version { + margin: 0; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; + font-size: 11px; + font-weight: 500; + color: $text-primary; + } + + .about-dep-revision { + margin: 0 0 0 6px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; + font-size: 10px; + font-weight: 400; + color: $text-secondary; + opacity: 0.7; + } +} diff --git a/src/ui/scss/bottom-toolbar.scss b/src/ui/scss/bottom-toolbar.scss new file mode 100644 index 0000000..119a414 --- /dev/null +++ b/src/ui/scss/bottom-toolbar.scss @@ -0,0 +1,83 @@ +@use 'colors.scss' as *; + +#bottom-toolbar { + position: absolute; + left: 50%; + bottom: 24px; + height: 54px; + transform: translate(-50%, 0); + padding: 0px 8px; + border-radius: 8px; + + background-color: $bcg-dark; + + display: flex; + flex-direction: row; + align-items: center; +} + +.bottom-toolbar-button, .bottom-toolbar-tool, .bottom-toolbar-toggle { + width: 38px; + height: 38px; + margin: 0px 1px; + padding: 0px; + border: 0px; + border-radius: 2px; + + &::before { + font-size: 16px !important; + line-height: 100%; + } + + svg { + color: $clr-default; + } +} + +.bottom-toolbar-separator { + width: 2px; + height: 38px; + margin: 0px 10px; + background-color: $bcg-primary; +} + +.bottom-toolbar-button { + svg { + color: $clr-default; + } +} + +.bottom-toolbar-tool { + background-color: $bcg-primary; + + svg { + color: $clr-default + } + + &.active { + color: $clr-active; + background-color: $clr-hilight !important; + + svg { + color: $clr-active; + } + } + + &.disabled { + background-color: $bcg-dark; + + svg { + color: $clr-disabled; + } + } +} + +.bottom-toolbar-toggle.active { + &::before { + color: $clr-hilight; + } + + svg { + color: $clr-hilight; + } +} diff --git a/src/ui/scss/color-panel.scss b/src/ui/scss/color-panel.scss new file mode 100644 index 0000000..57fb259 --- /dev/null +++ b/src/ui/scss/color-panel.scss @@ -0,0 +1,50 @@ +@use 'colors.scss' as *; + +#color-panel { + top: 50%; + transform: translate(0, -50%); + right: 102px; + width: 320px; + flex-direction: column; + + &:not(.pcui-hidden) { + display: flex; + } + + & > .color-panel-row { + display: flex; + flex-direction: row; + padding: 2px; + height: 28px; + + & > .color-panel-row-label { + flex-grow: 1; + } + + & > .color-panel-row-picker { + margin: 0px 0px; + padding: 0px; + height: 24px; + } + + & > .color-panel-row-slider { + width: 220px; + margin: 0px; + + & > .pcui-slider-container { + & > .pcui-slider-bar { + & > .pcui-slider-handle { + background-color: $clr-default; + border-radius: 3px; + } + } + } + } + } + + > .color-panel-control-row { + display: flex; + flex-direction: row; + background-color: $bcg-dark; + } +} \ No newline at end of file diff --git a/src/ui/scss/colors.scss b/src/ui/scss/colors.scss new file mode 100644 index 0000000..acbd98c --- /dev/null +++ b/src/ui/scss/colors.scss @@ -0,0 +1,24 @@ +@use 'pcui-theme-grey.scss' as theme; + +$text-primary: theme.$text-primary; +$text-secondary: theme.$text-secondary; +$text-dark: theme.$text-dark; +$text-darkest: theme.$text-darkest; + +$error: theme.$error; + +$clr-default: #b3aaac; +$clr-disabled: #7c7678; +$clr-active: white; +$clr-hilight: #f60; +$clr-icon-hilight: #FFAF50; + +$bcg-lighter: #444; +$bcg-light: #555; +$bcg-primary: theme.$bcg-primary; +$bcg-dark: theme.$bcg-dark; +$bcg-darker: theme.$bcg-darker; +$bcg-darkest: #181818; + +// darken amount when showing modal dialogs +$bcg-darken: rgba(0, 0, 0, 0.25); diff --git a/src/ui/scss/data-panel.scss b/src/ui/scss/data-panel.scss new file mode 100644 index 0000000..4dc1095 --- /dev/null +++ b/src/ui/scss/data-panel.scss @@ -0,0 +1,232 @@ +@use 'colors.scss' as *; + +#data-panel { + width: 100%; + height: 320px; + border-top: 1px solid $bcg-lighter; + + &:not(.pcui-hidden) { + display: flex; + } +} + +#data-panel-resize-handle { + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 8px; + cursor: ns-resize; + z-index: 10; +} + +#data-controls-container { + width: 200px; + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + background-color: $bcg-darker; + border-right: 1px solid $bcg-lighter; +} + +#data-controls { + width: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; + background-color: $bcg-primary; +} + +.data-panel-toggle-row { + height: 28px; + min-height: 28px; + flex-shrink: 0; + padding: 0px; + margin: 0 4px; + align-items: center; +} + +.data-panel-toggle-label { + flex-grow: 1; +} + +.data-panel-toggle { + flex-shrink: 0; + background-color: $bcg-dark; + + &::after { + background-color: $clr-default; + } + + &.pcui-boolean-input-ticked { + background-color: $clr-hilight; + + &::after { + background-color: $clr-active; + } + } +} + +#data-list-box { + overflow-y: auto; + flex-grow: 1; + margin: 8px; + background-color: $bcg-darker; + border-radius: 2px; + border: 1px solid $bcg-darkest; +} + +.data-list-item { + padding: 4px 8px; + cursor: pointer; + color: $text-secondary; + font-size: 12px; + border-left: 3px solid transparent; + + &:hover { + color: $text-primary; + background-color: $bcg-primary; + } + + &.active { + font-weight: bold; + color: $clr-hilight; + background-color: $bcg-primary; + border-left-color: $clr-hilight; + } +} + +#histogram-container { + flex-grow: 1; + flex-shrink: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +// holds the canvas and the SVG highlight overlay. flexes to fill the +// container's vertical space so the info row underneath gets a fixed slice. +#histogram-canvas-area { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +#histogram-canvas { + image-rendering: pixelated; +} + +#histogram-svg { + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +// row pinned beneath the histogram canvas: min on the left, max on the right, +// and the hovered/drag-range readout positioned horizontally to track the +// pointer x. pointer-events: none so it never blocks the canvas. +#histogram-info-row { + position: relative; + flex: 0 0 18px; + height: 18px; + pointer-events: none; + user-select: none; + font-size: 11px; + line-height: 18px; + font-variant-numeric: tabular-nums; + color: $text-secondary; + background-color: $bcg-darker; + white-space: nowrap; + overflow: hidden; +} + +.histogram-info-min, +.histogram-info-max { + position: absolute; + top: 0; +} + +.histogram-info-min { + left: 4px; +} + +.histogram-info-max { + right: 4px; +} + +// anchor and cursor labels both sit at `left: px`. by default they are +// centered on that point (used for the hover-cursor case). during a drag, +// data-panel toggles .align-left / .align-right so the labels sit just +// outside the selection rect (the left-most label is right-aligned, the +// right-most label is left-aligned). +.histogram-info-anchor, +.histogram-info-cursor { + position: absolute; + top: 0; + transform: translateX(-50%); + + &.align-left { + // text left edge at x; text grows to the right. + transform: translateX(0); + } + + &.align-right { + // text right edge at x; text grows to the left. + transform: translateX(-100%); + } + + &:empty { + display: none; + } +} + +// anchor uses the selection highlight color so the user can tell the click +// position apart from the live cursor (which uses the primary text color). +.histogram-info-anchor { + color: $clr-hilight; +} + +.histogram-info-cursor { + color: $text-primary; + background-color: $bcg-dark; +} + +// top-right stats overlay: shows aggregate counts (splats in the +// hovered bucket / drag range, and how many are selected). pointer-events: +// none so it never blocks canvas interactions. translucent dark background +// keeps it readable over the histogram visualization. +#histogram-stats-overlay { + position: absolute; + top: 4px; + right: 4px; + pointer-events: none; + user-select: none; + padding: 3px 6px; + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.55); + font-size: 10px; + line-height: 1.35; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.histogram-stats-row { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.histogram-stats-label { + color: $text-secondary; +} + +.histogram-stats-value { + color: $text-primary; +} diff --git a/src/ui/scss/export-popup.scss b/src/ui/scss/export-popup.scss new file mode 100644 index 0000000..d3806fb --- /dev/null +++ b/src/ui/scss/export-popup.scss @@ -0,0 +1,100 @@ +@use 'colors.scss' as *; + +#export-popup { + width: 100%; + height: 100%; + + background-color: $bcg-darken; + + pointer-events: all; + + #dialog { + position: absolute; + width: 380px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + overflow: hidden; + + border-radius: 8px; + background-color: $bcg-primary; + + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; + + #header { + height: 32px; + line-height: 32px; + margin: 0px; + padding: 0px 12px; + + font-weight: bold; + color: $text-primary; + background-color: $bcg-darker; + text-transform: uppercase; + + #icon { + vertical-align: middle; + color: $clr-icon-hilight; + } + } + + #content { + min-height: 60px; + padding: 12px; + + .row { + height: 24px; + line-height: 24px; + padding-bottom: 8px; + + &:not(.pcui-hidden) { + display: flex; + } + + .label { + margin: 0px; + flex-grow: 1; + width: 180px; + } + + .select, .color-picker, .slider, .text-entry { + margin: 0px; + width: 100%; + } + + .text-input { + margin: 0px; + width: 100%; + } + + .boolean { + margin: 5px 0 0 auto; + flex-grow: 0; + } + } + } + + #footer { + display: flex; + justify-content: center; + padding-bottom: 4px; + + .button { + width: 120px; + height: 30px; + border-radius: 4px; + + &:hover { + color: $text-primary; + background-color: $clr-hilight; + } + } + } + } +} diff --git a/src/ui/scss/menu-panel.scss b/src/ui/scss/menu-panel.scss new file mode 100644 index 0000000..8e9ca9b --- /dev/null +++ b/src/ui/scss/menu-panel.scss @@ -0,0 +1,84 @@ +@use 'colors.scss' as *; + +.menu-panel { + position: absolute; + + &::not(.pcui-hidden) { + display: flex; + } + flex-direction: column; + + border-radius: 8px; + overflow: hidden; + + background-color: $bcg-dark; + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; +} + +.menu-row { + display: flex; + flex-direction: row; + min-width: 180px; + align-items: center; + height: 32px; + padding: 0px 8px; + + svg { + color: $text-secondary; + } + + &:hover:not(.pcui-disabled) { + background-color: $bcg-darkest; + cursor: pointer; + + & > .menu-row-text, .menu-row-postscript, .menu-row-icon { + color: $text-primary; + } + + svg { + color: $text-primary; + } + } + + &.pcui-disabled { + & > .menu-row-text, .menu-row-postscript, .menu-row-icon { + color: $text-darkest; + } + + svg { + color: $text-darkest; + } + } + + // tweaks for attached boolean toggles + > .pcui-boolean-input { + background-color: $bcg-darkest; + border-radius: 2px; + &.pcui-boolean-input-ticked { + background-color: $clr-hilight; + &::after { + color: $text-primary; + } + } + } +} + +.menu-row-icon { + font-family: 'pc-icon' !important; +} + +.menu-row-text { + flex-grow: 1; +} + +.menu-row-postscript { + color: $text-dark; +} + +.menu-row-separator { + height: 1px; + background-color: $bcg-light; +} diff --git a/src/ui/scss/menu.scss b/src/ui/scss/menu.scss new file mode 100644 index 0000000..132b52d --- /dev/null +++ b/src/ui/scss/menu.scss @@ -0,0 +1,90 @@ +@use 'colors.scss' as *; + +#menu { + position: absolute; +} + +#menu-bar { + transition: width 0.1s ease; + + position: absolute; + top: 24px; + left: 24px; + height: 50px; + border-radius: 8px; + + overflow: hidden; + + background-color: $bcg-primary; + + display: flex; + flex-direction: row; + align-items: center; +} + +#menu-arrow { + display: none; +} + +#menu-container { + display: flex; + flex-direction: column; +} + +#menu-bar-options { + display: flex; + flex-direction: row; + flex-grow: 1; +} + +.menu-icon { + width: 16px; + height: 16px; + padding: 16px 10px; + margin: 0px; + flex-grow: 1; + color: $text-secondary; + + cursor: pointer; + &:hover { + color: $text-primary; + background-color: $bcg-dark; + } +} + +.menu-option { + padding: 16px 20px; + margin: 0px; + flex-grow: 1; + text-align: center; + text-overflow: clip; + + background-color: $bcg-primary; + + cursor: pointer; + + &:hover { + color: $text-primary; + background-color: $bcg-dark; + } +} + +.collapsed { + #menu-bar { + width: auto; + height: auto; + } + + #menu-collapse { + display: none; + } + + #menu-arrow { + display: block; + padding: 10px; + } + + .menu-option { + display: none; + } +} \ No newline at end of file diff --git a/src/ui/scss/mode-toggle.scss b/src/ui/scss/mode-toggle.scss new file mode 100644 index 0000000..3db9349 --- /dev/null +++ b/src/ui/scss/mode-toggle.scss @@ -0,0 +1,44 @@ +@use 'colors.scss' as *; + +#mode-toggle { + position: absolute; + left: calc(50% - 60px); + top: 0px; + width: 120px; + + padding: 0px 8px; + border-radius: 0px 0px 8px 8px; + + background-color: $bcg-dark; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + cursor: pointer; + + &.centers-mode { + #rings-icon, #rings-text { + display: none; + } + } + + &.rings-mode { + #centers-icon, #centers-text { + display: none; + } + } + + #centers-icon { + color: $clr-hilight; + } + + #rings-icon { + color: $clr-hilight; + } + + &:hover { + color: $text-primary; + } +} \ No newline at end of file diff --git a/src/ui/scss/panel.scss b/src/ui/scss/panel.scss new file mode 100644 index 0000000..de14588 --- /dev/null +++ b/src/ui/scss/panel.scss @@ -0,0 +1,65 @@ +@use 'colors.scss' as *; + +.panel { + position: absolute; + border-radius: 8px; + overflow: hidden; + + background-color: $bcg-primary; + + & > .panel-header { + display: flex; + flex-direction: row; + align-items: center; + padding: 2px; + + background-color: $bcg-dark; + + & > .panel-header-icon { + font-family: pc-icon; + font-weight: bold; + font-size: 13px; + color: $clr-hilight; + } + + & > .panel-header-label { + color: $text-primary; + font-weight: bold; + flex-grow: 1; + text-transform: uppercase; + } + } + + .panel-header-button { + font-family: pc-icon; + font-weight: bold; + font-size: 13px; + color: $clr-hilight; + + padding: 4px; + flex-grow: 0; + flex-shrink: 0; + + border-radius: 4px; + + display: flex; + align-items: center; + justify-content: center; + + svg { + color: $clr-hilight; + } + + &:hover { + color: #ff9900; + background-color: $bcg-darkest; + cursor: pointer; + } + } + + .panel-header-spacer { + flex-grow: 1; + padding: 0px; + margin: 0px; + } +} \ No newline at end of file diff --git a/src/ui/scss/popup.scss b/src/ui/scss/popup.scss new file mode 100644 index 0000000..bb77a9b --- /dev/null +++ b/src/ui/scss/popup.scss @@ -0,0 +1,124 @@ +@use 'colors.scss' as *; + +#popup { + width: 100%; + height: 100%; + + background-color: $bcg-darken; + pointer-events: all; + + #popup-dialog { + position: absolute; + left: 50%; + top: 50%; + min-width: 320px; + max-width: 480px; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + overflow: hidden; + + border-radius: 8px; + background-color: $bcg-primary; + + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; + + #popup-header { + height: 32px; + line-height: 32px; + margin: 0px; + padding: 0px 8px; + + font-weight: bold; + color: $text-primary; + background-color: $bcg-darker; + text-transform: uppercase; + } + + #popup-text { + text-wrap: wrap; + text-align: center; + padding: 20px 10px; + + color: $text-primary; + + &::before { + font-family: 'pc-icon'; + font-size: 16px; + margin: 0px 10px; + color: $text-primary; + } + + &.error::before { + content: '\E218'; + color: $error; + } + + &.info::before { + content: '\E400'; + } + + &.yesno::before { + content: '\E138'; + } + + &.okcancel::before { + content: '\E138'; + } + } + + #popup-link-row { + margin: 20px 10px; + + &:not(.pcui-hidden) { + display: flex; + } + + #popup-link-text { + width: 360px; + height: 32px; + line-height: 32px; + + background-color: $bcg-dark; + text-align: center; + + a { + color: $clr-hilight; + font-weight: bold; + font-size: 14px; + text-decoration-line: none; + }; + } + + #popup-link-copy { + width: 32px; + height: 32px; + line-height: 24px; + font-family: 'pc-icon'; + } + } + + #popup-buttons { + display: flex; + flex-direction: row; + justify-content: center; + margin: 6px; + + .popup-button { + height: 40px; + width: 120px; + border-radius: 4px; + background-color: $bcg-darker; + + &:hover { + color: $text-primary; + background-color: $clr-hilight; + } + } + } + } +} diff --git a/src/ui/scss/progress.scss b/src/ui/scss/progress.scss new file mode 100644 index 0000000..f4f10d9 --- /dev/null +++ b/src/ui/scss/progress.scss @@ -0,0 +1,81 @@ +@use 'colors.scss' as *; + +#progress-container { + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: $bcg-darken; + pointer-events: all; + cursor: progress; + + #dialog { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + border-radius: 8px; + background-color: $bcg-primary; + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + will-change: transform; // needed for drop-shadow to work on Safari + + display: flex; + flex-direction: column; + overflow: hidden; + + #header { + height: 32px; + line-height: 32px; + margin: 0px; + padding: 0px 8px; + + font-weight: bold; + color: $text-secondary; + background-color: $bcg-darker; + } + + #content { + width: 360px; + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + + padding: 16px; + + #text { + width: 100%; + text-wrap: wrap; + color: $text-secondary; + } + + #bar { + width: 100%; + height: 12px; + border: 1px solid $bcg-dark; + border-radius: 6px; + background-color: #505050; + } + + #cancel-button { + align-self: center; + margin: 0px; + } + } + } + + .pulsate { + animation-name: color; + animation-duration: 1s; + animation-iteration-count: infinite; + animation-direction: alternate-reverse; + animation-timing-function: ease; + } + + @keyframes color { + to { + background-color: #404040; + } + } +} \ No newline at end of file diff --git a/src/ui/scss/right-toolbar.scss b/src/ui/scss/right-toolbar.scss new file mode 100644 index 0000000..24d4e34 --- /dev/null +++ b/src/ui/scss/right-toolbar.scss @@ -0,0 +1,96 @@ +@use 'colors.scss' as *; + +#right-toolbar { + position: absolute; + right: 24px; + top: 50%; + width: 54px; + transform: translate(0, -50%); + padding: 8px 0px; + border-radius: 8px; + + background-color: $bcg-dark; + + display: flex; + flex-direction: column; + align-items: center; + + #right-toolbar-mode-toggle { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + svg { + width: 20px; + height: 20px; + } + } + + &>.right-toolbar-button, .right-toolbar-tool, .right-toolbar-toggle { + width: 38px; + height: 38px; + margin: 1px 1px; + padding: 0px; + border: 0px; + border-radius: 2px; + + &::before { + font-size: 16px !important; + line-height: 100%; + } + + svg { + color: $clr-default; + } + } + + &>.right-toolbar-separator { + width: 38px; + height: 2px; + margin: 4px 0px; + background-color: $bcg-lighter; + } + + &>.right-toolbar-button { + svg { + color: $clr-default; + } + } + + &>.right-toolbar-tool { + background-color: $bcg-primary; + + svg { + color: $clr-default; + } + + &.active { + color: $clr-active; + background-color: $clr-hilight !important; + + svg { + color: $clr-active; + } + } + + &.disabled { + background-color: $bcg-dark; + + svg { + color: $clr-disabled; + } + } + } + + &>.right-toolbar-toggle.active { + // highlight icon + &::before { + color: $clr-hilight; + } + + svg { + color: $clr-hilight; + } + } +} \ No newline at end of file diff --git a/src/ui/scss/scene-panel.scss b/src/ui/scss/scene-panel.scss new file mode 100644 index 0000000..e8f8512 --- /dev/null +++ b/src/ui/scss/scene-panel.scss @@ -0,0 +1,25 @@ +@use 'colors.scss' as *; + +#scene-panel { + top: 102px; + left: 24px; + width: 320px; +} + +.collapsed #scene-panel { + display: none; +} + +.panel-header-button.active, +.panel-header-button.active:hover { + background-color: $clr-hilight; + + svg { + color: $clr-active; + } +} + +.splat-list-container { + max-height: 300px; + overflow: auto; +} \ No newline at end of file diff --git a/src/ui/scss/select-toolbar.scss b/src/ui/scss/select-toolbar.scss new file mode 100644 index 0000000..517383f --- /dev/null +++ b/src/ui/scss/select-toolbar.scss @@ -0,0 +1,71 @@ +@use 'colors.scss' as *; + +.select-toolbar { + position: absolute; + left: 50%; + bottom: 100px; + height: 54px; + transform: translate(-50%, 0); + padding: 0px 8px; + border-radius: 8px; + + background-color: $bcg-primary; + + &:not(.pcui-hidden) { + display: flex; + } + flex-direction: row; + align-items: center; + + // buttons form a tight cluster like the bottom toolbar's controls + .select-toolbar-button { + height: 38px; + margin: 0px 1px; + padding: 0px 12px; + border-radius: 2px; + } + + // 16px group break from the preceding cluster, 6px to the input it labels + .select-toolbar-label { + margin: 0px 6px 0px 16px; + color: $text-secondary; + } + + // match the height treatment of the transform panel's inputs (see transform.scss); + // all numeric fields share one width so standalone inputs align with vector components + $input-height: 22px; + $field-width: 56px; + $field-gap: 10px; + + .select-toolbar-vector { + width: $field-width * 3 + $field-gap * 2; + margin: 0px; + gap: $field-gap; + height: $input-height; + + > .pcui-numeric-input { + margin: 0px; + height: $input-height; + line-height: $input-height; + + > input { + padding: 0px; + margin: 0px 0px 0px 4px; + height: $input-height; + } + } + } + + > .pcui-numeric-input { + width: $field-width; + margin: 0px; + height: $input-height; + line-height: $input-height; + + > input { + padding: 0px; + margin: 0px 0px 0px 4px; + height: $input-height; + } + } +} diff --git a/src/ui/scss/settings-dialog.scss b/src/ui/scss/settings-dialog.scss new file mode 100644 index 0000000..1d4a599 --- /dev/null +++ b/src/ui/scss/settings-dialog.scss @@ -0,0 +1,121 @@ +@use 'colors.scss' as *; + +.settings-dialog { + width: 100%; + height: 100%; + + background-color: $bcg-darken; + + pointer-events: all; + + #dialog { + position: absolute; + width: 400px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + overflow: hidden; + + border-radius: 8px; + background-color: $bcg-primary; + + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; + + #header { + height: 32px; + margin: 0; + padding: 0px 12px; + + background-color: $bcg-darker; + + #icon { + vertical-align: middle; + color: $clr-icon-hilight; + } + + text-transform: uppercase; + + #text { + margin: 0; + padding: 0px 12px; + line-height: 32px; + font-weight: bold; + color: $text-primary; + } + } + + #content { + min-height: 100px; + padding: 12px; + + .row { + // height: 24px; + line-height: 24px; + padding-bottom: 8px; + + &:not(.pcui-hidden) { + display: flex; + } + + .label { + width: 140px; + margin: 0px; + flex-grow: 0; + flex-shrink: 0; + } + + .select, .color-picker, .slider, .text-input, .boolean, .text-area { + margin: 0; + flex-grow: 1; + } + + .boolean { + margin: 5px 0 0 auto; + flex-grow: 0; + } + + .slider .pcui-slider-bar { + margin-right: 0px; + width: calc(100% - 9px); + } + + .vector-input { + margin: 0; + flex-grow: 1; + + div.pcui-numeric-input { + margin-top: 0; + margin-bottom: 0; + line-height: 22px; + > .pcui-numeric-input-slider-control::after { + top: -6px; + } + } + } + } + } + + #footer { + display: flex; + justify-content: center; + padding-bottom: 4px; + + .button { + width: 120px; + height: 30px; + border-radius: 4px; + + &:hover { + color: $text-primary; + background-color: $clr-hilight; + } + } + } + } +} diff --git a/src/ui/scss/settings-panel.scss b/src/ui/scss/settings-panel.scss new file mode 100644 index 0000000..7f9039f --- /dev/null +++ b/src/ui/scss/settings-panel.scss @@ -0,0 +1,77 @@ +@use 'colors.scss' as *; + +#settings-panel { + top: 50%; + transform: translate(0, -50%); + right: 102px; + width: 320px; + flex-direction: column; + + &:not(.pcui-hidden) { + display: flex; + } + + & > .settings-panel-row { + display: flex; + flex-direction: row; + padding: 2px; + height: 28px; + + & > .settings-panel-row-label { + flex-grow: 1; + } + + & > .settings-panel-row-toggle { + background-color: $bcg-dark; + + &::after { + background-color: $clr-default; + } + + &.pcui-boolean-input-ticked { + background-color: $clr-hilight; + + &::after { + background-color: $clr-active; + } + } + } + + & > .settings-panel-row-slider { + margin: 0px; + + & > .pcui-slider-container { + & > .pcui-slider-bar { + & > .pcui-slider-handle { + background-color: $clr-default; + border-radius: 3px; + } + } + } + } + + & > .settings-panel-row-pickers { + display: flex; + flex-direction: row; + margin: 2px 2px; + width: 185px; + justify-content: space-between; + + & > .settings-panel-row-picker { + margin: 0px 0px; + padding: 0px; + height: 24px; + } + } + + & > .settings-panel-row-select { + width: 187px; + margin: 0; + } + + & > .settings-panel-row-button { + flex-grow: 1; + margin: 0 2px; + } + } +} diff --git a/src/ui/scss/shortcuts-popup.scss b/src/ui/scss/shortcuts-popup.scss new file mode 100644 index 0000000..627aac4 --- /dev/null +++ b/src/ui/scss/shortcuts-popup.scss @@ -0,0 +1,105 @@ +@use 'colors.scss' as *; + +#shortcuts-popup { + width: 100%; + height: 100%; + + background-color: $bcg-darken; + + pointer-events: all; + + #dialog { + position: absolute; + width: 440px; + max-height: 80vh; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + overflow: hidden; + + border-radius: 8px; + background-color: $bcg-primary; + + filter: drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.8)); + + // the following is needed to get drop-shadow working on safari + will-change: transform; + + #header { + height: 32px; + min-height: 32px; + line-height: 32px; + margin: 0px; + padding: 0px 12px; + + font-weight: bold; + color: $text-primary; + background-color: $bcg-darker; + text-transform: uppercase; + } + + #content { + padding: 12px; + overflow-y: auto; + overflow-x: hidden; + } + } + + .shortcut-header { + margin: 8px 0 4px 0; + padding: 6px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + + &:first-child { + margin-top: 0; + } + + .shortcut-header-label { + margin: 0; + font-weight: 600; + font-size: 10px; + color: $clr-hilight; + text-transform: uppercase; + letter-spacing: 1.5px; + } + } + + .shortcut-entry { + display: flex; + flex-direction: row; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } + } + + .shortcut-key { + min-width: 140px; + margin: 0; + padding: 4px 8px; + + background-color: $bcg-darker; + border-radius: 4px; + + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; + font-size: 11px; + font-weight: 500; + color: $text-primary; + text-align: center; + white-space: nowrap; + } + + .shortcut-action { + flex-grow: 1; + margin: 0 0 0 12px; + color: $text-secondary; + font-size: 12px; + font-weight: 400; + } +} diff --git a/src/ui/scss/spinner.scss b/src/ui/scss/spinner.scss new file mode 100644 index 0000000..5442e1d --- /dev/null +++ b/src/ui/scss/spinner.scss @@ -0,0 +1,52 @@ +@use 'colors.scss' as *; + +#spinner-container { + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: $bcg-darken; + pointer-events: all; + cursor: progress; + + .spinner::before, .spinner::after { + border: 2px solid; + border-left: none; + box-sizing: border-box; + content: ''; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%); + transform-origin: 0% 50%; + animation: spinner-spin 1s linear 0s infinite; + border-width: 3px; + border-color: #aaa; + } + + .spinner::before { + width: 15px; + height: 30px; + border-radius: 0 30px 30px 0; + } + + .spinner::after { + width: 8px; + height: 16px; + border-radius: 0 16px 16px 0; + animation-direction: reverse; + } + + @keyframes spinner-spin { + 0% { + -webkit-transform: translateY(-50%) rotate(0deg); + transform: translateY(-50%) rotate(0deg); + } + + 100% { + -webkit-transform: translateY(-50%) rotate(360deg); + transform: translateY(-50%) rotate(360deg); + } + } +} \ No newline at end of file diff --git a/src/ui/scss/splat-list.scss b/src/ui/scss/splat-list.scss new file mode 100644 index 0000000..a74364b --- /dev/null +++ b/src/ui/scss/splat-list.scss @@ -0,0 +1,80 @@ +@use 'colors.scss' as *; + +.splat-list { + min-height: 80px; + padding: 4px 0px; + + .splat-item { + display: flex; + flex-direction: row; + padding: 2px; + + &:hover:not(.selected).visible { + cursor: pointer; + } + + &.selected { + background-color: $bcg-darker; + } + + #splat-edit { + margin: 0; + flex-grow: 1; + flex-shrink: 1; + } + + .splat-item-text { + flex-grow: 1; + flex-shrink: 1; + + .visible & { + &:hover:not(.selected) { + color: $text-primary; + } + } + + .selected & { + color: $text-primary; + } + } + + .splat-item-visible { + flex-grow: 0; + flex-shrink: 0; + + width: 24px; + height: 24px; + line-height: 24px; + + color: $text-secondary; + + cursor: pointer; + + .visible & { + color: $text-secondary; + } + + &:hover { + color: $text-primary; + } + } + + .splat-item-delete { + flex-grow: 0; + flex-shrink: 0; + + padding: 4px; + width: 16px; + height: 16px; + line-height: 16px; + + color: $text-secondary; + + cursor: pointer; + + &:hover { + color: $text-primary; + } + } + } +} \ No newline at end of file diff --git a/src/ui/scss/status-bar.scss b/src/ui/scss/status-bar.scss new file mode 100644 index 0000000..e5e621b --- /dev/null +++ b/src/ui/scss/status-bar.scss @@ -0,0 +1,65 @@ +@use 'colors.scss' as *; + +#status-bar { + width: 100%; + height: 32px; + display: flex; + flex-direction: row; + align-items: center; + background-color: $bcg-dark; + flex-shrink: 0; + border-top: 1px solid $bcg-primary; + padding: 0 4px; + position: relative; +} + +// toggle buttons for panels +.status-bar-toggle { + cursor: pointer; + font-size: 11px; + font-weight: bold !important; + color: $text-secondary; + height: 100%; + line-height: 32px; + padding: 0 8px; + white-space: nowrap; + border: none; + background-color: $bcg-primary; + + &:hover { + color: $text-primary; + background-color: $bcg-darkest; + } + + &.active { + color: $clr-active; + background-color: $clr-hilight !important; + } +} + +.status-bar-stats { + display: flex; + flex-direction: row; + align-items: center; + margin-left: auto; + height: 100%; + padding-right: 8px; +} + +.status-bar-stat { + display: flex; + flex-direction: row; + align-items: center; + padding: 0 8px; +} + +.status-bar-stat-label { + color: $text-secondary; + font-size: 11px; + margin-right: 4px; +} + +.status-bar-stat-value { + color: $text-primary; + font-size: 11px; +} diff --git a/src/ui/scss/style.scss b/src/ui/scss/style.scss new file mode 100644 index 0000000..0c7fc5c --- /dev/null +++ b/src/ui/scss/style.scss @@ -0,0 +1,278 @@ +@use 'pcui-theme-grey.scss'; +@use 'colors.scss' as *; +@use 'tooltips.scss'; +@use 'panel.scss'; +@use 'menu-panel.scss'; +@use 'menu.scss'; +@use 'scene-panel.scss'; +@use 'settings-panel.scss'; +@use 'color-panel.scss'; +@use 'splat-list.scss'; +@use 'transform.scss'; +@use 'bottom-toolbar.scss'; +@use 'right-toolbar.scss'; +@use 'select-toolbar.scss'; +@use 'data-panel.scss'; +@use 'popup.scss'; +@use 'mode-toggle.scss'; +@use 'settings-dialog.scss'; +@use 'spinner.scss'; +@use 'progress.scss'; +@use 'export-popup.scss'; +@use 'shortcuts-popup.scss'; +@use 'about-popup.scss'; +@use 'timeline-panel.scss'; +@use 'status-bar.scss'; +@use 'tool.scss'; + +* { + font-size: 12px; + user-select: none; +} + +// not on '*' - that breaks wheel scrolling over overflow:hidden elements (e.g. pcui labels) +html { + height: 100%; + overscroll-behavior: none; +} + +body { + margin: 0; + padding: 0; + height: 100%; + max-height: 100%; + background-color: black; + overflow: hidden; + touch-action: none; + overscroll-behavior: none; +} + +#app-container { + width: 100%; + height: 100%; +} + +#editor-container { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: row; +} + +#main-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border: 0; + padding: 0; + margin: 0; + flex-grow: 1; +} + +#sep-container { + background-color: $bcg-darker; +} + +#sep-container > span { + color: white; +} + +#coord-space-toggle.active { + background-color: $bcg-dark !important; + color: #f60; +} + +#file-selector { + display: none; +} + +#file-menu { + position: absolute; +} + +.file-menu-item span { + padding: 6px; + color: $text-secondary !important; + font-size: 14px; +} + +#app-label { + position: absolute; + right: 20px; + bottom: 20px; + color: $text-primary; + text-shadow: 1px 1px 4px black; +} + +#camera-info-overlay { + position: absolute; + left: 12px; + bottom: 17px; + text-shadow: 1px 1px 4px black; + font-family: ui-monospace, Menlo, Consolas, monospace; + + .camera-info-row { + display: flex; + flex-direction: row; + align-items: center; + padding: 1px 0; + font-family: inherit; + } + + .camera-info-key { + margin: 0; + color: $clr-icon-hilight; + font-family: inherit; + font-weight: bold; + overflow: visible; + } + + .camera-info-value { + margin: 0 0 0 8px; + color: $text-primary; + font-family: inherit; + font-variant-numeric: tabular-nums; + overflow: visible; + cursor: text; + user-select: text; + padding: 0 4px; + border-radius: 4px; + border: 1px solid transparent; + outline: none; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.25); + } + + &:focus { + border-color: $clr-hilight; + background-color: rgba(0, 0, 0, 0.25); + } + + &.flash-ok { + background-color: rgba(120, 220, 140, 0.35); + } + + &.flash-bad { + background-color: rgba(220, 100, 100, 0.45); + } + } +} + +#view-cube-container { + position: absolute; + width: 140px; + height: 140px; + right: 0px; + top: 0px; + pointer-events: none; +} + +#mask-canvas { + display: none; + position: absolute; + opacity: 0.4; +} + +#canvas-container { + width: 100%; + display: flex; + border: 0; + padding: 0; + margin: 0; + flex-grow: 1; +} + +#tools-container { + display: none; + position: absolute; + width: 100%; + height: 100%; + cursor: crosshair; + &.noevents { + pointer-events: none; + } +} + +#canvas { + width: 100%; + height: 100%; + image-rendering: pixelated; +} + +#tooltips-container { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +#top-container { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +// placeholder +.pcui-input-element[placeholder] { + &::after { + color: $text-darkest; + } +} + +.pcui-vector-input { + margin-left: 6px; + margin-right: 6px; +} + +/* scrollbar styling */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: $bcg-darkest; +} +::-webkit-scrollbar-thumb { + background: $bcg-lighter; +} +::-webkit-scrollbar-thumb:hover { + background: $clr-hilight; +} +::-webkit-scrollbar-corner { + background: $bcg-darkest; +} + +.font-thin { + font-family: 'Proxima Nova Thin', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-weight: 100; + font-style: normal; +} + +.font-light { + font-family: 'Proxima Nova Light', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-weight: 200; + font-style: normal; +} + +.font-regular { + font-family: 'Proxima Nova Regular', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-weight: normal; + font-style: normal; +} + +.font-bold { + font-family: 'Proxima Nova Bold', 'Helvetica Neue', Arial, Helvetica, sans-serif; + font-weight: bold; + font-style: normal; +} diff --git a/src/ui/scss/timeline-panel.scss b/src/ui/scss/timeline-panel.scss new file mode 100644 index 0000000..d31bdd2 --- /dev/null +++ b/src/ui/scss/timeline-panel.scss @@ -0,0 +1,186 @@ +@use 'colors.scss' as *; + +#timeline-panel { + flex-direction: column; + border-top: none; + + &:not(.pcui-hidden) { + display: flex; + } + + > #controls-wrap { + display: flex; + flex-direction: row; + background-color: $bcg-primary; + justify-content: center; + padding: 1px; + + > #button-controls { + display: flex; + flex-direction: row; + align-items: center; + + > .button { + font-family: pc-icon; + font-weight: bold; + font-size: 13px; + + width: 36px; + height: 24px; + margin: 1px; + padding: 0; + flex-grow: 0; + flex-shrink: 0; + + border-radius: 4px; + + text-align: center; + line-height: 24px; + + &:hover { + color: #ff9900; + cursor: pointer; + box-shadow: none; + border-color: #ff990088; + } + } + } + + > .spacer { + display: flex; + flex-grow: 1; + flex-basis: 0; + justify-content: flex-end; + + > #settings-controls { + display: flex; + align-items: center; + gap: 1px; + margin-right: 1px; + + > #speed { + margin: 0; + width: 80px; + } + + > #totalFrames { + margin: 0; + width: 80px; + } + + > #smoothness { + margin: 0; + width: 60px; + } + + > #loop { + font-family: pc-icon; + font-weight: bold; + font-size: 13px; + + width: 36px; + height: 24px; + margin: 0; + padding: 0; + flex-grow: 0; + flex-shrink: 0; + + border-radius: 4px; + + text-align: center; + line-height: 24px; + + &:hover { + color: #ff9900; + cursor: pointer; + box-shadow: none; + border-color: #ff990088; + } + + &.active { + color: $clr-hilight; + } + } + } + } + } + + > #frame-slider { + height: 24px; + margin: 0px; + + > .pcui-numeric-input { + display: none; + flex-grow: 0; + margin: 0; + > input { + width: 40px; + } + } + } + + > #ticks { + height: 38px; + background-color: $bcg-darkest; + + > #ticks-area { + width: 100%; + height: 100%; + + > .time-tick { + position: absolute; + bottom: 1px; + width: 1px; + height: 5px; + transform: translate(-50%, 0); + background-color: $clr-disabled; + pointer-events: none; + } + + > .time-label { + position: absolute; + font-size: 12px; + bottom: 1px; + transform: translate(-50%, 0); + padding: 2px; + pointer-events: none; + + color: $clr-default; + + &.cursor { + color: $clr-active; + background-color: $clr-hilight; + padding: 2px 6px; + border-radius: 4px; + } + + &.key { + background-color: $clr-icon-hilight; + bottom: 22px; + width: 8px; + height: 8px; + // rectangle shape + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); + // circle shape + // border-radius: 50%; + cursor: pointer; + pointer-events: auto; + } + + // declared before dragging/copying so those cursors win + // during a ctrl+drag + &.stamping { + cursor: crosshair; + } + + &.dragging { + cursor: ew-resize; + } + + &.copying { + cursor: copy; + } + } + } + } +} \ No newline at end of file diff --git a/src/ui/scss/tool.scss b/src/ui/scss/tool.scss new file mode 100644 index 0000000..c6b642e --- /dev/null +++ b/src/ui/scss/tool.scss @@ -0,0 +1,79 @@ + +.tool-svg { + display: inline; + position: absolute; + width: 100%; + height: 100%; + + &.hidden { + display: none; + } + + &#brush-select-svg { + >circle { + fill: rgba(255, 102, 0, 0.2); + stroke: #f60; + stroke-width: 1; + stroke-dasharray: 5, 5; + } + } + + &#rect-select-svg { + >rect { + fill: none; + stroke: #f60; + stroke-width: 1; + stroke-dasharray: 5, 5; + } + } + + &#lasso-select-svg { + >polygon { + fill: none; + stroke-width: 1; + stroke-dasharray: 5, 5; + stroke-dashoffset: 0; + } + } + + &#polygon-select-svg { + >polyline { + fill: none; + stroke-width: 1; + stroke-dasharray: 5, 5; + stroke-dashoffset: 0; + } + } + + &#bound-dimensions-svg { + pointer-events: none; + + > text { + font-family: monospace; + font-size: 11px; + fill: white; + paint-order: stroke; + stroke: rgba(0, 0, 0, 0.7); + stroke-width: 3px; + } + } + + &#measure-tool-svg { + >#measure-line-bottom { + stroke: black; + stroke-width: 6; + } + + >#measure-line-top { + stroke: white; + stroke-width: 2; + } + + >#measure-line-start, >#measure-line-end { + fill: white; + stroke: black; + stroke-width: 2; + r: 5; + } + } +} diff --git a/src/ui/scss/tooltips.scss b/src/ui/scss/tooltips.scss new file mode 100644 index 0000000..450a827 --- /dev/null +++ b/src/ui/scss/tooltips.scss @@ -0,0 +1,21 @@ +@use 'colors.scss' as *; + +.tooltips { + position: absolute; + background-color: $bcg-darkest; + border: 1px solid $bcg-primary; + padding: 4px; + box-sizing: border-box; +} + +.tooltips-content { + display: block; + box-sizing: border-box; + max-width: min(260px, calc(100vw - 24px)); + white-space: normal; + overflow-wrap: break-word; + word-wrap: break-word; + + color: $clr-default; + margin: 0; +} diff --git a/src/ui/scss/transform.scss b/src/ui/scss/transform.scss new file mode 100644 index 0000000..edb90af --- /dev/null +++ b/src/ui/scss/transform.scss @@ -0,0 +1,63 @@ +@use 'colors.scss' as *; + +#transform { + display: flex; + flex-direction: column; + + background-color: $bcg-primary; + + padding: 6px; +} + +.transform-row { + height: 32px; + line-height: 32px; + width: 100%; + display: flex; + flex-direction: row; + flex-grow: 1; + align-items: center; +} + +.transform-label { + width: 70px; + flex-shrink: 0; + flex-grow: 0; + margin: 0px; +} + +.transform-expand { + flex-grow: 1; +} + +$height: 22px; + +#transform > div > div.pcui-vector-input { + margin: 0px; + gap: 10px; + height: $height; +} + +#transform > div > div.pcui-numeric-input { + margin: 0px; + height: $height; + line-height: $height; + + & > input { + padding: 0px; + margin: 0px 0px 0px 4px; + height: $height; + } +} + +#transform > div > div > div.pcui-numeric-input { + margin: 0px; + height: $height; + line-height: $height; + + & > input { + padding: 0px; + margin: 0px 0px 0px 4px; + height: $height; + } +} \ No newline at end of file diff --git a/src/ui/select-cursor.ts b/src/ui/select-cursor.ts new file mode 100644 index 0000000..a291c2a --- /dev/null +++ b/src/ui/select-cursor.ts @@ -0,0 +1,75 @@ +import { Events } from '../events'; +import { opFromModifiers } from '../select-op'; +import addCursor from './svg/cursor-add.svg'; +import intersectCursor from './svg/cursor-intersect.svg'; +import removeCursor from './svg/cursor-remove.svg'; + +// tools whose selection op is driven by shift/ctrl modifiers (see opFromModifiers) +const pointerTools = new Set([ + 'rectSelection', + 'brushSelection', + 'floodSelection', + 'polygonSelection', + 'lassoSelection' +]); + +// op → cursor image (data-URI). 'set' is intentionally absent: it clears the +// inline style so the underlying default/crosshair cursor applies. +const cursors: Record = { + add: addCursor, + remove: removeCursor, + intersect: intersectCursor +}; + +// Set (or clear, for 'set') the op cursor on an element. Hotspot at the crosshair +// centre (16 16); the trailing `crosshair` is the fallback when the browser can't +// render an SVG cursor (older Safari). Shared by the selection tools and the +// histogram so both surfaces show the same add/remove/intersect badge. +const applyOpCursor = (element: HTMLElement, op: string) => { + element.style.cursor = cursors[op] ? `url("${cursors[op]}") 16 16, crosshair` : ''; +}; + +// While a pointer selection tool is active, swap the cursor on the tools overlay +// to reflect the op the held modifiers will produce, so the user sees +// add/remove/intersect before acting. +const registerSelectCursor = (events: Events, toolsContainer: HTMLElement) => { + let engaged = false; + // track modifier state so the cursor is correct the instant a tool is activated + // with Shift/Ctrl already held, not only after the next key event. + const modifiers = { shiftKey: false, ctrlKey: false }; + + const refresh = () => { + if (engaged) { + applyOpCursor(toolsContainer, opFromModifiers(modifiers)); + } + }; + + const onKey = (e: KeyboardEvent) => { + modifiers.shiftKey = e.shiftKey; + modifiers.ctrlKey = e.ctrlKey; + refresh(); + }; + + events.on('tool.activated', (name: string) => { + engaged = pointerTools.has(name); + refresh(); + }); + + events.on('tool.deactivated', () => { + engaged = false; + applyOpCursor(toolsContainer, 'set'); + }); + + // capture phase so we still see keydown/keyup when a focused dialog stops + // propagation; blur clears the tracked modifiers because a key release while + // unfocused never fires. (mirrors the modifier tracking in controllers.ts) + window.addEventListener('keydown', onKey, { capture: true }); + window.addEventListener('keyup', onKey, { capture: true }); + window.addEventListener('blur', () => { + modifiers.shiftKey = false; + modifiers.ctrlKey = false; + refresh(); + }); +}; + +export { registerSelectCursor, applyOpCursor }; diff --git a/src/ui/settings-panel.ts b/src/ui/settings-panel.ts new file mode 100644 index 0000000..b79c0ad --- /dev/null +++ b/src/ui/settings-panel.ts @@ -0,0 +1,648 @@ +import { BooleanInput, Button, ColorPicker, Container, Label, SelectInput, SliderInput } from '@playcanvas/pcui'; +import { Color } from 'playcanvas'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +class SettingsPanel extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'settings-panel', + class: 'panel', + hidden: true + }; + + super(args); + + // stop pointer events bubbling + ['pointerdown', 'pointerup', 'pointermove', 'wheel', 'dblclick'].forEach((eventName) => { + this.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); + }); + + // header + + const header = new Container({ + class: 'panel-header' + }); + + const icon = new Label({ + text: '\uE403', + class: 'panel-header-icon' + }); + + const label = new Label({ + class: 'panel-header-label' + }); + i18n.bindText(label, 'panel.settings'); + + header.append(icon); + header.append(label); + + // language + + const languageRow = new Container({ + class: 'settings-panel-row' + }); + + const languageLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(languageLabel, 'panel.settings.language'); + + const languageSelection = new SelectInput({ + class: 'settings-panel-row-select', + // 'auto' unless the user has explicitly pinned a language + defaultValue: i18n.storedLanguage ?? 'auto' + }); + // 'auto' label follows the language; the per-language names are shown in + // their native form so they're recognisable regardless of current UI lang + i18n.bindOptions(languageSelection, () => [ + { v: 'auto', t: i18n.t('panel.settings.language.auto') }, + ...i18n.languages.map(l => ({ v: l.code, t: l.name })) + ]); + + // switch language live (no reload). a stored choice persists across + // sessions; 'auto' clears it and reverts to the browser locale. + languageSelection.on('change', (value: string) => { + i18n.setLanguage(value === 'auto' ? null : value); + }); + + languageRow.append(languageLabel); + languageRow.append(languageSelection); + + // colors + + const clrRow = new Container({ + class: 'settings-panel-row' + }); + + const clrLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(clrLabel, 'panel.settings.colors'); + + const clrPickers = new Container({ + class: 'settings-panel-row-pickers' + }); + + const bgClrPicker = new ColorPicker({ + class: 'settings-panel-row-picker', + channels: 3, + value: [0, 0, 0] + }); + + const selectedClrPicker = new ColorPicker({ + class: 'settings-panel-row-picker', + channels: 4, + value: [0, 0, 0, 1] + }); + + const unselectedClrPicker = new ColorPicker({ + class: 'settings-panel-row-picker', + channels: 4, + value: [0, 0, 0, 1] + }); + + const lockedClrPicker = new ColorPicker({ + class: 'settings-panel-row-picker', + channels: 4, + value: [0, 0, 0, 1] + }); + + const toArray = (clr: Color) => { + return [clr.r, clr.g, clr.b, clr.a]; + }; + + events.on('bgClr', (clr: Color) => { + bgClrPicker.value = toArray(clr); + }); + + events.on('selectedClr', (clr: Color) => { + selectedClrPicker.value = toArray(clr); + }); + + events.on('unselectedClr', (clr: Color) => { + unselectedClrPicker.value = toArray(clr); + }); + + events.on('lockedClr', (clr: Color) => { + lockedClrPicker.value = toArray(clr); + }); + + clrPickers.append(bgClrPicker); + clrPickers.append(selectedClrPicker); + clrPickers.append(unselectedClrPicker); + clrPickers.append(lockedClrPicker); + + clrRow.append(clrLabel); + clrRow.append(clrPickers); + + // tonemapping + + const tonemappingRow = new Container({ + class: 'settings-panel-row' + }); + + const tonemappingLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(tonemappingLabel, 'panel.settings.tonemapping'); + + const tonemappingSelection = new SelectInput({ + class: 'settings-panel-row-select', + defaultValue: 'linear' + }); + i18n.bindOptions(tonemappingSelection, () => [ + { v: 'linear', t: i18n.t('panel.settings.tonemapping.linear') }, + { v: 'neutral', t: i18n.t('panel.settings.tonemapping.neutral') }, + { v: 'aces', t: i18n.t('panel.settings.tonemapping.aces') }, + { v: 'aces2', t: i18n.t('panel.settings.tonemapping.aces2') }, + { v: 'filmic', t: i18n.t('panel.settings.tonemapping.filmic') }, + { v: 'hejl', t: i18n.t('panel.settings.tonemapping.hejl') } + ]); + + tonemappingRow.append(tonemappingLabel); + tonemappingRow.append(tonemappingSelection); + + // camera fov + + const fovRow = new Container({ + class: 'settings-panel-row' + }); + + const fovLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(fovLabel, 'panel.settings.fov'); + + const fovSlider = new SliderInput({ + class: 'settings-panel-row-slider', + min: 10, + max: 120, + precision: 1, + value: 60 + }); + + fovRow.append(fovLabel); + fovRow.append(fovSlider); + + // fov auto dolly + + const fovDollyRow = new Container({ + class: 'settings-panel-row' + }); + + const fovDollyLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(fovDollyLabel, 'panel.settings.fov-dolly'); + + const fovDollyToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + fovDollyRow.append(fovDollyLabel); + fovDollyRow.append(fovDollyToggle); + + // sh bands + const shBandsRow = new Container({ + class: 'settings-panel-row' + }); + + const shBandsLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(shBandsLabel, 'panel.settings.sh-bands'); + + const shBandsSlider = new SliderInput({ + class: 'settings-panel-row-slider', + min: 0, + max: 3, + precision: 0, + value: 3 + }); + + shBandsRow.append(shBandsLabel); + shBandsRow.append(shBandsSlider); + + // camera fly speed + + const cameraFlySpeedRow = new Container({ + class: 'settings-panel-row' + }); + + const cameraFlySpeedLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(cameraFlySpeedLabel, 'panel.settings.fly-speed'); + + const cameraFlySpeedSlider = new SliderInput({ + class: 'settings-panel-row-slider', + min: 0.1, + max: 30, + precision: 1, + value: 1 + }); + + cameraFlySpeedRow.append(cameraFlySpeedLabel); + cameraFlySpeedRow.append(cameraFlySpeedSlider); + + // centers size + + const centersSizeRow = new Container({ + class: 'settings-panel-row' + }); + + const centersSizeLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(centersSizeLabel, 'panel.settings.centers-size'); + + const centersSizeSlider = new SliderInput({ + class: 'settings-panel-row-slider', + min: 0, + max: 10, + precision: 1, + value: 2 + }); + + centersSizeRow.append(centersSizeLabel); + centersSizeRow.append(centersSizeSlider); + + // centers gaussian color + const centersColorRow = new Container({ + class: 'settings-panel-row' + }); + + const centersColorLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(centersColorLabel, 'panel.settings.centers-gaussian-color'); + + const centersColorToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + centersColorRow.append(centersColorLabel); + centersColorRow.append(centersColorToggle); + + // outline selection + + const outlineSelectionRow = new Container({ + class: 'settings-panel-row' + }); + + const outlineSelectionLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(outlineSelectionLabel, 'panel.settings.outline-selection'); + + const outlineSelectionToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + outlineSelectionRow.append(outlineSelectionLabel); + outlineSelectionRow.append(outlineSelectionToggle); + + // show grid + + const showGridRow = new Container({ + class: 'settings-panel-row' + }); + + const showGridLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(showGridLabel, 'panel.settings.show-grid'); + + const showGridToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: true + }); + + showGridRow.append(showGridLabel); + showGridRow.append(showGridToggle); + + // show bound + + const showBoundRow = new Container({ + class: 'settings-panel-row' + }); + + const showBoundLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(showBoundLabel, 'panel.settings.show-bound'); + + const showBoundToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: true + }); + + showBoundRow.append(showBoundLabel); + showBoundRow.append(showBoundToggle); + + // show dimensions + + const showBoundDimensionsRow = new Container({ + class: 'settings-panel-row' + }); + + const showBoundDimensionsLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(showBoundDimensionsLabel, 'panel.settings.show-bound-dimensions'); + + const showBoundDimensionsToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + showBoundDimensionsRow.append(showBoundDimensionsLabel); + showBoundDimensionsRow.append(showBoundDimensionsToggle); + + // show camera poses + + const showCameraPosesRow = new Container({ + class: 'settings-panel-row' + }); + + const showCameraPosesLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(showCameraPosesLabel, 'panel.settings.show-camera-poses'); + + const showCameraPosesToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + showCameraPosesRow.append(showCameraPosesLabel); + showCameraPosesRow.append(showCameraPosesToggle); + + // show camera info + + const showCameraInfoRow = new Container({ + class: 'settings-panel-row' + }); + + const showCameraInfoLabel = new Label({ + class: 'settings-panel-row-label' + }); + i18n.bindText(showCameraInfoLabel, 'panel.settings.show-camera-info'); + + const showCameraInfoToggle = new BooleanInput({ + type: 'toggle', + class: 'settings-panel-row-toggle', + value: false + }); + + showCameraInfoRow.append(showCameraInfoLabel); + showCameraInfoRow.append(showCameraInfoToggle); + + // reset preferences to defaults + + const resetRow = new Container({ + class: 'settings-panel-row' + }); + + const resetButton = new Button({ + class: 'settings-panel-row-button' + }); + i18n.bindText(resetButton, 'panel.settings.reset'); + + resetRow.append(resetButton); + + this.append(header); + this.append(languageRow); + this.append(clrRow); + this.append(tonemappingRow); + this.append(fovRow); + this.append(fovDollyRow); + this.append(shBandsRow); + this.append(cameraFlySpeedRow); + this.append(centersSizeRow); + this.append(centersColorRow); + this.append(outlineSelectionRow); + this.append(showGridRow); + this.append(showBoundRow); + this.append(showBoundDimensionsRow); + this.append(showCameraPosesRow); + this.append(showCameraInfoRow); + this.append(resetRow); + + // handle panel visibility + + const setVisible = (visible: boolean) => { + if (visible === this.hidden) { + this.hidden = !visible; + events.fire('settingsPanel.visible', visible); + } + }; + + events.function('settingsPanel.visible', () => { + return !this.hidden; + }); + + events.on('settingsPanel.setVisible', (visible: boolean) => { + setVisible(visible); + }); + + events.on('settingsPanel.toggleVisible', () => { + setVisible(this.hidden); + }); + + events.on('colorPanel.visible', (visible: boolean) => { + if (visible) { + setVisible(false); + } + }); + + // sh bands + + events.on('view.bands', (bands: number) => { + shBandsSlider.value = bands; + }); + + shBandsSlider.on('change', (value: number) => { + events.fire('view.setBands', value); + }); + + // splat size + + events.on('camera.splatSize', (value: number) => { + centersSizeSlider.value = value; + }); + + centersSizeSlider.on('change', (value: number) => { + events.fire('camera.setSplatSize', value); + events.fire('camera.setOverlay', true); + events.fire('camera.setMode', 'centers'); + }); + + // centers gaussian color + events.on('view.centersUseGaussianColor', (value: boolean) => { + centersColorToggle.value = value; + }); + + centersColorToggle.on('change', (value: boolean) => { + events.fire('view.setCentersUseGaussianColor', value); + }); + + // camera speed + + events.on('camera.flySpeed', (value: number) => { + cameraFlySpeedSlider.value = value; + }); + + cameraFlySpeedSlider.on('change', (value: number) => { + events.fire('camera.setFlySpeed', value); + }); + + // fov auto dolly + + events.on('camera.fovDolly', (value: boolean) => { + fovDollyToggle.value = value; + }); + + fovDollyToggle.on('change', (value: boolean) => { + events.fire('camera.setFovDolly', value); + }); + + // outline selection + + events.on('view.outlineSelection', (value: boolean) => { + outlineSelectionToggle.value = value; + }); + + outlineSelectionToggle.on('change', (value: boolean) => { + events.fire('view.setOutlineSelection', value); + }); + + // show grid + + events.on('grid.visible', (visible: boolean) => { + showGridToggle.value = visible; + }); + + showGridToggle.on('change', () => { + events.fire('grid.setVisible', showGridToggle.value); + }); + + // show bound + + events.on('camera.bound', (visible: boolean) => { + showBoundToggle.value = visible; + }); + + showBoundToggle.on('change', () => { + events.fire('camera.setBound', showBoundToggle.value); + }); + + // show dimensions + + events.on('camera.boundDimensions', (visible: boolean) => { + showBoundDimensionsToggle.value = visible; + }); + + showBoundDimensionsToggle.on('change', () => { + events.fire('camera.setBoundDimensions', showBoundDimensionsToggle.value); + }); + + // show camera poses + + events.on('camera.showPoses', (visible: boolean) => { + showCameraPosesToggle.value = visible; + }); + + showCameraPosesToggle.on('change', () => { + events.fire('camera.setShowPoses', showCameraPosesToggle.value); + }); + + // show camera info + + events.on('camera.showInfo', (visible: boolean) => { + showCameraInfoToggle.value = visible; + }); + + showCameraInfoToggle.on('change', () => { + events.fire('camera.setShowInfo', showCameraInfoToggle.value); + }); + + // background color + + bgClrPicker.on('change', (value: number[]) => { + events.fire('setBgClr', new Color(value[0], value[1], value[2])); + }); + + selectedClrPicker.on('change', (value: number[]) => { + events.fire('setSelectedClr', new Color(value[0], value[1], value[2], value[3])); + }); + + unselectedClrPicker.on('change', (value: number[]) => { + events.fire('setUnselectedClr', new Color(value[0], value[1], value[2], value[3])); + }); + + lockedClrPicker.on('change', (value: number[]) => { + events.fire('setLockedClr', new Color(value[0], value[1], value[2], value[3])); + }); + + // camera fov + + events.on('camera.fov', (fov: number) => { + fovSlider.value = fov; + }); + + fovSlider.on('change', (value: number) => { + events.fire('camera.setFov', value); + }); + + // tonemapping + + events.on('camera.tonemapping', (tonemapping: string) => { + tonemappingSelection.value = tonemapping; + }); + + tonemappingSelection.on('change', (value: string) => { + events.fire('camera.setTonemapping', value); + }); + + // reset preferences + + resetButton.on('click', () => { + events.fire('preferences.reset'); + }); + + // reset reverts language to automatic; sync the selector (its change + // handler makes the equivalent setLanguage(null) call idempotently) + events.on('preferences.reset', () => { + languageSelection.value = 'auto'; + }); + + // tooltips + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + const shortcut = shortcutManager.formatShortcut('grid.toggleVisible'); + tooltips.register(showGridLabel, () => i18n.formatTooltipWithShortcut(i18n.t('panel.settings.show-grid'), shortcut), 'left'); + const cameraInfoShortcut = shortcutManager.formatShortcut('camera.toggleShowInfo'); + tooltips.register(showCameraInfoLabel, () => i18n.formatTooltipWithShortcut(i18n.t('panel.settings.show-camera-info'), cameraInfoShortcut), 'left'); + tooltips.register(bgClrPicker, () => i18n.t('panel.settings.background-color'), 'left'); + tooltips.register(selectedClrPicker, () => i18n.t('panel.settings.selected-color'), 'top'); + tooltips.register(unselectedClrPicker, () => i18n.t('panel.settings.unselected-color'), 'top'); + tooltips.register(lockedClrPicker, () => i18n.t('panel.settings.locked-color'), 'top'); + } +} + +export { SettingsPanel }; diff --git a/src/ui/shortcuts-popup.ts b/src/ui/shortcuts-popup.ts new file mode 100644 index 0000000..5c34ca3 --- /dev/null +++ b/src/ui/shortcuts-popup.ts @@ -0,0 +1,246 @@ +import { Container, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; + +// Popup display configuration - maps shortcuts to categories and locale keys +// This is separate from the shortcut bindings themselves (separation of concerns) +interface ShortcutDisplayItem { + id: string; // event ID to look up in ShortcutManager + localeKey: string; // localization key for the action description +} + +interface HintDisplayItem { + displayKey: string; // what to show in the key column + localeKey: string; // localization key for the action description +} + +interface CategoryConfig { + localeKey: string; + shortcuts: ShortcutDisplayItem[]; + hints?: HintDisplayItem[]; +} + +// Display configuration for the shortcuts popup +const popupConfig: Record = { + navigation: { + localeKey: 'popup.shortcuts.navigation', + shortcuts: [ + { id: 'camera.reset', localeKey: 'popup.shortcuts.reset-camera' }, + { id: 'camera.focus', localeKey: 'popup.shortcuts.focus-camera' }, + { id: 'camera.toggleControlMode', localeKey: 'popup.shortcuts.toggle-control-mode' } + ] + }, + camera: { + localeKey: 'popup.shortcuts.camera', + shortcuts: [], + hints: [ + { displayKey: 'W / A / S / D', localeKey: 'popup.shortcuts.fly-movement' }, + { displayKey: 'Q / E', localeKey: 'popup.shortcuts.fly-vertical' }, + { displayKey: 'Shift', localeKey: 'popup.shortcuts.fly-speed-fast' }, + { displayKey: 'Alt', localeKey: 'popup.shortcuts.fly-speed-slow' } + ] + }, + show: { + localeKey: 'popup.shortcuts.show', + shortcuts: [ + { id: 'camera.toggleOverlay', localeKey: 'popup.shortcuts.toggle-splat-overlay' }, + { id: 'camera.toggleMode', localeKey: 'popup.shortcuts.toggle-overlay-mode' }, + { id: 'grid.toggleVisible', localeKey: 'popup.shortcuts.toggle-grid' }, + { id: 'camera.toggleShowInfo', localeKey: 'popup.shortcuts.toggle-camera-info' }, + { id: 'select.hide', localeKey: 'popup.shortcuts.lock-selected-splats' }, + { id: 'select.unhide', localeKey: 'popup.shortcuts.unlock-all-splats' } + ] + }, + selection: { + localeKey: 'popup.shortcuts.selection', + shortcuts: [ + { id: 'select.all', localeKey: 'popup.shortcuts.select-all' }, + { id: 'select.none', localeKey: 'popup.shortcuts.deselect-all' }, + { id: 'select.invert', localeKey: 'popup.shortcuts.invert-selection' }, + { id: 'select.delete', localeKey: 'popup.shortcuts.delete-selected-splats' } + ], + hints: [ + { displayKey: 'Shift', localeKey: 'popup.shortcuts.add-to-selection' }, + { displayKey: 'Ctrl', localeKey: 'popup.shortcuts.remove-from-selection' } + ] + }, + tools: { + localeKey: 'popup.shortcuts.tools', + shortcuts: [ + { id: 'tool.move', localeKey: 'popup.shortcuts.move' }, + { id: 'tool.rotate', localeKey: 'popup.shortcuts.rotate' }, + { id: 'tool.scale', localeKey: 'popup.shortcuts.scale' }, + { id: 'tool.rectSelection', localeKey: 'popup.shortcuts.rect-selection' }, + { id: 'tool.lassoSelection', localeKey: 'popup.shortcuts.lasso-selection' }, + { id: 'tool.polygonSelection', localeKey: 'popup.shortcuts.polygon-selection' }, + { id: 'tool.brushSelection', localeKey: 'popup.shortcuts.brush-selection' }, + { id: 'tool.floodSelection', localeKey: 'popup.shortcuts.flood-selection' }, + { id: 'tool.eyedropperSelection', localeKey: 'popup.shortcuts.eyedropper-selection' }, + { id: 'tool.deactivate', localeKey: 'popup.shortcuts.deactivate-tool' }, + { id: 'tool.toggleCoordSpace', localeKey: 'popup.shortcuts.toggle-gizmo-coordinate-space' } + ], + hints: [ + { displayKey: '[ ]', localeKey: 'popup.shortcuts.brush-size' } + ] + }, + playback: { + localeKey: 'popup.shortcuts.playback', + shortcuts: [ + { id: 'timeline.togglePlay', localeKey: 'popup.shortcuts.play-pause' }, + { id: 'timeline.prevFrame', localeKey: 'popup.shortcuts.prev-frame' }, + { id: 'timeline.nextFrame', localeKey: 'popup.shortcuts.next-frame' }, + { id: 'timeline.prevKey', localeKey: 'popup.shortcuts.prev-key' }, + { id: 'timeline.nextKey', localeKey: 'popup.shortcuts.next-key' }, + { id: 'track.addKey', localeKey: 'popup.shortcuts.add-key' }, + { id: 'track.removeKey', localeKey: 'popup.shortcuts.remove-key' } + ] + }, + other: { + localeKey: 'popup.shortcuts.other', + shortcuts: [ + { id: 'edit.undo', localeKey: 'popup.shortcuts.undo' }, + { id: 'edit.redo', localeKey: 'popup.shortcuts.redo' }, + { id: 'dataPanel.toggle', localeKey: 'popup.shortcuts.toggle-data-panel' }, + { id: 'timelinePanel.toggle', localeKey: 'popup.shortcuts.toggle-timeline-panel' } + ] + } +}; + +// Category display order +const categoryOrder = ['navigation', 'camera', 'show', 'selection', 'tools', 'playback', 'other']; + +class ShortcutsPopup extends Container { + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'shortcuts-popup', + hidden: true, + tabIndex: -1 + }; + + super(args); + + // Handle keyboard events to prevent global shortcuts from firing + this.dom.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.hidden = true; + } + e.stopPropagation(); + }); + + // Close when clicking outside dialog + this.on('click', () => { + this.hidden = true; + }); + + const dialog = new Container({ + id: 'dialog' + }); + + // Prevent clicks inside dialog from closing + dialog.on('click', (event: MouseEvent) => { + event.stopPropagation(); + }); + + // Header + const header = new Label({ + id: 'header' + }); + i18n.bindText(header, () => i18n.t('popup.shortcuts.title').toUpperCase()); + + // Content + const content = new Container({ + id: 'content' + }); + + // Get the shortcut manager from events + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + + // Build the shortcut list from the popup display configuration + for (const categoryId of categoryOrder) { + const config = popupConfig[categoryId]; + if (!config) continue; + + // Add category header + const headerLabel = new Label({ + class: 'shortcut-header-label' + }); + i18n.bindText(headerLabel, config.localeKey); + + const headerEntry = new Container({ + class: 'shortcut-header' + }); + + headerEntry.append(headerLabel); + content.append(headerEntry); + + // Add shortcuts for this category + for (const item of config.shortcuts) { + const keyText = shortcutManager.formatShortcut(item.id); + if (!keyText) continue; // Skip if shortcut not found + + const key = new Label({ + class: 'shortcut-key', + text: keyText + }); + + const action = new Label({ + class: 'shortcut-action' + }); + i18n.bindText(action, item.localeKey); + + const entry = new Container({ + class: 'shortcut-entry' + }); + + entry.append(key); + entry.append(action); + content.append(entry); + } + + // Add hints for this category (non-shortcut display items) + if (config.hints) { + for (const hint of config.hints) { + const key = new Label({ + class: 'shortcut-key', + text: hint.displayKey + }); + + const action = new Label({ + class: 'shortcut-action' + }); + i18n.bindText(action, hint.localeKey); + + const entry = new Container({ + class: 'shortcut-entry' + }); + + entry.append(key); + entry.append(action); + content.append(entry); + } + } + } + + dialog.append(header); + dialog.append(content); + + this.append(dialog); + } + + set hidden(value: boolean) { + super.hidden = value; + if (!value) { + // Take keyboard focus so shortcuts stop working + this.dom.focus(); + } + } + + get hidden(): boolean { + return super.hidden; + } +} + +export { ShortcutsPopup }; diff --git a/src/ui/spinner.ts b/src/ui/spinner.ts new file mode 100644 index 0000000..11be083 --- /dev/null +++ b/src/ui/spinner.ts @@ -0,0 +1,30 @@ +import { Container, Element } from '@playcanvas/pcui'; + +class Spinner extends Container { + constructor(args = {}) { + args = { + ...args, + id: 'spinner-container', + hidden: true + }; + + super(args); + + this.dom.tabIndex = 0; + + const spinner = new Element({ + dom: 'div', + class: 'spinner' + }); + + this.append(spinner); + + this.dom.addEventListener('keydown', (event) => { + if (this.hidden) return; + event.stopPropagation(); + event.preventDefault(); + }); + } +} + +export { Spinner }; diff --git a/src/ui/splat-list.ts b/src/ui/splat-list.ts new file mode 100644 index 0000000..4dfacd4 --- /dev/null +++ b/src/ui/splat-list.ts @@ -0,0 +1,334 @@ +import { Container, Label, Element as PcuiElement, TextInput } from '@playcanvas/pcui'; + +import { SplatRenameOp } from '../edit-ops'; +import { Element, ElementType } from '../element'; +import { Events } from '../events'; +import { Splat } from '../splat'; +import deleteSvg from './svg/delete.svg'; +import hiddenSvg from './svg/hidden.svg'; +import shownSvg from './svg/shown.svg'; + +const createSvg = (svgString: string) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement; +}; + +class SplatItem extends Container { + getName: () => string; + setName: (value: string) => void; + getSelected: () => boolean; + setSelected: (value: boolean) => void; + getVisible: () => boolean; + setVisible: (value: boolean) => void; + destroy: () => void; + + constructor(name: string, edit: TextInput, args = {}) { + args = { + ...args, + class: ['splat-item', 'visible'] + }; + + super(args); + + const text = new Label({ + class: 'splat-item-text', + text: name + }); + + const visible = new PcuiElement({ + dom: createSvg(shownSvg), + class: 'splat-item-visible' + }); + + const invisible = new PcuiElement({ + dom: createSvg(hiddenSvg), + class: 'splat-item-visible', + hidden: true + }); + + const remove = new PcuiElement({ + dom: createSvg(deleteSvg), + class: 'splat-item-delete' + }); + + this.append(text); + this.append(visible); + this.append(invisible); + this.append(remove); + + this.getName = () => { + return text.value; + }; + + this.setName = (value: string) => { + text.value = value; + }; + + this.getSelected = () => { + return this.class.contains('selected'); + }; + + this.setSelected = (value: boolean) => { + if (value !== this.selected) { + if (value) { + this.class.add('selected'); + this.emit('select', this); + } else { + this.class.remove('selected'); + this.emit('unselect', this); + } + } + }; + + this.getVisible = () => { + return this.class.contains('visible'); + }; + + this.setVisible = (value: boolean) => { + if (value !== this.visible) { + visible.hidden = !value; + invisible.hidden = value; + if (value) { + this.class.add('visible'); + this.emit('visible', this); + } else { + this.class.remove('visible'); + this.emit('invisible', this); + } + } + }; + + const toggleVisible = (event: MouseEvent) => { + event.stopPropagation(); + this.visible = !this.visible; + }; + + const handleRemove = (event: MouseEvent) => { + event.stopPropagation(); + this.emit('removeClicked', this); + }; + + // rename on double click + text.dom.addEventListener('dblclick', (event: MouseEvent) => { + event.stopPropagation(); + + const onblur = () => { + this.remove(edit); + this.emit('rename', edit.value); + edit.input.removeEventListener('blur', onblur); + text.hidden = false; + }; + + text.hidden = true; + + this.appendAfter(edit, text); + edit.value = text.value; + edit.input.addEventListener('blur', onblur); + edit.focus(); + }); + + // handle clicks + visible.dom.addEventListener('click', toggleVisible); + invisible.dom.addEventListener('click', toggleVisible); + remove.dom.addEventListener('click', handleRemove); + + this.destroy = () => { + visible.dom.removeEventListener('click', toggleVisible); + invisible.dom.removeEventListener('click', toggleVisible); + remove.dom.removeEventListener('click', handleRemove); + }; + } + + set name(value: string) { + this.setName(value); + } + + get name() { + return this.getName(); + } + + set selected(value) { + this.setSelected(value); + } + + get selected() { + return this.getSelected(); + } + + set visible(value) { + this.setVisible(value); + } + + get visible() { + return this.getVisible(); + } +} + +class SplatList extends Container { + constructor(events: Events, args = {}) { + args = { + ...args, + class: 'splat-list' + }; + + super(args); + + const items = new Map(); + let soloMode = false; + const savedVisibility = new Map(); + + // edit input used during renames + const edit = new TextInput({ + id: 'splat-edit' + }); + + events.on('scene.elementAdded', (element: Element) => { + if (element.type === ElementType.splat) { + const splat = element as Splat; + const item = new SplatItem(splat.name, edit); + this.append(item); + items.set(splat, item); + + if (soloMode) { + savedVisibility.set(splat, splat.visible); + splat.visible = false; + } + + item.on('visible', () => { + splat.visible = true; + + // also select it if there is no other selection + if (!events.invoke('selection')) { + events.fire('selection', splat); + } + }); + item.on('invisible', () => { + splat.visible = false; + }); + item.on('rename', (value: string) => { + events.fire('edit.add', new SplatRenameOp(splat, value)); + }); + } + }); + + events.on('scene.elementRemoved', (element: Element) => { + if (element.type === ElementType.splat) { + const splat = element as Splat; + const item = items.get(splat); + if (item) { + this.remove(item); + items.delete(splat); + } + savedVisibility.delete(splat); + } + }); + + events.on('selection.changed', (selection: Splat, prev: Splat) => { + items.forEach((value, key) => { + value.selected = key === selection; + }); + + if (soloMode) { + if (prev) { + prev.visible = false; + } + if (selection) { + selection.visible = true; + } + } + }); + + events.on('scene.solo', (value: boolean) => { + soloMode = value; + const selection = events.invoke('selection') as Splat; + + if (soloMode) { + items.forEach((item, splat) => { + savedVisibility.set(splat, splat.visible); + splat.visible = splat === selection; + }); + } else { + items.forEach((item, splat) => { + const wasVisible = savedVisibility.get(splat); + splat.visible = wasVisible !== undefined ? wasVisible : true; + }); + savedVisibility.clear(); + } + }); + + events.on('splat.name', (splat: Splat) => { + const item = items.get(splat); + if (item) { + item.name = splat.name; + } + }); + + events.on('splat.visibility', (splat: Splat) => { + const item = items.get(splat); + if (item) { + item.visible = splat.visible; + } + }); + + this.on('click', (item: SplatItem) => { + for (const [key, value] of items) { + if (item === value) { + if (soloMode && !key.visible) { + key.visible = true; + } + events.fire('selection', key); + break; + } + } + }); + + this.on('removeClicked', async (item: SplatItem) => { + let splat; + for (const [key, value] of items) { + if (item === value) { + splat = key; + break; + } + } + + if (!splat) { + return; + } + + const result = await events.invoke('showPopup', { + type: 'yesno', + header: 'Remove Splat', + message: `Are you sure you want to remove '${splat.name}' from the scene? This operation can not be undone.` + }); + + if (result?.action === 'yes') { + splat.destroy(); + } + }); + } + + protected _onAppendChild(element: PcuiElement): void { + super._onAppendChild(element); + + if (element instanceof SplatItem) { + element.on('click', () => { + this.emit('click', element); + }); + + element.on('removeClicked', () => { + this.emit('removeClicked', element); + }); + } + } + + protected _onRemoveChild(element: PcuiElement): void { + if (element instanceof SplatItem) { + element.unbind('click'); + element.unbind('removeClicked'); + } + + super._onRemoveChild(element); + } +} + +export { SplatList, SplatItem }; diff --git a/src/ui/status-bar.ts b/src/ui/status-bar.ts new file mode 100644 index 0000000..963fc65 --- /dev/null +++ b/src/ui/status-bar.ts @@ -0,0 +1,133 @@ +import { Button, Container, Label } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { Splat } from '../splat'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +class StatusBar extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'status-bar' + }; + + super(args); + + // Track the currently active panel + let activePanel = ''; + + // Toggle buttons for panels + const timelineButton = new Button({ + class: 'status-bar-toggle' + }); + i18n.bindText(timelineButton, () => i18n.t('status-bar.timeline').toUpperCase()); + + const splatDataButton = new Button({ + class: 'status-bar-toggle' + }); + i18n.bindText(splatDataButton, () => i18n.t('status-bar.splat-data').toUpperCase()); + + // Panel toggle logic + const setActivePanel = (panel: string) => { + activePanel = panel; + timelineButton.dom.classList[panel === 'timeline' ? 'add' : 'remove']('active'); + splatDataButton.dom.classList[panel === 'splatData' ? 'add' : 'remove']('active'); + events.fire('statusBar.panelChanged', panel || null); + }; + + timelineButton.on('click', () => { + setActivePanel(activePanel === 'timeline' ? '' : 'timeline'); + }); + + splatDataButton.on('click', () => { + setActivePanel(activePanel === 'splatData' ? '' : 'splatData'); + }); + + // Right section: stats + const statsContainer = new Container({ + class: 'status-bar-stats' + }); + + const createStat = (labelKey: string) => { + const container = new Container({ + class: 'status-bar-stat' + }); + const label = new Label({ + class: 'status-bar-stat-label' + }); + i18n.bindText(label, labelKey); + const value = new Label({ + class: 'status-bar-stat-value', + text: '0' + }); + container.append(label); + container.append(value); + statsContainer.append(container); + return value; + }; + + const splatsValue = createStat('status-bar.splats'); + const selectedValue = createStat('status-bar.selected'); + const lockedValue = createStat('status-bar.locked'); + const deletedValue = createStat('status-bar.deleted'); + + this.append(timelineButton); + this.append(splatDataButton); + this.append(statsContainer); + + // register tooltips + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + const tooltip = (localeKey: string, shortcutId?: string) => () => { + const text = i18n.t(localeKey); + if (shortcutId) { + const shortcut = shortcutManager.formatShortcut(shortcutId); + if (shortcut) { + return i18n.formatTooltipWithShortcut(text, shortcut); + } + } + return text; + }; + + tooltips.register(timelineButton, tooltip('tooltip.status-bar.timeline', 'timelinePanel.toggle'), 'top'); + tooltips.register(splatDataButton, tooltip('tooltip.status-bar.splat-data', 'dataPanel.toggle'), 'top'); + + // Handle keyboard shortcuts for panel toggles + events.on('dataPanel.toggle', () => { + setActivePanel(activePanel === 'splatData' ? '' : 'splatData'); + }); + + events.on('timelinePanel.toggle', () => { + setActivePanel(activePanel === 'timeline' ? '' : 'timeline'); + }); + + // Update stats from splat state + let splat: Splat; + + const updateStats = () => { + if (!splat) return; + const state = splat.splatData.getProp('state') as Uint8Array; + if (state) { + splatsValue.text = i18n.formatInteger(state.length - splat.numDeleted); + selectedValue.text = i18n.formatInteger(splat.numSelected); + lockedValue.text = i18n.formatInteger(splat.numLocked); + deletedValue.text = i18n.formatInteger(splat.numDeleted); + } + }; + + events.on('splat.stateChanged', (splat_: Splat) => { + splat = splat_; + updateStats(); + }); + + events.on('selection.changed', (selection: Element) => { + if (selection instanceof Splat) { + splat = selection; + updateStats(); + } + }); + } +} + +export { StatusBar }; diff --git a/src/ui/svg/arrow.svg b/src/ui/svg/arrow.svg new file mode 100644 index 0000000..e5d5943 --- /dev/null +++ b/src/ui/svg/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/camera-frame-selection.svg b/src/ui/svg/camera-frame-selection.svg new file mode 100644 index 0000000..5fc498b --- /dev/null +++ b/src/ui/svg/camera-frame-selection.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/camera-panel.svg b/src/ui/svg/camera-panel.svg new file mode 100644 index 0000000..489f32d --- /dev/null +++ b/src/ui/svg/camera-panel.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/ui/svg/camera-reset.svg b/src/ui/svg/camera-reset.svg new file mode 100644 index 0000000..0374524 --- /dev/null +++ b/src/ui/svg/camera-reset.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/ui/svg/centers.svg b/src/ui/svg/centers.svg new file mode 100644 index 0000000..2483267 --- /dev/null +++ b/src/ui/svg/centers.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ui/svg/collapse.svg b/src/ui/svg/collapse.svg new file mode 100644 index 0000000..cfc6b0c --- /dev/null +++ b/src/ui/svg/collapse.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/ui/svg/color-panel.svg b/src/ui/svg/color-panel.svg new file mode 100644 index 0000000..b260465 --- /dev/null +++ b/src/ui/svg/color-panel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/svg/crop.svg b/src/ui/svg/crop.svg new file mode 100644 index 0000000..3934ec0 --- /dev/null +++ b/src/ui/svg/crop.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/cursor-add.svg b/src/ui/svg/cursor-add.svg new file mode 100644 index 0000000..e0c5cb3 --- /dev/null +++ b/src/ui/svg/cursor-add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/svg/cursor-intersect.svg b/src/ui/svg/cursor-intersect.svg new file mode 100644 index 0000000..bc11f05 --- /dev/null +++ b/src/ui/svg/cursor-intersect.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/svg/cursor-remove.svg b/src/ui/svg/cursor-remove.svg new file mode 100644 index 0000000..5eb3fb1 --- /dev/null +++ b/src/ui/svg/cursor-remove.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/svg/delete.svg b/src/ui/svg/delete.svg new file mode 100644 index 0000000..e714a56 --- /dev/null +++ b/src/ui/svg/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/edit-redo.svg b/src/ui/svg/edit-redo.svg new file mode 100644 index 0000000..f3aed9a --- /dev/null +++ b/src/ui/svg/edit-redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/edit-undo.svg b/src/ui/svg/edit-undo.svg new file mode 100644 index 0000000..694438f --- /dev/null +++ b/src/ui/svg/edit-undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/export.svg b/src/ui/svg/export.svg new file mode 100644 index 0000000..ab85590 --- /dev/null +++ b/src/ui/svg/export.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/fly-camera.svg b/src/ui/svg/fly-camera.svg new file mode 100644 index 0000000..f6457a6 --- /dev/null +++ b/src/ui/svg/fly-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/hidden.svg b/src/ui/svg/hidden.svg new file mode 100644 index 0000000..724a1ef --- /dev/null +++ b/src/ui/svg/hidden.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/import.svg b/src/ui/svg/import.svg new file mode 100644 index 0000000..65e856c --- /dev/null +++ b/src/ui/svg/import.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/new.svg b/src/ui/svg/new.svg new file mode 100644 index 0000000..70dca81 --- /dev/null +++ b/src/ui/svg/new.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/open.svg b/src/ui/svg/open.svg new file mode 100644 index 0000000..c0f0348 --- /dev/null +++ b/src/ui/svg/open.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/orbit-camera.svg b/src/ui/svg/orbit-camera.svg new file mode 100644 index 0000000..68defcc --- /dev/null +++ b/src/ui/svg/orbit-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/playcanvas-logo.svg b/src/ui/svg/playcanvas-logo.svg new file mode 100644 index 0000000..75eb313 --- /dev/null +++ b/src/ui/svg/playcanvas-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/svg/publish.svg b/src/ui/svg/publish.svg new file mode 100644 index 0000000..55c092d --- /dev/null +++ b/src/ui/svg/publish.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/ui/svg/redo.svg b/src/ui/svg/redo.svg new file mode 100644 index 0000000..f279aea --- /dev/null +++ b/src/ui/svg/redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/rings.svg b/src/ui/svg/rings.svg new file mode 100644 index 0000000..3a10b18 --- /dev/null +++ b/src/ui/svg/rings.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/ui/svg/save.svg b/src/ui/svg/save.svg new file mode 100644 index 0000000..0cb0b09 --- /dev/null +++ b/src/ui/svg/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/select-all.svg b/src/ui/svg/select-all.svg new file mode 100644 index 0000000..787e67f --- /dev/null +++ b/src/ui/svg/select-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/select-brush.svg b/src/ui/svg/select-brush.svg new file mode 100644 index 0000000..051a28a --- /dev/null +++ b/src/ui/svg/select-brush.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/select-duplicate.svg b/src/ui/svg/select-duplicate.svg new file mode 100644 index 0000000..f43812a --- /dev/null +++ b/src/ui/svg/select-duplicate.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/ui/svg/select-eyedropper.svg b/src/ui/svg/select-eyedropper.svg new file mode 100644 index 0000000..4e8cd94 --- /dev/null +++ b/src/ui/svg/select-eyedropper.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/svg/select-flood.svg b/src/ui/svg/select-flood.svg new file mode 100644 index 0000000..a7a8566 --- /dev/null +++ b/src/ui/svg/select-flood.svg @@ -0,0 +1,53 @@ + + + + + + + + diff --git a/src/ui/svg/select-inverse.svg b/src/ui/svg/select-inverse.svg new file mode 100644 index 0000000..681f6aa --- /dev/null +++ b/src/ui/svg/select-inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/select-lasso.svg b/src/ui/svg/select-lasso.svg new file mode 100644 index 0000000..1a5a909 --- /dev/null +++ b/src/ui/svg/select-lasso.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/select-lock.svg b/src/ui/svg/select-lock.svg new file mode 100644 index 0000000..946b833 --- /dev/null +++ b/src/ui/svg/select-lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/svg/select-none.svg b/src/ui/svg/select-none.svg new file mode 100644 index 0000000..768f522 --- /dev/null +++ b/src/ui/svg/select-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/select-picker.svg b/src/ui/svg/select-picker.svg new file mode 100644 index 0000000..3fd9442 --- /dev/null +++ b/src/ui/svg/select-picker.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/ui/svg/select-poly.svg b/src/ui/svg/select-poly.svg new file mode 100644 index 0000000..584efe9 --- /dev/null +++ b/src/ui/svg/select-poly.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/select-separate.svg b/src/ui/svg/select-separate.svg new file mode 100644 index 0000000..d41f1c3 --- /dev/null +++ b/src/ui/svg/select-separate.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ui/svg/select-sphere.svg b/src/ui/svg/select-sphere.svg new file mode 100644 index 0000000..0a511e9 --- /dev/null +++ b/src/ui/svg/select-sphere.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/select-unlock.svg b/src/ui/svg/select-unlock.svg new file mode 100644 index 0000000..d0f2ab7 --- /dev/null +++ b/src/ui/svg/select-unlock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/svg/show-hide-splats.svg b/src/ui/svg/show-hide-splats.svg new file mode 100644 index 0000000..aaa2f03 --- /dev/null +++ b/src/ui/svg/show-hide-splats.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/shown.svg b/src/ui/svg/shown.svg new file mode 100644 index 0000000..705db25 --- /dev/null +++ b/src/ui/svg/shown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/svg/solo.svg b/src/ui/svg/solo.svg new file mode 100644 index 0000000..be15a2d --- /dev/null +++ b/src/ui/svg/solo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/svg/undo.svg b/src/ui/svg/undo.svg new file mode 100644 index 0000000..6ac6248 --- /dev/null +++ b/src/ui/svg/undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/timeline-panel.ts b/src/ui/timeline-panel.ts new file mode 100644 index 0000000..5a1bba2 --- /dev/null +++ b/src/ui/timeline-panel.ts @@ -0,0 +1,555 @@ +import { Button, Container, Element, NumericInput, SelectInput } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { ShortcutManager } from '../shortcut-manager'; +import { i18n } from './localization'; +import { Tooltips } from './tooltips'; + +class Ticks extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'ticks' + }; + + super(args); + + const workArea = new Container({ + id: 'ticks-area' + }); + + this.append(workArea); + + let frameFromOffset: (offset: number) => number; + let moveCursor: (frame: number) => void; + + // pcui wrappers around key markers, kept so their tooltips unregister + // (via the destroy event) when the timeline is rebuilt + let keyElements: Element[] = []; + + // rebuild the timeline + const rebuild = () => { + // clear existing labels + keyElements.forEach(el => el.destroy()); + keyElements = []; + workArea.dom.innerHTML = ''; + + const numFrames = events.invoke('timeline.frames'); + const currentFrame = events.invoke('timeline.frame'); + + const padding = 20; + const width = this.dom.getBoundingClientRect().width - padding * 2; + + // round the smallest step that keeps labels ~50px apart up to the + // 1/2/5 * 10^n series so labels land on round frame numbers + const minStep = Math.max(1, numFrames / Math.max(1, Math.floor(width / 50))); + const magnitude = 10 ** Math.floor(Math.log10(minStep)); + const labelStep = [1, 2, 5, 10].map(m => m * magnitude).find(s => s >= minStep) ?? 10 * magnitude; + + // subdivide labels with minor ticks (fifths, or halves for 1/2 steps) + const tickStep = labelStep === 1 ? 0 : labelStep / (labelStep % 5 === 0 ? 5 : 2); + + const offsetFromFrame = (frame: number) => { + return padding + Math.floor(frame / (numFrames - 1) * width); + }; + + frameFromOffset = (offset: number) => { + return Math.max(0, Math.min(numFrames - 1, Math.floor((offset - padding) / width * (numFrames - 1)))); + }; + + // timeline labels + + for (let frame = 0; frame < numFrames; frame += labelStep) { + const label = document.createElement('div'); + label.classList.add('time-label'); + label.style.left = `${offsetFromFrame(frame)}px`; + label.textContent = frame.toString(); + workArea.dom.appendChild(label); + } + + // minor ticks + + if (tickStep > 0) { + for (let frame = tickStep; frame < numFrames; frame += tickStep) { + if (frame % labelStep !== 0) { + const tick = document.createElement('div'); + tick.classList.add('time-tick'); + tick.style.left = `${offsetFromFrame(frame)}px`; + workArea.dom.appendChild(tick); + } + } + } + + // keys - get from active track + const keys = events.invoke('track.keys') as number[] ?? []; + + const createKey = (keyFrame: number) => { + const label = document.createElement('div'); + label.classList.add('time-label', 'key'); + label.style.left = `${offsetFromFrame(keyFrame)}px`; + label.dataset.frame = keyFrame.toString(); + + const wrapper = new Element({ dom: label }); + tooltips.register(wrapper, () => i18n.t('tooltip.timeline.key'), 'top'); + keyElements.push(wrapper); + let dragging = false; + let copying = false; + let clone: HTMLElement = null; + let toFrame = -1; + + label.addEventListener('pointerdown', (event) => { + if (!dragging && event.isPrimary) { + dragging = true; + copying = event.shiftKey; + label.setPointerCapture(event.pointerId); + event.stopPropagation(); + + if (copying) { + // create a visual clone to drag; original stays in place + clone = document.createElement('div'); + clone.classList.add('time-label', 'key', 'dragging'); + clone.style.left = label.style.left; + workArea.dom.appendChild(clone); + label.classList.add('copying'); + } else { + label.classList.add('dragging'); + } + } + }); + + label.addEventListener('pointermove', (event: PointerEvent) => { + if (dragging) { + toFrame = frameFromOffset(parseInt(label.style.left, 10) + event.offsetX); + if (copying) { + clone.style.left = `${offsetFromFrame(toFrame)}px`; + } else { + label.style.left = `${offsetFromFrame(toFrame)}px`; + } + } else { + // hint that ctrl+click overwrites the key with the current pose + label.classList.toggle('stamping', event.ctrlKey); + } + }); + + label.addEventListener('pointerup', (event: PointerEvent) => { + if (dragging && event.isPrimary) { + const fromFrame = parseInt(label.dataset.frame, 10); + + // Clean up DOM state before firing events, since event + // handlers may call rebuild() which clears workArea. + if (copying) { + workArea.dom.removeChild(clone); + clone = null; + label.classList.remove('copying'); + } else { + label.classList.remove('dragging'); + } + + label.releasePointerCapture(event.pointerId); + + if (event.ctrlKey && (toFrame < 0 || toFrame === fromFrame)) { + // ctrl+click without dragging: overwrite this key + // with the current camera pose + events.fire('track.addKey', fromFrame); + } else if (fromFrame !== toFrame && toFrame >= 0) { + if (copying) { + events.fire('track.copyKey', fromFrame, toFrame); + } else { + events.fire('track.moveKey', fromFrame, toFrame); + } + } + + copying = false; + dragging = false; + } + }); + + workArea.dom.appendChild(label); + }; + + keys.forEach((keyFrame: number) => { + createKey(keyFrame); + }); + + // cursor + + const cursor = document.createElement('div'); + cursor.classList.add('time-label', 'cursor'); + cursor.style.left = `${offsetFromFrame(currentFrame)}px`; + cursor.textContent = currentFrame.toString(); + workArea.dom.appendChild(cursor); + + moveCursor = (frame: number) => { + cursor.style.left = `${offsetFromFrame(frame)}px`; + cursor.textContent = frame.toString(); + }; + }; + + // handle scrubbing + + let scrubbing = false; + + workArea.dom.addEventListener('pointerdown', (event: PointerEvent) => { + if (!scrubbing && event.isPrimary) { + scrubbing = true; + workArea.dom.setPointerCapture(event.pointerId); + events.fire('timeline.setFrame', frameFromOffset(event.offsetX)); + } + }); + + workArea.dom.addEventListener('pointermove', (event: PointerEvent) => { + if (scrubbing) { + events.fire('timeline.setFrame', frameFromOffset(event.offsetX)); + } + }); + + workArea.dom.addEventListener('pointerup', (event: PointerEvent) => { + if (scrubbing && event.isPrimary) { + workArea.dom.releasePointerCapture(event.pointerId); + scrubbing = false; + } + }); + + // update the stamp cursor hint when ctrl changes while already + // hovering a key (pointermove alone misses a stationary pointer) + const updateStamping = (down: boolean) => { + const hovered = workArea.dom.querySelector('.key:hover'); + workArea.dom.querySelectorAll('.key.stamping').forEach((el) => { + if (!down || el !== hovered) el.classList.remove('stamping'); + }); + if (down && hovered) hovered.classList.add('stamping'); + }; + + const onCtrlDown = (event: KeyboardEvent) => { + if (event.key === 'Control' && !event.repeat) updateStamping(true); + }; + + const onCtrlUp = (event: KeyboardEvent) => { + if (event.key === 'Control') updateStamping(false); + }; + + // ctrl released while the window is unfocused never fires keyup + const onBlur = () => updateStamping(false); + + window.addEventListener('keydown', onCtrlDown); + window.addEventListener('keyup', onCtrlUp); + window.addEventListener('blur', onBlur); + + this.on('destroy', () => { + keyElements.forEach(el => el.destroy()); + keyElements = []; + window.removeEventListener('keydown', onCtrlDown); + window.removeEventListener('keyup', onCtrlUp); + window.removeEventListener('blur', onBlur); + }); + + // rebuild the timeline on dom resize + new ResizeObserver(() => rebuild()).observe(workArea.dom); + + // rebuild when timeline frames change + events.on('timeline.frames', () => { + rebuild(); + }); + + events.on('timeline.frame', (frame: number) => { + moveCursor(frame); + }); + + // rebuild when track keys change + events.on('track.keyAdded', () => { + rebuild(); + }); + + events.on('track.keyRemoved', () => { + rebuild(); + }); + + events.on('track.keyMoved', () => { + rebuild(); + }); + + events.on('track.keyUpdated', () => { + rebuild(); + }); + + events.on('track.keysLoaded', () => { + rebuild(); + }); + + events.on('track.keysCleared', () => { + rebuild(); + }); + } +} + +class TimelinePanel extends Container { + constructor(events: Events, tooltips: Tooltips, args = {}) { + args = { + ...args, + id: 'timeline-panel' + }; + + super(args); + + // play controls + + const prev = new Button({ + class: 'button', + text: '\uE162' + }); + + const play = new Button({ + class: 'button', + text: '\uE131' + }); + + const next = new Button({ + class: 'button', + text: '\uE164' + }); + + // key controls + + const addKey = new Button({ + class: 'button', + text: '\uE120' + }); + + const removeKey = new Button({ + class: 'button', + text: '\uE121', + enabled: false + }); + + const buttonControls = new Container({ + id: 'button-controls' + }); + buttonControls.append(prev); + buttonControls.append(play); + buttonControls.append(next); + buttonControls.append(addKey); + buttonControls.append(removeKey); + + // settings + + const speed = new SelectInput({ + id: 'speed', + defaultValue: 30, + options: [ + { v: 1, t: '1 fps' }, + { v: 6, t: '6 fps' }, + { v: 12, t: '12 fps' }, + { v: 24, t: '24 fps' }, + { v: 30, t: '30 fps' }, + { v: 60, t: '60 fps' } + ] + }); + + speed.on('change', (value: string) => { + events.fire('timeline.setFrameRate', parseInt(value, 10)); + }); + + events.on('timeline.frameRate', (frameRate: number) => { + speed.value = frameRate.toString(); + }); + + const frames = new NumericInput({ + id: 'totalFrames', + value: 180, + min: 1, + max: 10000, + precision: 0 + }); + + frames.on('change', (value: number) => { + events.fire('timeline.setFrames', value); + }); + + events.on('timeline.frames', (framesIn: number) => { + frames.value = framesIn; + }); + + // smoothness + + const smoothness = new NumericInput({ + id: 'smoothness', + min: 0, + max: 1, + step: 0.05, + value: 1 + }); + + smoothness.on('change', (value: number) => { + events.fire('timeline.setSmoothness', value); + }); + + events.on('timeline.smoothness', (smoothnessIn: number) => { + smoothness.value = smoothnessIn; + }); + + // loop + + const loop = new Button({ + id: 'loop', + text: '\uE128' + }); + + loop.on('click', () => { + events.fire('timeline.setLoop', !events.invoke('timeline.loop')); + }); + + events.on('timeline.loop', (loopIn: boolean) => { + loop.class[loopIn ? 'add' : 'remove']('active'); + }); + + if (events.invoke('timeline.loop')) { + loop.class.add('active'); + } + + const settingsControls = new Container({ + id: 'settings-controls' + }); + settingsControls.append(speed); + settingsControls.append(frames); + settingsControls.append(smoothness); + settingsControls.append(loop); + + // append control groups + + const controlsWrap = new Container({ + id: 'controls-wrap' + }); + + const spacerL = new Container({ + class: 'spacer' + }); + + const spacerR = new Container({ + class: 'spacer' + }); + spacerR.append(settingsControls); + + controlsWrap.append(spacerL); + controlsWrap.append(buttonControls); + controlsWrap.append(spacerR); + + const ticks = new Ticks(events, tooltips); + + this.append(controlsWrap); + this.append(ticks); + + // ui handlers + + prev.on('click', (evt: MouseEvent) => { + if (evt.shiftKey) { + events.fire('timeline.prevKey'); + } else { + events.fire('timeline.prevFrame'); + } + }); + + next.on('click', (evt: MouseEvent) => { + if (evt.shiftKey) { + events.fire('timeline.nextKey'); + } else { + events.fire('timeline.nextFrame'); + } + }); + + play.on('click', () => { + if (events.invoke('timeline.playing')) { + events.fire('timeline.setPlaying', false); + } else { + events.fire('timeline.setPlaying', true); + } + }); + + // Sync play button icon when playing state changes (e.g. via keyboard shortcut) + events.on('timeline.playing', (isPlaying: boolean) => { + play.text = isPlaying ? '\uE135' : '\uE131'; + }); + + addKey.on('click', () => { + events.fire('track.addKey'); + }); + + removeKey.on('click', () => { + const frame = events.invoke('timeline.frame'); + events.fire('track.removeKey', frame); + }); + + // Helper to check if the current frame has a key + const canDeleteKey = () => { + const keys = events.invoke('track.keys') as number[] ?? []; + const frame = events.invoke('timeline.frame'); + return keys.includes(frame); + }; + + // Update key button states + const updateKeyButtonStates = () => { + removeKey.enabled = canDeleteKey(); + }; + + // Update button states when frame changes + events.on('timeline.frame', () => { + updateKeyButtonStates(); + }); + + // Update button states when track keys change + events.on('track.keyAdded', () => { + updateKeyButtonStates(); + }); + + events.on('track.keyRemoved', () => { + updateKeyButtonStates(); + }); + + events.on('track.keyMoved', () => { + updateKeyButtonStates(); + }); + + events.on('track.keyUpdated', () => { + updateKeyButtonStates(); + }); + + events.on('track.keysLoaded', () => { + updateKeyButtonStates(); + }); + + events.on('track.keysCleared', () => { + updateKeyButtonStates(); + }); + + // cancel animation playback if user interacts with camera + events.on('camera.controller', (type: string) => { + if (events.invoke('timeline.playing')) { + // stop + } + }); + + // tooltips + const shortcutManager: ShortcutManager = events.invoke('shortcutManager'); + const tooltip = (localeKey: string, shortcutId?: string) => () => { + const text = i18n.t(localeKey); + if (shortcutId) { + const shortcut = shortcutManager.formatShortcut(shortcutId); + if (shortcut) { + return i18n.formatTooltipWithShortcut(text, shortcut); + } + } + return text; + }; + + tooltips.register(prev, tooltip('tooltip.timeline.prev-frame', 'timeline.prevFrame'), 'top'); + tooltips.register(play, tooltip('tooltip.timeline.play', 'timeline.togglePlay'), 'top'); + tooltips.register(next, tooltip('tooltip.timeline.next-frame', 'timeline.nextFrame'), 'top'); + tooltips.register(addKey, tooltip('tooltip.timeline.add-key', 'track.addKey'), 'top'); + tooltips.register(removeKey, tooltip('tooltip.timeline.remove-key', 'track.removeKey'), 'top'); + tooltips.register(speed, () => i18n.t('tooltip.timeline.frame-rate'), 'top'); + tooltips.register(frames, () => i18n.t('tooltip.timeline.total-frames'), 'top'); + tooltips.register(smoothness, () => i18n.t('tooltip.timeline.smoothness'), 'top'); + tooltips.register(loop, () => i18n.t('tooltip.timeline.loop'), 'top'); + } +} + +export { TimelinePanel }; diff --git a/src/ui/tooltips.ts b/src/ui/tooltips.ts new file mode 100644 index 0000000..26d3e66 --- /dev/null +++ b/src/ui/tooltips.ts @@ -0,0 +1,139 @@ +import { Container, Element, Label } from '@playcanvas/pcui'; + +type Direction = 'left' | 'right' | 'top' | 'bottom'; + +// Tooltip text may be a static string or a resolver. A resolver is evaluated +// each time the tooltip is shown, so localized tooltips always reflect the +// current language without any language-change listener. +type TooltipText = string | (() => string); + +class Tooltips extends Container { + register: (target: Element, text: TooltipText, direction?: Direction) => void; + unregister: (target: Element) => void; + destroy: () => void; + + constructor(args: any = {}) { + args = { + ...args, + class: 'tooltips', + hidden: true + }; + + super(args); + + const text = new Label({ + class: 'tooltips-content' + }); + + this.append(text); + + const targets = new Map(); + const style = this.dom.style; + let timer: number = 0; + + this.register = (target: Element, textString: TooltipText, direction: Direction = 'bottom') => { + + const activate = () => { + const rect = target.dom.getBoundingClientRect(); + const midx = Math.floor((rect.left + rect.right) * 0.5); + const midy = Math.floor((rect.top + rect.bottom) * 0.5); + + switch (direction) { + case 'left': + style.left = `${rect.left}px`; + style.top = `${midy}px`; + style.transform = 'translate(calc(-100% - 10px), -50%)'; + break; + case 'right': + style.left = `${rect.right}px`; + style.top = `${midy}px`; + style.transform = 'translate(10px, -50%)'; + break; + case 'top': + style.left = `${midx}px`; + style.top = `${rect.top}px`; + style.transform = 'translate(-50%, calc(-100% - 10px))'; + break; + case 'bottom': + style.left = `${midx}px`; + style.top = `${rect.bottom}px`; + style.transform = 'translate(-50%, 10px)'; + break; + } + + text.text = typeof textString === 'function' ? textString() : textString; + // inline-block so max-width / wrapping in SCSS apply (inline + // would stay one long line). + style.display = 'inline-block'; + + // clamp to viewport so tooltip doesn't go off-screen + const tooltipRect = this.dom.getBoundingClientRect(); + if (tooltipRect.left < 0) { + style.left = `${parseFloat(style.left) - tooltipRect.left}px`; + } else if (tooltipRect.right > window.innerWidth) { + style.left = `${parseFloat(style.left) - (tooltipRect.right - window.innerWidth)}px`; + } + }; + + const startTimer = (fn: () => void) => { + timer = window.setTimeout(() => { + fn(); + timer = -1; + }, 250); + }; + + const cancelTimer = () => { + if (timer >= 0) { + clearTimeout(timer); + timer = -1; + } + }; + + const enter = () => { + cancelTimer(); + + if (style.display === 'inline-block') { + activate(); + } else { + startTimer(() => activate()); + } + }; + + const leave = () => { + cancelTimer(); + + if (style.display === 'inline-block') { + startTimer(() => { + style.display = 'none'; + }); + } + }; + + target.dom.addEventListener('pointerenter', enter); + target.dom.addEventListener('pointerleave', leave); + + target.on('destroy', () => { + this.unregister(target); + }); + + targets.set(target, { enter, leave }); + }; + + this.unregister = (target: Element) => { + const value = targets.get(target); + if (value) { + target.dom.removeEventListener('pointerenter', value.enter); + target.dom.removeEventListener('pointerleave', value.leave); + targets.delete(target); + } + }; + + this.destroy = () => { + for (const target of targets.keys()) { + this.unregister(target); + } + }; + } +} + +export { Tooltips }; diff --git a/src/ui/transform.ts b/src/ui/transform.ts new file mode 100644 index 0000000..dc8a643 --- /dev/null +++ b/src/ui/transform.ts @@ -0,0 +1,175 @@ +import { Container, ContainerArgs, Label, NumericInput, VectorInput } from '@playcanvas/pcui'; +import { Quat, Vec3 } from 'playcanvas'; + +import { Events } from '../events'; +import { i18n } from './localization'; +import { Pivot } from '../pivot'; + +const v = new Vec3(); + +class Transform extends Container { + constructor(events: Events, args: ContainerArgs = {}) { + args = { + ...args, + id: 'transform' + }; + + super(args); + + // position + const position = new Container({ + class: 'transform-row' + }); + + const positionLabel = new Label({ + class: 'transform-label' + }); + i18n.bindText(positionLabel, 'panel.scene-manager.transform.position'); + + const positionVector = new VectorInput({ + class: 'transform-expand', + precision: 3, + dimensions: 3, + placeholder: ['X', 'Y', 'Z'], + value: [0, 0, 0], + enabled: false + }); + + position.append(positionLabel); + position.append(positionVector); + + // rotation + const rotation = new Container({ + class: 'transform-row' + }); + + const rotationLabel = new Label({ + class: 'transform-label' + }); + i18n.bindText(rotationLabel, 'panel.scene-manager.transform.rotation'); + + const rotationVector = new VectorInput({ + class: 'transform-expand', + precision: 2, + dimensions: 3, + placeholder: ['X', 'Y', 'Z'], + value: [0, 0, 0], + enabled: false + }); + + rotation.append(rotationLabel); + rotation.append(rotationVector); + + // scale + const scale = new Container({ + class: 'transform-row' + }); + + const scaleLabel = new Label({ + class: 'transform-label' + }); + i18n.bindText(scaleLabel, 'panel.scene-manager.transform.scale'); + + const scaleInput = new NumericInput({ + class: 'transform-expand', + precision: 3, + value: 1, + min: 0.001, + max: 10000, + enabled: false + }); + + scale.append(scaleLabel); + scale.append(scaleInput); + + this.append(position); + this.append(rotation); + this.append(scale); + + const toArray = (v: Vec3) => { + return [v.x, v.y, v.z]; + }; + + let uiUpdating = false; + let mouseUpdating = false; + + // update UI with pivot + const updateUI = (pivot: Pivot) => { + uiUpdating = true; + const transform = pivot.transform; + transform.rotation.getEulerAngles(v); + positionVector.value = toArray(transform.position); + rotationVector.value = toArray(v); + scaleInput.value = transform.scale.x; + uiUpdating = false; + }; + + // update pivot with UI + const updatePivot = (pivot: Pivot) => { + const p = positionVector.value; + const r = rotationVector.value; + const q = new Quat().setFromEulerAngles(r[0], r[1], r[2]); + const s = scaleInput.value; + + if (q.w < 0) { + q.mulScalar(-1); + } + + pivot.moveTRS(new Vec3(p[0], p[1], p[2]), q, new Vec3(s, s, s)); + }; + + // handle a change in the UI state + const change = () => { + if (!uiUpdating) { + const pivot = events.invoke('pivot') as Pivot; + if (mouseUpdating) { + updatePivot(pivot); + } else { + pivot.start(); + updatePivot(pivot); + pivot.end(); + } + } + }; + + const mousedown = () => { + mouseUpdating = true; + const pivot = events.invoke('pivot') as Pivot; + pivot.start(); + }; + + const mouseup = () => { + const pivot = events.invoke('pivot') as Pivot; + updatePivot(pivot); + mouseUpdating = false; + pivot.end(); + }; + + [positionVector.inputs, rotationVector.inputs, scaleInput].flat().forEach((input) => { + input.on('change', change); + input.on('slider:mousedown', mousedown); + input.on('slider:mouseup', mouseup); + }); + + // toggle ui availability based on selection + events.on('selection.changed', (selection) => { + positionVector.enabled = rotationVector.enabled = scaleInput.enabled = !!selection; + }); + + events.on('pivot.placed', (pivot: Pivot) => { + updateUI(pivot); + }); + + events.on('pivot.moved', (pivot: Pivot) => { + if (!mouseUpdating) { + updateUI(pivot); + } + }); + + events.on('pivot.ended', (pivot: Pivot) => { + updateUI(pivot); + }); + } +} + +export { Transform }; diff --git a/src/ui/video-settings-dialog.ts b/src/ui/video-settings-dialog.ts new file mode 100644 index 0000000..43fe576 --- /dev/null +++ b/src/ui/video-settings-dialog.ts @@ -0,0 +1,465 @@ +import { BooleanInput, Button, Container, Element, Label, SelectInput, VectorInput } from '@playcanvas/pcui'; + +import { Events } from '../events'; +import { VideoSettings } from '../render'; +import { i18n } from './localization'; +import sceneExport from './svg/export.svg'; + +const createSvg = (svgString: string, args = {}) => { + const decodedStr = decodeURIComponent(svgString.substring('data:image/svg+xml,'.length)); + return new Element({ + dom: new DOMParser().parseFromString(decodedStr, 'image/svg+xml').documentElement, + ...args + }); +}; + +class VideoSettingsDialog extends Container { + show: () => Promise; + hide: () => void; + destroy: () => void; + + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'video-settings-dialog', + class: 'settings-dialog', + hidden: true, + tabIndex: -1 + }; + + super(args); + + const dialog = new Container({ + id: 'dialog' + }); + + // header + + const headerIcon = createSvg(sceneExport, { id: 'icon' }); + const headerText = new Label({ id: 'text' }); + i18n.bindText(headerText, () => i18n.t('popup.render-video.header').toUpperCase()); + const header = new Container({ id: 'header' }); + header.append(headerIcon); + header.append(headerText); + + // projection + + const projectionLabel = new Label({ class: 'label' }); + i18n.bindText(projectionLabel, 'popup.render-video.projection'); + const projectionSelect = new SelectInput({ + class: 'select', + defaultValue: 'standard', + options: [ + { v: 'standard', t: 'Standard' }, + { v: 'equirect', t: '360° Equirectangular' } + ] + }); + i18n.bindOptions(projectionSelect, () => [ + { v: 'standard', t: i18n.t('popup.render-video.projection-standard') }, + { v: 'equirect', t: i18n.t('popup.render-video.projection-360') } + ]); + const projectionRow = new Container({ class: 'row' }); + projectionRow.append(projectionLabel); + projectionRow.append(projectionSelect); + + // resolution + + const standardResolutions = [ + { v: '540', t: '960x540' }, + { v: '720', t: '1280x720' }, + { v: '1080', t: '1920x1080' }, + { v: '1440', t: '2560x1440' }, + { v: '4k', t: '3840x2160' } + ]; + + // 360 output is 2:1 equirectangular, capped at 4096 wide to stay + // within common encoder dimension limits + const equirectResolutions = [ + { v: '360-1k', t: '1024x512' }, + { v: '360-2k', t: '2048x1024' }, + { v: '360-4k', t: '3840x1920' }, + { v: '360-4096', t: '4096x2048' } + ]; + + const resolutionLabel = new Label({ class: 'label' }); + i18n.bindText(resolutionLabel, 'popup.render-video.resolution'); + const resolutionSelect = new SelectInput({ + class: 'select', + defaultValue: '1080', + options: standardResolutions + }); + const resolutionRow = new Container({ class: 'row' }); + resolutionRow.append(resolutionLabel); + resolutionRow.append(resolutionSelect); + + // format + + const formatLabel = new Label({ class: 'label' }); + i18n.bindText(formatLabel, 'popup.render-video.format'); + const formatSelect = new SelectInput({ + class: 'select', + defaultValue: 'mp4', + options: [ + { v: 'mp4', t: 'MP4' }, + { v: 'webm', t: 'WebM' }, + { v: 'mov', t: 'MOV' }, + { v: 'mkv', t: 'MKV' } + ] + }); + const formatRow = new Container({ class: 'row' }); + formatRow.append(formatLabel); + formatRow.append(formatSelect); + + // codec + + const codecLabel = new Label({ class: 'label' }); + i18n.bindText(codecLabel, 'popup.render-video.codec'); + const codecSelect = new SelectInput({ + class: 'select', + defaultValue: 'h264', + options: [ + { v: 'h264', t: 'H.264' }, + { v: 'h265', t: 'H.265/HEVC' } + ] + }); + const codecRow = new Container({ class: 'row' }); + codecRow.append(codecLabel); + codecRow.append(codecSelect); + + // Codec compatibility mapping + const codecOptions: Record> = { + 'mp4': [ + { v: 'h264', t: 'H.264' }, + { v: 'h265', t: 'H.265/HEVC' } + ], + 'webm': [ + { v: 'vp9', t: 'VP9' }, + { v: 'av1', t: 'AV1' } + ], + 'mov': [ + { v: 'h264', t: 'H.264' }, + { v: 'h265', t: 'H.265/HEVC' } + ], + 'mkv': [ + { v: 'h264', t: 'H.264' }, + { v: 'h265', t: 'H.265/HEVC' }, + { v: 'vp9', t: 'VP9' }, + { v: 'av1', t: 'AV1' } + ] + }; + + // Update codec options when format changes + formatSelect.on('change', () => { + const format = formatSelect.value; + const options = codecOptions[format] || codecOptions.mp4; + codecSelect.options = options; + + // Set default codec based on format + if (format === 'webm') { + codecSelect.value = 'vp9'; + } else { + codecSelect.value = 'h264'; + } + }); + + // framerate + + const frameRateLabel = new Label({ class: 'label' }); + i18n.bindText(frameRateLabel, 'popup.render-video.frame-rate'); + const frameRateSelect = new SelectInput({ + class: 'select', + defaultValue: '30', + options: [ + { v: '12', t: '12 fps' }, + { v: '15', t: '15 fps' }, + { v: '24', t: '24 fps' }, + { v: '25', t: '25 fps' }, + { v: '30', t: '30 fps' }, + { v: '48', t: '48 fps' }, + { v: '60', t: '60 fps' }, + { v: '120', t: '120 fps' } + ] + }); + + const frameRateRow = new Container({ class: 'row' }); + frameRateRow.append(frameRateLabel); + frameRateRow.append(frameRateSelect); + + // bitrate + + const bitrateLabel = new Label({ class: 'label' }); + i18n.bindText(bitrateLabel, 'popup.render-video.bitrate'); + const bitrateSelect = new SelectInput({ + class: 'select', + defaultValue: 'high', + options: [ + { v: 'low', t: 'Low' }, + { v: 'medium', t: 'Medium' }, + { v: 'high', t: 'High' }, + { v: 'ultra', t: 'Ultra' } + ] + }); + const bitrateRow = new Container({ class: 'row' }); + bitrateRow.append(bitrateLabel); + bitrateRow.append(bitrateSelect); + + // frame range + + const totalFrames = events.invoke('timeline.frames'); + const frameRangeLabel = new Label({ class: 'label' }); + i18n.bindText(frameRangeLabel, 'popup.render-video.frame-range'); + const frameRangeInput = new VectorInput({ + class: 'vector-input', + dimensions: 2, + min: 0, + max: totalFrames - 1, + precision: 0, + value: [0, totalFrames - 1] + }); + i18n.onChange(() => { + frameRangeInput.placeholder = [i18n.t('popup.render-video.frame-range-first'), i18n.t('popup.render-video.frame-range-last')]; + }, frameRangeInput); + const frameRangeRow = new Container({ class: 'row' }); + frameRangeRow.append(frameRangeLabel); + frameRangeRow.append(frameRangeInput); + + // Validate frame range + frameRangeInput.on('change', (value: number[]) => { + if (value[0] > value[1]) { + frameRangeInput.value = [value[1], value[0]]; + } + }); + + // portrait mode + + const portraitLabel = new Label({ class: 'label' }); + i18n.bindText(portraitLabel, 'popup.render-video.portrait'); + const portraitBoolean = new BooleanInput({ class: 'boolean', value: false }); + const portraitRow = new Container({ class: 'row' }); + portraitRow.append(portraitLabel); + portraitRow.append(portraitBoolean); + + // level horizon (360 only) + + const levelHorizonLabel = new Label({ class: 'label' }); + i18n.bindText(levelHorizonLabel, 'popup.render-video.level-horizon'); + const levelHorizonBoolean = new BooleanInput({ class: 'boolean', value: true }); + const levelHorizonRow = new Container({ class: 'row' }); + levelHorizonRow.append(levelHorizonLabel); + levelHorizonRow.append(levelHorizonBoolean); + + // transparent background + + const transparentBgLabel = new Label({ class: 'label' }); + i18n.bindText(transparentBgLabel, 'popup.render-video.transparent-bg'); + const transparentBgBoolean = new BooleanInput({ class: 'boolean', value: false }); + const transparentBgRow = new Container({ class: 'row' }); + transparentBgRow.append(transparentBgLabel); + transparentBgRow.append(transparentBgBoolean); + + // hide transparent background till we add support for webm + // video container + transparentBgRow.hidden = true; + + // show debug overlays + + const showDebugLabel = new Label({ class: 'label' }); + i18n.bindText(showDebugLabel, 'popup.render-video.show-debug'); + const showDebugBoolean = new BooleanInput({ class: 'boolean', value: false }); + const showDebugRow = new Container({ class: 'row' }); + showDebugRow.append(showDebugLabel); + showDebugRow.append(showDebugBoolean); + + // sync the ui to the selected projection: 360 renders are 2:1 + // equirectangular without portrait mode or debug overlays + const syncProjection = () => { + const is360 = projectionSelect.value === 'equirect'; + resolutionSelect.options = is360 ? equirectResolutions : standardResolutions; + resolutionSelect.value = is360 ? '360-4k' : '1080'; + portraitRow.hidden = is360; + showDebugRow.hidden = is360; + levelHorizonRow.hidden = !is360; + }; + + projectionSelect.on('change', syncProjection); + syncProjection(); + + // content + + const content = new Container({ id: 'content' }); + content.append(projectionRow); + content.append(resolutionRow); + content.append(formatRow); + content.append(codecRow); + content.append(frameRateRow); + content.append(bitrateRow); + content.append(frameRangeRow); + content.append(portraitRow); + content.append(levelHorizonRow); + content.append(transparentBgRow); + content.append(showDebugRow); + + // footer + + const footer = new Container({ id: 'footer' }); + + const cancelButton = new Button({ + class: 'button' + }); + i18n.bindText(cancelButton, 'panel.render.cancel'); + + const okButton = new Button({ + class: 'button' + }); + i18n.bindText(okButton, 'panel.render.ok'); + + footer.append(cancelButton); + footer.append(okButton); + + dialog.append(header); + dialog.append(content); + dialog.append(footer); + + this.append(dialog); + + // handle key bindings for enter and escape + + let onCancel: () => void; + let onOK: () => void; + + cancelButton.on('click', () => onCancel()); + okButton.on('click', () => onOK()); + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } + }; + + // reset UI and configure for current state + const reset = () => { + const totalFrames = events.invoke('timeline.frames'); + frameRangeInput.max = totalFrames - 1; + frameRangeInput.value = [0, totalFrames - 1]; + }; + + // function implementations + + this.show = () => { + reset(); + + this.hidden = false; + document.addEventListener('keydown', keydown); + this.dom.focus(); + + return new Promise((resolve) => { + onCancel = () => { + resolve(null); + }; + + onOK = () => { + + const widths: Record = { + '540': 960, + '720': 1280, + '1080': 1920, + '1440': 2560, + '4k': 3840, + '360-1k': 1024, + '360-2k': 2048, + '360-4k': 3840, + '360-4096': 4096 + }; + + const heights: Record = { + '540': 540, + '720': 720, + '1080': 1080, + '1440': 1440, + '4k': 2160, + '360-1k': 512, + '360-2k': 1024, + '360-4k': 1920, + '360-4096': 2048 + }; + + const frameRates: Record = { + '12': 12, + '15': 15, + '24': 24, + '25': 25, + '30': 30, + '48': 48, + '60': 60, + '120': 120 + }; + + // bits per pixel per frame for different quality settings + const bppfs: Record = { + 'low': 0.001, + 'medium': 0.01, + 'high': 0.1, + 'ultra': 1 + }; + + // scale down higher resolutions (matched by pixel count) + const bbpfFactors: Record = { + '540': 1, + '720': 1 / 2, + '1080': 1 / 3, + '1440': 1 / 4, + '4k': 1 / 5, + '360-1k': 1, + '360-2k': 1 / 3, + '360-4k': 1 / 5, + '360-4096': 1 / 5 + }; + + const is360 = projectionSelect.value === 'equirect'; + const portrait = !is360 && portraitBoolean.value; + const width = (portrait ? heights : widths)[resolutionSelect.value]; + const height = (portrait ? widths : heights)[resolutionSelect.value]; + const frameRate = frameRates[frameRateSelect.value]; + const bppf = bppfs[bitrateSelect.value] * bbpfFactors[resolutionSelect.value]; + // bitrate (bps) = 100m * (width × height × frame rate × bppf) / 1m + const bitrate = Math.floor(10 * width * height * frameRate * bppf); + + const frameRange = frameRangeInput.value as number[]; + + const videoSettings = { + startFrame: frameRange[0], + endFrame: frameRange[1], + frameRate, + width, + height, + bitrate, + transparentBg: transparentBgBoolean.value, + showDebug: !is360 && showDebugBoolean.value, + format: formatSelect.value as 'mp4' | 'webm' | 'mov' | 'mkv', + codec: codecSelect.value as 'h264' | 'h265' | 'vp9' | 'av1', + projection: (is360 ? 'equirect' : 'standard') as 'standard' | 'equirect', + levelHorizon: is360 && levelHorizonBoolean.value + }; + + resolve(videoSettings); + }; + }).finally(() => { + document.removeEventListener('keydown', keydown); + this.hide(); + }); + }; + + this.hide = () => { + this.hidden = true; + }; + + this.destroy = () => { + this.hide(); + super.destroy(); + }; + } +} + +export { VideoSettingsDialog }; diff --git a/src/ui/view-cube.ts b/src/ui/view-cube.ts new file mode 100644 index 0000000..43006f1 --- /dev/null +++ b/src/ui/view-cube.ts @@ -0,0 +1,173 @@ +import { Container } from '@playcanvas/pcui'; +import { Mat4, Vec3 } from 'playcanvas'; + +import { Events } from '../events'; + +const vecx = new Vec3(); +const vecy = new Vec3(); +const vecz = new Vec3(); +const mat4 = new Mat4(); + +class ViewCube extends Container { + update: (cameraMatrix: Mat4) => void; + + constructor(events: Events, args = {}) { + args = { + ...args, + id: 'view-cube-container' + }; + + super(args); + + // construct svg elements + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.id = 'view-cube-svg'; + + const group = document.createElementNS(svg.namespaceURI, 'g'); + svg.appendChild(group); + + const circle = (color: string, fill: boolean, text?: string) => { + const result = document.createElementNS(svg.namespaceURI, 'g') as SVGElement; + + const circle = document.createElementNS(svg.namespaceURI, 'circle') as SVGCircleElement; + circle.setAttribute('fill', fill ? color : '#222'); + circle.setAttribute('stroke', color); + circle.setAttribute('stroke-width', '2'); + circle.setAttribute('r', '10'); + circle.setAttribute('cx', '0'); + circle.setAttribute('cy', '0'); + circle.setAttribute('pointer-events', 'all'); + + result.appendChild(circle); + + if (text) { + const t = document.createElementNS(svg.namespaceURI, 'text') as SVGTextElement; + t.setAttribute('font-size', '10'); + t.setAttribute('font-family', 'Arial'); + t.setAttribute('font-weight', 'bold'); + t.setAttribute('text-anchor', 'middle'); + t.setAttribute('alignment-baseline', 'central'); + t.textContent = text; + result.appendChild(t); + } + + result.setAttribute('cursor', 'pointer'); + + group.appendChild(result); + + return result; + }; + + const line = (color: string) => { + const result = document.createElementNS(svg.namespaceURI, 'line') as SVGLineElement; + result.setAttribute('stroke', color); + result.setAttribute('stroke-width', '2'); + group.appendChild(result); + return result; + }; + + const r = '#f44'; + const g = '#4f4'; + const b = '#77f'; + + const shapes = { + nx: circle(r, false), + ny: circle(g, false), + nz: circle(b, false), + xaxis: line(r), + yaxis: line(g), + zaxis: line(b), + px: circle(r, true, 'X'), + py: circle(g, true, 'Y'), + pz: circle(b, true, 'Z') + }; + + shapes.px.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'px'); e.stopPropagation(); + }); + shapes.py.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'py'); e.stopPropagation(); + }); + shapes.pz.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'pz'); e.stopPropagation(); + }); + shapes.nx.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'nx'); e.stopPropagation(); + }); + shapes.ny.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'ny'); e.stopPropagation(); + }); + shapes.nz.children[0].addEventListener('pointerdown', (e) => { + events.fire('camera.align', 'nz'); e.stopPropagation(); + }); + + this.dom.appendChild(svg); + + let cw = 0; + let ch = 0; + + this.update = (cameraMatrix: Mat4) => { + const w = this.dom.clientWidth; + const h = this.dom.clientHeight; + + if (w && h) { + if (w !== cw || h !== ch) { + // resize elements + svg.setAttribute('width', w.toString()); + svg.setAttribute('height', h.toString()); + group.setAttribute('transform', `translate(${w * 0.5}, ${h * 0.5})`); + cw = w; + ch = h; + } + + mat4.invert(cameraMatrix); + mat4.getX(vecx); + mat4.getY(vecy); + mat4.getZ(vecz); + + const transform = (group: SVGElement, x: number, y: number) => { + group.setAttribute('transform', `translate(${x * 40}, ${y * 40})`); + }; + + const x2y2 = (line: SVGLineElement, x: number, y: number) => { + line.setAttribute('x2', (x * 40).toString()); + line.setAttribute('y2', (y * 40).toString()); + }; + + transform(shapes.px, vecx.x, -vecx.y); + transform(shapes.nx, -vecx.x, vecx.y); + transform(shapes.py, vecy.x, -vecy.y); + transform(shapes.ny, -vecy.x, vecy.y); + transform(shapes.pz, vecz.x, -vecz.y); + transform(shapes.nz, -vecz.x, vecz.y); + + x2y2(shapes.xaxis, vecx.x, -vecx.y); + x2y2(shapes.yaxis, vecy.x, -vecy.y); + x2y2(shapes.zaxis, vecz.x, -vecz.y); + + // reorder dom for the mighty svg painter's algorithm + const order = [ + { n: ['xaxis', 'px'], value: vecx.z }, + { n: ['yaxis', 'py'], value: vecy.z }, + { n: ['zaxis', 'pz'], value: vecz.z }, + { n: ['nx'], value: -vecx.z }, + { n: ['ny'], value: -vecy.z }, + { n: ['nz'], value: -vecz.z } + ].sort((a, b) => a.value - b.value); + + const fragment = document.createDocumentFragment(); + + order.forEach((o) => { + o.n.forEach((n) => { + // @ts-ignore + fragment.appendChild(shapes[n]); + }); + }); + + group.appendChild(fragment); + } + }; + } +} + +export { ViewCube }; diff --git a/src/underlay.ts b/src/underlay.ts new file mode 100644 index 0000000..b782ca7 --- /dev/null +++ b/src/underlay.ts @@ -0,0 +1,61 @@ +import { + BLENDEQUATION_ADD, + BLENDMODE_ONE, + BLENDMODE_ZERO, + BlendState, + Layer +} from 'playcanvas'; + +import { Element, ElementType } from './element'; +import { vertexShader, fragmentShader } from './shaders/blit-shader'; +import { ShaderQuad, SimpleRenderPass } from './utils/simple-render-pass'; + +class Underlay extends Element { + shaderQuad: ShaderQuad; + renderPass: SimpleRenderPass; + enabled = true; + + constructor() { + super(ElementType.other); + } + + add() { + const device = this.scene.app.graphicsDevice; + + this.shaderQuad = new ShaderQuad(device, vertexShader, fragmentShader, 'apply-underlay'); + this.renderPass = new SimpleRenderPass(device, this.shaderQuad, { + blendState: new BlendState(true, + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE, + BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE + ) + }); + + const { camera, events } = this.scene; + + camera.camera.on('preRenderLayer', (layer: Layer, transparent: boolean) => { + // underlay is used when outline mode is disabled + if (!this.enabled || events.invoke('view.outlineSelection')) { + return; + } + + // apply at the start of the gizmo layer + if (layer !== this.scene.gizmoLayer || transparent) { + return; + } + + this.renderPass.execute({ + srcTexture: camera.workTarget.colorBuffer + }); + }); + } + + remove() { + // event listeners are cleaned up when camera is destroyed + } + + onPreRender() { + // no longer need to manage a separate camera + } +} + +export { Underlay }; diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts new file mode 100644 index 0000000..39aa222 --- /dev/null +++ b/src/utils/resolve.ts @@ -0,0 +1,9 @@ +import { ScopeSpace } from 'playcanvas'; + +const resolve = (scope: ScopeSpace, values: any) => { + for (const [key, value] of Object.entries(values)) { + scope.resolve(key).setValue(value); + } +}; + +export { resolve }; diff --git a/src/utils/simple-render-pass.ts b/src/utils/simple-render-pass.ts new file mode 100644 index 0000000..8569b1e --- /dev/null +++ b/src/utils/simple-render-pass.ts @@ -0,0 +1,84 @@ +import { + CULLFACE_NONE, + SEMANTIC_POSITION, + BlendState, + DepthState, + GraphicsDevice, + QuadRender, + RenderPass, + Shader, + ShaderUtils, + StencilParameters, + Vec4 +} from 'playcanvas'; + +import { resolve } from './resolve'; + +class ShaderQuad { + shader: Shader; + quadRender: QuadRender; + + constructor(device: GraphicsDevice, vertexGLSL: string, fragmentGLSL: string, uniqueName: string) { + this.shader = ShaderUtils.createShader(device, { + uniqueName, + attributes: { + vertex_position: SEMANTIC_POSITION + }, + vertexGLSL, + fragmentGLSL + }); + + this.quadRender = new QuadRender(this.shader); + } + + render(viewport?: Vec4, scissor?: Vec4) { + this.quadRender.render(viewport, scissor); + } + + destroy() { + this.shader.destroy(); + this.quadRender.destroy(); + } +} + +interface Renderable { + render(viewport?: Vec4, scissor?: Vec4): void; +} + +class SimpleRenderPass extends RenderPass { + blendState = BlendState.NOBLEND; + cullMode = CULLFACE_NONE; + depthState = DepthState.NODEPTH; + stencilFront: StencilParameters = null; + stencilBack: StencilParameters = null; + viewport: Vec4 = null; + scissor: Vec4 = null; + + renderable: Renderable; + vars: () => object = null; + + constructor(device: GraphicsDevice, renderable: Renderable, args?: Partial) { + super(device); + this.renderable = renderable; + Object.assign(this, args); + } + + execute(vars: any = {}) { + const { device, blendState, cullMode, depthState, stencilFront, stencilBack, viewport, scissor } = this; + + if (this.vars) { + resolve(device.scope, this.vars()); + } + + resolve(device.scope, vars); + + device.setBlendState(blendState); + device.setCullMode(cullMode); + device.setDepthState(depthState); + device.setStencilState(stencilFront, stencilBack); + + this.renderable.render(viewport, scissor); + } +} + +export { ShaderQuad, SimpleRenderPass }; diff --git a/static/env/Echopark.png b/static/env/Echopark.png new file mode 100644 index 0000000..48a5dd5 Binary files /dev/null and b/static/env/Echopark.png differ diff --git a/static/env/VertebraeHDRI_v1_512.png b/static/env/VertebraeHDRI_v1_512.png new file mode 100644 index 0000000..12e313a Binary files /dev/null and b/static/env/VertebraeHDRI_v1_512.png differ diff --git a/static/icons/logo-192.png b/static/icons/logo-192.png new file mode 100644 index 0000000..30f2a80 Binary files /dev/null and b/static/icons/logo-192.png differ diff --git a/static/icons/logo-512.png b/static/icons/logo-512.png new file mode 100644 index 0000000..7414b33 Binary files /dev/null and b/static/icons/logo-512.png differ diff --git a/static/images/header.webp b/static/images/header.webp new file mode 100644 index 0000000..8b88416 Binary files /dev/null and b/static/images/header.webp differ diff --git a/static/images/screenshot-narrow.jpg b/static/images/screenshot-narrow.jpg new file mode 100644 index 0000000..0e75f1c Binary files /dev/null and b/static/images/screenshot-narrow.jpg differ diff --git a/static/images/screenshot-wide.jpg b/static/images/screenshot-wide.jpg new file mode 100644 index 0000000..b038743 Binary files /dev/null and b/static/images/screenshot-wide.jpg differ diff --git a/static/lib/webp/webp.mjs b/static/lib/webp/webp.mjs new file mode 100644 index 0000000..942ffa6 --- /dev/null +++ b/static/lib/webp/webp.mjs @@ -0,0 +1,14 @@ +var Module = (() => { + + return ( +async function(moduleArg = {}) { + var moduleRtn; + +var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&process.versions?.node&&process.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(_scriptName.startsWith("file:")){scriptDirectory=nodePath.dirname(require("url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;var runtimeInitialized=false;var isFileURI=filename=>filename.startsWith("file://");function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["c"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("webp.wasm")}return new URL("webp.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{a:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["b"];updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();try{var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}catch(e){readyPromiseReject(e);return Promise.reject(e)}}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"]}Module["cwrap"]=cwrap;var wasmImports={a:_emscripten_resize_heap};var wasmExports=await createWasm();var ___wasm_call_ctors=wasmExports["c"];var _webp_encode_rgba=Module["_webp_encode_rgba"]=wasmExports["d"];var _webp_encode_lossless_rgba=Module["_webp_encode_lossless_rgba"]=wasmExports["e"];var _webp_decode_rgba=Module["_webp_decode_rgba"]=wasmExports["f"];var _webp_free=Module["_webp_free"]=wasmExports["g"];var _malloc=Module["_malloc"]=wasmExports["h"];var _free=Module["_free"]=wasmExports["i"];var __emscripten_stack_restore=wasmExports["j"];var __emscripten_stack_alloc=wasmExports["k"];var _emscripten_stack_get_current=wasmExports["l"];function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}function preInit(){if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}preInit();run();moduleRtn=readyPromise; + + + return moduleRtn; +} +); +})(); +export default Module; diff --git a/static/lib/webp/webp.wasm b/static/lib/webp/webp.wasm new file mode 100755 index 0000000..4d871b7 Binary files /dev/null and b/static/lib/webp/webp.wasm differ diff --git a/static/locales/de.json b/static/locales/de.json new file mode 100644 index 0000000..002307a --- /dev/null +++ b/static/locales/de.json @@ -0,0 +1,324 @@ +{ + "menu.file": "Datei", + "menu.file.new": "Neu", + "menu.file.open": "Öffnen", + "menu.file.open-recent": "Zuletzt geöffnet", + "menu.file.open-recent.clear": "Liste leeren", + "menu.file.import": "Importieren", + "menu.file.save": "Speichern", + "menu.file.save-as": "Speichern als", + "menu.file.publish": "Veröffentlichen", + "menu.file.export": "Exportieren", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Viewer App", + "menu.edit": "Bearbeiten", + "menu.edit.undo": "Rückgängig", + "menu.edit.redo": "Wiederholen", + "menu.edit.duplicate": "Duplizieren", + "menu.edit.separate": "Separieren", + "menu.select": "Auswahl", + "menu.select.all": "Alles", + "menu.select.none": "Aufheben", + "menu.select.invert": "Invertieren", + "menu.select.lock": "Selektion sperren", + "menu.select.unlock": "Sperre aufheben", + "menu.select.delete": "Selektion aufheben", + "menu.select.reset": "Splats zurücksetzen", + "menu.render": "Rendern", + "menu.render.image": "Bild", + "menu.render.video": "Video", + "menu.help": "Hilfe", + "menu.help.shortcuts": "Tastaturkürzel", + "menu.help.user-guide": "Handbuch", + "menu.help.log-issue": "Problem melden", + "menu.help.github-repo": "GitHub Repository", + "menu.help.video-tutorials": "Video-Tutorials", + "menu.help.video-tutorials.basics": "Grundlagen lernen", + "menu.help.video-tutorials.in-depth": "Ausführliches Tutorial", + "menu.help.video-tutorials.deleting-floaters": "Floater löschen", + "menu.help.video-tutorials.scaling": "Splats skalieren", + "menu.help.discord": "Discord Server", + "menu.help.forum": "Forum", + "menu.help.about": "Über SuperSplat", + "panel.mode.centers": "Punkt Modus", + "panel.mode.rings": "Ring Modus", + "panel.scene-manager": "Szenen Manager", + "panel.scene-manager.transform": "Transformation", + "panel.scene-manager.transform.position": "Position", + "panel.scene-manager.transform.rotation": "Rotation", + "panel.scene-manager.transform.scale": "Skalierung", + "panel.colors": "Farbe", + "panel.colors.tint": "Färbung", + "panel.colors.temperature": "Temperatur", + "panel.colors.saturation": "Sättigung", + "panel.colors.brightness": "Helligkeit", + "panel.colors.black-point": "Schwarzpunkt", + "panel.colors.white-point": "Weißpunkt", + "panel.colors.transparency": "Transparenz", + "panel.colors.reset": "Zurücksetzen", + "panel.settings": "Einstellungen", + "panel.settings.language": "Sprache", + "panel.settings.language.auto": "Automatisch", + "panel.settings.colors": "Farben", + "panel.settings.background-color": "Hintergrundfarbe", + "panel.settings.selected-color": "Selektierte Farbe", + "panel.settings.unselected-color": "Nicht selektierte Farbe", + "panel.settings.locked-color": "Gesperrte Farbe", + "panel.settings.fov": "Sichtfeld (FoV)", + "panel.settings.fov-dolly": "FOV-Auto-Dolly", + "panel.settings.control-mode": "Steuerungsmodus", + "panel.settings.control-mode.orbit": "Orbit", + "panel.settings.control-mode.fly": "Fliegen", + "panel.settings.sh-bands": "SH Bänder", + "panel.settings.centers-size": "Punktgrößen", + "panel.settings.centers-gaussian-color": "Farbzentren", + "panel.settings.outline-selection": "Umriss Selektion", + "panel.settings.show-grid": "Raster anzeigen", + "panel.settings.show-bound": "Objektbox anzeigen", + "panel.settings.show-bound-dimensions": "Abmessungen anzeigen", + "panel.settings.show-camera-poses": "Kameras anzeigen", + "panel.settings.show-camera-info": "Kamera-Info anzeigen", + "panel.settings.fly-speed": "Kamera Geschwindigkeit", + "panel.settings.tonemapping": "Tonemapping", + "panel.settings.tonemapping.none": "Keins", + "panel.settings.tonemapping.linear": "Linear", + "panel.settings.tonemapping.neutral": "Neutral", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Filmic", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Auf Standard zurücksetzen", + "panel.splat-data": "Splat-Daten", + "panel.splat-data.distance": "Entfernung", + "panel.splat-data.camera-depth": "Kameratiefe", + "panel.splat-data.volume": "Volumen", + "panel.splat-data.surface-area": "Oberfläche", + "panel.splat-data.scale-x": "Größe X", + "panel.splat-data.scale-y": "Größe Y", + "panel.splat-data.scale-z": "Größe Z", + "panel.splat-data.red": "Rot", + "panel.splat-data.green": "Grün", + "panel.splat-data.blue": "Blau", + "panel.splat-data.dc-red": "DC Rot", + "panel.splat-data.dc-green": "DC Grün", + "panel.splat-data.dc-blue": "DC Blau", + "panel.splat-data.opacity": "Deckkraft", + "panel.splat-data.hue": "Farbe", + "panel.splat-data.saturation": "Sättigung", + "panel.splat-data.value": "Helligkeit", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Logarithmische Skala", + "panel.splat-data.show-all": "Alle Eigenschaften", + "panel.splat-data.on-screen-only": "Nur sichtbare Splats", + "panel.splat-data.totals": "Summe", + "panel.splat-data.totals.splats": "Splats", + "panel.splat-data.totals.selected": "Selektiert", + "panel.splat-data.totals.locked": "Gesperrt", + "panel.splat-data.totals.deleted": "Gelöscht", + "panel.render.ok": "Rendern", + "panel.render.cancel": "Abbrechen", + "panel.render.render-video": "Video rendern", + "panel.render.rendering": "Frames werden gerendert", + "panel.render.failed": "Rendern fehlgeschlagen", + "select-toolbar.set": "Setzen", + "select-toolbar.add": "Hinzufügen", + "select-toolbar.remove": "Entfernen", + "select-toolbar.intersect": "Schneiden", + "select-toolbar.position": "Position", + "select-toolbar.size": "Größe", + "select-toolbar.radius": "Radius", + "popup.ok": "OK", + "popup.cancel": "Abbrechen", + "popup.yes": "Ja", + "popup.no": "Nein", + "popup.error": "Fehler", + "popup.error-loading": "Fehler beim Laden der Datei", + "popup.webgpu-unavailable": "Dieser Export erfordert WebGPU, das in diesem Browser nicht verfügbar ist. Bitte verwenden Sie eine aktuelle Version von Chrome, Edge oder Safari.", + "popup.lcc-upload-warning": "Für eine bessere Veröffentlichung auf superspl.at laden Sie Ihre LCC-Datei direkt über die Upload-Seite hoch.", + "popup.export": "Exportieren", + "popup.copy-to-clipboard": "Link in die Zwischenablage kopieren", + "popup.render-image.header": "Bild Einstellungen", + "popup.render-image.preset": "Vorgabe", + "popup.render-image.resolution": "Auflösung", + "popup.render-image.transparent-bg": "Transparenter Hintergrund", + "popup.render-image.show-debug": "Debug-Überlagerungen anzeigen", + "popup.render-image.resolution-current": "Aktuell", + "popup.render-image.resolution-custom": "Benutzerdefiniert", + "popup.render-image.projection": "Projektion", + "popup.render-image.projection-standard": "Standard", + "popup.render-image.projection-360": "360° Equirektangular", + "popup.render-image.level-horizon": "Horizont ausrichten", + "popup.render-video.header": "Video Einstellungen", + "popup.render-video.projection": "Projektion", + "popup.render-video.projection-standard": "Standard", + "popup.render-video.projection-360": "360° Equirektangular", + "popup.render-video.resolution": "Auflösung", + "popup.render-video.format": "Format", + "popup.render-video.codec": "Codec", + "popup.render-video.frame-rate": "Bildrate", + "popup.render-video.frame-range": "Frame-Bereich", + "popup.render-video.frame-range-first": "Erste", + "popup.render-video.frame-range-last": "Letzte", + "popup.render-video.bitrate": "Bitrate", + "popup.render-video.portrait": "Hochformat", + "popup.render-video.level-horizon": "Horizont ausrichten", + "popup.render-video.transparent-bg": "Transparenter Hintergrund", + "popup.render-video.show-debug": "Debug-Überlagerungen anzeigen", + "popup.shortcuts.title": "Tastaturkürzel", + "popup.shortcuts.navigation": "Navigation", + "popup.shortcuts.tools": "Werkzeuge", + "popup.shortcuts.move": "Bewegen", + "popup.shortcuts.rotate": "Drehen", + "popup.shortcuts.scale": "Skalieren", + "popup.shortcuts.rect-selection": "Rechteckselektion", + "popup.shortcuts.lasso-selection": "Lassoselektion", + "popup.shortcuts.polygon-selection": "Polygonselektion", + "popup.shortcuts.brush-selection": "Pinselselektion", + "popup.shortcuts.flood-selection": "Füllselektion", + "popup.shortcuts.eyedropper-selection": "Pipettenselektion", + "popup.shortcuts.brush-size": "Pinsel Verkleinern/Vergrößern", + "popup.shortcuts.deactivate-tool": "Werkzeug deaktivieren", + "popup.shortcuts.selection": "Selektion", + "popup.shortcuts.select-all": "Alle Selektieren", + "popup.shortcuts.deselect-all": "Selektion aufheben", + "popup.shortcuts.invert-selection": "Selektion invertieren", + "popup.shortcuts.add-to-selection": "Zur Selektion hinzufügen", + "popup.shortcuts.remove-from-selection": "Von Selektion entfernen", + "popup.shortcuts.delete-selected-splats": "Selektierte Splats löschen", + "popup.shortcuts.show": "Anzeigen", + "popup.shortcuts.lock-selected-splats": "Ausgewählte Splats sperren", + "popup.shortcuts.unlock-all-splats": "Alle Splats entsperren", + "popup.shortcuts.toggle-data-panel": "Splat Daten Panel anzeigen", + "popup.shortcuts.toggle-timeline-panel": "Zeitleiste anzeigen", + "popup.shortcuts.other": "Weitere", + "popup.shortcuts.undo": "Rückgängig", + "popup.shortcuts.redo": "Wiederholen", + "popup.shortcuts.toggle-splat-overlay": "Splateinblendung umschalten", + "popup.shortcuts.focus-camera": "Kamera auf selektion ausrichten", + "popup.shortcuts.reset-camera": "Kamera zurücksetzen", + "popup.shortcuts.toggle-camera-mode": "Kameramodus umschalten", + "popup.shortcuts.toggle-overlay-mode": "Overlay-Modus umschalten", + "popup.shortcuts.toggle-control-mode": "Fliegen/Orbit Modus umschalten", + "popup.shortcuts.toggle-grid": "Rasteranzeige umschalten", + "popup.shortcuts.toggle-camera-info": "Kamera-Info umschalten", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Gizmoanzeige umschalten", + "popup.shortcuts.camera": "Kamera (Flugmodus)", + "popup.shortcuts.fly-movement": "Vorwärts/Links/Rückwärts/Rechts bewegen", + "popup.shortcuts.fly-vertical": "Runter/Hoch bewegen", + "popup.shortcuts.fly-speed-fast": "Schnelle Bewegung", + "popup.shortcuts.fly-speed-slow": "Langsame Bewegung", + "popup.shortcuts.playback": "Wiedergabe", + "popup.shortcuts.play-pause": "Abspielen/Pause", + "popup.shortcuts.prev-frame": "Vorheriges Bild", + "popup.shortcuts.next-frame": "Nächstes Bild", + "popup.shortcuts.prev-key": "Vorheriger Schlüssel", + "popup.shortcuts.next-key": "Nächster Schlüssel", + "popup.shortcuts.add-key": "Schlüssel Hinzufügen", + "popup.shortcuts.remove-key": "Schlüssel Entfernen", + "popup.export.header": "Exportieren", + "popup.export.type": "Export Typ", + "popup.export.html": "HTML", + "popup.export.package": "ZIP Paket", + "popup.export.sh-bands": "SH Bänder", + "popup.export.start-position": "Start Position", + "popup.export.default": "Standard", + "popup.export.viewport": "Aktuelle Ansicht", + "popup.export.pose-camera": "1. Kamera Pose", + "popup.export.fov": "Sichtfeld (FoV)", + "popup.export.background-color": "Hintergrund", + "popup.export.filename": "Dateiname", + "popup.export.animation": "Animation", + "popup.export.animation.none": "Keine", + "popup.export.animation.track": "Track", + "popup.export.loop-mode": "Wiederholungsmodus", + "popup.export.loop-mode.none": "Keine", + "popup.export.loop-mode.repeat": "Wiederholen", + "popup.export.loop-mode.pingpong": "Ping Pong", + "popup.export.compress-ply": "PLY komprimieren", + "popup.export.splats-select": "Splats", + "popup.export.splats-select.all": "Alle Splats", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Version", + "popup.export.spz-version.4": "SPZ 4 (aktuell)", + "popup.export.spz-version.3": "SPZ 3 (Legacy-gzip)", + "popup.publish.header": "Veröffentlichen Einstellungen", + "popup.publish.ok": "Veröffentlichen", + "popup.publish.cancel": "Abbrechen", + "popup.publish.title": "Titel", + "popup.publish.description": "Beschreibung", + "popup.publish.listed": "Aufgelistet", + "popup.publish.failed": "Veröffentlichen Fehlgeschlagen", + "popup.publish.please-try-again": "Bitte versuchen Sie es später erneut.", + "popup.publish.succeeded": "Veröffentlichen Erfolgreich", + "popup.publish.message": "Verwenden Sie den Link unten, um auf Ihre Szene zuzugreifen.", + "popup.publish.please-log-in": "Das Veröffentlichen in PlayCanvas erfordert ein Benutzerkonto. Bitte melden Sie sich an und versuchen Sie es erneut.", + "popup.publish.converting": "Konvertieren", + "popup.publish.uploading": "Hochladen", + "popup.publish.to": "Veröffentlichen in", + "popup.publish.new-scene": "Neue Szene", + "popup.publish.override-model": "Modell überschreiben", + "popup.publish.override-animation": "Animation überschreiben", + "popup.publish.generate-lods": "LODs erzeugen", + "camera-info.position": "Kameraposition", + "camera-info.target": "Kameraziel", + "doc.reset": "Szene Zurücksetzen", + "doc.unsaved-message": "Sie haben ungespeicherte Änderungen. Möchten Sie die Szene wirklich zurücksetzen?", + "doc.reset-message": "Möchten Sie die Szene wirklich zurücksetzen?", + "doc.save-failed": "Fehlgeschlagen zu Speichern", + "doc.load-failed": "Fehlgeschlagen zu Laden", + "tooltip.right-toolbar.splat-mode": "Splat Modus", + "tooltip.right-toolbar.show-hide": "Anzeigen/Ausblenden Splats", + "tooltip.right-toolbar.orbit-camera": "Orbit-Kamera", + "tooltip.right-toolbar.fly-camera": "Flugkamera", + "tooltip.right-toolbar.frame-selection": "Rechteckselektion", + "tooltip.right-toolbar.reset-camera": "Kamera zurücksetzen", + "tooltip.right-toolbar.colors": "Farben", + "tooltip.right-toolbar.settings": "Einstellungen", + "tooltip.bottom-toolbar.undo": "Rückgängig", + "tooltip.bottom-toolbar.redo": "Wiederholen", + "tooltip.bottom-toolbar.rect": "Rechteckselektion", + "tooltip.bottom-toolbar.lasso": "Lassoselektion", + "tooltip.bottom-toolbar.polygon": "Polygonselektion", + "tooltip.bottom-toolbar.brush": "Pinselselektion", + "tooltip.bottom-toolbar.flood": "Füllselektion", + "tooltip.bottom-toolbar.eyedropper": "Pipettenselektion", + "tooltip.bottom-toolbar.sphere": "Kugelselektion", + "tooltip.bottom-toolbar.box": "Kastenselektion", + "tooltip.bottom-toolbar.translate": "Verschieben", + "tooltip.bottom-toolbar.rotate": "Drehen", + "tooltip.bottom-toolbar.scale": "Skalieren", + "tooltip.bottom-toolbar.measure": "Messung", + "measure.length": "Länge", + "tooltip.bottom-toolbar.local-space": "Gizmo in local-space", + "tooltip.bottom-toolbar.bound-center": "Mittelpunkt verwenden", + "tooltip.timeline.prev-frame": "Vorheriges Bild", + "tooltip.timeline.next-frame": "Nächstes Bild", + "tooltip.timeline.play": "Abspielen/Stoppen", + "tooltip.timeline.add-key": "Key hinzufügen", + "tooltip.timeline.remove-key": "Key entfernen", + "tooltip.timeline.key": "Ziehen zum Verschieben. Umschalt+Ziehen zum Kopieren. Strg+Klick übernimmt die aktuelle Ansicht.", + "tooltip.timeline.frame-rate": "Bildrate", + "tooltip.timeline.total-frames": "Gesamtanzahl der Frames", + "tooltip.timeline.smoothness": "Weichheit", + "tooltip.timeline.loop": "Animation wiederholen", + "tooltip.scene.solo": "Auswahl solo anzeigen", + "tooltip.scene.import": "Szene importieren", + "tooltip.scene.new": "Neue Szene", + "status-bar.splats": "Splats", + "status-bar.selected": "Ausgewählt", + "status-bar.locked": "Gesperrt", + "status-bar.deleted": "Gelöscht", + "status-bar.timeline": "Zeitleiste", + "status-bar.splat-data": "Splat-Daten", + "tooltip.status-bar.timeline": "Zeitleisten-Panel umschalten", + "tooltip.status-bar.splat-data": "Splat-Daten-Panel umschalten", + "tooltip.splat-data.on-screen-only": "Nur Splats zählen, die in der Kameraansicht sichtbar sind. Splats außerhalb des Bildschirms werden aus dem Histogramm ausgeschlossen.", + "tooltip.splat-data.log-scale": "Logarithmische vertikale Skala verwenden, damit kleine Werte neben großen sichtbar bleiben.", + "tooltip.splat-data.show-all": "Alle Eigenschaften pro Splat in der Liste anzeigen, einschließlich roher DC- und Kugelflächenfunktions-Koeffizienten." +} diff --git a/static/locales/en.json b/static/locales/en.json new file mode 100644 index 0000000..ee3751c --- /dev/null +++ b/static/locales/en.json @@ -0,0 +1,324 @@ +{ + "menu.file": "File", + "menu.file.new": "New", + "menu.file.open": "Open", + "menu.file.open-recent": "Open Recent", + "menu.file.open-recent.clear": "Clear Recent", + "menu.file.import": "Import", + "menu.file.save": "Save", + "menu.file.save-as": "Save As", + "menu.file.publish": "Publish", + "menu.file.export": "Export", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Viewer App", + "menu.edit": "Edit", + "menu.edit.undo": "Undo", + "menu.edit.redo": "Redo", + "menu.edit.duplicate": "Duplicate", + "menu.edit.separate": "Separate", + "menu.select": "Select", + "menu.select.all": "All", + "menu.select.none": "None", + "menu.select.invert": "Invert", + "menu.select.lock": "Lock Selection", + "menu.select.unlock": "Unlock All", + "menu.select.delete": "Delete Selection", + "menu.select.reset": "Reset Splat", + "menu.render": "Render", + "menu.render.image": "Image", + "menu.render.video": "Video", + "menu.help": "Help", + "menu.help.shortcuts": "Keyboard Shortcuts", + "menu.help.user-guide": "User Guide", + "menu.help.log-issue": "Log an Issue", + "menu.help.github-repo": "GitHub Repo", + "menu.help.video-tutorials": "Video Tutorials", + "menu.help.video-tutorials.basics": "Learn the Basics", + "menu.help.video-tutorials.in-depth": "In-Depth Tutorial", + "menu.help.video-tutorials.deleting-floaters": "Deleting Floaters", + "menu.help.video-tutorials.scaling": "Scaling Your Splats", + "menu.help.discord": "Discord Server", + "menu.help.forum": "Forum", + "menu.help.about": "About SuperSplat", + "panel.mode.centers": "Centers Mode", + "panel.mode.rings": "Rings Mode", + "panel.scene-manager": "Scene Manager", + "panel.scene-manager.transform": "Transform", + "panel.scene-manager.transform.position": "Position", + "panel.scene-manager.transform.rotation": "Rotation", + "panel.scene-manager.transform.scale": "Scale", + "panel.colors": "Colors", + "panel.colors.tint": "Tint", + "panel.colors.temperature": "Temperature", + "panel.colors.saturation": "Saturation", + "panel.colors.brightness": "Brightness", + "panel.colors.black-point": "Black Point", + "panel.colors.white-point": "White Point", + "panel.colors.transparency": "Transparency", + "panel.colors.reset": "Reset", + "panel.settings": "Settings", + "panel.settings.language": "Language", + "panel.settings.language.auto": "Automatic", + "panel.settings.colors": "Colors", + "panel.settings.background-color": "Background Color", + "panel.settings.selected-color": "Selected Color", + "panel.settings.unselected-color": "Unselected Color", + "panel.settings.locked-color": "Locked Color", + "panel.settings.fov": "Field of View", + "panel.settings.fov-dolly": "FOV Auto Dolly", + "panel.settings.control-mode": "Control Mode", + "panel.settings.control-mode.orbit": "Orbit", + "panel.settings.control-mode.fly": "Fly", + "panel.settings.sh-bands": "SH Bands", + "panel.settings.centers-size": "Centers Size", + "panel.settings.centers-gaussian-color": "Color Centers", + "panel.settings.outline-selection": "Outline Selection", + "panel.settings.show-grid": "Show Grid", + "panel.settings.show-bound": "Show Bound", + "panel.settings.show-bound-dimensions": "Show Dimensions", + "panel.settings.show-camera-poses": "Show Cameras", + "panel.settings.show-camera-info": "Show Camera Info", + "panel.settings.fly-speed": "Fly Speed", + "panel.settings.tonemapping": "Tonemapping", + "panel.settings.tonemapping.none": "None", + "panel.settings.tonemapping.linear": "Linear", + "panel.settings.tonemapping.neutral": "Neutral", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Filmic", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Reset to Defaults", + "panel.splat-data": "Splat Data", + "panel.splat-data.distance": "Distance", + "panel.splat-data.camera-depth": "Camera Depth", + "panel.splat-data.volume": "Volume", + "panel.splat-data.surface-area": "Surface Area", + "panel.splat-data.scale-x": "Scale X", + "panel.splat-data.scale-y": "Scale Y", + "panel.splat-data.scale-z": "Scale Z", + "panel.splat-data.red": "Red", + "panel.splat-data.green": "Green", + "panel.splat-data.blue": "Blue", + "panel.splat-data.dc-red": "DC R", + "panel.splat-data.dc-green": "DC G", + "panel.splat-data.dc-blue": "DC B", + "panel.splat-data.opacity": "Opacity", + "panel.splat-data.hue": "Hue", + "panel.splat-data.saturation": "Saturation", + "panel.splat-data.value": "Value", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Log Scale", + "panel.splat-data.show-all": "All Properties", + "panel.splat-data.on-screen-only": "Visible Splats Only", + "panel.splat-data.totals": "Totals", + "panel.splat-data.totals.splats": "Splats", + "panel.splat-data.totals.selected": "Selected", + "panel.splat-data.totals.locked": "Locked", + "panel.splat-data.totals.deleted": "Deleted", + "panel.render.ok": "Render", + "panel.render.cancel": "Cancel", + "panel.render.render-video": "Render Video", + "panel.render.rendering": "Rendering Frames", + "panel.render.failed": "Failed to Render", + "select-toolbar.set": "Set", + "select-toolbar.add": "Add", + "select-toolbar.remove": "Remove", + "select-toolbar.intersect": "Intersect", + "select-toolbar.position": "Position", + "select-toolbar.size": "Size", + "select-toolbar.radius": "Radius", + "popup.ok": "OK", + "popup.cancel": "Cancel", + "popup.yes": "Yes", + "popup.no": "No", + "popup.error": "Error", + "popup.error-loading": "Error Loading File", + "popup.webgpu-unavailable": "This export requires WebGPU, which is not available in this browser. Please try a recent version of Chrome, Edge or Safari.", + "popup.lcc-upload-warning": "For better publishing to superspl.at, upload your LCC file directly via the upload page.", + "popup.export": "Export", + "popup.copy-to-clipboard": "Copy Link to Clipboard", + "popup.render-image.header": "Image Settings", + "popup.render-image.preset": "Preset", + "popup.render-image.resolution": "Resolution", + "popup.render-image.transparent-bg": "Transparent Background", + "popup.render-image.show-debug": "Show Debug Overlays", + "popup.render-image.resolution-current": "Current", + "popup.render-image.resolution-custom": "Custom", + "popup.render-image.projection": "Projection", + "popup.render-image.projection-standard": "Standard", + "popup.render-image.projection-360": "360° Equirectangular", + "popup.render-image.level-horizon": "Level Horizon", + "popup.render-video.header": "Video Settings", + "popup.render-video.projection": "Projection", + "popup.render-video.projection-standard": "Standard", + "popup.render-video.projection-360": "360° Equirectangular", + "popup.render-video.resolution": "Resolution", + "popup.render-video.format": "Format", + "popup.render-video.codec": "Codec", + "popup.render-video.frame-rate": "Frame Rate", + "popup.render-video.frame-range": "Frame Range", + "popup.render-video.frame-range-first": "First", + "popup.render-video.frame-range-last": "Last", + "popup.render-video.bitrate": "Bitrate", + "popup.render-video.portrait": "Portrait Mode", + "popup.render-video.level-horizon": "Level Horizon", + "popup.render-video.transparent-bg": "Transparent Background", + "popup.render-video.show-debug": "Show Debug Overlays", + "popup.shortcuts.title": "Keyboard Shortcuts", + "popup.shortcuts.navigation": "Navigation", + "popup.shortcuts.tools": "Tools", + "popup.shortcuts.move": "Move", + "popup.shortcuts.rotate": "Rotate", + "popup.shortcuts.scale": "Scale", + "popup.shortcuts.rect-selection": "Rect Selection", + "popup.shortcuts.lasso-selection": "Lasso Selection", + "popup.shortcuts.polygon-selection": "Polygon Selection", + "popup.shortcuts.brush-selection": "Brush Selection", + "popup.shortcuts.flood-selection": "Flood Selection", + "popup.shortcuts.eyedropper-selection": "Eyedropper Selection", + "popup.shortcuts.brush-size": "Decrease/Increase Brush Size", + "popup.shortcuts.deactivate-tool": "Deactivate Tool", + "popup.shortcuts.selection": "Selection", + "popup.shortcuts.select-all": "Select All", + "popup.shortcuts.deselect-all": "Deselect All", + "popup.shortcuts.invert-selection": "Invert Selection", + "popup.shortcuts.add-to-selection": "Add to Selection", + "popup.shortcuts.remove-from-selection": "Remove from Selection", + "popup.shortcuts.delete-selected-splats": "Delete Selected Splats", + "popup.shortcuts.show": "Show", + "popup.shortcuts.lock-selected-splats": "Lock Selected Splats", + "popup.shortcuts.unlock-all-splats": "Unlock All Splats", + "popup.shortcuts.toggle-data-panel": "Toggle Data Panel", + "popup.shortcuts.toggle-timeline-panel": "Toggle Timeline Panel", + "popup.shortcuts.other": "Other", + "popup.shortcuts.undo": "Undo", + "popup.shortcuts.redo": "Redo", + "popup.shortcuts.toggle-splat-overlay": "Toggle Splat Overlay", + "popup.shortcuts.focus-camera": "Focus Camera on Current Selection", + "popup.shortcuts.reset-camera": "Reset Camera", + "popup.shortcuts.toggle-camera-mode": "Toggle Camera Mode", + "popup.shortcuts.toggle-overlay-mode": "Toggle Overlay Mode", + "popup.shortcuts.toggle-control-mode": "Toggle Fly/Orbit Mode", + "popup.shortcuts.toggle-grid": "Toggle Grid", + "popup.shortcuts.toggle-camera-info": "Toggle Camera Info", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Toggle Gizmo Coordinate Space", + "popup.shortcuts.camera": "Camera (Fly Mode)", + "popup.shortcuts.fly-movement": "Move Forward/Left/Backward/Right", + "popup.shortcuts.fly-vertical": "Move Down/Up", + "popup.shortcuts.fly-speed-fast": "Fast Movement", + "popup.shortcuts.fly-speed-slow": "Slow Movement", + "popup.shortcuts.playback": "Playback", + "popup.shortcuts.play-pause": "Play/Pause", + "popup.shortcuts.prev-frame": "Previous Frame", + "popup.shortcuts.next-frame": "Next Frame", + "popup.shortcuts.prev-key": "Previous Key", + "popup.shortcuts.next-key": "Next Key", + "popup.shortcuts.add-key": "Add Key", + "popup.shortcuts.remove-key": "Remove Key", + "popup.export.header": "Export", + "popup.export.type": "Export Type", + "popup.export.html": "HTML", + "popup.export.package": "ZIP Package", + "popup.export.sh-bands": "SH Bands", + "popup.export.start-position": "Start Position", + "popup.export.default": "Default", + "popup.export.viewport": "Current Viewport", + "popup.export.pose-camera": "1st Camera Pose", + "popup.export.fov": "Field of View", + "popup.export.background-color": "Background", + "popup.export.filename": "Filename", + "popup.export.animation": "Animation", + "popup.export.animation.none": "None", + "popup.export.animation.track": "Track", + "popup.export.loop-mode": "Loop Mode", + "popup.export.loop-mode.none": "None", + "popup.export.loop-mode.repeat": "Repeat", + "popup.export.loop-mode.pingpong": "Ping Pong", + "popup.export.compress-ply": "Compress PLY", + "popup.export.splats-select": "Splats", + "popup.export.splats-select.all": "All Splats", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Version", + "popup.export.spz-version.4": "SPZ 4 (latest)", + "popup.export.spz-version.3": "SPZ 3 (legacy gzip)", + "popup.publish.header": "Publish Settings", + "popup.publish.ok": "Publish", + "popup.publish.cancel": "Cancel", + "popup.publish.title": "Title", + "popup.publish.description": "Description", + "popup.publish.listed": "Listed", + "popup.publish.failed": "Publish Failed", + "popup.publish.please-try-again": "Please try again later.", + "popup.publish.succeeded": "Publish Succeeded", + "popup.publish.message": "Use the link below to access your scene.", + "popup.publish.please-log-in": "Publishing to PlayCanvas requires a user account. Please log in and try again.", + "popup.publish.converting": "Converting", + "popup.publish.uploading": "Uploading", + "popup.publish.to": "Publish To", + "popup.publish.new-scene": "New Scene", + "popup.publish.override-model": "Override Model", + "popup.publish.override-animation": "Override Animation", + "popup.publish.generate-lods": "Generate LODs", + "camera-info.position": "Camera Position", + "camera-info.target": "Camera Target", + "doc.reset": "Reset Scene", + "doc.unsaved-message": "You have unsaved changes. Are you sure you want to reset the scene?", + "doc.reset-message": "Are you sure you want to reset the scene?", + "doc.save-failed": "Failed to Save", + "doc.load-failed": "Failed to Load", + "tooltip.right-toolbar.splat-mode": "Splat Mode", + "tooltip.right-toolbar.show-hide": "Show/Hide Splats", + "tooltip.right-toolbar.orbit-camera": "Orbit Camera", + "tooltip.right-toolbar.fly-camera": "Fly Camera", + "tooltip.right-toolbar.frame-selection": "Frame Selection", + "tooltip.right-toolbar.reset-camera": "Reset Camera", + "tooltip.right-toolbar.colors": "Colors", + "tooltip.right-toolbar.settings": "Settings", + "tooltip.bottom-toolbar.undo": "Undo", + "tooltip.bottom-toolbar.redo": "Redo", + "tooltip.bottom-toolbar.rect": "Rect Select", + "tooltip.bottom-toolbar.lasso": "Lasso Select", + "tooltip.bottom-toolbar.polygon": "Polygon Select", + "tooltip.bottom-toolbar.brush": "Brush Select", + "tooltip.bottom-toolbar.flood": "Flood Select", + "tooltip.bottom-toolbar.eyedropper": "Eyedropper Select", + "tooltip.bottom-toolbar.sphere": "Sphere Select", + "tooltip.bottom-toolbar.box": "Box Select", + "tooltip.bottom-toolbar.translate": "Translate", + "tooltip.bottom-toolbar.rotate": "Rotate", + "tooltip.bottom-toolbar.scale": "Scale", + "tooltip.bottom-toolbar.measure": "Measurement", + "measure.length": "Length", + "tooltip.bottom-toolbar.local-space": "Use Local Orientation", + "tooltip.bottom-toolbar.bound-center": "Use Bound Center", + "tooltip.timeline.prev-frame": "Previous Frame", + "tooltip.timeline.next-frame": "Next Frame", + "tooltip.timeline.play": "Play/Stop", + "tooltip.timeline.add-key": "Add Key", + "tooltip.timeline.remove-key": "Remove Key", + "tooltip.timeline.key": "Drag to move. Shift+drag to copy. Ctrl+click to set to current view.", + "tooltip.timeline.frame-rate": "Frame Rate", + "tooltip.timeline.total-frames": "Total Frames", + "tooltip.timeline.smoothness": "Smoothness", + "tooltip.timeline.loop": "Loop Animation", + "tooltip.scene.solo": "Solo Selected", + "tooltip.scene.import": "Import Scene", + "tooltip.scene.new": "New Scene", + "status-bar.splats": "Splats", + "status-bar.selected": "Selected", + "status-bar.locked": "Locked", + "status-bar.deleted": "Deleted", + "status-bar.timeline": "Timeline", + "status-bar.splat-data": "Splat Data", + "tooltip.status-bar.timeline": "Toggle Timeline Panel", + "tooltip.status-bar.splat-data": "Toggle Splat Data Panel", + "tooltip.splat-data.on-screen-only": "Only count splats visible in the camera's view. Off-screen splats are excluded from the histogram.", + "tooltip.splat-data.log-scale": "Use a logarithmic vertical scale so small bucket counts remain visible alongside large ones.", + "tooltip.splat-data.show-all": "Show all per-splat properties in the list, including raw DC and spherical harmonic coefficients." +} diff --git a/static/locales/es.json b/static/locales/es.json new file mode 100644 index 0000000..908c880 --- /dev/null +++ b/static/locales/es.json @@ -0,0 +1,324 @@ +{ + "menu.file": "Archivo", + "menu.file.new": "Nuevo", + "menu.file.open": "Abrir", + "menu.file.open-recent": "Abrir reciente", + "menu.file.open-recent.clear": "Borrar recientes", + "menu.file.import": "Importar", + "menu.file.save": "Guardar", + "menu.file.save-as": "Guardar como", + "menu.file.publish": "Publicar", + "menu.file.export": "Exportar", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Aplicación de visualización", + "menu.edit": "Edición", + "menu.edit.undo": "Deshacer", + "menu.edit.redo": "Rehacer", + "menu.edit.duplicate": "Duplicar", + "menu.edit.separate": "Separar", + "menu.select": "Seleccionar", + "menu.select.all": "Todo", + "menu.select.none": "Ninguno", + "menu.select.invert": "Invertir", + "menu.select.lock": "Bloquear selección", + "menu.select.unlock": "Desbloquear todo", + "menu.select.delete": "Eliminar selección", + "menu.select.reset": "Restablecer Splat", + "menu.render": "Renderizar", + "menu.render.image": "Imagen", + "menu.render.video": "Video", + "menu.help": "Ayuda", + "menu.help.shortcuts": "Atajos de teclado", + "menu.help.user-guide": "Guía del usuario", + "menu.help.log-issue": "Reportar un problema", + "menu.help.github-repo": "Repositorio de GitHub", + "menu.help.video-tutorials": "Tutoriales en Video", + "menu.help.video-tutorials.basics": "Aprender lo Básico", + "menu.help.video-tutorials.in-depth": "Tutorial Detallado", + "menu.help.video-tutorials.deleting-floaters": "Eliminar Flotantes", + "menu.help.video-tutorials.scaling": "Escalar tus Splats", + "menu.help.discord": "Servidor de Discord", + "menu.help.forum": "Foro", + "menu.help.about": "Acerca de SuperSplat", + "panel.mode.centers": "Modo centros", + "panel.mode.rings": "Modo anillos", + "panel.scene-manager": "Gestor de escena", + "panel.scene-manager.transform": "Transformación", + "panel.scene-manager.transform.position": "Posición", + "panel.scene-manager.transform.rotation": "Rotación", + "panel.scene-manager.transform.scale": "Escala", + "panel.colors": "Colores", + "panel.colors.tint": "Tinte", + "panel.colors.temperature": "Temperatura", + "panel.colors.saturation": "Saturación", + "panel.colors.brightness": "Brillo", + "panel.colors.black-point": "Punto negro", + "panel.colors.white-point": "Punto blanco", + "panel.colors.transparency": "Transparencia", + "panel.colors.reset": "Restablecer", + "panel.settings": "Ajustes", + "panel.settings.language": "Idioma", + "panel.settings.language.auto": "Automático", + "panel.settings.colors": "Colores", + "panel.settings.background-color": "Color de fondo", + "panel.settings.selected-color": "Color seleccionado", + "panel.settings.unselected-color": "Color no seleccionado", + "panel.settings.locked-color": "Color bloqueado", + "panel.settings.fov": "Campo de visión", + "panel.settings.fov-dolly": "Auto dolly de FOV", + "panel.settings.control-mode": "Modo de control", + "panel.settings.control-mode.orbit": "Órbita", + "panel.settings.control-mode.fly": "Volar", + "panel.settings.sh-bands": "Bandas SH", + "panel.settings.centers-size": "Tamaño de centros", + "panel.settings.centers-gaussian-color": "Centros de Color", + "panel.settings.outline-selection": "Contorno de selección", + "panel.settings.show-grid": "Mostrar cuadrícula", + "panel.settings.show-bound": "Mostrar límites", + "panel.settings.show-bound-dimensions": "Mostrar dimensiones", + "panel.settings.show-camera-poses": "Mostrar cámaras", + "panel.settings.show-camera-info": "Mostrar info de cámara", + "panel.settings.fly-speed": "Velocidad de vuelo", + "panel.settings.tonemapping": "Mapeo de tonos", + "panel.settings.tonemapping.none": "Ninguno", + "panel.settings.tonemapping.linear": "Lineal", + "panel.settings.tonemapping.neutral": "Neutral", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Fílmico", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Restablecer valores predeterminados", + "panel.splat-data": "Datos de Splat", + "panel.splat-data.distance": "Distancia", + "panel.splat-data.camera-depth": "Profundidad de cámara", + "panel.splat-data.volume": "Volumen", + "panel.splat-data.surface-area": "Área de superficie", + "panel.splat-data.scale-x": "Escala X", + "panel.splat-data.scale-y": "Escala Y", + "panel.splat-data.scale-z": "Escala Z", + "panel.splat-data.red": "Rojo", + "panel.splat-data.green": "Verde", + "panel.splat-data.blue": "Azul", + "panel.splat-data.dc-red": "DC Rojo", + "panel.splat-data.dc-green": "DC Verde", + "panel.splat-data.dc-blue": "DC Azul", + "panel.splat-data.opacity": "Opacidad", + "panel.splat-data.hue": "Matiz", + "panel.splat-data.saturation": "Saturación", + "panel.splat-data.value": "Valor", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Escala logarítmica", + "panel.splat-data.show-all": "Todas las propiedades", + "panel.splat-data.on-screen-only": "Solo splats visibles", + "panel.splat-data.totals": "Totales", + "panel.splat-data.totals.splats": "Splats", + "panel.splat-data.totals.selected": "Seleccionados", + "panel.splat-data.totals.locked": "Bloqueados", + "panel.splat-data.totals.deleted": "Eliminados", + "panel.render.ok": "Renderizar", + "panel.render.cancel": "Cancelar", + "panel.render.render-video": "Renderizar video", + "panel.render.rendering": "Renderizando fotogramas", + "panel.render.failed": "Error al renderizar", + "select-toolbar.set": "Establecer", + "select-toolbar.add": "Añadir", + "select-toolbar.remove": "Quitar", + "select-toolbar.intersect": "Intersecar", + "select-toolbar.position": "Posición", + "select-toolbar.size": "Tamaño", + "select-toolbar.radius": "Radio", + "popup.ok": "Aceptar", + "popup.cancel": "Cancelar", + "popup.yes": "Sí", + "popup.no": "No", + "popup.error": "Error", + "popup.error-loading": "Error al cargar archivo", + "popup.webgpu-unavailable": "Esta exportación requiere WebGPU, que no está disponible en este navegador. Pruebe con una versión reciente de Chrome, Edge o Safari.", + "popup.lcc-upload-warning": "Para una mejor publicación en superspl.at, suba su archivo LCC directamente a través de la página de carga.", + "popup.export": "Exportar", + "popup.copy-to-clipboard": "Copiar enlace al portapapeles", + "popup.render-image.header": "Configuración de imagen", + "popup.render-image.preset": "Preajuste", + "popup.render-image.resolution": "Resolución", + "popup.render-image.transparent-bg": "Fondo transparente", + "popup.render-image.show-debug": "Mostrar superposiciones de depuración", + "popup.render-image.resolution-current": "Actual", + "popup.render-image.resolution-custom": "Personalizada", + "popup.render-image.projection": "Proyección", + "popup.render-image.projection-standard": "Estándar", + "popup.render-image.projection-360": "Equirrectangular 360°", + "popup.render-image.level-horizon": "Nivelar horizonte", + "popup.render-video.header": "Configuración de video", + "popup.render-video.projection": "Proyección", + "popup.render-video.projection-standard": "Estándar", + "popup.render-video.projection-360": "Equirrectangular 360°", + "popup.render-video.resolution": "Resolución", + "popup.render-video.format": "Formato", + "popup.render-video.codec": "Códec", + "popup.render-video.frame-rate": "Velocidad de fotogramas", + "popup.render-video.frame-range": "Rango de fotogramas", + "popup.render-video.frame-range-first": "Primero", + "popup.render-video.frame-range-last": "Último", + "popup.render-video.bitrate": "Tasa de bits", + "popup.render-video.portrait": "Modo retrato", + "popup.render-video.level-horizon": "Nivelar horizonte", + "popup.render-video.transparent-bg": "Fondo transparente", + "popup.render-video.show-debug": "Mostrar superposiciones de depuración", + "popup.shortcuts.title": "Atajos de teclado", + "popup.shortcuts.navigation": "Navegación", + "popup.shortcuts.tools": "Herramientas", + "popup.shortcuts.move": "Mover", + "popup.shortcuts.rotate": "Rotar", + "popup.shortcuts.scale": "Escalar", + "popup.shortcuts.rect-selection": "Selección rectangular", + "popup.shortcuts.lasso-selection": "Selección con lazo", + "popup.shortcuts.polygon-selection": "Selección poligonal", + "popup.shortcuts.brush-selection": "Selección con pincel", + "popup.shortcuts.flood-selection": "Selección por relleno", + "popup.shortcuts.eyedropper-selection": "Selección con cuentagotas", + "popup.shortcuts.brush-size": "Disminuir/Aumentar tamaño del pincel", + "popup.shortcuts.deactivate-tool": "Desactivar herramienta", + "popup.shortcuts.selection": "Selección", + "popup.shortcuts.select-all": "Seleccionar todo", + "popup.shortcuts.deselect-all": "Deseleccionar todo", + "popup.shortcuts.invert-selection": "Invertir selección", + "popup.shortcuts.add-to-selection": "Añadir a selección", + "popup.shortcuts.remove-from-selection": "Eliminar de selección", + "popup.shortcuts.delete-selected-splats": "Eliminar Splats seleccionados", + "popup.shortcuts.show": "Mostrar", + "popup.shortcuts.lock-selected-splats": "Bloquear Splats seleccionados", + "popup.shortcuts.unlock-all-splats": "Desbloquear todos los Splats", + "popup.shortcuts.toggle-data-panel": "Alternar panel de datos", + "popup.shortcuts.toggle-timeline-panel": "Alternar panel de línea de tiempo", + "popup.shortcuts.other": "Otros", + "popup.shortcuts.undo": "Deshacer", + "popup.shortcuts.redo": "Rehacer", + "popup.shortcuts.toggle-splat-overlay": "Alternar superposición de Splat", + "popup.shortcuts.focus-camera": "Enfocar cámara en la selección actual", + "popup.shortcuts.reset-camera": "Restablecer cámara", + "popup.shortcuts.toggle-camera-mode": "Alternar modo de cámara", + "popup.shortcuts.toggle-overlay-mode": "Alternar modo de superposición", + "popup.shortcuts.toggle-control-mode": "Alternar modo Volar/Órbita", + "popup.shortcuts.toggle-grid": "Alternar cuadrícula", + "popup.shortcuts.toggle-camera-info": "Alternar info de cámara", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Alternar espacio de coordenadas del gizmo", + "popup.shortcuts.camera": "Cámara (Modo Vuelo)", + "popup.shortcuts.fly-movement": "Mover Adelante/Izquierda/Atrás/Derecha", + "popup.shortcuts.fly-vertical": "Mover Abajo/Arriba", + "popup.shortcuts.fly-speed-fast": "Movimiento Rápido", + "popup.shortcuts.fly-speed-slow": "Movimiento Lento", + "popup.shortcuts.playback": "Reproducción", + "popup.shortcuts.play-pause": "Reproducir/Pausar", + "popup.shortcuts.prev-frame": "Fotograma Anterior", + "popup.shortcuts.next-frame": "Fotograma Siguiente", + "popup.shortcuts.prev-key": "Clave Anterior", + "popup.shortcuts.next-key": "Clave Siguiente", + "popup.shortcuts.add-key": "Añadir Clave", + "popup.shortcuts.remove-key": "Eliminar Clave", + "popup.export.header": "Exportar", + "popup.export.type": "Tipo de exportación", + "popup.export.html": "HTML", + "popup.export.package": "Paquete ZIP", + "popup.export.sh-bands": "Bandas SH", + "popup.export.start-position": "Posición inicial", + "popup.export.default": "Predeterminado", + "popup.export.viewport": "Vista actual", + "popup.export.pose-camera": "Pose de 1ª cámara", + "popup.export.fov": "Campo de visión", + "popup.export.background-color": "Fondo", + "popup.export.filename": "Nombre de archivo", + "popup.export.animation": "Animación", + "popup.export.animation.none": "Ninguna", + "popup.export.animation.track": "Pista", + "popup.export.loop-mode": "Modo de bucle", + "popup.export.loop-mode.none": "Ninguno", + "popup.export.loop-mode.repeat": "Repetir", + "popup.export.loop-mode.pingpong": "Ping Pong", + "popup.export.compress-ply": "Comprimir PLY", + "popup.export.splats-select": "Splats", + "popup.export.splats-select.all": "Todos los Splats", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Versión", + "popup.export.spz-version.4": "SPZ 4 (más reciente)", + "popup.export.spz-version.3": "SPZ 3 (gzip heredado)", + "popup.publish.header": "Configuración de publicación", + "popup.publish.ok": "Publicar", + "popup.publish.cancel": "Cancelar", + "popup.publish.title": "Título", + "popup.publish.description": "Descripción", + "popup.publish.listed": "Listado", + "popup.publish.failed": "Publicación fallida", + "popup.publish.please-try-again": "Por favor, inténtelo de nuevo más tarde.", + "popup.publish.succeeded": "Publicación exitosa", + "popup.publish.message": "Use el enlace de abajo para acceder a su escena.", + "popup.publish.please-log-in": "Publicar en PlayCanvas requiere una cuenta de usuario. Por favor, inicie sesión e inténtelo de nuevo.", + "popup.publish.converting": "Convirtiendo", + "popup.publish.uploading": "Subiendo", + "popup.publish.to": "Publicar en", + "popup.publish.new-scene": "Nueva escena", + "popup.publish.override-model": "Reemplazar modelo", + "popup.publish.override-animation": "Reemplazar animación", + "popup.publish.generate-lods": "Generar LOD", + "camera-info.position": "Posición de la cámara", + "camera-info.target": "Objetivo de la cámara", + "doc.reset": "Restablecer escena", + "doc.unsaved-message": "Tiene cambios sin guardar. ¿Está seguro de que desea restablecer la escena?", + "doc.reset-message": "¿Está seguro de que desea restablecer la escena?", + "doc.save-failed": "Error al guardar", + "doc.load-failed": "Error al cargar", + "tooltip.right-toolbar.splat-mode": "Modo Splat", + "tooltip.right-toolbar.show-hide": "Mostrar/Ocultar Splats", + "tooltip.right-toolbar.orbit-camera": "Cámara orbital", + "tooltip.right-toolbar.fly-camera": "Cámara de vuelo", + "tooltip.right-toolbar.frame-selection": "Enmarcar selección", + "tooltip.right-toolbar.reset-camera": "Restablecer cámara", + "tooltip.right-toolbar.colors": "Colores", + "tooltip.right-toolbar.settings": "Ajustes", + "tooltip.bottom-toolbar.undo": "Deshacer", + "tooltip.bottom-toolbar.redo": "Rehacer", + "tooltip.bottom-toolbar.rect": "Selección rectangular", + "tooltip.bottom-toolbar.lasso": "Selección con lazo", + "tooltip.bottom-toolbar.polygon": "Selección poligonal", + "tooltip.bottom-toolbar.brush": "Selección con pincel", + "tooltip.bottom-toolbar.flood": "Selección por relleno", + "tooltip.bottom-toolbar.eyedropper": "Selección con cuentagotas", + "tooltip.bottom-toolbar.sphere": "Selección esférica", + "tooltip.bottom-toolbar.box": "Selección de caja", + "tooltip.bottom-toolbar.translate": "Trasladar", + "tooltip.bottom-toolbar.rotate": "Rotar", + "tooltip.bottom-toolbar.scale": "Escalar", + "tooltip.bottom-toolbar.measure": "Medición", + "measure.length": "Longitud", + "tooltip.bottom-toolbar.local-space": "Usar orientación local", + "tooltip.bottom-toolbar.bound-center": "Usar centro de límites", + "tooltip.timeline.prev-frame": "Fotograma Anterior", + "tooltip.timeline.next-frame": "Siguiente Fotograma", + "tooltip.timeline.play": "Reproducir/Detener", + "tooltip.timeline.add-key": "Añadir clave", + "tooltip.timeline.remove-key": "Eliminar clave", + "tooltip.timeline.key": "Arrastra para mover. Mayús+arrastrar para copiar. Ctrl+clic para fijar la vista actual.", + "tooltip.timeline.frame-rate": "Velocidad de fotogramas", + "tooltip.timeline.total-frames": "Fotogramas totales", + "tooltip.timeline.smoothness": "Suavidad", + "tooltip.timeline.loop": "Animación en bucle", + "tooltip.scene.solo": "Solo seleccionado", + "tooltip.scene.import": "Importar escena", + "tooltip.scene.new": "Nueva escena", + "status-bar.splats": "Splats", + "status-bar.selected": "Seleccionados", + "status-bar.locked": "Bloqueados", + "status-bar.deleted": "Eliminados", + "status-bar.timeline": "Línea de tiempo", + "status-bar.splat-data": "Datos de Splat", + "tooltip.status-bar.timeline": "Alternar panel de Línea de tiempo", + "tooltip.status-bar.splat-data": "Alternar panel de Datos de Splat", + "tooltip.splat-data.on-screen-only": "Contar solo los splats visibles desde la cámara. Los splats fuera de pantalla se excluyen del histograma.", + "tooltip.splat-data.log-scale": "Usar una escala vertical logarítmica para que los valores pequeños sean visibles junto a los grandes.", + "tooltip.splat-data.show-all": "Mostrar todas las propiedades de los splats en la lista, incluyendo coeficientes DC y armónicos esféricos." +} diff --git a/static/locales/fr.json b/static/locales/fr.json new file mode 100644 index 0000000..3f63c08 --- /dev/null +++ b/static/locales/fr.json @@ -0,0 +1,324 @@ +{ + "menu.file": "Fichier", + "menu.file.new": "Créer", + "menu.file.open": "Ouvrir", + "menu.file.open-recent": "Récemment ouverts", + "menu.file.open-recent.clear": "Effacer l'historique", + "menu.file.import": "Importer", + "menu.file.save": "Enregistrer", + "menu.file.save-as": "Enregistrer sous", + "menu.file.publish": "Publier", + "menu.file.export": "Exporter", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Application de visualisation", + "menu.edit": "Édition", + "menu.edit.undo": "Annuler", + "menu.edit.redo": "Rétablir", + "menu.edit.duplicate": "Dupliquer", + "menu.edit.separate": "Séparer", + "menu.select": "Sélection", + "menu.select.all": "Tout", + "menu.select.none": "Aucune", + "menu.select.invert": "Inverser", + "menu.select.lock": "Verrouiller la sélection", + "menu.select.unlock": "Tout débloquer", + "menu.select.delete": "Supprimer la sélection", + "menu.select.reset": "Réinitialiser splat", + "menu.render": "Rendu", + "menu.render.image": "Image", + "menu.render.video": "Vidéo", + "menu.help": "Aide", + "menu.help.shortcuts": "Raccourcis claviers", + "menu.help.user-guide": "Guide utilisateur", + "menu.help.log-issue": "Signaler un problème", + "menu.help.github-repo": "Dépôt GitHub", + "menu.help.video-tutorials": "Tutoriels Vidéo", + "menu.help.video-tutorials.basics": "Apprendre les Bases", + "menu.help.video-tutorials.in-depth": "Tutoriel Approfondi", + "menu.help.video-tutorials.deleting-floaters": "Supprimer les Floaters", + "menu.help.video-tutorials.scaling": "Redimensionner vos Splats", + "menu.help.discord": "Serveur Discord", + "menu.help.forum": "Forum", + "menu.help.about": "À propos de SuperSplat", + "panel.mode.centers": "Mode centres", + "panel.mode.rings": "Mode anneaux", + "panel.scene-manager": "Gestionnaire de Scène", + "panel.scene-manager.transform": "Transformation", + "panel.scene-manager.transform.position": "Position", + "panel.scene-manager.transform.rotation": "Rotation", + "panel.scene-manager.transform.scale": "Échelle", + "panel.colors": "Couleurs", + "panel.colors.tint": "Teinte", + "panel.colors.temperature": "Température", + "panel.colors.saturation": "Saturation", + "panel.colors.brightness": "Luminosité", + "panel.colors.black-point": "Point noir", + "panel.colors.white-point": "Point blanc", + "panel.colors.transparency": "Transparence", + "panel.colors.reset": "Réinitialiser", + "panel.settings": "Paramètres", + "panel.settings.language": "Langue", + "panel.settings.language.auto": "Automatique", + "panel.settings.colors": "Couleurs", + "panel.settings.background-color": "Couleur de fond", + "panel.settings.selected-color": "Couleur sélectionnée", + "panel.settings.unselected-color": "Couleur non sélectionnée", + "panel.settings.locked-color": "Couleur verrouillée", + "panel.settings.fov": "Champ de vision", + "panel.settings.fov-dolly": "Dolly auto du FOV", + "panel.settings.control-mode": "Mode de contrôle", + "panel.settings.control-mode.orbit": "Orbite", + "panel.settings.control-mode.fly": "Vol", + "panel.settings.sh-bands": "Ordres d'HS", + "panel.settings.centers-size": "Échelle des centres", + "panel.settings.centers-gaussian-color": "Centres de Couleur", + "panel.settings.outline-selection": "Contour de la sélection", + "panel.settings.show-grid": "Afficher la grille", + "panel.settings.show-bound": "Afficher limites", + "panel.settings.show-bound-dimensions": "Afficher dimensions", + "panel.settings.show-camera-poses": "Afficher caméras", + "panel.settings.show-camera-info": "Afficher infos caméra", + "panel.settings.fly-speed": "Vitesse de vol", + "panel.settings.tonemapping": "Mappage tonal", + "panel.settings.tonemapping.none": "Aucun", + "panel.settings.tonemapping.linear": "Linéaire", + "panel.settings.tonemapping.neutral": "Neutre", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Filmique", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Réinitialiser les valeurs par défaut", + "panel.splat-data": "Données Splat", + "panel.splat-data.distance": "Distance", + "panel.splat-data.camera-depth": "Profondeur caméra", + "panel.splat-data.volume": "Volume", + "panel.splat-data.surface-area": "Zone de surface", + "panel.splat-data.scale-x": "Échelle X", + "panel.splat-data.scale-y": "Échelle Y", + "panel.splat-data.scale-z": "Échelle Z", + "panel.splat-data.red": "Rouge", + "panel.splat-data.green": "Vert", + "panel.splat-data.blue": "Bleu", + "panel.splat-data.dc-red": "DC Rouge", + "panel.splat-data.dc-green": "DC Vert", + "panel.splat-data.dc-blue": "DC Bleu", + "panel.splat-data.opacity": "Opacité", + "panel.splat-data.hue": "Teinte", + "panel.splat-data.saturation": "Saturation", + "panel.splat-data.value": "Luminosité", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Échelle logarithmique", + "panel.splat-data.show-all": "Toutes les propriétés", + "panel.splat-data.on-screen-only": "Splats visibles uniquement", + "panel.splat-data.totals": "Totaux", + "panel.splat-data.totals.splats": "Splats", + "panel.splat-data.totals.selected": "Selectionné", + "panel.splat-data.totals.locked": "Verrouillé", + "panel.splat-data.totals.deleted": "Supprimé", + "panel.render.ok": "Rendu", + "panel.render.cancel": "Annuler", + "panel.render.render-video": "Rendu vidéo", + "panel.render.rendering": "Rendu des images", + "panel.render.failed": "Échec du rendu", + "select-toolbar.set": "Définir", + "select-toolbar.add": "Ajouter", + "select-toolbar.remove": "Retirer", + "select-toolbar.intersect": "Intersecter", + "select-toolbar.position": "Position", + "select-toolbar.size": "Taille", + "select-toolbar.radius": "Rayon", + "popup.ok": "OK", + "popup.cancel": "Annuler", + "popup.yes": "Oui", + "popup.no": "Non", + "popup.error": "Erreur", + "popup.error-loading": "Erreur de chargement du fichier", + "popup.webgpu-unavailable": "Cette exportation nécessite WebGPU, qui n'est pas disponible dans ce navigateur. Veuillez essayer une version récente de Chrome, Edge ou Safari.", + "popup.lcc-upload-warning": "Pour une meilleure publication sur superspl.at, téléchargez votre fichier LCC directement via la page de téléchargement.", + "popup.export": "Exporter", + "popup.copy-to-clipboard": "Copier le lien dans le presse-papiers", + "popup.render-image.header": "Paramètres Image", + "popup.render-image.preset": "Préréglage", + "popup.render-image.resolution": "Résolution", + "popup.render-image.transparent-bg": "Fond transparent", + "popup.render-image.show-debug": "Afficher les superpositions de débogage", + "popup.render-image.resolution-current": "Actuelle", + "popup.render-image.resolution-custom": "Personnalisée", + "popup.render-image.projection": "Projection", + "popup.render-image.projection-standard": "Standard", + "popup.render-image.projection-360": "Équirectangulaire 360°", + "popup.render-image.level-horizon": "Niveler l'horizon", + "popup.render-video.header": "Paramètres Vidéo", + "popup.render-video.projection": "Projection", + "popup.render-video.projection-standard": "Standard", + "popup.render-video.projection-360": "Équirectangulaire 360°", + "popup.render-video.resolution": "Résolution", + "popup.render-video.format": "Format", + "popup.render-video.codec": "Codec", + "popup.render-video.frame-rate": "Fréquence d'image", + "popup.render-video.frame-range": "Plage d'images", + "popup.render-video.frame-range-first": "Première", + "popup.render-video.frame-range-last": "Dernière", + "popup.render-video.bitrate": "Débit binaire", + "popup.render-video.portrait": "Mode portrait", + "popup.render-video.level-horizon": "Niveler l'horizon", + "popup.render-video.transparent-bg": "Fond transparent", + "popup.render-video.show-debug": "Afficher les superpositions de débogage", + "popup.shortcuts.title": "Raccourcis Claviers", + "popup.shortcuts.navigation": "Navigation", + "popup.shortcuts.tools": "Outils", + "popup.shortcuts.move": "Déplacer", + "popup.shortcuts.rotate": "Tourner", + "popup.shortcuts.scale": "Changer l'échelle", + "popup.shortcuts.rect-selection": "Sélection avec rectangle", + "popup.shortcuts.lasso-selection": "Sélection avec lasso", + "popup.shortcuts.polygon-selection": "Sélection avec polygone", + "popup.shortcuts.brush-selection": "Sélection avec pinceau", + "popup.shortcuts.flood-selection": "Sélection par remplissage", + "popup.shortcuts.eyedropper-selection": "Sélection avec pipette", + "popup.shortcuts.brush-size": "Augmenter/Diminuer la taille du pinceau", + "popup.shortcuts.deactivate-tool": "Désactiver l'outil", + "popup.shortcuts.selection": "Sélection", + "popup.shortcuts.select-all": "Tout sélectionner", + "popup.shortcuts.deselect-all": "Tout desélectionner", + "popup.shortcuts.invert-selection": "Inverser la sélection", + "popup.shortcuts.add-to-selection": "Ajouter à la sélection", + "popup.shortcuts.remove-from-selection": "Retirer de la sélection", + "popup.shortcuts.delete-selected-splats": "Supprimer splats sélectionnés", + "popup.shortcuts.show": "Afficher", + "popup.shortcuts.lock-selected-splats": "Verrouiller les splats sélectionnés", + "popup.shortcuts.unlock-all-splats": "Déverrouiller tous les splats", + "popup.shortcuts.toggle-data-panel": "Afficher/Cacher l'onglet données", + "popup.shortcuts.toggle-timeline-panel": "Afficher/Cacher la timeline", + "popup.shortcuts.other": "Autres", + "popup.shortcuts.undo": "Annuler", + "popup.shortcuts.redo": "Rétablir", + "popup.shortcuts.toggle-splat-overlay": "Basculer affichage splat", + "popup.shortcuts.focus-camera": "Focaliser la caméra sur la sélection actuelle", + "popup.shortcuts.reset-camera": "Réinitialiser la caméra", + "popup.shortcuts.toggle-camera-mode": "Basculer le mode de camera", + "popup.shortcuts.toggle-overlay-mode": "Basculer le mode superposition", + "popup.shortcuts.toggle-control-mode": "Basculer mode Vol/Orbite", + "popup.shortcuts.toggle-grid": "Afficher/Cacher la grille", + "popup.shortcuts.toggle-camera-info": "Afficher/Cacher infos caméra", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Basculer en espace de coordonnées Gizmo", + "popup.shortcuts.camera": "Caméra (Mode Vol)", + "popup.shortcuts.fly-movement": "Avancer/Gauche/Reculer/Droite", + "popup.shortcuts.fly-vertical": "Descendre/Monter", + "popup.shortcuts.fly-speed-fast": "Mouvement Rapide", + "popup.shortcuts.fly-speed-slow": "Mouvement Lent", + "popup.shortcuts.playback": "Lecture", + "popup.shortcuts.play-pause": "Lecture/Pause", + "popup.shortcuts.prev-frame": "Image Précédente", + "popup.shortcuts.next-frame": "Image Suivante", + "popup.shortcuts.prev-key": "Clé Précédente", + "popup.shortcuts.next-key": "Clé Suivante", + "popup.shortcuts.add-key": "Ajouter Clé", + "popup.shortcuts.remove-key": "Supprimer Clé", + "popup.export.header": "Exporter", + "popup.export.type": "Type d'export", + "popup.export.html": "HTML", + "popup.export.package": "Package ZIP", + "popup.export.sh-bands": "Bandes SH", + "popup.export.start-position": "Position de départ", + "popup.export.default": "Défaut", + "popup.export.viewport": "Vue actuelle", + "popup.export.pose-camera": "1ère pose de caméra", + "popup.export.fov": "Champ de vision", + "popup.export.background-color": "Arrière-plan", + "popup.export.filename": "Nom de fichier", + "popup.export.animation": "Animation", + "popup.export.animation.none": "Aucune", + "popup.export.animation.track": "Piste", + "popup.export.loop-mode": "Mode de boucle", + "popup.export.loop-mode.none": "Aucun", + "popup.export.loop-mode.repeat": "Répétition", + "popup.export.loop-mode.pingpong": "Ping Pong", + "popup.export.compress-ply": "Compresser PLY", + "popup.export.splats-select": "Splats", + "popup.export.splats-select.all": "Tous les Splats", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Version", + "popup.export.spz-version.4": "SPZ 4 (le plus récent)", + "popup.export.spz-version.3": "SPZ 3 (gzip hérité)", + "popup.publish.header": "Paramètres de Publication", + "popup.publish.ok": "Publier", + "popup.publish.cancel": "Annuler", + "popup.publish.title": "Titre", + "popup.publish.description": "Description", + "popup.publish.listed": "Répertorié", + "popup.publish.failed": "ÉCHEC DE PUBLICATION", + "popup.publish.please-try-again": "Veuillez réessayer plus tard.", + "popup.publish.succeeded": "Publication Réussie", + "popup.publish.message": "Utilisez le lien ci-dessous pour accéder à votre scène.", + "popup.publish.please-log-in": "La publication sur PlayCanvas nécessite un compte utilisateur. Veuillez vous connecter et réessayer.", + "popup.publish.converting": "Conversion", + "popup.publish.uploading": "Téléchargement", + "popup.publish.to": "Publier sur", + "popup.publish.new-scene": "Nouvelle scène", + "popup.publish.override-model": "Remplacer le modèle", + "popup.publish.override-animation": "Remplacer l'animation", + "popup.publish.generate-lods": "Générer les LOD", + "camera-info.position": "Position de la caméra", + "camera-info.target": "Cible de la caméra", + "doc.reset": "Réinitialiser la Scène", + "doc.unsaved-message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir réinitialiser la scène?", + "doc.reset-message": "Êtes-vous sûr de vouloir réinitialiser la scène?", + "doc.save-failed": "ÉCHEC DE L'ENREGISTREMENT", + "doc.load-failed": "ÉCHEC DU CHARGEMENT", + "tooltip.right-toolbar.splat-mode": "Mode splat", + "tooltip.right-toolbar.show-hide": "Afficher/cacher les splats", + "tooltip.right-toolbar.orbit-camera": "Caméra orbitale", + "tooltip.right-toolbar.fly-camera": "Caméra de vol", + "tooltip.right-toolbar.frame-selection": "Cadrer la sélection", + "tooltip.right-toolbar.reset-camera": "Réinitialiser la caméra", + "tooltip.right-toolbar.colors": "Couleurs", + "tooltip.right-toolbar.settings": "Paramètres", + "tooltip.bottom-toolbar.undo": "Annuler", + "tooltip.bottom-toolbar.redo": "Rétablir", + "tooltip.bottom-toolbar.rect": "Sélection avec rectangle", + "tooltip.bottom-toolbar.lasso": "Sélection avec lasso", + "tooltip.bottom-toolbar.polygon": "Sélection avec polygone", + "tooltip.bottom-toolbar.brush": "Sélection avec pinceau", + "tooltip.bottom-toolbar.flood": "Sélection par remplissage", + "tooltip.bottom-toolbar.eyedropper": "Sélection avec pipette", + "tooltip.bottom-toolbar.sphere": "Sélection avec sphère", + "tooltip.bottom-toolbar.box": "Sélection de boîte", + "tooltip.bottom-toolbar.translate": "Translation", + "tooltip.bottom-toolbar.rotate": "Rotation", + "tooltip.bottom-toolbar.scale": "Échelle", + "tooltip.bottom-toolbar.measure": "Mesure", + "measure.length": "Longueur", + "tooltip.bottom-toolbar.local-space": "Espace local gizmo", + "tooltip.bottom-toolbar.bound-center": "Utiliser le centre de la limite", + "tooltip.timeline.prev-frame": "Image Précédente", + "tooltip.timeline.next-frame": "Image Suivante", + "tooltip.timeline.play": "Jouer/Arrêter", + "tooltip.timeline.add-key": "Ajouter une clé", + "tooltip.timeline.remove-key": "Supprimer une clé", + "tooltip.timeline.key": "Glisser pour déplacer. Maj+glisser pour copier. Ctrl+clic pour reprendre la vue actuelle.", + "tooltip.timeline.frame-rate": "Fréquence d'image", + "tooltip.timeline.total-frames": "Nombre total de frames", + "tooltip.timeline.smoothness": "Douceur", + "tooltip.timeline.loop": "Animation en boucle", + "tooltip.scene.solo": "Solo sélectionné", + "tooltip.scene.import": "Importer une scène", + "tooltip.scene.new": "Nouvelle scène", + "status-bar.splats": "Splats", + "status-bar.selected": "Sélectionnés", + "status-bar.locked": "Verrouillés", + "status-bar.deleted": "Supprimés", + "status-bar.timeline": "Chronologie", + "status-bar.splat-data": "Données Splat", + "tooltip.status-bar.timeline": "Afficher/Masquer le panneau Chronologie", + "tooltip.status-bar.splat-data": "Afficher/Masquer le panneau Données Splat", + "tooltip.splat-data.on-screen-only": "Ne compter que les splats visibles dans la caméra. Les splats hors écran sont exclus de l'histogramme.", + "tooltip.splat-data.log-scale": "Utiliser une échelle verticale logarithmique pour rendre visibles les petites valeurs à côté des grandes.", + "tooltip.splat-data.show-all": "Afficher toutes les propriétés des splats dans la liste, y compris les coefficients DC bruts et harmoniques sphériques." +} diff --git a/static/locales/ja.json b/static/locales/ja.json new file mode 100644 index 0000000..f21d051 --- /dev/null +++ b/static/locales/ja.json @@ -0,0 +1,324 @@ +{ + "menu.file": "ファイル", + "menu.file.new": "新規作成", + "menu.file.open": "開く", + "menu.file.open-recent": "最近開いたファイル", + "menu.file.open-recent.clear": "履歴を消去", + "menu.file.import": "インポート", + "menu.file.save": "保存", + "menu.file.save-as": "名前を付けて保存", + "menu.file.publish": "公開", + "menu.file.export": "エクスポート", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Viewer App", + "menu.edit": "編集", + "menu.edit.undo": "元に戻す", + "menu.edit.redo": "やり直し", + "menu.edit.duplicate": "複製", + "menu.edit.separate": "分離", + "menu.select": "選択", + "menu.select.all": "全て", + "menu.select.none": "選択を解除", + "menu.select.invert": "反転", + "menu.select.lock": "選択をロック", + "menu.select.unlock": "ロックを解除", + "menu.select.delete": "選択を削除", + "menu.select.reset": "変更を全てリセット", + "menu.render": "レンダリング", + "menu.render.image": "画像", + "menu.render.video": "動画", + "menu.help": "ヘルプ", + "menu.help.shortcuts": "キーボードショートカット", + "menu.help.user-guide": "ユーザーガイド", + "menu.help.log-issue": "問題を報告", + "menu.help.github-repo": "GitHubリポジトリ", + "menu.help.video-tutorials": "動画チュートリアル", + "menu.help.video-tutorials.basics": "基本を学ぶ", + "menu.help.video-tutorials.in-depth": "詳細チュートリアル", + "menu.help.video-tutorials.deleting-floaters": "フローターの削除", + "menu.help.video-tutorials.scaling": "スプラットのスケーリング", + "menu.help.discord": "Discordサーバー", + "menu.help.forum": "フォーラム", + "menu.help.about": "SuperSplatについて", + "panel.mode.centers": "センターモード", + "panel.mode.rings": "リングモード", + "panel.scene-manager": "シーンマネージャー", + "panel.scene-manager.transform": "トランスフォーム", + "panel.scene-manager.transform.position": "位置", + "panel.scene-manager.transform.rotation": "回転", + "panel.scene-manager.transform.scale": "スケール", + "panel.colors": "色", + "panel.colors.tint": "色合い", + "panel.colors.temperature": "温度", + "panel.colors.saturation": "彩度", + "panel.colors.brightness": "明るさ", + "panel.colors.black-point": "黒点", + "panel.colors.white-point": "白点", + "panel.colors.transparency": "透明度", + "panel.colors.reset": "リセット", + "panel.settings": "設定", + "panel.settings.language": "言語", + "panel.settings.language.auto": "自動", + "panel.settings.colors": "色", + "panel.settings.background-color": "背景色", + "panel.settings.selected-color": "選択色", + "panel.settings.unselected-color": "非選択色", + "panel.settings.locked-color": "ロック色", + "panel.settings.fov": "視野 ( FOV )", + "panel.settings.fov-dolly": "FOV自動ドリー", + "panel.settings.control-mode": "操作モード", + "panel.settings.control-mode.orbit": "オービット", + "panel.settings.control-mode.fly": "フライ", + "panel.settings.sh-bands": "球面調和関数のバンド", + "panel.settings.centers-size": "センターサイズ", + "panel.settings.centers-gaussian-color": "カラーセンター", + "panel.settings.outline-selection": "選択のアウトライン", + "panel.settings.show-grid": "グリッド", + "panel.settings.show-bound": "バウンディングボックス", + "panel.settings.show-bound-dimensions": "寸法を表示", + "panel.settings.show-camera-poses": "カメラを表示", + "panel.settings.show-camera-info": "カメラ情報を表示", + "panel.settings.fly-speed": "カメラの移動速度", + "panel.settings.tonemapping": "トーンマッピング", + "panel.settings.tonemapping.none": "なし", + "panel.settings.tonemapping.linear": "リニア", + "panel.settings.tonemapping.neutral": "ニュートラル", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "フィルミック", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "デフォルトに戻す", + "panel.splat-data": "スプラットの統計", + "panel.splat-data.distance": "距離", + "panel.splat-data.camera-depth": "カメラ深度", + "panel.splat-data.volume": "体積", + "panel.splat-data.surface-area": "表面積", + "panel.splat-data.scale-x": "スケール X", + "panel.splat-data.scale-y": "スケール Y", + "panel.splat-data.scale-z": "スケール Z", + "panel.splat-data.red": "赤", + "panel.splat-data.green": "緑", + "panel.splat-data.blue": "青", + "panel.splat-data.dc-red": "DC 赤", + "panel.splat-data.dc-green": "DC 緑", + "panel.splat-data.dc-blue": "DC 青", + "panel.splat-data.opacity": "不透明度", + "panel.splat-data.hue": "色相", + "panel.splat-data.saturation": "彩度", + "panel.splat-data.value": "明度", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "対数スケール", + "panel.splat-data.show-all": "すべてのプロパティ", + "panel.splat-data.on-screen-only": "表示中のスプラットのみ", + "panel.splat-data.totals": "合計", + "panel.splat-data.totals.splats": "スプラット数", + "panel.splat-data.totals.selected": "選択中", + "panel.splat-data.totals.locked": "ロック中", + "panel.splat-data.totals.deleted": "削除", + "panel.render.ok": "レンダリング", + "panel.render.cancel": "キャンセル", + "panel.render.render-video": "ビデオレンダリング", + "panel.render.rendering": "フレームをレンダリング中", + "panel.render.failed": "レンダリングに失敗しました", + "select-toolbar.set": "セット", + "select-toolbar.add": "追加", + "select-toolbar.remove": "削除", + "select-toolbar.intersect": "交差", + "select-toolbar.position": "位置", + "select-toolbar.size": "サイズ", + "select-toolbar.radius": "半径", + "popup.ok": "OK", + "popup.cancel": "キャンセル", + "popup.yes": "はい", + "popup.no": "いいえ", + "popup.error": "エラー", + "popup.error-loading": "ファイルの読み込みエラー", + "popup.webgpu-unavailable": "このエクスポートにはWebGPUが必要ですが、このブラウザでは利用できません。Chrome、Edge、Safariの最新バージョンをお試しください。", + "popup.lcc-upload-warning": "superspl.atへの公開には、アップロードページから直接LCCファイルをアップロードすることをお勧めします。", + "popup.export": "エクスポート", + "popup.copy-to-clipboard": "リンクをクリップボードにコピー", + "popup.render-image.header": "画像設定", + "popup.render-image.preset": "プリセット", + "popup.render-image.resolution": "解像度", + "popup.render-image.transparent-bg": "透明な背景", + "popup.render-image.show-debug": "デバッグオーバーレイを表示", + "popup.render-image.resolution-current": "現在", + "popup.render-image.resolution-custom": "カスタム", + "popup.render-image.projection": "投影", + "popup.render-image.projection-standard": "標準", + "popup.render-image.projection-360": "360° 正距円筒", + "popup.render-image.level-horizon": "水平を維持", + "popup.render-video.header": "ビデオ設定", + "popup.render-video.projection": "投影", + "popup.render-video.projection-standard": "標準", + "popup.render-video.projection-360": "360° 正距円筒", + "popup.render-video.resolution": "解像度", + "popup.render-video.format": "フォーマット", + "popup.render-video.codec": "コーデック", + "popup.render-video.frame-rate": "フレームレート", + "popup.render-video.frame-range": "フレーム範囲", + "popup.render-video.frame-range-first": "最初", + "popup.render-video.frame-range-last": "最後", + "popup.render-video.bitrate": "ビットレート", + "popup.render-video.portrait": "ポートレートモード", + "popup.render-video.level-horizon": "水平を維持", + "popup.render-video.transparent-bg": "透明な背景", + "popup.render-video.show-debug": "デバッグオーバーレイを表示", + "popup.shortcuts.title": "キーボードショートカット", + "popup.shortcuts.navigation": "ナビゲーション", + "popup.shortcuts.tools": "ツール", + "popup.shortcuts.move": "移動", + "popup.shortcuts.rotate": "回転", + "popup.shortcuts.scale": "スケール", + "popup.shortcuts.rect-selection": "四角形選択", + "popup.shortcuts.lasso-selection": "なげなわ選択", + "popup.shortcuts.polygon-selection": "ポリゴン選択", + "popup.shortcuts.brush-selection": "ブラシ選択", + "popup.shortcuts.flood-selection": "フラッド選択", + "popup.shortcuts.eyedropper-selection": "スポイト選択", + "popup.shortcuts.brush-size": "ブラシサイズの増減", + "popup.shortcuts.deactivate-tool": "ツールの非アクティブ化", + "popup.shortcuts.selection": "選択", + "popup.shortcuts.select-all": "全て選択", + "popup.shortcuts.deselect-all": "全て選択解除", + "popup.shortcuts.invert-selection": "選択反転", + "popup.shortcuts.add-to-selection": "選択追加", + "popup.shortcuts.remove-from-selection": "選択解除", + "popup.shortcuts.delete-selected-splats": "選択削除", + "popup.shortcuts.show": "表示", + "popup.shortcuts.lock-selected-splats": "選択をロック", + "popup.shortcuts.unlock-all-splats": "すべてロック解除", + "popup.shortcuts.toggle-data-panel": "データパネルの切り替え", + "popup.shortcuts.toggle-timeline-panel": "タイムラインパネルの切り替え", + "popup.shortcuts.other": "その他", + "popup.shortcuts.undo": "元に戻す", + "popup.shortcuts.redo": "やり直し", + "popup.shortcuts.toggle-splat-overlay": "スプラットオーバーレイの切り替え", + "popup.shortcuts.focus-camera": "カメラの焦点を合わせる", + "popup.shortcuts.reset-camera": "カメラをリセット", + "popup.shortcuts.toggle-camera-mode": "カメラモードの切り替え", + "popup.shortcuts.toggle-overlay-mode": "オーバーレイモードの切り替え", + "popup.shortcuts.toggle-control-mode": "フライ/オービットモードの切り替え", + "popup.shortcuts.toggle-grid": "グリッドの切り替え", + "popup.shortcuts.toggle-camera-info": "カメラ情報の切り替え", + "popup.shortcuts.toggle-gizmo-coordinate-space": "ギズモ座標空間の切り替え", + "popup.shortcuts.camera": "カメラ(フライモード)", + "popup.shortcuts.fly-movement": "前/左/後/右に移動", + "popup.shortcuts.fly-vertical": "下/上に移動", + "popup.shortcuts.fly-speed-fast": "高速移動", + "popup.shortcuts.fly-speed-slow": "低速移動", + "popup.shortcuts.playback": "再生", + "popup.shortcuts.play-pause": "再生/一時停止", + "popup.shortcuts.prev-frame": "前のフレーム", + "popup.shortcuts.next-frame": "次のフレーム", + "popup.shortcuts.prev-key": "前のキー", + "popup.shortcuts.next-key": "次のキー", + "popup.shortcuts.add-key": "キーを追加", + "popup.shortcuts.remove-key": "キーを削除", + "popup.export.header": "エクスポート", + "popup.export.type": "エクスポートタイプ", + "popup.export.html": "HTML", + "popup.export.package": "ZIPパッケージ", + "popup.export.sh-bands": "SHバンド", + "popup.export.start-position": "開始位置", + "popup.export.default": "デフォルト", + "popup.export.viewport": "現在のビューポート", + "popup.export.pose-camera": "1番目のカメラポーズ", + "popup.export.fov": "視野角", + "popup.export.background-color": "背景色", + "popup.export.filename": "ファイル名", + "popup.export.animation": "アニメーション", + "popup.export.animation.none": "なし", + "popup.export.animation.track": "トラック", + "popup.export.loop-mode": "ループモード", + "popup.export.loop-mode.none": "なし", + "popup.export.loop-mode.repeat": "リピート", + "popup.export.loop-mode.pingpong": "ピンポン", + "popup.export.compress-ply": "PLYを圧縮", + "popup.export.splats-select": "Splat", + "popup.export.splats-select.all": "すべてのSplat", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "バージョン", + "popup.export.spz-version.4": "SPZ 4(最新)", + "popup.export.spz-version.3": "SPZ 3(レガシー gzip)", + "popup.publish.header": "公開設定", + "popup.publish.ok": "公開", + "popup.publish.cancel": "キャンセル", + "popup.publish.title": "タイトル", + "popup.publish.description": "説明", + "popup.publish.listed": "公開一覧に表示", + "popup.publish.failed": "公開に失敗", + "popup.publish.please-try-again": "後でもう一度お試しください。", + "popup.publish.succeeded": "公開に成功", + "popup.publish.message": "以下のリンクを使用してシーンにアクセスしてください。", + "popup.publish.please-log-in": "PlayCanvasに公開するにはユーザーアカウントが必要です。ログインしてもう一度お試しください。", + "popup.publish.converting": "変換中", + "popup.publish.uploading": "アップロード中", + "popup.publish.to": "公開先", + "popup.publish.new-scene": "新規シーンを作成", + "popup.publish.override-model": "モデルを上書き", + "popup.publish.override-animation": "アニメーションを上書き", + "popup.publish.generate-lods": "LODを生成", + "camera-info.position": "カメラ位置", + "camera-info.target": "カメラ注視点", + "doc.reset": "シーンをリセット", + "doc.unsaved-message": "保存されていない変更があります。シーンをリセットしてもよろしいですか?", + "doc.reset-message": "シーンをリセットしてもよろしいですか?", + "doc.save-failed": "保存に失敗", + "doc.load-failed": "読み込みに失敗", + "tooltip.right-toolbar.splat-mode": "スプラットモード", + "tooltip.right-toolbar.show-hide": "スプラットの表示/非表示", + "tooltip.right-toolbar.orbit-camera": "オービットカメラ", + "tooltip.right-toolbar.fly-camera": "フライカメラ", + "tooltip.right-toolbar.frame-selection": "選択をフレームイン", + "tooltip.right-toolbar.reset-camera": "カメラをリセット", + "tooltip.right-toolbar.colors": "色", + "tooltip.right-toolbar.settings": "設定", + "tooltip.bottom-toolbar.undo": "元に戻す", + "tooltip.bottom-toolbar.redo": "やり直し", + "tooltip.bottom-toolbar.rect": "四角形選択", + "tooltip.bottom-toolbar.lasso": "なげなわ選択", + "tooltip.bottom-toolbar.polygon": "ポリゴン選択", + "tooltip.bottom-toolbar.brush": "ブラシ選択", + "tooltip.bottom-toolbar.flood": "フラッド選択", + "tooltip.bottom-toolbar.eyedropper": "スポイト選択", + "tooltip.bottom-toolbar.sphere": "球で選択", + "tooltip.bottom-toolbar.box": "箱で選択", + "tooltip.bottom-toolbar.translate": "移動", + "tooltip.bottom-toolbar.rotate": "回転", + "tooltip.bottom-toolbar.scale": "スケール", + "tooltip.bottom-toolbar.measure": "測定", + "measure.length": "長さ", + "tooltip.bottom-toolbar.local-space": "ローカル座標へ切り替え", + "tooltip.bottom-toolbar.bound-center": "バウンディングボックスの中心を使用", + "tooltip.timeline.prev-frame": "前のフレーム", + "tooltip.timeline.next-frame": "次のフレーム", + "tooltip.timeline.play": "再生/停止", + "tooltip.timeline.add-key": "キーフレームを追加", + "tooltip.timeline.remove-key": "キーフレームを削除", + "tooltip.timeline.key": "ドラッグで移動。Shift+ドラッグでコピー。Ctrl+クリックで現在のビューを設定。", + "tooltip.timeline.frame-rate": "フレームレート", + "tooltip.timeline.total-frames": "総フレーム数", + "tooltip.timeline.smoothness": "スムーズさ", + "tooltip.timeline.loop": "アニメーションをループ", + "tooltip.scene.solo": "選択をソロ表示", + "tooltip.scene.import": "シーンをインポート", + "tooltip.scene.new": "新規シーンを作成", + "status-bar.splats": "スプラット", + "status-bar.selected": "選択済み", + "status-bar.locked": "ロック済み", + "status-bar.deleted": "削除済み", + "status-bar.timeline": "タイムライン", + "status-bar.splat-data": "スプラットデータ", + "tooltip.status-bar.timeline": "タイムラインパネルの切り替え", + "tooltip.status-bar.splat-data": "スプラットデータパネルの切り替え", + "tooltip.splat-data.on-screen-only": "カメラに映っているスプラットのみをヒストグラムに含めます。画面外のスプラットは除外されます。", + "tooltip.splat-data.log-scale": "縦軸を対数スケールで表示し、小さい値と大きい値の両方を見やすくします。", + "tooltip.splat-data.show-all": "未加工のDC係数や球面調和関数係数を含む、すべてのスプラットプロパティをリストに表示します。" +} diff --git a/static/locales/ko.json b/static/locales/ko.json new file mode 100644 index 0000000..54bc3a2 --- /dev/null +++ b/static/locales/ko.json @@ -0,0 +1,324 @@ +{ + "menu.file": "파일", + "menu.file.new": "새로 만들기", + "menu.file.open": "열기", + "menu.file.open-recent": "최근 파일 열기", + "menu.file.open-recent.clear": "기록 지우기", + "menu.file.import": "가져오기", + "menu.file.save": "저장", + "menu.file.save-as": "다른 이름으로 저장", + "menu.file.publish": "게시", + "menu.file.export": "내보내기", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "뷰어 앱", + "menu.edit": "편집", + "menu.edit.undo": "실행 취소", + "menu.edit.redo": "다시 실행", + "menu.edit.duplicate": "복제", + "menu.edit.separate": "분리", + "menu.select": "선택", + "menu.select.all": "모두", + "menu.select.none": "없음", + "menu.select.invert": "반전", + "menu.select.lock": "선택 잠금", + "menu.select.unlock": "모두 잠금 해제", + "menu.select.delete": "선택 삭제", + "menu.select.reset": "Splat 재설정", + "menu.render": "렌더링", + "menu.render.image": "이미지", + "menu.render.video": "비디오", + "menu.help": "도움말", + "menu.help.shortcuts": "키보드 단축키", + "menu.help.user-guide": "사용자 가이드", + "menu.help.log-issue": "문제 보고", + "menu.help.github-repo": "GitHub 저장소", + "menu.help.video-tutorials": "비디오 튜토리얼", + "menu.help.video-tutorials.basics": "기본 배우기", + "menu.help.video-tutorials.in-depth": "심화 튜토리얼", + "menu.help.video-tutorials.deleting-floaters": "플로터 삭제", + "menu.help.video-tutorials.scaling": "스플랫 크기 조정", + "menu.help.discord": "Discord 서버", + "menu.help.forum": "포럼", + "menu.help.about": "SuperSplat 정보", + "panel.mode.centers": "센터 모드", + "panel.mode.rings": "링 모드", + "panel.scene-manager": "장면 관리자", + "panel.scene-manager.transform": "변환", + "panel.scene-manager.transform.position": "위치", + "panel.scene-manager.transform.rotation": "회전", + "panel.scene-manager.transform.scale": "크기", + "panel.colors": "색상", + "panel.colors.tint": "색조", + "panel.colors.temperature": "온도", + "panel.colors.saturation": "채도", + "panel.colors.brightness": "밝기", + "panel.colors.black-point": "검은 점", + "panel.colors.white-point": "흰 점", + "panel.colors.transparency": "투명도", + "panel.colors.reset": "리셋", + "panel.settings": "설정", + "panel.settings.language": "언어", + "panel.settings.language.auto": "자동", + "panel.settings.colors": "색상", + "panel.settings.background-color": "배경 색상", + "panel.settings.selected-color": "선택된 색상", + "panel.settings.unselected-color": "선택되지 않은 색상", + "panel.settings.locked-color": "잠긴 색상", + "panel.settings.fov": "시야각", + "panel.settings.fov-dolly": "FOV 자동 달리", + "panel.settings.control-mode": "제어 모드", + "panel.settings.control-mode.orbit": "궤도", + "panel.settings.control-mode.fly": "비행", + "panel.settings.sh-bands": "SH 밴드", + "panel.settings.centers-size": "센터 크기", + "panel.settings.centers-gaussian-color": "컬러 센터", + "panel.settings.outline-selection": "선택 윤곽선", + "panel.settings.show-grid": "그리드 표시", + "panel.settings.show-bound": "경계 표시", + "panel.settings.show-bound-dimensions": "치수 표시", + "panel.settings.show-camera-poses": "카메라 표시", + "panel.settings.show-camera-info": "카메라 정보 표시", + "panel.settings.fly-speed": "카메라 이동 속도", + "panel.settings.tonemapping": "톤매핑", + "panel.settings.tonemapping.none": "없음", + "panel.settings.tonemapping.linear": "선형", + "panel.settings.tonemapping.neutral": "중립", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "필름 스타일", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "기본값으로 재설정", + "panel.splat-data": "Splat 데이터", + "panel.splat-data.distance": "거리", + "panel.splat-data.camera-depth": "카메라 깊이", + "panel.splat-data.volume": "부피", + "panel.splat-data.surface-area": "표면적", + "panel.splat-data.scale-x": "크기 X", + "panel.splat-data.scale-y": "크기 Y", + "panel.splat-data.scale-z": "크기 Z", + "panel.splat-data.red": "빨강", + "panel.splat-data.green": "녹색", + "panel.splat-data.blue": "파랑", + "panel.splat-data.dc-red": "DC 빨강", + "panel.splat-data.dc-green": "DC 녹색", + "panel.splat-data.dc-blue": "DC 파랑", + "panel.splat-data.opacity": "불투명도", + "panel.splat-data.hue": "색조", + "panel.splat-data.saturation": "채도", + "panel.splat-data.value": "명도", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "로그 크기", + "panel.splat-data.show-all": "모든 속성", + "panel.splat-data.on-screen-only": "보이는 스플랫만", + "panel.splat-data.totals": "합계", + "panel.splat-data.totals.splats": "Splat", + "panel.splat-data.totals.selected": "선택", + "panel.splat-data.totals.locked": "잠금", + "panel.splat-data.totals.deleted": "삭제된", + "panel.render.ok": "렌더링", + "panel.render.cancel": "취소", + "panel.render.render-video": "비디오 렌더링", + "panel.render.rendering": "프레임 렌더링 중", + "panel.render.failed": "렌더링 실패", + "select-toolbar.set": "설정", + "select-toolbar.add": "추가", + "select-toolbar.remove": "제거", + "select-toolbar.intersect": "교차", + "select-toolbar.position": "위치", + "select-toolbar.size": "크기", + "select-toolbar.radius": "반경", + "popup.ok": "확인", + "popup.cancel": "취소", + "popup.yes": "예", + "popup.no": "아니요", + "popup.error": "오류", + "popup.error-loading": "파일 로드 오류", + "popup.webgpu-unavailable": "이 내보내기에는 WebGPU가 필요하지만 이 브라우저에서는 사용할 수 없습니다. 최신 버전의 Chrome, Edge 또는 Safari를 사용해 보세요.", + "popup.lcc-upload-warning": "superspl.at에 더 나은 게시를 위해 업로드 페이지를 통해 LCC 파일을 직접 업로드하세요.", + "popup.export": "내보내기", + "popup.copy-to-clipboard": "클립 보드에 링크 복사", + "popup.render-image.header": "이미지 설정", + "popup.render-image.preset": "프리셋", + "popup.render-image.resolution": "해상도", + "popup.render-image.transparent-bg": "투명 배경", + "popup.render-image.show-debug": "디버그 오버레이 표시", + "popup.render-image.resolution-current": "현재", + "popup.render-image.resolution-custom": "사용자 정의", + "popup.render-image.projection": "투영", + "popup.render-image.projection-standard": "표준", + "popup.render-image.projection-360": "360° 등장방형", + "popup.render-image.level-horizon": "수평 유지", + "popup.render-video.header": "비디오 설정", + "popup.render-video.projection": "투영", + "popup.render-video.projection-standard": "표준", + "popup.render-video.projection-360": "360° 등장방형", + "popup.render-video.resolution": "해상도", + "popup.render-video.format": "형식", + "popup.render-video.codec": "코덱", + "popup.render-video.frame-rate": "프레임 속도", + "popup.render-video.frame-range": "프레임 범위", + "popup.render-video.frame-range-first": "첫 번째", + "popup.render-video.frame-range-last": "마지막", + "popup.render-video.bitrate": "비트 전송률", + "popup.render-video.portrait": "세로 모드", + "popup.render-video.level-horizon": "수평 유지", + "popup.render-video.transparent-bg": "투명 배경", + "popup.render-video.show-debug": "디버그 오버레이 표시", + "popup.shortcuts.title": "키보드 단축키", + "popup.shortcuts.navigation": "탐색", + "popup.shortcuts.tools": "도구", + "popup.shortcuts.move": "이동", + "popup.shortcuts.rotate": "회전", + "popup.shortcuts.scale": "크기 조정", + "popup.shortcuts.rect-selection": "사각형 선택", + "popup.shortcuts.lasso-selection": "올가미 선택", + "popup.shortcuts.polygon-selection": "다각형 선택", + "popup.shortcuts.brush-selection": "브러시 선택", + "popup.shortcuts.flood-selection": "플러드 선택", + "popup.shortcuts.eyedropper-selection": "스포이드 선택", + "popup.shortcuts.brush-size": "브러시 크기 조정", + "popup.shortcuts.deactivate-tool": "도구 비활성화", + "popup.shortcuts.selection": "선택", + "popup.shortcuts.select-all": "모두 선택", + "popup.shortcuts.deselect-all": "모두 선택 해제", + "popup.shortcuts.invert-selection": "선택 반전", + "popup.shortcuts.add-to-selection": "선택 추가", + "popup.shortcuts.remove-from-selection": "선택 제거", + "popup.shortcuts.delete-selected-splats": "선택된 Splat 삭제", + "popup.shortcuts.show": "표시", + "popup.shortcuts.lock-selected-splats": "선택된 Splat 잠금", + "popup.shortcuts.unlock-all-splats": "모든 Splat 잠금 해제", + "popup.shortcuts.toggle-data-panel": "데이터 패널 전환", + "popup.shortcuts.toggle-timeline-panel": "타임라인 패널 전환", + "popup.shortcuts.other": "기타", + "popup.shortcuts.undo": "실행 취소", + "popup.shortcuts.redo": "다시 실행", + "popup.shortcuts.toggle-splat-overlay": "Splat 오버레이 전환", + "popup.shortcuts.focus-camera": "현재 선택에 초점 맞추기", + "popup.shortcuts.reset-camera": "카메라 재설정", + "popup.shortcuts.toggle-camera-mode": "카메라 모드 전환", + "popup.shortcuts.toggle-overlay-mode": "오버레이 모드 전환", + "popup.shortcuts.toggle-control-mode": "비행/궤도 모드 전환", + "popup.shortcuts.toggle-grid": "그리드 전환", + "popup.shortcuts.toggle-camera-info": "카메라 정보 전환", + "popup.shortcuts.toggle-gizmo-coordinate-space": "기즈모 좌표 공간 전환", + "popup.shortcuts.camera": "카메라 (비행 모드)", + "popup.shortcuts.fly-movement": "앞/왼쪽/뒤/오른쪽 이동", + "popup.shortcuts.fly-vertical": "아래/위 이동", + "popup.shortcuts.fly-speed-fast": "빠른 이동", + "popup.shortcuts.fly-speed-slow": "느린 이동", + "popup.shortcuts.playback": "재생", + "popup.shortcuts.play-pause": "재생/일시정지", + "popup.shortcuts.prev-frame": "이전 프레임", + "popup.shortcuts.next-frame": "다음 프레임", + "popup.shortcuts.prev-key": "이전 키", + "popup.shortcuts.next-key": "다음 키", + "popup.shortcuts.add-key": "키 추가", + "popup.shortcuts.remove-key": "키 삭제", + "popup.export.header": "내보내기", + "popup.export.type": "내보내기 유형", + "popup.export.html": "HTML", + "popup.export.package": "ZIP 패키지", + "popup.export.sh-bands": "SH 밴드", + "popup.export.start-position": "시작 위치", + "popup.export.default": "기본값", + "popup.export.viewport": "현재 뷰포트", + "popup.export.pose-camera": "1번째 카메라 포즈", + "popup.export.fov": "시야각", + "popup.export.background-color": "배경색", + "popup.export.filename": "파일 이름", + "popup.export.animation": "애니메이션", + "popup.export.animation.none": "없음", + "popup.export.animation.track": "트랙", + "popup.export.loop-mode": "루프 모드", + "popup.export.loop-mode.none": "없음", + "popup.export.loop-mode.repeat": "반복", + "popup.export.loop-mode.pingpong": "핑퐁", + "popup.export.compress-ply": "PLY 압축", + "popup.export.splats-select": "Splat", + "popup.export.splats-select.all": "모든 Splat", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "버전", + "popup.export.spz-version.4": "SPZ 4 (최신)", + "popup.export.spz-version.3": "SPZ 3 (레거시 gzip)", + "popup.publish.header": "게시 설정", + "popup.publish.ok": "게시", + "popup.publish.cancel": "취소", + "popup.publish.title": "제목", + "popup.publish.description": "설명", + "popup.publish.listed": "목록", + "popup.publish.failed": "게시 실패", + "popup.publish.please-try-again": "잠시 후 다시 시도하십시오.", + "popup.publish.succeeded": "게시 성공", + "popup.publish.message": "아래 링크를 사용하여 장면에 액세스하십시오.", + "popup.publish.please-log-in": "PlayCanvas에 게시하려면 사용자 계정이 필요합니다. 로그인하고 다시 시도하십시오.", + "popup.publish.converting": "변환 중", + "popup.publish.uploading": "업로드 중", + "popup.publish.to": "다음에 게시", + "popup.publish.new-scene": "새 장면", + "popup.publish.override-model": "모델 덮어쓰기", + "popup.publish.override-animation": "애니메이션 덮어쓰기", + "popup.publish.generate-lods": "LOD 생성", + "camera-info.position": "카메라 위치", + "camera-info.target": "카메라 대상", + "doc.reset": "장면 재설정", + "doc.unsaved-message": "저장되지 않은 변경 사항이 있습니다. 장면을 재설정하시겠습니까?", + "doc.reset-message": "장면을 재설정하시겠습니까?", + "doc.save-failed": "저장 실패", + "doc.load-failed": "로드 실패", + "tooltip.right-toolbar.splat-mode": "Splat 모드", + "tooltip.right-toolbar.show-hide": "스플래츠 표시/숨기기", + "tooltip.right-toolbar.orbit-camera": "오빗 카메라", + "tooltip.right-toolbar.fly-camera": "플라이 카메라", + "tooltip.right-toolbar.frame-selection": "프레임 선택", + "tooltip.right-toolbar.reset-camera": "카메라 재설정", + "tooltip.right-toolbar.colors": "색상", + "tooltip.right-toolbar.settings": "설정", + "tooltip.bottom-toolbar.undo": "실행 취소", + "tooltip.bottom-toolbar.redo": "다시 실행", + "tooltip.bottom-toolbar.rect": "사각형 선택", + "tooltip.bottom-toolbar.lasso": "올가미 선택", + "tooltip.bottom-toolbar.polygon": "다각형 선택", + "tooltip.bottom-toolbar.brush": "브러시 선택", + "tooltip.bottom-toolbar.flood": "플러드 선택", + "tooltip.bottom-toolbar.eyedropper": "스포이드 선택", + "tooltip.bottom-toolbar.sphere": "구 선택", + "tooltip.bottom-toolbar.box": "상자 선택", + "tooltip.bottom-toolbar.translate": "이동", + "tooltip.bottom-toolbar.rotate": "회전", + "tooltip.bottom-toolbar.scale": "크기 조정", + "tooltip.bottom-toolbar.measure": "측정", + "measure.length": "길이", + "tooltip.bottom-toolbar.local-space": "로컬 공간", + "tooltip.bottom-toolbar.bound-center": "바운드 중심 사용", + "tooltip.timeline.prev-frame": "이전 프레임", + "tooltip.timeline.next-frame": "다음 프레임", + "tooltip.timeline.play": "재생/정지", + "tooltip.timeline.add-key": "키 추가", + "tooltip.timeline.remove-key": "키 제거", + "tooltip.timeline.key": "드래그하여 이동. Shift+드래그로 복사. Ctrl+클릭으로 현재 뷰 설정.", + "tooltip.timeline.frame-rate": "프레임 속도", + "tooltip.timeline.total-frames": "총 프레임 수", + "tooltip.timeline.smoothness": "부드러움", + "tooltip.timeline.loop": "애니메이션 루프", + "tooltip.scene.solo": "선택 항목 솔로", + "tooltip.scene.import": "장면 가져오기", + "tooltip.scene.new": "새 장면", + "status-bar.splats": "스플랫", + "status-bar.selected": "선택됨", + "status-bar.locked": "잠김", + "status-bar.deleted": "삭제됨", + "status-bar.timeline": "타임라인", + "status-bar.splat-data": "스플랫 데이터", + "tooltip.status-bar.timeline": "타임라인 패널 전환", + "tooltip.status-bar.splat-data": "스플랫 데이터 패널 전환", + "tooltip.splat-data.on-screen-only": "카메라에 보이는 스플랫만 히스토그램에 집계합니다. 화면 밖의 스플랫은 제외됩니다.", + "tooltip.splat-data.log-scale": "세로축을 로그 스케일로 표시하여 작은 값과 큰 값이 함께 잘 보이도록 합니다.", + "tooltip.splat-data.show-all": "원시 DC 계수 및 구면 조화 함수 계수를 포함한 모든 스플랫 속성을 목록에 표시합니다." +} diff --git a/static/locales/pt-BR.json b/static/locales/pt-BR.json new file mode 100644 index 0000000..7411014 --- /dev/null +++ b/static/locales/pt-BR.json @@ -0,0 +1,324 @@ +{ + "menu.file": "Arquivo", + "menu.file.new": "Novo", + "menu.file.open": "Abrir", + "menu.file.open-recent": "Abrir Recentes", + "menu.file.open-recent.clear": "Limpar Recentes", + "menu.file.import": "Importar", + "menu.file.save": "Salvar", + "menu.file.save-as": "Salvar Como", + "menu.file.publish": "Publicar", + "menu.file.export": "Exportar", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Aplicativo de Visualização", + "menu.edit": "Editar", + "menu.edit.undo": "Desfazer", + "menu.edit.redo": "Refazer", + "menu.edit.duplicate": "Duplicar", + "menu.edit.separate": "Separar", + "menu.select": "Selecionar", + "menu.select.all": "Todos", + "menu.select.none": "Nenhum", + "menu.select.invert": "Inverter", + "menu.select.lock": "Bloquear Seleção", + "menu.select.unlock": "Desbloquear Tudo", + "menu.select.delete": "Excluir Seleção", + "menu.select.reset": "Redefinir Splat", + "menu.render": "Renderizar", + "menu.render.image": "Imagem", + "menu.render.video": "Vídeo", + "menu.help": "Ajuda", + "menu.help.shortcuts": "Atalhos de Teclado", + "menu.help.user-guide": "Guia do Usuário", + "menu.help.log-issue": "Registrar um Problema", + "menu.help.github-repo": "Repositório GitHub", + "menu.help.video-tutorials": "Tutoriais em Vídeo", + "menu.help.video-tutorials.basics": "Aprender o Básico", + "menu.help.video-tutorials.in-depth": "Tutorial Detalhado", + "menu.help.video-tutorials.deleting-floaters": "Excluir Flutuantes", + "menu.help.video-tutorials.scaling": "Dimensionar seus Splats", + "menu.help.discord": "Servidor Discord", + "menu.help.forum": "Fórum", + "menu.help.about": "Sobre o SuperSplat", + "panel.mode.centers": "Modo Centros", + "panel.mode.rings": "Modo Anéis", + "panel.scene-manager": "Gerenciador de Cena", + "panel.scene-manager.transform": "Transformar", + "panel.scene-manager.transform.position": "Posição", + "panel.scene-manager.transform.rotation": "Giro", + "panel.scene-manager.transform.scale": "Escala", + "panel.colors": "Cores", + "panel.colors.tint": "Matiz", + "panel.colors.temperature": "Temperatura", + "panel.colors.saturation": "Saturação", + "panel.colors.brightness": "Brilho", + "panel.colors.black-point": "Ponto Preto", + "panel.colors.white-point": "Ponto Branco", + "panel.colors.transparency": "Transparência", + "panel.colors.reset": "Redefinir", + "panel.settings": "Configurações", + "panel.settings.language": "Idioma", + "panel.settings.language.auto": "Automático", + "panel.settings.colors": "Cores", + "panel.settings.background-color": "Cor de Fundo", + "panel.settings.selected-color": "Cor Selecionada", + "panel.settings.unselected-color": "Cor Não Selecionada", + "panel.settings.locked-color": "Objetos Bloqueados", + "panel.settings.fov": "Campo de Visão", + "panel.settings.fov-dolly": "Dolly Automático de FOV", + "panel.settings.control-mode": "Modo de Controle", + "panel.settings.control-mode.orbit": "Órbita", + "panel.settings.control-mode.fly": "Voar", + "panel.settings.sh-bands": "Bandas SH", + "panel.settings.centers-size": "Tamanho dos Centros", + "panel.settings.centers-gaussian-color": "Centros de Cor", + "panel.settings.outline-selection": "Contorno da Seleção", + "panel.settings.show-grid": "Mostrar Grade", + "panel.settings.show-bound": "Mostrar Limite", + "panel.settings.show-bound-dimensions": "Mostrar Dimensões", + "panel.settings.show-camera-poses": "Mostrar Câmeras", + "panel.settings.show-camera-info": "Mostrar Info da Câmera", + "panel.settings.fly-speed": "Velocidade de Voo", + "panel.settings.tonemapping": "Mapeamento de Tons", + "panel.settings.tonemapping.none": "Nenhum", + "panel.settings.tonemapping.linear": "Linear", + "panel.settings.tonemapping.neutral": "Neutro", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Filmic", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Redefinir padrões", + "panel.splat-data": "Dados do Splat", + "panel.splat-data.distance": "Distância", + "panel.splat-data.camera-depth": "Profundidade da câmera", + "panel.splat-data.volume": "Volume", + "panel.splat-data.surface-area": "Área da Superfície", + "panel.splat-data.scale-x": "Escala X", + "panel.splat-data.scale-y": "Escala Y", + "panel.splat-data.scale-z": "Escala Z", + "panel.splat-data.red": "Vermelho", + "panel.splat-data.green": "Verde", + "panel.splat-data.blue": "Azul", + "panel.splat-data.dc-red": "DC Vermelho", + "panel.splat-data.dc-green": "DC Verde", + "panel.splat-data.dc-blue": "DC Azul", + "panel.splat-data.opacity": "Opacidade", + "panel.splat-data.hue": "Matiz", + "panel.splat-data.saturation": "Saturação", + "panel.splat-data.value": "Valor", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Escala Logarítmica", + "panel.splat-data.show-all": "Todas as propriedades", + "panel.splat-data.on-screen-only": "Apenas splats visíveis", + "panel.splat-data.totals": "Totais", + "panel.splat-data.totals.splats": "Splats", + "panel.splat-data.totals.selected": "Selecionado", + "panel.splat-data.totals.locked": "Bloqueado", + "panel.splat-data.totals.deleted": "Excluído", + "panel.render.ok": "Renderizar", + "panel.render.cancel": "Cancelar", + "panel.render.render-video": "Renderizar Vídeo", + "panel.render.rendering": "Renderizando Quadros", + "panel.render.failed": "Falha ao renderizar", + "select-toolbar.set": "Definir", + "select-toolbar.add": "Adicionar", + "select-toolbar.remove": "Remover", + "select-toolbar.intersect": "Interseccionar", + "select-toolbar.position": "Posição", + "select-toolbar.size": "Tamanho", + "select-toolbar.radius": "Raio", + "popup.ok": "OK", + "popup.cancel": "Cancelar", + "popup.yes": "Sim", + "popup.no": "Não", + "popup.error": "Erro", + "popup.error-loading": "Erro ao Carregar Arquivo", + "popup.webgpu-unavailable": "Esta exportação requer WebGPU, que não está disponível neste navegador. Tente uma versão recente do Chrome, Edge ou Safari.", + "popup.lcc-upload-warning": "Para uma melhor publicação no superspl.at, envie seu arquivo LCC diretamente pela página de upload.", + "popup.export": "Exportar", + "popup.copy-to-clipboard": "Copiar Link para Área de Transferência", + "popup.render-image.header": "Configurações de Imagem", + "popup.render-image.preset": "Predefinições", + "popup.render-image.resolution": "Resolução", + "popup.render-image.transparent-bg": "Fundo Transparente", + "popup.render-image.show-debug": "Mostrar Sobreposições (Debug)", + "popup.render-image.resolution-current": "Resolução da Tela", + "popup.render-image.resolution-custom": "Personalizado", + "popup.render-image.projection": "Projeção", + "popup.render-image.projection-standard": "Padrão", + "popup.render-image.projection-360": "Equirretangular 360°", + "popup.render-image.level-horizon": "Nivelar Horizonte", + "popup.render-video.header": "Configurações de Vídeo", + "popup.render-video.projection": "Projeção", + "popup.render-video.projection-standard": "Padrão", + "popup.render-video.projection-360": "Equirretangular 360°", + "popup.render-video.resolution": "Resolução", + "popup.render-video.format": "Formato", + "popup.render-video.codec": "Codec", + "popup.render-video.frame-rate": "Taxa de Quadros", + "popup.render-video.frame-range": "Intervalo de Quadros", + "popup.render-video.frame-range-first": "Primeiro", + "popup.render-video.frame-range-last": "Último", + "popup.render-video.bitrate": "Taxa de Bits", + "popup.render-video.portrait": "Modo Retrato", + "popup.render-video.level-horizon": "Nivelar Horizonte", + "popup.render-video.transparent-bg": "Fundo Transparente", + "popup.render-video.show-debug": "Mostrar Sobreposições (Debug)", + "popup.shortcuts.title": "Atalhos de Teclado", + "popup.shortcuts.navigation": "Navegação", + "popup.shortcuts.tools": "Ferramentas", + "popup.shortcuts.move": "Mover", + "popup.shortcuts.rotate": "Rotacionar", + "popup.shortcuts.scale": "Escalar", + "popup.shortcuts.rect-selection": "Seleção Retangular", + "popup.shortcuts.lasso-selection": "Seleção com Laço", + "popup.shortcuts.polygon-selection": "Seleção com Polígono", + "popup.shortcuts.brush-selection": "Seleção com Pincel", + "popup.shortcuts.flood-selection": "Seleção por Preenchimento", + "popup.shortcuts.eyedropper-selection": "Seleção com Conta-gotas", + "popup.shortcuts.brush-size": "Aumentar/Diminuir Tamanho do Pincel", + "popup.shortcuts.deactivate-tool": "Desativar Ferramenta", + "popup.shortcuts.selection": "Seleção", + "popup.shortcuts.select-all": "Selecionar Tudo", + "popup.shortcuts.deselect-all": "Desselecionar Tudo", + "popup.shortcuts.invert-selection": "Inverter Seleção", + "popup.shortcuts.add-to-selection": "Adicionar à Seleção", + "popup.shortcuts.remove-from-selection": "Remover da Seleção", + "popup.shortcuts.delete-selected-splats": "Excluir Splats Selecionados", + "popup.shortcuts.show": "Mostrar", + "popup.shortcuts.lock-selected-splats": "Bloquear Splats Selecionados", + "popup.shortcuts.unlock-all-splats": "Desbloquear Todos os Splats", + "popup.shortcuts.toggle-data-panel": "Alternar Painel de Dados", + "popup.shortcuts.toggle-timeline-panel": "Alternar Painel de Linha do Tempo", + "popup.shortcuts.other": "Outro", + "popup.shortcuts.undo": "Desfazer", + "popup.shortcuts.redo": "Refazer", + "popup.shortcuts.toggle-splat-overlay": "Alternar Sobreposição de Splat", + "popup.shortcuts.focus-camera": "Focar Câmera na Seleção Atual", + "popup.shortcuts.reset-camera": "Redefinir Câmera", + "popup.shortcuts.toggle-camera-mode": "Alternar Modo de Câmera", + "popup.shortcuts.toggle-overlay-mode": "Alternar Modo de Sobreposição", + "popup.shortcuts.toggle-control-mode": "Alternar Modo Voar/Órbita", + "popup.shortcuts.toggle-grid": "Mostrar/Ocultar a Grade", + "popup.shortcuts.toggle-camera-info": "Mostrar/Ocultar Info da Câmera", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Alternar Espaço de Coordenadas do Gizmo", + "popup.shortcuts.camera": "Câmera (Modo Voar)", + "popup.shortcuts.fly-movement": "Mover Frente/Esquerda/Trás/Direita", + "popup.shortcuts.fly-vertical": "Mover Baixo/Cima", + "popup.shortcuts.fly-speed-fast": "Movimento Rápido", + "popup.shortcuts.fly-speed-slow": "Movimento Lento", + "popup.shortcuts.playback": "Reprodução", + "popup.shortcuts.play-pause": "Reproduzir/Pausar", + "popup.shortcuts.prev-frame": "Quadro Anterior", + "popup.shortcuts.next-frame": "Próximo Quadro", + "popup.shortcuts.prev-key": "Chave Anterior", + "popup.shortcuts.next-key": "Próxima Chave", + "popup.shortcuts.add-key": "Adicionar Chave", + "popup.shortcuts.remove-key": "Remover Chave", + "popup.export.header": "Exportar", + "popup.export.type": "Tipo de Exportação", + "popup.export.html": "HTML", + "popup.export.package": "Arquivo ZIP", + "popup.export.sh-bands": "Bandas SH", + "popup.export.start-position": "Posição Inicial", + "popup.export.default": "Padrão", + "popup.export.viewport": "Viewport Atual", + "popup.export.pose-camera": "1ª Posição da Câmera", + "popup.export.fov": "Campo de Visão", + "popup.export.background-color": "Cor de Fundo", + "popup.export.filename": "Nome do Arquivo", + "popup.export.animation": "Animação", + "popup.export.animation.none": "Nenhuma", + "popup.export.animation.track": "Faixa", + "popup.export.loop-mode": "Modo de Repetição", + "popup.export.loop-mode.none": "Nenhum", + "popup.export.loop-mode.repeat": "Repetir", + "popup.export.loop-mode.pingpong": "Ping Pong", + "popup.export.compress-ply": "Comprimir PLY", + "popup.export.splats-select": "Splats Selecionados", + "popup.export.splats-select.all": "Todos os Splats", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Versão", + "popup.export.spz-version.4": "SPZ 4 (mais recente)", + "popup.export.spz-version.3": "SPZ 3 (gzip legado)", + "popup.publish.header": "Configurações de Publicação", + "popup.publish.ok": "Publicar", + "popup.publish.cancel": "Cancelar", + "popup.publish.title": "Título", + "popup.publish.description": "Descrição", + "popup.publish.listed": "Listado", + "popup.publish.failed": "Falha na Publicação", + "popup.publish.please-try-again": "Por favor, tente novamente mais tarde.", + "popup.publish.succeeded": "Publicação Bem-Sucedida", + "popup.publish.message": "Use o link abaixo para acessar sua cena.", + "popup.publish.please-log-in": "Publicar no PlayCanvas requer uma conta de usuário. Por favor, faça login e tente novamente.", + "popup.publish.converting": "Convertendo", + "popup.publish.uploading": "Enviando", + "popup.publish.to": "Publicar em", + "popup.publish.new-scene": "Nova Cena", + "popup.publish.override-model": "Substituir Modelo", + "popup.publish.override-animation": "Substituir Animação", + "popup.publish.generate-lods": "Gerar LODs", + "camera-info.position": "Posição da câmera", + "camera-info.target": "Alvo da câmera", + "doc.reset": "Reiniciar a Cena", + "doc.unsaved-message": "Você tem alterações não salvas. Tem certeza de que deseja reiniciar a cena?", + "doc.reset-message": "Tem certeza de que deseja reiniciar a cena?", + "doc.save-failed": "Falha ao Salvar", + "doc.load-failed": "Falha ao Carregar", + "tooltip.right-toolbar.splat-mode": "Modo Splat", + "tooltip.right-toolbar.show-hide": "Mostrar/Ocultar Splats", + "tooltip.right-toolbar.orbit-camera": "Câmera Orbital", + "tooltip.right-toolbar.fly-camera": "Câmera de Voo", + "tooltip.right-toolbar.frame-selection": "Selecionar Quadro", + "tooltip.right-toolbar.reset-camera": "Redefinir Câmera", + "tooltip.right-toolbar.colors": "Cores", + "tooltip.right-toolbar.settings": "Configurações", + "tooltip.bottom-toolbar.undo": "Desfazer", + "tooltip.bottom-toolbar.redo": "Refazer", + "tooltip.bottom-toolbar.rect": "Seleção Retangular", + "tooltip.bottom-toolbar.lasso": "Selecionar com Laço", + "tooltip.bottom-toolbar.polygon": "Selecionar com Polígono", + "tooltip.bottom-toolbar.brush": "Selecionar com Pincel", + "tooltip.bottom-toolbar.flood": "Selecionar com Preenchimento", + "tooltip.bottom-toolbar.eyedropper": "Seleção com Conta-gotas", + "tooltip.bottom-toolbar.sphere": "Selecionar com Esfera", + "tooltip.bottom-toolbar.box": "Selecionar com Caixa", + "tooltip.bottom-toolbar.translate": "Mover", + "tooltip.bottom-toolbar.rotate": "Rotacionar", + "tooltip.bottom-toolbar.scale": "Escalar", + "tooltip.bottom-toolbar.measure": "Medir", + "measure.length": "Comprimento", + "tooltip.bottom-toolbar.local-space": "Usar Orientação Local", + "tooltip.bottom-toolbar.bound-center": "Usar Centro da Cena", + "tooltip.timeline.prev-frame": "Quadro Anterior", + "tooltip.timeline.next-frame": "Próximo Quadro", + "tooltip.timeline.play": "Tocar/Parar", + "tooltip.timeline.add-key": "Adicionar Quadro", + "tooltip.timeline.remove-key": "Remover Quadro", + "tooltip.timeline.key": "Arraste para mover. Shift+arrastar para copiar. Ctrl+clique para definir a visão atual.", + "tooltip.timeline.frame-rate": "Taxa de Quadros", + "tooltip.timeline.total-frames": "Total de Quadros", + "tooltip.timeline.smoothness": "Suavidade", + "tooltip.timeline.loop": "Repetir Animação", + "tooltip.scene.solo": "Solo selecionado", + "tooltip.scene.import": "Importar Cena", + "tooltip.scene.new": "Nova Cena", + "status-bar.splats": "Splats", + "status-bar.selected": "Selecionados", + "status-bar.locked": "Bloqueados", + "status-bar.deleted": "Excluídos", + "status-bar.timeline": "Linha do Tempo", + "status-bar.splat-data": "Dados de Splat", + "tooltip.status-bar.timeline": "Alternar painel de Linha do Tempo", + "tooltip.status-bar.splat-data": "Alternar painel de Dados de Splat", + "tooltip.splat-data.on-screen-only": "Contar apenas os splats visíveis pela câmera. Splats fora da tela são excluídos do histograma.", + "tooltip.splat-data.log-scale": "Usar escala vertical logarítmica para que valores pequenos fiquem visíveis ao lado dos grandes.", + "tooltip.splat-data.show-all": "Mostrar todas as propriedades dos splats na lista, incluindo coeficientes DC brutos e harmônicos esféricos." +} diff --git a/static/locales/ru.json b/static/locales/ru.json new file mode 100644 index 0000000..20e68ff --- /dev/null +++ b/static/locales/ru.json @@ -0,0 +1,324 @@ +{ + "menu.file": "Файл", + "menu.file.new": "Новый", + "menu.file.open": "Открыть", + "menu.file.open-recent": "Открыть недавние", + "menu.file.open-recent.clear": "Очистить недавние", + "menu.file.import": "Импорт", + "menu.file.save": "Сохранить", + "menu.file.save-as": "Сохранить как", + "menu.file.publish": "Опубликовать", + "menu.file.export": "Экспорт", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "Приложение просмотра", + "menu.edit": "Правка", + "menu.edit.undo": "Отменить", + "menu.edit.redo": "Повторить", + "menu.edit.duplicate": "Дублировать", + "menu.edit.separate": "Разделить", + "menu.select": "Выбор", + "menu.select.all": "Все", + "menu.select.none": "Ничего", + "menu.select.invert": "Инвертировать", + "menu.select.lock": "Заблокировать выделение", + "menu.select.unlock": "Разблокировать все", + "menu.select.delete": "Удалить выделение", + "menu.select.reset": "Сбросить Splat", + "menu.render": "Рендеринг", + "menu.render.image": "Изображение", + "menu.render.video": "Видео", + "menu.help": "Помощь", + "menu.help.shortcuts": "Горячие клавиши", + "menu.help.user-guide": "Руководство пользователя", + "menu.help.log-issue": "Сообщить о проблеме", + "menu.help.github-repo": "Репозиторий GitHub", + "menu.help.video-tutorials": "Видео Учебники", + "menu.help.video-tutorials.basics": "Изучить Основы", + "menu.help.video-tutorials.in-depth": "Подробный Учебник", + "menu.help.video-tutorials.deleting-floaters": "Удаление Флоатеров", + "menu.help.video-tutorials.scaling": "Масштабирование Сплатов", + "menu.help.discord": "Сервер Discord", + "menu.help.forum": "Форум", + "menu.help.about": "О SuperSplat", + "panel.mode.centers": "Режим центров", + "panel.mode.rings": "Режим колец", + "panel.scene-manager": "Менеджер сцены", + "panel.scene-manager.transform": "Трансформация", + "panel.scene-manager.transform.position": "Позиция", + "panel.scene-manager.transform.rotation": "Вращение", + "panel.scene-manager.transform.scale": "Масштаб", + "panel.colors": "Цвета", + "panel.colors.tint": "Оттенок", + "panel.colors.temperature": "Температура", + "panel.colors.saturation": "Насыщенность", + "panel.colors.brightness": "Яркость", + "panel.colors.black-point": "Точка чёрного", + "panel.colors.white-point": "Точка белого", + "panel.colors.transparency": "Прозрачность", + "panel.colors.reset": "Сбросить", + "panel.settings": "Настройки", + "panel.settings.language": "Язык", + "panel.settings.language.auto": "Автоматически", + "panel.settings.colors": "Цвета", + "panel.settings.background-color": "Цвет фона", + "panel.settings.selected-color": "Цвет выделенного", + "panel.settings.unselected-color": "Цвет невыделенного", + "panel.settings.locked-color": "Цвет заблокированного", + "panel.settings.fov": "Поле зрения", + "panel.settings.fov-dolly": "Авто-долли FOV", + "panel.settings.control-mode": "Режим управления", + "panel.settings.control-mode.orbit": "Орбита", + "panel.settings.control-mode.fly": "Полёт", + "panel.settings.sh-bands": "Полосы SH", + "panel.settings.centers-size": "Размер центров", + "panel.settings.centers-gaussian-color": "Цветные центры", + "panel.settings.outline-selection": "Контур выделения", + "panel.settings.show-grid": "Показать сетку", + "panel.settings.show-bound": "Показать границы", + "panel.settings.show-bound-dimensions": "Показать размеры", + "panel.settings.show-camera-poses": "Показать камеры", + "panel.settings.show-camera-info": "Показать информацию о камере", + "panel.settings.fly-speed": "Скорость полёта", + "panel.settings.tonemapping": "Тональная коррекция", + "panel.settings.tonemapping.none": "Нет", + "panel.settings.tonemapping.linear": "Линейная", + "panel.settings.tonemapping.neutral": "Нейтральная", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "Кинематографическая", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "Сбросить настройки", + "panel.splat-data": "Данные Splat", + "panel.splat-data.distance": "Расстояние", + "panel.splat-data.camera-depth": "Глубина камеры", + "panel.splat-data.volume": "Объём", + "panel.splat-data.surface-area": "Площадь поверхности", + "panel.splat-data.scale-x": "Масштаб X", + "panel.splat-data.scale-y": "Масштаб Y", + "panel.splat-data.scale-z": "Масштаб Z", + "panel.splat-data.red": "Красный", + "panel.splat-data.green": "Зелёный", + "panel.splat-data.blue": "Синий", + "panel.splat-data.dc-red": "DC Красный", + "panel.splat-data.dc-green": "DC Зелёный", + "panel.splat-data.dc-blue": "DC Синий", + "panel.splat-data.opacity": "Непрозрачность", + "panel.splat-data.hue": "Тон", + "panel.splat-data.saturation": "Насыщенность", + "panel.splat-data.value": "Значение", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "Логарифмическая шкала", + "panel.splat-data.show-all": "Все свойства", + "panel.splat-data.on-screen-only": "Только видимые сплаты", + "panel.splat-data.totals": "Итого", + "panel.splat-data.totals.splats": "Сплаты", + "panel.splat-data.totals.selected": "Выбрано", + "panel.splat-data.totals.locked": "Заблокировано", + "panel.splat-data.totals.deleted": "Удалено", + "panel.render.ok": "Рендерить", + "panel.render.cancel": "Отмена", + "panel.render.render-video": "Рендерить видео", + "panel.render.rendering": "Рендеринг кадров", + "panel.render.failed": "Ошибка рендеринга", + "select-toolbar.set": "Задать", + "select-toolbar.add": "Добавить", + "select-toolbar.remove": "Убрать", + "select-toolbar.intersect": "Пересечь", + "select-toolbar.position": "Позиция", + "select-toolbar.size": "Размер", + "select-toolbar.radius": "Радиус", + "popup.ok": "ОК", + "popup.cancel": "Отмена", + "popup.yes": "Да", + "popup.no": "Нет", + "popup.error": "Ошибка", + "popup.error-loading": "Ошибка загрузки файла", + "popup.webgpu-unavailable": "Для этого экспорта требуется WebGPU, который недоступен в этом браузере. Попробуйте последнюю версию Chrome, Edge или Safari.", + "popup.lcc-upload-warning": "Для лучшей публикации на superspl.at загрузите LCC-файл напрямую через страницу загрузки.", + "popup.export": "Экспорт", + "popup.copy-to-clipboard": "Скопировать ссылку в буфер обмена", + "popup.render-image.header": "Настройки изображения", + "popup.render-image.preset": "Предустановка", + "popup.render-image.resolution": "Разрешение", + "popup.render-image.transparent-bg": "Прозрачный фон", + "popup.render-image.show-debug": "Показать отладочные наложения", + "popup.render-image.resolution-current": "Текущее", + "popup.render-image.resolution-custom": "Пользовательское", + "popup.render-image.projection": "Проекция", + "popup.render-image.projection-standard": "Стандартная", + "popup.render-image.projection-360": "Эквиректангулярная 360°", + "popup.render-image.level-horizon": "Выровнять горизонт", + "popup.render-video.header": "Настройки видео", + "popup.render-video.projection": "Проекция", + "popup.render-video.projection-standard": "Стандартная", + "popup.render-video.projection-360": "Эквиректангулярная 360°", + "popup.render-video.resolution": "Разрешение", + "popup.render-video.format": "Формат", + "popup.render-video.codec": "Кодек", + "popup.render-video.frame-rate": "Частота кадров", + "popup.render-video.frame-range": "Диапазон кадров", + "popup.render-video.frame-range-first": "Первый", + "popup.render-video.frame-range-last": "Последний", + "popup.render-video.bitrate": "Битрейт", + "popup.render-video.portrait": "Портретный режим", + "popup.render-video.level-horizon": "Выровнять горизонт", + "popup.render-video.transparent-bg": "Прозрачный фон", + "popup.render-video.show-debug": "Показать отладочные наложения", + "popup.shortcuts.title": "Горячие клавиши", + "popup.shortcuts.navigation": "Навигация", + "popup.shortcuts.tools": "Инструменты", + "popup.shortcuts.move": "Перемещение", + "popup.shortcuts.rotate": "Вращение", + "popup.shortcuts.scale": "Масштабирование", + "popup.shortcuts.rect-selection": "Прямоугольное выделение", + "popup.shortcuts.lasso-selection": "Выделение лассо", + "popup.shortcuts.polygon-selection": "Выделение полигоном", + "popup.shortcuts.brush-selection": "Выделение кистью", + "popup.shortcuts.flood-selection": "Заливка выделением", + "popup.shortcuts.eyedropper-selection": "Выделение пипеткой", + "popup.shortcuts.brush-size": "Уменьшить/Увеличить размер кисти", + "popup.shortcuts.deactivate-tool": "Деактивировать инструмент", + "popup.shortcuts.selection": "Выделение", + "popup.shortcuts.select-all": "Выбрать всё", + "popup.shortcuts.deselect-all": "Снять выделение", + "popup.shortcuts.invert-selection": "Инвертировать выделение", + "popup.shortcuts.add-to-selection": "Добавить к выделению", + "popup.shortcuts.remove-from-selection": "Убрать из выделения", + "popup.shortcuts.delete-selected-splats": "Удалить выделенные сплаты", + "popup.shortcuts.show": "Показать", + "popup.shortcuts.lock-selected-splats": "Заблокировать выделенные сплаты", + "popup.shortcuts.unlock-all-splats": "Разблокировать все сплаты", + "popup.shortcuts.toggle-data-panel": "Переключить панель данных", + "popup.shortcuts.toggle-timeline-panel": "Переключить панель временной шкалы", + "popup.shortcuts.other": "Прочее", + "popup.shortcuts.undo": "Отменить", + "popup.shortcuts.redo": "Повторить", + "popup.shortcuts.toggle-splat-overlay": "Переключить наложение сплатов", + "popup.shortcuts.focus-camera": "Сфокусировать камеру на текущем выделении", + "popup.shortcuts.reset-camera": "Сбросить камеру", + "popup.shortcuts.toggle-camera-mode": "Переключить режим камеры", + "popup.shortcuts.toggle-overlay-mode": "Переключить режим наложения", + "popup.shortcuts.toggle-control-mode": "Переключить режим Полёт/Орбита", + "popup.shortcuts.toggle-grid": "Переключить сетку", + "popup.shortcuts.toggle-camera-info": "Переключить информацию о камере", + "popup.shortcuts.toggle-gizmo-coordinate-space": "Переключить систему координат гизмо", + "popup.shortcuts.camera": "Камера (Режим полёта)", + "popup.shortcuts.fly-movement": "Вперёд/Влево/Назад/Вправо", + "popup.shortcuts.fly-vertical": "Вниз/Вверх", + "popup.shortcuts.fly-speed-fast": "Быстрое перемещение", + "popup.shortcuts.fly-speed-slow": "Медленное перемещение", + "popup.shortcuts.playback": "Воспроизведение", + "popup.shortcuts.play-pause": "Воспроизведение/Пауза", + "popup.shortcuts.prev-frame": "Предыдущий кадр", + "popup.shortcuts.next-frame": "Следующий кадр", + "popup.shortcuts.prev-key": "Предыдущий ключ", + "popup.shortcuts.next-key": "Следующий ключ", + "popup.shortcuts.add-key": "Добавить ключ", + "popup.shortcuts.remove-key": "Удалить ключ", + "popup.export.header": "Экспорт", + "popup.export.type": "Тип экспорта", + "popup.export.html": "HTML", + "popup.export.package": "ZIP-пакет", + "popup.export.sh-bands": "Полосы SH", + "popup.export.start-position": "Начальная позиция", + "popup.export.default": "По умолчанию", + "popup.export.viewport": "Текущий вид", + "popup.export.pose-camera": "Поза 1-й камеры", + "popup.export.fov": "Поле зрения", + "popup.export.background-color": "Фон", + "popup.export.filename": "Имя файла", + "popup.export.animation": "Анимация", + "popup.export.animation.none": "Нет", + "popup.export.animation.track": "Трек", + "popup.export.loop-mode": "Режим цикла", + "popup.export.loop-mode.none": "Нет", + "popup.export.loop-mode.repeat": "Повтор", + "popup.export.loop-mode.pingpong": "Пинг-понг", + "popup.export.compress-ply": "Сжать PLY", + "popup.export.splats-select": "Сплаты", + "popup.export.splats-select.all": "Все сплаты", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "Версия", + "popup.export.spz-version.4": "SPZ 4 (последняя)", + "popup.export.spz-version.3": "SPZ 3 (устаревший gzip)", + "popup.publish.header": "Настройки публикации", + "popup.publish.ok": "Опубликовать", + "popup.publish.cancel": "Отмена", + "popup.publish.title": "Название", + "popup.publish.description": "Описание", + "popup.publish.listed": "В списке", + "popup.publish.failed": "Публикация не удалась", + "popup.publish.please-try-again": "Пожалуйста, попробуйте позже.", + "popup.publish.succeeded": "Публикация успешна", + "popup.publish.message": "Используйте ссылку ниже для доступа к вашей сцене.", + "popup.publish.please-log-in": "Для публикации в PlayCanvas требуется учётная запись. Пожалуйста, войдите и попробуйте снова.", + "popup.publish.converting": "Конвертация", + "popup.publish.uploading": "Загрузка", + "popup.publish.to": "Опубликовать в", + "popup.publish.new-scene": "Новая сцена", + "popup.publish.override-model": "Заменить модель", + "popup.publish.override-animation": "Заменить анимацию", + "popup.publish.generate-lods": "Создавать LOD", + "camera-info.position": "Позиция камеры", + "camera-info.target": "Цель камеры", + "doc.reset": "Сбросить сцену", + "doc.unsaved-message": "У вас есть несохранённые изменения. Вы уверены, что хотите сбросить сцену?", + "doc.reset-message": "Вы уверены, что хотите сбросить сцену?", + "doc.save-failed": "Не удалось сохранить", + "doc.load-failed": "Не удалось загрузить", + "tooltip.right-toolbar.splat-mode": "Режим Splat", + "tooltip.right-toolbar.show-hide": "Показать/Скрыть сплаты", + "tooltip.right-toolbar.orbit-camera": "Орбитальная камера", + "tooltip.right-toolbar.fly-camera": "Камера полёта", + "tooltip.right-toolbar.frame-selection": "Обрамить выделение", + "tooltip.right-toolbar.reset-camera": "Сбросить камеру", + "tooltip.right-toolbar.colors": "Цвета", + "tooltip.right-toolbar.settings": "Настройки", + "tooltip.bottom-toolbar.undo": "Отменить", + "tooltip.bottom-toolbar.redo": "Повторить", + "tooltip.bottom-toolbar.rect": "Прямоугольное выделение", + "tooltip.bottom-toolbar.lasso": "Выделение лассо", + "tooltip.bottom-toolbar.polygon": "Выделение полигоном", + "tooltip.bottom-toolbar.brush": "Выделение кистью", + "tooltip.bottom-toolbar.flood": "Заливка выделением", + "tooltip.bottom-toolbar.eyedropper": "Выделение пипеткой", + "tooltip.bottom-toolbar.sphere": "Выделение сферой", + "tooltip.bottom-toolbar.box": "Выделение прямоугольником", + "tooltip.bottom-toolbar.translate": "Перемещение", + "tooltip.bottom-toolbar.rotate": "Вращение", + "tooltip.bottom-toolbar.scale": "Масштабирование", + "tooltip.bottom-toolbar.measure": "Измерение", + "measure.length": "Длина", + "tooltip.bottom-toolbar.local-space": "Использовать локальную ориентацию", + "tooltip.bottom-toolbar.bound-center": "Использовать центр границ", + "tooltip.timeline.prev-frame": "Предыдущий кадр", + "tooltip.timeline.next-frame": "Следующий кадр", + "tooltip.timeline.play": "Воспроизвести/Остановить", + "tooltip.timeline.add-key": "Добавить ключ", + "tooltip.timeline.remove-key": "Удалить ключ", + "tooltip.timeline.key": "Перетащите для перемещения. Shift+перетаскивание — копировать. Ctrl+клик — задать текущий вид.", + "tooltip.timeline.frame-rate": "Частота кадров", + "tooltip.timeline.total-frames": "Всего кадров", + "tooltip.timeline.smoothness": "Плавность", + "tooltip.timeline.loop": "Зациклить анимацию", + "tooltip.scene.solo": "Соло выбранного", + "tooltip.scene.import": "Импортировать сцену", + "tooltip.scene.new": "Новая сцена", + "status-bar.splats": "Сплаты", + "status-bar.selected": "Выбрано", + "status-bar.locked": "Заблокировано", + "status-bar.deleted": "Удалено", + "status-bar.timeline": "Временная шкала", + "status-bar.splat-data": "Данные сплатов", + "tooltip.status-bar.timeline": "Переключить панель временной шкалы", + "tooltip.status-bar.splat-data": "Переключить панель данных сплатов", + "tooltip.splat-data.on-screen-only": "Учитывать только сплаты, видимые в поле зрения камеры. Сплаты вне экрана исключаются из гистограммы.", + "tooltip.splat-data.log-scale": "Использовать логарифмическую вертикальную шкалу, чтобы малые значения были видны рядом с большими.", + "tooltip.splat-data.show-all": "Показать все свойства сплатов в списке, включая необработанные коэффициенты DC и сферических гармоник." +} diff --git a/static/locales/zh-CN.json b/static/locales/zh-CN.json new file mode 100644 index 0000000..2abca87 --- /dev/null +++ b/static/locales/zh-CN.json @@ -0,0 +1,324 @@ +{ + "menu.file": "文件", + "menu.file.new": "新建", + "menu.file.open": "打开", + "menu.file.open-recent": "打开最近", + "menu.file.open-recent.clear": "清除最近", + "menu.file.import": "导入", + "menu.file.save": "保存", + "menu.file.save-as": "另存为", + "menu.file.publish": "发布", + "menu.file.export": "导出", + "menu.file.export.ply": "PLY (.ply)", + "menu.file.export.splat": "Splat (.splat)", + "menu.file.export.sog": "SOG (.sog)", + "menu.file.export.spz": "SPZ (.spz)", + "menu.file.export.viewer": "查看器应用", + "menu.edit": "编辑", + "menu.edit.undo": "撤销", + "menu.edit.redo": "重做", + "menu.edit.duplicate": "复制", + "menu.edit.separate": "分离", + "menu.select": "选择", + "menu.select.all": "全部", + "menu.select.none": "无", + "menu.select.invert": "反选", + "menu.select.lock": "锁定选择", + "menu.select.unlock": "解锁全部", + "menu.select.delete": "删除选择", + "menu.select.reset": "重置 Splat", + "menu.render": "渲染", + "menu.render.image": "图像", + "menu.render.video": "视频", + "menu.help": "帮助", + "menu.help.shortcuts": "键盘快捷键", + "menu.help.user-guide": "用户指南", + "menu.help.log-issue": "报告问题", + "menu.help.github-repo": "GitHub 仓库", + "menu.help.video-tutorials": "视频教程", + "menu.help.video-tutorials.basics": "学习基础", + "menu.help.video-tutorials.in-depth": "深入教程", + "menu.help.video-tutorials.deleting-floaters": "删除浮动点", + "menu.help.video-tutorials.scaling": "缩放你的 Splats", + "menu.help.discord": "Discord 服务器", + "menu.help.forum": "论坛", + "menu.help.about": "关于 SuperSplat", + "panel.mode.centers": "中心模式", + "panel.mode.rings": "环模式", + "panel.scene-manager": "场景管理器", + "panel.scene-manager.transform": "变换", + "panel.scene-manager.transform.position": "位置", + "panel.scene-manager.transform.rotation": "旋转", + "panel.scene-manager.transform.scale": "缩放", + "panel.colors": "颜色", + "panel.colors.tint": "色调", + "panel.colors.temperature": "温度", + "panel.colors.saturation": "饱和度", + "panel.colors.brightness": "亮度", + "panel.colors.black-point": "黑点", + "panel.colors.white-point": "白点", + "panel.colors.transparency": "透明度", + "panel.colors.reset": "重置", + "panel.settings": "设置", + "panel.settings.language": "语言", + "panel.settings.language.auto": "自动", + "panel.settings.colors": "颜色", + "panel.settings.background-color": "背景颜色", + "panel.settings.selected-color": "选中颜色", + "panel.settings.unselected-color": "未选中颜色", + "panel.settings.locked-color": "锁定颜色", + "panel.settings.fov": "视野角", + "panel.settings.fov-dolly": "FOV 自动推轨", + "panel.settings.control-mode": "控制模式", + "panel.settings.control-mode.orbit": "环绕", + "panel.settings.control-mode.fly": "飞行", + "panel.settings.sh-bands": "SH 带", + "panel.settings.centers-size": "中心大小", + "panel.settings.centers-gaussian-color": "颜色中心", + "panel.settings.outline-selection": "轮廓选择", + "panel.settings.show-grid": "显示网格", + "panel.settings.show-bound": "显示边界", + "panel.settings.show-bound-dimensions": "显示尺寸", + "panel.settings.show-camera-poses": "显示相机", + "panel.settings.show-camera-info": "显示相机信息", + "panel.settings.fly-speed": "相机飞行速度", + "panel.settings.tonemapping": "色调映射", + "panel.settings.tonemapping.none": "无", + "panel.settings.tonemapping.linear": "线性", + "panel.settings.tonemapping.neutral": "中性", + "panel.settings.tonemapping.aces": "ACES", + "panel.settings.tonemapping.aces2": "ACES2", + "panel.settings.tonemapping.filmic": "电影", + "panel.settings.tonemapping.hejl": "Hejl", + "panel.settings.reset": "恢复默认设置", + "panel.splat-data": "Splat 数据", + "panel.splat-data.distance": "距离", + "panel.splat-data.camera-depth": "相机深度", + "panel.splat-data.volume": "体积", + "panel.splat-data.surface-area": "表面积", + "panel.splat-data.scale-x": "缩放 X", + "panel.splat-data.scale-y": "缩放 Y", + "panel.splat-data.scale-z": "缩放 Z", + "panel.splat-data.red": "红", + "panel.splat-data.green": "绿", + "panel.splat-data.blue": "蓝", + "panel.splat-data.dc-red": "DC 红", + "panel.splat-data.dc-green": "DC 绿", + "panel.splat-data.dc-blue": "DC 蓝", + "panel.splat-data.opacity": "不透明度", + "panel.splat-data.hue": "色相", + "panel.splat-data.saturation": "饱和度", + "panel.splat-data.value": "明度", + "panel.splat-data.position": "Position", + "panel.splat-data.quat": "Quat", + "panel.splat-data.normal": "Normal", + "panel.splat-data.sh": "SH", + "panel.splat-data.log-scale": "对数缩放", + "panel.splat-data.show-all": "全部属性", + "panel.splat-data.on-screen-only": "仅可见的高斯点", + "panel.splat-data.totals": "总计", + "panel.splat-data.totals.splats": "Splat", + "panel.splat-data.totals.selected": "选择", + "panel.splat-data.totals.locked": "锁定", + "panel.splat-data.totals.deleted": "删除", + "panel.render.ok": "渲染", + "panel.render.cancel": "取消", + "panel.render.render-video": "渲染视频", + "panel.render.rendering": "渲染帧中", + "panel.render.failed": "渲染失败", + "select-toolbar.set": "设置", + "select-toolbar.add": "添加", + "select-toolbar.remove": "移除", + "select-toolbar.intersect": "相交", + "select-toolbar.position": "位置", + "select-toolbar.size": "大小", + "select-toolbar.radius": "半径", + "popup.ok": "确定", + "popup.cancel": "取消", + "popup.yes": "是", + "popup.no": "否", + "popup.error": "错误", + "popup.error-loading": "加载文件错误", + "popup.webgpu-unavailable": "此导出需要 WebGPU,但当前浏览器不支持。请尝试使用最新版本的 Chrome、Edge 或 Safari。", + "popup.lcc-upload-warning": "为了更好地发布到 superspl.at,请通过上传页面直接上传您的 LCC 文件。", + "popup.export": "导出", + "popup.copy-to-clipboard": "复制链接到剪贴板", + "popup.render-image.header": "图像设置", + "popup.render-image.preset": "预设", + "popup.render-image.resolution": "分辨率", + "popup.render-image.transparent-bg": "透明背景", + "popup.render-image.show-debug": "显示调试覆盖", + "popup.render-image.resolution-current": "当前", + "popup.render-image.resolution-custom": "自定义", + "popup.render-image.projection": "投影", + "popup.render-image.projection-standard": "标准", + "popup.render-image.projection-360": "360° 等距柱状", + "popup.render-image.level-horizon": "保持地平线水平", + "popup.render-video.header": "视频设置", + "popup.render-video.projection": "投影", + "popup.render-video.projection-standard": "标准", + "popup.render-video.projection-360": "360° 等距柱状", + "popup.render-video.resolution": "分辨率", + "popup.render-video.format": "格式", + "popup.render-video.codec": "编解码器", + "popup.render-video.frame-rate": "帧率", + "popup.render-video.frame-range": "帧范围", + "popup.render-video.frame-range-first": "起始", + "popup.render-video.frame-range-last": "结束", + "popup.render-video.bitrate": "比特率", + "popup.render-video.portrait": "纵向模式", + "popup.render-video.level-horizon": "保持地平线水平", + "popup.render-video.transparent-bg": "透明背景", + "popup.render-video.show-debug": "显示调试覆盖", + "popup.shortcuts.title": "键盘快捷键", + "popup.shortcuts.navigation": "导航", + "popup.shortcuts.tools": "工具", + "popup.shortcuts.move": "移动", + "popup.shortcuts.rotate": "旋转", + "popup.shortcuts.scale": "缩放", + "popup.shortcuts.rect-selection": "矩形选择", + "popup.shortcuts.lasso-selection": "套索选择", + "popup.shortcuts.polygon-selection": "多边形选择", + "popup.shortcuts.brush-selection": "画笔选择", + "popup.shortcuts.flood-selection": "填充选择", + "popup.shortcuts.eyedropper-selection": "吸管选择", + "popup.shortcuts.brush-size": "减小/增大画笔大小", + "popup.shortcuts.deactivate-tool": "停用工具", + "popup.shortcuts.selection": "选择", + "popup.shortcuts.select-all": "全选", + "popup.shortcuts.deselect-all": "取消全选", + "popup.shortcuts.invert-selection": "反选", + "popup.shortcuts.add-to-selection": "添加到选择", + "popup.shortcuts.remove-from-selection": "从选择中移除", + "popup.shortcuts.delete-selected-splats": "删除选择的 Splat", + "popup.shortcuts.show": "显示", + "popup.shortcuts.lock-selected-splats": "锁定选择的 Splat", + "popup.shortcuts.unlock-all-splats": "解锁全部 Splat", + "popup.shortcuts.toggle-data-panel": "切换数据面板", + "popup.shortcuts.toggle-timeline-panel": "切换时间轴面板", + "popup.shortcuts.other": "其他", + "popup.shortcuts.undo": "撤销", + "popup.shortcuts.redo": "重做", + "popup.shortcuts.toggle-splat-overlay": "切换 Splat 叠加", + "popup.shortcuts.focus-camera": "聚焦当前选择", + "popup.shortcuts.reset-camera": "重置相机", + "popup.shortcuts.toggle-camera-mode": "切换相机模式", + "popup.shortcuts.toggle-overlay-mode": "切换叠加模式", + "popup.shortcuts.toggle-control-mode": "切换飞行/环绕模式", + "popup.shortcuts.toggle-grid": "切换网格", + "popup.shortcuts.toggle-camera-info": "切换相机信息", + "popup.shortcuts.toggle-gizmo-coordinate-space": "切换 Gizmo 坐标空间", + "popup.shortcuts.camera": "相机(飞行模式)", + "popup.shortcuts.fly-movement": "前进/左移/后退/右移", + "popup.shortcuts.fly-vertical": "下降/上升", + "popup.shortcuts.fly-speed-fast": "快速移动", + "popup.shortcuts.fly-speed-slow": "慢速移动", + "popup.shortcuts.playback": "播放", + "popup.shortcuts.play-pause": "播放/暂停", + "popup.shortcuts.prev-frame": "上一帧", + "popup.shortcuts.next-frame": "下一帧", + "popup.shortcuts.prev-key": "上一个关键帧", + "popup.shortcuts.next-key": "下一个关键帧", + "popup.shortcuts.add-key": "添加关键帧", + "popup.shortcuts.remove-key": "删除关键帧", + "popup.export.header": "导出", + "popup.export.type": "导出类型", + "popup.export.html": "HTML", + "popup.export.package": "ZIP 包", + "popup.export.sh-bands": "SH 带", + "popup.export.start-position": "起始位置", + "popup.export.default": "默认", + "popup.export.viewport": "当前视口", + "popup.export.pose-camera": "第一个相机姿势", + "popup.export.fov": "视野角", + "popup.export.background-color": "背景颜色", + "popup.export.filename": "文件名", + "popup.export.animation": "动画", + "popup.export.animation.none": "无", + "popup.export.animation.track": "轨道", + "popup.export.loop-mode": "循环模式", + "popup.export.loop-mode.none": "无", + "popup.export.loop-mode.repeat": "重复", + "popup.export.loop-mode.pingpong": "乒乓", + "popup.export.compress-ply": "压缩 PLY", + "popup.export.splats-select": "Splat", + "popup.export.splats-select.all": "所有 Splat", + "popup.export.iterations": "Iterations", + "popup.export.spz-version": "版本", + "popup.export.spz-version.4": "SPZ 4(最新)", + "popup.export.spz-version.3": "SPZ 3(旧版 gzip)", + "popup.publish.header": "发布设置", + "popup.publish.ok": "发布", + "popup.publish.cancel": "取消", + "popup.publish.title": "标题", + "popup.publish.description": "描述", + "popup.publish.listed": "列出", + "popup.publish.failed": "发布失败", + "popup.publish.please-try-again": "请稍后再试。", + "popup.publish.succeeded": "发布成功", + "popup.publish.message": "请使用以下链接访问场景。", + "popup.publish.please-log-in": "要在 PlayCanvas 上发布,需要用户帐户。请登录并重试。", + "popup.publish.converting": "转换中", + "popup.publish.uploading": "上传中", + "popup.publish.to": "发布到", + "popup.publish.new-scene": "新场景", + "popup.publish.override-model": "覆盖模型", + "popup.publish.override-animation": "覆盖动画", + "popup.publish.generate-lods": "生成 LOD", + "camera-info.position": "相机位置", + "camera-info.target": "相机目标", + "doc.reset": "重置场景", + "doc.unsaved-message": "有未保存的更改。您确定要重置场景吗?", + "doc.reset-message": "您确定要重置场景吗?", + "doc.save-failed": "保存失败", + "doc.load-failed": "加载失败", + "tooltip.right-toolbar.splat-mode": "Splat 模式", + "tooltip.right-toolbar.show-hide": "显示/隐藏 Splats", + "tooltip.right-toolbar.orbit-camera": "轨道相机", + "tooltip.right-toolbar.fly-camera": "飞行相机", + "tooltip.right-toolbar.frame-selection": "框显所选", + "tooltip.right-toolbar.reset-camera": "重置相机", + "tooltip.right-toolbar.colors": "颜色", + "tooltip.right-toolbar.settings": "设置", + "tooltip.bottom-toolbar.undo": "撤销", + "tooltip.bottom-toolbar.redo": "重做", + "tooltip.bottom-toolbar.rect": "矩形选择", + "tooltip.bottom-toolbar.lasso": "套索选择", + "tooltip.bottom-toolbar.polygon": "多边形选择", + "tooltip.bottom-toolbar.brush": "画笔", + "tooltip.bottom-toolbar.flood": "填充选择", + "tooltip.bottom-toolbar.eyedropper": "吸管选择", + "tooltip.bottom-toolbar.sphere": "球选择", + "tooltip.bottom-toolbar.box": "盒选择", + "tooltip.bottom-toolbar.translate": "移动", + "tooltip.bottom-toolbar.rotate": "旋转", + "tooltip.bottom-toolbar.scale": "缩放", + "tooltip.bottom-toolbar.measure": "测量", + "measure.length": "长度", + "tooltip.bottom-toolbar.local-space": "局部坐标系", + "tooltip.bottom-toolbar.bound-center": "使用边界中心", + "tooltip.timeline.prev-frame": "上一帧", + "tooltip.timeline.next-frame": "下一帧", + "tooltip.timeline.play": "播放/停止", + "tooltip.timeline.add-key": "添加关键帧", + "tooltip.timeline.remove-key": "删除关键帧", + "tooltip.timeline.key": "拖动以移动。Shift+拖动复制。Ctrl+点击设为当前视图。", + "tooltip.timeline.frame-rate": "帧率", + "tooltip.timeline.total-frames": "总帧数", + "tooltip.timeline.smoothness": "平滑度", + "tooltip.timeline.loop": "循环动画", + "tooltip.scene.solo": "独显所选", + "tooltip.scene.import": "导入场景", + "tooltip.scene.new": "新建场景", + "status-bar.splats": "高斯点", + "status-bar.selected": "已选择", + "status-bar.locked": "已锁定", + "status-bar.deleted": "已删除", + "status-bar.timeline": "时间线", + "status-bar.splat-data": "高斯点数据", + "tooltip.status-bar.timeline": "切换时间线面板", + "tooltip.status-bar.splat-data": "切换高斯点数据面板", + "tooltip.splat-data.on-screen-only": "仅统计相机视野内可见的高斯点。屏幕外的高斯点将从直方图中排除。", + "tooltip.splat-data.log-scale": "对纵轴使用对数刻度,让小数值与大数值同时清晰可见。", + "tooltip.splat-data.show-all": "在列表中显示所有高斯点属性,包括原始 DC 系数和球谐函数系数。" +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aed0e22 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2020", + "moduleResolution": "bundler", + + "lib": ["es2022", "dom", "WebWorker"], + "sourceMap": true, + "noImplicitAny": true, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "inlineSources": true, + "resolveJsonModule": true, + }, + "include": [ + "./src/**/*.ts", + "global.d.ts" + ], + "exclude": [ + "src/debug.ts", // Testing file + "dist", + "node_modules" + ] +}